176 lines
5.6 KiB
Svelte
176 lines
5.6 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import { famStore } from '$lib/stores/fam.svelte';
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { Chart, registerables } from 'chart.js';
|
|
|
|
Chart.register(...registerables);
|
|
|
|
let { data } = $props();
|
|
let famSlug = $derived(page.params.fam);
|
|
|
|
let summary = $state(data.summary);
|
|
let donutCanvases = $state<Record<string, HTMLCanvasElement | undefined>>({});
|
|
let donutCharts: Record<string, Chart> = {};
|
|
let choresCanvas: HTMLCanvasElement | undefined;
|
|
let choresChart: Chart | undefined;
|
|
|
|
function captureCanvas(node: HTMLCanvasElement, memberId: string) {
|
|
donutCanvases[memberId] = node;
|
|
return {
|
|
destroy() { delete donutCanvases[memberId]; }
|
|
};
|
|
}
|
|
|
|
let members = $state(data.members);
|
|
let templates = $state(data.templates);
|
|
|
|
$effect(() => {
|
|
if (data.members?.length) members = data.members;
|
|
if (data.templates?.length) templates = data.templates;
|
|
if (famStore.initialized) {
|
|
if (famStore.members.length) members = famStore.members;
|
|
if (famStore.templates.length) templates = famStore.templates;
|
|
}
|
|
})
|
|
|
|
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: true }, title: { display: true, text: 'Chores per day' } },
|
|
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
|
},
|
|
});
|
|
}
|
|
|
|
for (const s of summary.summaries) {
|
|
const canvas = donutCanvases[s.memberId];
|
|
if (!canvas) continue;
|
|
const done = s.choresCompleted || 0;
|
|
const total = s.totalChores || 0;
|
|
const remaining = Math.max(0, total - done);
|
|
donutCharts[s.memberId] = new Chart(canvas, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: ['Done', 'Remaining'],
|
|
datasets: [{
|
|
data: [done, remaining],
|
|
backgroundColor: [s.memberColor || '#22c55e', '#e5e7eb'],
|
|
borderWidth: 0,
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
cutout: '70%',
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (ctx) => `${ctx.label}: ${ctx.parsed} chores`,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
choresChart?.destroy();
|
|
for (const c of Object.values(donutCharts)) c?.destroy();
|
|
famStore.cleanup();
|
|
});
|
|
</script>
|
|
|
|
<h1>🏠 {famSlug}</h1>
|
|
|
|
{#if summary?.summaries?.length}
|
|
<h2>This week</h2>
|
|
<table>
|
|
<thead><tr><th>Member</th><th>Points</th><th>Done</th></tr></thead>
|
|
<tbody>
|
|
{#each summary.summaries as s}
|
|
<tr>
|
|
<td><span style="color:{s.memberColor}">■</span> {s.memberName}</td>
|
|
<td>{s.pointsEarned}</td>
|
|
<td>{s.choresCompleted}/{s.totalChores}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="card">
|
|
<h2>Chores per day</h2>
|
|
<canvas bind:this={choresCanvas} height="200"></canvas>
|
|
</div>
|
|
|
|
<div class="donut-grid">
|
|
{#each summary.summaries as s}
|
|
<div class="donut-card">
|
|
<canvas use:captureCanvas={s.memberId} height="150" width="150"></canvas>
|
|
<div class="donut-label" style="color:{s.memberColor}">{s.memberName}</div>
|
|
<div class="donut-points">{s.pointsEarned} pts</div>
|
|
<div class="donut-hint">{s.choresCompleted}/{s.totalChores} chores</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<h2>Members ({members?.length || 0})</h2>
|
|
<div class="member-grid">
|
|
{#each members || [] as m}
|
|
<a href="/{famSlug}/{m.name}" class="member-card" style="border-left: 4px solid {m.color}">
|
|
<strong>{m.name}</strong>
|
|
<span class="hint">{m.deviceTokenHint || '—'}</span>
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if templates?.length}
|
|
<h2>Templates ({templates.length})</h2>
|
|
<ul>
|
|
{#each templates as t}
|
|
<li>{t.name} — {t.defaultFrequency} — {t.defaultValue} {t.defaultType}</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
|
|
<style>
|
|
nav { display: flex; gap: 0.5rem; margin: 1rem 0; }
|
|
nav a { padding: 0.4rem 0.8rem; background: #6366f1; color: white; border-radius: 4px; text-decoration: none; font-size: 0.9rem; }
|
|
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
|
|
th, td { padding: 0.4rem; border: 1px solid #e5e7eb; text-align: left; }
|
|
th { background: #f9fafb; font-weight: 600; }
|
|
.card { margin: 1rem 0; padding: 1rem; border: 1px solid #e5e7eb; border-radius: 8px; background: #f9fafb; }
|
|
.card h2 { margin: 0 0 0.75rem; font-size: 1rem; }
|
|
.card canvas { max-height: 50vh; }
|
|
.donut-grid { display: flex; gap: 1rem; flex-wrap: wrap; margin: 1rem 0; justify-content: center; }
|
|
.donut-card { display: flex; flex-direction: column; align-items: center; padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; min-width: 160px; }
|
|
.donut-card canvas { max-height: 150px; max-width: 150px; }
|
|
.donut-label { font-weight: 600; font-size: 0.9rem; margin-top: 0.5rem; }
|
|
.donut-points { font-size: 1.1rem; font-weight: 700; }
|
|
.donut-hint { font-size: 0.75rem; color: #9ca3af; }
|
|
.member-grid { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
.member-card { flex: 1; min-width: 160px; padding: 0.8rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; text-decoration: none; color: inherit; display: block; }
|
|
.member-card strong { display: block; }
|
|
.hint { font-size: 0.8rem; color: #9ca3af; }
|
|
ul { list-style: none; padding: 0; }
|
|
li { padding: 0.25rem 0; }
|
|
</style>
|