diff --git a/frontend/src/lib/server/services/stats.ts b/frontend/src/lib/server/services/stats.ts index 12d635c..28d5ed1 100644 --- a/frontend/src/lib/server/services/stats.ts +++ b/frontend/src/lib/server/services/stats.ts @@ -85,10 +85,19 @@ export async function familyStats(pb: any, famId: string): Promise 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 // earnings once issued. Unclaimed/requested stay "to chase" client-side. for (const r of rewards) { if (r.status !== 'claimed') continue; + if (autoRewardIds.has(r.id)) continue; const date = ((r.claimedAt as string) || (r.date as string) || '').slice(0, 10); if (r.rewardType === 'points') { const v = Number(r.value) || 0; diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 282e304..63f4945 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -382,28 +382,50 @@ rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart) ); - let allTimeCash = $derived.by(() => - completions - .filter((c) => c.memberId === memberId) - .reduce((sum, c) => { - const chore = assigned.find((a) => a.id === c.assignedChoreId); - return sum + (chore?.type === 'money' ? Number(chore.value) : 0); - }, 0) + - rewards - .filter((r) => r.memberId === memberId && r.rewardType === 'cash' && r.status === 'claimed') - .reduce((sum, r) => sum + Number(r.value), 0) + // 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 allTimePoints = $derived.by(() => - completions - .filter((c) => c.memberId === memberId) - .reduce((sum, c) => { - const chore = assigned.find((a) => a.id === c.assignedChoreId); - return sum + (chore?.type === 'points' ? Number(chore.value) : 0); - }, 0) + - rewards - .filter((r) => r.memberId === memberId && r.rewardType === 'points' && r.status === 'claimed') - .reduce((sum, r) => sum + Number(r.value), 0) + let allTimeCash = $derived.by( + () => + completions + .filter((c) => c.memberId === memberId) + .reduce((sum, c) => { + const chore = assigned.find((a) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'money' ? Number(chore.value) : 0); + }, 0) + + rewards + .filter( + (r) => + r.memberId === memberId && + r.rewardType === 'cash' && + r.status === 'claimed' && + !autoRewardIds.has(r.id) + ) + .reduce((sum, r) => sum + Number(r.value), 0) + ); + + let allTimePoints = $derived.by( + () => + completions + .filter((c) => c.memberId === memberId) + .reduce((sum, c) => { + const chore = assigned.find((a) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0) + + rewards + .filter( + (r) => + r.memberId === memberId && + r.rewardType === 'points' && + r.status === 'claimed' && + !autoRewardIds.has(r.id) + ) + .reduce((sum, r) => sum + Number(r.value), 0) ); // Week-to-date totals from chore completions (not rewards — rewards are created at EOW) @@ -1386,17 +1408,14 @@
{@html walletIcon} Wallet -
- - -
-
- £{allTimeCash.toFixed(2)} - earned all time -
-
- {allTimePoints.toLocaleString()} - points accrued + +
+ £{allTimeCash.toFixed(2)} all time + {allTimePoints.toLocaleString()} pts
@@ -2431,18 +2450,31 @@ vertical-align: -0.15em; margin-right: 0.3em; } - .lifetime { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.75rem; - margin: 0.75rem 0 1rem; - } - .life-cell { + .life-badges { display: flex; - flex-direction: column; - gap: 0.1rem; - padding: 0.85rem 1rem; - border-radius: 12px; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + 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 { background: linear-gradient(135deg, #059669, #10b981); @@ -2450,20 +2482,6 @@ .life-pts { 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 { border-top: 1px solid #eef0f2; padding-top: 0.6rem; diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.svelte b/frontend/src/routes/[fam]/[username]/chores/+page.svelte index f65b108..9f6ad2e 100644 --- a/frontend/src/routes/[fam]/[username]/chores/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/chores/+page.svelte @@ -6,6 +6,7 @@ import TemplateIcon from '$lib/components/TemplateIcon.svelte'; import { choresIcon, todoIcon, checkCircleIcon } from '$lib/components/icons'; import { formatHumanDate } from '$lib/format'; + import { nextPaydayAfter, todayInTz, resolveTz } from '@shared/timezone'; import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons'; import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types'; @@ -228,13 +229,39 @@ } // Sort todos by completeBy (soonest first), then by startDate - function sortedTodosForMember(memberId: string): AssignedChore[] { - return todosForMember(memberId).sort((a, b) => { - const da = a.completeBy || '9999-99-99'; - const db = b.completeBy || '9999-99-99'; - if (da !== db) return da.localeCompare(db); - return (a.startDate || '').localeCompare(b.startDate || ''); - }); + function todoSort(a: AssignedChore, b: AssignedChore): number { + const da = a.completeBy || '9999-99-99'; + const db = b.completeBy || '9999-99-99'; + if (da !== db) return da.localeCompare(db); + 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 @@ -400,11 +427,11 @@ > {todosForMember(m.id).length}{visibleTodosForMember(m.id).length} {#if accordionState[m.id]?.todos ?? true}
- {#each sortedTodosForMember(m.id) as a} + {#each visibleTodosForMember(m.id) as a} {@const completed = isTodoCompleted(a.id)} {@const urgency = todoUrgency(a)}
- +
{/each} - {#if allTemplates.length === 0} -

No templates yet.

+ {#if (data.choreTemplates || []).length === 0} +

No chore templates yet.

{/if}
+
+ + + +
+ {#each (data.bonusTemplates || []) as t} +
+
+ + {t.name} +
+
+ {t.type} + {t.rewardType} + {#if t.isPocketMoney}Pocket Money{/if} +
+
+ +
{ + return async ({ update }) => { + update(); + }; + }} + > + + + +
+
+
+ {/each} + {#if (data.bonusTemplates || []).length === 0} +

No reward templates yet.

+ {/if} +
+
@@ -979,6 +1016,8 @@ border: 1px solid #e5e7eb; border-radius: 8px; } + .badge.chore { background: #dbeafe; color: #1e40af; } + .badge.reward { background: #ede9fe; color: #6d28d9; } .badge.pocket { background: #dcfce7; color: #166534; } .check { display: flex; align-items: center; gap: 0.4rem; }