diff --git a/AGENTS.md b/AGENTS.md index cddbb45..30720e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,13 +60,24 @@ ## Data Flow +### Reads (both roles) +- **Parent (admin):** `famStore.init()` fetches all collections via PB SDK (authenticated via `pb_token` cookie). +- **Child (member):** `famStore.init()` fetches all collections via PB SDK — **unauthenticated/anonymous**. All family-scoped collections have public `listRule` / `viewRule` (empty string = allow all), so reads work without any auth. PB SDK `.subscribe()` also works anonymously for public collections. +- **TopNav season pills:** Read from `famStore.seasons` — reactive, no extra fetches needed. + +### Writes (both roles go through Hono proxy) - **Chore toggle:** Browser → Hono proxy → PB (auth via device token or admin JWT) -- **Admin CRUD:** Browser → Hono proxy → PB (admin JWT) +- **Admin CRUD:** Form actions → Hono proxy → PB (admin JWT via `sessionHeaders`) +- **Member updates:** Browser → Hono proxy → PB (auth via `x-device-token` + `x-device-famid`) - **Reward creation:** After completion toggle, Hono proxy creates reward if threshold met - **Weekly CRON:** Coolify → `GET /api/weekly-cron` on Hono → Hono queries PB, computes summaries, upserts weekly_history - **Stripe donate:** Browser → Hono `/api/stripe/create-checkout` → Stripe → Hono webhook → update fam - **WhatsApp:** Deferred — Hono CRON handler has pluggable notification interface -- **UI reactivity:** Svelte `$state` / `$derived` / `$effect` — no PB SDK `.subscribe()` / SSE + +### UI reactivity +- Svelte `$state` / `$derived` / `$effect` +- PB SDK `.subscribe()` for realtime multi-user sync (public reads → works for both roles) +- `famStore.applyRecord()` for instant optimistic UI feedback from form actions ## Env Vars (SvelteKit 3.0.0-next.4) diff --git a/frontend/src/lib/client/api.ts b/frontend/src/lib/client/api.ts index bcda61d..9bdf788 100644 --- a/frontend/src/lib/client/api.ts +++ b/frontend/src/lib/client/api.ts @@ -36,4 +36,7 @@ export const memberApi = { async claimReward(token: string, famId: string, rewardId: string) { return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`, token, famId); }, + async requestAllRewards(token: string, famId: string) { + return memberFetch<{ count: number }>('POST', '/api/members/rewards/request-all', token, famId); + }, }; diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 669dd96..2898d1f 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -15,22 +15,22 @@ 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 }, - { href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }, ] : showChildItems ? [ + { href: `/${famSlug}`, label: famName, icon: homeIcon }, { href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon }, - { href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }, ] : [] ); let footerItems = $derived([ - { href: `/${famSlug}`, label: famName, icon: homeIcon }, + ...(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 }, ]); diff --git a/frontend/src/lib/components/TopNav.svelte b/frontend/src/lib/components/TopNav.svelte index 1081032..7404142 100644 --- a/frontend/src/lib/components/TopNav.svelte +++ b/frontend/src/lib/components/TopNav.svelte @@ -1,12 +1,36 @@
+
+ {#if role} + {role} + {/if} +
{#if announcement}{announcement}{/if}
+ {#each seasons as s} + + {s.name} + + {/each} {@render children?.()}
@@ -23,8 +47,35 @@ padding: 0 1.5rem; z-index: 90; transition: left 0.2s; + gap: 0.75rem; } + .topnav-left { display: flex; align-items: center; gap: 0.5rem; } .topnav-announcement { flex: 1; text-align: center; } .announcement-text { font-size: 0.85rem; color: #6b7280; } .topnav-actions { display: flex; align-items: center; gap: 0.5rem; } + .role-pill { + font-size: 0.7rem; + padding: 0.15rem 0.5rem; + border-radius: 999px; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.03em; + } + .role-pill.parent { background: #fef3c7; color: #b45309; } + .role-pill.child { background: #dbeafe; color: #1d4ed8; } + .season-pill { + font-size: 0.75rem; + padding: 0.2rem 0.6rem; + border-radius: 999px; + border: 1.5px solid var(--season-color, #d1d5db); + background: transparent; + color: #374151; + font-weight: 500; + transition: all 0.15s; + } + .season-pill.active { + background: var(--season-color, #6366f1); + color: #fff; + border-color: var(--season-color, #6366f1); + } diff --git a/frontend/src/lib/server/hono.ts b/frontend/src/lib/server/hono.ts index 0581410..57dc440 100644 --- a/frontend/src/lib/server/hono.ts +++ b/frontend/src/lib/server/hono.ts @@ -65,6 +65,9 @@ export const hono = { async claimReward(event: RequestEvent, famId: string, rewardId: string) { return request('POST', `/api/admin/${famId}/rewards/${rewardId}/claim`, undefined, sessionHeaders(event)); }, + async issueAllRewards(event: RequestEvent, famId: string, memberId: string, message?: string) { + return request('POST', `/api/admin/${famId}/rewards/issue-all`, { memberId, message }, sessionHeaders(event)); + }, async bonusConfigs(event: RequestEvent, famId: string) { return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event)); }, @@ -107,5 +110,11 @@ export const hono = { async updateProfile(event: RequestEvent, famId: string, data: Record) { return request('PATCH', `/api/admin/${famId}/profile`, data, sessionHeaders(event)); }, + async updateFam(event: RequestEvent, famId: string, data: Record) { + return request('PATCH', `/api/admin/${famId}/fam`, data, sessionHeaders(event)); + }, + async request(event: RequestEvent, method: string, path: string, body?: unknown) { + return request(method, path, body, sessionHeaders(event)); + }, }, }; diff --git a/frontend/src/lib/server/pb-admin.ts b/frontend/src/lib/server/pb-admin.ts index 7073243..4c109f8 100644 --- a/frontend/src/lib/server/pb-admin.ts +++ b/frontend/src/lib/server/pb-admin.ts @@ -42,4 +42,16 @@ export const pbAdmin = { if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`); return data; }, + + async update(collection: string, id: string, data: Record) { + const t = await ensureToken(); + const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify(data), + }); + const result = await res.json(); + if (!res.ok) throw new Error(`PB update ${collection}/${id}: ${JSON.stringify(result)}`); + return result; + }, }; diff --git a/frontend/src/lib/stores/fam.svelte.ts b/frontend/src/lib/stores/fam.svelte.ts index 7375988..2812c26 100644 --- a/frontend/src/lib/stores/fam.svelte.ts +++ b/frontend/src/lib/stores/fam.svelte.ts @@ -1,10 +1,10 @@ import { pb } from '$lib/pocketbase'; import type { Member, ChoreTemplate, AssignedChore, Completion, - WeeklyHistory, Reward, BonusConfig, Fam, + WeeklyHistory, Reward, BonusConfig, Fam, Season, } from '$lib/types'; -type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards'; +type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards' | 'seasons'; class FamStore { fam = $state(null) @@ -15,6 +15,7 @@ class FamStore { history = $state([]) rewards = $state([]) bonusConfigs = $state([]) + seasons = $state([]) initialized = $state(false) famId = $state('') @@ -62,7 +63,7 @@ class FamStore { this.initPromise = (async () => { try { - const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes] = + const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes, seasonsRes] = await Promise.all([ pb.collection('fams').getOne(famId) as Promise, pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise, @@ -71,6 +72,7 @@ class FamStore { pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise, pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise, pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise, + pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }) as Promise, ]) this.fam = famRes this.members = membersRes @@ -79,6 +81,7 @@ class FamStore { this.completions = completionsRes this.bonusConfigs = bonusConfigsRes this.rewards = rewardsRes + this.seasons = seasonsRes this.initialized = true } catch (e) { console.error('FamStore.init failed:', e) @@ -101,6 +104,7 @@ class FamStore { { collection: 'completions', filter: this.famId }, { collection: 'bonus_configs', filter: this.famId }, { collection: 'rewards', filter: this.famId }, + { collection: 'seasons', filter: this.famId }, ] const promises = subs.map(({ collection, filter }) => { @@ -136,6 +140,7 @@ class FamStore { case 'completions': this.completions = apply(this.completions); break case 'bonus_configs': this.bonusConfigs = apply(this.bonusConfigs); break case 'rewards': this.rewards = apply(this.rewards); break + case 'seasons': this.seasons = apply(this.seasons); break } } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 165a45e..6c99f94 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -8,6 +8,18 @@ export type BonusPeriod = 'weekly' | 'monthly' export type BonusStatus = 'active' | 'archived' export type BonusState = 'pending' | 'unclaimed' | 'claimed' +export interface Season { + id: string + famId: string + name: string + color: string + active: boolean + autoDisable?: string + autoStart?: string + created: string + updated: string +} + export interface Fam { id: string name: string @@ -51,6 +63,7 @@ export interface AssignedChore { type: RewardType value: number customName?: string + seasonIds?: string[] created: string updated: string } @@ -102,8 +115,9 @@ export interface Reward { label: string value: number rewardType: BonusRewardType - claimed: boolean + status: 'unclaimed' | 'requested' | 'claimed' claimedAt?: string + requestedAt?: string date: string created: string updated: string diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index 093e12b..568504f 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -1,4 +1,29 @@ -export function load(event) { +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 || null; - return { session, isParent: session !== null, role: session?.role || 'child' }; + const isParent = session !== null; + + let famId = ''; + + if (isParent) { + famId = session.famId; + } else { + const deviceToken = event.cookies.get('device_token') || ''; + if (deviceToken) { + try { + const res = await fetch(`${HONO_URL}/api/members/seasons`, { + headers: { 'x-device-token': deviceToken }, + }); + if (res.ok) { + const body = await res.json(); + famId = body.famId || ''; + } + } catch {} + } + } + + return { session, isParent, role: session?.role || 'child', famId }; } diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index 260d738..d31c2ab 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -10,7 +10,9 @@ let session = $state(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); + let famName = $derived( + famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam + ); // Claim toast watcher (admin only) let claimToast = $state(''); @@ -22,31 +24,29 @@ const rewards = famStore.rewards; if (!mounted) { mounted = true; - prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id)); + prevClaimedIds = new Set(rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id)); return; } for (const r of rewards) { - if (r.claimed && !prevClaimedIds.has(r.id)) { + if (r.status === 'requested' && !prevClaimedIds.has(r.id)) { const m = famStore.memberMap().get(r.memberId); const name = m?.name || 'Unknown'; - claimToast = `Claim made by ${name}`; - setTimeout(() => claimToast = '', 5000); + claimToast = `Claim requested by ${name}`; + setTimeout(() => (claimToast = ''), 5000); } } - prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id)); + prevClaimedIds = new Set(rewards.filter((r: any) => r.status === 'requested').map((r: any) => r.id)); }); onMount(() => { initPbFromCookie(); - if (session) { - famStore.init(session.famId); - } + if (page.data.famId) famStore.init(page.data.famId); });
- +
{@render children()}
@@ -57,8 +57,24 @@
diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte index 1fd9cec..f39c74b 100644 --- a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte @@ -57,9 +57,9 @@ import type { let readyConfigs = $derived(configs.filter((c) => c.phase === 'ready') as BonusConfig[]); let activeConfigs = $derived(configs.filter((c) => c.phase === 'active') as BonusConfig[]); let completedConfigs = $derived(configs.filter((c) => c.phase === 'completed') as BonusConfig[]); - let completedRewards = $derived(allRewards.filter((r) => r.claimed) as Reward[]); + let completedRewards = $derived(allRewards.filter((r) => r.status === 'claimed') as Reward[]); let unclaimedRewards = $derived( - allRewards.filter((r) => !r.claimed) as Reward[] + allRewards.filter((r) => r.status === 'unclaimed') as Reward[] ); let progressByConfigId = $derived.by(() => { const map = new Map(); diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.server.ts b/frontend/src/routes/[fam]/[username]/chores/+page.server.ts index 89490f5..64d2735 100644 --- a/frontend/src/routes/[fam]/[username]/chores/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/chores/+page.server.ts @@ -4,12 +4,13 @@ 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([ + const [templates, members, assigned, seasons] = await Promise.all([ hono.admin.list(event, 'chore-templates', famId), hono.admin.list(event, 'members', famId), hono.admin.list(event, 'assigned-chores', famId), + hono.admin.list(event, 'seasons', famId), ]); - return { templates, members, assigned }; + return { templates, members, assigned, seasons }; } export const actions = { @@ -80,29 +81,6 @@ export const actions = { } }, - 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; @@ -113,10 +91,14 @@ export const actions = { const type = fd.get('type'); const value = fd.get('value'); const customName = fd.get('customName'); + const seasonIdsRaw = fd.get('seasonIds') as string; 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; + if (seasonIdsRaw || seasonIdsRaw === '') { + data.seasonIds = seasonIdsRaw ? seasonIdsRaw.split(',').filter(Boolean) : []; + } try { const record = await hono.admin.update(event, 'assigned-chores', famId, id, data); return { record }; @@ -125,16 +107,4 @@ export const actions = { } }, - 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' }); - } - }, }; diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.svelte b/frontend/src/routes/[fam]/[username]/chores/+page.svelte index f1af52c..02bba05 100644 --- a/frontend/src/routes/[fam]/[username]/chores/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/chores/+page.svelte @@ -3,7 +3,7 @@ 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'; + import type { ChoreTemplate, AssignedChore, Member, Season } from '$lib/types'; let { data, form } = $props(); @@ -30,18 +30,45 @@ let editTplType = $state('points') let editTplValue = $state(10) + let seasonFilter = $state('all') + let editSeasonIds = $state([]) + 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[] || [])) + let assigned = $derived(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[] || [])) + + let seasons = $state(famStore.initialized ? famStore.seasons : (data.seasons || [])) + + let filteredAssigned = $derived( + seasonFilter === 'all' + ? assigned + : assigned.filter(a => a.seasonIds?.includes(seasonFilter)) + ) function assignedForMember(memberId: string): AssignedChore[] { + return filteredAssigned.filter(a => a.memberId === memberId) + } + + function assignedForMemberAll(memberId: string): AssignedChore[] { return assigned.filter(a => a.memberId === memberId) } + function isGlobalChore(a: AssignedChore): boolean { + return !a.seasonIds || a.seasonIds.length === 0 + } + function templateName(templateId: string): string { return famStore.templateMap().get(templateId)?.name || '?' } + function seasonName(id: string): string { + return seasons.find(s => s.id === id)?.name || id + } + + function seasonNames(a: AssignedChore): string { + return (a.seasonIds || []).map(id => seasonName(id)).join(', ') || 'Global' + } + 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 } @@ -65,25 +92,16 @@ if (!tid) return draggedTemplateId = null + const s = page.data.session as any + if (!s) return 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`, { + const body: Record = { memberId, templateId: tid, frequency: def.frequency, type: def.type, value: def.value } + if (seasonFilter !== 'all') body.seasonIds = [seasonFilter] + await fetch(`/api/admin/${s.famId}/assigned-chores`, { method: 'POST', - body: fd, + headers: { 'Content-Type': 'application/json', 'x-session-famid': s.famId, 'x-session-userid': s.userId }, + body: JSON.stringify(body), }) - 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) { @@ -92,6 +110,7 @@ editType = a.type editValue = a.value editCustomName = a.customName || '' + editSeasonIds = a.seasonIds ? [...a.seasonIds] : [] showEditModal = true } @@ -138,6 +157,7 @@ type: editType, value: editValue, customName: editCustomName || undefined, + seasonIds: editSeasonIds.length > 0 ? editSeasonIds : [], } as AssignedChore famStore.applyRecord('assigned_chores', assigned[idx], 'update') } @@ -179,6 +199,16 @@

{form.error}

{/if} +
+ + +
+
@@ -233,12 +263,28 @@ {a.customName || tName} {a.frequency} {a.type} + {#if seasonFilter !== 'all' && isGlobalChore(a)} + Add to + {/if}
{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : a.value}
-
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'); } } }; }}> - - -
+
+ {seasonNames(a)} +
+ {/each} {#if assignedForMember(m.id).length === 0} @@ -333,6 +379,7 @@

Edit: {editingAssignment.customName || templateName(editingAssignment.templateId)}

+ - -