Files
famdone/frontend/src/routes/[fam]/[username]/+page.svelte
T
2026-09-23 09:43:25 +01:00

3419 lines
92 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { onMount } from 'svelte';
import { famStore } from '$lib/stores/fam.svelte';
import { memberApi } from '$lib/client/api';
import { formatDDMMYY, formatHumanDate } from '$lib/format';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import {
checkCircleIcon,
revokeIcon,
circleIcon,
targetIcon,
sunIcon,
calendarIcon,
lockIcon,
clockIcon,
giftIcon,
handCoinsIcon,
coinsIcon,
walletIcon,
chevronLeftIcon,
chevronRightIcon
} from '$lib/components/icons';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
import PiggyBank from '@lucide/svelte/icons/piggy-bank';
import { handleOf } from '@shared/slugify';
import {
weekStart as tzWeekStart,
addDaysStr,
todayInTz,
weekdayInTz,
wallClockToUtc,
resolveTz,
isCompleteForPeriod,
bonusWindow,
completionInWindow
} from '@shared/timezone';
let { data } = $props();
let role = $state(data.role || 'child');
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
let username = $derived(page.params.username);
// Access gate (frontend-only). When the fam is paused/disabled the member
// kanban interactions are locked — toggles no-op and the board renders empty.
let accessDisabled = $derived(!!page.data.famAccess?.disabled);
// ─── Family timezone (resolved) ───
const rawFamTz = $derived(data.timezone || data.fam?.timezone || 'auto');
const famTz = $derived(resolveTz(rawFamTz));
// ─── Parent View (admin overview) ───
let summary = $state(data.summary);
let today = $derived(todayInTz(famTz));
let toast = $state('');
let parentMembers = $derived(famStore.initialized ? famStore.members : data.members || []);
let parentTemplates = $derived(famStore.initialized ? famStore.templates : data.templates || []);
let parentAssigned = $derived(famStore.initialized ? famStore.assigned : data.assigned || []);
let parentRewards = $derived(famStore.initialized ? famStore.rewards : data.rewards || []);
let parentCompletions = $derived(
famStore.initialized ? famStore.completions : data.completions || []
);
let parentBonusConfigs = $derived(
famStore.initialized ? famStore.bonusConfigs : data.bonusConfigs || []
);
// Admin overview stat tiles (summed across this week's member summaries).
let adminTiles = $derived.by(() => {
const sums = summary?.summaries || [];
return {
members: parentMembers.length,
pts: sums.reduce((t: number, s: any) => t + (Number(s.pointsEarned) || 0), 0),
money: sums.reduce((t: number, s: any) => t + (Number(s.moneyEarned) || 0), 0),
chores: sums.reduce((t: number, s: any) => t + (Number(s.choresCompleted) || 0), 0)
};
});
function todayCompletionsFor(memberId: string) {
return parentCompletions.filter(
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today
);
}
// Yesterday's completions surface in the admin view for revoke — but never
// from the week's first day (yesterday would be payday = last week).
let yesterdayParent = $derived(addDaysStr(today, -1));
let yesterdayParentInWeek = $derived(
!!summary?.weekStart ? yesterdayParent >= (summary.weekStart as string).slice(0, 10) : true
);
function yesterdayCompletionsFor(memberId: string) {
if (!yesterdayParentInWeek) return [];
return parentCompletions.filter(
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === yesterdayParent
);
}
function choreNameFor(assignedChoreId: string): string {
const a = parentAssigned.find((x: any) => x.id === assignedChoreId);
if (!a) return '?';
const t = parentTemplates.find((x: any) => x.id === a.templateId);
return a.customName || t?.name || '?';
}
function memberInSummary(memberId: string) {
return summary?.summaries?.find((s: any) => s.memberId === memberId);
}
function claimableRewards() {
return parentRewards.filter(
(r: any) =>
r.rewardType !== 'points' && (r.status === 'unclaimed' || r.status === 'requested')
);
}
let hasClaimableRewards = $derived(claimableRewards().length > 0);
function memberClaimable(memberId: string) {
return claimableRewards().filter((r: any) => r.memberId === memberId);
}
function memberRequested(memberId: string) {
return parentRewards.filter(
(r: any) => r.rewardType !== 'points' && r.memberId === memberId && r.status === 'requested'
);
}
function memberOutstanding(memberId: string) {
return parentRewards.filter(
(r: any) => r.rewardType !== 'points' && r.memberId === memberId && r.status === 'unclaimed'
);
}
let _confetti: any;
async function fire() {
if (!_confetti) _confetti = await import('@hiseb/confetti').then((m) => m.default);
_confetti({
count: 80,
size: 4,
velocity: 500,
fade: true,
position: { x: window.innerWidth / 2, y: 0 }
});
}
function showToast(msg: string) {
toast = msg;
setTimeout(() => (toast = ''), 4000);
}
function handleResult({ result }: any, onSuccess: ((d: any) => void) | null = null) {
const d = result.data || {};
if (d.error) showToast(d.error);
else if (result.type === 'success') {
if (onSuccess) onSuccess(d);
fire();
}
}
// Same "Bonus name – prize" split as the ledger, so prize rewards don't
// duplicate the full label in both columns of a claim row.
function rewardLabelParts(r: any): { name: string; prize: string } {
const label = r.label || '';
const idx = label.indexOf(' – ');
if (idx === -1) return { name: label, prize: label };
return { name: label.slice(0, idx), prize: label.slice(idx + 3) };
}
function rewardLabel(r: any): string {
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`;
return rewardLabelParts(r).prize;
}
function manualConfigs() {
return parentBonusConfigs.filter((b: any) => {
if (!(b.status === 'active' && b.type === 'manual')) return false;
if (b.occurrence === 'once') {
const hasReward = parentRewards.some(
(r: any) => r.bonusConfigId === b.id && (!b.memberId || r.memberId === b.memberId)
);
if (hasReward) return false;
}
return true;
});
}
// Goals completed feed for the admin overview — this week's materialized
// bonus rewards (prize / points / cash), newest first. Undoing the
// underlying chore/points deletes the unclaimed reward server-side, so
// this list shrinks again in realtime via famStore.
let parentGoalsCompleted = $derived.by(() => {
const ws = (summary?.weekStart as string) || today;
return parentRewards
.filter((r: any) => r.bonusConfigId && (r.date || '').slice(0, 10) >= ws)
.map((r: any) => {
const cfg = parentBonusConfigs.find((c: any) => c.id === r.bonusConfigId);
const m = parentMembers.find((x: any) => x.id === r.memberId);
return {
id: r.id,
goalName: cfg?.name || r.label,
memberName: m?.name || 'Unknown',
color: m?.color || '#6366f1',
rewardText:
r.rewardType === 'cash'
? `£${Number(r.value).toFixed(2)}`
: r.rewardType === 'points'
? `${r.value} pts`
: cfg?.rewardValue || r.label,
date: (r.date || '').slice(0, 10)
};
})
.sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
.slice(0, 10);
});
// ─── Child View (kanban) ───
let memberId = $state(data.memberId || '');
let pbToken = $state(data.token || '');
let famId = $state(data.famId || '');
let memberName = $state(data.memberName || '');
let memberColor = $state(data.memberColor || '#6366f1');
// Party mascot: live member record first (updates without re-login),
// server load as fallback, pig as default.
let partyEmoji = $derived(
(famStore.initialized
? (famStore.members as any[]).find((m) => m.id === memberId)?.partyEmoji
: null) ||
(data as any).partyEmoji ||
'🐖'
);
let editingName = $state(false);
let nameInput = $state('');
let showColorPicker = $state(false);
let templates = $derived(
famStore.initialized
? (famStore.templates as ChoreTemplate[])
: (data.templates as ChoreTemplate[])
);
let assigned = $derived(
(famStore.initialized
? (famStore.assigned as AssignedChore[])
: (data.assigned as AssignedChore[])
).map((a) => (a.memberId === '' ? { ...a, memberId: null } : a))
);
let completions = $derived(
famStore.initialized
? (famStore.completions as Completion[])
: (data.completions as Completion[])
);
let rewards = $derived(
famStore.initialized ? (famStore.rewards as Reward[]) : (data.rewards as Reward[])
);
let bonusConfigs = $derived(
famStore.initialized
? (famStore.bonusConfigs as BonusConfig[])
: (data.bonusConfigs as BonusConfig[])
);
let loading = $state(true);
let error = $state('');
let claimError = $state('');
let todayChild = $derived(todayInTz(famTz));
let togglingIds = $state<Set<string>>(new Set());
// ─── Payday countdown (child view) ───
let nowMs = $state(Date.now());
let eowFired = $state(false);
// Nyan-style payday party: rainbow banner + confetti when payday unlocks.
let paydayParty = $state(false);
let partyTimer: ReturnType<typeof setTimeout> | undefined;
function startPaydayParty() {
paydayParty = true;
fire();
if (partyTimer) clearTimeout(partyTimer);
partyTimer = setTimeout(() => (paydayParty = false), 15000);
}
$effect(() => {
const id = setInterval(() => (nowMs = Date.now()), 1000);
return () => clearInterval(id);
});
const paydayDay = $derived(data.payday != null ? Number(data.payday) : 1);
const paydayTime = $derived(data.paydayTime || '18:00');
const isPaydayToday = $derived(weekdayInTz(new Date(), famTz) === paydayDay);
const paydayTarget = $derived.by(() =>
new Date(wallClockToUtc(todayInTz(famTz), paydayTime, famTz)).getTime()
);
const secondsLeft = $derived(Math.max(0, Math.floor((paydayTarget - nowMs) / 1000)));
const countdownHours = $derived(Math.floor(secondsLeft / 3600));
const countdownMinutes = $derived(Math.floor((secondsLeft % 3600) / 60));
const countdownSecs = $derived(secondsLeft % 60);
const showCountdown = $derived(isPaydayToday && secondsLeft > 0);
$effect(() => {
if (role !== 'child' || !isPaydayToday) return;
if (secondsLeft > 0 || eowFired) return;
if (!pbToken || !famId) return;
eowFired = true;
memberApi.payday(famId).catch(() => {});
startPaydayParty();
});
function paydayWeekStart(): string {
return tzWeekStart(paydayDay, famTz);
}
function addDays(dateStr: string, days: number): string {
return addDaysStr(dateStr, days);
}
function monthStartStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
}
const todayIso = todayInTz(famTz);
const currentWeek = paydayWeekStart();
let weekStart = $state(currentWeek);
let weekEnd = $derived(addDays(weekStart, 6));
const isCurrentWeek = $derived(weekStart === currentWeek);
// ─── Yesterday catch-up mode (child only) ───
// Before 12pm fam-local, and only when yesterday is still inside the
// current week (never across payday — e.g. payday Sunday → no yesterdays
// on Monday), the hero offers a "forgot yesterday?" button that flips the
// kanban to yesterday's date so missed chores can be marked late.
let yesterdayStr = $derived(addDaysStr(todayChild, -1));
let yesterdayInWeek = $derived(yesterdayStr >= currentWeek);
let famHour = $derived.by(() => {
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: famTz,
hour: 'numeric',
hour12: false
}).formatToParts(new Date(nowMs));
return Number(parts.find((p) => p.type === 'hour')?.value ?? 0) % 24;
} catch {
return new Date(nowMs).getHours();
}
});
let yesterdayAvailable = $derived(role === 'child' && yesterdayInWeek && famHour < 12);
let viewDate = $state<'today' | 'yesterday'>('today');
// Viewed day for the kanban: today normally, yesterday in catch-up mode.
let activeDate = $derived(
viewDate === 'yesterday' && yesterdayAvailable ? yesterdayStr : todayChild
);
let inYesterday = $derived(activeDate === yesterdayStr && yesterdayAvailable);
// Snap back to today when the mode stops being valid (past noon, or the
// day rolled over) so stale yesterday state can never linger.
$effect(() => {
if (!yesterdayAvailable && viewDate === 'yesterday') viewDate = 'today';
});
const daysLeft = $derived(
Math.max(
0,
Math.round(
// Payday closes the week on weekStart+6 (weeks run payday+1..payday).
(new Date(addDays(weekStart, 6) + 'T00:00:00').getTime() -
new Date(todayIso + 'T00:00:00').getTime()) /
86400000
)
)
);
let memberChores = $derived.by(() => {
if (role === 'parent' || !memberId) return [];
const activeSeasonIds = new Set(famStore.seasons.filter((s) => s.active).map((s) => s.id));
return assigned.filter((a) => {
if (a.memberId !== null && a.memberId !== memberId) return false;
if (a.isTodo) return false;
if (!a.seasonIds || a.seasonIds.length === 0) return true;
if (activeSeasonIds.size === 0) return true;
return a.seasonIds.some((sid) => activeSeasonIds.has(sid));
});
});
// Todos — one-off items with deadlines (sorted: soonest first)
let memberTodos = $derived.by(() =>
assigned
.filter((a) => (a.memberId === null || a.memberId === memberId) && a.isTodo)
.sort((a, b) => {
const da = a.completeBy || '9999-99-99';
const db = b.completeBy || '9999-99-99';
if (da !== db) return da.localeCompare(db);
return (a.startDate || '').localeCompare(b.startDate || '');
})
);
// Celebration badge — completed todos from the last 24h, dismissed per-device
// via localStorage (no PB storage; auto-expires after 24h).
let celebrateDismissed = $state<string[]>([]);
if (typeof localStorage !== 'undefined') {
celebrateDismissed = JSON.parse(localStorage.getItem('celebrateDismissed') || '[]');
}
function dismissCelebrate(id: string) {
if (!celebrateDismissed.includes(id)) {
celebrateDismissed = [...celebrateDismissed, id];
try {
localStorage.setItem('celebrateDismissed', JSON.stringify(celebrateDismissed));
} catch {}
}
}
let celebrations = $derived.by(() => {
const cutoff = Date.now() - 24 * 3600 * 1000;
return completions
.filter((c: Completion) => {
if (c.id.startsWith('optimistic-')) return false;
if (c.memberId !== memberId) return false;
const at = c.completedAt ? new Date(c.completedAt).getTime() : 0;
if (!at || at < cutoff) return false;
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return !!chore?.isTodo && !celebrateDismissed.includes(c.id);
})
.map((c: Completion) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
const emoji =
chore?.type === 'emoji' ? chore.emoji || '🎉' : chore?.type === 'money' ? '💸' : '⭐';
return { id: c.id, emoji, name: chore?.customName || 'Todo' };
});
});
let dailyPending = $derived(
memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, activeDate))
);
let weeklyPending = $derived(
memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, activeDate))
);
// Traffic light: days until todo deadline (only non-emoji todos)
function todoUrgency(todo: AssignedChore): 'blue' | 'green' | 'orange' | 'red' | 'black' {
if (!todo.completeBy) return 'blue';
const days = Math.round(
(new Date(todo.completeBy + 'T00:00:00').getTime() -
new Date(todayChild + 'T00:00:00').getTime()) /
86400000
);
if (days <= 0) return 'black';
if (days === 1) return 'red';
if (days === 2) return 'orange';
if (days === 3) return 'green';
return 'blue';
}
let completedToday = $derived(
completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === activeDate)
);
let weeklyTotal = $derived.by(() => {
// Must match the pocket-money % threshold definition (and the family
// dashboard Done denominator): only the member's own chores count —
// personal assignments + shared chores they claimed. Unclaimed shared
// chores stay doable in the kanban but out of every denominator, so
// all views agree instead of drifting (e.g. 1/37 here vs 1/30 there).
const chores = assigned.filter((a) => a.memberId === memberId && !a.isTodo);
const periodDays =
Math.round(
(new Date(weekEnd + 'T00:00:00').getTime() - new Date(weekStart + 'T00:00:00').getTime()) /
86400000
) + 1;
return chores.reduce((sum, a) => sum + (a.frequency === 'daily' ? periodDays : 1), 0);
});
let weekCompletions = $derived(
completions.filter(
(c) =>
c.memberId === memberId &&
(c.date?.slice(0, 10) || c.date) >= weekStart &&
(c.date?.slice(0, 10) || c.date) <= weekEnd
)
);
// Viewed-week daily activity for the hero background effect: completions
// per day across the 7-day window, plotted as a blurred white line.
let heroWeekActivity = $derived.by(() => {
const counts: number[] = [];
for (let i = 0; i < 7; i++) {
const d = addDays(weekStart, i);
counts.push(
weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length
);
}
return counts;
});
let heroSpark = $derived.by(() => {
const max = Math.max(1, ...heroWeekActivity);
const pts = heroWeekActivity.map((v, i) => ({
x: 20 + i * ((700 - 40) / 6),
y: 100 - (v / max) * 72
}));
return {
line: pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '),
pts
};
});
let weeklyDone = $derived(weekCompletions.length);
let weeklyPct = $derived(weeklyTotal > 0 ? Math.round((weeklyDone / weeklyTotal) * 100) : 0);
// Pocket-money target as a % of chores, for the layered target bar under the
// hero progress. Only meaningful for a percent threshold (points targets
// can't be expressed as a fraction of chores).
let pmTargetPct = $derived.by(() => {
const pm = bonusConfigs.find(
(c) => c.isPocketMoney && c.status === 'active' && (!c.memberId || c.memberId === memberId)
);
if (!pm || pm.thresholdType !== 'percent') return null;
return Math.min(100, Math.max(0, Number(pm.criteriaValue) || 0));
});
let weekRewards = $derived(
rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart)
);
// Money-type todos auto-create a matching cash reward when completed (see
// completions.toggle). Those rewards are already counted via the chore
// completions below, so skip any claimed reward that a completion linked to
// (via rewardId) to avoid double-counting the same earning event.
let autoRewardIds = $derived(
new Set(completions.map((c) => (c as any).rewardId).filter((id) => !!id))
);
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' &&
!autoRewardIds.has(r.id)
)
.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' &&
!autoRewardIds.has(r.id)
)
.reduce((sum, r) => sum + Number(r.value), 0)
);
// Week-to-date totals from chore completions (not rewards — rewards are created at EOW)
let weekChoreCash = $derived.by(() =>
weekCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
}, 0)
);
// Week's cash-reward earnings (pocket money, bonuses) count while still
// pending AND once claimed — auto-created money-todo rewards are skipped
// (already counted via the chore completion above).
let weekRewardCash = $derived.by(() =>
weekRewards
.filter((r) => r.rewardType === 'cash' && !autoRewardIds.has(r.id))
.reduce((sum, r) => sum + Number(r.value), 0)
);
let weekCashTotal = $derived(weekChoreCash + weekRewardCash);
let weekChorePoints = $derived.by(() =>
weekCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0)
);
let pendingCash = $derived.by(() =>
weekRewards
.filter((r) => r.rewardType === 'cash' && r.status !== 'claimed')
.reduce((sum, r) => sum + Number(r.value), 0)
);
// Money owed to this member across all time (incl. carry-over from past weeks).
// Payday-gated rewards are excluded until their settleDate — the wallet shows
// them separately as a locked "pays out on payday" line.
let owedCash = $derived.by(() =>
rewards
.filter(
(r) =>
r.memberId === memberId &&
r.rewardType === 'cash' &&
r.status !== 'claimed' &&
!paydayLocked(r)
)
.reduce((sum, r) => sum + Number(r.value), 0)
);
let weekPointsEarned = $derived.by(() =>
weekRewards
.filter((r) => r.rewardType === 'points')
.reduce((sum, r) => sum + Number(r.value), 0)
);
const pendingRewards = $derived(
rewards.filter(
(r) =>
r.memberId === memberId &&
r.rewardType !== 'points' &&
(r.status === 'unclaimed' || r.status === 'requested')
)
);
// Payday-gated bonus rewards unlock on their settleDate at the fam's payday
// time (stamped server-side) — not midnight on the day.
function paydayLocked(r: any) {
if (r.claimable !== 'payday' || !r.settleDate) return false;
const settle = (r.settleDate as string).slice(0, 10);
if (todayChild < settle) return true;
if (todayChild > settle) return false;
return nowMs < new Date(wallClockToUtc(settle, paydayTime, famTz)).getTime();
}
let weekBonusTallies = $derived.by(() => {
const map = new Map<string, number>();
for (const r of weekRewards) {
if (r.memberId !== memberId) continue;
const name = bonusConfigs.find((b: BonusConfig) => b.id === r.bonusConfigId)?.name || r.label;
map.set(name, (map.get(name) || 0) + 1);
}
// Badge every bonus earned this week — including a single occurrence
// (×1): the hero is the child's only at-a-glance signal, so nothing
// earned should stay invisible here.
return [...map.entries()].map(([name, count]) => ({ name, count }));
});
let anyWeekBonuses = $derived(weekRewards.length > 0);
// ─── Threshold Goals (bonus configs the member is chasing) ───
let thresholdGoals = $derived.by(() => {
// Member's own chores (non-todo) — personal + claimed shared, never
// unclaimed shared — used to compute period potential. Matches
// weeklyTotal above, the family dashboard, and the server's % definition.
const myChores = assigned.filter((a) => a.memberId === memberId && !a.isTodo);
return bonusConfigs
.filter((cfg) => {
if (cfg.status !== 'active') return false;
if (cfg.type === 'manual') return false;
if (cfg.target !== 'individual') return false;
return !cfg.memberId || cfg.memberId === memberId;
})
.map((cfg) => {
const win = bonusWindow(cfg, paydayDay, famTz);
const pStart = win.from || '';
const pEnd = win.to || '';
const periodDays =
pStart && pEnd
? Math.round(
(new Date(pEnd + 'T00:00:00').getTime() -
new Date(pStart + 'T00:00:00').getTime()) /
86400000
) + 1
: 1;
const periodCompletions = completions.filter(
(c) => c.memberId === memberId && completionInWindow(c, win)
);
const isPercent = cfg.thresholdType === 'percent';
const isCount = cfg.type === 'count';
// Resolve the per-member target chore (multi-target map wins,
// then the legacy scalar targetChoreId).
const memberTargetChoreId =
(cfg.targetChoreIds && cfg.targetChoreIds[memberId]) || cfg.targetChoreId || '';
// Count-type rewards pinned to a single chore scoped their count
// (and the period's reclaimable potential) to that chore only.
const countChores =
isCount && memberTargetChoreId
? myChores.filter((a) => a.id === memberTargetChoreId)
: myChores;
const totalPotential = countChores.reduce(
(sum, a) => sum + (a.frequency === 'daily' ? periodDays : 1),
0
);
let current = 0;
if (isPercent) {
current = periodCompletions.length;
} else if (isCount) {
current = memberTargetChoreId
? periodCompletions.filter((c) => c.assignedChoreId === memberTargetChoreId).length
: periodCompletions.length;
} else {
current = periodCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
}
const configured = Number(cfg.criteriaValue) || 0;
// Percent thresholds resolve to an absolute count against the
// period's potential (0 when no chores are assigned).
const target = isPercent ? Math.round((configured / 100) * totalPotential) : configured;
// Display percent = completed ÷ period potential (not ÷ threshold),
// so a 1-of-7 week reads 14%, not 25%.
const ratio = totalPotential > 0 ? current / totalPotential : 0;
const pct = isPercent
? totalPotential > 0
? Math.min(100, Math.round(ratio * 100))
: 0
: target > 0
? Math.min(100, Math.round((current / target) * 100))
: 0;
const existingReward = rewards.find(
(r) => r.bonusConfigId === cfg.id && r.memberId === memberId
);
// An earned reward only keeps lighting up the card while it's fresh
// (earned in the viewed week) or still outstanding (not yet claimed).
// Once claimed and the week rolls over, "Reward earned" disappears.
const rDate = (existingReward?.date || '').slice(0, 10);
const rewardInWeek = !!rDate && rDate >= weekStart && rDate <= weekEnd;
const rewardOutstanding = !!existingReward && existingReward.status !== 'claimed';
const achieved = existingReward
? rewardInWeek || rewardOutstanding
: isPercent
? totalPotential > 0 && ratio * 100 >= configured
: target > 0 && current >= target;
const unit = isPercent ? '% of chores' : isCount ? 'chores' : 'pts';
const targetLabel = `${configured} ${unit}`;
const targetPct = isPercent ? Math.min(100, Math.max(0, configured)) : 100;
const targetChore = isCount ? countChores[0] : null;
return {
config: cfg,
current,
criteriaValue: configured,
targetPct,
targetLabel,
target,
totalPotential,
isPercent,
targetChoreName: targetChore
? targetChore.customName ||
templates.find((t) => t.id === targetChore.templateId)?.name ||
''
: '',
pct,
achieved,
rewardStatus: existingReward?.status || null,
periodStart: pStart,
periodEnd: pEnd
};
});
});
// ─── Goal celebrations (bonus rewards achieved) ───
// A goal flips to achieved the moment its completions cross the threshold.
// We celebrate once per goal per day (localStorage, so the notice survives
// reloads but expires at the next 00:00), re-arm when the achievement is
// revoked (chore/points undone), and always show the reward value/prize.
const goalCelebratedKey = `goalCelebrated:${famId}`;
let goalCelebrated = $state<Record<string, string>>(
typeof localStorage !== 'undefined'
? (JSON.parse(localStorage.getItem(goalCelebratedKey) || '{}') as Record<string, string>)
: {}
);
let goalCelebrations = $state<{ id: string; name: string; reward: string }[]>([]);
function goalRewardLabel(g: { config: any }): string {
const cfg = g.config;
if (cfg.rewardType === 'cash') return `£${Number(cfg.rewardValue).toFixed(2)}`;
if (cfg.rewardType === 'prize') return cfg.rewardValue || 'prize';
return `${cfg.rewardValue} pts`;
}
function dismissGoalCelebrate(id: string) {
goalCelebrations = goalCelebrations.filter((c) => c.id !== id);
goalCelebrated = { ...goalCelebrated, [id]: todayChild };
try {
localStorage.setItem(goalCelebratedKey, JSON.stringify(goalCelebrated));
} catch {}
}
$effect(() => {
if (role !== 'child') return;
const today = todayChild;
let next: Record<string, string> = { ...goalCelebrated };
let changed = false;
// Yesterday's celebrations expire — a notice lives for its day only.
for (const [id, day] of Object.entries(next)) {
if (day !== today) {
delete next[id];
changed = true;
}
}
for (const g of thresholdGoals) {
if (g.achieved && next[g.config.id] !== today) {
next[g.config.id] = today;
changed = true;
goalCelebrations = [
{ id: g.config.id, name: g.config.name, reward: goalRewardLabel(g) },
...goalCelebrations.filter((c) => c.id !== g.config.id)
];
fire();
} else if (!g.achieved && next[g.config.id]) {
// Achievement revoked (chore/points undone) — drop the notice and
// re-arm so completing it again celebrates again.
delete next[g.config.id];
changed = true;
goalCelebrations = goalCelebrations.filter((c) => c.id !== g.config.id);
}
}
if (changed) {
goalCelebrated = next;
try {
localStorage.setItem(goalCelebratedKey, JSON.stringify(next));
} catch {}
}
});
function shortDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
const day = String(d.getDate()).padStart(2, '0');
const mon = String(d.getMonth() + 1).padStart(2, '0');
return `${day}/${mon}/${String(d.getFullYear()).slice(2)}`;
}
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
function headDate(): Date {
return new Date(activeDate + 'T00:00:00');
}
function headDay(): number {
return headDate().getDate();
}
function headDayOrdinal(): string {
const n = headDay();
if (n % 100 >= 11 && n % 100 <= 13) return `${n}th`;
const s = ['th', 'st', 'nd', 'rd'];
return `${n}${s[n % 10] || s[0]}`;
}
function headWeekday(): string {
return headDate().toLocaleDateString(undefined, { weekday: 'long' });
}
function headMonth(): string {
const d = headDate();
return `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
function navigate(amount: number) {
weekStart = addDays(weekStart, amount * 7);
}
function jumpToNow() {
weekStart = currentWeek;
}
onMount(async () => {
if (role === 'parent') return;
if (data.verified && data.famId && data.memberId) {
memberId = data.memberId;
famId = data.famId;
loading = false;
} else {
error = 'Please open your invite link to get access.';
loading = false;
}
});
function isCompleted(assignedChoreId: string, date: string): boolean {
const a = assigned.find((x) => x.id === assignedChoreId);
// Weekly chores are "done for the week" — complete if completed any day
// in the current week, not just today.
if (a?.frequency === 'weekly') {
const dates = completions
.filter((c) => c.assignedChoreId === assignedChoreId)
.map((c) => c.date);
return isCompleteForPeriod('weekly', paydayDay, famTz, dates);
}
return completions.some(
(c) => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date
);
}
// Todos are one-off: done once the completion row exists, regardless of date.
function isTodoDone(todoId: string): boolean {
return completions.some((c) => c.assignedChoreId === todoId);
}
// A todo whose completeBy has passed without being completed is expired —
// no longer doable for the rest of the week.
function isTodoExpired(todo: AssignedChore): boolean {
if (isTodoDone(todo.id)) return false;
if (!todo.completeBy) return false;
return todo.completeBy < todayChild;
}
function findCompletion(chore: AssignedChore) {
const match = (c: Completion) =>
chore.isTodo
? c.assignedChoreId === chore.id
: chore.frequency === 'weekly'
? c.assignedChoreId === chore.id &&
(c.date?.slice(0, 10) || c.date) >= weekStart &&
(c.date?.slice(0, 10) || c.date) < addDays(weekStart, 7)
: c.assignedChoreId === chore.id && (c.date?.slice(0, 10) || c.date) === activeDate;
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
return optimistic || completions.find(match);
}
async function toggle(chore: AssignedChore) {
if (accessDisabled) return;
if (!pbToken) {
showToast('Logged out — please log in again to update chores.');
return;
}
if (chore.isTodo && isTodoExpired(chore)) return;
if (togglingIds.has(chore.id)) return;
togglingIds = new Set(togglingIds).add(chore.id);
const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, activeDate);
// Backdated (yesterday) completions are stamped for yesterday — never
// today's wall-clock time — so period/window logic can't double-count.
const stampDate = activeDate;
const stampAt =
stampDate === todayChild
? new Date().toISOString()
: new Date(wallClockToUtc(stampDate, '12:00', famTz)).toISOString();
if (wasCompleted) {
const existing = findCompletion(chore);
if (existing) {
famStore.applyRecord('completions', existing, 'delete');
}
} else {
famStore.applyRecord(
'completions',
{
id: 'optimistic-' + chore.id,
famId,
memberId,
assignedChoreId: chore.id,
date: stampDate,
completedAt: stampAt
} as Completion,
'create'
);
}
try {
await memberApi.toggleCompletion(famId, chore.id, stampDate, stampAt);
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
if (optimistic) {
famStore.applyRecord('completions', optimistic, 'delete');
}
} catch (e) {
// A stale assigned ID (parent re-saved chores while this tab was
// open in the background) leaves the chore untogglable. init() is
// a no-op when already initialized, so force a resync() to drop
// the dead card. Never re-add completions for the gone chore —
// that is what kept the error reoccurring after "refresh".
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
if (optimistic) {
famStore.applyRecord('completions', optimistic, 'delete');
}
try {
await famStore.resync();
} catch {}
const msg = e instanceof Error ? e.message : String(e);
showToast(
/CHORE_GONE/.test(msg)
? 'This chore was changed — list refreshed.'
: 'Toggle failed — please try again.'
);
console.error('Toggle failed:', e);
} finally {
const next = new Set(togglingIds);
next.delete(chore.id);
togglingIds = next;
}
}
function templateFor(chore: AssignedChore): ChoreTemplate | undefined {
return templates.find((t) => t.id === chore.templateId);
}
function choreName(chore: AssignedChore): string {
return chore.customName || templateFor(chore)?.name || 'Chore';
}
</script>
{#if role === 'parent'}
<!-- Parent View: Admin Overview -->
<ViewHeader
title={famStore.fam?.name || 'Dashboard'}
subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : ''}
hero
/>
{#if toast}
<div class="toast">{toast}</div>
{/if}
<!-- WEEK-TO-DATE TILES -->
<div class="tiles">
<div class="tile tile-members">
<span class="tile-val">{adminTiles.members}</span>
<span class="tile-lbl">members</span>
</div>
<div class="tile tile-pts">
<span class="tile-val">{adminTiles.pts}</span>
<span class="tile-lbl">points this week</span>
</div>
<div class="tile tile-cash">
<span class="tile-val">£{adminTiles.money.toFixed(2)}</span>
<span class="tile-lbl">cash this week</span>
</div>
<div class="tile tile-chores">
<span class="tile-val">{adminTiles.chores}</span>
<span class="tile-lbl">chores done</span>
</div>
</div>
<!-- GOALS COMPLETED (mini cards, above the member/trigger/claims grid) -->
<div class="goals-completed">
{#if parentGoalsCompleted.length === 0}
<p class="empty">
No goals completed yet — rewards appear here the moment a member hits a target.
</p>
{:else}
<div class="goal-mini-grid">
{#each parentGoalsCompleted as g}
<div class="goal-mini">
<div class="gm-top">
<span class="dot" style="background:{g.color}"></span>
<span class="gm-name">🎉 {g.goalName}</span>
<span class="gm-member">{g.memberName}</span>
</div>
<div class="gm-bottom">
<span class="gm-reward">{g.rewardText}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
<div class="kanban-scroll">
<div class="kanban-inner">
<CardGrid stackTablet>
<Card cols={1} title="Members">
{#each parentMembers as m}
{@const todays = todayCompletionsFor(m.id)}
{@const yesterdays = yesterdayCompletionsFor(m.id)}
{@const done = todays.length}
{@const s = memberInSummary(m.id)}
{@const pm = parentBonusConfigs.find(
(c: any) => c.isPocketMoney && c.memberId === m.id
)}
<div class="member-card">
<div class="card-header">
<span class="dot" style="background:{m.color}"></span>
<span class="member-name">{m.name}</span>
{#if !(pm && pm.rewardValue)}
<Button
href="/{famSlug}/{username}/rewards?s=pocketmoney"
variant="secondary"
size="sm">Pocket money</Button
>
{/if}
</div>
<div class="stats">
<span>Points: {s?.pointsEarned ?? 0}</span>
<span>Money: £{(s?.moneyEarned ?? 0).toFixed(2)}</span>
</div>
<div class="pocket-row">
{#if pm && pm.rewardValue}
<span
>Pocket money: £{Number(pm.rewardValue).toFixed(2)} · {pm.criteriaValue || 0}%
of chores</span
>
{:else}
<span class="unset">Pocket money: amount not set</span>
{/if}
</div>
{#if done > 0}
<ul class="done-list">
{#each todays as c, i}
<li class="done-item" class:odd={i % 2 === 1}>
<span class="done-icon">{@html checkCircleIcon}</span>
<span class="done-name">{choreNameFor(c.assignedChoreId)}</span>
<form
method="POST"
action="?/revoke"
use:enhance={() => {
return async (args) => handleResult(args);
}}
class="revoke-form"
>
<input type="hidden" name="id" value={c.id} />
<button type="submit" class="revoke-btn" title="Revoke"
>{@html revokeIcon}</button
>
</form>
</li>
{/each}
</ul>
{/if}
{#if yesterdays.length > 0}
<p class="section-label">Yesterday</p>
<ul class="done-list yesterday-list">
{#each yesterdays as c, i}
<li class="done-item" class:odd={i % 2 === 1}>
<span class="done-icon">{@html checkCircleIcon}</span>
<span class="done-name">{choreNameFor(c.assignedChoreId)}</span>
<span class="yesterday-tag">yesterday</span>
<form
method="POST"
action="?/revoke"
use:enhance={() => {
return async (args) => handleResult(args);
}}
class="revoke-form"
>
<input type="hidden" name="id" value={c.id} />
<button type="submit" class="revoke-btn" title="Revoke"
>{@html revokeIcon}</button
>
</form>
</li>
{/each}
</ul>
{/if}
</div>
{/each}
</Card>
<Card cols={1} title="Triggers">
{#if manualConfigs().length === 0}
<p class="empty">No manual bonus configs.</p>
{:else}
{#each manualConfigs() as bc}
{@const val =
bc.rewardType === 'cash'
? `£${Number(bc.rewardValue).toFixed(2)}`
: bc.rewardType === 'points'
? `${bc.rewardValue} pts`
: bc.rewardValue}
{@const targeted = bc.memberId
? parentMembers.find((m: any) => m.id === bc.memberId)
: null}
<div class="trigger-card">
<div class="trigger-head">
<span class="trigger-name">{bc.name}</span>
<span class="trigger-value">{val}</span>
</div>
{#if targeted}
<form
method="POST"
action="?/trigger"
use:enhance={() => {
return async (args: any) => handleResult(args);
}}
>
<input type="hidden" name="configId" value={bc.id} />
<input type="hidden" name="memberId" value={targeted.id} />
<div class="trigger-row">
<span class="trigger-target" style="color:{targeted.color}"
>■ {targeted.name}</span
>
<Button type="submit" size="sm" variant="primary">Award</Button>
</div>
</form>
{:else}
<form
method="POST"
action="?/trigger"
use:enhance={() => {
return async (args: any) => handleResult(args);
}}
>
<input type="hidden" name="configId" value={bc.id} />
<div class="trigger-row">
<select name="memberId" class="trigger-select">
<option value="">Select member</option>
{#each parentMembers as m}
<option value={m.id}>{m.name}</option>
{/each}
</select>
<Button type="submit" size="sm" variant="primary">Award</Button>
</div>
</form>
{/if}
</div>
{/each}
{/if}
</Card>
<Card cols={1} title="Claims" accent={hasClaimableRewards ? '#f97316' : undefined} class={hasClaimableRewards ? 'claims-card' : ''}>
{#if claimableRewards().length === 0}
<div class="claims-empty">
{@html checkCircleIcon}<span>No outstanding claims</span>
</div>
{:else}
{@const familyOwed = claimableRewards()
.filter((r: any) => r.rewardType === 'cash')
.reduce((s: number, r: any) => s + Number(r.value), 0)}
{@const pendingCount = claimableRewards().filter(
(r: any) => r.status === 'requested'
).length}
<div class="claims-summary">
<div class="claims-stat">
<span class="claims-stat-icon">{@html handCoinsIcon}</span>
<div>
<div class="claims-stat-value">£{familyOwed.toFixed(2)}</div>
<div class="claims-stat-label">Total owed</div>
</div>
</div>
<div class="claims-stat pending">
<span class="claims-stat-icon pending">{@html clockIcon}</span>
<div>
<div class="claims-stat-value">{pendingCount}</div>
<div class="claims-stat-label">Awaiting approval</div>
</div>
</div>
</div>
{#each parentMembers as m}
{@const requested = memberRequested(m.id)}
{@const outstanding = memberOutstanding(m.id)}
{@const claimable = memberClaimable(m.id)}
{@const cashClaimable = claimable.filter((r: any) => r.rewardType === 'cash')}
{@const cashRequested = requested.filter((r: any) => r.rewardType === 'cash')}
{@const requestedTotal = cashRequested.reduce(
(sum: number, r: any) => sum + Number(r.value),
0
)}
{@const total = cashClaimable.reduce(
(sum: number, r: any) => sum + Number(r.value),
0
)}
{@const hasClaims = requested.length > 0 || outstanding.length > 0}
{#if hasClaims}
<div class="member-claims">
<div class="member-claims-head">
<span class="dot" style="background:{m.color}"></span>
<span class="member-name">{m.name}</span>
{#if total > 0}
<span class="member-owed">£{total.toFixed(2)} owed</span>
{/if}
</div>
{#if requested.length > 0}
<p class="section-label">Requested</p>
{#each requested as r}
<form
class="flex flex-wrap justify-between items-center"
method="POST"
action="?/claim"
use:enhance={() => {
return async (args: any) => handleResult(args);
}}
>
<input type="hidden" name="id" value={r.id} />
<div class="flex gap-4">
<span class="claim-icon"
>{@html r.rewardType === 'cash' ? coinsIcon : giftIcon}</span
>
<span class="claim-label">{rewardLabelParts(r).name}</span>
<span class="claim-value">{rewardLabel(r)}</span>
</div>
<Button type="submit" size="sm" variant="primary">Issue</Button>
</form>
{/each}
{/if}
{#if outstanding.length > 0}
<p class="section-label">Outstanding</p>
{#each outstanding as r}
<div class="claim-item outstanding">
<span class="claim-icon"
>{@html r.rewardType === 'cash' ? coinsIcon : giftIcon}</span
>
<span class="claim-label">{rewardLabelParts(r).name}</span>
<span class="claim-value">
{rewardLabel(r)}
{#if paydayLocked(r)}
<span class="owe-locked"
>{@html lockIcon} {formatHumanDate(r.settleDate)}</span
>
{/if}
</span>
</div>
{/each}
{/if}
<!-- Issue All: only when payday has run (rewards are requested) -->
{#if cashRequested.length > 0}
<form
class="issue-all-form flex items-center [&>button]:w-full mt-4"
method="POST"
action="?/issueAll"
use:enhance={() => {
return async (args: any) => handleResult(args);
}}
>
<input type="hidden" name="memberId" value={m.id} />
<Button type="submit" variant="success"
>Issue all · £{requestedTotal.toFixed(2)}</Button
>
</form>
{/if}
</div>
{/if}
{/each}
{/if}
</Card>
</CardGrid>
</div>
</div>
{:else}
<!-- Child View -->
{#if loading}
<p class="empty">Loading...</p>
{:else if error}
<p class="error">{error}</p>
<p class="empty">
<Button href={`/${famSlug}/join/${username}`} variant="primary" size="md">Join</Button>
</p>
{:else}
{#if owedCash > 0}
<div class="payday-banner">
🎉 You've earned <b>£{owedCash.toFixed(2)}</b> — go get it from your parent!
</div>
{/if}
{#if showCountdown}
<div class="payday-countdown">
<div class="pd-icon"><PiggyBank size={44} strokeWidth={2} /></div>
<div class="pd-body">
<p class="pd-title">Payday is today!</p>
<p class="pd-num">
{#if countdownHours > 0}
{countdownHours}h {countdownMinutes}m left
{:else}
{countdownMinutes}m {countdownSecs}s left
{/if}
</p>
<p class="pd-sub">Your earnings get settled at {paydayTime}</p>
</div>
</div>
{/if}
{#if paydayParty}
<div class="payday-party" role="status" onclick={() => (paydayParty = false)}>
<span class="party-cat">{partyEmoji}</span>
<div class="party-body">
<p class="party-title">PAYDAY!!</p>
<p class="party-sub">Your cash is ready — go collect it! 🎉</p>
</div>
<span class="party-stars">🌈⭐🌈</span>
</div>
{/if}
<!-- HERO -->
<header class="hero">
<svg
class="hero-bg"
viewBox="0 0 700 120"
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline
points={heroSpark.line}
fill="none"
stroke="#fff"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
{#each heroSpark.pts as p}
<circle cx={p.x} cy={p.y} r="5" fill="#fff" />
{/each}
</svg>
<div class="hero-top">
<div class="hero-id">
<span
class="color-dot hero-dot"
style="background:{memberColor}"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
showColorPicker = !showColorPicker;
}}
onkeydown={(e) => e.key === 'Enter' && (showColorPicker = !showColorPicker)}
></span>
{#if editingName}
<input
type="text"
bind:value={nameInput}
class="name-edit name-edit-dark"
onkeydown={async (e: KeyboardEvent) => {
if (e.key === 'Enter') {
editingName = false;
if (nameInput && nameInput !== memberName) {
const old = memberName;
memberName = nameInput;
try {
const res = await fetch('/api/members', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nameInput })
});
if (!res.ok) memberName = old;
} catch {
memberName = old;
}
}
} else if (e.key === 'Escape') {
editingName = false;
nameInput = memberName;
}
}}
onblur={() => {
editingName = false;
nameInput = memberName;
}}
autofocus
/>
{:else}
<span
class="hero-name"
role="button"
tabindex="0"
onclick={() => {
nameInput = memberName;
editingName = true;
}}
onkeydown={(e) =>
e.key === 'Enter' && ((nameInput = memberName), (editingName = true))}
>
{memberName || username}
</span>
{/if}
</div>
<div class="hero-date">
<span class="hero-weekday">{headWeekday()}</span>
<span class="hero-day">{headDayOrdinal()}</span>
<span class="hero-month">{headMonth()}</span>
</div>
</div>
{#if showColorPicker}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="color-picker-overlay" onclick={() => (showColorPicker = false)}>
<div class="color-picker-popup" onclick={(e) => e.stopPropagation()}>
<p>Pick a color:</p>
<div class="color-swatches">
{#each ['#ef4444', '#f97316', '#eab308', '#22c55e', '#14b8a6', '#3b82f6', '#6366f1', '#a855f7', '#ec4899', '#78716c'] as color}
<button
class="swatch"
style="background:{color}"
aria-label={color}
onclick={async () => {
const old = memberColor;
memberColor = color;
showColorPicker = false;
try {
const res = await fetch('/api/members', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ color })
});
if (!res.ok) memberColor = old;
} catch {
memberColor = old;
}
}}
></button>
{/each}
</div>
</div>
</div>
{/if}
<div class="hero-progress">
<span class="hero-pct">{weeklyTotal > 0 ? weeklyPct : 0}%</span>
<div class="hero-bars">
<div class="bar-wrap hero-bar">
{#if pmTargetPct != null}
<div class="hero-target-fill" style="width:{pmTargetPct}%"></div>
{/if}
<div class="bar-fill hero-fill" style="width:{weeklyPct}%"></div>
{#if pmTargetPct != null}
<span class="hero-target-lbl" style="left:{pmTargetPct}%"
>{@html targetIcon} {pmTargetPct}%</span
>
{/if}
</div>
</div>
<span class="hero-count">
{weeklyTotal > 0 ? `${weeklyDone}/${weeklyTotal} done` : 'no chores yet'}
</span>
</div>
<div class="hero-meta">
<div class="hero-bonus-slot">
{#if anyWeekBonuses && weekBonusTallies.length > 0}
<div class="hero-pills">
{#each weekBonusTallies as t}
<span class="hero-pill">🎉 {t.name} ×{t.count}</span>
{/each}
</div>
{/if}
</div>
{#if isCurrentWeek}
<div class="hero-days"><b>{daysLeft}</b> days until payday</div>
{:else}
<button class="hero-now" onclick={jumpToNow}><span>← Back to this week</span></button>
{/if}
{#if inYesterday}
<button class="hero-now hero-yesterday-on" onclick={() => (viewDate = 'today')}>
<span>← Back to today</span>
</button>
{:else if yesterdayAvailable}
<button class="hero-now hero-yesterday" onclick={() => (viewDate = 'yesterday')}>
<span>Forgot to mark a chore yesterday?</span>
</button>
{/if}
</div>
<nav class="hero-nav">
<button class="hero-nav-btn" onclick={() => navigate(-1)} aria-label="Previous week"
>{@html chevronLeftIcon}</button
>
<button class="hero-nav-btn cur" onclick={jumpToNow}
>{isCurrentWeek ? 'This week' : `${shortDate(weekStart)}`}</button
>
<button class="hero-nav-btn" onclick={() => navigate(1)} aria-label="Next week"
>{@html chevronRightIcon}</button
>
</nav>
</header>
<!-- WEEK-TO-DATE TILES -->
<div class="tiles">
<div class="tile tile-cash">
<span class="tile-val">£{weekCashTotal.toFixed(2)}</span>
<span class="tile-lbl">cash this week</span>
</div>
<div class="tile tile-pts">
<span class="tile-val">{weekChorePoints}</span>
<span class="tile-lbl">points this week</span>
</div>
</div>
<!-- GOALS (threshold bonus configs) -->
{#if thresholdGoals.length > 0}
<div class="goals">
{#each thresholdGoals as goal}
{@const pct = goal.pct}
<div class="goal-card" class:goal-achieved={goal.achieved}>
<div class="goal-head">
<span class="goal-name">{goal.config.name}</span>
<span class="goal-reward">
{#if goal.config.rewardType === 'cash'}
£{Number(goal.config.rewardValue).toFixed(2)}
{:else if goal.config.rewardType === 'prize'}
{@html giftIcon}
{:else}
{goal.config.rewardValue} pts
{/if}
</span>
</div>
{#if goal.achieved}
<div class="goal-done">
<span class="goal-tick">{@html checkCircleIcon}</span>
<span class="goal-done-text">
<b>Reward earned</b>
<span>{goalRewardLabel(goal)}</span>
</span>
</div>
{:else}
<div class="goal-bar-wrap">
<div class="goal-bar-fill" style="width:{pct}%"></div>
{#if goal.targetPct < 100}
<div class="goal-target" style="left:{goal.targetPct}%"></div>
{/if}
</div>
<div class="goal-foot">
{#if goal.isPercent && goal.totalPotential === 0}
<span class="goal-progress muted">no chores set</span>
{:else if goal.isPercent}
<span class="goal-progress"
>{Math.min(goal.current, goal.totalPotential)} / {goal.totalPotential}</span
>
{:else}
<span class="goal-progress"
>{Math.min(goal.current, goal.target)} / {goal.target}</span
>
{/if}
<span class="goal-pct">{pct}%</span>
<span class="goal-target-pill"
>{@html targetIcon}
{goal.targetLabel}{#if goal.targetChoreName}
<span class="goal-chore"> · {goal.targetChoreName}</span>
{/if}</span
>
</div>
{/if}
</div>
{/each}
</div>
{/if}
<!-- KANBAN -->
{#if accessDisabled}
<div class="kanban locked">
<div class="column col-daily">
<h2>{@html sunIcon} Daily</h2>
<p class="empty">{@html lockIcon} Access paused</p>
</div>
<div class="column col-weekly">
<h2>{@html calendarIcon} Weekly</h2>
<p class="empty">{@html lockIcon}</p>
</div>
<div class="column col-done">
<h2>{@html checkCircleIcon} Done</h2>
<p class="empty">{@html lockIcon}</p>
</div>
</div>
{:else}
{#if celebrations.length > 0 || goalCelebrations.length > 0}
<div class="celebrations">
{#each celebrations as cel}
<div class="celebrate-card">
<span class="celebrate-emoji">{cel.emoji}</span>
<span class="celebrate-text"
><strong>{cel.name}</strong> complete — amazing work!</span
>
<button class="celebrate-close" onclick={() => dismissCelebrate(cel.id)}>✕</button>
</div>
{/each}
{#each goalCelebrations as cel}
<div class="celebrate-card goal">
<span class="celebrate-emoji">🎉</span>
<span class="celebrate-text"
><strong>{cel.name}</strong> complete — you earned <b>{cel.reward}</b>!</span
>
<button class="celebrate-close" onclick={() => dismissGoalCelebrate(cel.id)}>✕</button
>
</div>
{/each}
</div>
{/if}
{#if inYesterday}
<div class="yesterday-banner">
<span>📝 Showing <b>yesterday</b> — tap anything you missed. Undo lives on today's view.</span>
<button class="yesterday-back" onclick={() => (viewDate = 'today')}>Back to today →</button>
</div>
{/if}
<div class="kanban">
<div class="column col-daily">
<h2>{@html sunIcon} Daily ({dailyPending.length})</h2>
{#if dailyPending.length === 0}
<p class="empty">All done!</p>
{:else}
{#each dailyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}>
<span class="checkbox">{@html circleIcon}</span>
<span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span>
</button>
{/each}
{/if}
</div>
<div class="column col-weekly">
<h2>
{@html calendarIcon} Weekly ({weeklyPending.length +
memberTodos.filter((t) => !isTodoDone(t.id)).length})
</h2>
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isTodoDone(t.id)).length === 0}
<p class="empty">All done!</p>
{:else}
{#each weeklyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}>
<span class="checkbox">{@html circleIcon}</span>
<span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span>
</button>
{/each}
{#each memberTodos.filter((t) => !isTodoDone(t.id)) as todo}
{@const urgency = todoUrgency(todo)}
{@const expired = isTodoExpired(todo)}
{#if todo.type === 'emoji'}
<button
class="chore todo-chore"
class:todo-blue={urgency === 'blue'}
class:todo-green={urgency === 'green'}
class:todo-orange={urgency === 'orange'}
class:todo-red={urgency === 'red'}
class:todo-black={urgency === 'black'}
class:todo-expired={expired}
onclick={() => toggle(todo)}
disabled={togglingIds.has(todo.id) || expired}
>
<span class="checkbox">{@html circleIcon}</span>
<span class="todo-main">
<span class="chore-name">{todo.customName || 'Todo'}</span>
{#if todo.completeBy}
{#if expired}
<span class="todo-due due-expired">EXPIRED</span>
{:else if todo.completeBy === todayChild}
<span class="todo-due due-today">DUE TODAY!</span>
{:else}
<span class="todo-due">due {formatHumanDate(todo.completeBy)}</span>
{/if}
{/if}
</span>
<span class="todo-label">todo</span>
<span class="chore-value">{todo.emoji || '🎯'}</span>
</button>
{:else}
<button
class="chore todo-chore"
class:todo-blue={urgency === 'blue'}
class:todo-green={urgency === 'green'}
class:todo-orange={urgency === 'orange'}
class:todo-red={urgency === 'red'}
class:todo-black={urgency === 'black'}
class:todo-expired={expired}
onclick={() => toggle(todo)}
disabled={togglingIds.has(todo.id) || expired}
>
<span class="checkbox">{@html circleIcon}</span>
<span class="todo-main">
<span class="chore-name">{todo.customName || 'Todo'}</span>
{#if todo.completeBy}
{#if expired}
<span class="todo-due due-expired">EXPIRED</span>
{:else if todo.completeBy === todayChild}
<span class="todo-due due-today">DUE TODAY!</span>
{:else}
<span class="todo-due">due {formatHumanDate(todo.completeBy)}</span>
{/if}
{/if}
</span>
<span class="todo-label">todo</span>
<span class="chore-value">
{todo.type === 'money'
? `£${Number(todo.value).toFixed(2)}`
: `${todo.value} pts`}
</span>
</button>
{/if}
{/each}
{/if}
</div>
<div class="column col-done">
<h2>{@html checkCircleIcon} Done ({completedToday.length})</h2>
{#if completedToday.length === 0}
<p class="empty">Nothing yet</p>
{:else}
{#each completedToday as c}
{@const chore = assigned.find((a) => a.id === c.assignedChoreId)}
{#if chore}
<!-- Yesterday view is mark-only: un-doing a backdated
completion would toggle against this week's window
and create confusing duplicates, so undo stays on today. -->
<button
class="chore done"
onclick={() => !inYesterday && toggle(chore)}
disabled={togglingIds.has(chore.id) || inYesterday}
title={inYesterday ? 'Go back to today to undo' : undefined}
>
<span class="checkbox">{@html checkCircleIcon}</span>
<span class="chore-name">{choreName(chore)}</span>
{#if chore.type === 'emoji'}
<span class="chore-value">{chore.emoji || '🎯'}</span>
{:else}
<span class="chore-value">{chore.value} {chore.type}</span>
{/if}
</button>
{/if}
{/each}
{/if}
</div>
</div>
{/if}
<!-- WALLET / CLAIMS -->
<div class="wallet">
<div class="wallet-top">
<span class="wallet-lbl">{@html walletIcon} Wallet</span>
<!-- Lifetime earnings (everything earned + claimed), one-shot totals -->
<div class="life-badges">
<span class="life-badge life-cash">£{allTimeCash.toFixed(2)} <i>all time</i></span>
<span class="life-badge life-pts">{allTimePoints.toLocaleString()} <i>pts</i></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">Nothing waiting — you're all caught up</p>
{:else}
<div class="wallet-rewards">
{#each pendingRewards as r}
{@const isReq = r.status === 'requested'}
<div class="wallet-reward">
<span class="wr-label">{r.label}</span>
<span class="wr-val">
{#if r.rewardType === 'cash'}
£{Number(r.value).toFixed(2)}
{:else if r.rewardType === 'prize'}
{@html giftIcon}
{:else}
{r.value} pts
{/if}
</span>
{#if r.rewardType === 'points'}
<span class="wr-pending">{@html checkCircleIcon} credited</span>
{:else if isReq}
<span class="wr-pending">{@html clockIcon} waiting</span>
{:else if paydayLocked(r)}
<span class="wr-pending wr-locked"
>{@html lockIcon} pays out {formatHumanDate(r.settleDate)}</span
>
{:else}
<button
class="wr-cta"
onclick={async () => {
try {
await memberApi.claimReward(famId, r.id);
} catch (e) {
claimError = e instanceof Error ? e.message : 'Claim failed';
}
}}>Request</button
>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
<style>
.toast {
position: fixed;
top: 3.5rem;
right: 1.5rem;
background: #dc2626;
color: white;
padding: 0.6rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
z-index: 999;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fadein 0.2s;
}
@keyframes fadein {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.empty {
color: #9ca3af;
font-size: 0.85rem;
text-align: center;
padding: 1.5rem;
}
.member-card {
border: 1px solid #e5e7eb;
border-left: 4px solid #6366f1;
border-radius: 10px;
padding: 0.65rem 0.85rem;
background: #f6fbff;
margin-bottom: 0.6rem;
transition:
transform 0.15s,
box-shadow 0.15s,
border-color 0.15s;
}
.member-card:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
border-color: #c7d2fe;
}
.member-card:last-child {
margin-bottom: 0;
}
.card-header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.4rem;
}
.dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.member-name {
font-weight: 600;
flex: 1;
font-size: 0.9rem;
}
.link {
font-size: 0.8rem;
color: #6366f1;
text-decoration: none;
}
.pocket-row {
font-size: 0.78rem;
color: #6b7280;
margin-top: 0.3rem;
}
.pocket-row .unset {
color: #b45309;
font-weight: 600;
}
.stats {
display: flex;
gap: 0.75rem;
font-size: 0.8rem;
color: #6b7280;
margin-bottom: 0.4rem;
}
.progress-row {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.3rem;
}
.label {
font-size: 0.75rem;
color: #6b7280;
min-width: 2.5rem;
}
.bar-wrap {
flex: 1;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 4px;
transition: width 0.3s;
}
.count {
font-size: 0.75rem;
color: #6b7280;
min-width: 3rem;
text-align: right;
}
.done-list {
list-style: none;
padding: 0;
margin: 0.4rem 0 0;
display: flex;
flex-direction: column;
gap: 0;
}
.done-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
border-radius: 6px;
font-size: 0.8rem;
color: #059669;
}
.done-item.odd {
background: #ffffff;
}
.done-icon {
display: inline-flex;
color: #22c55e;
flex-shrink: 0;
}
.done-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.revoke-form {
margin-left: auto;
display: inline-flex;
flex-shrink: 0;
}
.revoke-btn {
background: none;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
padding: 0 2px;
font-size: 0.75rem;
line-height: 1.2;
color: #9ca3af;
}
.revoke-btn:hover {
color: #dc2626;
border-color: #fca5a5;
background: #fef2f2;
}
.trigger-card {
border: 1px solid #e5e7eb;
border-left: 3px solid #22c55e;
border-radius: 10px;
padding: 0.65rem 0.85rem;
background: #f0fdf4;
margin-bottom: 0.6rem;
transition:
transform 0.15s,
box-shadow 0.15s;
}
.trigger-card:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
}
.trigger-card:last-child {
margin-bottom: 0;
}
.trigger-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
}
.trigger-name {
font-weight: 600;
font-size: 0.9rem;
}
.trigger-value {
font-size: 0.85rem;
color: #059669;
font-weight: 600;
}
.trigger-row {
display: flex;
gap: 0.4rem;
}
.trigger-select {
flex: 1;
padding: 0.35rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.8rem;
}
.member-claims {
margin-bottom: 1rem;
padding: 0.75rem;
background: #fafafa;
border: 1px solid #f1f1f3;
border-radius: 10px;
}
.member-claims:last-child {
margin-bottom: 0;
}
.member-claims-head {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.member-claims-head .dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.member-name {
font-weight: 600;
font-size: 0.9rem;
}
.member-owed {
font-size: 0.72rem;
font-weight: 600;
color: #2563eb;
background: #eff6ff;
border: 1px solid #dbeafe;
padding: 0.1rem 0.45rem;
border-radius: 999px;
}
.issue-all-form {
display: flex;
justify-content: flex-end;
}
.section-label {
font-size: 0.7rem;
font-weight: 700;
color: #9ca3af;
margin: 0.6rem 0 0.3rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.claim-item {
margin-bottom: 0.3rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
background: #fff;
border: 1px solid #eef0f3;
border-radius: 8px;
}
.claim-item.outstanding {
background: #f8f9fb;
opacity: 0.75;
}
.claim-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: #f59e0b;
flex-shrink: 0;
}
.claim-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.claim-value {
font-weight: 600;
color: #059669;
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.owe-locked {
font-weight: 500;
color: #9ca3af;
font-size: 0.72rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.claim-item :global(button) {
margin-left: auto;
flex-shrink: 0;
}
.claims-summary {
display: flex;
gap: 0.6rem;
margin-bottom: 0.9rem;
}
.claims-stat {
flex: 1;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.6rem 0.7rem;
background: linear-gradient(135deg, #fffbeb, #fff);
border: 1px solid #fde68a;
border-radius: 10px;
}
.claims-stat.pending {
background: linear-gradient(135deg, #eef2ff, #fff);
border-color: #c7d2fe;
}
.claims-stat-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
background: #f59e0b;
color: #fff;
flex-shrink: 0;
}
.claims-stat-icon.pending {
background: #6366f1;
}
.claims-stat-value {
font-size: 1rem;
font-weight: 700;
line-height: 1.1;
color: #1f2937;
}
.claims-stat-label {
font-size: 0.68rem;
color: #6b7280;
}
.claims-stat.pending .claims-stat-label {
color: #4338ca;
}
.claims-empty {
display: flex;
align-items: center;
gap: 0.5rem;
color: #059669;
font-size: 0.85rem;
font-weight: 500;
padding: 0.5rem 0;
}
.error {
color: #dc2626;
padding: 1rem;
text-align: center;
}
.color-dot {
display: inline-block;
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #e5e7eb;
cursor: pointer;
flex-shrink: 0;
}
.name-edit {
font-size: 1.2rem;
padding: 0.1rem 0.3rem;
border: 1px solid #6366f1;
border-radius: 4px;
outline: none;
font-weight: 700;
}
.color-picker-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
}
.color-picker-popup {
background: white;
padding: 1.25rem;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
}
.color-picker-popup p {
margin: 0 0 0.75rem;
font-weight: 600;
font-size: 0.9rem;
}
.color-swatches {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.swatch {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid #e5e7eb;
cursor: pointer;
}
.swatch:hover {
border-color: #6366f1;
transform: scale(1.1);
}
/* ── Child view ── */
.hero {
background: linear-gradient(135deg, #6366f1, #8b5cf6 55%, #a855f7);
border-radius: 16px;
padding: 1.25rem 1.5rem;
margin-bottom: 1rem;
color: #fff;
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.35);
position: relative;
overflow: hidden;
}
/* Week-activity line effect behind the hero content — no axes, just a
blurred white trace with dots. Pure decoration, ignored by AT. */
.hero-bg {
position: absolute;
inset: auto 0 0 0;
width: 100%;
height: 120px;
opacity: 0.45;
filter: blur(5px);
pointer-events: none;
}
.hero-top,
.hero-progress,
.hero-meta,
.hero-nav {
position: relative;
z-index: 1;
}
.hero-now {
/* Same full-width centered slot as .hero-days so the hero keeps its height */
flex: 1 1 100%;
margin-top: 0.25rem;
min-height: 2.6rem;
align-content: center;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.hero-now span {
display: inline-block;
background: rgba(255, 255, 255, 0.92);
color: #4338ca;
border-radius: 999px;
padding: 0.45rem 1.1rem;
font-size: 0.85rem;
font-weight: 800;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.hero-now:hover span {
background: #fff;
}
.hero-now.hero-yesterday span {
background: rgba(255, 255, 255, 0.22);
color: #fff;
border: 1px dashed rgba(255, 255, 255, 0.7);
box-shadow: none;
font-weight: 700;
}
.hero-now.hero-yesterday:hover span {
background: rgba(255, 255, 255, 0.32);
}
.yesterday-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
background: #fef3c7;
border: 1px solid #fcd34d;
color: #92400e;
border-radius: 10px;
padding: 0.5rem 0.85rem;
font-size: 0.82rem;
margin-bottom: 0.75rem;
}
.yesterday-back {
background: none;
border: none;
color: #b45309;
font-weight: 800;
font-size: 0.82rem;
cursor: pointer;
white-space: nowrap;
}
.yesterday-back:hover {
text-decoration: underline;
}
.yesterday-tag {
font-size: 0.62rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #b45309;
background: #fef3c7;
border-radius: 999px;
padding: 0.1rem 0.45rem;
flex-shrink: 0;
}
.hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.hero-id {
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
}
.hero-dot {
border-color: rgba(255, 255, 255, 0.6);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35);
}
.hero-name {
font-size: 1.5rem;
font-weight: 800;
color: #fff;
cursor: pointer;
border-bottom: 1px dashed rgba(255, 255, 255, 0.4);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name-edit-dark {
background: rgba(255, 255, 255, 0.92);
color: #1e1b4b;
border-color: #fff;
font-size: 1.2rem;
}
.hero-date {
display: flex;
align-items: baseline;
gap: 0.45rem;
flex-shrink: 0;
}
.hero-day {
font-size: 1.7rem;
font-weight: 800;
line-height: 1;
color: #fff;
}
.hero-month {
font-size: 0.85rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
}
.hero-weekday {
font-size: 0.85rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
}
.hero-progress {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.7rem;
}
.hero-pct {
font-size: 1.5rem;
font-weight: 800;
color: #fff;
flex-shrink: 0;
min-width: 3.4rem;
}
.hero-bar {
flex: 1;
height: 16px;
background: rgba(255, 255, 255, 0.28);
border-radius: 8px;
position: relative;
}
.hero-fill {
position: relative;
z-index: 2;
height: 100%;
background: #c7d2fe;
border-radius: 8px;
}
.hero-bars {
flex: 1;
min-width: 0;
}
.hero-target-fill {
position: absolute;
top: 0;
left: 0;
bottom: 0;
z-index: 1;
background: rgba(253, 224, 71, 0.45);
border-radius: 8px;
}
.hero-target-lbl {
position: absolute;
left: 0;
bottom: 100%;
transform: translateX(-50%);
margin-bottom: 4px;
font-size: 0.7rem;
font-weight: 700;
color: #fde047;
white-space: nowrap;
z-index: 4;
}
.hero-count {
font-size: 0.8rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
flex-shrink: 0;
white-space: nowrap;
}
.hero-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.9rem;
flex-wrap: wrap;
}
/* Fixed slot for the pills / no-bonus line so week navigation never
moves the hero when the bonus content changes height. Fits two rows
of pills; more than that still grows gracefully. */
.hero-bonus-slot {
flex: 1 1 auto;
display: flex;
align-items: center;
min-height: 3.4rem;
align-content: center;
}
.hero-pills {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.hero-pill {
font-size: 0.75rem;
font-weight: 700;
padding: 3px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.5);
}
.hero-days {
font-size: 1rem;
color: rgba(255, 255, 255, 0.9);
/* Own full-width line, centered under the hero content */
flex: 1 1 100%;
text-align: center;
margin-top: 0.25rem;
/* Fixed slot so swapping in the back-to-this-week CTA never moves the hero */
min-height: 2.6rem;
align-content: center;
}
.hero-days b {
font-size: 1.5rem;
margin-right: 0.15rem;
}
.hero-nav {
display: flex;
gap: 0.4rem;
}
.hero-nav-btn {
font-size: 0.85rem;
font-weight: 700;
padding: 0.35rem 0.9rem;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.2);
color: #fff;
cursor: pointer;
}
.hero-nav-btn:hover {
background: rgba(255, 255, 255, 0.32);
}
.hero-nav-btn.cur {
flex: 1;
background: transparent;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.5);
}
/* ── Kanban scroll for parent view cards ── */
.kanban-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin-bottom: 1rem;
}
.kanban-inner {
min-width: 450px;
}
.tiles {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
.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-cash {
background: linear-gradient(135deg, #059669, #10b981);
}
.tile-pts {
background: linear-gradient(135deg, #7c3aed, #a855f7);
}
.tile-members {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
.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;
letter-spacing: 0.06em;
opacity: 0.9;
}
/* ── Goals (threshold bonus progress) ── */
.goals {
display: grid;
grid-template-columns: 1fr;
gap: 0.6rem;
margin-bottom: 1rem;
}
@media (min-width: 1024px) {
.goals {
grid-template-columns: 1fr 1fr;
}
}
.goal-card {
background: #fff;
border: 1px solid #e5e7eb;
border-left: 4px solid #6366f1;
border-radius: 10px;
padding: 0.75rem 1rem;
transition: border-color 0.2s;
}
.goal-achieved {
border-left-color: #10b981;
background: #f0fdf4;
}
.goal-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
}
.goal-name {
font-size: 0.9rem;
font-weight: 700;
color: #1f2937;
}
.goal-reward {
font-size: 1.3rem;
font-weight: 800;
color: #6366f1;
white-space: nowrap;
}
.goal-achieved .goal-reward {
color: #059669;
}
.goal-bar-wrap {
position: relative;
height: 8px;
background: #f3f4f6;
border-radius: 4px;
overflow: visible;
margin: 0.35rem 0 0.5rem;
}
.goal-bar-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 4px;
transition: width 0.3s ease;
max-width: 100%;
}
.goal-target {
position: absolute;
top: -4px;
bottom: -4px;
width: 3px;
background: #10b981;
border-radius: 2px;
transform: translateX(-50%);
z-index: 2;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8);
}
.goal-target-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 1rem;
font-weight: 800;
color: #059669;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 999px;
padding: 3px 12px;
white-space: nowrap;
}
.goal-target-pill :global(svg) {
width: 16px;
height: 16px;
}
.goal-chore {
font-weight: 600;
color: #6b7280;
}
.goal-achieved .goal-bar-fill {
background: linear-gradient(90deg, #10b981, #34d399);
}
.goal-foot {
display: flex;
justify-content: space-between;
align-items: center;
}
.goal-progress {
font-size: 0.75rem;
color: #6b7280;
font-weight: 600;
}
.goal-badge {
font-size: 0.75rem;
font-weight: 700;
color: #059669;
}
.goal-pct {
font-size: 0.75rem;
font-weight: 600;
color: #6366f1;
}
/* Achieved goal — big tick + reward, progress hidden */
.goal-done {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0 0.15rem;
}
.goal-tick {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #10b981;
color: #fff;
flex: none;
}
.goal-tick :global(svg) {
width: 20px;
height: 20px;
}
.goal-done-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
font-size: 0.85rem;
color: #047857;
}
.goal-done-text b {
font-size: 0.9rem;
}
.goal-done-text span {
font-weight: 800;
color: #059669;
font-size: 1rem;
}
.goal-achieved .goal-done {
animation: goal-pop 0.35s ease;
}
@keyframes goal-pop {
0% {
transform: scale(0.9);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* Admin overview — "Goals completed" mini cards */
.goals-completed {
margin-bottom: 1rem;
}
.goal-mini-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.6rem;
}
@media (max-width: 1023px) {
.goal-mini-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.goal-mini {
background: linear-gradient(135deg, #d1d3fa, #ccfbf1);
border: 1px solid #a7f3d0;
border-radius: 12px;
padding: 0.55rem 0.7rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-width: 0;
/* Own width as a query container, so tight cards drop the member name */
container-type: inline-size;
}
/* Card narrower than 250px: hide the member name so the row doesn't squish */
@container (max-width: 249px) {
.gm-member {
display: none;
}
}
.gm-top {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.gm-top .dot {
flex: none;
}
.gm-name {
font-weight: 700;
font-size: 0.82rem;
color: #7a43bf;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.gm-member {
font-size: 0.72rem;
font-weight: 600;
color: #6b7280;
flex: none;
}
.gm-bottom {
display: flex;
justify-content: space-between;
gap: 0.4rem;
white-space: nowrap;
}
.gm-reward {
font-weight: 800;
color: #047857;
font-size: 0.85rem;
}
/* Goal celebration notice (child view) */
.celebrate-card.goal {
border-color: #86efac;
background: #ecfdf5;
}
/* ── Kanban ── */
.celebrations {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.celebrate-card {
display: flex;
align-items: center;
gap: 0.6rem;
background: linear-gradient(90deg, #fef3c7, #fce7f3);
border: 1px solid #fcd34d;
border-radius: 12px;
padding: 0.55rem 0.85rem;
animation: celebrate-pop 0.35s ease-out;
}
@keyframes celebrate-pop {
from {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.celebrate-emoji {
font-size: 1.6rem;
}
.celebrate-text {
flex: 1;
font-size: 0.85rem;
color: #78350f;
}
.celebrate-close {
background: none;
border: none;
color: #92400e;
cursor: pointer;
font-size: 0.85rem;
padding: 0.15rem;
}
.kanban {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
.column {
border: 1px solid #e5e7eb;
border-top: 4px solid #6366f1;
border-radius: 12px;
padding: 0.75rem;
background: #fafbff;
}
.column h2 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
}
.col-daily {
border-top-color: #6366f1;
background: #eef2ff;
}
.col-weekly {
border-top-color: #f59e0b;
background: #fffbeb;
}
.col-done {
border-top-color: #10b981;
background: #ecfdf5;
}
.chore {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.5rem 0.6rem;
margin-bottom: 0.4rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: white;
cursor: pointer;
text-align: left;
font-size: 0.85rem;
}
.chore:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
border-color: #c7d2fe;
}
/* ── Todo traffic light borders ── */
.chore.todo-chore {
border-left: 4px solid #3b82f6;
}
.chore.todo-blue {
border-left-color: #3b82f6;
}
.chore.todo-green {
border-left-color: #22c55e;
}
.chore.todo-orange {
border-left-color: #f97316;
}
.chore.todo-red {
border-left-color: #ef4444;
}
.chore.todo-black {
border-left-color: #1f2937;
}
.chore.todo-expired {
border-left-color: #9ca3af;
background: #f3f4f6;
opacity: 0.75;
}
.chore.todo-expired .chore-name {
text-decoration: line-through;
color: #6b7280;
}
.todo-due.due-expired {
color: #ef4444;
font-weight: 700;
letter-spacing: 0.03em;
}
.todo-label {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
background: #fef3c7;
color: #b45309;
border: 1px solid #fcd34d;
border-radius: 4px;
padding: 1px 5px;
flex-shrink: 0;
}
.todo-main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
gap: 1px;
}
.todo-due {
font-size: 0.68rem;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.todo-due.due-today {
color: #dc2626;
font-weight: 700;
letter-spacing: 0.03em;
animation: due-today-pulse 1.4s ease-in-out infinite;
}
@keyframes due-today-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
.chore:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.done {
opacity: 0.7;
}
.checkbox {
font-size: 1rem;
flex-shrink: 0;
}
.checkbox :global(svg) {
width: 1.15em;
height: 1.15em;
display: inline-block;
vertical-align: -0.2em;
}
.chore-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chore-value {
font-size: 0.75rem;
color: #6b7280;
font-weight: 600;
white-space: nowrap;
}
.wallet {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 14px;
padding: 1rem 1.25rem;
}
.wallet-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.wallet-lbl {
font-size: 0.8rem;
font-weight: 800;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.wallet-lbl :global(svg) {
width: 1em;
height: 1em;
vertical-align: -0.15em;
margin-right: 0.3em;
}
.life-badges {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.life-badge {
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
font-size: 0.8rem;
font-weight: 800;
padding: 3px 11px;
border-radius: 999px;
color: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.life-badge i {
font-style: normal;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.9;
}
.life-cash {
background: linear-gradient(135deg, #059669, #10b981);
}
.life-pts {
background: linear-gradient(135deg, #7c3aed, #a855f7);
}
.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;
font-size: 0.85rem;
color: #9ca3af;
}
.wallet-rewards {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wallet-reward {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.8rem;
border-radius: 10px;
background: #fafbfc;
border: 1px solid #eeeff2;
}
.wr-label {
flex: 1;
font-weight: 600;
font-size: 0.85rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wr-val {
font-weight: 800;
color: #059669;
}
.wr-pending {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
}
.wr-pending :global(svg) {
width: 0.95em;
height: 0.95em;
vertical-align: -0.15em;
margin-right: 0.25em;
}
.wr-locked {
color: #d97706;
}
.owe-locked {
margin-left: 0.35rem;
font-size: 0.72rem;
font-weight: 600;
color: #d97706;
}
.wr-cta {
font-size: 0.72rem;
font-weight: 700;
padding: 3px 12px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
.wr-cta:hover {
filter: brightness(1.1);
}
.eow-mode-on {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.6rem 0;
padding: 0.6rem 0.8rem;
border: 1px dashed #f59e0b;
border-radius: 8px;
background: #fffbeb;
color: #92400e;
font-size: 0.85rem;
}
.payday-banner {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 1rem;
padding: 0.85rem 1.1rem;
border-radius: 12px;
background: linear-gradient(135deg, #f59e0b, #fbbf24);
color: #451a03;
font-weight: 600;
font-size: 1.05rem;
box-shadow: 0 6px 16px rgba(245, 158, 11, 0.35);
}
.payday-banner b {
font-weight: 800;
}
.payday-countdown {
display: flex;
align-items: center;
gap: 0.9rem;
margin-bottom: 1rem;
padding: 1rem 1.2rem;
border-radius: 12px;
background: linear-gradient(135deg, #f59e0b, #fbbf24);
color: #451a03;
box-shadow: 0 6px 16px rgba(245, 158, 11, 0.35);
}
.pd-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.pd-body {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.pd-title {
margin: 0;
font-weight: 700;
font-size: 0.95rem;
opacity: 0.9;
}
.pd-num {
margin: 0;
font-size: 1.7rem;
font-weight: 800;
letter-spacing: 0.5px;
font-variant-numeric: tabular-nums;
}
.pd-sub {
margin: 0;
font-size: 0.8rem;
opacity: 0.85;
}
/* ── Payday party: nyan-style colour-cycling celebration ── */
.payday-party {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 1rem;
padding: 1rem 1.2rem;
border-radius: 14px;
color: #fff;
cursor: pointer;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
background: linear-gradient(
90deg,
#ef4444,
#f97316,
#eab308,
#22c55e,
#3b82f6,
#a855f7,
#ec4899,
#ef4444
);
background-size: 300% 100%;
animation:
nyan-slide 3s linear infinite,
party-pop 0.45s ease-out;
box-shadow: 0 8px 24px rgba(168, 85, 247, 0.45);
}
@keyframes nyan-slide {
to {
background-position: 300% 0;
}
}
@keyframes party-pop {
from {
transform: scale(0.85);
}
to {
transform: scale(1);
}
}
.party-cat {
font-size: 2.6rem;
animation: party-bounce 0.55s ease-in-out infinite alternate;
}
@keyframes party-bounce {
from {
transform: translateY(-4px) rotate(-10deg);
}
to {
transform: translateY(4px) rotate(10deg);
}
}
.party-body {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.party-title {
margin: 0;
font-size: 1.5rem;
font-weight: 900;
letter-spacing: 0.1em;
animation: party-wiggle 1s ease-in-out infinite;
}
@keyframes party-wiggle {
0%,
100% {
transform: rotate(-2deg);
}
50% {
transform: rotate(2deg);
}
}
.party-sub {
margin: 0;
font-size: 0.85rem;
opacity: 0.95;
}
.party-stars {
margin-left: auto;
font-size: 1.4rem;
animation: party-bounce 0.55s ease-in-out infinite alternate-reverse;
}
/* Mobile: declared last so it wins over the base .goal-mini (.kanban)
rules (same specificity, source order decides). */
@media (max-width: 639px) {
.goal-mini-grid {
grid-template-columns: 1fr;
}
.kanban {
grid-template-columns: 1fr;
}
/* Admin overview cards flow full width on phones — same convention as
the chores kanban (no horizontal scroll). */
.kanban-scroll {
overflow-x: visible;
}
.kanban-inner {
min-width: 0;
}
/* Keep the orange wash visible: restore body padding for this card
only (the shared Card removes it on mobile). */
:global(.card.claims-card .card-body) {
padding: 0.75rem;
}
/* Claim rows: icon/label/value stay on one line (label truncates
with ellipsis) and the Issue button becomes a full-width action
below — no squishing or overflow under 450px. */
.member-claims form.flex {
flex-wrap: wrap;
row-gap: 0.5rem;
}
.member-claims form.flex > div {
flex: 1 1 100%;
min-width: 0;
flex-wrap: nowrap;
align-items: center;
}
/* :global — the button element is rendered by the shared Button
component, so a scoped selector never matches it. */
.member-claims form.flex :global(button) {
flex: 1 1 100%;
width: 100%;
margin-left: 0;
}
.member-claims .claim-label {
white-space: nowrap;
}
.member-claims .claim-item {
flex-wrap: wrap;
}
}
/* Claims card — orange wash so pending requests are impossible to miss.
:global + !important deliberately override the shared Card's white/
mobile-transparent backgrounds. */
:global(.card.claims-card) {
background: linear-gradient(135deg, #ffedd5, #fdba74 70%, #fb923c) !important;
border-color: #f97316;
}
</style>