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 @@

Create your family

- 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.

@@ -131,7 +132,10 @@

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. +

diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index d2356e7..edee81a 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -134,11 +134,16 @@ // their OTP on /{fam}/join/{username} then landing on /{fam}/{username}. // The handle comes from the session (not page.params) so jump-back-in lands // on the user's own dashboard even from the bare /{fam} page. + // The slug MUST come from the fam record — page.params.fam may be the PB + // id (login ID-fallback / stale link) when page.data.fam failed to load, + // and recording that poisons the shortcut (famSlug === famName === id). $effect(() => { if (!page.data.session) return; + const slug = page.data.fam?.slug as string; + if (!slug) return; recordShortcut({ - famSlug: (page.data.fam?.slug as string) || page.params.fam || '', - famName: (page.data.fam?.name as string) || page.params.fam || '', + famSlug: slug, + famName: (page.data.fam?.name as string) || slug, userName: page.data.session.username || page.params.username || '' }); }); diff --git a/frontend/src/routes/[fam]/+page.svelte b/frontend/src/routes/[fam]/+page.svelte index 6912039..741a3da 100644 --- a/frontend/src/routes/[fam]/+page.svelte +++ b/frontend/src/routes/[fam]/+page.svelte @@ -160,19 +160,24 @@ .filter((a: any) => a.memberId === memberId && a.frequency === 'daily') .reduce((sum: number, a: any) => sum + (a.type === 'points' ? Number(a.value) || 0 : 0), 0); } - // Days elapsed in the current week (min 1), for a running daily average. - function daysElapsed(): number { - const days = summary?.daysInWeek || []; + // All-time average daily points for a member, from the server-computed + // weekly series (all history, not just this week). Days active runs from + // their earliest recorded week through today (min 1). + function avgDailyPointsFor(memberId: string): number { + const line = trend.find((m: any) => m.memberId === memberId); + const weeks = line?.weeks || []; + if (weeks.length === 0) return 0; + const total = weeks.reduce((sum: number, w: any) => sum + (Number(w.points) || 0), 0); + const earliest = weeks.map((w: any) => w.weekStart).sort()[0]; const today = todayInTz(famTz); - return Math.max(1, days.filter((d: string) => d <= today).length); - } - // Average daily points from chore completions this week. - function avgDailyPointsFor(s: any): number { - const total = Object.values(s.dayPoints || {}).reduce( - (sum: number, v: any) => sum + (Number(v) || 0), - 0 + const days = Math.max( + 1, + Math.round( + (new Date(today + 'T00:00:00').getTime() - new Date(earliest + 'T00:00:00').getTime()) / + 86400000 + ) + 1 ); - return total / daysElapsed(); + return total / days; } // Today's completion count — tolerant of UTC vs fam-local date keys. function todayDone(s: any): number { @@ -257,7 +262,7 @@ if (scoreCanvas) { donutCharts[`${s.memberId}:score`] = makeDonut( scoreCanvas, - avgDailyPointsFor(s), + avgDailyPointsFor(s.memberId), dailyPointsPotential(s.memberId), s.memberColor ); @@ -379,7 +384,7 @@ {@const tdDone = todayDone(s)} {@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0} {@const scPot = dailyPointsPotential(s.memberId)} - {@const scVal = avgDailyPointsFor(s)} + {@const scVal = avgDailyPointsFor(s.memberId)} {@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0} {@const goals = goalsByMember.get(s.memberId)}
diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 9695313..7ecd69c 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -174,13 +174,14 @@ }); } - // Goals completed feed for the admin overview — every materialized bonus - // reward (prize / points / cash), newest first. Undoing the underlying - // chore/points deletes the unclaimed reward server-side, so this list - // shrinks again in realtime via famStore. - let parentGoalsCompleted = $derived.by(() => - parentRewards - .filter((r: any) => r.bonusConfigId) + // Goals completed feed for the admin overview — this week's materialized + // bonus rewards (prize / points / cash), newest first. Undoing the + // underlying chore/points deletes the unclaimed reward server-side, so + // this list shrinks again in realtime via famStore. + let parentGoalsCompleted = $derived.by(() => { + const ws = (summary?.weekStart as string) || today; + return parentRewards + .filter((r: any) => r.bonusConfigId && (r.date || '').slice(0, 10) >= ws) .map((r: any) => { const cfg = parentBonusConfigs.find((c: any) => c.id === r.bonusConfigId); const m = parentMembers.find((x: any) => x.id === r.memberId); @@ -199,8 +200,8 @@ }; }) .sort((a: any, b: any) => (b.date || '').localeCompare(a.date || '')) - .slice(0, 10) - ); + .slice(0, 10); + }); // ─── Child View (kanban) ─── let memberId = $state(data.memberId || ''); @@ -242,7 +243,7 @@ let claimError = $state(''); let todayChild = $derived(todayInTz(famTz)); - let togglingIds = $state(''); + let togglingIds = $state>(new Set()); // ─── Payday countdown (child view) ─── let nowMs = $state(Date.now()); @@ -622,6 +623,12 @@ const rDate = (existingReward?.date || '').slice(0, 10); const rewardInWeek = !!rDate && rDate >= weekStart && rDate <= weekEnd; const rewardOutstanding = !!existingReward && existingReward.status !== 'claimed'; + // A claimed reward from a previous week with no fresh progress + // means the goal is done — drop the card entirely instead of + // showing a stale 0% chase. + const staleClaimed = + !!existingReward && !rewardInWeek && !rewardOutstanding && current === 0; + if (staleClaimed) return null; const achieved = existingReward ? rewardInWeek || rewardOutstanding : isPercent @@ -652,7 +659,8 @@ periodStart: pStart, periodEnd: pEnd }; - }); + }) + .filter((g) => g !== null); }); // ─── Goal celebrations (bonus rewards achieved) ─── @@ -823,8 +831,8 @@ async function toggle(chore: AssignedChore) { if (accessDisabled) return; if (chore.isTodo && isTodoExpired(chore)) return; - if (togglingIds) return; - togglingIds = chore.id; + if (togglingIds.has(chore.id)) return; + togglingIds = new Set(togglingIds).add(chore.id); const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, todayChild); if (wasCompleted) { @@ -875,7 +883,9 @@ } console.error('Toggle failed:', e); } finally { - togglingIds = ''; + const next = new Set(togglingIds); + next.delete(chore.id); + togglingIds = next; } } @@ -1492,7 +1502,7 @@

All done!

{:else} {#each dailyPending as chore} -
{/each} @@ -565,7 +565,7 @@ {/if}
- Click to edit + Mark as complete
{/each} @@ -644,7 +644,7 @@ {/if} {/if}
- Click to edit + Mark as complete
{/each}