diff --git a/frontend/src/lib/server/member-otp.ts b/frontend/src/lib/server/member-otp.ts index 6e6fc83..e6286da 100644 --- a/frontend/src/lib/server/member-otp.ts +++ b/frontend/src/lib/server/member-otp.ts @@ -55,6 +55,35 @@ export async function createChild(opts: { } if (!user) throw new Error('Failed to create child'); + + // Auto-create the child's pocket-money droplet. The amount (rewardValue) + // starts empty — the parent sets it on the Bonuses page. This keeps pocket + // money a first-class, always-present feature without a separate field. + const pm = await pb + .collection('bonus_configs') + .getFirstListItem(`famId='${opts.famId}' && memberId='${user.id}' && isPocketMoney=true`) + .catch(() => null); + if (!pm) { + await pb + .collection('bonus_configs') + .create({ + famId: opts.famId, + name: 'Pocket Money', + target: 'individual', + type: 'threshold', + thresholdType: 'percent', + occurrence: 'recurring', + rewardType: 'cash', + rewardValue: '', + criteriaValue: 50, + memberId: user.id, + period: 'weekly', + status: 'active', + isPocketMoney: true + }) + .catch(() => null); + } + return user; } diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index b5c9511..56145ea 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -327,7 +327,76 @@ export async function migrate(): Promise { // Runs even when the schema already exists (unlike ensureSchema's early // return) so new platform collections/fields/seed land on existing installs. await ensureFamFields(); + await ensureBonusFields(); await ensureAccessCodes(); await ensurePlatform(); console.log("[migrate] Done"); +} + +// Add the pocket-money fields to bonus collections on existing installs, and +// backfill a default pocket-money droplet (£10 / 50% chores) for any child that +// doesn't yet have one. Idempotent. +async function ensureBonusFields(): Promise { + for (const name of ["bonus_templates", "bonus_configs"]) { + const col = await getCollection(name); + if (!col) continue; + const has = (n: string) => col.fields.some((f: any) => f.name === n); + let changed = false; + const fields = [...col.fields]; + if (!has("thresholdType")) { + fields.push({ + name: "thresholdType", type: "select", required: false, + values: ["points", "percent"], maxSelect: 1, + }); + changed = true; + } + if (!has("isPocketMoney")) { + fields.push({ name: "isPocketMoney", type: "bool", required: false }); + changed = true; + } + // rewardValue must be optional so an unset pocket-money droplet can exist. + const rv = fields.find((f: any) => f.name === "rewardValue"); + if (rv && rv.required) { + rv.required = false; + changed = true; + } + if (changed) await updateCollection(col.id, { ...col, fields }); + } + await backfillPocketMoney(); +} + +async function backfillPocketMoney(): Promise { + const t = await auth(); + const childrenRes = await fetch( + `${PB_ENDPOINT}/api/collections/users/records?filter=role='child'&perPage=500`, + { headers: { Authorization: `Bearer ${t}` } } + ); + const children: any[] = (await childrenRes.json())?.items || []; + for (const c of children) { + const existingRes = await fetch( + `${PB_ENDPOINT}/api/collections/bonus_configs/records?filter=famId='${c.famId}' && memberId='${c.id}' && isPocketMoney=true`, + { headers: { Authorization: `Bearer ${t}` } } + ); + if ((await existingRes.json())?.items?.length) continue; + await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify({ + famId: c.famId, + name: "Pocket Money", + target: "individual", + type: "threshold", + thresholdType: "percent", + occurrence: "recurring", + rewardType: "cash", + rewardValue: 10, + criteriaValue: 50, + memberId: c.id, + period: "weekly", + status: "active", + isPocketMoney: true, + }), + }).catch(() => {}); + } + console.log("[migrate] Pocket-money droplets ensured."); } \ No newline at end of file diff --git a/frontend/src/lib/server/services/bonuses.ts b/frontend/src/lib/server/services/bonuses.ts index 993568c..6abcb3d 100644 --- a/frontend/src/lib/server/services/bonuses.ts +++ b/frontend/src/lib/server/services/bonuses.ts @@ -44,7 +44,17 @@ export async function evaluateFam(pb: any, famId: string) { } catch {} const { payday: paydayEval, tz: tzEval } = await famMeta(pb, famId); + // Chores "due" this week per member (daily = 7, otherwise 1). Used by the + // `percent` threshold type to compute % of chores completed. + const dueByMember: Record = {}; + for (const a of allAssigned) { + if (a.memberId) dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1); + } + const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1; + for (const cfg of configs) { + // Pocket money pauses until the parent sets an amount. + if (cfg.isPocketMoney && !cfg.rewardValue) continue; const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : ''; const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : ''; const periodCompletions = cfg.period @@ -82,10 +92,15 @@ export async function evaluateFam(pb: any, famId: string) { const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); let current = 0; if (cfg.type === 'threshold') { - current = memberCompletions.reduce((sum: number, c: any) => { - const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); - return sum + (chore?.type === 'points' ? Number(chore.value) : 0); - }, 0); + if (cfg.thresholdType === 'percent') { + const due = dueByMember[m.id] || 0; + current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0; + } else { + current = memberCompletions.reduce((sum: number, c: any) => { + const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + } } else if (cfg.type === 'count') { current = memberCompletions.length; } @@ -114,10 +129,14 @@ export async function evaluateFam(pb: any, famId: string) { const teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId)); let total = 0; if (cfg.type === 'threshold') { - total = teamCompletions.reduce((sum: number, c: any) => { - const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); - return sum + (chore?.type === 'points' ? Number(chore.value) : 0); - }, 0); + if (cfg.thresholdType === 'percent') { + total = Math.round((teamCompletions.length / totalDue) * 100); + } else { + total = teamCompletions.reduce((sum: number, c: any) => { + const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + } } else if (cfg.type === 'count') { total = teamCompletions.length; } @@ -145,10 +164,15 @@ export async function evaluateFam(pb: any, famId: string) { const memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); let current = 0; if (cfg.type === 'threshold') { - current = memberCompletions.reduce((sum: number, c: any) => { - const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); - return sum + (chore?.type === 'points' ? Number(chore.value) : 0); - }, 0); + if (cfg.thresholdType === 'percent') { + const due = dueByMember[m.id] || 0; + current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0; + } else { + current = memberCompletions.reduce((sum: number, c: any) => { + const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + } } else if (cfg.type === 'count') { current = memberCompletions.length; } @@ -203,6 +227,11 @@ export async function progress(pb: any, famId: string) { const assignedList = assigned; const completionsList = completions; + const dueByMember: Record = {}; + for (const a of assignedList) { + if (a.memberId) dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1); + } + const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1; let allRewards: any[] = []; try { allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }); @@ -226,10 +255,14 @@ export async function progress(pb: any, famId: string) { ); let teamCurrent = 0; if (cfg.type === 'threshold') { - teamCurrent = teamCompletions.reduce((sum: number, c: any) => { - const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); - return sum + (chore?.type === 'points' ? Number(chore.value) : 0); - }, 0); + if (cfg.thresholdType === 'percent') { + teamCurrent = Math.round((teamCompletions.length / totalDue) * 100); + } else { + teamCurrent = teamCompletions.reduce((sum: number, c: any) => { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + } } else if (cfg.type === 'count') { teamCurrent = teamCompletions.length; } @@ -257,10 +290,15 @@ export async function progress(pb: any, famId: string) { let current = 0; if (cfg.type === 'threshold') { - current = memberCompletions.reduce((sum: number, c: any) => { - const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); - return sum + (chore?.type === 'points' ? Number(chore.value) : 0); - }, 0); + if (cfg.thresholdType === 'percent') { + const due = dueByMember[m.id] || 0; + current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0; + } else { + current = memberCompletions.reduce((sum: number, c: any) => { + const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + } } else if (cfg.type === 'count') { current = memberCompletions.length; } else if (cfg.type === 'manual') { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 15b3fa8..de14161 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -2,6 +2,7 @@ export type Frequency = 'daily' | 'weekly'; export type RewardType = 'points' | 'money' | 'emoji'; export type BonusTarget = 'individual' | 'competitive' | 'collaborative'; export type BonusType = 'threshold' | 'count' | 'manual'; +export type BonusThresholdType = 'points' | 'percent'; export type BonusOccurrence = 'recurring' | 'once'; export type BonusRewardType = 'points' | 'cash' | 'prize'; export type BonusPeriod = 'weekly' | 'monthly' | 'daily'; @@ -15,11 +16,13 @@ export interface BonusTemplate { description?: string; target: BonusTarget; type: BonusType; + thresholdType?: BonusThresholdType; occurrence: BonusOccurrence; rewardType: BonusRewardType; rewardValue: string; criteriaValue?: number; period?: BonusPeriod; + isPocketMoney?: boolean; created: string; updated: string; } @@ -117,12 +120,14 @@ export interface BonusConfig { target: BonusTarget; memberId?: string; type: BonusType; + thresholdType?: BonusThresholdType; occurrence: BonusOccurrence; rewardType: BonusRewardType; rewardValue: string; criteriaValue?: number; period?: BonusPeriod; status: BonusStatus; + isPocketMoney?: boolean; created: string; updated: string; } diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 97d4e63..4c9a13f 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -633,16 +633,33 @@ {@const done = todays.length} {@const pct = total > 0 ? Math.round((done / total) * 100) : 0} {@const s = memberInSummary(m.id)} + {@const pm = parentBonusConfigs.find( + (c: any) => c.isPocketMoney && c.memberId === m.id + )}
{m.name} - Kanban +
Points: {s?.pointsEarned ?? 0} Money: £{(s?.moneyEarned ?? 0).toFixed(2)}
+
+ {#if pm && pm.rewardValue} + Pocket money: £{Number(pm.rewardValue).toFixed(2)} · {pm.criteriaValue || 0}% of + chores + {:else} + Pocket money: amount not set + {/if} +
Today:
@@ -1362,6 +1379,15 @@ const res = await fetch('/api/members', { color: #6366f1; text-decoration: none; } + .pocket-row { + font-size: 0.78rem; + color: #6b7280; + margin-top: 0.3rem; + } + .pocket-row .unset { + color: #b45309; + font-weight: 600; + } .stats { display: flex; gap: 0.75rem; diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts b/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts index 334ef7c..0c8cd34 100644 --- a/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts @@ -25,6 +25,7 @@ export const actions = { description: fd.get('description') || '', target: fd.get('target'), type: fd.get('type'), + thresholdType: fd.get('thresholdType') || 'points', occurrence: fd.get('occurrence'), rewardType: fd.get('rewardType'), rewardValue: fd.get('rewardValue') @@ -55,6 +56,8 @@ export const actions = { if (target) data.target = target; const type = fd.get('type'); if (type) data.type = type; + const thresholdType = fd.get('thresholdType'); + if (thresholdType) data.thresholdType = thresholdType; const occurrence = fd.get('occurrence'); if (occurrence) data.occurrence = occurrence; const rewardType = fd.get('rewardType'); @@ -101,6 +104,7 @@ export const actions = { name: fd.get('name'), target: fd.get('target'), type: fd.get('type'), + thresholdType: fd.get('thresholdType') || 'points', occurrence: fd.get('occurrence'), rewardType: fd.get('rewardType'), rewardValue: fd.get('rewardValue'), @@ -136,6 +140,8 @@ export const actions = { if (target) data.target = target; const type = fd.get('type'); if (type) data.type = type; + const thresholdType = fd.get('thresholdType'); + if (thresholdType) data.thresholdType = thresholdType; const occurrence = fd.get('occurrence'); if (occurrence) data.occurrence = occurrence; const rewardType = fd.get('rewardType'); @@ -185,6 +191,7 @@ export const actions = { description: fd.get('description') || '', target: fd.get('target'), type: fd.get('type'), + thresholdType: fd.get('thresholdType') || 'points', occurrence: fd.get('occurrence'), rewardType: fd.get('rewardType'), rewardValue: fd.get('rewardValue'), diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte index cf15209..5ca9b93 100644 --- a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte @@ -27,6 +27,7 @@ target: 'individual' as string, memberId: '' as string, type: 'threshold' as string, + thresholdType: 'points' as string, occurrence: 'recurring' as string, rewardType: 'points' as string, rewardValue: '100', @@ -41,6 +42,7 @@ target: 'individual' as string, memberId: '' as string, type: 'threshold' as string, + thresholdType: 'points' as string, occurrence: 'recurring' as string, rewardType: 'points' as string, rewardValue: '100', @@ -69,6 +71,8 @@ let showPeriod = $derived(editVals.occurrence !== 'once'); let showCriteria = $derived(editVals.type !== 'manual'); + let createIsPercent = $derived(createVals.type === 'threshold' && createVals.thresholdType === 'percent'); + let editIsPercent = $derived(editVals.type === 'threshold' && editVals.thresholdType === 'percent'); let templateConfigs = $derived(templates as BonusTemplate[]); let allBonusConfigs = $derived( @@ -88,6 +92,15 @@ const username = $derived(page.params.username); + // Pocket money: seeded for every child, but the amount (rewardValue) starts + // empty until the parent sets it on this page. The admin dashboard links + // here with ?s=pocketmoney to surface this mandatory teaching notice. + let pocketNotice = $derived(page.url.searchParams.get('s') === 'pocketmoney'); + let pocketConfigs = $derived( + (configs as BonusConfig[]).filter((c: BonusConfig) => c.isPocketMoney) + ); + let hasUnsetPocket = $derived(pocketConfigs.some((c: BonusConfig) => !c.rewardValue)); + function memberName(id: string): string { const m = members.find((m) => m.id === id); return m?.name || 'Unknown'; @@ -131,6 +144,7 @@ target: targetType, memberId: '', type: tpl.type, + thresholdType: tpl.thresholdType || 'points', occurrence: tpl.occurrence, rewardType: tpl.rewardType, rewardValue: tpl.rewardValue, @@ -186,6 +200,7 @@ target: cfg.target, memberId: cfg.memberId || '', type: cfg.type, + thresholdType: cfg.thresholdType || 'points', occurrence: cfg.occurrence, rewardType: cfg.rewardType, rewardValue: cfg.rewardValue, @@ -205,6 +220,26 @@ + +{#if pocketNotice} + + +
+ Pocket Money + {#if hasUnsetPocket} +

+ Every child has a Pocket Money droplet. Set how it's awarded — pick the + amount (£) and the % of weekly chores they must complete to earn it — then save. Until + you set the amount, pocket money is paused for that child. +

+ {:else} +

All Pocket Money droplets are set. Children will earn pocket money as they complete chores.

+ {/if} +
+
+
+{/if} +
@@ -214,10 +249,6 @@
handleDragStart(e, cfg)}>
{cfg.name} -
- - -
{cfg.target} @@ -226,6 +257,9 @@ {#if cfg.period} {cfg.period} {/if} + {#if cfg.isPocketMoney} + Pocket Money + {/if}
{formatReward(cfg.rewardType, cfg.rewardValue)} @@ -243,6 +277,7 @@ target: cfg.target, memberId: '', type: cfg.type, + thresholdType: cfg.thresholdType || 'points', occurrence: cfg.occurrence, rewardType: cfg.rewardType, rewardValue: cfg.rewardValue, @@ -277,6 +312,9 @@ {cfg.target} {cfg.type} {cfg.occurrence} + {#if cfg.isPocketMoney} + Pocket Money + {/if}
{#each members as m} @@ -292,7 +330,12 @@ {@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} + {@const isPct = cfg.thresholdType === 'percent'} + {@const pct = isPct + ? Math.min(100, current) + : criteria > 0 + ? Math.min(100, (current / criteria) * 100) + : 0}
{m.name} {#if criteria > 0} @@ -345,7 +388,9 @@ class="bar-fill" style="width:{Math.min( 100, - (p.current / Math.max(1, p.criteriaValue)) * 100 + cfg.thresholdType === 'percent' + ? p.current + : (p.current / Math.max(1, p.criteriaValue)) * 100 )}%" class:met={p.achieved} >
@@ -391,14 +436,16 @@ {team.memberName} {#if team.criteriaValue > 0}
-
+ class:met={team.achieved} + >
{/if} @@ -449,20 +496,43 @@ - {#if showCriteriaForCreate} + {#if createVals.type === 'threshold'} {/if} + {#if showCriteriaForCreate} + {#if createIsPercent} + + {:else} + + {/if} + {/if} - {#if showCriteria} + {#if editVals.type === 'threshold'} {/if} + {#if showCriteria} + {#if editIsPercent} + + {:else} + + {/if} + {/if}
{/each} @@ -542,7 +527,7 @@
{/if} @@ -780,7 +789,17 @@ display: grid; gap: 0.5rem; margin-bottom: 0.75rem; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + @media (max-width: 1100px) { + .template-list { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + @media (max-width: 720px) { + .template-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } /* ── Kanban (3 columns, scrolls via Card scrollX) ── */ @@ -1057,6 +1076,10 @@ background: #6366f1; color: white; } + .modal-actions button.delete-btn { + background: #dc2626; + color: white; + } /* ── Accordion wrappers ── */ .accordion-wrapper { diff --git a/frontend/src/routes/signup/+page.server.ts b/frontend/src/routes/signup/+page.server.ts index 2a01dd9..1f0fc85 100644 --- a/frontend/src/routes/signup/+page.server.ts +++ b/frontend/src/routes/signup/+page.server.ts @@ -54,6 +54,24 @@ export const actions = { role: 'parent' }); await pbAdmin.create('settings', { famId: fam.id }); + // Seed the family-level POCKET MONEY bonus template (the teaching + // droplet). Per-child instances are created when each child joins. + await pbAdmin + .create('bonus_templates', { + famId: fam.id, + name: 'POCKET MONEY', + description: 'Weekly pocket money for children', + target: 'individual', + type: 'threshold', + thresholdType: 'percent', + occurrence: 'recurring', + rewardType: 'cash', + rewardValue: '', + criteriaValue: 50, + period: 'weekly', + isPocketMoney: true + }) + .catch(() => null); } catch (e) { throw new SignupError( `Could not create account — ${e instanceof Error ? e.message : 'please try again'}` diff --git a/shared/pb/schema.ts b/shared/pb/schema.ts index fa83320..665205c 100644 --- a/shared/pb/schema.ts +++ b/shared/pb/schema.ts @@ -163,11 +163,13 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ text("description"), select("target", ["individual", "competitive", "collaborative"], true), select("type", ["threshold", "count", "manual"], true), + select("thresholdType", ["points", "percent"], false), select("occurrence", ["recurring", "once"], true), select("rewardType", ["points", "cash", "prize"], true), - text("rewardValue", true), + text("rewardValue", false), number("criteriaValue"), select("period", ["schedule", "daily", "weekly", "monthly"]), + bool("isPocketMoney"), ], { createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED, @@ -208,13 +210,15 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ text("description"), select("target", ["individual", "competitive", "collaborative"], true), select("type", ["threshold", "count", "manual"], true), + select("thresholdType", ["points", "percent"], false), select("occurrence", ["recurring", "once"], true), select("rewardType", ["points", "cash", "prize"], true), - text("rewardValue", true), + text("rewardValue", false), number("criteriaValue"), rel("memberId", ids.users), select("period", ["schedule", "daily", "weekly", "monthly"]), select("status", ["active", "completed"], true), + bool("isPocketMoney"), ], { createRule: RULE_PARENT_WRITE, updateRule: RULE_PARENT_SCOPED,