user testing and fixing minor issues
This commit is contained in:
@@ -85,10 +85,19 @@ export async function familyStats(pb: any, famId: string): Promise<FamilyStats>
|
|||||||
if (date) addWeek(c.memberId, weekStartFor(date, payday, tz), pts, cash, 1);
|
if (date) addWeek(c.memberId, weekStartFor(date, payday, tz), pts, cash, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Money-type todos auto-create a matching cash reward when the todo is
|
||||||
|
// completed (see completions.toggle). That cash is already counted in the
|
||||||
|
// completion loop above, so skip those completion-linked rewards here to
|
||||||
|
// avoid double-counting the same earning event.
|
||||||
|
const autoRewardIds = new Set(
|
||||||
|
completions.map((c: any) => c.rewardId).filter((id?: string) => !!id)
|
||||||
|
);
|
||||||
|
|
||||||
// Claimed rewards (bonuses/triggers/pocket money) count toward lifetime
|
// Claimed rewards (bonuses/triggers/pocket money) count toward lifetime
|
||||||
// earnings once issued. Unclaimed/requested stay "to chase" client-side.
|
// earnings once issued. Unclaimed/requested stay "to chase" client-side.
|
||||||
for (const r of rewards) {
|
for (const r of rewards) {
|
||||||
if (r.status !== 'claimed') continue;
|
if (r.status !== 'claimed') continue;
|
||||||
|
if (autoRewardIds.has(r.id)) continue;
|
||||||
const date = ((r.claimedAt as string) || (r.date as string) || '').slice(0, 10);
|
const date = ((r.claimedAt as string) || (r.date as string) || '').slice(0, 10);
|
||||||
if (r.rewardType === 'points') {
|
if (r.rewardType === 'points') {
|
||||||
const v = Number(r.value) || 0;
|
const v = Number(r.value) || 0;
|
||||||
|
|||||||
@@ -382,7 +382,16 @@
|
|||||||
rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart)
|
rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart)
|
||||||
);
|
);
|
||||||
|
|
||||||
let allTimeCash = $derived.by(() =>
|
// Money-type todos auto-create a matching cash reward when completed (see
|
||||||
|
// completions.toggle). Those rewards are already counted via the chore
|
||||||
|
// completions below, so skip any claimed reward that a completion linked to
|
||||||
|
// (via rewardId) to avoid double-counting the same earning event.
|
||||||
|
let autoRewardIds = $derived(
|
||||||
|
new Set(completions.map((c) => (c as any).rewardId).filter((id) => !!id))
|
||||||
|
);
|
||||||
|
|
||||||
|
let allTimeCash = $derived.by(
|
||||||
|
() =>
|
||||||
completions
|
completions
|
||||||
.filter((c) => c.memberId === memberId)
|
.filter((c) => c.memberId === memberId)
|
||||||
.reduce((sum, c) => {
|
.reduce((sum, c) => {
|
||||||
@@ -390,11 +399,18 @@
|
|||||||
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
|
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
|
||||||
}, 0) +
|
}, 0) +
|
||||||
rewards
|
rewards
|
||||||
.filter((r) => r.memberId === memberId && r.rewardType === 'cash' && r.status === 'claimed')
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
r.memberId === memberId &&
|
||||||
|
r.rewardType === 'cash' &&
|
||||||
|
r.status === 'claimed' &&
|
||||||
|
!autoRewardIds.has(r.id)
|
||||||
|
)
|
||||||
.reduce((sum, r) => sum + Number(r.value), 0)
|
.reduce((sum, r) => sum + Number(r.value), 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
let allTimePoints = $derived.by(() =>
|
let allTimePoints = $derived.by(
|
||||||
|
() =>
|
||||||
completions
|
completions
|
||||||
.filter((c) => c.memberId === memberId)
|
.filter((c) => c.memberId === memberId)
|
||||||
.reduce((sum, c) => {
|
.reduce((sum, c) => {
|
||||||
@@ -402,7 +418,13 @@
|
|||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||||||
}, 0) +
|
}, 0) +
|
||||||
rewards
|
rewards
|
||||||
.filter((r) => r.memberId === memberId && r.rewardType === 'points' && r.status === 'claimed')
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
r.memberId === memberId &&
|
||||||
|
r.rewardType === 'points' &&
|
||||||
|
r.status === 'claimed' &&
|
||||||
|
!autoRewardIds.has(r.id)
|
||||||
|
)
|
||||||
.reduce((sum, r) => sum + Number(r.value), 0)
|
.reduce((sum, r) => sum + Number(r.value), 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1386,17 +1408,14 @@
|
|||||||
<div class="wallet">
|
<div class="wallet">
|
||||||
<div class="wallet-top">
|
<div class="wallet-top">
|
||||||
<span class="wallet-lbl">{@html walletIcon} Wallet</span>
|
<span class="wallet-lbl">{@html walletIcon} Wallet</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Lifetime earnings (everything earned + claimed), one-shot totals -->
|
<!-- Lifetime earnings (everything earned + claimed), one-shot totals -->
|
||||||
<div class="lifetime">
|
<div class="life-badges">
|
||||||
<div class="life-cell life-cash">
|
<span class="life-badge life-cash"
|
||||||
<span class="life-num">£{allTimeCash.toFixed(2)}</span>
|
>£{allTimeCash.toFixed(2)} <i>all time</i></span
|
||||||
<span class="life-lbl">earned all time</span>
|
>
|
||||||
</div>
|
<span class="life-badge life-pts"
|
||||||
<div class="life-cell life-pts">
|
>{allTimePoints.toLocaleString()} <i>pts</i></span
|
||||||
<span class="life-num">{allTimePoints.toLocaleString()}</span>
|
>
|
||||||
<span class="life-lbl">points accrued</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2431,18 +2450,31 @@
|
|||||||
vertical-align: -0.15em;
|
vertical-align: -0.15em;
|
||||||
margin-right: 0.3em;
|
margin-right: 0.3em;
|
||||||
}
|
}
|
||||||
.lifetime {
|
.life-badges {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin: 0.75rem 0 1rem;
|
|
||||||
}
|
|
||||||
.life-cell {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 0.1rem;
|
gap: 0.4rem;
|
||||||
padding: 0.85rem 1rem;
|
flex-wrap: wrap;
|
||||||
border-radius: 12px;
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.life-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 3px 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.life-badge i {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.life-cash {
|
.life-cash {
|
||||||
background: linear-gradient(135deg, #059669, #10b981);
|
background: linear-gradient(135deg, #059669, #10b981);
|
||||||
@@ -2450,20 +2482,6 @@
|
|||||||
.life-pts {
|
.life-pts {
|
||||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||||
}
|
}
|
||||||
.life-num {
|
|
||||||
font-size: 1.7rem;
|
|
||||||
font-weight: 900;
|
|
||||||
color: #fff;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
.life-lbl {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
}
|
|
||||||
.wallet-sub {
|
.wallet-sub {
|
||||||
border-top: 1px solid #eef0f2;
|
border-top: 1px solid #eef0f2;
|
||||||
padding-top: 0.6rem;
|
padding-top: 0.6rem;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
||||||
import { choresIcon, todoIcon, checkCircleIcon } from '$lib/components/icons';
|
import { choresIcon, todoIcon, checkCircleIcon } from '$lib/components/icons';
|
||||||
import { formatHumanDate } from '$lib/format';
|
import { formatHumanDate } from '$lib/format';
|
||||||
|
import { nextPaydayAfter, todayInTz, resolveTz } from '@shared/timezone';
|
||||||
import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons';
|
import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons';
|
||||||
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
|
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
|
||||||
|
|
||||||
@@ -228,13 +229,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort todos by completeBy (soonest first), then by startDate
|
// Sort todos by completeBy (soonest first), then by startDate
|
||||||
function sortedTodosForMember(memberId: string): AssignedChore[] {
|
function todoSort(a: AssignedChore, b: AssignedChore): number {
|
||||||
return todosForMember(memberId).sort((a, b) => {
|
|
||||||
const da = a.completeBy || '9999-99-99';
|
const da = a.completeBy || '9999-99-99';
|
||||||
const db = b.completeBy || '9999-99-99';
|
const db = b.completeBy || '9999-99-99';
|
||||||
if (da !== db) return da.localeCompare(db);
|
if (da !== db) return da.localeCompare(db);
|
||||||
return (a.startDate || '').localeCompare(b.startDate || '');
|
return (a.startDate || '').localeCompare(b.startDate || '');
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// Family payday anchor + resolved tz for week-boundary maths.
|
||||||
|
const famPayday = $derived(
|
||||||
|
(famStore.fam as any as { payday?: number })?.payday != null
|
||||||
|
? Number((famStore.fam as any).payday)
|
||||||
|
: 1
|
||||||
|
);
|
||||||
|
const famTz = $derived(resolveTz((famStore.fam as any)?.timezone || 'auto'));
|
||||||
|
|
||||||
|
// A completed todo stays visible (green "TBC completed") for the rest of the
|
||||||
|
// week it was completed; once the next payday rolls around (a new week
|
||||||
|
// starts) it drops off the admin Todos list.
|
||||||
|
function comprisesTodo(todo: AssignedChore): boolean {
|
||||||
|
if (!isTodoCompleted(todo.id)) return false;
|
||||||
|
const completion = completions.find((c: Completion) => c.assignedChoreId === todo.id);
|
||||||
|
const done = (completion?.date || '').slice(0, 10);
|
||||||
|
if (!done) return true;
|
||||||
|
const nextPayday = nextPaydayAfter(done, famPayday, famTz);
|
||||||
|
return todayInTz(famTz) >= nextPayday;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All todos shown for a member — but once the week they were completed in is
|
||||||
|
// over (payday has passed) the completed todo is hidden from the list.
|
||||||
|
function visibleTodosForMember(memberId: string): AssignedChore[] {
|
||||||
|
return todosForMember(memberId)
|
||||||
|
.filter((a) => !comprisesTodo(a))
|
||||||
|
.sort(todoSort);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7 days from today as YYYY-MM-DD for the todo modal default
|
// 7 days from today as YYYY-MM-DD for the todo modal default
|
||||||
@@ -400,11 +427,11 @@
|
|||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<span class="accordion-count" style="background:{m.color}"
|
<span class="accordion-count" style="background:{m.color}"
|
||||||
>{todosForMember(m.id).length}</span
|
>{visibleTodosForMember(m.id).length}</span
|
||||||
>
|
>
|
||||||
{#if accordionState[m.id]?.todos ?? true}
|
{#if accordionState[m.id]?.todos ?? true}
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
{#each sortedTodosForMember(m.id) as a}
|
{#each visibleTodosForMember(m.id) as a}
|
||||||
{@const completed = isTodoCompleted(a.id)}
|
{@const completed = isTodoCompleted(a.id)}
|
||||||
{@const urgency = todoUrgency(a)}
|
{@const urgency = todoUrgency(a)}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -78,10 +78,6 @@
|
|||||||
};
|
};
|
||||||
showTplModal = true;
|
showTplModal = true;
|
||||||
}
|
}
|
||||||
const allTemplates = $derived([
|
|
||||||
...(data.choreTemplates || []).map((t: any) => ({ ...t, kind: 'chore' as TplKind })),
|
|
||||||
...(data.bonusTemplates || []).map((t: any) => ({ ...t, kind: 'reward' as TplKind }))
|
|
||||||
]);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
@@ -321,34 +317,30 @@
|
|||||||
</CardGrid>
|
</CardGrid>
|
||||||
|
|
||||||
<CardGrid>
|
<CardGrid>
|
||||||
<Card cols={3} title="Templates" accent="#8b5cf6">
|
<Card cols={2} title="Chore Templates" accent="#2563eb">
|
||||||
<p class="muted">
|
|
||||||
Platform-owned chore & reward droplets. Families assign these as read-only sources — they
|
|
||||||
can't create their own.
|
|
||||||
</p>
|
|
||||||
<div class="tpl-grid">
|
<div class="tpl-grid">
|
||||||
{#each allTemplates as t}
|
{#each (data.choreTemplates || []) as t}
|
||||||
<div class="tpl-card" style="border-left-color:{t.color}">
|
<div class="tpl-card" style="border-left-color:{t.color}">
|
||||||
<div class="tpl-head">
|
<div class="tpl-head">
|
||||||
<TemplateIcon name={t.icon} size={20} color={t.color} />
|
<TemplateIcon name={t.icon} size={20} color={t.color} />
|
||||||
<strong>{t.name}</strong>
|
<strong>{t.name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="tpl-meta">
|
<div class="tpl-meta">
|
||||||
<span class="badge">{t.kind}</span>
|
<span class="badge chore">{t.defaultFrequency}</span>
|
||||||
{#if t.isPocketMoney}<span class="badge pocket">Pocket Money</span>{/if}
|
<span class="badge">{t.defaultType}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tpl-actions">
|
<div class="tpl-actions">
|
||||||
<button class="link-btn" onclick={() => openEditTpl(t.kind, t)}>Edit</button>
|
<button class="link-btn" onclick={() => openEditTpl('chore', t)}>Edit</button>
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/deleteTemplate"
|
action="?/deleteTemplate"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return async ({ result, update }) => {
|
return async ({ update }) => {
|
||||||
update();
|
update();
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="kind" value={t.kind} />
|
<input type="hidden" name="kind" value="chore" />
|
||||||
<input type="hidden" name="id" value={t.id} />
|
<input type="hidden" name="id" value={t.id} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -361,12 +353,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if allTemplates.length === 0}
|
{#if (data.choreTemplates || []).length === 0}
|
||||||
<p class="muted">No templates yet.</p>
|
<p class="muted">No chore templates yet.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="tpl-new">
|
<div class="tpl-new">
|
||||||
<Button onclick={() => openNewTpl('chore')}>+ Chore template</Button>
|
<Button onclick={() => openNewTpl('chore')}>+ Chore template</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card cols={1} title="Reward Templates" accent="#8b5cf6">
|
||||||
|
<div class="tpl-grid">
|
||||||
|
{#each (data.bonusTemplates || []) as t}
|
||||||
|
<div class="tpl-card" style="border-left-color:{t.color}">
|
||||||
|
<div class="tpl-head">
|
||||||
|
<TemplateIcon name={t.icon} size={20} color={t.color} />
|
||||||
|
<strong>{t.name}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="tpl-meta">
|
||||||
|
<span class="badge reward">{t.type}</span>
|
||||||
|
<span class="badge">{t.rewardType}</span>
|
||||||
|
{#if t.isPocketMoney}<span class="badge pocket">Pocket Money</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="tpl-actions">
|
||||||
|
<button class="link-btn" onclick={() => openEditTpl('reward', t)}>Edit</button>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteTemplate"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="kind" value="reward" />
|
||||||
|
<input type="hidden" name="id" value={t.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="link-btn danger"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (!confirm('Delete this template?')) e.preventDefault();
|
||||||
|
}}>Delete</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if (data.bonusTemplates || []).length === 0}
|
||||||
|
<p class="muted">No reward templates yet.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="tpl-new">
|
||||||
<Button onclick={() => openNewTpl('reward')}>+ Reward template</Button>
|
<Button onclick={() => openNewTpl('reward')}>+ Reward template</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -979,6 +1016,8 @@
|
|||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
.badge.chore { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge.reward { background: #ede9fe; color: #6d28d9; }
|
||||||
.badge.pocket { background: #dcfce7; color: #166534; }
|
.badge.pocket { background: #dcfce7; color: #166534; }
|
||||||
.check { display: flex; align-items: center; gap: 0.4rem; }
|
.check { display: flex; align-items: center; gap: 0.4rem; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user