update ui and other frontend updates

This commit is contained in:
JCEEE
2026-07-29 09:41:52 +01:00
parent 04b83925e0
commit 57ef30d3a7
79 changed files with 7323 additions and 429 deletions
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import { page } from '$app/state';
let { error } = $props();
</script>
<div class="error-page">
<h1>Something went wrong</h1>
<p>{error?.message || 'An unexpected error occurred.'}</p>
<p class="hint">Try <a href="/logout">logging out</a> and signing in again.</p>
</div>
<style>
.error-page { max-width: 480px; margin: 4rem auto; text-align: center; padding: 2rem; }
h1 { font-size: 1.5rem; color: #111827; margin-bottom: 0.5rem; }
p { color: #6b7280; }
.hint { margin-top: 1.5rem; font-size: 0.9rem; }
a { color: #6366f1; }
</style>
@@ -0,0 +1,4 @@
export function load(event) {
const session = event.locals.session || null;
return { session, isParent: session !== null, role: session?.role || 'child' };
}
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { initPbFromCookie } from '$lib/pocketbase';
import { famStore } from '$lib/stores/fam.svelte';
import { Sidebar, TopNav, Footer } from '$lib/components';
import type { Session } from '$lib/types';
let { children, data } = $props();
let session = $state<Session | null>(data.session);
let isParent = $state(data.isParent);
let role = $state(data.role);
let famName = $derived(famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam);
// Claim toast watcher (admin only)
let claimToast = $state('');
let prevClaimedIds = new Set<string>();
let mounted = false;
$effect(() => {
if (!session || !famStore.initialized) return;
const rewards = famStore.rewards;
if (!mounted) {
mounted = true;
prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id));
return;
}
for (const r of rewards) {
if (r.claimed && !prevClaimedIds.has(r.id)) {
const m = famStore.memberMap().get(r.memberId);
const name = m?.name || 'Unknown';
claimToast = `Claim made by ${name}`;
setTimeout(() => claimToast = '', 5000);
}
}
prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id));
});
onMount(() => {
initPbFromCookie();
if (session) {
famStore.init(session.famId);
}
});
</script>
<div class="app-shell">
<Sidebar {famName} session={data.session} {isParent} {role} />
<TopNav />
<main class="app-main">
{@render children()}
</main>
{#if claimToast}
<div class="claim-toast">{claimToast}</div>
{/if}
<Footer />
</div>
<style>
.claim-toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #059669; 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; }
.app-shell { min-height: 100vh; display: flex; flex-direction: column; }
.app-main {
margin-left: 220px;
margin-top: 48px;
padding: 1.5rem 2rem;
flex: 1;
transition: margin-left 0.2s;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
import { hono } from '$lib/server/hono';
export async function load(event) {
const session = event.locals.session;
if (!session) return {};
const famId = session.famId;
try {
const [members, templates, assigned, summary] = await Promise.all([
hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'chore-templates', famId),
hono.admin.list(event, 'assigned-chores', famId),
hono.admin.weeklySummary(event, famId),
]);
return { members, templates, assigned, summary };
} catch {
return {};
}
}
+175
View File
@@ -0,0 +1,175 @@
<script lang="ts">
import { page } from '$app/state';
import { famStore } from '$lib/stores/fam.svelte';
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
let { data } = $props();
let famSlug = $derived(page.params.fam);
let summary = $state(data.summary);
let donutCanvases = $state<Record<string, HTMLCanvasElement | undefined>>({});
let donutCharts: Record<string, Chart> = {};
let choresCanvas: HTMLCanvasElement | undefined;
let choresChart: Chart | undefined;
function captureCanvas(node: HTMLCanvasElement, memberId: string) {
donutCanvases[memberId] = node;
return {
destroy() { delete donutCanvases[memberId]; }
};
}
let members = $state(data.members);
let templates = $state(data.templates);
$effect(() => {
if (data.members?.length) members = data.members;
if (data.templates?.length) templates = data.templates;
if (famStore.initialized) {
if (famStore.members.length) members = famStore.members;
if (famStore.templates.length) templates = famStore.templates;
}
})
onMount(() => {
if (!summary?.summaries?.length) return;
if (choresCanvas && summary.daysInWeek?.length) {
const days = summary.daysInWeek;
const labels = days.map((d: string) =>
new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' })
);
const datasets = summary.summaries.map((s: any) => ({
label: s.memberName,
data: days.map((d: string) => s.dayCompletions?.[d] || 0),
backgroundColor: s.memberColor || '#6366f1',
borderRadius: 4,
}));
choresChart = new Chart(choresCanvas, {
type: 'bar',
data: { labels, datasets },
options: {
responsive: true,
plugins: { legend: { display: true }, title: { display: true, text: 'Chores per day' } },
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
},
});
}
for (const s of summary.summaries) {
const canvas = donutCanvases[s.memberId];
if (!canvas) continue;
const done = s.choresCompleted || 0;
const total = s.totalChores || 0;
const remaining = Math.max(0, total - done);
donutCharts[s.memberId] = new Chart(canvas, {
type: 'doughnut',
data: {
labels: ['Done', 'Remaining'],
datasets: [{
data: [done, remaining],
backgroundColor: [s.memberColor || '#22c55e', '#e5e7eb'],
borderWidth: 0,
}],
},
options: {
responsive: true,
cutout: '70%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => `${ctx.label}: ${ctx.parsed} chores`,
},
},
},
},
});
}
});
onDestroy(() => {
choresChart?.destroy();
for (const c of Object.values(donutCharts)) c?.destroy();
famStore.cleanup();
});
</script>
<h1>🏠 {famSlug}</h1>
{#if summary?.summaries?.length}
<h2>This week</h2>
<table>
<thead><tr><th>Member</th><th>Points</th><th>Done</th></tr></thead>
<tbody>
{#each summary.summaries as s}
<tr>
<td><span style="color:{s.memberColor}"></span> {s.memberName}</td>
<td>{s.pointsEarned}</td>
<td>{s.choresCompleted}/{s.totalChores}</td>
</tr>
{/each}
</tbody>
</table>
<div class="card">
<h2>Chores per day</h2>
<canvas bind:this={choresCanvas} height="200"></canvas>
</div>
<div class="donut-grid">
{#each summary.summaries as s}
<div class="donut-card">
<canvas use:captureCanvas={s.memberId} height="150" width="150"></canvas>
<div class="donut-label" style="color:{s.memberColor}">{s.memberName}</div>
<div class="donut-points">{s.pointsEarned} pts</div>
<div class="donut-hint">{s.choresCompleted}/{s.totalChores} chores</div>
</div>
{/each}
</div>
{/if}
<h2>Members ({members?.length || 0})</h2>
<div class="member-grid">
{#each members || [] as m}
<a href="/{famSlug}/{m.name}" class="member-card" style="border-left: 4px solid {m.color}">
<strong>{m.name}</strong>
<span class="hint">{m.deviceTokenHint || '—'}</span>
</a>
{/each}
</div>
{#if templates?.length}
<h2>Templates ({templates.length})</h2>
<ul>
{#each templates as t}
<li>{t.name}{t.defaultFrequency}{t.defaultValue} {t.defaultType}</li>
{/each}
</ul>
{/if}
<style>
nav { display: flex; gap: 0.5rem; margin: 1rem 0; }
nav a { padding: 0.4rem 0.8rem; background: #6366f1; color: white; border-radius: 4px; text-decoration: none; font-size: 0.9rem; }
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
th, td { padding: 0.4rem; border: 1px solid #e5e7eb; text-align: left; }
th { background: #f9fafb; font-weight: 600; }
.card { margin: 1rem 0; padding: 1rem; border: 1px solid #e5e7eb; border-radius: 8px; background: #f9fafb; }
.card h2 { margin: 0 0 0.75rem; font-size: 1rem; }
.card canvas { max-height: 50vh; }
.donut-grid { display: flex; gap: 1rem; flex-wrap: wrap; margin: 1rem 0; justify-content: center; }
.donut-card { display: flex; flex-direction: column; align-items: center; padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; min-width: 160px; }
.donut-card canvas { max-height: 150px; max-width: 150px; }
.donut-label { font-weight: 600; font-size: 0.9rem; margin-top: 0.5rem; }
.donut-points { font-size: 1.1rem; font-weight: 700; }
.donut-hint { font-size: 0.75rem; color: #9ca3af; }
.member-grid { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.member-card { flex: 1; min-width: 160px; padding: 0.8rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; text-decoration: none; color: inherit; display: block; }
.member-card strong { display: block; }
.hint { font-size: 0.8rem; color: #9ca3af; }
ul { list-style: none; padding: 0; }
li { padding: 0.25rem 0; }
</style>
@@ -0,0 +1,122 @@
import { fail, redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import { memberApi } from '$lib/client/api';
import { SERVER_IP, PROXY_PORT } from '../../../../../config.ts';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
export async function load(event) {
const session = event.locals.session;
// Parent (session auth) → admin overview
if (session) {
const famId = session.famId;
const famSlug = event.params.fam;
const username = event.params.username;
if (session.memberName && session.memberName !== username) {
throw redirect(303, `/${famSlug}/${session.memberName}`);
}
const [members, templates, assigned, summary, fam, rewards, bonusConfigs, completions] = await Promise.all([
hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'chore-templates', famId),
hono.admin.list(event, 'assigned-chores', famId),
hono.admin.weeklySummary(event, famId),
hono.admin.fam(event, famId),
hono.admin.rewards(event, famId),
hono.admin.bonusConfigs(event, famId),
hono.admin.completions(event, famId),
]);
return { role: 'parent', members, templates, assigned, summary, fam, famSlug, rewards, bonusConfigs, completions };
}
// Child (device token) → kanban
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
const famSlug = event.params.fam;
const username = event.params.username;
if (!deviceToken) {
return { role: 'child', token: '', memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} };
}
try {
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceToken, famSlug }),
});
const data = await res.json();
if (!res.ok || data.name !== username) {
return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', memberName: '', memberColor: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} };
}
let chores: any = {};
try {
chores = await memberApi.myChores(deviceToken, data.famId);
} catch {}
return {
role: 'child',
token: deviceToken,
memberId: data.memberId,
famId: data.famId,
verified: true,
memberName: data.name,
memberColor: data.color,
templates: chores.templates || [],
assigned: chores.assigned || [],
completions: chores.completions || [],
rewards: chores.rewards || [],
bonusConfigs: chores.bonusConfigs || [],
tallies: chores.tallies || {},
};
} catch {
return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} };
}
}
export const actions = {
claim: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const rewardId = fd.get('id') as string;
const message = fd.get('message') as string;
try {
const record = await hono.admin.claimReward(event, famId, rewardId);
if (message) {
await hono.admin.sendMessage(event, famId, record.memberId, message);
}
return { record };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
}
},
revoke: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const completionId = fd.get('id') as string;
try {
await hono.admin.revokeCompletion(event, famId, completionId);
return { revoked: true, id: completionId };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to revoke' };
}
},
trigger: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const configId = fd.get('configId') as string;
const memberId = fd.get('memberId') as string;
if (!configId) return { error: 'Config ID required' };
try {
const result = await hono.admin.triggerBonusConfig(event, famId, configId, memberId || undefined);
return { records: result.records || [] };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to trigger' };
}
},
};
@@ -0,0 +1,813 @@
<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.claimed)
}
function memberClaimable(memberId: string) {
return claimableRewards().filter((r: any) => r.memberId === memberId)
}
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) / 100).toFixed(2)}`
if (r.rewardType === 'points') return `${r.value} pts`
return r.label
}
function manualConfigs() {
return parentBonusConfigs.filter((b: any) => (b.phase === 'active' || (!b.phase && b.status === 'active')) && b.type === 'manual')
}
// ─── 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 = $state(data.templates as ChoreTemplate[])
let assigned = $state(data.assigned as AssignedChore[])
let completions = $state(data.completions as Completion[])
let rewards = $state(data.rewards as Reward[])
let bonusConfigs = $state(data.bonusConfigs as BonusConfig[])
let loading = $state(true)
let error = $state('')
let dailyPending = $state<AssignedChore[]>([])
let weeklyPending = $state<AssignedChore[]>([])
let completedToday = $state<Completion[]>([])
let claimableRewardsChild = $state<Reward[]>([])
let requestedRewards = $state<string[]>([])
let claimError = $state('')
let todayChild = $state(new Date().toISOString().slice(0, 10))
let togglingIds = $state<string>('')
let bonusNotices = $state<Reward[]>([])
let totalCash = $state(0)
let pendingCash = $state(0)
let totalPoints = $state(0)
let weeklyTotal = $state(0)
let weeklyDone = $state(0)
let bonusProgresses = $state<{ config: BonusConfig; current: number; criteria: number; periodStart: string; periodEnd: 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`
}
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 {}
}
$effect(() => {
if (role === 'parent' || !memberId) return
const memberChores = assigned.filter(a => a.memberId === memberId)
dailyPending = memberChores.filter(a => a.frequency === 'daily' && !isCompleted(a.id, todayChild))
weeklyPending = memberChores.filter(a => a.frequency === 'weekly' && !isCompleted(a.id, todayChild))
completedToday = completions.filter(c => c.memberId === memberId && c.date?.slice(0, 10) === todayChild)
claimableRewardsChild = rewards.filter(r =>
r.memberId === memberId && !r.claimed
)
const ws = weekStartStr()
const dailyChores = memberChores.filter(a => a.frequency === 'daily')
const weeklyChores = memberChores.filter(a => a.frequency === 'weekly')
weeklyTotal = dailyChores.length * 7 + weeklyChores.length
weeklyDone = completions.filter(
c => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) >= ws
).length
const myRewards = rewards.filter(r => r.memberId === memberId)
const myCompletions = completions.filter(c => c.memberId === memberId)
bonusNotices = myRewards.filter(r => r.claimed && r.claimedAt?.slice(0, 10) === r.date)
totalCash = 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)
pendingCash = myRewards.filter(r => r.rewardType === 'cash' && !r.claimed).reduce((sum, r) => sum + Number(r.value), 0)
totalPoints = 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)
const activeConfigs = bonusConfigs.filter(
(b: BonusConfig) => (b.phase === 'active' || (!b.phase && b.status === 'active')) && b.target === 'individual' && (!b.memberId || b.memberId === memberId)
)
bonusProgresses = 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 }
})
})
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) {
const idx = completions.indexOf(existing)
completions = [...completions.slice(0, idx), ...completions.slice(idx + 1)]
}
} else {
completions = [...completions, {
id: 'optimistic-' + chore.id,
famId,
memberId,
assignedChoreId: chore.id,
date: todayChild,
completedAt: new Date().toISOString(),
} as Completion]
}
try {
await memberApi.toggleCompletion(deviceToken, famId, chore.id, todayChild)
completions = completions.filter(c =>
c.assignedChoreId !== chore.id || (c.date?.slice(0, 10) || c.date) !== todayChild || c.id.startsWith('optimistic-')
)
} catch (e) {
if (wasCompleted) {
completions = [...completions, {
id: 'revert-' + chore.id + '-' + Date.now(),
famId,
memberId,
assignedChoreId: chore.id,
date: todayChild,
completedAt: new Date().toISOString(),
} as Completion]
} else {
const existing = findCompletion(chore.id)
if (existing && existing.id.startsWith('optimistic-')) {
const idx = completions.indexOf(existing)
completions = [...completions.slice(0, idx), ...completions.slice(idx + 1)]
}
}
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) / 100).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, (d) => { if (d.revoked) famStore.applyRecord('completions', { id: d.id }, 'delete'); }); }} 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) / 100).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, (d: any) => { if (d.records) { d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } }); }}>
<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, (d: any) => { if (d.records) { d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } }); }}>
<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 mc = memberClaimable(m.id)}
{#if mc.length > 0}
<div class="member-claims">
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
{#each mc as r}
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => handleResult(args, (d: any) => { if (d.record) { famStore.applyRecord('rewards', d.record, 'update'); } }); }}>
<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">Claim</Button>
</div>
</div>
</form>
{/each}
<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 / 100).toFixed(2)}</span>
</div>
<div class="badge-card pending">
<span class="badge-label">Pending Cash</span>
<span class="badge-value">£{(pendingCash / 100).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}
{#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); requestedRewards = [...requestedRewards, r.id]; } catch (e) { claimError = e instanceof Error ? e.message : 'Claim failed'; } }} disabled={requestedRewards.includes(r.id)}>
{requestedRewards.includes(r.id) ? 'Requested' : 'Request'}
</button>
</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; 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; }
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>
@@ -0,0 +1,186 @@
import { fail, redirect } from '@sveltejs/kit';
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 [configs, members, progress, rewards] = await Promise.all([
hono.admin.bonusConfigs(event, famId),
hono.admin.list(event, 'members', famId),
hono.admin.bonusConfigProgress(event, famId),
hono.admin.rewards(event, famId),
]);
return { configs, members, progress, rewards };
}
export const actions = {
createConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const phase = fd.get('phase') as string;
const data: Record<string, unknown> = {
name: fd.get('name'),
target: fd.get('target'),
type: fd.get('type'),
occurrence: fd.get('occurrence'),
rewardType: fd.get('rewardType'),
rewardValue: fd.get('rewardValue'),
phase: phase || 'ready',
};
const criteriaValue = fd.get('criteriaValue');
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
const period = fd.get('period');
if (period !== null) data.period = period;
const description = fd.get('description');
if (description) data.description = description;
const memberId = fd.get('memberId');
if (memberId) data.memberId = memberId;
try {
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create config' });
}
},
updateConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const data: Record<string, unknown> = {};
const name = fd.get('name');
if (name) data.name = name;
const target = fd.get('target');
if (target) data.target = target;
const type = fd.get('type');
if (type) data.type = type;
const occurrence = fd.get('occurrence');
if (occurrence) data.occurrence = occurrence;
const rewardType = fd.get('rewardType');
if (rewardType) data.rewardType = rewardType;
const rewardValue = fd.get('rewardValue');
if (rewardValue) data.rewardValue = rewardValue;
const criteriaValue = fd.get('criteriaValue');
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
const period = fd.get('period');
if (period !== null) data.period = period;
const description = fd.get('description');
if (description) data.description = description;
const memberId = fd.get('memberId');
if (memberId !== null) data.memberId = memberId || null;
try {
const record = await hono.admin.update(event, 'bonus-configs', famId, id, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update config' });
}
},
deleteConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
await hono.admin.remove(event, 'bonus-configs', famId, id);
return { deleted: true };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to archive config' });
}
},
createFromTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const templateId = fd.get('templateId') as string;
try {
const templates = await hono.admin.bonusConfigs(event, famId);
const tmpl = (Array.isArray(templates) ? templates : []).find((c: any) => c.id === templateId);
if (!tmpl) return fail(400, { error: 'Template not found' });
const data: Record<string, unknown> = {
name: tmpl.name,
description: tmpl.description || '',
target: tmpl.target,
type: tmpl.type,
occurrence: tmpl.occurrence,
rewardType: tmpl.rewardType,
rewardValue: tmpl.rewardValue,
criteriaValue: tmpl.criteriaValue || 0,
period: tmpl.period || '',
memberId: tmpl.memberId || '',
phase: 'ready',
};
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create bonus from template' });
}
},
assignConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const target = fd.get('target') as string;
const memberId = fd.get('memberId') as string;
try {
const record = await hono.admin.assignBonusConfig(event, famId, id, { target, memberId: memberId || null });
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign bonus' });
}
},
completeConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const phase = fd.get('phase') as string;
try {
const record = await hono.admin.completeBonusConfig(event, famId, id, phase || 'completed');
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update bonus phase' });
}
},
destroyConfig: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
await hono.admin.destroyBonusConfig(event, famId, id);
return { destroyed: true };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete bonus' });
}
},
claimReward: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const rewardId = fd.get('id') as string;
try {
await hono.admin.claimReward(event, famId, rewardId);
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
}
},
evaluate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
try {
await hono.admin.evaluateBonusConfig(event, famId);
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to evaluate' };
}
},
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
import { fail, redirect } from '@sveltejs/kit';
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 [templates, members, assigned] = await Promise.all([
hono.admin.list(event, 'chore-templates', famId),
hono.admin.list(event, 'members', famId),
hono.admin.list(event, 'assigned-chores', famId),
]);
return { templates, members, assigned };
}
export const actions = {
createTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const data = {
name: fd.get('name'),
defaultFrequency: fd.get('defaultFrequency'),
defaultType: fd.get('defaultType'),
defaultValue: parseFloat(fd.get('defaultValue') as string) || 0,
};
if (!data.name || !data.defaultFrequency || !data.defaultType) {
return fail(400, { error: 'Name, frequency, and type are required' });
}
try {
const record = await hono.admin.create(event, 'chore-templates', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
}
},
updateTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const defaultFrequency = fd.get('defaultFrequency') as string;
const defaultType = fd.get('defaultType') as string;
const defaultValue = parseFloat(fd.get('defaultValue') as string) || 0;
const data = {
name: fd.get('name'),
defaultFrequency,
defaultType,
defaultValue,
};
try {
const record = await hono.admin.update(event, 'chore-templates', famId, id, data);
const allAssigned = await hono.admin.list(event, 'assigned-chores', famId);
if (Array.isArray(allAssigned)) {
const toUpdate = allAssigned.filter((a: any) => a.templateId === id);
await Promise.all(toUpdate.map((a: any) =>
hono.admin.update(event, 'assigned-chores', famId, a.id, {
frequency: defaultFrequency,
type: defaultType,
value: defaultValue,
})
));
}
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update template' });
}
},
deleteTemplate: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
const record = await hono.admin.remove(event, 'chore-templates', famId, id);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
}
},
assignChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const data = {
memberId: fd.get('memberId'),
templateId: fd.get('templateId'),
frequency: fd.get('frequency'),
type: fd.get('type'),
value: parseFloat(fd.get('value') as string) || 0,
customName: fd.get('customName') || undefined,
};
if (!data.memberId || !data.templateId || !data.frequency || !data.type) {
return fail(400, { error: 'Member, template, frequency, and type are required' });
}
try {
const record = await hono.admin.create(event, 'assigned-chores', famId, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign chore' });
}
},
updateAssignedChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
const data: Record<string, unknown> = {};
const frequency = fd.get('frequency');
const type = fd.get('type');
const value = fd.get('value');
const customName = fd.get('customName');
if (frequency) data.frequency = frequency;
if (type) data.type = type;
if (value) data.value = parseFloat(value as string) || 0;
data.customName = (customName as string) || undefined;
try {
const record = await hono.admin.update(event, 'assigned-chores', famId, id, data);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update assigned chore' });
}
},
unassignChore: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
const record = await hono.admin.remove(event, 'assigned-chores', famId, id);
return { record };
} catch (e) {
return fail(400, { error: e instanceof Error ? e.message : 'Failed to unassign chore' });
}
},
};
@@ -0,0 +1,402 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { page } from '$app/state';
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { ChoreTemplate, AssignedChore, Member } from '$lib/types';
let { data, form } = $props();
let showCreateModal = $state(false)
let showEditModal = $state(false)
let showEditTemplateModal = $state(false)
let editingAssignment = $state<AssignedChore | null>(null)
let editingTemplate = $state<ChoreTemplate | null>(null)
let editMemberId = $state('')
let draggedTemplateId = $state<string | null>(null)
let createName = $state('')
let createFreq = $state('daily')
let createType = $state('points')
let createValue = $state(10)
let editFreq = $state('daily')
let editType = $state('points')
let editValue = $state(10)
let editCustomName = $state('')
let editTplName = $state('')
let editTplFreq = $state('daily')
let editTplType = $state('points')
let editTplValue = $state(10)
let templates = $state(famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[] || []))
let members = $state(famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[] || []))
let assigned = $state(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[] || []))
function assignedForMember(memberId: string): AssignedChore[] {
return assigned.filter(a => a.memberId === memberId)
}
function templateName(templateId: string): string {
return famStore.templateMap().get(templateId)?.name || '?'
}
function getDefault(templateId: string): { frequency: string; type: string; value: number } {
const t = famStore.templateMap().get(templateId)
if (!t) return { frequency: 'daily', type: 'points', value: 10 }
return { frequency: t.defaultFrequency, type: t.defaultType, value: t.defaultValue }
}
function handleDragStart(e: DragEvent, templateId: string) {
draggedTemplateId = templateId
e.dataTransfer?.setData('text/plain', templateId)
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
async function handleDrop(e: DragEvent, memberId: string) {
e.preventDefault()
const tid = e.dataTransfer?.getData('text/plain') || draggedTemplateId
if (!tid) return
draggedTemplateId = null
const def = getDefault(tid)
const fd = new FormData()
fd.set('memberId', memberId)
fd.set('templateId', tid)
fd.set('frequency', def.frequency)
fd.set('type', def.type)
fd.set('value', String(def.value))
const res = await fetch(`/${page.params.fam}/${page.params.username}/chores?/assignChore`, {
method: 'POST',
body: fd,
})
if (res.ok) {
const body = await res.json()
if (body.record) {
assigned = [body.record as AssignedChore, ...assigned]
famStore.applyRecord('assigned_chores', body.record, 'create')
}
}
}
function openEdit(a: AssignedChore) {
editingAssignment = a
editFreq = a.frequency
editType = a.type
editValue = a.value
editCustomName = a.customName || ''
showEditModal = true
}
function closeEdit() {
showEditModal = false
editingAssignment = null
}
function openEditTemplate(t: ChoreTemplate) {
editingTemplate = t
editTplName = t.name
editTplFreq = t.defaultFrequency
editTplType = t.defaultType
editTplValue = t.defaultValue
showEditTemplateModal = true
}
function closeEditTemplate() {
showEditTemplateModal = false
editingTemplate = null
}
function enhanceCreate() {
return async ({ result, formData }: any) => {
if (result.type === 'success') {
showCreateModal = false
const rec = result.data?.record
if (rec) {
templates = [rec as ChoreTemplate, ...templates]
famStore.applyRecord('chore_templates', rec, 'create')
}
}
}
}
function enhanceUpdateAssigned() {
return async ({ result }: any) => {
if (result.type === 'success' && editingAssignment) {
const idx = assigned.findIndex((a: any) => a.id === editingAssignment!.id)
if (idx !== -1) {
assigned[idx] = {
...assigned[idx],
frequency: editFreq,
type: editType,
value: editValue,
customName: editCustomName || undefined,
} as AssignedChore
famStore.applyRecord('assigned_chores', assigned[idx], 'update')
}
}
closeEdit()
}
}
function enhanceUpdateTemplate() {
return async ({ result, formData }: any) => {
if (result.type === 'success' && editingTemplate) {
const tid = editingTemplate!.id
const idx = templates.findIndex((t: any) => t.id === tid)
if (idx !== -1) {
templates[idx] = {
...templates[idx],
name: editTplName,
defaultFrequency: editTplFreq,
defaultType: editTplType,
defaultValue: editTplValue,
} as ChoreTemplate
}
assigned = assigned.map((a: any) =>
a.templateId === tid
? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue }
: a
) as AssignedChore[]
famStore.applyRecord('chore_templates', templates[idx], 'update')
closeEditTemplate()
}
}
}
</script>
<ViewHeader title="Chore Assignment" />
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<CardGrid>
<Card cols={3}>
<div style="margin-bottom:0.75rem">
<Button onclick={() => showCreateModal = true}>+ New Chore Template</Button>
</div>
<div class="kanban">
<div class="column templates-col">
<h3>Templates</h3>
{#each templates as t}
<div
class="card template"
draggable="true"
ondragstart={(e) => handleDragStart(e, t.id)}
>
<div class="card-body" onclick={() => openEditTemplate(t)} role="button" tabindex="0">
<strong>{t.name}</strong>
<span class="badge">{t.defaultFrequency}</span>
<span class="badge type">{t.defaultType}</span>
</div>
<div class="card-value">
{t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue}
</div>
<div class="card-actions">
<button onclick={() => openEditTemplate(t)} class="edit-btn" title="Edit template"></button>
<form method="POST" action="?/deleteTemplate" use:enhance={() => { return async ({ result, formData }) => { if (result.type === 'success') { const id = formData.get('id'); templates = templates.filter((t) => t.id !== id) as ChoreTemplate[]; const rec = result.data.record; if (rec) famStore.applyRecord('chore_templates', { ...rec, id }, 'delete'); } }; }}>
<input type="hidden" name="id" value={t.id} />
<button type="submit" class="del-btn" title="Delete template">×</button>
</form>
</div>
</div>
{/each}
{#if templates.length === 0}
<p class="empty">No templates yet</p>
{/if}
</div>
{#each members as m}
<div
class="column member-col"
style="border-top-color: {m.color}"
ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, m.id)}
>
<h3>
<span class="dot" style="background:{m.color}"></span>
{m.name}
</h3>
{#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>
</div>
<div class="card-value">{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : a.value}</div>
<form method="POST" action="?/unassignChore" onclick={(e) => e.stopPropagation()} use:enhance={() => { return async ({ result }) => { if (result.type === 'success') { const id = result.data.record?.id; if (id) { assigned = assigned.filter((x) => x.id !== id) as AssignedChore[]; famStore.applyRecord('assigned_chores', { id }, 'delete'); } } }; }}>
<input type="hidden" name="id" value={a.id} />
<button type="submit" class="del-btn" title="Remove assignment">×</button>
</form>
</div>
{/each}
{#if assignedForMember(m.id).length === 0}
<p class="empty">Drop a chore here</p>
{/if}
</div>
{/each}
</div>
</Card></CardGrid>
<!-- Create Template Modal -->
{#if showCreateModal}
<div class="overlay" onclick={() => showCreateModal = false} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>New Chore Template</h3>
<form method="POST" action="?/createTemplate" use:enhance={enhanceCreate}>
<label>
Name
<input name="name" bind:value={createName} required />
</label>
<label>
Frequency
<select name="defaultFrequency" bind:value={createFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select name="defaultType" bind:value={createType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Default Value
<input name="defaultValue" type="number" step="any" bind:value={createValue} required />
</label>
<div class="modal-actions">
<button type="button" onclick={() => showCreateModal = false}>Cancel</button>
<button type="submit">Create</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Edit Template Modal -->
{#if showEditTemplateModal && editingTemplate}
<div class="overlay" onclick={closeEditTemplate} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Edit Template: {editingTemplate.name}</h3>
<p class="hint">Changes will also update all existing assigned chores using this template.</p>
<form method="POST" action="?/updateTemplate" use:enhance={enhanceUpdateTemplate}>
<input name="id" type="hidden" value={editingTemplate.id} />
<label>
Name
<input name="name" bind:value={editTplName} required />
</label>
<label>
Frequency
<select name="defaultFrequency" bind:value={editTplFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select name="defaultType" bind:value={editTplType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Default Value
<input name="defaultValue" type="number" step="any" bind:value={editTplValue} required />
</label>
<div class="modal-actions">
<button type="button" onclick={closeEditTemplate}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Edit Assignment Modal -->
{#if showEditModal && editingAssignment}
<div class="overlay" onclick={closeEdit} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Edit: {editingAssignment.customName || templateName(editingAssignment.templateId)}</h3>
<form method="POST" action="?/updateAssignedChore" use:enhance={enhanceUpdateAssigned}>
<input name="id" type="hidden" value={editingAssignment.id} />
<label>
Custom Name
<input name="customName" bind:value={editCustomName} placeholder="Override template name" />
</label>
<label>
Frequency
<select name="frequency" bind:value={editFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select name="type" bind:value={editType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Value
<input name="value" type="number" step="any" bind:value={editValue} required />
</label>
<div class="modal-actions">
<button type="button" onclick={closeEdit}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.error { color: #dc2626; padding: 0.5rem; background: #fef2f2; border-radius: 4px; margin: 0.5rem 0; }
.kanban { display: flex; gap: 1rem; overflow-x: auto; align-items: flex-start; }
.column { flex: 1; min-width: 200px; max-width: 280px; border: 2px solid #e5e7eb; border-top-width: 4px; border-radius: 8px; padding: 0.75rem; background: #fafafa; min-height: 200px; }
.column h3 { margin: 0 0 0.75rem; font-size: 1rem; display: flex; align-items: center; gap: 0.4rem; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.empty { color: #9ca3af; font-size: 0.85rem; text-align: center; padding: 1rem; border: 1px dashed #d1d5db; border-radius: 6px; }
.card { background: white; border: 1px solid #e5e7eb; border-radius: 6px; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem; position: relative; }
.card-body { flex: 1; min-width: 0; }
.card-body strong { display: block; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-value { font-size: 0.85rem; color: #6b7280; font-weight: 600; white-space: nowrap; }
.badge { font-size: 0.7rem; background: #e0e7ff; color: #4338ca; padding: 1px 6px; border-radius: 10px; margin-right: 4px; }
.badge.type { background: #fce7f3; color: #be185d; }
.card-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.edit-btn, .del-btn { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 1rem; padding: 0 4px; line-height: 1; flex-shrink: 0; }
.edit-btn:hover { color: #6366f1; }
.del-btn:hover { color: #dc2626; }
.hint { color: #6b7280; font-size: 0.85rem; margin-bottom: 1rem; }
.template { cursor: grab; border-left: 3px solid #6366f1; }
.template:active { cursor: grabbing; opacity: 0.8; }
.assigned { cursor: pointer; border-left: 3px solid #10b981; }
.assigned:hover { background: #f3f4f6; }
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { background: white; border-radius: 12px; padding: 1.5rem; min-width: 320px; max-width: 440px; box-shadow: 0 10px 25px rgba(0,0,0,0.15); }
.modal h3 { margin: 0 0 1rem; }
.modal label { display: block; margin-bottom: 0.75rem; font-size: 0.9rem; color: #374151; }
.modal input, .modal select { display: block; width: 100%; margin-top: 0.25rem; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box; }
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
.modal-actions button { padding: 0.5rem 1rem; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9rem; }
.modal-actions button[type="button"] { background: #e5e7eb; color: #374151; }
.modal-actions button[type="submit"] { background: #6366f1; color: white; }
</style>
@@ -0,0 +1,99 @@
import { redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
export async function load(event) {
const session = event.locals.session;
const famSlug = event.params.fam;
const username = event.params.username;
// Parent (session auth) — profile lives in fam_admins
if (session) {
const famId = session.famId;
if (session.memberName && session.memberName !== username) {
throw redirect(303, `/${famSlug}/${session.memberName}/preferences`);
}
try {
const me = await hono.admin.getProfile(event, famId);
return {
verified: true, token: '', memberId: me.id, famId,
memberName: me.name, memberColor: me.color, email: me.email || '',
session: true,
};
} catch {
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
}
}
// Child (device token) — profile lives in members
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
if (!deviceToken) {
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
}
try {
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceToken, famSlug }),
});
const data = await res.json();
if (!res.ok || data.name !== username) {
return { verified: false, token: deviceToken, memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
}
return {
verified: true, token: deviceToken, memberId: data.memberId, famId: data.famId,
memberName: data.name, memberColor: data.color, email: data.email || '', session: false,
};
} catch {
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
}
}
export const actions = {
update: async (event) => {
const session = event.locals.session;
const fd = await event.request.formData();
const name = fd.get('name') as string;
const color = fd.get('color') as string;
const email = fd.get('email') as string;
if (session) {
const famId = session.famId;
try {
const data: Record<string, string> = {};
if (name) data.name = name;
if (color) data.color = color;
data.email = email || '';
const me = await hono.admin.updateProfile(event, famId, data);
return { success: true, name: me.name, color: me.color, email: me.email };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Update failed' };
}
}
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
const famSlug = event.params.fam;
if (!deviceToken) return { error: 'Not authenticated' };
try {
const verifyRes = await fetch(`${HONO_URL}/api/members/verify-token`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceToken, famSlug }),
});
const verify = await verifyRes.json();
if (!verifyRes.ok) return { error: 'Verification failed' };
const res = await fetch(`${HONO_URL}/api/members/me`, {
method: 'PATCH', headers: { 'x-device-token': deviceToken, 'x-device-famid': verify.famId, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color }),
});
const data = await res.json();
if (!res.ok) return { error: data.error || 'Update failed' };
return { success: true, name: data.name, color: data.color };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Update failed' };
}
},
};
@@ -0,0 +1,99 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
let { data, form } = $props();
let memberName = $state(data.memberName || '');
let memberColor = $state(data.memberColor || '#6366f1');
let memberEmail = $state(data.email || '');
const colors = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#ef4444', '#8b5cf6', '#14b8a6'];
</script>
<ViewHeader title="Preferences" subtitle={data.verified ? data.memberName : ''} />
{#if !data.verified}
<CardGrid>
<Card cols={3}>
<p style="color:#9ca3af;text-align:center;padding:2rem">
Not authenticated. Use the link from your invite.
</p>
</Card>
</CardGrid>
{:else}
<CardGrid>
<Card cols={2} title="Appearance">
<form method="POST" action="?/update" use:enhance>
<div style="margin-bottom:1rem">
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
Display Name
</label>
<input
type="text"
name="name"
bind:value={memberName}
required
style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box"
/>
</div>
<div style="margin-bottom:1rem">
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
Color
</label>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
{#each colors as c}
<button
type="button"
style="width:2rem;height:2rem;border-radius:50%;border:{c === memberColor ? '3px solid #111827' : '2px solid transparent'};background:{c};cursor:pointer"
onclick={() => memberColor = c}
></button>
{/each}
</div>
<input type="hidden" name="color" value={memberColor} />
</div>
<div style="margin-bottom:1rem">
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
Email
</label>
{#if data.session}
<input
type="email"
name="email"
bind:value={memberEmail}
style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box"
/>
{:else}
<input
type="email"
name="email"
value={memberEmail}
disabled
style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box;background:#f3f4f6;color:#9ca3af"
/>
{/if}
</div>
{#if form?.error}
<p style="color:#dc2626;font-size:0.85rem;margin-bottom:0.5rem">{form.error}</p>
{/if}
{#if form?.success}
<p style="color:#059669;font-size:0.85rem;margin-bottom:0.5rem">Saved!</p>
{/if}
<Button type="submit" variant="primary">Save</Button>
</form>
</Card>
<Card cols={1} title="Account">
<p style="font-size:0.85rem;color:#6b7280">
To change your device or sign in on a new device, use your invite link.
</p>
<p style="font-size:0.85rem;color:#6b7280;margin-top:0.5rem">
Contact your family admin to change your token or reset your account.
</p>
</Card>
</CardGrid>
{/if}
@@ -0,0 +1,27 @@
import { redirect } from '@sveltejs/kit';
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([
hono.admin.rewards(event, famId),
hono.admin.list(event, 'members', famId),
]);
return { rewards, members };
}
export const actions = {
claim: async (event) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const rewardId = fd.get('id') as string;
try {
const record = await hono.admin.claimReward(event, famId, rewardId);
return { record };
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
}
},
};
@@ -0,0 +1,137 @@
<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) / 100).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`;
return r.label;
}
function rewardStatus(r: any): string {
if (!r.claimed) return 'Outstanding';
if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
return 'Claimed';
}
function isOutstanding(r: any): boolean { return !r.claimed; }
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) => isOutstanding(r))
.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" />
{#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}>
<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:claimed-status={!outstanding}>
{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') { if (d.record) famStore.applyRecord('rewards', d.record, 'update'); 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 / 100).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; }
.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.claimed-status { background: #d1fae5; color: #065f46; }
tfoot td { border-top: 2px solid #d1d5db; padding-top: 0.6rem; }
</style>
@@ -0,0 +1,57 @@
import { redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import type { RequestEvent } from '@sveltejs/kit';
export async function load(event: RequestEvent) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const [members, fam] = await Promise.all([
hono.admin.list(event, 'members', famId),
hono.admin.fam(event, famId),
]);
return { members, fam };
}
export const actions = {
regenInvite: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
return { newCode: (await hono.admin.regenInvite(event, famId)).inviteCode };
},
addMember: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const name = fd.get('name') as string;
if (!name) return { error: 'Name required' };
await hono.admin.create(event, 'members', famId, { name, color: '#6366f1' });
},
renameFam: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const name = fd.get('name') as string;
if (!name) return { error: 'Name required' };
return await hono.admin.renameFam(event, famId, name);
},
deleteMember: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const id = fd.get('id') as string;
if (!id) return { error: 'Member ID required' };
await hono.admin.remove(event, 'members', famId, id);
},
updatePayday: async (event: RequestEvent) => {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
const fd = await event.request.formData();
const payday = parseInt(fd.get('payday') as string, 10);
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
return await hono.admin.updatePayday(event, famId, payday);
},
};
@@ -0,0 +1,171 @@
<script lang="ts">
import { page } from '$app/state';
import { enhance } from '$app/forms';
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import { onDestroy } from 'svelte';
import QRCode from 'qrcode';
let { data } = $props();
let fam = $state(data.fam)
let famSlug = $state(page.params.fam)
let childInviteUrl = $derived(`${page.url.origin}/join/${fam?.inviteCode}`)
let addName = $state('')
let rename = $state('')
let selectedMember = $state('')
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1)
let showQR = $state(false)
let qrDataUrl = $state('')
let copied = $state(false)
let parentInviteEmail = $state('')
let selectedInviteUrl = $derived(
selectedMember
? `${page.url.origin}/join/${fam?.inviteCode}/${selectedMember}`
: childInviteUrl
)
let members = $state(famStore.initialized ? famStore.members : (data.members || []))
function copy(url: string) {
navigator.clipboard.writeText(url)
copied = true
setTimeout(() => copied = false, 2000)
}
async function generateQR(url: string) {
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 })
}
function handleParentInvite() {
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail)
}
</script>
<ViewHeader title="Settings" />
<CardGrid>
<Card title="Family Name">
<form method="POST" action="?/renameFam" use:enhance>
<input name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
<Button type="submit" size="sm">Rename</Button>
</form>
</Card>
<Card title="Members ({members.length})" cols={2}>
<form method="POST" action="?/addMember" use:enhance>
<input name="name" bind:value={addName} placeholder="Member name" required />
<Button type="submit" size="sm">Add</Button>
</form>
<ul>
{#each members as m}
<li>
<span class="dot" style="background:{m.color}"></span>
{m.name}
{m.deviceToken ? '' : ' (pending join)'}
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
<form method="POST" action="?/deleteMember" use:enhance class="inline">
<input type="hidden" name="id" value={m.id} />
<Button type="submit" variant="danger" size="sm" onclick={() => confirm('Remove {m.name}?')}>Remove</Button>
</form>
</li>
{/each}
</ul>
</Card>
<Card title="Payday" cols={1}>
<form method="POST" action="?/updatePayday" use:enhance>
<select name="payday" bind:value={payday}>
<option value={0}>Sunday</option>
<option value={1}>Monday</option>
<option value={2}>Tuesday</option>
<option value={3}>Wednesday</option>
<option value={4}>Thursday</option>
<option value={5}>Friday</option>
<option value={6}>Saturday</option>
</select>
<Button type="submit" size="sm">Set payday</Button>
</form>
<p class="hint">The week starts on this day for tracking weekly chores and bonus cycles.</p>
</Card>
<Card title="Invite Children" cols={1}>
<p class="code">{fam?.inviteCode || '...'}</p>
<div class="invite-row">
<label>Member:</label>
<select bind:value={selectedMember}>
<option value="">— General link —</option>
{#each members as m}
<option value={m.name}>{m.name}</option>
{/each}
</select>
</div>
<p class="hint">Re-joining a member will disconnect their old device.</p>
<p class="invite-url">{selectedInviteUrl}</p>
<div class="actions">
<Button onclick={() => copy(selectedInviteUrl)} size="sm">
{copied ? 'Copied!' : 'Copy link'}
</Button>
<Button onclick={async () => { showQR = !showQR; if (!showQR) { qrDataUrl = ''; } else { await generateQR(selectedInviteUrl); } }} size="sm">
{showQR ? 'Hide QR' : 'Show QR'}
</Button>
<form method="POST" action="?/regenInvite" use:enhance class="inline">
<Button type="submit" size="sm">Renew code</Button>
</form>
</div>
{#if showQR && qrDataUrl}
<img src={qrDataUrl} alt="QR Code" class="qr" />
{/if}
</Card>
<Card title="Invite Parent" cols={1}>
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
<div class="invite-row">
<input
type="email"
bind:value={parentInviteEmail}
placeholder="parent@example.com"
/>
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
</div>
<p class="hint">They will set up their own password on first login.</p>
</Card>
<Card title="Data" cols={1}>
<div class="actions">
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
</div>
</Card>
</CardGrid>
<style>
.section { margin: 0.5rem 0; }
.code { font-family: monospace; font-size: 1.2rem; padding: 0.5rem; background: #f3f4f6; border-radius: 4px; display: inline-block; }
.invite-url { font-family: monospace; font-size: 0.9rem; word-break: break-all; background: #f9fafb; padding: 0.4rem; border-radius: 4px; }
.invite-row { display: flex; gap: 0.5rem; align-items: center; margin: 0.5rem 0; }
.invite-row select { padding: 0.4rem; border: 1px solid #ccc; border-radius: 4px; flex: 1; }
.invite-row input { padding: 0.4rem; border: 1px solid #ccc; border-radius: 4px; flex: 1; }
.hint { font-size: 0.85rem; color: #9ca3af; }
.actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; flex-wrap: wrap; }
.qr { margin-top: 0.5rem; border: 1px solid #e5e7eb; border-radius: 4px; }
ul { list-style: none; padding: 0; }
li { padding: 0.3rem 0; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.link { font-size: 0.85rem; color: #6366f1; }
form { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
input, select, button { padding: 0.4rem 0.7rem; border: 1px solid #ccc; border-radius: 4px; }
button { background: #6366f1; color: white; border: none; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.danger { background: #dc2626; font-size: 0.8rem; padding: 0.2rem 0.5rem; }
.inline { display: inline; margin: 0; }
</style>