Payday!
-If a kid meets their criteria - they can claim their pocket money!
++ If a kid meets their criteria - they can claim their pocket money by annoying you like a + noisey flying unicorn. +
From 158a60f30cbe5fb0ab28c3d35934fe63854c8cf9 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Mon, 14 Sep 2026 12:09:44 +0100 Subject: [PATCH] add toggle fix and possible log out issue --- frontend/src/lib/server/services/bonuses.ts | 19 +++++++- .../src/lib/server/services/completions.ts | 34 ++++++++++--- frontend/src/lib/shortcut.ts | 12 ++++- frontend/src/routes/+page.svelte | 10 ++-- frontend/src/routes/[fam]/+layout.svelte | 9 +++- frontend/src/routes/[fam]/+page.svelte | 31 +++++++----- .../src/routes/[fam]/[username]/+page.svelte | 48 +++++++++++-------- .../[fam]/[username]/rewards/+page.svelte | 6 +-- 8 files changed, 120 insertions(+), 49 deletions(-) diff --git a/frontend/src/lib/server/services/bonuses.ts b/frontend/src/lib/server/services/bonuses.ts index fecfbe1..876f964 100644 --- a/frontend/src/lib/server/services/bonuses.ts +++ b/frontend/src/lib/server/services/bonuses.ts @@ -10,6 +10,23 @@ import { } from '@shared/timezone'; import { computeBonusProgress } from '@shared/bonus-progress'; import { famMeta } from './fam'; +import { pbAdmin } from '$lib/server/pocketbase'; + +// `fams` reads via superuser: the acting token may predate RULE_OWN_FAM +// (migrate only bootstraps fresh stores), which otherwise logs a 404 and +// aborts evaluation. Fam-scoped by record id. +async function famMetaSU(famId: string) { + try { + const fam: any = await pbAdmin.getOne('fams', famId); + return { + payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1, + paydayTime: fam.paydayTime || '18:00', + tz: resolveTz(fam.timezone || 'auto') + }; + } catch { + return { payday: 1, paydayTime: '18:00', tz: resolveTz('auto') }; + } +} function resolveServerTz(tz?: string): string { return resolveTz(tz || 'auto'); @@ -64,7 +81,7 @@ export async function evaluateFam(pb: any, famId: string) { try { allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }); } catch {} - const { payday: paydayEval, tz: tzEval } = await famMeta(pb, famId); + const { payday: paydayEval, tz: tzEval } = await famMetaSU(famId); // Chores "due" this week per member (daily = 7, otherwise 1). Used by the // `percent` threshold type to compute % of chores completed. diff --git a/frontend/src/lib/server/services/completions.ts b/frontend/src/lib/server/services/completions.ts index 0e981d3..c7e72b1 100644 --- a/frontend/src/lib/server/services/completions.ts +++ b/frontend/src/lib/server/services/completions.ts @@ -1,7 +1,27 @@ import { periodWindow } from '@shared/timezone'; -import { famMeta } from './fam'; +import { pbAdmin } from '$lib/server/pocketbase'; import { evaluateFam } from './bonuses'; +// Reads/writes the acting user may not be permitted by PB rules: +// - `fams` view (prod may predate RULE_OWN_FAM; migrate only bootstraps fresh) +// - `assigned_chores` update (parent-only rule, but children claim shared chores) +// Both stay fam-scoped: the fam record id and the chore's own famId are checked. +async function famMetaSU(famId: string) { + const fam: any = await pbAdmin.getOne('fams', famId); + const { resolveTz } = await import('@shared/timezone'); + return { + payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1, + paydayTime: fam.paydayTime || '18:00', + tz: resolveTz(fam.timezone || 'auto') + }; +} + +async function claimChore(assignedChoreId: string, famId: string, memberId: string | '') { + const chore: any = await pbAdmin.getOne('assigned_chores', assignedChoreId); + if (!chore || chore.famId !== famId) throw new Error('Chore not found'); + await pbAdmin.update('assigned_chores', assignedChoreId, { memberId }); +} + export async function myChores(pb: any, famId: string, memberId: string) { const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([ pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }), @@ -12,7 +32,7 @@ export async function myChores(pb: any, famId: string, memberId: string) { pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` }) ]); - const { payday, paydayTime, tz } = await famMeta(pb, famId); + const { payday, paydayTime, tz } = await famMetaSU(famId); return { templates, assigned, @@ -39,13 +59,13 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a let filter: string; if (isShared && !isTodo) { // Shared non-todo: any completion in the period = already claimed - const { payday, tz } = await famMeta(pb, famId); + const { payday, tz } = await famMetaSU(famId); const { from, to } = periodWindow(chore?.frequency, payday, tz); filter = `assignedChoreId = '${assignedChoreId}' && date >= '${from}' && date < '${to}'`; } else if (isTodo) { filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`; } else { - const { payday, tz } = await famMeta(pb, famId); + const { payday, tz } = await famMetaSU(famId); const { from, to } = periodWindow(chore?.frequency, payday, tz); filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`; } @@ -60,7 +80,7 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a } // If this was a shared chore claimed by this member, un-share it if (isShared && existing[0].memberId === memberId) { - await pb.collection('assigned_chores').update(assignedChoreId, { memberId: '' }); + await claimChore(assignedChoreId, famId, ''); } evaluateFam(pb, famId).catch(() => {}); return { completed: false }; @@ -89,7 +109,7 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a }); // Claim the shared chore by setting memberId if (isShared) { - await pb.collection('assigned_chores').update(assignedChoreId, { memberId }); + await claimChore(assignedChoreId, famId, memberId); } evaluateFam(pb, famId).catch(() => {}); return { completed: true, record }; @@ -107,7 +127,7 @@ export async function revoke(pb: any, famId: string, completionId: string) { .getFullList({ filter: `famId = '${famId}' && id = '${completion.assignedChoreId}'` }); const chore = choreList?.[0]; if (chore?.shared === true) { - await pb.collection('assigned_chores').update(chore.id, { memberId: '' }); + await claimChore(chore.id, famId, ''); } evaluateFam(pb, famId).catch(() => {}); return { revoked: true }; diff --git a/frontend/src/lib/shortcut.ts b/frontend/src/lib/shortcut.ts index 52b4921..a8eb0b7 100644 --- a/frontend/src/lib/shortcut.ts +++ b/frontend/src/lib/shortcut.ts @@ -15,7 +15,17 @@ export function readShortcut(): Shortcut | null { if (typeof localStorage === 'undefined') return null; try { const raw = localStorage.getItem(KEY); - return raw ? (JSON.parse(raw) as Shortcut) : null; + if (!raw) return null; + const s = JSON.parse(raw) as Shortcut; + // Self-heal the old poisoned entry: a write that fell back to + // page.params.fam while page.data.fam was null stored the PB id as + // both slug and name ({famSlug: id, famName: id}). A real slug is + // never identical to the display name AND a 15-char PB id. + if (s && s.famSlug === s.famName && /^[a-z0-9]{15}$/.test(s.famSlug || '')) { + localStorage.removeItem(KEY); + return null; + } + return s; } catch { return null; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index a057e25..eda6980 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -106,8 +106,9 @@
- Set up chores and how much each one is worth. Your kids join in seconds with an invite - code. + Setting up chores isn't a bore! Your kids join in seconds with an emailess invite code.
If a kid meets their criteria - they can claim their pocket money!
++ If a kid meets their criteria - they can claim their pocket money by annoying you like a + noisey flying unicorn. +
All done!
{:else} {#each dailyPending as chore} -