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