Files
famdone/frontend/src/routes/[fam]/[username]/+page.svelte
T
2026-07-31 16:13:05 +01:00

1595 lines
40 KiB
Svelte

<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 { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
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);
// ─── Parent View (admin overview) ───
let summary = $state(data.summary);
let today = $state(new Date().toISOString().slice(0, 10));
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 || []
);
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.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.memberId === memberId && r.status === 'requested');
}
function memberOutstanding(memberId: string) {
return parentRewards.filter((r: any) => r.memberId === memberId && r.status === 'unclaimed');
}
function memberRequestedTotal(memberId: string) {
return memberRequested(memberId)
.filter((r: any) => r.rewardType === 'cash')
.reduce((sum: number, r: any) => sum + Number(r.value), 0);
}
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 requestedRewards = $state<string[]>([]);
let claimError = $state('');
let todayChild = $state(new Date().toISOString().slice(0, 10));
let togglingIds = $state<string>('');
function weekStartStr(): string {
const d = new Date();
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
d.setDate(diff);
return d.toISOString().slice(0, 10);
}
function monthStartStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
}
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.seasonIds || a.seasonIds.length === 0) return true;
if (activeSeasonIds.size === 0) return true;
return a.seasonIds.some((sid) => activeSeasonIds.has(sid));
});
});
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))
);
let completedToday = $derived(
completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === todayChild)
);
let claimableRewardsChild = $derived(
rewards.filter((r) => r.memberId === memberId && r.status === 'unclaimed')
);
let requestedRewardsChild = $derived(
rewards.filter((r) => r.memberId === memberId && r.status === 'requested')
);
let weeklyTotal = $derived.by(() => {
const dailyChores = memberChores.filter((a) => a.frequency === 'daily');
const weeklyChores = memberChores.filter((a) => a.frequency === 'weekly');
return dailyChores.length * 7 + weeklyChores.length;
});
let weeklyDone = $derived.by(() => {
const ws = weekStartStr();
return completions.filter(
(c) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) >= ws
).length;
});
let totalCash = $derived.by(() => {
const myCompletions = completions.filter((c) => c.memberId === memberId);
const myRewards = rewards.filter((r) => r.memberId === memberId);
return (
myCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
}, 0) +
myRewards.filter((r) => r.rewardType === 'cash').reduce((sum, r) => sum + Number(r.value), 0)
);
});
let pendingCash = $derived.by(() => {
const myRewards = rewards.filter((r) => r.memberId === memberId);
return myRewards
.filter((r) => r.rewardType === 'cash' && r.status !== 'claimed')
.reduce((sum, r) => sum + Number(r.value), 0);
});
let totalPoints = $derived.by(() => {
const myCompletions = completions.filter((c) => c.memberId === memberId);
const myRewards = rewards.filter((r) => r.memberId === memberId);
return (
myCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0) +
myRewards
.filter((r) => r.rewardType === 'points')
.reduce((sum, r) => sum + Number(r.value), 0)
);
});
let bonusNotices = $derived.by(() => {
const myRewards = rewards.filter((r) => r.memberId === memberId);
return myRewards.filter((r) => r.status === 'claimed' && r.claimedAt?.slice(0, 10) === r.date);
});
let bonusProgresses = $derived.by(() => {
const ws = weekStartStr();
const myCompletions = completions.filter((c) => c.memberId === memberId);
const activeConfigs = bonusConfigs.filter(
(b: BonusConfig) =>
b.status === 'active' &&
b.target === 'individual' &&
(!b.memberId || b.memberId === memberId)
);
return activeConfigs.map((cfg) => {
const pStart = cfg.period === 'weekly' ? ws : cfg.period === 'monthly' ? monthStartStr() : '';
const pEnd = pStart
? (() => {
if (cfg.period === 'weekly') {
const d = new Date(pStart);
d.setDate(d.getDate() + 6);
return d.toISOString().slice(0, 10);
}
if (cfg.period === 'monthly') {
const [y, m] = pStart.split('-').map(Number);
const lastDay = new Date(y, m, 0).getDate();
return `${pStart.slice(0, 7)}-${String(lastDay).padStart(2, '0')}`;
}
return '';
})()
: '';
const periodCompletions = pStart
? myCompletions.filter(
(c) =>
(c.date?.slice(0, 10) || c.date) >= pStart && (c.date?.slice(0, 10) || c.date) <= pEnd
)
: myCompletions;
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;
}
return {
config: cfg,
current,
criteria: cfg.criteriaValue || 0,
periodStart: pStart,
periodEnd: pEnd
};
});
});
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';
}
function rewardLabelChild(r: Reward): string {
if (r.rewardType === 'cash') return ${Number(r.value).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`;
return r.label;
}
</script>
{#if role === 'parent'}
<!-- Parent View: Admin Overview -->
<ViewHeader
title={famStore.fam?.name || 'Dashboard'}
subtitle={summary?.weekStart ? `Week of ${summary.weekStart}` : ''}
/>
{#if toast}
<div class="toast">{toast}</div>
{/if}
<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 total = memberRequestedTotal(m.id)}
{#if requested.length > 0 || outstanding.length > 0}
<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)} requested</p>
{/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 requested.length > 1}
<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 ({requested.length})</Button
>
</form>
{/if}
{/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)}</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>
{:else}
<!-- Child View: Member Kanban -->
<ViewHeader title={memberName || username} />
<CardGrid>
<Card cols={3}>
<h1>
<span
class="color-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"
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="name-display"
role="button"
tabindex="0"
onclick={() => {
nameInput = memberName;
editingName = true;
}}
onkeydown={(e) => e.key === 'Enter' && ((nameInput = memberName), (editingName = true))}
>
{memberName || username}
</span>
{/if}
</h1>
{#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}
{#if loading}
<p>Loading...</p>
{:else if error}
<p class="error">{error}</p>
{:else}
<!-- Badge Cards -->
<div class="badges-row">
<div class="badge-card notice">
<span class="badge-label">Bonus Notices</span>
<span class="badge-value">{bonusNotices.length}</span>
{#if bonusNotices.length > 0}
<span class="badge-sub"
>+{bonusNotices.reduce((s, r) => s + Number(r.value), 0)} pts</span
>
{/if}
</div>
<div class="badge-card cash">
<span class="badge-label">Total Cash</span>
<span class="badge-value">£{totalCash.toFixed(2)}</span>
</div>
<div class="badge-card pending">
<span class="badge-label">Pending Cash</span>
<span class="badge-value">£{pendingCash.toFixed(2)}</span>
</div>
<div class="badge-card points">
<span class="badge-label">Total Points</span>
<span class="badge-value">{totalPoints}</span>
</div>
</div>
<!-- Weekly Progress -->
{#if weeklyTotal > 0}
{@const wpct = Math.round((weeklyDone / weeklyTotal) * 100)}
<div class="week-card">
<div class="week-head">
<span class="week-label">This Week</span>
<span class="week-stat">{weeklyDone}/{weeklyTotal} done</span>
</div>
<div class="bar-wrap lg">
<div class="bar-fill" style="width:{wpct}%"></div>
</div>
</div>
{/if}
<!-- Bonus Progress -->
{#if bonusProgresses.length > 0}
<div class="bonus-grid">
{#each bonusProgresses as bp}
{@const pct = bp.criteria > 0 ? Math.min(100, (bp.current / bp.criteria) * 100) : 0}
{@const hasReward = bp.periodStart
? rewards.some(
(r) =>
r.memberId === memberId &&
r.bonusConfigId === bp.config.id &&
r.date >= bp.periodStart &&
r.date <= bp.periodEnd
)
: rewards.some((r) => r.memberId === memberId && r.bonusConfigId === bp.config.id)}
{@const typeClass =
bp.config.type === 'threshold'
? 'type-threshold'
: bp.config.type === 'count'
? 'type-count'
: 'type-manual'}
<div class="bonus-badge {typeClass}" class:achieved={hasReward}>
<div class="bonus-head">
<span class="bonus-name">{bp.config.name}</span>
<span class="bonus-type">{bp.config.type}</span>
</div>
<div class="bonus-body">
{#if bp.criteria > 0}
<div class="bar-wrap">
<div class="bar-fill" style="width:{pct}%"></div>
</div>
<span class="bonus-stat"
>{bp.current}/{bp.criteria}
{bp.config.type === 'threshold' ? 'pts' : 'chores'}</span
>
{/if}
</div>
{#if hasReward}
<span class="bonus-done">Reward earned 🎉</span>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Kanban -->
<div class="kanban">
<div class="column">
<h2>Daily Pending ({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">
<h2>Weekly Pending ({weeklyPending.length})</h2>
{#if weeklyPending.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}
{/if}
</div>
<div class="column">
<h2>Completed Today ({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 class="column claim-col">
<h2>Claimable ({claimableRewardsChild.length})</h2>
{#if claimableRewardsChild.length === 0}
<p class="empty">Nothing to claim</p>
{:else}
{#if claimableRewardsChild.length > 1}
<button
class="request-all-btn"
onclick={async () => {
try {
await memberApi.requestAllRewards(deviceToken, famId);
} catch (e) {
claimError = e instanceof Error ? e.message : 'Request all failed';
}
}}
>
Request All
</button>
{/if}
{#each claimableRewardsChild as r}
<div class="claim-card">
<div class="claim-body">
<span class="claim-label">{r.label}</span>
<span class="claim-value">{rewardLabelChild(r)}</span>
</div>
<div class="claim-actions">
<span class="badge">{r.rewardType}</span>
<button
class="request-btn"
onclick={async () => {
try {
await memberApi.claimReward(deviceToken, famId, r.id);
} catch (e) {
claimError = e instanceof Error ? e.message : 'Claim failed';
}
}}
>
Request
</button>
</div>
</div>
{/each}
{/if}
</div>
<div class="column request-col">
<h2>Requested ({requestedRewardsChild.length})</h2>
{#if requestedRewardsChild.length === 0}
<p class="empty">No pending requests</p>
{:else}
{#each requestedRewardsChild as r}
<div class="claim-card requested">
<div class="claim-body">
<span class="claim-label">{r.label}</span>
<span class="claim-value">{rewardLabelChild(r)}</span>
</div>
<div class="claim-actions">
<span class="badge">{r.rewardType}</span>
<span class="requested-badge">Requested</span>
</div>
</div>
{/each}
{/if}
</div>
</div>
{/if}
</Card>
</CardGrid>
{/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-radius: 8px;
padding: 0.65rem;
background: white;
margin-bottom: 0.6rem;
}
.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: #6366f1;
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-radius: 8px;
padding: 0.65rem;
background: #f0fdf4;
margin-bottom: 0.6rem;
border-left: 3px solid #22c55e;
}
.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;
}
.badges-row {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.badge-card {
flex: 1;
min-width: 120px;
padding: 0.75rem;
border-radius: 8px;
background: #f9fafb;
border: 1px solid #e5e7eb;
}
.badge-label {
font-size: 0.75rem;
color: #6b7280;
display: block;
}
.badge-value {
font-size: 1.4rem;
font-weight: 700;
display: block;
}
.badge-sub {
font-size: 0.75rem;
color: #059669;
}
.badge-card.notice {
border-left: 3px solid #f59e0b;
}
.badge-card.cash {
border-left: 3px solid #10b981;
}
.badge-card.pending {
border-left: 3px solid #3b82f6;
}
.badge-card.points {
border-left: 3px solid #8b5cf6;
}
.week-card {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 10px;
padding: 0.85rem 1rem;
margin-bottom: 1rem;
}
.week-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.week-label {
font-weight: 700;
font-size: 0.95rem;
color: #166534;
}
.week-stat {
font-size: 0.8rem;
color: #16a34a;
font-weight: 600;
}
.bar-wrap.lg {
height: 14px;
}
.bonus-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.6rem;
margin-bottom: 1rem;
}
.bonus-badge {
border-radius: 10px;
padding: 0.7rem;
background: white;
border: 1px solid #e5e7eb;
border-left: 4px solid #9ca3af;
}
.bonus-badge.type-threshold {
border-left-color: #8b5cf6;
}
.bonus-badge.type-count {
border-left-color: #3b82f6;
}
.bonus-badge.type-manual {
border-left-color: #f59e0b;
}
.bonus-badge.achieved {
background: #f0fdf4;
border-color: #bbf7d0;
border-left-color: #22c55e;
}
.bonus-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.3rem;
}
.bonus-name {
font-size: 0.8rem;
font-weight: 600;
}
.bonus-type {
font-size: 0.6rem;
padding: 1px 6px;
border-radius: 6px;
background: #f3f4f6;
color: #6b7280;
text-transform: capitalize;
}
.bonus-body {
display: flex;
align-items: center;
gap: 0.5rem;
}
.bonus-body .bar-wrap {
flex: 1;
height: 8px;
}
.bonus-stat {
font-size: 0.7rem;
color: #6b7280;
white-space: nowrap;
font-weight: 600;
min-width: 70px;
text-align: right;
}
.bonus-done {
font-size: 0.7rem;
color: #16a34a;
font-weight: 600;
margin-top: 0.2rem;
display: block;
}
.kanban {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
gap: 0.75rem;
margin-top: 0.5rem;
}
.column {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0.75rem;
background: #f9fafb;
}
.column h2 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
}
.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: 6px;
background: white;
cursor: pointer;
text-align: left;
font-size: 0.85rem;
}
.chore:hover {
background: #f3f4f6;
}
.chore:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.done {
opacity: 0.65;
}
.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;
}
.claim-col {
border-left: 3px solid #10b981;
}
.claim-card {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem;
margin-bottom: 0.4rem;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: white;
}
.claim-body {
flex: 1;
}
.claim-label {
font-size: 0.8rem;
display: block;
}
.claim-value {
font-size: 0.9rem;
font-weight: 700;
color: #059669;
}
.claim-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
}
.badge {
font-size: 0.65rem;
padding: 1px 6px;
border-radius: 8px;
background: #d1fae5;
color: #059669;
white-space: nowrap;
}
.request-btn {
font-size: 0.7rem;
padding: 2px 8px;
background: #6366f1;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.request-btn:disabled {
background: #9ca3af;
cursor: default;
}
.request-all-btn {
display: block;
width: 100%;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: #2563eb;
color: white;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.request-all-btn:hover {
background: #1d4ed8;
}
.request-col {
border-color: #93c5fd;
background: #eff6ff;
}
.claim-card.requested {
opacity: 0.7;
}
.requested-badge {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 10px;
background: #dbeafe;
color: #1e40af;
font-weight: 600;
}
.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;
}
h1 {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-dot {
display: inline-block;
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #e5e7eb;
cursor: pointer;
flex-shrink: 0;
}
.name-display {
cursor: pointer;
border-bottom: 1px dashed #d1d5db;
}
.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);
}
</style>