enhance with charts and home layout, and make more mobile friendly

This commit is contained in:
JCEEE
2026-08-31 18:03:44 +01:00
parent 3bf78d777c
commit 11a09d65ff
10 changed files with 724 additions and 156 deletions
+9
View File
@@ -321,6 +321,15 @@
- **Settings accordion order:** Family (name/payday/**seasons**, opens by default via `<AccordionItem open>`) → 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'`.
@@ -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;
}
}
</style>
@@ -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; }
+138 -35
View File
@@ -1,20 +1,40 @@
<script lang="ts">
let { title, subtitle, hero = false, tabs, weeknav, sort }: {
import type { Snippet } from 'svelte';
let {
title,
subtitle,
hero = false,
tabs,
weeknav,
sort,
children
}: {
title: string;
subtitle?: string;
hero?: boolean;
tabs?: { items: { label: string; value: string }[]; active: string; onchange: (v: string) => void };
tabs?: {
items: { label: string; value: string }[];
active: string;
onchange: (v: string) => void;
};
weeknav?: { current: string; onPrev: () => void; onNext: () => void };
sort?: { options: { label: string; value: string }[]; active: string; onchange: (v: string) => void };
sort?: {
options: { label: string; value: string }[];
active: string;
onchange: (v: string) => void;
};
children?: Snippet;
} = $props();
</script>
<div class="view-header" class:hero>
<div class="view-header flex justify-between" class:hero>
<div class="view-title-group">
<h1 class="view-title">{title}</h1>
<h1 class="view-title text-3xl">{title}</h1>
{#if subtitle}<p class="view-subtitle">{subtitle}</p>{/if}
</div>
{@render children?.()}
<div class="view-tools">
{#if tabs}
<div class="tab-bar">
@@ -22,8 +42,8 @@
<button
class="tab-btn"
class:active={tab.value === tabs.active}
onclick={() => tabs.onchange(tab.value)}
>{tab.label}</button>
onclick={() => tabs.onchange(tab.value)}>{tab.label}</button
>
{/each}
</div>
{/if}
@@ -37,7 +57,11 @@
{/if}
{#if sort}
<select class="sort-select" value={sort.active} onchange={(e) => sort.onchange(e.currentTarget.value)}>
<select
class="sort-select"
value={sort.active}
onchange={(e) => sort.onchange(e.currentTarget.value)}
>
{#each sort.options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
@@ -47,27 +71,80 @@
</div>
<style>
.view-header { margin-bottom: 1.5rem; }
.view-title-group { margin-bottom: 0.5rem; }
.view-title { font-size: 1.4rem; font-weight: 700; color: #111827; margin: 0; }
.view-subtitle { font-size: 0.9rem; color: #6b7280; margin: 0.25rem 0 0 0; }
.view-tools { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.tab-bar { display: flex; gap: 2px; background: #f3f4f6; border-radius: 6px; padding: 2px; }
.view-header {
margin-bottom: 1.5rem;
}
.view-title-group {
margin-bottom: 0.5rem;
}
.view-title {
font-weight: 700;
color: #111827;
margin: 0;
}
.view-subtitle {
font-size: 0.9rem;
color: #6b7280;
margin: 0.25rem 0 0 0;
}
.view-tools {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.tab-bar {
display: flex;
gap: 2px;
background: #f3f4f6;
border-radius: 6px;
padding: 2px;
}
.tab-btn {
padding: 0.35rem 0.85rem; border: none; background: transparent; border-radius: 5px;
font-size: 0.85rem; color: #6b7280; cursor: pointer;
padding: 0.35rem 0.85rem;
border: none;
background: transparent;
border-radius: 5px;
font-size: 0.85rem;
color: #6b7280;
cursor: pointer;
}
.tab-btn.active {
background: #fff;
color: #4338ca;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.week-nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tab-btn.active { background: #fff; color: #4338ca; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.week-nav { display: flex; align-items: center; gap: 0.5rem; }
.nav-btn {
padding: 0.2rem 0.5rem; border: 1px solid #d1d5db; background: #fff; border-radius: 4px;
cursor: pointer; font-size: 1rem; line-height: 1;
padding: 0.2rem 0.5rem;
border: 1px solid #d1d5db;
background: #fff;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.nav-btn:hover {
background: #f3f4f6;
}
.nav-label {
font-size: 0.85rem;
color: #374151;
font-weight: 500;
min-width: 140px;
text-align: center;
}
.nav-btn:hover { background: #f3f4f6; }
.nav-label { font-size: 0.85rem; color: #374151; font-weight: 500; min-width: 140px; text-align: center; }
.sort-select {
padding: 0.3rem 0.6rem; border: 1px solid #d1d5db; border-radius: 5px;
font-size: 0.85rem; background: #fff;
padding: 0.3rem 0.6rem;
border: 1px solid #d1d5db;
border-radius: 5px;
font-size: 0.85rem;
background: #fff;
}
/* ── Hero lead (child-dashboard style) ── */
@@ -78,15 +155,41 @@
color: #fff;
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.35);
}
.view-header.hero .view-title-group { margin-bottom: 0.25rem; }
.view-header.hero .view-title { color: #fff; }
.view-header.hero .view-subtitle { color: rgba(255, 255, 255, 0.85); }
.view-header.hero .view-tools { margin-top: 0.75rem; }
.view-header.hero .tab-bar { background: rgba(255, 255, 255, 0.16); }
.view-header.hero .tab-btn { color: rgba(255, 255, 255, 0.9); }
.view-header.hero .tab-btn:hover { color: #fff; }
.view-header.hero .tab-btn.active { background: #fff; color: #4338ca; }
.view-header.hero .nav-btn { background: rgba(255, 255, 255, 0.92); border-color: transparent; color: #4338ca; }
.view-header.hero .nav-label { color: #fff; }
.view-header.hero .sort-select { background: rgba(255, 255, 255, 0.92); color: #1e1b4b; }
.view-header.hero .view-title-group {
margin-bottom: 0.25rem;
}
.view-header.hero .view-title {
color: #fff;
}
.view-header.hero .view-subtitle {
color: rgba(255, 255, 255, 0.85);
}
.view-header.hero .view-tools {
margin-top: 0.75rem;
}
.view-header.hero .tab-bar {
background: rgba(255, 255, 255, 0.16);
}
.view-header.hero .tab-btn {
color: rgba(255, 255, 255, 0.9);
}
.view-header.hero .tab-btn:hover {
color: #fff;
}
.view-header.hero .tab-btn.active {
background: #fff;
color: #4338ca;
}
.view-header.hero .nav-btn {
background: rgba(255, 255, 255, 0.92);
border-color: transparent;
color: #4338ca;
}
.view-header.hero .nav-label {
color: #fff;
}
.view-header.hero .sort-select {
background: rgba(255, 255, 255, 0.92);
color: #1e1b4b;
}
</style>
@@ -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)
}
};
}
+120
View File
@@ -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<FamilyStats> {
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<string, any>(assigned.map((a: any) => [a.id, a]));
const memberPts: Record<string, number> = {};
const memberCash: Record<string, number> = {};
const memberChores: Record<string, number> = {};
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<string, WeeklyPoint>();
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
};
}
+5
View File
@@ -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;
+5 -4
View File
@@ -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 {};
}
+340 -96
View File
@@ -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<Record<string, HTMLCanvasElement | undefined>>({});
let donutCharts: Record<string, Chart> = {};
let choresCanvas: HTMLCanvasElement | undefined;
let choresChart: Chart | undefined;
let trendCanvas = $state<HTMLCanvasElement | undefined>();
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<string>();
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<string>();
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' },
},
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
legend: { display: summary.summaries.length > 1, position: 'bottom' }
},
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
}
});
}
@@ -96,13 +185,15 @@
type: 'doughnut',
data: {
labels: ['Done', 'Remaining'],
datasets: [{
datasets: [
{
data: [done, remaining],
backgroundColor: [color || '#22c55e', '#e5e7eb'],
borderWidth: 0,
}],
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();
});
</script>
@@ -138,7 +275,19 @@
title={page.data.fam?.name || famSlug}
subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : 'Dashboard'}
hero
/>
>
<!-- All-time family totals, large in the header -->
<div class="vh-stats grow text-right">
<span class="vh-stat">
<span class="vh-num pts">{allTimePts.toLocaleString()}</span>
<span class="vh-lbl">points all time</span>
</span>
<span class="vh-stat">
<span class="vh-num cash">£{allTimeCash.toFixed(2)}</span>
<span class="vh-lbl">cash earned all time</span>
</span>
</div>
</ViewHeader>
<!-- STAT TILES -->
<div class="tiles">
@@ -154,15 +303,28 @@
<span class="tile-val">£{tileMoney.toFixed(2)}</span>
<span class="tile-lbl">cash this week</span>
</div>
<div class="tile tile-chores">
<span class="tile-val">{tileChores}</span>
<span class="tile-lbl">chores done</span>
</div>
</div>
<CardGrid>
<!-- CHORES: this week | total -->
<Card cols={1} title="Chores">
<div class="chores-totals">
<div class="chore-total">
<span class="ct-val">{tileChores}</span>
<span class="ct-lbl">this week</span>
</div>
<div class="chore-total">
<span class="ct-val">{allTimeChores}</span>
<span class="ct-lbl">total all time</span>
</div>
</div>
<div class="chart-wrap">
<canvas bind:this={choresCanvas} height="200"></canvas>
</div>
</Card>
<!-- MEMBERS KANBAN -->
<Card cols={3} scrollX title="Members">
<Card cols={2} scrollX title="Members">
{#if !summary?.summaries?.length}
<p class="empty">No data yet this week</p>
{: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}
<div class="column col-member">
<div class="member-head">
<span class="dot" style="background:{s.memberColor}"></span>
@@ -194,6 +359,11 @@
<div class="donut-center" style="color:{s.memberColor}">{wkPct}%</div>
<span class="donut-label">Week</span>
</div>
<div class="donut-wrap">
<canvas use:captureCanvas={`${s.memberId}:score`} height="100" width="100"></canvas>
<div class="donut-center" style="color:{s.memberColor}">{scPct}%</div>
<span class="donut-label">Avg/day</span>
</div>
</div>
<div class="member-stats">
<div class="stat-row">
@@ -215,44 +385,126 @@
{/if}
</Card>
<!-- CHORES PER DAY CHART -->
<Card cols={2} title="Chores per day">
{#if summary?.summaries?.length}
<!-- WEEKLY TREND (cash / points / chores per member) -->
<Card cols={3} title="Over time">
<div class="trend-tabs">
<button
class="trend-tab"
class:active={metric === 'points'}
onclick={() => (metric = 'points')}>Points</button
>
<button class="trend-tab" class:active={metric === 'cash'} onclick={() => (metric = 'cash')}
>Cash</button
>
<button
class="trend-tab"
class:active={metric === 'chores'}
onclick={() => (metric = 'chores')}>Chores</button
>
</div>
{#if distinctTrendWeeks() === 0}
<p class="empty">No weekly data yet</p>
{:else}
<div class="chart-wrap">
<canvas bind:this={choresCanvas} height="200"></canvas>
</div>
{:else}
<p class="empty">No data yet</p>
{/if}
</Card>
<!-- TEMPLATES -->
<Card cols={1} title="Templates ({templates.length})">
{#if templates.length === 0}
<p class="empty">No templates created</p>
{:else}
<div class="template-list">
{#each templates as t}
<div class="template-chip">
<span class="tpl-name">{t.name}</span>
<span class="tpl-meta">{t.defaultFrequency} · {t.defaultValue} {t.defaultType}</span>
</div>
{/each}
<canvas bind:this={trendCanvas} height="220"></canvas>
</div>
{/if}
</Card>
</CardGrid>
<style>
/* ── All-time header stats ── */
.vh-stats {
display: flex;
flex-direction: column;
}
.vh-stat {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.vh-num {
font-size: 2.4rem;
font-weight: 900;
line-height: 1;
letter-spacing: -0.02em;
}
.vh-num.pts {
color: #fff;
}
.vh-lbl {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.85;
}
/* ── Chores card totals ── */
.chores-totals {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
background: linear-gradient(135deg, #0284c7, #38bdf8);
border-radius: 14px;
padding: 0.9rem 1.1rem;
margin-bottom: 0.85rem;
color: #fff;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.chore-total {
display: inline-flex;
flex-direction: column;
}
.ct-val {
font-size: 1.6rem;
font-weight: 800;
color: #fff;
}
.ct-lbl {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.85);
}
.trend-tabs {
display: flex;
gap: 2px;
background: #f3f4f6;
border-radius: 6px;
padding: 2px;
margin-bottom: 0.85rem;
width: fit-content;
}
.trend-tab {
padding: 0.35rem 0.85rem;
border: none;
background: transparent;
border-radius: 5px;
font-size: 0.85rem;
color: #6b7280;
cursor: pointer;
}
.trend-tab.active {
background: #fff;
color: #4338ca;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* ── Tiles (matching child dashboard) ── */
.tiles {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 640px) {
.tiles { grid-template-columns: repeat(2, 1fr); }
.tiles {
grid-template-columns: repeat(2, 1fr);
}
}
.tile {
border-radius: 14px;
@@ -263,11 +515,22 @@
gap: 0.15rem;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.tile-members { background: linear-gradient(135deg, #6366f1, #8b5cf6); }
.tile-pts { background: linear-gradient(135deg, #7c3aed, #a855f7); }
.tile-cash { background: linear-gradient(135deg, #059669, #10b981); }
.tile-chores { background: linear-gradient(135deg, #0284c7, #38bdf8); }
.tile-val { font-size: 1.6rem; font-weight: 800; }
.tile-members {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
.tile-pts {
background: linear-gradient(135deg, #7c3aed, #a855f7);
}
.tile-cash {
background: linear-gradient(135deg, #059669, #10b981);
}
.tile-chores {
background: linear-gradient(135deg, #0284c7, #38bdf8);
}
.tile-val {
font-size: 1.6rem;
font-weight: 800;
}
.tile-lbl {
font-size: 0.75rem;
text-transform: uppercase;
@@ -289,15 +552,11 @@
padding: 0.75rem;
background: #fafbff;
}
.col-member { border-top-color: #6366f1; background: #eef2ff; }
.member-hero {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
margin-bottom: 0.5rem;
.col-member {
border-top-color: #6366f1;
background: #eef2ff;
}
.member-head {
display: flex;
align-items: center;
@@ -307,7 +566,8 @@
}
.dot {
display: inline-block;
width: 10px; height: 10px;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
@@ -317,6 +577,8 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.kanban-link {
font-size: 0.75rem;
@@ -325,21 +587,26 @@
font-weight: 600;
flex-shrink: 0;
}
.kanban-link:hover { text-decoration: underline; }
.kanban-link:hover {
text-decoration: underline;
}
.donuts {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.85rem;
margin: 0.25rem 0 0.75rem;
margin: 3rem 0;
}
.donut-wrap {
position: relative;
width: 62px; height: 62px;
width: 62px;
height: 62px;
flex-shrink: 0;
}
.donut-wrap canvas {
max-width: 62px; max-height: 62px;
max-width: 62px;
max-height: 62px;
}
.donut-center {
position: absolute;
@@ -376,50 +643,27 @@
align-items: center;
font-size: 0.8rem;
}
.stat-lbl { color: #6b7280; font-weight: 500; }
.stat-val { font-weight: 700; }
.stat-val.pts { color: #7c3aed; }
.stat-val.cash { color: #059669; }
.progress-row {
margin-top: 0.2rem;
.stat-lbl {
color: #6b7280;
font-weight: 500;
}
.bar-wrap {
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
.stat-val {
font-weight: 700;
}
.bar-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 4px;
transition: width 0.3s ease;
.stat-val.pts {
color: #7c3aed;
}
.stat-val.cash {
color: #059669;
}
/* ── Chart ── */
.chart-wrap { min-height: 200px; }
.chart-wrap canvas { max-height: 300px; }
/* ── Templates ── */
.template-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
.chart-wrap {
min-height: 200px;
}
.template-chip {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border: 1px solid #e5e7eb;
border-left: 4px solid #6366f1;
border-radius: 8px;
background: #fafbff;
gap: 0.5rem;
.chart-wrap canvas {
max-height: 300px;
}
.tpl-name { font-weight: 600; font-size: 0.85rem; }
.tpl-meta { font-size: 0.75rem; color: #6b7280; white-space: nowrap; }
/* ── Shared ── */
.empty {
@@ -383,12 +383,24 @@
);
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)
);
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)
@@ -1374,16 +1386,25 @@
<div class="wallet">
<div class="wallet-top">
<span class="wallet-lbl">{@html walletIcon} Wallet</span>
{#if allTimeCash > 0 || allTimePoints > 0}
<span class="wallet-total">
<span class="wallet-cash">£{allTimeCash.toFixed(2)}</span>
<span class="wallet-dot">·</span>
<span class="wallet-pts">{allTimePoints} pts</span>
</span>
{/if}
</div>
<!-- Lifetime earnings (everything earned + claimed), one-shot totals -->
<div class="lifetime">
<div class="life-cell life-cash">
<span class="life-num">£{allTimeCash.toFixed(2)}</span>
<span class="life-lbl">earned all time</span>
</div>
<div class="life-cell life-pts">
<span class="life-num">{allTimePoints.toLocaleString()}</span>
<span class="life-lbl">points accrued</span>
</div>
</div>
<div class="wallet-sub">
<span class="wallet-sub-lbl">{@html clockIcon} To collect from parent</span>
</div>
{#if pendingRewards.length === 0}
<p class="wallet-empty">No rewards waiting</p>
<p class="wallet-empty">Nothing waiting — you're all caught up</p>
{:else}
<div class="wallet-rewards">
{#each pendingRewards as r}
@@ -2410,21 +2431,56 @@
vertical-align: -0.15em;
margin-right: 0.3em;
}
.wallet-total {
.lifetime {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin: 0.75rem 0 1rem;
}
.life-cell {
display: flex;
gap: 0.4rem;
align-items: center;
font-size: 0.9rem;
font-weight: 800;
flex-direction: column;
gap: 0.1rem;
padding: 0.85rem 1rem;
border-radius: 12px;
}
.wallet-cash {
color: #059669;
.life-cash {
background: linear-gradient(135deg, #059669, #10b981);
}
.wallet-pts {
color: #7c3aed;
.life-pts {
background: linear-gradient(135deg, #7c3aed, #a855f7);
}
.wallet-dot {
color: #d1d5db;
.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;
margin-bottom: 0.6rem;
}
.wallet-sub-lbl {
font-size: 0.72rem;
font-weight: 700;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.wallet-sub-lbl :global(svg) {
width: 1em;
height: 1em;
vertical-align: -0.15em;
margin-right: 0.3em;
}
.wallet-empty {
margin: 0;