diff --git a/MEMORY.md b/MEMORY.md index 766dee1..5e41a49 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -321,6 +321,15 @@ - **Settings accordion order:** Family (name/payday/**seasons**, opens by default via ``) → Invites → **Account** (Access + Subscription) → App last. - **`clearLegacyCookies(cookies)`** added to `$lib/server/session.ts`; auth/join/logout use it instead of inline `device_token` deletes. +### 2026-08-31 — All-time earnings + weekly trend chart (family root), lifetime wallet (member) + +- **New server stats service** `frontend/src/lib/server/services/stats.ts` (`familyStats(pb, famId)`, exposed as `s.stats.family(famId)`): scans full-history `completions` + `rewards` once, filtered/summed **on the server**, returns fixed-size `{ allTimePts, allTimeCash, allTimeChores, series }` (per-member weekly buckets `{weekStart, points, cash, chores}`). Server-side aggregation keeps the client payload constant-size regardless of history depth — this is the chosen scaling win for all-time totals + the retrospective graph. +- **Totals definition (matches the app's own accounting, no double count):** lifetime points = points-type completions' chore values + **claimed** points rewards; lifetime cash = money-type completions' chore values + **claimed** cash rewards; chores = count of all completions. Unclaimed/requested rewards stay out of totals (they're the "to chase" list). +- **Reading `weekly_history` NOT needed** — totals derive from live `completions`+`rewards` (both have `listRule: ""` in SCHEMA_PLAN = open to any authenticated session, which is why parent/child token reads already work today). +- **Member wallet** (`[fam]/[username]/+page.svelte`): the existing `allTimeCash`/`allTimePoints` deriveds now include chore completion earnings (previously claimed-rewards only). Wallet UI restyled: big lifetime cells (cash "earned all time" + points "accrued") on top, then a "To collect from parent" divider + the existing pendingRewards list (unclaimed/requested/payday-locked) so members still see what to chase. +- **Family root** (`[fam]/+page.svelte` + `+page.server.ts`): load now fetches `familyStats` (+ drops the removed templates). ViewHeader gained an optional default slot (`children?: Snippet`); the fam hero renders large all-time points | cash inside the header. Chores card shows "this week | total all-time" with `justify-content: space-between`. Templates card removed. Chart.js line graph "Over time" plots per-member lines with a Points/Cash/Chores tab; window starts at the current month and expands a month at a time up to 3 months based on data span (≤31d→1, ≤62d→2, else 3). +- **Typecheck/build:** still 6 pre-existing canary errors only (none in edited files); `pnpm build` (adapter-node) clean. New code is picked up by HMR; no schema/migration change so no restart required. + ### 2026-08-31 — Bonus progress window: `completeBy` + `startDate` implemented - **Problem**: a standalone cash bonus (core reward with a points threshold) displayed progress differently on the child dashboard (current week → "0/500") vs the admin dashboard (cumulative since records began). Both were "correct" because `bonus_configs` had no way to scope tracking — with no `period` set, progress()/evaluateFam totalled *all* completions, and the child dashboard forced `cfg.period || 'weekly'`. diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 61e2ae4..c06d467 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -147,4 +147,25 @@ .nav-label { overflow: hidden; } + + @media (max-width: 1023px) { + .sidebar { + width: 56px; + } + .sidebar-header { + justify-content: center; + padding: 1rem 0; + } + .app-name { + display: none; + } + .nav-item { + justify-content: center; + padding: 0.6rem 0; + margin: 0; + } + .nav-label { + display: none; + } + } diff --git a/frontend/src/lib/components/TopNav.svelte b/frontend/src/lib/components/TopNav.svelte index 7404142..42c20b4 100644 --- a/frontend/src/lib/components/TopNav.svelte +++ b/frontend/src/lib/components/TopNav.svelte @@ -49,6 +49,11 @@ transition: left 0.2s; gap: 0.75rem; } + @media (max-width: 1023px) { + .topnav { + left: 56px; + } + } .topnav-left { display: flex; align-items: center; gap: 0.5rem; } .topnav-announcement { flex: 1; text-align: center; } .announcement-text { font-size: 0.85rem; color: #6b7280; } diff --git a/frontend/src/lib/components/ViewHeader.svelte b/frontend/src/lib/components/ViewHeader.svelte index c037292..1c83dff 100644 --- a/frontend/src/lib/components/ViewHeader.svelte +++ b/frontend/src/lib/components/ViewHeader.svelte @@ -1,20 +1,40 @@ -
+
-

{title}

+

{title}

{#if subtitle}

{subtitle}

{/if}
+ {@render children?.()} +
{#if tabs}
@@ -22,8 +42,8 @@ + onclick={() => tabs.onchange(tab.value)}>{tab.label} {/each}
{/if} @@ -37,7 +57,11 @@ {/if} {#if sort} - sort.onchange(e.currentTarget.value)} + > {#each sort.options as opt} {/each} @@ -47,27 +71,80 @@
diff --git a/frontend/src/lib/server/services/index.ts b/frontend/src/lib/server/services/index.ts index 1afd0fb..602039d 100644 --- a/frontend/src/lib/server/services/index.ts +++ b/frontend/src/lib/server/services/index.ts @@ -7,6 +7,7 @@ import * as chatSvc from './chat'; import * as settingsSvc from './settings'; import * as crudSvc from './crud'; import * as debugSvc from './debug'; +import * as statsSvc from './stats'; export * from './chat'; export { famMeta } from './fam'; @@ -93,6 +94,9 @@ export function createServices( }, debug: { generateData: (famId: string, days?: number) => debugSvc.generateData(pb, famId, days) + }, + stats: { + family: (famId: string) => statsSvc.familyStats(pb, famId) } }; } diff --git a/frontend/src/lib/server/services/stats.ts b/frontend/src/lib/server/services/stats.ts new file mode 100644 index 0000000..12d635c --- /dev/null +++ b/frontend/src/lib/server/services/stats.ts @@ -0,0 +1,120 @@ +import { addDaysStr, weekdayInTz, resolveTz } from '@shared/timezone'; +import { famMeta } from './fam'; + +// Week start (payday-anchored) for an arbitrary YYYY-MM-DD date — a +// parametric form of `weekStart()` so we can bucket historical weeks, not +// just the current one. +function weekStartFor(dateStr: string, payday: number, tz: string): string { + const wd = weekdayInTz(new Date(dateStr + 'T12:00:00Z'), tz); + const back = (((wd - payday) % 7) + 7) % 7; + return addDaysStr(dateStr, -back); +} + +export type WeeklyPoint = { + weekStart: string; + points: number; + cash: number; + chores: number; +}; + +export type MemberSeries = { + memberId: string; + memberName: string; + memberColor: string; + weeks: WeeklyPoint[]; +}; + +export type FamilyStats = { + allTimePts: number; + allTimeCash: number; + allTimeChores: number; + series: MemberSeries[]; +}; + +// Server-side lifetime + weekly aggregates. Scans full-history `completions` +// and `rewards` once here (filtered and summed on the server) so the client +// receives fixed-size pre-computed numbers instead of every historical row — +// this is what keeps the all-time totals + retrospective graph scalable. +export async function familyStats(pb: any, famId: string): Promise { + const { payday, tz } = await famMeta(pb, famId); + + const [members, assigned, completions, rewards] = await Promise.all([ + pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }), + pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }), + pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }), + pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) + ]); + + const assignedMap = new Map(assigned.map((a: any) => [a.id, a])); + + const memberPts: Record = {}; + const memberCash: Record = {}; + const memberChores: Record = {}; + for (const m of members) { + memberPts[m.id] = 0; + memberCash[m.id] = 0; + memberChores[m.id] = 0; + } + + // Per-member weekly buckets (points/cash/chores), keyed by `memberId:weekStart`. + const weekMap = new Map(); + + function addWeek(memberId: string, weekStart: string, pts: number, cash: number, chores: number) { + const key = `${memberId}:${weekStart}`; + const w = weekMap.get(key); + if (w) { + w.points += pts; + w.cash += cash; + w.chores += chores; + } else { + weekMap.set(key, { weekStart, points: pts, cash, chores }); + } + } + + // Chore completions contribute their chore value (points/money) to the + // member's lifetime totals and their weekly bucket. Every completion counts + // as a chore done (regardless of type). + for (const c of completions) { + const chore = assignedMap.get(c.assignedChoreId); + const pts = chore?.type === 'points' ? Number(chore.value) || 0 : 0; + const cash = chore?.type === 'money' ? Number(chore.value) || 0 : 0; + const date = (c.date || '').slice(0, 10); + memberPts[c.memberId] = (memberPts[c.memberId] || 0) + pts; + memberCash[c.memberId] = (memberCash[c.memberId] || 0) + cash; + memberChores[c.memberId] = (memberChores[c.memberId] || 0) + 1; + if (date) addWeek(c.memberId, weekStartFor(date, payday, tz), pts, cash, 1); + } + + // 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; + const date = ((r.claimedAt as string) || (r.date as string) || '').slice(0, 10); + if (r.rewardType === 'points') { + const v = Number(r.value) || 0; + memberPts[r.memberId] = (memberPts[r.memberId] || 0) + v; + if (date) addWeek(r.memberId, weekStartFor(date, payday, tz), v, 0, 0); + } else if (r.rewardType === 'cash') { + const v = Number(r.value) || 0; + memberCash[r.memberId] = (memberCash[r.memberId] || 0) + v; + if (date) addWeek(r.memberId, weekStartFor(date, payday, tz), 0, v, 0); + } + } + + const series: MemberSeries[] = members.map((m: any) => ({ + memberId: m.id, + memberName: m.name, + memberColor: m.color || '#6366f1', + weeks: [...weekMap.entries()] + .filter(([key]) => key.startsWith(m.id + ':')) + .map(([, w]) => w) + .sort((a, b) => a.weekStart.localeCompare(b.weekStart)) + })); + + return { + allTimePts: Object.values(memberPts).reduce((s, n) => s + n, 0), + allTimeCash: Object.values(memberCash).reduce((s, n) => s + n, 0), + allTimeChores: Object.values(memberChores).reduce((s, n) => s + n, 0), + series + }; +} diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index 47865cf..802a132 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -265,6 +265,11 @@ flex: 1; transition: margin-left 0.2s; } + @media (max-width: 1023px) { + .app-main { + margin-left: 56px; + } + } .page-wrap { position: relative; min-height: 70vh; diff --git a/frontend/src/routes/[fam]/+page.server.ts b/frontend/src/routes/[fam]/+page.server.ts index de62e50..045a0af 100644 --- a/frontend/src/routes/[fam]/+page.server.ts +++ b/frontend/src/routes/[fam]/+page.server.ts @@ -9,13 +9,14 @@ export async function load(event) { const s = createServices(pbUser(event), session); try { - const [members, templates, assigned, summary] = await Promise.all([ + const [members, assigned, summary, fam, stats] = await Promise.all([ s.crud.list('members', famId), - s.crud.list('chore-templates', famId), s.crud.list('assigned-chores', famId), - s.fam.weeklySummary(famId) + s.fam.weeklySummary(famId), + s.fam.get(famId), + s.stats.family(famId) ]); - return { members, templates, assigned, summary }; + return { members, assigned, summary, fam, stats }; } catch { return {}; } diff --git a/frontend/src/routes/[fam]/+page.svelte b/frontend/src/routes/[fam]/+page.svelte index bd83196..58c0188 100644 --- a/frontend/src/routes/[fam]/+page.svelte +++ b/frontend/src/routes/[fam]/+page.svelte @@ -5,28 +5,39 @@ import { ViewHeader, CardGrid, Card } from '$lib/components'; import { formatDDMMYY } from '$lib/format'; import { handleOf } from '@shared/slugify'; + import { addDaysStr, resolveTz, todayInTz } from '@shared/timezone'; Chart.register(...registerables); let { data } = $props(); let famSlug = $derived(page.data.famSlug ?? page.params.fam); + let famTz = $derived(resolveTz(data.fam?.timezone || (page.data.fam as any)?.timezone || 'auto')); + let summary = $state(data.summary); let members = $state(data.members || []); - let templates = $state(data.templates || []); let assigned = $state(data.assigned || []); + let stats = $state(data.stats || { allTimePts: 0, allTimeCash: 0, allTimeChores: 0, series: [] }); let donutCanvases = $state>({}); let donutCharts: Record = {}; let choresCanvas: HTMLCanvasElement | undefined; let choresChart: Chart | undefined; + let trendCanvas = $state(); + let trendChart: Chart | undefined; + // Line-graph metric view: cash | points | chores + let metric = $state<'cash' | 'points' | 'chores'>('points'); function captureCanvas(node: HTMLCanvasElement, memberId: string) { donutCanvases[memberId] = node; - return { destroy() { delete donutCanvases[memberId]; } }; + return { + destroy() { + delete donutCanvases[memberId]; + } + }; } - // ─── Derived tile stats ─── + // ─── Derived tile stats (this week) ─── let tileMembers = $derived(members.length); let tilePts = $derived( summary?.summaries?.reduce((s: number, m: any) => s + (m.pointsEarned || 0), 0) ?? 0 @@ -38,6 +49,58 @@ summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0 ); + // ─── All-time header stats (server-computed) ─── + let allTimePts = $derived(Number(stats.allTimePts) || 0); + let allTimeCash = $derived(Number(stats.allTimeCash) || 0); + let allTimeChores = $derived(Number(stats.allTimeChores) || 0); + + // ─── Trend chart window: show current month, expand a month at a time up to 3. ─── + let trend = $derived.by(() => { + const series = Array.isArray(stats.series) ? stats.series : []; + const memberLines = series.filter((m: any) => m.memberId); + return memberLines; + }); + + function computeTrend(): { labels: string[]; datasets: any[] } { + const allWeeks = new Set(); + for (const m of trend) { + for (const w of m.weeks || []) allWeeks.add(w.weekStart); + } + const weeks = [...allWeeks].sort(); + if (weeks.length === 0) return { labels: [], datasets: [] }; + + const latest = weeks[weeks.length - 1]; + const earliest = weeks[0]; + const spanDays = Math.max( + 0, + Math.round((new Date(latest).getTime() - new Date(earliest).getTime()) / 86400000) + ); + // 1 month if data spans <= ~1 month; grow a month at a time up to 3. + const months = spanDays <= 31 ? 1 : spanDays <= 62 ? 2 : 3; + const windowStart = latest ? addDaysStr(latest, -(months * 31)) : earliest; + const labels = weeks.filter((w) => w >= windowStart); + + const valueFor = (m: any, w: string, met: string): number => { + const wk = (m.weeks || []).find((x: any) => x.weekStart === w); + if (!wk) return 0; + return Number(wk[met]) || 0; + }; + + return { + labels, + datasets: trend.map((m: any) => ({ + label: m.memberName, + data: labels.map((w) => valueFor(m, w, metric)), + borderColor: m.memberColor || '#6366f1', + backgroundColor: m.memberColor || '#6366f1', + tension: 0.35, + fill: false, + pointRadius: 3, + borderWidth: 2 + })) + }; + } + function totalChoresFor(memberId: string) { return assigned.filter((a: any) => a.memberId === memberId).length; } @@ -54,6 +117,26 @@ function todayPotentialFor(memberId: string) { return assigned.filter((a: any) => a.memberId === memberId && a.frequency === 'daily').length; } + // Max points a full "all daily chores done" day is worth for a member. + function dailyPointsPotential(memberId: string) { + return assigned + .filter((a: any) => a.memberId === memberId && a.frequency === 'daily') + .reduce((sum: number, a: any) => sum + (a.type === 'points' ? Number(a.value) || 0 : 0), 0); + } + // Days elapsed in the current week (min 1), for a running daily average. + function daysElapsed(): number { + const days = summary?.daysInWeek || []; + const today = todayInTz(famTz); + return Math.max(1, days.filter((d: string) => d <= today).length); + } + // Average daily points from chore completions this week. + function avgDailyPointsFor(s: any): number { + const total = Object.values(s.dayPoints || {}).reduce( + (sum: number, v: any) => sum + (Number(v) || 0), + 0 + ); + return total / daysElapsed(); + } // Today's completion count — tolerant of UTC vs fam-local date keys. function todayDone(s: any): number { if (!s.dayCompletions) return 0; @@ -63,6 +146,12 @@ return s.dayCompletions[local] || 0; } + function distinctTrendWeeks(): number { + const set = new Set(); + for (const m of trend) for (const w of m.weeks || []) set.add(w.weekStart); + return set.size; + } + onMount(() => { if (!summary?.summaries?.length) return; @@ -75,7 +164,7 @@ label: s.memberName, data: days.map((d: string) => s.dayCompletions?.[d] || 0), backgroundColor: s.memberColor || '#6366f1', - borderRadius: 4, + borderRadius: 4 })); choresChart = new Chart(choresCanvas, { type: 'bar', @@ -83,10 +172,10 @@ options: { responsive: true, plugins: { - legend: { display: summary.summaries.length > 1, position: 'bottom' }, + legend: { display: summary.summaries.length > 1, position: 'bottom' } }, - scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }, - }, + scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } } + } }); } @@ -96,13 +185,15 @@ type: 'doughnut', data: { labels: ['Done', 'Remaining'], - datasets: [{ - data: [done, remaining], - backgroundColor: [color || '#22c55e', '#e5e7eb'], - borderWidth: 0, - }], + datasets: [ + { + data: [done, remaining], + backgroundColor: [color || '#22c55e', '#e5e7eb'], + borderWidth: 0 + } + ] }, - options: { responsive: true, cutout: '70%', plugins: { legend: { display: false } } }, + options: { responsive: true, cutout: '70%', plugins: { legend: { display: false } } } }); } @@ -125,11 +216,57 @@ s.memberColor ); } + const scoreCanvas = donutCanvases[`${s.memberId}:score`]; + if (scoreCanvas) { + donutCharts[`${s.memberId}:score`] = makeDonut( + scoreCanvas, + avgDailyPointsFor(s), + dailyPointsPotential(s.memberId), + s.memberColor + ); + } + } + }); + + // Re-render the trend line chart when the metric view changes. + $effect(() => { + metric; + trend; + if (!trendCanvas) return; + const { labels, datasets } = computeTrend(); + if (trendChart) { + trendChart.data.labels = labels; + trendChart.data.datasets = datasets; + trendChart.update(); + } else if (datasets.length > 0) { + trendChart = new Chart(trendCanvas, { + type: 'line', + data: { labels, datasets }, + options: { + responsive: true, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { display: trend.length > 1, position: 'bottom' }, + tooltip: { + callbacks: { + label: (ctx: any) => { + const v = ctx.parsed.y; + if (metric === 'cash') return ` ${ctx.dataset.label}: £${Number(v).toFixed(2)}`; + if (metric === 'chores') return ` ${ctx.dataset.label}: ${v} chores`; + return ` ${ctx.dataset.label}: ${v} pts`; + } + } + } + }, + scales: { y: { beginAtZero: true } } + } + }); } }); onDestroy(() => { choresChart?.destroy(); + trendChart?.destroy(); for (const c of Object.values(donutCharts)) c?.destroy(); }); @@ -138,7 +275,19 @@ title={page.data.fam?.name || famSlug} subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : 'Dashboard'} hero -/> +> + +
+ + {allTimePts.toLocaleString()} + points all time + + + £{allTimeCash.toFixed(2)} + cash earned all time + +
+
@@ -154,15 +303,28 @@ £{tileMoney.toFixed(2)} cash this week
-
- {tileChores} - chores done -
+ + +
+
+ {tileChores} + this week +
+
+ {allTimeChores} + total all time +
+
+
+ +
+
+ - + {#if !summary?.summaries?.length}

No data yet this week

{:else} @@ -175,6 +337,9 @@ {@const tdTotal = todayPotentialFor(s.memberId)} {@const tdDone = todayDone(s)} {@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0} + {@const scPot = dailyPointsPotential(s.memberId)} + {@const scVal = avgDailyPointsFor(s)} + {@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0}
@@ -194,6 +359,11 @@
{wkPct}%
Week
+
+ +
{scPct}%
+ Avg/day +
@@ -215,44 +385,126 @@ {/if} - - - {#if summary?.summaries?.length} + + +
+ + + +
+ {#if distinctTrendWeeks() === 0} +

No weekly data yet

+ {:else}
- -
- {:else} -

No data yet

- {/if} -
- - - - {#if templates.length === 0} -

No templates created

- {:else} -
- {#each templates as t} -
- {t.name} - {t.defaultFrequency} · {t.defaultValue} {t.defaultType} -
- {/each} +
{/if}