Files
famdone/frontend/src/routes/[fam]/+page.svelte
T
2026-09-10 11:27:27 +01:00

732 lines
20 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
import { ViewHeader, CardGrid, Card } from '$lib/components';
import { formatDDMMYY } from '$lib/format';
import { famStore } from '$lib/stores/fam.svelte';
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 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];
}
};
}
// ─── 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
);
let tileMoney = $derived(
summary?.summaries?.reduce((s: number, m: any) => s + (Number(m.moneyEarned) || 0), 0) ?? 0
);
let tileChores = $derived(
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);
// ─── Goals completed (rewards from bonus configs, pocket money excluded) ───
// Derived from famStore so realtime updates (a goal met / revoked) flow in.
let rewards = $derived(famStore.initialized ? famStore.rewards : data.rewards || []);
// All-time goals achieved — every materialized bonus reward (prize / points /
// cash), including pocket money configs.
let tileGoals = $derived(rewards.filter((r: any) => r.bonusConfigId).length);
let bonusConfigs = $derived(
famStore.initialized ? famStore.bonusConfigs : data.bonusConfigs || []
);
let goalsByMember = $derived.by(() => {
const ws = (summary?.weekStart as string) || todayInTz(famTz);
const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id));
const map = new Map<string, { count: number; names: string[] }>();
for (const r of rewards as any[]) {
if (!r.bonusConfigId || pmIds.has(r.bonusConfigId)) continue;
if ((r.date || '').slice(0, 10) < ws) continue;
const cfg = bonusConfigs.find((c: any) => c.id === r.bonusConfigId);
const entry = map.get(r.memberId) || { count: 0, names: [] };
entry.count++;
if (cfg?.name && !entry.names.includes(cfg.name)) entry.names.push(cfg.name);
map.set(r.memberId, entry);
}
return map;
});
// ─── 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;
}
// Weekly completion potential for a member: each daily chore = 7 instances,
// each weekly/todo chore = 1. (The raw `s.totalChores` is a template count,
// which is why the old donut could read >100%.)
function weeklyPotentialFor(memberId: string) {
return assigned
.filter((a: any) => a.memberId === memberId)
.reduce((sum: number, a: any) => sum + (a.frequency === 'daily' ? 7 : 1), 0);
}
// Chores expected "today" = the member's daily chores (each due once today).
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;
const utc = new Date().toISOString().slice(0, 10);
if (s.dayCompletions[utc] != null) return s.dayCompletions[utc];
const local = new Date().toLocaleDateString('en-CA');
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;
if (choresCanvas && summary.daysInWeek?.length) {
const days = summary.daysInWeek;
const labels = days.map((d: string) =>
new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' })
);
const datasets = summary.summaries.map((s: any) => ({
label: s.memberName,
data: days.map((d: string) => s.dayCompletions?.[d] || 0),
backgroundColor: s.memberColor || '#6366f1',
borderRadius: 4
}));
choresChart = new Chart(choresCanvas, {
type: 'bar',
data: { labels, datasets },
options: {
responsive: true,
plugins: {
legend: { display: summary.summaries.length > 1, position: 'bottom' }
},
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } }
}
});
}
function makeDonut(canvas: HTMLCanvasElement, done: number, total: number, color: string) {
const remaining = Math.max(0, total - done);
return new Chart(canvas, {
type: 'doughnut',
data: {
labels: ['Done', 'Remaining'],
datasets: [
{
data: [done, remaining],
backgroundColor: [color || '#22c55e', '#e5e7eb'],
borderWidth: 0
}
]
},
options: { responsive: true, cutout: '70%', plugins: { legend: { display: false } } }
});
}
for (const s of summary.summaries) {
const todayCanvas = donutCanvases[`${s.memberId}:today`];
if (todayCanvas) {
donutCharts[`${s.memberId}:today`] = makeDonut(
todayCanvas,
todayDone(s),
todayPotentialFor(s.memberId),
s.memberColor
);
}
const weekCanvas = donutCanvases[`${s.memberId}:week`];
if (weekCanvas) {
donutCharts[`${s.memberId}:week`] = makeDonut(
weekCanvas,
s.choresCompleted || 0,
weeklyPotentialFor(s.memberId),
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>
<ViewHeader
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">
<div class="tile tile-members">
<span class="tile-val">{tileMembers}</span>
<span class="tile-lbl">members</span>
</div>
<div class="tile tile-pts">
<span class="tile-val">{tilePts}</span>
<span class="tile-lbl">points this week</span>
</div>
<div class="tile tile-cash">
<span class="tile-val">£{tileMoney.toFixed(2)}</span>
<span class="tile-lbl">cash this week</span>
</div>
<div class="tile tile-goals">
<span class="tile-val">{tileGoals}</span>
<span class="tile-lbl">goals achieved</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={2} scrollX title="Members">
{#if !summary?.summaries?.length}
<p class="empty">No data yet this week</p>
{:else}
<div class="kanban">
{#each summary.summaries as s}
{@const m = members.find((m2: any) => m2.id === s.memberId)}
{@const wkTotal = weeklyPotentialFor(s.memberId)}
{@const wkDone = s.choresCompleted || 0}
{@const wkPct = wkTotal > 0 ? Math.min(100, Math.round((wkDone / wkTotal) * 100)) : 0}
{@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}
{@const goals = goalsByMember.get(s.memberId)}
<div class="column col-member" style="--mc: {s.memberColor}">
<div class="member-head">
<span class="dot" style="background:{s.memberColor}"></span>
<span class="member-name">{s.memberName}</span>
{#if m}
<a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
{/if}
</div>
<div class="donuts">
<div class="donut-wrap">
<canvas use:captureCanvas={`${s.memberId}:today`} height="100" width="100"></canvas>
<div class="donut-center" style="color:{s.memberColor}">{tdPct}%</div>
<span class="donut-label">Today</span>
</div>
<div class="donut-wrap">
<canvas use:captureCanvas={`${s.memberId}:week`} height="100" width="100"></canvas>
<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">
<span class="stat-lbl">Points</span>
<span class="stat-val pts">{s.pointsEarned}</span>
</div>
<div class="stat-row">
<span class="stat-lbl">Cash</span>
<span class="stat-val cash">£{Number(s.moneyEarned || 0).toFixed(2)}</span>
</div>
<div class="stat-row">
<span class="stat-lbl">Done</span>
<span class="stat-val">{wkDone}/{wkTotal}</span>
</div>
<div class="stat-row">
<span class="stat-lbl">Goals 🎉</span>
<span class="stat-val goals" title={goals?.names.join(' · ')}
>{goals?.count ?? 0}</span
>
</div>
</div>
</div>
{/each}
</div>
{/if}
</Card>
<!-- 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={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;
/* members 1 unit wide; goals / points / cash 2 units each (1+2+2+2 on one row) */
grid-template-columns: 1fr 2fr 2fr 2fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 640px) {
.tiles {
grid-template-columns: repeat(2, 1fr);
}
.vh-num {
font-size: 1.5em;
}
}
.tile {
border-radius: 14px;
padding: 0.9rem 1.1rem;
color: #fff;
display: flex;
flex-direction: column;
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-goals {
background: linear-gradient(135deg, #f59e0b, #f97316);
}
.tile-val {
font-size: 1.6rem;
font-weight: 800;
}
.tile-lbl {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.9;
}
/* ── Kanban columns ── */
.kanban {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
min-width: 450px;
}
.column {
border: 1px solid #e5e7eb;
border-top: 4px solid #6366f1;
border-radius: 12px;
padding: 0.75rem;
background: #fafbff;
}
.col-member {
border-top-color: #6366f1;
/* Translucent wash of the member's own colour (set via --mc inline) */
background: color-mix(in srgb, var(--mc, #6366f1) 12%, transparent);
}
.member-head {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1;
min-width: 0;
}
.dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.member-name {
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.kanban-link {
font-size: 0.75rem;
color: #6366f1;
text-decoration: none;
font-weight: 600;
flex-shrink: 0;
}
.kanban-link:hover {
text-decoration: underline;
}
.donuts {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.85rem;
margin: 3rem 0;
}
.donut-wrap {
position: relative;
width: 62px;
height: 62px;
flex-shrink: 0;
}
.donut-wrap canvas {
max-width: 62px;
max-height: 62px;
}
.donut-center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: 800;
pointer-events: none;
}
.donut-label {
position: absolute;
bottom: -15px;
left: 0;
right: 0;
text-align: center;
font-size: 0.6rem;
font-weight: 700;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.member-stats {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.5rem;
}
.stat-row {
display: flex;
justify-content: space-between;
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;
}
.stat-val.goals {
color: #059669;
}
/* ── Chart ── */
.chart-wrap {
min-height: 200px;
}
.chart-wrap canvas {
max-height: 300px;
}
/* ── Shared ── */
.empty {
color: #9ca3af;
font-size: 0.85rem;
text-align: center;
padding: 1.5rem;
}
/* Mobile: member columns stack full width (same convention as the chores
kanban) instead of the 450px horizontal scroll. Declared last so it wins
over the base .kanban rule. */
@media (max-width: 639px) {
.kanban {
grid-template-columns: 1fr;
min-width: 0;
}
}
</style>