fix some issues - including add ledger page and fix reoccuring completions

This commit is contained in:
JCEEE
2026-08-05 09:03:08 +01:00
parent 6e817be13f
commit d162ea762e
14 changed files with 993 additions and 546 deletions
+66 -26
View File
@@ -1,6 +1,15 @@
<script lang="ts">
import { page } from '$app/state';
import { dashboardIcon, choresIcon, rewardsIcon, bonusesIcon, homeIcon, settingsIcon, logoutIcon, prefsIcon } from './icons';
import {
dashboardIcon,
choresIcon,
rewardsIcon,
bonusesIcon,
homeIcon,
settingsIcon,
logoutIcon,
prefsIcon
} from './icons';
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
@@ -9,30 +18,41 @@
let famSlug = $derived(page.params.fam);
let memberName = $derived(session?.memberName || page.params.username || '');
function toggle() { collapsed = !collapsed; }
function toggle() {
collapsed = !collapsed;
}
let showChildItems = $derived(!!memberName);
let navItems = $derived(isParent && memberName
? [
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
{ href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: rewardsIcon },
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon },
]
: showChildItems
let navItems = $derived(
isParent && memberName
? [
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
]
: []
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: rewardsIcon },
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon }
]
: showChildItems
? [
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon }
]
: []
);
let footerItems = $derived([
...(memberName ? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }] : []),
...(isParent && memberName ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] : []),
{ href: session ? '/logout' : '/login', label: session ? 'Log out' : 'Log in', icon: logoutIcon },
...(memberName
? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }]
: []),
...(isParent && memberName
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
: []),
{
href: session ? '/logout' : '/login',
label: session ? 'Log out' : 'Log in',
icon: logoutIcon
}
]);
</script>
@@ -68,7 +88,8 @@
<style>
.sidebar {
position: fixed;
top: 0; left: 0;
top: 0;
left: 0;
height: 100vh;
width: 220px;
background: #1e1b4b;
@@ -79,10 +100,13 @@
z-index: 100;
overflow: hidden;
}
.sidebar.collapsed { width: 56px; }
.sidebar.collapsed {
width: 56px;
}
.toggle-btn {
position: absolute;
top: 0.5rem; right: 0.5rem;
top: 0.5rem;
right: 0.5rem;
background: none;
border: none;
color: #a5b4fc;
@@ -99,8 +123,15 @@
border-bottom: 1px solid #3730a3;
min-height: 52px;
}
.app-icon { font-size: 1.3rem; flex-shrink: 0; }
.app-name { font-weight: 700; font-size: 1.05rem; white-space: nowrap; }
.app-icon {
font-size: 1.3rem;
flex-shrink: 0;
}
.app-name {
font-weight: 700;
font-size: 1.05rem;
white-space: nowrap;
}
.sidebar-nav {
flex: 1;
padding: 0.5rem 0;
@@ -128,7 +159,16 @@
white-space: nowrap;
transition: background 0.15s;
}
.nav-item:hover { background: #3730a3; color: #e0e7ff; }
.nav-item.active { background: #4338ca; color: #fff; font-weight: 600; }
.nav-label { overflow: hidden; }
.nav-item:hover {
background: #3730a3;
color: #e0e7ff;
}
.nav-item.active {
background: #4338ca;
color: #fff;
font-weight: 600;
}
.nav-label {
overflow: hidden;
}
</style>
+20 -6
View File
@@ -1,20 +1,34 @@
// Format a YYYY-MM-DD (or ISO) string as DDMMYY for user-facing dates.
// Two date display views used across the app:
// - Data view: `08-08-26` (dashed DD-MM-YY) — compact, tabular-friendly.
// - Human view: weekday in a badge (`Thursday`) with month/year added per requirement.
// Format a YYYY-MM-DD (or ISO) string as a compact dashed data-view date.
export function formatDDMMYY(dateStr: string | undefined): string {
if (!dateStr) return '';
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
if (Number.isNaN(d.getTime())) return '';
const day = String(d.getDate()).padStart(2, '0');
const mon = String(d.getMonth() + 1).padStart(2, '0');
const yr = String(d.getFullYear()).slice(2);
return `${day}${mon}${yr}`;
return `${day}-${mon}-${yr}`;
}
// Human-friendly date for due dates etc: "5 Aug" (adds year when not the
// current one, e.g. "5 Aug 26"). Better than the compact DDMMYY code.
export function formatShortDate(dateStr: string | undefined): string {
// Human view — the weekday name ("Thursday"). Render this inside a badge.
export function formatWeekday(dateStr: string | undefined): string {
if (!dateStr) return '';
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('en-GB', { weekday: 'long' });
}
// Human view — weekday + short date ("Thursday 7 Aug", adds year when not the
// current one, e.g. "Thursday 7 Aug 26").
export function formatHumanDate(dateStr: string | undefined): string {
if (!dateStr) return '';
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
if (Number.isNaN(d.getTime())) return '';
const weekday = d.toLocaleDateString('en-GB', { weekday: 'long' });
const mon = d.toLocaleDateString('en-GB', { month: 'short' });
const sameYear = d.getFullYear() === new Date().getFullYear();
return `${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`;
return `${weekday} ${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`;
}
+252 -225
View File
@@ -4,7 +4,7 @@
import { onMount } from 'svelte';
import { famStore } from '$lib/stores/fam.svelte';
import { memberApi } from '$lib/client/api';
import { formatDDMMYY, formatShortDate } from '$lib/format';
import { formatDDMMYY, formatHumanDate } from '$lib/format';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
import {
@@ -15,7 +15,8 @@
wallClockToUtc,
resolveTz,
periodStart,
periodEnd
periodEnd,
isCompleteForPeriod
} from '../../../../../timezone.ts';
interface Notification {
@@ -254,7 +255,8 @@
Math.max(
0,
Math.round(
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() - new Date(todayIso + 'T00:00:00').getTime()) /
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() -
new Date(todayIso + 'T00:00:00').getTime()) /
86400000
)
)
@@ -541,14 +543,34 @@
}
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
);
}
function findCompletion(assignedChoreId: string) {
// 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);
}
function findCompletion(chore: AssignedChore) {
const match = (c: Completion) =>
c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === todayChild;
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) === todayChild;
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
return optimistic || completions.find(match);
}
@@ -557,9 +579,9 @@
if (togglingIds) return;
togglingIds = chore.id;
const wasCompleted = isCompleted(chore.id, todayChild);
const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, todayChild);
if (wasCompleted) {
const existing = findCompletion(chore.id);
const existing = findCompletion(chore);
if (existing) {
famStore.applyRecord('completions', existing, 'delete');
}
@@ -653,225 +675,228 @@
<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>
<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>
<span class="count">{done}/{total}</span>
</div>
{#if done > 0}
<ul class="done-list">
{#each todays as c}
<li>
✅ {choreNameFor(c.assignedChoreId)}
{/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="?/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"
action="?/trigger"
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>
<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>
{/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];
}}
/>
{: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>
</div>
{/each}
{/if}
{/each}
{/if}
</Card>
</CardGrid>
</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">🔒 {formatHumanDate(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>
@@ -966,8 +991,7 @@
{:else}
{#if simulateEow}
<div class="preview-notice">
👀 <b>Payday preview</b> — your parent is checking this week's payday. Nothing is paid
out yet.
👀 <b>Payday preview</b> — your parent is checking this week's payday. Nothing is paid out yet.
</div>
{/if}
{#if owedCash > 0}
@@ -1205,10 +1229,9 @@
</div>
<div class="column col-weekly">
<h2>
📅 Weekly ({weeklyPending.length +
memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length})
📅 Weekly ({weeklyPending.length + memberTodos.filter((t) => !isTodoDone(t.id)).length})
</h2>
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length === 0}
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isTodoDone(t.id)).length === 0}
<p class="empty">All done!</p>
{:else}
{#each weeklyPending as chore}
@@ -1218,7 +1241,7 @@
<span class="chore-value">{chore.value} {chore.type}</span>
</button>
{/each}
{#each memberTodos.filter((t) => !isCompleted(t.id, todayChild)) as todo}
{#each memberTodos.filter((t) => !isTodoDone(t.id)) as todo}
{@const urgency = todoUrgency(todo)}
{#if todo.type === 'emoji'}
<div
@@ -1307,8 +1330,7 @@
{: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
<span class="wr-pending wr-locked">🔒 pays out {formatHumanDate(r.settleDate)}</span
>
{:else}
<button
@@ -1369,7 +1391,10 @@
padding: 0.65rem 0.85rem;
background: white;
margin-bottom: 0.6rem;
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
transition:
transform 0.15s,
box-shadow 0.15s,
border-color 0.15s;
}
.member-card:hover {
transform: translateY(-1px);
@@ -1478,7 +1503,9 @@
padding: 0.65rem 0.85rem;
background: #f0fdf4;
margin-bottom: 0.6rem;
transition: transform 0.15s, box-shadow 0.15s;
transition:
transform 0.15s,
box-shadow 0.15s;
}
.trigger-card:hover {
transform: translateY(-1px);
@@ -262,7 +262,6 @@
<p class="empty">No templates yet</p>
{/if}
</div>
</div>
<Button onclick={() => (showCreateModal = true)}>New Template</Button>
</div>
</Card>
@@ -3,12 +3,14 @@
import { page } from '$app/state';
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card } from '$lib/components';
import { formatShortDate } from '$lib/format';
import { formatHumanDate } from '$lib/format';
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
// ── Chevron SVG icons ──
const CHEVRON_DOWN = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
const CHEVRON_UP = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>';
const CHEVRON_DOWN =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
const CHEVRON_UP =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>';
let { data, form } = $props();
@@ -354,7 +356,6 @@
<p class="empty">No templates yet</p>
{/if}
</div>
</div>
<button class="add-inline" onclick={() => (showCreateModal = true)}> New template</button>
</div>
</Card>
@@ -369,53 +370,106 @@
ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, m.id)}
>
<h3>
<span class="dot" style="background:{m.color}"></span>
{m.name}
</h3>
<h3>
<span class="dot" style="background:{m.color}"></span>
{m.name}
</h3>
<!-- TODOS accordion wrapper -->
<div class="accordion-wrapper accordion-todos">
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
<span class="accordion-chevron">{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span>
</button>
{#if accordionState[m.id]?.todos ?? true}
<div class="accordion-body">
{#each sortedTodosForMember(m.id) as a}
{@const completed = isTodoCompleted(a.id)}
{@const urgency = todoUrgency(a)}
<div
class="todo-admin-card"
class:todo-completed={completed}
class:todo-blue-bg={!completed && urgency === 'blue'}
class:todo-green-bg={!completed && urgency === 'green'}
class:todo-orange-bg={!completed && urgency === 'orange'}
class:todo-red-bg={!completed && urgency === 'red'}
class:todo-black-bg={!completed && urgency === 'black'}
onclick={() => !completed && openEdit(a)}
role="button"
tabindex={completed ? -1 : 0}
<!-- TODOS accordion wrapper -->
<div class="accordion-wrapper accordion-todos">
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
<span class="accordion-chevron"
>{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
>
</button>
{#if accordionState[m.id]?.todos ?? true}
<div class="accordion-body">
{#each sortedTodosForMember(m.id) as a}
{@const completed = isTodoCompleted(a.id)}
{@const urgency = todoUrgency(a)}
<div
class="todo-admin-card"
class:todo-completed={completed}
class:todo-blue-bg={!completed && urgency === 'blue'}
class:todo-green-bg={!completed && urgency === 'green'}
class:todo-orange-bg={!completed && urgency === 'orange'}
class:todo-red-bg={!completed && urgency === 'red'}
class:todo-black-bg={!completed && urgency === 'black'}
onclick={() => !completed && openEdit(a)}
role="button"
tabindex={completed ? -1 : 0}
>
<div class="todo-admin-body">
<strong class="todo-admin-name">{a.customName || 'Todo'}</strong>
<div class="todo-admin-meta">
{#if a.type === 'emoji'}
<span class="todo-admin-type">🎯 emoji</span>
{:else}
<span class="todo-admin-type"
><span class="badge badge-pts">{a.value} pts</span></span
>
{/if}
{#if a.completeBy}
<span class="todo-admin-deadline"
>due {formatHumanDate(a.completeBy)}</span
>
{/if}
</div>
</div>
{#if completed}
<span class="todo-completed-badge">✅ TBC completed</span>
{:else}
<button
class="del-btn"
title="Remove todo"
onclick={async (e) => {
e.stopPropagation();
const s = page.data.session as any;
if (!s) return;
await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
method: 'DELETE',
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId }
});
}}>×</button
>
{/if}
</div>
{/each}
<button class="add-inline accordion-add" onclick={() => openTodo(m.id)}
> Add a todo</button
>
<div class="todo-admin-body">
<strong class="todo-admin-name">{a.customName || 'Todo'}</strong>
<div class="todo-admin-meta">
{#if a.type === 'emoji'}
<span class="todo-admin-type">🎯 emoji</span>
{:else}
<span class="todo-admin-type"><span class="badge badge-pts">{a.value} pts</span></span>
{/if}
{#if a.completeBy}
<span class="todo-admin-deadline">due {formatShortDate(a.completeBy)}</span>
</div>
{/if}
</div>
<!-- CHORES accordion wrapper -->
<div class="accordion-wrapper accordion-chores">
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span>
<span class="accordion-chevron"
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
>
</button>
{#if accordionState[m.id]?.chores ?? true}
<div class="accordion-body">
{#each assignedForMember(m.id) as a}
{@const tName = templateName(a.templateId)}
<div class="card assigned" onclick={() => openEdit(a)} role="button" tabindex="0">
<div class="card-body">
<strong>{a.customName || tName}</strong>
<span class="badge">{a.frequency}</span>
<span class="badge type">{a.type}</span>
{#if seasonFilter !== 'all' && isGlobalChore(a)}
<span class="badge season">Add to</span>
{/if}
</div>
</div>
{#if completed}
<span class="todo-completed-badge">✅ TBC completed</span>
{:else}
<div class="card-value">
{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : `${a.value} pts`}
</div>
<button
class="del-btn"
title="Remove todo"
title="Remove assignment"
onclick={async (e) => {
e.stopPropagation();
const s = page.data.session as any;
@@ -426,57 +480,14 @@
});
}}>×</button
>
{/if}
</div>
{/each}
<button class="add-inline accordion-add" onclick={() => openTodo(m.id)}> Add a todo</button>
</div>
{/if}
</div>
<!-- CHORES accordion wrapper -->
<div class="accordion-wrapper accordion-chores">
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span>
<span class="accordion-chevron">{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span>
</button>
{#if accordionState[m.id]?.chores ?? true}
<div class="accordion-body">
{#each assignedForMember(m.id) as a}
{@const tName = templateName(a.templateId)}
<div class="card assigned" onclick={() => openEdit(a)} role="button" tabindex="0">
<div class="card-body">
<strong>{a.customName || tName}</strong>
<span class="badge">{a.frequency}</span>
<span class="badge type">{a.type}</span>
{#if seasonFilter !== 'all' && isGlobalChore(a)}
<span class="badge season">Add to</span>
{/if}
</div>
<div class="card-value">
{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : `${a.value} pts`}
</div>
<button
class="del-btn"
title="Remove assignment"
onclick={async (e) => {
e.stopPropagation();
const s = page.data.session as any;
if (!s) return;
await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
method: 'DELETE',
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId }
});
}}>×</button
>
</div>
{/each}
{#if assignedForMember(m.id).length === 0}
<p class="empty">Drop a chore here</p>
{/if}
</div>
{/if}
</div>
{/each}
{#if assignedForMember(m.id).length === 0}
<p class="empty">Drop a chore here</p>
{/if}
</div>
{/if}
</div>
</div>
{/each}
{#if members.length < 3}
@@ -819,7 +830,9 @@
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
transition:
background 0.15s,
border-color 0.15s;
text-align: center;
}
.template-section > .add-inline {
@@ -828,13 +841,13 @@
.accordion-add {
margin-bottom: 0;
margin-top: 0.35rem;
background: rgba(255,255,255,0.25);
border-color: rgba(255,255,255,0.4);
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.4);
color: #fff;
}
.accordion-add:hover {
background: rgba(255,255,255,0.35);
border-color: rgba(255,255,255,0.6);
background: rgba(255, 255, 255, 0.35);
border-color: rgba(255, 255, 255, 0.6);
color: #fff;
}
.add-inline:hover {
@@ -852,7 +865,10 @@
align-items: center;
gap: 0.5rem;
position: relative;
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
transition:
transform 0.15s,
box-shadow 0.15s,
border-color 0.15s;
}
.card:hover {
transform: translateY(-1px);
@@ -905,7 +921,9 @@
line-height: 1;
flex-shrink: 0;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
transition:
color 0.15s,
background 0.15s;
}
.edit-btn:hover {
color: #6366f1;
@@ -1056,7 +1074,7 @@
width: 100%;
padding: 0.55rem 0.85rem;
border: none;
border-left: 4px solid rgba(0,0,0,0.2);
border-left: 4px solid rgba(0, 0, 0, 0.2);
background: transparent;
cursor: pointer;
font-size: 0.82rem;
@@ -1072,7 +1090,7 @@
}
.accordion-body {
padding: 0.5rem 0.65rem 0.65rem;
background: rgba(0,0,0,0.08);
background: rgba(0, 0, 0, 0.08);
}
.accordion-chevron {
display: flex;
@@ -1093,7 +1111,10 @@
display: flex;
align-items: center;
gap: 0.4rem;
transition: transform 0.15s, box-shadow 0.15s, opacity 0.2s;
transition:
transform 0.15s,
box-shadow 0.15s,
opacity 0.2s;
}
.todo-admin-card:hover {
transform: translateY(-1px);
@@ -1185,8 +1206,8 @@
45deg,
transparent,
transparent 8px,
rgba(0,0,0,0.02) 8px,
rgba(0,0,0,0.02) 16px
rgba(0, 0, 0, 0.02) 8px,
rgba(0, 0, 0, 0.02) 16px
);
border-style: dashed;
opacity: 0.5;
@@ -4,11 +4,14 @@ import { hono } from '$lib/server/hono';
export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const [rewards, members] = await Promise.all([
const [rewards, members, assigned, templates, completions] = await Promise.all([
hono.admin.rewards(event, famId),
hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'assigned-chores', famId),
hono.admin.list(event, 'chore-templates', famId),
hono.admin.completions(event, famId)
]);
return { rewards, members };
return { rewards, members, assigned, templates, completions };
}
export const actions = {
@@ -23,5 +26,5 @@ export const actions = {
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
}
},
}
};
@@ -0,0 +1,359 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import { formatDDMMYY } from '$lib/format';
import type { Member, AssignedChore, ChoreTemplate, Completion, Reward } from '$lib/types';
let { data } = $props();
// Derived from famStore so SSE updates flow through (AGENTS.md rule).
let rewards = $derived(
famStore.initialized ? famStore.rewards : ((data.rewards || []) as Reward[])
);
let members = $derived(
famStore.initialized ? famStore.members : ((data.members || []) as Member[])
);
let assigned = $derived(
famStore.initialized ? famStore.assigned : ((data.assigned || []) as AssignedChore[])
);
let completions = $derived(
famStore.initialized ? famStore.completions : ((data.completions || []) as Completion[])
);
let templates = $derived(
famStore.initialized ? famStore.templates : ((data.templates || []) as ChoreTemplate[])
);
let activeTab = $state('rewards');
let memberMap = $derived.by(() => new Map(members.map((m: any) => [m.id, m])));
let templateMap = $derived.by(() => new Map(templates.map((t: any) => [t.id, t])));
let assignedMap = $derived.by(() => new Map(assigned.map((a: any) => [a.id, a])));
function memberName(memberId: string): string {
return memberMap.get(memberId)?.name || '?';
}
function memberColor(memberId: string): string {
return memberMap.get(memberId)?.color || '#6366f1';
}
function choreName(a: any): string {
return a?.customName || templateMap.get(a?.templateId)?.name || 'Chore';
}
// ── Rewards tab ──
function rewardAmount(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 rewardStatus(r: any): string {
if (r.status === 'unclaimed') return 'Outstanding';
if (r.status === 'requested') return 'Requested';
if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
return 'Claimed';
}
function isOutstanding(r: any): boolean {
return r.status === 'unclaimed';
}
function isRequested(r: any): boolean {
return r.status === 'requested';
}
let sortedRewards = $derived(
[...rewards].sort((a: any, b: any) => {
const da = a.date || a.id || '';
const db = b.date || b.id || '';
return da < db ? 1 : da > db ? -1 : 0;
})
);
let totalOutstanding = $derived(
rewards
.filter((r: any) => r.status !== 'claimed')
.filter((r: any) => r.rewardType === 'cash')
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
);
// ── Chores / Todos tabs ──
let choreCompletions = $derived(
completions.filter((c: any) => !assignedMap.get(c.assignedChoreId)?.isTodo)
);
let todoCompletions = $derived(
completions.filter((c: any) => assignedMap.get(c.assignedChoreId)?.isTodo)
);
let sortedChoreCompletions = $derived(
[...choreCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
);
let sortedTodoCompletions = $derived(
[...todoCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
);
let toast = $state('');
async function fire() {
const { default: confetti } = await import('@hiseb/confetti');
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);
}
</script>
<ViewHeader
title="Ledger"
subtitle="Rewards, chores and todos earned by members"
hero
tabs={{
items: [
{ label: 'Rewards', value: 'rewards' },
{ label: 'Chores', value: 'chores' },
{ label: 'Todos', value: 'todos' }
],
active: activeTab,
onchange: (v: string) => (activeTab = v)
}}
/>
{#if toast}
<div class="toast">{toast}</div>
{/if}
<CardGrid>
{#if activeTab === 'rewards'}
<Card cols={3}>
{#if rewards.length === 0}
<p class="empty">
No rewards yet. Points earned will automatically create bonus rewards when thresholds are
met.
</p>
{:else}
<table>
<thead>
<tr>
<th>Date</th>
<th>Member</th>
<th>Description</th>
<th class="num">Amount</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{#each sortedRewards as r}
{@const outstanding = isOutstanding(r)}
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}>
<td class="date">{r.date ? formatDDMMYY(r.date) : '—'}</td>
<td>
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
{memberName(r.memberId)}
</td>
<td>{r.label}</td>
<td class="num">{rewardAmount(r)}</td>
<td>
<span
class="status-badge"
class:outstanding
class:requested-status={isRequested(r)}
class:claimed-status={!outstanding && !isRequested(r)}
>
{rewardStatus(r)}
</span>
</td>
<td>
{#if outstanding}
<form
method="POST"
action="?/claim"
use:enhance={() => {
return async (args: any) => {
const d = args.result.data || {};
if (d.error) showToast(d.error);
else if (args.result.type === 'success') fire();
};
}}
>
<input name="id" type="hidden" value={r.id} />
<Button type="submit" size="sm">Claim</Button>
</form>
{/if}
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td></td>
<td></td>
<td class="num"><strong>Outstanding cash</strong></td>
<td class="num"><strong>£{totalOutstanding.toFixed(2)}</strong></td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
{/if}
</Card>
{:else if activeTab === 'chores'}
<Card cols={3} scrollX title="Chore completions">
{#if sortedChoreCompletions.length === 0}
<p class="empty">No chore completions yet.</p>
{:else}
<table>
<thead>
<tr>
<th>Date</th>
<th>Member</th>
<th>Chore</th>
<th class="num">Value</th>
</tr>
</thead>
<tbody>
{#each sortedChoreCompletions as c}
{@const a = assignedMap.get(c.assignedChoreId)}
<tr>
<td class="date">{formatDDMMYY(c.date)}</td>
<td>
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
{memberName(c.memberId)}
</td>
<td>{choreName(a)}</td>
<td class="num">
{#if a?.type === 'money'}£{Number(a.value).toFixed(2)}
{:else}{a?.value ?? 0} pts{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Card>
{:else}
<Card cols={3} scrollX title="Todo completions">
{#if sortedTodoCompletions.length === 0}
<p class="empty">No todos completed yet.</p>
{:else}
<table>
<thead>
<tr>
<th>Completed</th>
<th>Member</th>
<th>Todo</th>
<th>Due</th>
</tr>
</thead>
<tbody>
{#each sortedTodoCompletions as c}
{@const a = assignedMap.get(c.assignedChoreId)}
<tr>
<td class="date">{formatDDMMYY(c.date)}</td>
<td>
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
{memberName(c.memberId)}
</td>
<td>{choreName(a)}</td>
<td class="date">{a?.completeBy ? formatDDMMYY(a.completeBy) : '—'}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</Card>
{/if}
</CardGrid>
<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);
}
.empty {
color: #9ca3af;
text-align: center;
padding: 2rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
padding: 0.5rem 0.6rem;
border-bottom: 1px solid #e5e7eb;
text-align: left;
}
th {
background: #f9fafb;
font-weight: 600;
position: sticky;
top: 0;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.date {
font-family: monospace;
font-size: 0.85rem;
color: #6b7280;
white-space: nowrap;
}
tr.claimed {
opacity: 0.5;
}
tr.claimed td {
color: #9ca3af;
}
tr.requested {
background: #eff6ff;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.3rem;
}
.status-badge {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 10px;
}
.status-badge.outstanding {
background: #fef3c7;
color: #92400e;
}
.status-badge.requested-status {
background: #dbeafe;
color: #1e40af;
}
.status-badge.claimed-status {
background: #d1fae5;
color: #065f46;
}
tfoot td {
border-top: 2px solid #d1d5db;
padding-top: 0.6rem;
}
</style>
@@ -1,141 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
let { data } = $props();
let rewards = $state(famStore.initialized ? famStore.rewards : (data.rewards || []))
let members = $state(famStore.initialized ? famStore.members : (data.members || []))
function memberName(memberId: string): string {
return famStore.memberMap().get(memberId)?.name || members.find((m: any) => m.id === memberId)?.name || '?';
}
function memberColor(memberId: string): string {
return famStore.memberMap().get(memberId)?.color || members.find((m: any) => m.id === memberId)?.color || '#6366f1';
}
function rewardAmount(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 rewardStatus(r: any): string {
if (r.status === 'unclaimed') return 'Outstanding';
if (r.status === 'requested') return 'Requested';
if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
return 'Claimed';
}
function isOutstanding(r: any): boolean { return r.status === 'unclaimed'; }
function isRequested(r: any): boolean { return r.status === 'requested'; }
let sorted = $derived([...rewards].sort((a: any, b: any) => {
const da = a.date || a.id || '';
const db = b.date || b.id || '';
return da < db ? 1 : da > db ? -1 : 0;
}))
let toast = $state('')
async function fire() {
const { default: confetti } = await import('@hiseb/confetti');
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);
}
let totalOutstanding = $derived(
rewards
.filter((r: any) => r.status !== 'claimed')
.filter((r: any) => r.rewardType === 'cash')
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
)
</script>
<ViewHeader title="Reward Ledger" subtitle="All rewards earned by members" hero />
{#if toast}
<div class="toast">{toast}</div>
{/if}
<CardGrid>
<Card cols={3}>
{#if rewards.length === 0}
<p style="color:#9ca3af;text-align:center;padding:2rem">No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.</p>
{:else}
<table>
<thead>
<tr>
<th>Date</th>
<th>Member</th>
<th>Description</th>
<th class="num">Amount</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{#each sorted as r}
{@const outstanding = isOutstanding(r)}
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}>
<td class="date">{r.date?.slice(0, 10) || '—'}</td>
<td>
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
{memberName(r.memberId)}
</td>
<td>{r.label}</td>
<td class="num">{rewardAmount(r)}</td>
<td>
<span class="status-badge" class:outstanding class:requested-status={isRequested(r)} class:claimed-status={!outstanding && !isRequested(r)}>
{rewardStatus(r)}
</span>
</td>
<td>
{#if outstanding}
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { fire(); } }; }}>
<input name="id" type="hidden" value={r.id} />
<Button type="submit" size="sm">Claim</Button>
</form>
{/if}
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td></td>
<td></td>
<td class="num"><strong>Outstanding cash</strong></td>
<td class="num"><strong>£{totalOutstanding.toFixed(2)}</strong></td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
{/if}
</Card>
</CardGrid>
<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); }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { padding: 0.5rem 0.6rem; border-bottom: 1px solid #e5e7eb; text-align: left; }
th { background: #f9fafb; font-weight: 600; position: sticky; top: 0; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.date { font-family: monospace; font-size: 0.85rem; color: #6b7280; white-space: nowrap; }
tr.claimed { opacity: 0.5; }
tr.claimed td { color: #9ca3af; }
tr.requested { background: #eff6ff; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.3rem; }
.status-badge { font-size: 0.75rem; padding: 2px 8px; border-radius: 10px; }
.status-badge.outstanding { background: #fef3c7; color: #92400e; }
.status-badge.requested-status { background: #dbeafe; color: #1e40af; }
.status-badge.claimed-status { background: #d1fae5; color: #065f46; }
tfoot td { border-top: 2px solid #d1d5db; padding-top: 0.6rem; }
</style>