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
+342 -98
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' },
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();
});
</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 {