From d720f4e8631ba31d28d2dfc916e5d1f8d2253bc7 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Fri, 31 Jul 2026 16:13:05 +0100 Subject: [PATCH] make major bonus updates --- AGENTS.md | 51 +- frontend/src/app.d.ts | 2 + frontend/src/lib/server/hono.ts | 151 +- frontend/src/lib/stores/fam.svelte.ts | 260 +- frontend/src/lib/types.ts | 249 +- .../src/routes/[fam]/[username]/+page.svelte | 162 +- .../[fam]/[username]/bonuses/+page.server.ts | 126 +- .../[fam]/[username]/bonuses/+page.svelte | 807 +++-- .../[fam]/[username]/rewards/+page.svelte | 4 +- proxy/scripts/seed.ts | 565 ++-- proxy/src/index.ts | 2586 ++++++++++------- proxy/src/migrate.ts | 1809 +++++++----- 12 files changed, 4174 insertions(+), 2598 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7ac1b85..9fb5228 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,10 +18,10 @@ ## Auth -| Role | Auth | Session | Record in | -| ---------------- | -------------------------- | --------------------------- | -------------------- | -| Admin (parent) | PB email+pass | 24hr JWT | `fam_admins` | -| Member (child) | Invite code + device token | `device_token` cookie only | `members` | +| Role | Auth | Session | Record in | +| -------------- | -------------------------- | -------------------------- | ------------ | +| Admin (parent) | PB email+pass | 24hr JWT | `fam_admins` | +| Member (child) | Invite code + device token | `device_token` cookie only | `members` | - **Admins** (parents) have a PB auth record + `fam_admins` record. They authenticate via email/password login, get a session cookie (`session`) with `{ famId, userId, famSlug, memberName, role: "parent" }`. - **Members** (children) exist only in the `members` collection. They authenticate via invite code + device token (SHA-256 hashed). The `device_token` cookie is set on join; no session cookie. @@ -61,11 +61,13 @@ ## 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:** Form actions → Hono proxy → PB (admin JWT via `sessionHeaders`) - **Member updates:** Browser → Hono proxy → PB (auth via `x-device-token` + `x-device-famid`) @@ -75,6 +77,7 @@ - **WhatsApp:** Deferred — Hono CRON handler has pluggable notification interface ### 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 @@ -84,15 +87,16 @@ Env vars must be declared in `frontend/src/env.ts` using `defineEnvVars` from `@sveltejs/kit/hooks`: ```ts -import { defineEnvVars } from '@sveltejs/kit/hooks'; +import { defineEnvVars } from "@sveltejs/kit/hooks"; export const variables = defineEnvVars({ DEBUG_RECORD_ID: {}, - SERVER_IP: {public: true}, - PB_PORT: {public: true} + SERVER_IP: { public: true }, + PB_PORT: { public: true }, }); ``` Only vars with `{public: true}` are exposed to client-side code via `$app/env/public`. If you need a new client-side env var (e.g., `PUBLIC_PB_URL`), you must: + 1. Add it to `.env` with `PUBLIC_` prefix 2. Add it to `frontend/src/env.ts` with `{public: true}` @@ -101,6 +105,7 @@ The actual values come from `.env` (symlinked from project root at `frontend/.en ## ⚠️ CRITICAL: Reactivity Pattern (NEVER use `$effect` to sync from famStore) **Do NOT do this:** + ```svelte let items = $state(data.items); @@ -111,21 +116,45 @@ $effect(() => { ``` **Do this instead:** + ```svelte let items = $state(famStore.initialized ? famStore.items : (data.items || [])); ``` +### ⚠️ CRITICAL: `$state` vs `$derived` for famStore collections + +**When a page reads data that should update via SSE (realtime from other users/actions), you MUST use `$derived` — NOT `$state`.** + +This is the most commonly missed rule. Agents repeatedly initialize collections from `famStore` with `$state`, which captures a **frozen snapshot** at init time. When PocketBase pushes a record via SSE → `famStore.applyRecord()`, the `$state` variable never reacts — the UI stays stale until a page refresh. + +**Rule: if the data should update without a refresh, use `$derived`.** + +```svelte +// ❌ WRONG — frozen snapshot, won't react to SSE updates +let configs = $state(famStore.initialized ? famStore.bonusConfigs : data.configs); + +// ✅ RIGHT — reactive, updates when famStore changes via SSE +let configs = $derived( + famStore.initialized ? famStore.bonusConfigs : (data.configs || []) +); +``` + +**The only exception:** high-frequency member actions (like chore toggles) that need **optimistic UI** before the server responds. Those use `$state` + `famStore.applyRecord()` for instant feedback, then reconcile on the server response. See `applyRecord` pattern below. + +**All other pages (admin CRUD, bonuses, rewards, settings, etc.) must use `$derived`** so that SSE updates flow through `famStore` → `$derived` → UI automatically. + ## Update Patterns Two patterns based on who's acting: -| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth | -|---------|-----|-----------|-------------|-------------|------| -| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `x-device-token` header | -| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `session` cookie | +| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth | +| ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ------------------------- | +| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `x-device-token` header | +| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `session` cookie | **Member direct fetch** — optimistic UI via local state mutation, reconciled on response: + ```svelte let completions = $state(data.completions) async function toggle(chore) { diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index fb2103d..c749e75 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,4 +1,5 @@ import { PocketBase } from 'pocketbase'; +import type { Session } from './lib/types'; // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces @@ -8,6 +9,7 @@ declare global { // interface Error {} interface Locals { pb: PocketBase; + session?: Session | null; } // interface PageData {} // interface PageState {} diff --git a/frontend/src/lib/server/hono.ts b/frontend/src/lib/server/hono.ts index 57dc440..db04bec 100644 --- a/frontend/src/lib/server/hono.ts +++ b/frontend/src/lib/server/hono.ts @@ -9,15 +9,20 @@ function sessionHeaders(event: RequestEvent): Record { return { 'x-session-famid': s.famId, 'x-session-userid': s.userId, - 'Content-Type': 'application/json', + 'Content-Type': 'application/json' }; } -async function request(method: string, path: string, body?: unknown, headers?: Record) { +async function request( + method: string, + path: string, + body?: unknown, + headers?: Record +) { const res = await fetch(`${HONO_URL}${path}`, { method, headers: headers || { 'Content-Type': 'application/json' }, - body: body ? JSON.stringify(body) : undefined, + body: body ? JSON.stringify(body) : undefined }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `${method} ${path} failed`); @@ -29,14 +34,30 @@ export const hono = { async list(event: RequestEvent, resource: string, famId: string) { return request('GET', `/api/admin/${famId}/${resource}`, undefined, sessionHeaders(event)); }, - async create(event: RequestEvent, resource: string, famId: string, data: Record) { + async create( + event: RequestEvent, + resource: string, + famId: string, + data: Record + ) { return request('POST', `/api/admin/${famId}/${resource}`, data, sessionHeaders(event)); }, - async update(event: RequestEvent, resource: string, famId: string, id: string, data: Record) { + async update( + event: RequestEvent, + resource: string, + famId: string, + id: string, + data: Record + ) { return request('PATCH', `/api/admin/${famId}/${resource}/${id}`, data, sessionHeaders(event)); }, async remove(event: RequestEvent, resource: string, famId: string, id: string) { - return request('DELETE', `/api/admin/${famId}/${resource}/${id}`, undefined, sessionHeaders(event)); + return request( + 'DELETE', + `/api/admin/${famId}/${resource}/${id}`, + undefined, + sessionHeaders(event) + ); }, async renameFam(event: RequestEvent, famId: string, name: string) { return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event)); @@ -63,46 +84,126 @@ export const hono = { return request('GET', `/api/admin/${famId}/rewards`, undefined, sessionHeaders(event)); }, async claimReward(event: RequestEvent, famId: string, rewardId: string) { - return request('POST', `/api/admin/${famId}/rewards/${rewardId}/claim`, undefined, sessionHeaders(event)); + 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)); + 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)); }, async bonusConfigProgress(event: RequestEvent, famId: string) { - return request('GET', `/api/admin/${famId}/bonus-configs/progress`, undefined, sessionHeaders(event)); + return request( + 'GET', + `/api/admin/${famId}/bonus-configs/progress`, + undefined, + sessionHeaders(event) + ); }, async evaluateBonusConfig(event: RequestEvent, famId: string, configId?: string) { - return request('POST', `/api/admin/${famId}/bonus-configs/evaluate`, { configId }, sessionHeaders(event)); + return request( + 'POST', + `/api/admin/${famId}/bonus-configs/evaluate`, + { configId }, + sessionHeaders(event) + ); }, - async triggerBonusConfig(event: RequestEvent, famId: string, configId: string, memberId?: string) { - return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/trigger`, { memberId }, sessionHeaders(event)); + async triggerBonusConfig( + event: RequestEvent, + famId: string, + configId: string, + memberId?: string + ) { + return request( + 'POST', + `/api/admin/${famId}/bonus-configs/${configId}/trigger`, + { memberId }, + sessionHeaders(event) + ); }, - async assignBonusConfig(event: RequestEvent, famId: string, configId: string, data: Record) { - return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/assign`, data, sessionHeaders(event)); + async assignBonusConfig( + event: RequestEvent, + famId: string, + configId: string, + data: Record + ) { + return request( + 'POST', + `/api/admin/${famId}/bonus-configs/${configId}/assign`, + data, + sessionHeaders(event) + ); }, - async completeBonusConfig(event: RequestEvent, famId: string, configId: string, phase?: string) { - return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/complete`, { phase }, sessionHeaders(event)); + async completeBonusConfig(event: RequestEvent, famId: string, configId: string) { + return request( + 'POST', + `/api/admin/${famId}/bonus-configs/${configId}/complete`, + undefined, + sessionHeaders(event) + ); }, async destroyBonusConfig(event: RequestEvent, famId: string, configId: string) { - return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/destroy`, undefined, sessionHeaders(event)); + return request( + 'POST', + `/api/admin/${famId}/bonus-configs/${configId}/destroy`, + undefined, + sessionHeaders(event) + ); }, async bonusConfigTallies(event: RequestEvent, famId: string) { - return request('GET', `/api/admin/${famId}/bonus-configs/tallies`, undefined, sessionHeaders(event)); + return request( + 'GET', + `/api/admin/${famId}/bonus-configs/tallies`, + undefined, + sessionHeaders(event) + ); }, async revokeCompletion(event: RequestEvent, famId: string, completionId: string) { - return request('POST', `/api/admin/${famId}/completions/${completionId}/revoke`, undefined, sessionHeaders(event)); + return request( + 'POST', + `/api/admin/${famId}/completions/${completionId}/revoke`, + undefined, + sessionHeaders(event) + ); }, async sendMessage(event: RequestEvent, famId: string, memberId: string, message: string) { - return request('POST', `/api/admin/${famId}/send-message`, { memberId, message }, sessionHeaders(event)); + return request( + 'POST', + `/api/admin/${famId}/send-message`, + { memberId, message }, + sessionHeaders(event) + ); }, async memberChores(event: RequestEvent, famId: string, memberId: string) { - return request('GET', `/api/admin/${famId}/members/${memberId}/chores`, undefined, sessionHeaders(event)); + return request( + 'GET', + `/api/admin/${famId}/members/${memberId}/chores`, + undefined, + sessionHeaders(event) + ); }, - async updateMember(event: RequestEvent, famId: string, memberId: string, data: Record) { - return request('PATCH', `/api/admin/${famId}/members/${memberId}`, data, sessionHeaders(event)); + async updateMember( + event: RequestEvent, + famId: string, + memberId: string, + data: Record + ) { + return request( + 'PATCH', + `/api/admin/${famId}/members/${memberId}`, + data, + sessionHeaders(event) + ); }, async getProfile(event: RequestEvent, famId: string) { return request('GET', `/api/admin/${famId}/profile`, undefined, sessionHeaders(event)); @@ -115,6 +216,6 @@ export const hono = { }, async request(event: RequestEvent, method: string, path: string, body?: unknown) { return request(method, path, body, sessionHeaders(event)); - }, - }, + } + } }; diff --git a/frontend/src/lib/stores/fam.svelte.ts b/frontend/src/lib/stores/fam.svelte.ts index 2812c26..c10848e 100644 --- a/frontend/src/lib/stores/fam.svelte.ts +++ b/frontend/src/lib/stores/fam.svelte.ts @@ -1,99 +1,145 @@ import { pb } from '$lib/pocketbase'; import type { - Member, ChoreTemplate, AssignedChore, Completion, - WeeklyHistory, Reward, BonusConfig, Fam, Season, + Member, + ChoreTemplate, + AssignedChore, + Completion, + WeeklyHistory, + Reward, + BonusConfig, + BonusTemplate, + Fam, + Season } from '$lib/types'; -type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards' | 'seasons'; +type CollectionName = + | 'members' + | 'chore_templates' + | 'assigned_chores' + | 'completions' + | 'bonus_configs' + | 'bonus_templates' + | 'rewards' + | 'seasons'; class FamStore { - fam = $state(null) - members = $state([]) - templates = $state([]) - assigned = $state([]) - completions = $state([]) - history = $state([]) - rewards = $state([]) - bonusConfigs = $state([]) - seasons = $state([]) - initialized = $state(false) - famId = $state('') + fam = $state(null); + members = $state([]); + templates = $state([]); + assigned = $state([]); + completions = $state([]); + history = $state([]); + rewards = $state([]); + bonusConfigs = $state([]); + bonusTemplates = $state([]); + seasons = $state([]); + initialized = $state(false); + famId = $state(''); - private unsubs: (() => void)[] = [] - private destroyed = false + private unsubs: (() => void)[] = []; + private destroyed = false; memberMap(): Map { - return new Map(this.members.map((m) => [m.id, m])) + return new Map(this.members.map((m) => [m.id, m])); } templateMap(): Map { - return new Map(this.templates.map((t) => [t.id, t])) + return new Map(this.templates.map((t) => [t.id, t])); } bonusConfigMap(): Map { - return new Map(this.bonusConfigs.map((b) => [b.id, b])) + return new Map(this.bonusConfigs.map((b) => [b.id, b])); } assignedForMember(memberId: string): AssignedChore[] { - return this.assigned.filter((a) => a.memberId === memberId) + return this.assigned.filter((a) => a.memberId === memberId); } completionsForDate(date: string): Completion[] { - return this.completions.filter((c) => (c.date?.slice(0, 10) || c.date) === date) + return this.completions.filter((c) => (c.date?.slice(0, 10) || c.date) === date); } isCompleted(assignedChoreId: string, date: string): boolean { - return this.completions.some((c) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === date) + return this.completions.some( + (c) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === date + ); } - private initPromise: Promise | null = null + private initPromise: Promise | null = null; async init(famId: string) { - if (this.initialized && this.famId === famId) return + if (this.initialized && this.famId === famId) return; // Wait for any in-flight init to finish first if (this.initPromise) { - await this.initPromise - if (this.initialized && this.famId === famId) return + await this.initPromise; + if (this.initialized && this.famId === famId) return; } - this.cleanup() - this.destroyed = false - this.famId = famId + this.cleanup(); + this.destroyed = false; + this.famId = famId; this.initPromise = (async () => { try { - 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, - pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise, - pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise, - 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 - this.templates = templatesRes - this.assigned = assignedRes - this.completions = completionsRes - this.bonusConfigs = bonusConfigsRes - this.rewards = rewardsRes - this.seasons = seasonsRes - this.initialized = true + const [ + famRes, + membersRes, + templatesRes, + assignedRes, + completionsRes, + bonusConfigsRes, + bonusTemplatesRes, + rewardsRes, + seasonsRes + ] = await Promise.all([ + pb.collection('fams').getOne(famId) as Promise, + pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise< + Member[] + >, + pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise< + ChoreTemplate[] + >, + pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise< + AssignedChore[] + >, + pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise< + Completion[] + >, + pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise< + BonusConfig[] + >, + pb.collection('bonus_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise< + BonusTemplate[] + >, + pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise< + Reward[] + >, + pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }) as Promise< + Season[] + > + ]); + this.fam = famRes; + this.members = membersRes; + this.templates = templatesRes; + this.assigned = assignedRes; + this.completions = completionsRes; + this.bonusConfigs = bonusConfigsRes; + this.bonusTemplates = bonusTemplatesRes; + this.rewards = rewardsRes; + this.seasons = seasonsRes; + this.initialized = true; } catch (e) { - console.error('FamStore.init failed:', e) - this.initPromise = null - throw e + console.error('FamStore.init failed:', e); + this.initPromise = null; + throw e; } - await this.subscribe() - this.initPromise = null - })() + await this.subscribe(); + this.initPromise = null; + })(); - return this.initPromise! + return this.initPromise!; } private async subscribe() { @@ -103,58 +149,92 @@ class FamStore { { collection: 'assigned_chores', filter: this.famId }, { collection: 'completions', filter: this.famId }, { collection: 'bonus_configs', filter: this.famId }, + { collection: 'bonus_templates', filter: this.famId }, { collection: 'rewards', filter: this.famId }, - { collection: 'seasons', filter: this.famId }, - ] + { collection: 'seasons', filter: this.famId } + ]; const promises = subs.map(({ collection, filter }) => { - const filterStr = filter ? `famId = '${filter}'` : '' - return pb.collection(collection).subscribe('*', (data: any) => { - if (this.destroyed) return - this.handleRealtime(collection, data.action, data.record) - }, { filter: filterStr || undefined }).then((unsub) => { - if (this.destroyed) { unsub(); return } - this.unsubs.push(unsub) - }).catch((err: Error) => { - console.error(`[famStore] subscribe failed for ${collection}:`, err) - }) - }) + const filterStr = filter ? `famId = '${filter}'` : ''; + return pb + .collection(collection) + .subscribe( + '*', + (data: any) => { + if (this.destroyed) return; + this.handleRealtime(collection, data.action, data.record); + }, + { filter: filterStr || undefined } + ) + .then((unsub) => { + if (this.destroyed) { + unsub(); + return; + } + this.unsubs.push(unsub); + }) + .catch((err: Error) => { + console.error(`[famStore] subscribe failed for ${collection}:`, err); + }); + }); - await Promise.allSettled(promises) + await Promise.allSettled(promises); } // Called from form action callbacks for instant UI feedback, // and from PB subscribe SSE for multi-user realtime. applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') { const apply = (list: T[]): T[] => { - if (action === 'create') return [record, ...list] - if (action === 'update') return list.map((x) => (x.id === record.id ? { ...x, ...record } : x)) - if (action === 'delete') return list.filter((x) => x.id !== record.id) - return list - } + if (action === 'create') return [record, ...list]; + if (action === 'update') + return list.map((x) => (x.id === record.id ? { ...x, ...record } : x)); + if (action === 'delete') return list.filter((x) => x.id !== record.id); + return list; + }; switch (collection) { - case 'members': this.members = apply(this.members); break - case 'chore_templates': this.templates = apply(this.templates); break - case 'assigned_chores': this.assigned = apply(this.assigned); break - 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 + case 'members': + this.members = apply(this.members); + break; + case 'chore_templates': + this.templates = apply(this.templates); + break; + case 'assigned_chores': + this.assigned = apply(this.assigned); + break; + case 'completions': + this.completions = apply(this.completions); + break; + case 'bonus_configs': + this.bonusConfigs = apply(this.bonusConfigs); + break; + case 'bonus_templates': + this.bonusTemplates = apply(this.bonusTemplates); + break; + case 'rewards': + this.rewards = apply(this.rewards); + break; + case 'seasons': + this.seasons = apply(this.seasons); + break; } } private handleRealtime(collection: string, action: string, record: any) { - this.applyRecord(collection as CollectionName, record, action as 'create' | 'update' | 'delete') + this.applyRecord( + collection as CollectionName, + record, + action as 'create' | 'update' | 'delete' + ); } - cleanup() { - // TODO - we need to properly unsubscribe from pocketbase - this.destroyed = true - for (const unsub of this.unsubs) unsub() - this.unsubs = [] - this.initialized = false + cleanup() { + // TODO - we need to properly unsubscribe from pocketbase + this.destroyed = true; + for (const unsub of this.unsubs) unsub(); + this.unsubs = []; + this.initialized = false; } } -export const famStore = new FamStore() +export const famStore = new FamStore(); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e622736..965f46a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,156 +1,171 @@ -export type Frequency = 'daily' | 'weekly' -export type RewardType = 'points' | 'money' -export type BonusTarget = 'individual' | 'competitive' | 'collaborative' -export type BonusType = 'threshold' | 'count' | 'manual' -export type BonusOccurrence = 'recurring' | 'once' -export type BonusRewardType = 'points' | 'cash' | 'prize' -export type BonusPeriod = 'weekly' | 'monthly' -export type BonusStatus = 'active' | 'archived' -export type BonusState = 'pending' | 'unclaimed' | 'claimed' +export type Frequency = 'daily' | 'weekly'; +export type RewardType = 'points' | 'money'; +export type BonusTarget = 'individual' | 'competitive' | 'collaborative'; +export type BonusType = 'threshold' | 'count' | 'manual'; +export type BonusOccurrence = 'recurring' | 'once'; +export type BonusRewardType = 'points' | 'cash' | 'prize'; +export type BonusPeriod = 'weekly' | 'monthly' | 'daily'; +export type BonusStatus = 'active' | 'completed' | 'disabled'; +export type BonusState = 'pending' | 'unclaimed' | 'claimed'; + +export interface BonusTemplate { + id: string; + famId: string; + name: string; + description?: string; + target: BonusTarget; + type: BonusType; + occurrence: BonusOccurrence; + rewardType: BonusRewardType; + rewardValue: string; + criteriaValue?: number; + period?: BonusPeriod; + created: string; + updated: string; +} export interface Season { - id: string - famId: string - name: string - color: string - active: boolean - autoDisable?: string - autoStart?: string - created: string - updated: string + 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 - slug: string - inviteCode: string - stripeCustomerId?: string - featureFlags: Record - created: string - updated: string + id: string; + name: string; + slug: string; + inviteCode: string; + stripeCustomerId?: string; + featureFlags: Record; + created: string; + updated: string; } export interface Member { - id: string - famId: string - name: string - color: string - deviceToken: string - deviceTokenHint: string - created: string - updated: string + id: string; + famId: string; + name: string; + color: string; + deviceToken: string; + deviceTokenHint: string; + created: string; + updated: string; } export interface ChoreTemplate { - id: string - famId: string - name: string - description?: string - defaultFrequency: Frequency - defaultType: RewardType - defaultValue: number - created: string - updated: string + id: string; + famId: string; + name: string; + description?: string; + defaultFrequency: Frequency; + defaultType: RewardType; + defaultValue: number; + created: string; + updated: string; } export interface AssignedChore { - id: string - famId: string - memberId: string - templateId: string - frequency: Frequency - type: RewardType - value: number - customName?: string - seasonIds?: string[] - created: string - updated: string + id: string; + famId: string; + memberId: string; + templateId: string; + frequency: Frequency; + type: RewardType; + value: number; + customName?: string; + seasonIds?: string[]; + created: string; + updated: string; } export interface Completion { - id: string - famId: string - memberId: string - assignedChoreId: string - date: string - completedAt: string + id: string; + famId: string; + memberId: string; + assignedChoreId: string; + date: string; + completedAt: string; } export interface WeeklyHistory { - id: string - famId: string - memberId: string - weekStart: string - pointsEarned: number - moneyEarned: number - choresCompleted: number - bonusEarned: number + id: string; + famId: string; + memberId: string; + weekStart: string; + pointsEarned: number; + moneyEarned: number; + choresCompleted: number; + bonusEarned: number; } export interface BonusConfig { - id: string - famId: string - name: string - description?: string - target: BonusTarget - memberId?: string - type: BonusType - occurrence: BonusOccurrence - rewardType: BonusRewardType - rewardValue: string - criteriaValue?: number - period?: BonusPeriod - status: BonusStatus - phase?: 'template' | 'ready' | 'active' | 'completed' - created: string - updated: string + id: string; + famId: string; + name: string; + description?: string; + target: BonusTarget; + memberId?: string; + type: BonusType; + occurrence: BonusOccurrence; + rewardType: BonusRewardType; + rewardValue: string; + criteriaValue?: number; + period?: BonusPeriod; + status: BonusStatus; + created: string; + updated: string; } export interface Reward { - id: string - famId: string - memberId: string - bonusConfigId?: string - label: string - value: number - rewardType: BonusRewardType - status: 'unclaimed' | 'requested' | 'claimed' - claimedAt?: string - requestedAt?: string - date: string - created: string - updated: string + id: string; + famId: string; + memberId: string; + bonusConfigId?: string; + label: string; + value: number; + rewardType: BonusRewardType; + status: 'unclaimed' | 'requested' | 'claimed'; + claimedAt?: string; + requestedAt?: string; + date: string; + created: string; + updated: string; } export interface BonusProgress { - memberId: string - memberName: string - memberColor: string - current: number - criteriaValue: number - reward: { id: string; status: string } | null - state: BonusState - achieved: boolean + memberId: string; + memberName: string; + memberColor: string; + current: number; + criteriaValue: number; + reward: { id: string; status: string } | null; + state: BonusState; + achieved: boolean; } export interface BonusConfigWithProgress { - config: BonusConfig - progress: BonusProgress[] - periodStart: string - periodEnd: string + config: BonusConfig; + progress: BonusProgress[]; + periodStart: string; + periodEnd: string; } export interface Settings { - id: string - famId: string - webhookUrl?: string + id: string; + famId: string; + webhookUrl?: string; } export interface Session { - famId: string - userId: string - famSlug: string - memberName?: string - role?: string + famId: string; + userId: string; + famSlug: string; + memberName?: string; + role?: string; } diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 65ae877..4602408 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -118,16 +118,22 @@ } function rewardLabel(r: any): string { - if (r.rewardType === 'cash') return `£${(Number(r.value) / 100).toFixed(2)}`; + if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`; if (r.rewardType === 'points') return `${r.value} pts`; return r.label; } function manualConfigs() { - return parentBonusConfigs.filter( - (b: any) => - (b.phase === 'active' || (!b.phase && b.status === 'active')) && b.type === 'manual' - ); + return parentBonusConfigs.filter((b: any) => { + if (!(b.status === 'active' && b.type === 'manual')) return false; + if (b.occurrence === 'once') { + const hasReward = parentRewards.some( + (r: any) => r.bonusConfigId === b.id && (!b.memberId || r.memberId === b.memberId) + ); + if (hasReward) return false; + } + return true; + }); } // ─── Child View (kanban) ─── @@ -140,11 +146,29 @@ let nameInput = $state(''); let showColorPicker = $state(false); - let templates = $derived(famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[])); - let assigned = $derived(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[])); - let completions = $derived(famStore.initialized ? (famStore.completions as Completion[]) : (data.completions as Completion[])); - let rewards = $derived(famStore.initialized ? (famStore.rewards as Reward[]) : (data.rewards as Reward[])); - let bonusConfigs = $derived(famStore.initialized ? (famStore.bonusConfigs as BonusConfig[]) : (data.bonusConfigs as BonusConfig[])); + let templates = $derived( + famStore.initialized + ? (famStore.templates as ChoreTemplate[]) + : (data.templates as ChoreTemplate[]) + ); + let assigned = $derived( + famStore.initialized + ? (famStore.assigned as AssignedChore[]) + : (data.assigned as AssignedChore[]) + ); + let completions = $derived( + famStore.initialized + ? (famStore.completions as Completion[]) + : (data.completions as Completion[]) + ); + let rewards = $derived( + famStore.initialized ? (famStore.rewards as Reward[]) : (data.rewards as Reward[]) + ); + let bonusConfigs = $derived( + famStore.initialized + ? (famStore.bonusConfigs as BonusConfig[]) + : (data.bonusConfigs as BonusConfig[]) + ); let loading = $state(true); let error = $state(''); @@ -187,8 +211,12 @@ let completedToday = $derived( completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === todayChild) ); - let claimableRewardsChild = $derived(rewards.filter((r) => r.memberId === memberId && r.status === 'unclaimed')); - let requestedRewardsChild = $derived(rewards.filter((r) => r.memberId === memberId && r.status === 'requested')); + let claimableRewardsChild = $derived( + rewards.filter((r) => r.memberId === memberId && r.status === 'unclaimed') + ); + let requestedRewardsChild = $derived( + rewards.filter((r) => r.memberId === memberId && r.status === 'requested') + ); let weeklyTotal = $derived.by(() => { const dailyChores = memberChores.filter((a) => a.frequency === 'daily'); @@ -211,9 +239,7 @@ 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) + myRewards.filter((r) => r.rewardType === 'cash').reduce((sum, r) => sum + Number(r.value), 0) ); }); @@ -248,13 +274,12 @@ const myCompletions = completions.filter((c) => c.memberId === memberId); const activeConfigs = bonusConfigs.filter( (b: BonusConfig) => - (b.phase === 'active' || (!b.phase && b.status === 'active')) && + b.status === 'active' && b.target === 'individual' && (!b.memberId || b.memberId === memberId) ); return activeConfigs.map((cfg) => { - const pStart = - cfg.period === 'weekly' ? ws : cfg.period === 'monthly' ? monthStartStr() : ''; + const pStart = cfg.period === 'weekly' ? ws : cfg.period === 'monthly' ? monthStartStr() : ''; const pEnd = pStart ? (() => { if (cfg.period === 'weekly') { @@ -274,8 +299,7 @@ const periodCompletions = pStart ? myCompletions.filter( (c) => - (c.date?.slice(0, 10) || c.date) >= pStart && - (c.date?.slice(0, 10) || c.date) <= pEnd + (c.date?.slice(0, 10) || c.date) >= pStart && (c.date?.slice(0, 10) || c.date) <= pEnd ) : myCompletions; @@ -374,14 +398,18 @@ famStore.applyRecord('completions', existing, 'delete'); } } else { - famStore.applyRecord('completions', { - id: 'optimistic-' + chore.id, - famId, - memberId, - assignedChoreId: chore.id, - date: todayChild, - completedAt: new Date().toISOString() - } as Completion, 'create'); + famStore.applyRecord( + 'completions', + { + id: 'optimistic-' + chore.id, + famId, + memberId, + assignedChoreId: chore.id, + date: todayChild, + completedAt: new Date().toISOString() + } as Completion, + 'create' + ); } try { @@ -392,14 +420,18 @@ } } catch (e) { if (wasCompleted) { - famStore.applyRecord('completions', { - id: 'revert-' + chore.id + '-' + Date.now(), - famId, - memberId, - assignedChoreId: chore.id, - date: todayChild, - completedAt: new Date().toISOString() - } as Completion, 'create'); + famStore.applyRecord( + 'completions', + { + id: 'revert-' + chore.id + '-' + Date.now(), + famId, + memberId, + assignedChoreId: chore.id, + date: todayChild, + completedAt: new Date().toISOString() + } as Completion, + 'create' + ); } else { const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id); if (optimistic) { @@ -421,7 +453,7 @@ } function rewardLabelChild(r: Reward): string { - if (r.rewardType === 'cash') return `£${(Number(r.value) / 100).toFixed(2)}`; + if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`; if (r.rewardType === 'points') return `${r.value} pts`; return r.label; } @@ -468,7 +500,14 @@ {#each todays as c}
  • ✅ {choreNameFor(c.assignedChoreId)} -
    { return async (args) => handleResult(args); }} class="revoke-form"> + { + return async (args) => handleResult(args); + }} + class="revoke-form" + >
    @@ -487,7 +526,7 @@ {#each manualConfigs() as bc} {@const val = bc.rewardType === 'cash' - ? `£${(Number(bc.rewardValue) / 100).toFixed(2)}` + ? `£${Number(bc.rewardValue).toFixed(2)}` : bc.rewardType === 'points' ? `${bc.rewardValue} pts` : bc.rewardValue} @@ -500,7 +539,13 @@ {val} {#if targeted} -
    { return async (args: any) => handleResult(args); }}> + { + return async (args: any) => handleResult(args); + }} + >
    @@ -511,7 +556,13 @@
    {:else} -
    { return async (args: any) => handleResult(args); }}> + { + return async (args: any) => handleResult(args); + }} + >
    - + {/if} {/if} @@ -746,11 +799,11 @@
    Total Cash - £{(totalCash / 100).toFixed(2)} + £{totalCash.toFixed(2)}
    Pending Cash - £{(pendingCash / 100).toFixed(2)} + £{pendingCash.toFixed(2)}
    Total Points @@ -1440,9 +1493,16 @@ font-weight: 600; cursor: pointer; } - .request-all-btn:hover { background: #1d4ed8; } - .request-col { border-color: #93c5fd; background: #eff6ff; } - .claim-card.requested { opacity: 0.7; } + .request-all-btn:hover { + background: #1d4ed8; + } + .request-col { + border-color: #93c5fd; + background: #eff6ff; + } + .claim-card.requested { + opacity: 0.7; + } .requested-badge { font-size: 0.7rem; padding: 2px 8px; @@ -1465,7 +1525,9 @@ color: #2563eb; margin: 0 0 0.25rem; } - .payment-row.outstanding { opacity: 0.6; } + .payment-row.outstanding { + opacity: 0.6; + } h1 { display: flex; diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts b/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts index 1f0a74f..eb1a9e5 100644 --- a/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts @@ -4,21 +4,95 @@ 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([ + const [configs, templates, members, progress, rewards] = await Promise.all([ hono.admin.bonusConfigs(event, famId), + hono.admin.list(event, 'bonus-templates', famId), hono.admin.list(event, 'members', famId), hono.admin.bonusConfigProgress(event, famId), - hono.admin.rewards(event, famId), + hono.admin.rewards(event, famId) ]); - return { configs, members, progress, rewards }; + return { configs, templates, members, progress, rewards }; } 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: Record = { + name: fd.get('name'), + description: fd.get('description') || '', + target: fd.get('target'), + type: fd.get('type'), + occurrence: fd.get('occurrence'), + rewardType: fd.get('rewardType'), + rewardValue: fd.get('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; + if (data.occurrence === 'once') data.period = ''; + try { + const record = await hono.admin.create(event, 'bonus-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 data: Record = {}; + 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; + if (occurrence === 'once') data.period = ''; + const description = fd.get('description'); + if (description) data.description = description; + try { + const record = await hono.admin.update(event, 'bonus-templates', famId, id, data); + 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 { + await hono.admin.remove(event, 'bonus-templates', famId, id); + return { deleted: true }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' }); + } + }, + 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 startMode = fd.get('startMode') as string; + const status = startMode === 'disabled' ? 'disabled' : 'active'; const data: Record = { name: fd.get('name'), target: fd.get('target'), @@ -26,12 +100,13 @@ export const actions = { occurrence: fd.get('occurrence'), rewardType: fd.get('rewardType'), rewardValue: fd.get('rewardValue'), - phase: phase || 'ready', + status }; 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; + if (data.occurrence === 'once') data.period = ''; const description = fd.get('description'); if (description) data.description = description; const memberId = fd.get('memberId'); @@ -66,6 +141,7 @@ export const actions = { if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0; const period = fd.get('period'); if (period !== null) data.period = period; + if (occurrence === 'once') data.period = ''; const description = fd.get('description'); if (description) data.description = description; const memberId = fd.get('memberId'); @@ -87,7 +163,7 @@ export const actions = { 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' }); + return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete config' }); } }, @@ -95,6 +171,8 @@ export const actions = { if (!event.locals.session) throw redirect(303, '/login'); const famId = event.locals.session.famId; const fd = await event.request.formData(); + const startMode = fd.get('startMode') as string; + const status = startMode === 'disabled' ? 'disabled' : 'active'; const data: Record = { name: fd.get('name'), description: fd.get('description') || '', @@ -106,13 +184,16 @@ export const actions = { criteriaValue: parseInt(fd.get('criteriaValue') as string, 10) || 0, period: fd.get('period') || '', memberId: fd.get('memberId') || '', - phase: 'active', + status }; + if (data.occurrence === 'once') data.period = ''; 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 bonus from template' }); + return fail(400, { + error: e instanceof Error ? e.message : 'Failed to create bonus from template' + }); } }, @@ -124,7 +205,10 @@ export const actions = { 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 }); + 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' }); @@ -136,12 +220,11 @@ export const actions = { 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'); + const record = await hono.admin.completeBonusConfig(event, famId, id); return { record }; } catch (e) { - return fail(400, { error: e instanceof Error ? e.message : 'Failed to update bonus phase' }); + return fail(400, { error: e instanceof Error ? e.message : 'Failed to complete bonus' }); } }, @@ -158,6 +241,23 @@ export const actions = { } }, + toggleConfig: 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 currentStatus = fd.get('currentStatus') as string; + const newStatus = currentStatus === 'disabled' ? 'active' : 'disabled'; + try { + const record = await hono.admin.update(event, 'bonus-configs', famId, id, { + status: newStatus + }); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to toggle bonus' }); + } + }, + claimReward: async (event) => { if (!event.locals.session) throw redirect(303, '/login'); const famId = event.locals.session.famId; @@ -178,5 +278,5 @@ export const actions = { } catch (e) { return { error: e instanceof Error ? e.message : 'Failed to evaluate' }; } - }, + } }; diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte index d24055a..9568b57 100644 --- a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte @@ -1,23 +1,25 @@ -
    -

    Templates

    +

    Templates — Drag to assign

    {#each templateConfigs as cfg} -
    handleDragStart(e, cfg)} - > +
    handleDragStart(e, cfg)}>
    {cfg.name} -
    + - +
    {cfg.target} {cfg.type} {cfg.occurrence} - {#if cfg.period}{cfg.period}{/if} + {#if cfg.period} + {cfg.period} + {/if}
    -
    {formatReward(cfg)}
    +
    + {formatReward(cfg.rewardType, cfg.rewardValue)} +
    +
    {/each} {#if templateConfigs.length === 0} -

    No templates yet. Create one below.

    +

    No templates yet

    {/if}
    - +
    - + +

    Member

    - {#each activeConfigs.filter((c) => c.target === 'individual') as cfg} + {#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg} {@const done = isCompleted(cfg)} {@const outstanding = isOutstanding(cfg)} -
    +
    {cfg.name}
    @@ -262,21 +314,27 @@
    {#each members as m} {#if !cfg.memberId || cfg.memberId === m.id} - {@const p = progressByConfigId.get(cfg.id)?.progress.find((pr: BonusProgress) => pr.memberId === m.id)} - {#if p} + {@const p = progressByConfigId + .get(cfg.id) + ?.progress.find((pr: BonusProgress) => pr.memberId === m.id)} + {#if cfg.type === 'manual'}
    - {p.memberName} - {#if p.criteriaValue > 0} + {m.name} +
    + {:else} + {@const criteria = p?.criteriaValue ?? cfg.criteriaValue ?? 0} + {@const current = p?.current ?? 0} + {@const achieved = p?.achieved ?? false} + {@const pct = criteria > 0 ? Math.min(100, (current / criteria) * 100) : 0} +
    + {m.name} + {#if criteria > 0}
    -
    +
    {/if} - - {outstanding ? 'Outstanding' : p.state} + + {p ? (outstanding ? 'Outstanding' : p.state) : 'Pending'}
    {/if} @@ -284,24 +342,31 @@ {/each}
    {#if !done} - + {/if}
    {/each} - {#if activeConfigs.filter((c) => c.target === 'individual').length === 0} -

    Drag a template here

    + {#if allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)).length === 0} +

    No bonuses

    {/if}

    Competition

    - {#each activeConfigs.filter((c) => c.target === 'competitive') as cfg} + {#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg} {@const done = isCompleted(cfg)} {@const outstanding = isOutstanding(cfg)} {@const prog = progressByConfigId.get(cfg.id)} -
    +
    {cfg.name}
    @@ -320,7 +385,10 @@
    @@ -334,24 +402,31 @@ {/if}
    {#if !done} - + {/if}
    {/each} - {#if activeConfigs.filter((c) => c.target === 'competitive').length === 0} -

    Drag a template here

    + {#if allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)).length === 0} +

    No bonuses

    {/if}

    Collaboration

    - {#each activeConfigs.filter((c) => c.target === 'collaborative') as cfg} + {#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg} {@const done = isCompleted(cfg)} {@const outstanding = isOutstanding(cfg)} {@const prog = progressByConfigId.get(cfg.id)} -
    +
    {cfg.name}
    @@ -379,42 +454,48 @@ {/if} {team.current}/{team.criteriaValue} - {cfg.type === 'threshold' ? 'pts' : 'chores'} + {cfg.type === 'threshold' ? 'pts' : 'chores'} - {outstanding ? 'Outstanding' : team.state} + {team.state}
    {/if} {/if}
    {#if !done} - + {/if}
    {/each} - {#if activeConfigs.filter((c) => c.target === 'collaborative').length === 0} -

    Drag a template here

    + {#if allBonusConfigs.filter((c) => c.target === 'collaborative' && !doneConfigs.includes(c)).length === 0} +

    No bonuses

    {/if}
    - {#if outstandingConfigs.length > 0}

    Outstanding — Issue to complete

    {#each outstandingConfigs as cfg} - {@const rewards = allRewards.filter((r) => r.bonusConfigId === cfg.id && r.status === 'unclaimed')} + {@const rewards = allRewards.filter( + (r) => r.bonusConfigId === cfg.id && r.status === 'unclaimed' + )}
    {cfg.name}
    -
    {formatReward(cfg)}
    +
    + {formatReward(cfg.rewardType, cfg.rewardValue)} +
    {#each rewards as r}
    {memberName(r.memberId)} - +
    {/each}
    @@ -423,7 +504,6 @@
    {/if} - {#if doneConfigs.length > 0}

    Completed

    @@ -433,77 +513,90 @@
    {cfg.name}
    -
    {formatReward(cfg)}
    +
    + {formatReward(cfg.rewardType, cfg.rewardValue)} +
    {/each}
    {/if} - {#if showCreateModal}
    (showCreateModal = false)} role="presentation">