|
|
|
@@ -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 {
|
|
|
|
|