enhance with charts and home layout, and make more mobile friendly
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user