Files
famdone/frontend/src/routes/[fam]/[username]/+page.svelte
T
2026-08-04 15:32:59 +01:00

2293 lines
57 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, formatShortDate } from '$lib/format';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
import {
weekStart as tzWeekStart,
addDaysStr,
todayInTz,
weekdayInTz,
wallClockToUtc,
resolveTz,
periodStart,
periodEnd
} from '../../../../../timezone.ts';
interface Notification {
id: string;
famId: string;
memberId: string;
message: string;
read: boolean;
created: string;
}
let { data } = $props();
let role = $state(data.role || 'child');
let famSlug = $derived(page.params.fam);
let username = $derived(page.params.username);
// ─── 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 simulateEow = $state(!!(data.settings?.simulateEow ?? data.simulateEow));
let eowPreview = $state<any>(null);
const responseTags = [
'👏 well done',
'😊 really pleased',
'🎯 you deserved that',
'💪 great effort',
'🙏 thanks'
];
let selectedMessage = $state<Record<string, string>>({});
let customMessage = $state<Record<string, string>>({});
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 totalChoresFor(memberId: string): number {
return parentAssigned.filter((a: any) => a.memberId === memberId).length;
}
function todayCompletionsFor(memberId: string) {
return parentCompletions.filter(
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today
);
}
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')
);
}
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 }, onSuccess = null) {
const d = result.data || {};
if (d.error) showToast(d.error);
else if (result.type === 'success') {
if (onSuccess) onSuccess(d);
fire();
}
}
function rewardLabel(r: any): string {
if (r.rewardType === 'cash') return ${Number(r.value).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`;
return r.label;
}
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;
});
}
// ─── Child View (kanban) ───
let memberId = $state(data.memberId || '');
let deviceToken = $state(data.token || '');
let famId = $state(data.famId || '');
let memberName = $state(data.memberName || '');
let memberColor = $state(data.memberColor || '#6366f1');
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[])
);
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<string>('');
// ─── Payday countdown (child view) ───
let nowMs = $state(Date.now());
let eowFired = $state(false);
$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 (!deviceToken || !famId) return;
eowFired = true;
memberApi.payday(deviceToken, famId).catch(() => {});
});
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);
const daysLeft = $derived(
Math.max(
0,
Math.round(
(new Date(addDays(weekStart, 7) + '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 !== 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 === 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 || '');
})
);
let dailyPending = $derived(
memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, todayChild))
);
let weeklyPending = $derived(
memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, todayChild))
);
// 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) === todayChild)
);
let weeklyTotal = $derived.by(() => {
const dailyChores = memberChores.filter((a) => a.frequency === 'daily');
const weeklyChores = memberChores.filter((a) => a.frequency === 'weekly');
const activeTodos = memberTodos.filter((t) => t.type !== 'emoji').length;
return dailyChores.length * 7 + weeklyChores.length + activeTodos;
});
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
)
);
let weeklyDone = $derived(weekCompletions.length);
let weeklyPct = $derived(weeklyTotal > 0 ? Math.round((weeklyDone / weeklyTotal) * 100) : 0);
let weekRewards = $derived(
rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart)
);
let allTimeCash = $derived.by(() =>
rewards
.filter((r) => r.memberId === memberId && r.rewardType === 'cash' && r.status === 'claimed')
.reduce((sum, r) => sum + Number(r.value), 0)
);
let allTimePoints = $derived.by(() =>
rewards
.filter((r) => r.memberId === memberId && r.rewardType === 'points' && r.status === 'claimed')
.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)
);
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 (stamped server-side).
function paydayLocked(r: any) {
return r.claimable === 'payday' && r.settleDate && todayChild < r.settleDate;
}
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);
}
return [...map.entries()].map(([name, count]) => ({ name, count }));
});
// ─── Threshold Goals (bonus configs the member is chasing) ───
let thresholdGoals = $derived.by(() => {
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 per = cfg.period || 'weekly';
const pStart = periodStart(per, paydayDay, famTz);
const pEnd = periodEnd(per, pStart);
const periodCompletions = completions.filter(
(c) =>
c.memberId === memberId &&
(c.date?.slice(0, 10) || c.date) >= pStart &&
(c.date?.slice(0, 10) || c.date) <= pEnd
);
let current = 0;
if (cfg.type === 'threshold') {
current = periodCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
} else if (cfg.type === 'count') {
current = periodCompletions.length;
}
const existingReward = rewards.find(
(r) => r.bonusConfigId === cfg.id && r.memberId === memberId
);
const criteria = Number(cfg.criteriaValue) || 0;
const achieved = existingReward ? true : criteria > 0 && current >= criteria;
return {
config: cfg,
current,
criteriaValue: criteria,
achieved,
rewardStatus: existingReward?.status || null,
periodStart: pStart,
periodEnd: pEnd
};
});
});
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((isCurrentWeek ? todayIso : weekEnd) + 'T00:00:00');
}
function headDay(): number {
return headDate().getDate();
}
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 (!deviceToken) {
deviceToken = localStorage.getItem('deviceToken') || '';
} else {
localStorage.setItem('deviceToken', deviceToken);
}
if (!deviceToken) {
error = 'No device token found. Use the link from your invite.';
loading = false;
return;
}
if (data.verified && data.famId && data.memberId) {
memberId = data.memberId;
famId = data.famId;
loading = false;
} else {
error = 'Invalid device token. Use the link from your invite.';
loading = false;
return;
}
await loadAndDismiss();
});
async function loadAndDismiss() {
if (!deviceToken || !famId) return;
try {
const res = await fetch('/api/members/notifications', {
headers: {
'x-device-token': deviceToken,
'x-device-famid': famId
}
});
if (!res.ok) return;
const all: Notification[] = await res.json();
for (const n of all.filter((n) => !n.read)) {
await fetch(`/api/members/notifications/${n.id}/dismiss`, {
method: 'POST',
headers: {
'x-device-token': deviceToken,
'x-device-famid': famId
}
});
}
} catch {}
}
function isCompleted(assignedChoreId: string, date: string): boolean {
return completions.some(
(c) => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date
);
}
function findCompletion(assignedChoreId: string) {
const match = (c: Completion) =>
c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === todayChild;
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
return optimistic || completions.find(match);
}
async function toggle(chore: AssignedChore) {
if (togglingIds) return;
togglingIds = chore.id;
const wasCompleted = isCompleted(chore.id, todayChild);
if (wasCompleted) {
const existing = findCompletion(chore.id);
if (existing) {
famStore.applyRecord('completions', existing, 'delete');
}
} else {
famStore.applyRecord(
'completions',
{
id: 'optimistic-' + chore.id,
famId,
memberId,
assignedChoreId: chore.id,
date: todayChild,
completedAt: new Date().toISOString()
} as Completion,
'create'
);
}
try {
await memberApi.toggleCompletion(deviceToken, famId, chore.id, todayChild);
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
if (optimistic) {
famStore.applyRecord('completions', optimistic, 'delete');
}
} catch (e) {
if (wasCompleted) {
famStore.applyRecord(
'completions',
{
id: 'revert-' + chore.id + '-' + Date.now(),
famId,
memberId,
assignedChoreId: chore.id,
date: todayChild,
completedAt: new Date().toISOString()
} as Completion,
'create'
);
} else {
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
if (optimistic) {
famStore.applyRecord('completions', optimistic, 'delete');
}
}
console.error('Toggle failed:', e);
} finally {
togglingIds = '';
}
}
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>
<div class="kanban-scroll">
<div class="kanban-inner">
<CardGrid>
<Card cols={1} title="Members">
{#each parentMembers as m}
{@const total = totalChoresFor(m.id)}
{@const todays = todayCompletionsFor(m.id)}
{@const done = todays.length}
{@const pct = total > 0 ? Math.round((done / total) * 100) : 0}
{@const s = memberInSummary(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>
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
</div>
<div class="stats">
<span>Points: {s?.pointsEarned ?? 0}</span>
<span>Money: £{(s?.moneyEarned ?? 0).toFixed(2)}</span>
</div>
<div class="progress-row">
<span class="label">Today:</span>
<div class="bar-wrap">
<div class="bar-fill" style="width:{pct}%"></div>
</div>
<span class="count">{done}/{total}</span>
</div>
{#if done > 0}
<ul class="done-list">
{#each todays as c}
<li>
✅ {choreNameFor(c.assignedChoreId)}
<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">↩</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="#f59e0b">
{#if claimableRewards().length === 0}
<p class="empty">No outstanding claims</p>
{:else}
{#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">
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
{#if total > 0}
<p class="member-total">£{total.toFixed(2)} owed</p>
{/if}
<!-- Issue All: only when payday has run (rewards are requested) -->
{#if cashRequested.length > 0}
<form
class="issue-all-form"
method="POST"
action="?/issueAll"
use:enhance={() => {
return async (args: any) => handleResult(args);
}}
>
<input type="hidden" name="memberId" value={m.id} />
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
<Button type="submit" size="sm" variant="secondary"
>Issue All ({cashRequested.length}) · £{requestedTotal.toFixed(2)}</Button
>
</form>
{/if}
{#if requested.length > 0}
<p class="section-label">Requested</p>
{#each requested as r}
<form
method="POST"
action="?/claim"
use:enhance={() => {
return async (args: any) => handleResult(args);
}}
>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
<div class="payment-row">
<span>{r.label}</span>
<div class="payment-right">
<span class="value">{rewardLabel(r)}</span>
<Button type="submit" size="sm" variant="primary">Issue</Button>
</div>
</div>
</form>
{/each}
{/if}
{#if outstanding.length > 0}
<p class="section-label">Outstanding</p>
{#each outstanding as r}
<div class="payment-row outstanding">
<span>{r.label}</span>
<span class="value">
{rewardLabel(r)}
{#if paydayLocked(r)}
<span class="owe-locked">🔒 {formatShortDate(r.settleDate)}</span>
{/if}
</span>
</div>
{/each}
{/if}
<div class="response-area">
<p class="respond-label">Response:</p>
<div class="tags">
{#each responseTags as tag}
<button
type="button"
class="tag"
class:selected={selectedMessage[m.id] === tag}
onclick={() =>
(selectedMessage[m.id] = selectedMessage[m.id] === tag ? '' : tag)}
>{tag}</button
>
{/each}
</div>
<input
type="text"
class="custom-msg"
placeholder="Or type your own..."
bind:value={customMessage[m.id]}
oninput={() => {
if (customMessage[m.id]) selectedMessage[m.id] = customMessage[m.id];
}}
/>
</div>
</div>
{/if}
{/each}
{/if}
</Card>
</CardGrid>
</div>
</div>
<CardGrid>
<Card cols={3} title="Debug: Preview payday" accent="#f59e0b">
<p class="eow-desc">
Read-only preview of what the payday rollover will settle for this week. Nothing here is
written.
</p>
<form
method="POST"
action="?/previewEow"
use:enhance={() => {
return async ({ result }) => {
const d = (result as any).data || {};
if (d.error) toast = d.error;
else {
eowPreview = d.preview;
simulateEow = !!d.simulateEow;
}
};
}}
>
<Button type="submit" size="sm" variant="secondary">Preview payday</Button>
</form>
{#if simulateEow}
<p class="eow-mode-on">
👀 Preview mode is ON — child dashboards show a "payday preview" notice.
</p>
<form
method="POST"
action="?/setEow"
use:enhance={() => {
return async ({ result }) => {
const d = (result as any).data || {};
if (d.error) toast = d.error;
else simulateEow = !!d.simulateEow;
};
}}
>
<input type="hidden" name="on" value="false" />
<Button type="submit" size="sm" variant="ghost">Turn off preview</Button>
</form>
{/if}
{#if eowPreview}
<div class="eow-out">
<p class="eow-range">
Week {formatDDMMYY(eowPreview.weekStart)}{formatDDMMYY(eowPreview.weekEnd)}
</p>
<p class="eow-reset">
On payday these {eowPreview.completionsThisWeek} completions reset; week starts anew at
{formatDDMMYY(eowPreview.nextWeekStart)}.
</p>
<div class="eow-summaries">
{#each eowPreview.summaries as s}
<div class="eow-row">
<span class="eow-name">{s.memberName}</span>
<span class="eow-pts">{s.pointsEarned} pts</span>
<span class="eow-money">£{Number(s.moneyEarned || 0).toFixed(2)}</span>
<span class="eow-chores">{s.choresCompleted} chores</span>
</div>
{/each}
</div>
{#if eowPreview.predictedRewards.length}
<p class="eow-sec">Rewards that would be auto-created</p>
<div class="eow-rewards">
{#each eowPreview.predictedRewards as r}
<span class="eow-reward"
>{r.memberName} · {r.config} · {r.type === 'cash'
? '£' + Number(r.value).toFixed(2)
: r.value + ' pts'}</span
>
{/each}
</div>
{:else}
<p class="eow-none">No new rewards would be auto-created this week.</p>
{/if}
</div>
{/if}
</Card>
</CardGrid>
{:else}
<!-- Child View -->
{#if loading}
<p class="empty">Loading...</p>
{:else if error}
<p class="error">{error}</p>
{:else}
{#if simulateEow}
<div class="preview-notice">
👀 <b>Payday preview</b> — your parent is checking this week's payday. Nothing is paid
out yet.
</div>
{/if}
{#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">💰</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}
<!-- HERO -->
<header class="hero">
<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/me', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-device-token': deviceToken,
'x-device-famid': famId
},
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-day">{headDay()}</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/me', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-device-token': deviceToken,
'x-device-famid': famId
},
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="bar-wrap hero-bar">
<div class="bar-fill hero-fill" style="width:{weeklyPct}%"></div>
</div>
<span class="hero-count">
{weeklyTotal > 0 ? `${weeklyDone}/${weeklyTotal} done` : 'no chores yet'}
</span>
</div>
<div class="hero-meta">
{#if weekBonusTallies.length > 0}
<div class="hero-pills">
{#each weekBonusTallies as t}
<span class="hero-pill">🎉 {t.name} ×{t.count}</span>
{/each}
</div>
{:else}
<span class="hero-null">no bonuses earned yet</span>
{/if}
<div class="hero-days"><b>{daysLeft}</b> days until payday</div>
</div>
<nav class="hero-nav">
<button class="hero-nav-btn" onclick={() => navigate(-1)} aria-label="Previous week"
></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"></button>
</nav>
</header>
<!-- WEEK-TO-DATE TILES -->
<div class="tiles">
<div class="tile tile-cash">
<span class="tile-val">£{weekChoreCash.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.criteriaValue > 0
? Math.min(100, Math.round((goal.current / goal.criteriaValue) * 100))
: 0}
<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'}
🎁
{:else}
{goal.config.rewardValue} pts
{/if}
</span>
</div>
<div class="goal-bar-wrap">
<div class="goal-bar-fill" style="width:{pct}%"></div>
</div>
<div class="goal-foot">
<span class="goal-progress"
>{Math.min(goal.current, goal.criteriaValue)} / {goal.criteriaValue}</span
>
{#if goal.achieved}
<span class="goal-badge">✅ earned</span>
{:else}
<span class="goal-pct">{pct}%</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<!-- KANBAN -->
<div class="kanban">
<div class="column col-daily">
<h2>🎯 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}>
<span class="checkbox"></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>
📅 Weekly ({weeklyPending.length +
memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length})
</h2>
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length === 0}
<p class="empty">All done!</p>
{:else}
{#each weeklyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={!!togglingIds}>
<span class="checkbox"></span>
<span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span>
</button>
{/each}
{#each memberTodos.filter((t) => !isCompleted(t.id, todayChild)) as todo}
{@const urgency = todoUrgency(todo)}
{#if todo.type === 'emoji'}
<div
class="chore todo-chore todo-display"
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'}
>
<span class="checkbox">📋</span>
<span class="chore-name">{todo.customName || 'Todo'}</span>
<span class="todo-label">todo</span>
<span class="chore-value">🎯</span>
</div>
{: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'}
onclick={() => toggle(todo)}
disabled={!!togglingIds}
>
<span class="checkbox">📋</span>
<span class="chore-name">{todo.customName || 'Todo'}</span>
<span class="todo-label">todo</span>
<span class="chore-value">{todo.value} {todo.type}</span>
</button>
{/if}
{/each}
{/if}
</div>
<div class="column col-done">
<h2>✅ 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}
<button class="chore done" onclick={() => toggle(chore)} disabled={!!togglingIds}>
<span class="checkbox"></span>
<span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span>
</button>
{/if}
{/each}
{/if}
</div>
</div>
<!-- WALLET / CLAIMS -->
<div class="wallet">
<div class="wallet-top">
<span class="wallet-lbl">💰 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>
{#if pendingRewards.length === 0}
<p class="wallet-empty">No rewards waiting</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'}
🎁
{:else}
{r.value} pts
{/if}
</span>
{#if r.rewardType === 'points'}
<span class="wr-pending">✓ credited</span>
{:else if isReq}
<span class="wr-pending">⏳ waiting</span>
{:else if paydayLocked(r)}
<span class="wr-pending wr-locked"
>🔒 pays out {formatShortDate(r.settleDate)}</span
>
{:else}
<button
class="wr-cta"
onclick={async () => {
try {
await memberApi.claimReward(deviceToken, 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: white;
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;
}
.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;
}
.done-list li {
font-size: 0.75rem;
padding: 0.05rem 0;
color: #059669;
display: flex;
align-items: center;
gap: 0.25rem;
}
.revoke-form {
display: inline;
}
.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: 0.75rem;
}
.member-claims:last-child {
margin-bottom: 0;
}
.member-claims h4 {
display: flex;
align-items: center;
gap: 0.3rem;
margin: 0 0 0.2rem;
font-size: 0.85rem;
}
.payment-row {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
padding: 0.1rem 0;
}
.payment-row .value {
font-weight: 600;
color: #059669;
}
.payment-right {
display: flex;
align-items: center;
gap: 0.4rem;
}
.respond-label {
font-size: 0.8rem;
color: #6b7280;
margin: 0.4rem 0 0.3rem;
}
.tags {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
}
.tag {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 999px;
background: white;
cursor: pointer;
}
.tag:hover {
background: #f3f4f6;
}
.tag.selected {
background: #6366f1;
color: white;
border-color: #6366f1;
}
.custom-msg {
margin-top: 0.3rem;
padding: 0.3rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.8rem;
width: 100%;
box-sizing: border-box;
}
.error {
color: #dc2626;
padding: 1rem;
text-align: center;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
margin: 0.5rem 0 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.member-total {
font-size: 0.85rem;
font-weight: 600;
color: #2563eb;
margin: 0 0 0.25rem;
}
.payment-row.outstanding {
opacity: 0.6;
}
.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);
}
.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-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: 12px;
background: rgba(255, 255, 255, 0.28);
}
.hero-fill {
background: #fff;
}
.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;
}
.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-null {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7);
}
.hero-days {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.9);
}
.hero-days b {
font-size: 1.4rem;
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: #fff;
color: #6d28d9;
}
/* ── 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: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
.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: 0.85rem;
font-weight: 800;
color: #6366f1;
white-space: nowrap;
}
.goal-achieved .goal-reward {
color: #059669;
}
.goal-bar-wrap {
height: 8px;
background: #f3f4f6;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.35rem;
}
.goal-bar-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 4px;
transition: width 0.3s ease;
}
.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;
}
/* ── Kanban ── */
.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;
}
.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-display {
cursor: default;
opacity: 0.85;
}
.todo-display:hover {
transform: none;
box-shadow: none;
border-top-color: #e5e7eb;
border-right-color: #e5e7eb;
border-bottom-color: #e5e7eb;
}
.chore:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.done {
opacity: 0.7;
}
.checkbox {
font-size: 1rem;
flex-shrink: 0;
}
.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-total {
display: flex;
gap: 0.4rem;
align-items: center;
font-size: 0.9rem;
font-weight: 800;
}
.wallet-cash {
color: #059669;
}
.wallet-pts {
color: #7c3aed;
}
.wallet-dot {
color: #d1d5db;
}
.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-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-desc {
margin: 0 0 0.6rem;
}
.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;
}
.preview-notice {
margin-bottom: 1rem;
padding: 0.85rem 1.1rem;
border-radius: 12px;
border: 1px dashed #f59e0b;
background: #fffbeb;
color: #92400e;
font-size: 0.95rem;
line-height: 1.4;
}
.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, #4f46e5, #7c3aed);
color: #fff;
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.35);
}
.pd-icon {
font-size: 1.8rem;
}
.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;
}
.eow-out {
margin-top: 0.75rem;
border-top: 1px dashed #e5e7eb;
padding-top: 0.6rem;
}
.eow-range,
.eow-reset {
margin: 0.2rem 0;
font-size: 0.85rem;
}
.eow-reset {
color: #6b7280;
font-size: 0.8rem;
}
.eow-summaries {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin: 0.6rem 0;
}
.eow-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0.5rem;
border: 1px solid #eeeff2;
border-radius: 8px;
background: #fafbfc;
font-size: 0.85rem;
}
.eow-name {
flex: 1;
font-weight: 600;
}
.eow-pts {
color: #7c3aed;
font-weight: 700;
}
.eow-money {
color: #059669;
font-weight: 700;
}
.eow-chores {
color: #6b7280;
}
.eow-sec {
font-size: 0.85rem;
font-weight: 700;
margin: 0.6rem 0 0.3rem;
}
.eow-rewards {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.eow-reward {
font-size: 0.8rem;
padding: 0.3rem 0.55rem;
background: #eef2ff;
color: #4338ca;
border-radius: 6px;
}
.eow-none {
font-size: 0.8rem;
color: #9ca3af;
}
</style>