Compare commits

..

26 Commits

Author SHA1 Message Date
JCEEE 7de3c8ca62 propose new chore update fix 2026-09-30 08:12:30 +01:00
JCEEE d14a154dcb fix auth again 2026-09-28 21:13:37 +01:00
JCEEE 89ab955efc add debugging 2026-09-27 11:04:37 +01:00
JCEEE edc8568cc4 update schema 2026-09-25 08:24:26 +01:00
JCEEE c070cc8053 1.11.7 2026-09-24 12:50:26 +01:00
JCEEE 8388868bee enable kids todos 2026-09-24 12:50:20 +01:00
JCEEE 2a3f3836c0 1.11.6 2026-09-23 20:49:36 +01:00
JCEEE 6288a3d3f2 fix week in week out evaluateFam 2026-09-23 20:49:32 +01:00
JCEEE adcb400f41 1.11.5 2026-09-23 09:43:30 +01:00
JCEEE a710e22f35 add yesterday catchup 2026-09-23 09:43:25 +01:00
JCEEE 31da825e93 fix login 2026-09-22 20:39:35 +01:00
JCEEE fa1bf1e09d compact member card 2026-09-22 20:35:50 +01:00
JCEEE 66ed9750e9 fix broken chore gone 2026-09-22 20:10:42 +01:00
JCEEE 0fed19461e update stats 2026-09-21 11:09:09 +01:00
JCEEE 67b5eff06b fix payday 2026-09-21 08:54:37 +01:00
JCEEE a8c674f8b3 use custom emoji for payday 2026-09-21 08:35:13 +01:00
JCEEE 8e4eef06f5 add payday anim 2026-09-20 17:58:38 +01:00
JCEEE 1609e11c48 add new save button 2026-09-20 17:52:53 +01:00
JCEEE 18511b6546 payday tests 2026-09-20 17:49:14 +01:00
JCEEE 9e6d835061 1.11.4 2026-09-20 17:42:58 +01:00
JCEEE 0613fc4f46 payday tests 2026-09-20 17:42:51 +01:00
JCEEE 6b6d2df945 1.11.3 2026-09-20 16:40:12 +01:00
JCEEE 6d05721629 payday tests 2026-09-20 16:40:09 +01:00
JCEEE 3a6ba556e6 fix demo fam bug setting 2026-09-16 22:07:51 +01:00
JCEEE 9566506d30 fix auth issues 2026-09-16 21:07:21 +01:00
JCEEE 812f6a4f88 add showboaters 2026-09-15 15:23:44 +01:00
28 changed files with 1265 additions and 276 deletions
+18 -2
View File
@@ -7,6 +7,7 @@ import {
setSessionCookie, setSessionCookie,
clearSessionCookie, clearSessionCookie,
setChildSessionCookie, setChildSessionCookie,
setActiveChild,
clearActiveChild, clearActiveChild,
PLATFORM_SESSION_COOKIE, PLATFORM_SESSION_COOKIE,
clearPlatformSession clearPlatformSession
@@ -29,6 +30,7 @@ function sessionFrom(record: any, freshToken: string) {
pattern: record.pattern || '', pattern: record.pattern || '',
themeSize: record.themeSize || '', themeSize: record.themeSize || '',
themeOpacity: record.themeOpacity || '', themeOpacity: record.themeOpacity || '',
partyEmoji: record.partyEmoji || '',
token: freshToken token: freshToken
} satisfies SessionUser & { token: string }; } satisfies SessionUser & { token: string };
} }
@@ -80,11 +82,22 @@ export const handle: Handle = async ({ event, resolve }) => {
if (freshToken !== childToken) { if (freshToken !== childToken) {
setChildSessionCookie(event.cookies, activeId, freshToken); setChildSessionCookie(event.cookies, activeId, freshToken);
} }
// Sliding expiry: pb_active has the same 5-day maxAge as the
// token cookies but was previously never re-set, so it aged
// out 5 days after join/switch despite daily use. Re-set it
// on every authenticated request to keep it alive.
setActiveChild(event.cookies, activeId);
return resolve(event); return resolve(event);
} }
} catch { console.error(
`[diag] hooks child wrong-role activeId=${activeId} role=${record.role} path=${event.url.pathname}`
);
} catch (e) {
// Expired/revoked/malformed — leave the cookie; the picker switch // Expired/revoked/malformed — leave the cookie; the picker switch
// re-mints it server-side. Fall through to the single session. // re-mints it server-side. Fall through to the single session.
console.error(
`[diag] hooks child authRefresh-fail activeId=${activeId} path=${event.url.pathname} err=${e instanceof Error ? e.message : e}`
);
} }
} }
} }
@@ -102,8 +115,11 @@ export const handle: Handle = async ({ event, resolve }) => {
if (freshToken !== token) { if (freshToken !== token) {
setSessionCookie(event.cookies, freshToken); setSessionCookie(event.cookies, freshToken);
} }
} catch { } catch (e) {
// Expired, malformed, or revoked — drop it and treat as logged out. // Expired, malformed, or revoked — drop it and treat as logged out.
console.error(
`[diag] hooks single authRefresh-fail path=${event.url.pathname} err=${e instanceof Error ? e.message : e}`
);
clearSessionCookie(event.cookies); clearSessionCookie(event.cookies);
} }
} }
+5 -2
View File
@@ -20,8 +20,11 @@ async function memberFetch<T = unknown>(
} }
export const memberApi = { export const memberApi = {
async toggleCompletion(famId: string, assignedChoreId: string, date: string) { async toggleCompletion(famId: string, assignedChoreId: string, date: string, completedAt?: string) {
return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date }); return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date, completedAt });
},
async createTodo(name: string) {
return memberFetch<{ record: unknown }>('POST', '/api/todos', { name });
}, },
async claimReward(famId: string, rewardId: string) { async claimReward(famId: string, rewardId: string) {
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`); return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`);
@@ -0,0 +1,32 @@
<script lang="ts">
import Button from './Button.svelte';
let {
label,
successLabel = 'Saved ✓',
variant = 'primary',
size = 'sm',
...rest
}: {
label: string;
successLabel?: string;
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'purple';
size?: 'sm' | 'md' | 'lg';
[key: string]: unknown;
} = $props();
let saved = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
// Parent forms call this (via bind:this) from their use:enhance callback
// on result.type === 'success'. Green for 4s, then back to normal.
export function flash() {
saved = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => (saved = false), 4000);
}
</script>
<Button type="submit" {size} variant={saved ? 'success' : variant} {...rest}>
{saved ? successLabel : label}
</Button>
+1
View File
@@ -6,6 +6,7 @@ export { default as ViewHeader } from './ViewHeader.svelte';
export { default as Card } from './Card.svelte'; export { default as Card } from './Card.svelte';
export { default as CardGrid } from './CardGrid.svelte'; export { default as CardGrid } from './CardGrid.svelte';
export { default as Button } from './Button.svelte'; export { default as Button } from './Button.svelte';
export { default as SaveButton } from './SaveButton.svelte';
export { default as Accordion } from './Accordion.svelte'; export { default as Accordion } from './Accordion.svelte';
export { default as AccordionItem } from './AccordionItem.svelte'; export { default as AccordionItem } from './AccordionItem.svelte';
export { default as Chat } from './Chat.svelte'; export { default as Chat } from './Chat.svelte';
+34 -8
View File
@@ -479,6 +479,9 @@ async function ensureUserFields(): Promise<void> {
if (!has('passwordResetExpiry')) { if (!has('passwordResetExpiry')) {
needed.push({ name: 'passwordResetExpiry', type: 'text', required: false }); needed.push({ name: 'passwordResetExpiry', type: 'text', required: false });
} }
if (!has('partyEmoji')) {
needed.push({ name: 'partyEmoji', type: 'text', required: false });
}
if (needed.length) { if (needed.length) {
await updateCollection(usersCol.id, { ...usersCol, fields: [...usersCol.fields, ...needed] }); await updateCollection(usersCol.id, { ...usersCol, fields: [...usersCol.fields, ...needed] });
} }
@@ -526,7 +529,7 @@ export async function migrate(): Promise<void> {
await ensureBonusFields(); await ensureBonusFields();
await ensureTemplateFields(); await ensureTemplateFields();
await ensureAssignedChoreFields(); await ensureAssignedChoreFields();
await ensureAccessCodes(); await ensureFamReadRules(); await ensureAccessCodes();
await ensurePlatform(); await ensurePlatform();
// Superuser-only collections also land on EXISTING installs (ensureSchema's // Superuser-only collections also land on EXISTING installs (ensureSchema's
// early return skips them). Like ensureOtp before it, ensurePins no-ops when // early return skips them). Like ensureOtp before it, ensurePins no-ops when
@@ -540,6 +543,23 @@ export async function migrate(): Promise<void> {
console.log('[migrate] Done'); console.log('[migrate] Done');
} }
// Children toggle chores with their own token, so fam-scoped collections need
// fam read rules on EXISTING installs (fresh installs get them via
// SCHEMA_PLAN). Without these, the chore lookup returns empty and the
// completions create fails relation validation ("resource cannot be found").
// Idempotent.
async function ensureFamReadRules(): Promise<void> {
const readRule = 'famId = @request.auth.famId';
for (const name of ['assigned_chores', 'completions', 'rewards']) {
const col = await getCollection(name);
if (!col) continue;
if (col.listRule !== readRule || col.viewRule !== readRule) {
await updateCollection(col.id, { ...col, listRule: readRule, viewRule: readRule });
console.log(`[migrate] Fixed read rules on ${name}.`);
}
}
}
// Add colour / icon / description to `assigned_chores` on existing installs so // Add colour / icon / description to `assigned_chores` on existing installs so
// family-admins can override a template's look per assignment. Idempotent. // family-admins can override a template's look per assignment. Idempotent.
async function ensureAssignedChoreFields(): Promise<void> { async function ensureAssignedChoreFields(): Promise<void> {
@@ -1166,11 +1186,12 @@ async function seedDemoCompletions(): Promise<void> {
const startDate = addDays(todayDate, -20); const startDate = addDays(todayDate, -20);
// Track per-member-per-week totals for weekly_history // Track per-member-per-week totals for weekly_history
type WeekKey = string; // "YYYY-MM-DD" (Monday) type WeekKey = string; // "YYYY-MM-DD" (week start: day after payday)
const weekTotals: Record< const weekTotals: Record<
string, string,
Record<string, { points: number; money: number; count: number }> Record<string, { points: number; money: number; count: number }>
> = {}; > = {};
const demoPayday = fam.payday ?? 1;
for (const member of members) { for (const member of members) {
const profile = COMPLETION_PROFILES[member.name] || COMPLETION_PROFILES['Jalipo']; const profile = COMPLETION_PROFILES[member.name] || COMPLETION_PROFILES['Jalipo'];
@@ -1180,10 +1201,14 @@ async function seedDemoCompletions(): Promise<void> {
const ds = dateStr(day); const ds = dateStr(day);
const dow = day.getUTCDay(); // 0=Sun const dow = day.getUTCDay(); // 0=Sun
// Compute week start (Monday) for weekly_history tracking // Compute week start (payday-anchored: weeks close on payday, so
const weekMonday = new Date(day); // the bucket opens the day after — same rule as weekStart()).
weekMonday.setUTCDate(weekMonday.getUTCDate() - ((weekMonday.getUTCDay() + 6) % 7)); const dowNum = day.getUTCDay(); // 0=Sun
const ws = dateStr(weekMonday); const sincePayday = (((dowNum - demoPayday) % 7) + 7) % 7;
const back = sincePayday === 0 ? 6 : sincePayday - 1;
const wsDay = new Date(day);
wsDay.setUTCDate(wsDay.getUTCDate() - back);
const ws = dateStr(wsDay);
if (!weekTotals[member.id]) weekTotals[member.id] = {}; if (!weekTotals[member.id]) weekTotals[member.id] = {};
if (!weekTotals[member.id][ws]) weekTotals[member.id][ws] = { points: 0, money: 0, count: 0 }; if (!weekTotals[member.id][ws]) weekTotals[member.id][ws] = { points: 0, money: 0, count: 0 };
@@ -1196,8 +1221,9 @@ async function seedDemoCompletions(): Promise<void> {
); );
for (const chore of memberChores) { for (const chore of memberChores) {
// Weekly chores: only complete on Monday (dow=1) // Weekly chores: only complete on the week's first day
if (chore.frequency === 'weekly' && dow !== 1) continue; // (day after payday).
if (chore.frequency === 'weekly' && dow !== (demoPayday + 1) % 7) continue;
const prob = dayProfile[chore.customName] || dayProfile.default; const prob = dayProfile[chore.customName] || dayProfile.default;
const rng = demoRng( const rng = demoRng(
+34 -13
View File
@@ -30,9 +30,16 @@ export function pbUser(event: RequestEvent) {
// Superuser PB client (memoized). Reserved for server-only privileged // Superuser PB client (memoized). Reserved for server-only privileged
// operations that must bypass collection rules: creating child users, minting // operations that must bypass collection rules: creating child users, minting
// OTP-login tokens, and verifying OTPs against the superuser-only otp. // OTP-login tokens, and verifying OTPs against the superuser-only otp.
//
// NOTE: the auth token expires (prod PB showed ~36h). A stale memoized client
// goes out UNAUTHENTICATED, and PocketBase answers unauthenticated getOne()
// calls with 404 "resource wasn't found" — which the toggle path misreads as
// CHORE_GONE for every chore. So always re-auth when the stored token is no
// longer valid instead of reusing a dead client.
let superClient: PocketBase | null = null; let superClient: PocketBase | null = null;
export async function createSuperClient() { export async function createSuperClient() {
if (superClient) return superClient; if (superClient?.authStore.isValid) return superClient;
superClient = null;
const pb = new PocketBase(PB_ENDPOINT); const pb = new PocketBase(PB_ENDPOINT);
pb.autoCancellation(false); pb.autoCancellation(false);
await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD)); await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD));
@@ -40,29 +47,43 @@ export async function createSuperClient() {
return pb; return pb;
} }
// Run a superuser op, retrying once with a freshly-authenticated client when
// the first attempt hits an auth-shaped failure. A locally-valid token can
// still be dead server-side (restart/revoked secret rotation), and PB
// surfaces that as 401/403 — or as 404 when a viewRule then denies access.
async function withSuperRetry<T>(op: (pb: PocketBase) => Promise<T>): Promise<T> {
try {
return await op(await createSuperClient());
} catch (e: any) {
const status = e?.status;
if (status === 401 || status === 403 || status === 404) {
superClient = null;
return await op(await createSuperClient());
}
throw e;
}
}
// Superuser CRUD facade, built on the memoized SDK superuser client. All // Superuser CRUD facade, built on the memoized SDK superuser client. All
// server PB access (authenticated user + superuser) lives in this one module. // server PB access (authenticated user + superuser) lives in this one module.
export const pbAdmin = { export const pbAdmin = {
async getList(collection: string, filter = '') { async getList(collection: string, filter = '') {
const pb = await createSuperClient(); return withSuperRetry((pb) => {
const options: { filter?: string } = {}; const options: { filter?: string } = {};
if (filter) options.filter = filter; if (filter) options.filter = filter;
return pb.collection(collection).getFullList(options); return pb.collection(collection).getFullList(options);
});
}, },
async getOne(collection: string, id: string) { async getOne(collection: string, id: string) {
const pb = await createSuperClient(); return withSuperRetry((pb) => pb.collection(collection).getOne(id));
return pb.collection(collection).getOne(id);
}, },
async create(collection: string, data: Record<string, unknown>) { async create(collection: string, data: Record<string, unknown>) {
const pb = await createSuperClient(); return withSuperRetry((pb) => pb.collection(collection).create(data));
return pb.collection(collection).create(data);
}, },
async update(collection: string, id: string, data: Record<string, unknown>) { async update(collection: string, id: string, data: Record<string, unknown>) {
const pb = await createSuperClient(); return withSuperRetry((pb) => pb.collection(collection).update(id, data));
return pb.collection(collection).update(id, data);
}, },
async remove(collection: string, id: string) { async remove(collection: string, id: string) {
const pb = await createSuperClient(); return withSuperRetry((pb) => pb.collection(collection).delete(id));
return pb.collection(collection).delete(id);
} }
}; };
+117 -119
View File
@@ -106,6 +106,15 @@ export async function evaluateFam(pb: any, famId: string) {
const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win)); const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
// Uniform rule, one reward row per config per member per window:
// achieved + no window reward → create; unachieved → delete the window
// reward unless it is claimed, requested, or payday-gated (those persist
// by design until the parent issues them). Only rewards dated inside the
// CURRENT window count as "already rewarded" — last week's row must never
// block this week's payout.
const windowRewards = existingRewards.filter((r: any) => completionInWindow(r, win));
const removable = (r: any) =>
r.status !== 'claimed' && r.status !== 'requested' && r.claimable !== 'payday';
let createdReward = false; let createdReward = false;
const rewardData = (memberId: string) => { const rewardData = (memberId: string) => {
@@ -128,133 +137,122 @@ export async function evaluateFam(pb: any, famId: string) {
}; };
}; };
if (cfg.target === 'individual') { // Completions counting toward one member (target-chore scoped when set).
const targetMembers = cfg.memberId const completionsFor = (memberId: string) => {
? allMembers.filter((m: any) => m.id === cfg.memberId) const memberTarget = targetChoreFor(cfg, memberId);
: allMembers; let list = periodCompletions.filter((c: any) => c.memberId === memberId);
for (const m of targetMembers) { if (memberTarget) list = list.filter((c: any) => c.assignedChoreId === memberTarget);
const memberTarget = targetChoreFor(cfg, m.id); // Collaborative configs only honour the shared targetChoreId.
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); if (cfg.target === 'collaborative' && cfg.targetChoreId)
if (memberTarget) list = list.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
memberCompletions = memberCompletions.filter( return list;
(c: any) => c.assignedChoreId === memberTarget };
); // Score for one member: percent of due chores, points value, or raw count.
let current = 0; const scoreFor = (memberId: string): number => {
if (cfg.type === 'threshold') { const list = completionsFor(memberId);
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;
}
const achieved = cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
const memberReward = existingRewards.find((r: any) => r.memberId === m.id);
if (memberReward && !achieved) {
if (memberReward.status !== 'claimed') {
try {
await pb.collection('rewards').delete(memberReward.id);
} catch {}
}
continue;
}
if (memberReward) continue;
if (achieved) {
await pb.collection('rewards').create(rewardData(m.id));
createdReward = true;
}
}
} else if (cfg.target === 'collaborative') {
const allMemberIds = allMembers.map((m: any) => m.id);
let teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId));
if (cfg.targetChoreId)
teamCompletions = teamCompletions.filter(
(c: any) => c.assignedChoreId === cfg.targetChoreId
);
let total = 0;
if (cfg.type === 'threshold') { if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') { if (cfg.thresholdType === 'percent') {
total = Math.round((teamCompletions.length / totalDue) * 100); const due = dueByMember[memberId] || 0;
} else { return due > 0 ? Math.round((list.length / due) * 100) : 0;
total = teamCompletions.reduce((sum: number, c: any) => { }
return list.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.type === 'count') return list.length;
return 0;
};
const met = (current: number) => cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
// Reconcile one member's window reward against their achieved state.
// Returns true when a live window reward exists afterwards.
const reconcile = async (memberId: string, achieved: boolean): Promise<boolean> => {
const live = windowRewards.filter((r: any) => r.memberId === memberId);
if (!achieved) {
for (const r of live) {
if (!removable(r)) continue;
try {
await pb.collection('rewards').delete(r.id);
} catch {}
windowRewards.splice(windowRewards.indexOf(r), 1);
}
return false;
}
if (live.length > 0) return true;
await pb.collection('rewards').create(rewardData(memberId));
createdReward = true;
return true;
};
// Drop stale prior-window placeholders (unclaimed, non-gated) so they
// can't pile up unseen; gated/requested/claimed rows persist by design.
const purgeStale = async (memberId: string) => {
const stale = existingRewards.filter(
(r: any) => r.memberId === memberId && !completionInWindow(r, win) && removable(r)
);
for (const r of stale) {
try {
await pb.collection('rewards').delete(r.id);
} catch {}
}
};
if (cfg.target === 'competitive') {
// Winner-takes-all: a single window reward held by the top scorer.
const scored = allMembers.map((m: any) => ({ memberId: m.id, current: scoreFor(m.id) }));
const qualified = scored.filter((s: any) => met(s.current));
const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0);
const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0];
const holder = windowRewards[0];
if (!winner) {
if (holder && removable(holder)) {
try {
await pb.collection('rewards').delete(holder.id);
} catch {}
}
} else if (!holder) {
await purgeStale(winner.memberId);
await pb.collection('rewards').create(rewardData(winner.memberId));
createdReward = true;
} else if (holder.memberId !== winner.memberId) {
if (removable(holder)) {
try {
await pb.collection('rewards').delete(holder.id);
} catch {}
await purgeStale(winner.memberId);
await pb.collection('rewards').create(rewardData(winner.memberId));
createdReward = true;
}
// A locked (claimed/requested/gated) holder keeps the crown until issued.
}
} else {
// Individual: each member against their own score. Collaborative: the
// whole team against the team total, rewarded per member.
let teamTotal = 0;
if (cfg.target === 'collaborative') {
const ids = new Set(allMembers.map((m: any) => m.id));
let team = periodCompletions.filter((c: any) => ids.has(c.memberId));
if (cfg.targetChoreId)
team = team.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
if (cfg.type === 'threshold' && cfg.thresholdType === 'percent') {
teamTotal = Math.round((team.length / totalDue) * 100);
} else if (cfg.type === 'threshold') {
teamTotal = team.reduce((sum: number, c: any) => {
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0); return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0); }, 0);
}
} else if (cfg.type === 'count') {
total = teamCompletions.length;
}
const achieved = cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue);
if (!achieved && existingRewards.length > 0) {
for (const r of existingRewards) {
if (r.status !== 'claimed') {
try {
await pb.collection('rewards').delete(r.id);
} catch {}
}
}
continue;
}
if (achieved && existingRewards.length === 0) {
for (const m of allMembers) {
await pb.collection('rewards').create(rewardData(m.id));
createdReward = true;
}
}
} else if (cfg.target === 'competitive') {
const scored = allMembers.map((m: any) => {
const memberTarget = targetChoreFor(cfg, m.id);
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
if (memberTarget)
memberCompletions = memberCompletions.filter(
(c: any) => c.assignedChoreId === memberTarget
);
let current = 0;
if (cfg.type === 'threshold') {
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') { } else if (cfg.type === 'count') {
current = memberCompletions.length; teamTotal = team.length;
}
return { memberId: m.id, name: m.name, current };
});
const qualified = scored.filter(
(s: any) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue)
);
const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0);
const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0];
if (existingRewards.length > 0) {
const existing = existingRewards[0];
const stillValid = winner && existing.memberId === winner.memberId && winner.current > 0;
if (!stillValid && existing.status !== 'claimed') {
try {
await pb.collection('rewards').delete(existing.id);
} catch {}
} }
} }
const members =
if (winner && existingRewards.length === 0) { cfg.target === 'individual' && cfg.memberId
await pb.collection('rewards').create(rewardData(winner.memberId)); ? allMembers.filter((m: any) => m.id === cfg.memberId)
createdReward = true; : allMembers;
for (const m of members) {
const achieved =
cfg.target === 'collaborative' ? met(teamTotal) : met(scoreFor(m.id));
await reconcile(m.id, achieved);
await purgeStale(m.id);
} }
} }
@@ -45,14 +45,40 @@ export async function myChores(pb: any, famId: string, memberId: string) {
}; };
} }
export async function toggle(pb: any, famId: string, memberId: string, body: { assignedChoreId: string; date: string }) { export async function toggle(pb: any, famId: string, memberId: string, body: { assignedChoreId: string; date: string; completedAt?: string }) {
const { assignedChoreId, date } = body; const { assignedChoreId, date } = body;
if (!assignedChoreId || !date) throw new Error('assignedChoreId and date required'); if (!assignedChoreId || !date) throw new Error('assignedChoreId and date required');
// Backdated catch-up (yesterday mode) stamps the completion for the viewed
// day, never "now" — the logical `date` always comes from the client, and
// an explicit `completedAt` is honoured when it parses (validated ISO,
// defaulting to now). Server-side period windows are always computed from
// the real current day, so a yesterday stamp can't shift window logic.
let completedAt = new Date().toISOString();
if (typeof body.completedAt === 'string') {
const t = new Date(body.completedAt).getTime();
if (Number.isFinite(t)) completedAt = new Date(t).toISOString();
}
const choreList = await pb // Chore existence is checked as superuser so a stale client ID (parent
.collection('assigned_chores') // re-saved the chores grid → old assigned rows deleted, new ids issued)
.getFullList({ filter: `famId = '${famId}' && id = '${assignedChoreId}'` }); // surfaces as a clear "gone" error instead of PB's generic
const chore = choreList?.[0]; // "resource cannot be found" relation-validation 400. The child's own
// token is still used for the completion writes below (PB rules enforce).
let chore: any;
try {
chore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
} catch (e) {
console.error(
`[diag] toggle CHORE_GONE(getOne-fail) member=${memberId} fam=${famId} chore=${assignedChoreId} date=${date} err=${e instanceof Error ? e.message : e}`
);
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
}
if (!chore || chore.famId !== famId) {
console.error(
`[diag] toggle CHORE_GONE(fam-mismatch) member=${memberId} sessionFam=${famId} chore=${assignedChoreId} choreFam=${chore?.famId} choreMember=${chore?.memberId} date=${date}`
);
throw new Error('CHORE_GONE: this chore was changed — refresh to get the latest list');
}
const isTodo = chore?.isTodo; const isTodo = chore?.isTodo;
const isShared = chore?.shared === true || !chore?.memberId; const isShared = chore?.shared === true || !chore?.memberId;
// Only one-off shared todos are claimed (winner takes it). Recurring // Only one-off shared todos are claimed (winner takes it). Recurring
@@ -81,7 +107,14 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
if (isClaimable && existing[0].memberId === memberId) { if (isClaimable && existing[0].memberId === memberId) {
await claimChore(assignedChoreId, famId, ''); await claimChore(assignedChoreId, famId, '');
} }
evaluateFam(pb, famId).catch(() => {}); // Bonus evaluation is awaited (not fire-and-forget) so failures are
// visible in the server log instead of silently swallowing missed payouts.
// The client already updates optimistically, so this doesn't block the UI.
try {
await evaluateFam(pb, famId);
} catch (e) {
console.error('[evaluateFam] after un-complete:', e);
}
return { completed: false }; return { completed: false };
} }
let rewardId: string | undefined; let rewardId: string | undefined;
@@ -103,7 +136,7 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
memberId, memberId,
assignedChoreId, assignedChoreId,
date, date,
completedAt: new Date().toISOString(), completedAt,
...(rewardId ? { rewardId } : {}) ...(rewardId ? { rewardId } : {})
}); });
// Claim a shared one-off todo by setting memberId (recurring shared // Claim a shared one-off todo by setting memberId (recurring shared
@@ -111,7 +144,11 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
if (isClaimable) { if (isClaimable) {
await claimChore(assignedChoreId, famId, memberId); await claimChore(assignedChoreId, famId, memberId);
} }
evaluateFam(pb, famId).catch(() => {}); try {
await evaluateFam(pb, famId);
} catch (e) {
console.error('[evaluateFam] after toggle:', e);
}
return { completed: true, record }; return { completed: true, record };
} }
@@ -129,6 +166,10 @@ export async function revoke(pb: any, famId: string, completionId: string) {
if (chore?.shared === true) { if (chore?.shared === true) {
await claimChore(chore.id, famId, ''); await claimChore(chore.id, famId, '');
} }
evaluateFam(pb, famId).catch(() => {}); try {
await evaluateFam(pb, famId);
} catch (e) {
console.error('[evaluateFam] after revoke:', e);
}
return { revoked: true }; return { revoked: true };
} }
+15 -8
View File
@@ -76,6 +76,7 @@ export async function getProfile(pb: any, famId: string, userId: string) {
pattern: rec.pattern || '', pattern: rec.pattern || '',
themeSize: rec.themeSize || '', themeSize: rec.themeSize || '',
themeOpacity: rec.themeOpacity || '', themeOpacity: rec.themeOpacity || '',
partyEmoji: rec.partyEmoji || '',
email: rec.email || '' email: rec.email || ''
}; };
} }
@@ -92,6 +93,8 @@ export async function updateProfile(
if (data.pattern !== undefined) patch.pattern = data.pattern; if (data.pattern !== undefined) patch.pattern = data.pattern;
if (data.themeSize !== undefined) patch.themeSize = data.themeSize; if (data.themeSize !== undefined) patch.themeSize = data.themeSize;
if (data.themeOpacity !== undefined) patch.themeOpacity = data.themeOpacity; if (data.themeOpacity !== undefined) patch.themeOpacity = data.themeOpacity;
if (data.partyEmoji !== undefined)
patch.partyEmoji = String(data.partyEmoji).trim().slice(0, 8) || '🐖';
if (data.email !== undefined) patch.email = data.email; if (data.email !== undefined) patch.email = data.email;
const rec = await pb.collection('users').update(userId, patch); const rec = await pb.collection('users').update(userId, patch);
return { return {
@@ -101,6 +104,7 @@ export async function updateProfile(
pattern: rec.pattern || '', pattern: rec.pattern || '',
themeSize: rec.themeSize || '', themeSize: rec.themeSize || '',
themeOpacity: rec.themeOpacity || '', themeOpacity: rec.themeOpacity || '',
partyEmoji: rec.partyEmoji || '',
email: rec.email || '' email: rec.email || ''
}; };
} }
@@ -167,13 +171,16 @@ export async function weeklySummary(pb: any, famId: string) {
return sum + (chore?.type === 'money' ? Number(chore.value) : 0); return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
}, 0); }, 0);
let bonusMoney = 0; let bonusMoney = 0;
try { try {
const cashRewards = await pb.collection('rewards').getFullList({ // Incoming cash counts too (unclaimed/requested, e.g. achieved but
filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'` // not yet approved pocket money) — this is the "earned this week"
}); // figure, not just banked cash.
bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0); const cashRewards = await pb.collection('rewards').getFullList({
} catch {} filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && date >= '${ws}'`
});
bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0);
} catch {}
return { return {
memberId: m.id, memberId: m.id,
@@ -276,7 +283,7 @@ export async function completeWeek(pb: any, famId: string) {
filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'` filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`
}), }),
pb.collection('rewards').getFullList({ pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'` filter: `famId = '${famId}' && rewardType = 'cash' && date >= '${ws}'`
}) })
]); ]);
} catch {} } catch {}
+1 -1
View File
@@ -56,7 +56,7 @@ export function createServices(
cleanupExpiredTodos: (famId: string) => choresSvc.cleanupExpiredTodos(pb, famId) cleanupExpiredTodos: (famId: string) => choresSvc.cleanupExpiredTodos(pb, famId)
}, },
completions: { completions: {
toggle: (famId: string, body: { assignedChoreId: string; date: string }) => toggle: (famId: string, body: { assignedChoreId: string; date: string; completedAt?: string }) =>
completionsSvc.toggle(pb, famId, uid, body), completionsSvc.toggle(pb, famId, uid, body),
revoke: (famId: string, completionId: string) => revoke: (famId: string, completionId: string) =>
completionsSvc.revoke(pb, famId, completionId) completionsSvc.revoke(pb, famId, completionId)
+21 -10
View File
@@ -1,4 +1,4 @@
import { todayInTz, resolveTz } from '@shared/timezone'; import { todayInTz, resolveTz, wallClockToUtc } from '@shared/timezone';
import { famMeta } from './fam'; import { famMeta } from './fam';
import { pbAdmin } from '$lib/server/pocketbase'; import { pbAdmin } from '$lib/server/pocketbase';
@@ -7,7 +7,7 @@ function resolveServerTz(tz?: string): string {
} }
// A `once` goal is finished the moment its reward is approved — complete the // A `once` goal is finished the moment its reward is approved — complete the
// config here (not just in evaluateFam, which only runs on chore toggles) so // config here (not just in evaluateFam, which runs on toggles/revoke/payday) so
// the child dashboard stops offering it even before the next toggle. // the child dashboard stops offering it even before the next toggle.
// Config write via superuser: bonus_configs updates are parent-only. // Config write via superuser: bonus_configs updates are parent-only.
// Outstanding (unclaimed/requested) rewards are untouched — they stay visible // Outstanding (unclaimed/requested) rewards are untouched — they stay visible
@@ -26,24 +26,35 @@ async function completeOnceConfig(famId: string, reward: any) {
} catch {} } catch {}
} }
export function assertPaydayUnlocked(reward: any, tz?: string) { // Payday-gated rewards (claimable 'payday', e.g. pocket money) unlock on the
// settle DATE at the fam's configured payday TIME — not midnight. Anything
// else (claimable 'immediate'/unset, e.g. manual triggers) is always claimable.
export function assertPaydayUnlocked(reward: any, tz?: string, paydayTime?: string) {
if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return; if (!reward || reward.claimable !== 'payday' || !reward.settleDate) return;
const today = todayInTz(resolveServerTz(tz)); const resolvedTz = resolveServerTz(tz);
if (today < reward.settleDate) { const settle = (reward.settleDate as string).slice(0, 10);
throw new Error(`This bonus pays out on payday (${reward.settleDate}) — hang tight!`); const today = todayInTz(resolvedTz);
if (today < settle) {
throw new Error(`This bonus pays out on payday (${settle}) — hang tight!`);
}
if (today === settle && paydayTime && /^\d{2}:\d{2}$/.test(paydayTime)) {
const unlock = wallClockToUtc(settle, paydayTime, resolvedTz);
if (Date.now() < unlock) {
throw new Error(`This bonus unlocks at ${paydayTime} on payday — hang tight!`);
}
} }
} }
// Member claim → status 'requested' (pending parent approval). // Member claim → status 'requested' (pending parent approval).
export async function claim(pb: any, famId: string, id: string) { export async function claim(pb: any, famId: string, id: string) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const { tz } = await famMeta(pb, famId); const { tz, paydayTime } = await famMeta(pb, famId);
const found = await pb const found = await pb
.collection('rewards') .collection('rewards')
.getFullList({ filter: `famId = '${famId}' && id = '${id}'` }); .getFullList({ filter: `famId = '${famId}' && id = '${id}'` });
const reward = found?.[0]; const reward = found?.[0];
if (!reward) throw new Error('Reward not found'); if (!reward) throw new Error('Reward not found');
assertPaydayUnlocked(reward, tz); assertPaydayUnlocked(reward, tz, paydayTime);
return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now }); return pb.collection('rewards').update(id, { status: 'requested', requestedAt: now });
} }
@@ -59,14 +70,14 @@ export async function approve(pb: any, famId: string, id: string) {
export async function requestAll(pb: any, famId: string, memberId: string) { export async function requestAll(pb: any, famId: string, memberId: string) {
const now = new Date().toISOString(); const now = new Date().toISOString();
const { tz } = await famMeta(pb, famId); const { tz, paydayTime } = await famMeta(pb, famId);
const rewards = await pb.collection('rewards').getFullList({ const rewards = await pb.collection('rewards').getFullList({
filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'` filter: `famId = '${famId}' && memberId = '${memberId}' && status = 'unclaimed'`
}); });
let count = 0; let count = 0;
for (const r of rewards) { for (const r of rewards) {
try { try {
assertPaydayUnlocked(r, tz); assertPaydayUnlocked(r, tz, paydayTime);
} catch { } catch {
continue; continue;
} }
+1
View File
@@ -8,4 +8,5 @@ export interface SessionUser {
pattern?: string; pattern?: string;
themeSize?: string; themeSize?: string;
themeOpacity?: string; themeOpacity?: string;
partyEmoji?: string;
} }
+6 -1
View File
@@ -321,7 +321,12 @@ class FamStore {
// and from PB subscribe SSE for multi-user realtime. // and from PB subscribe SSE for multi-user realtime.
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') { applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
const apply = <T extends { id: string }>(list: T[]): T[] => { const apply = <T extends { id: string }>(list: T[]): T[] => {
if (action === 'create') return [record, ...list]; // A create for an id we already hold (optimistic add + SSE echo) is
// an update, not a second row.
if (action === 'create')
return list.some((x) => x.id === record.id)
? list.map((x) => (x.id === record.id ? { ...x, ...record } : x))
: [record, ...list];
if (action === 'update') if (action === 'update')
return list.map((x) => (x.id === record.id ? { ...x, ...record } : x)); return list.map((x) => (x.id === record.id ? { ...x, ...record } : x));
if (action === 'delete') return list.filter((x) => x.id !== record.id); if (action === 'delete') return list.filter((x) => x.id !== record.id);
+8 -1
View File
@@ -134,7 +134,14 @@ export async function load(event) {
chat = await resolveChatIdentity(session, pbToken); chat = await resolveChatIdentity(session, pbToken);
// fams is superadmin-only (non-realtime). Fetched server-side for both // fams is superadmin-only (non-realtime). Fetched server-side for both
// roles; also recomputes + persists the derived `active` flag. // roles; also recomputes + persists the derived `active` flag.
const res = await ensureFamAccess(famId).catch(() => null); // Retried: a post-deploy PB blip here blanks `fam`, which both hides
// data AND disables the canonical slug redirect below (letting /{id}
// URLs persist).
let res: any = null;
for (let i = 0; i < 3 && !res; i++) {
if (i > 0) await new Promise((r) => setTimeout(r, 400 * i));
res = await ensureFamAccess(famId).catch(() => null);
}
if (res) { if (res) {
fam = res.fam; fam = res.fam;
famAccess = res.access; famAccess = res.access;
+64 -1
View File
@@ -69,7 +69,12 @@
let session = $state<Session | null>(data.session); let session = $state<Session | null>(data.session);
let isParent = $state(data.isParent); let isParent = $state(data.isParent);
let role = $state(data.role); let role = $state(data.role);
let isDemo = $state(data.demoMode); // Demo UI restrictions apply only inside the demo family itself — the
// global `demo` flag must never degrade real families (setting it in
// prod once hid every fam's dashboard + settings links).
let isDemo = $derived(
!!data.demoMode && (page.data.famSlug ?? page.params.fam) === 'showboaters'
);
let famName = $derived( let famName = $derived(
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
); );
@@ -78,6 +83,14 @@
// Settings stays usable while paused so admins can apply a code / manage billing. // Settings stays usable while paused so admins can apply a code / manage billing.
let hasAuth = $derived(!!data.session); let hasAuth = $derived(!!data.session);
let locked = $derived(disabled && !page.url.pathname.endsWith('/settings')); let locked = $derived(disabled && !page.url.pathname.endsWith('/settings'));
// Expired-token zombie state: public PB rules still let logged-out users
// READ everything, so the dashboard looks alive while every write fails.
// Say so explicitly (join/switch/picker flows render their own UI).
let showLoggedOut = $derived(
!data.session &&
!pickerMode &&
!['join', 'switch'].includes(page.url.pathname.split('/')[2] || '')
);
// ── Post-checkout activation (event-driven) ── // ── Post-checkout activation (event-driven) ──
// Landing with ?checkout=return: if the webhook has already landed we show // Landing with ?checkout=return: if the webhook has already landed we show
@@ -282,6 +295,28 @@
<div class="page-wrap"> <div class="page-wrap">
<div class="page-content" class:locked>{@render children()}</div> <div class="page-content" class:locked>{@render children()}</div>
{#if showLoggedOut}
<div class="disabled-overlay loggedout-overlay">
<div class="disabled-card">
<strong>You're logged out</strong>
<span>
Your session expired, so everything below is read-only — toggles and
claims won't work until you log back in.
</span>
<span class="loggedout-actions">
<a class="btn-primary" href="/login">Parent log in</a>
{#if page.params.username}
<a
class="btn-secondary"
href={`/${data.famSlug || page.params.fam}/join/${page.params.username}`}
>Rejoin as {page.params.username}</a
>
{/if}
</span>
<span class="hint">Kids: re-open your invite link, or ask a parent to send it again.</span>
</div>
</div>
{/if}
{#if locked} {#if locked}
<div class="disabled-overlay"> <div class="disabled-overlay">
<div class="disabled-card"> <div class="disabled-card">
@@ -482,6 +517,34 @@
color: #6b7280; color: #6b7280;
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Logged-out card must be clickable (the paused overlay is display-only). */
.loggedout-overlay {
pointer-events: auto;
}
.loggedout-actions {
display: flex;
gap: 0.6rem;
justify-content: center;
margin-top: 0.4rem;
}
.loggedout-actions a {
border-radius: 8px;
padding: 0.5rem 1.1rem;
font-size: 0.85rem;
font-weight: 700;
text-decoration: none;
}
.loggedout-actions .btn-primary {
background: #6366f1;
color: #fff;
}
.loggedout-actions .btn-secondary {
background: #eef2ff;
color: #4338ca;
}
.disabled-card .hint {
font-size: 0.78rem;
}
.chat-toggle { .chat-toggle {
position: relative; position: relative;
width: 40px; width: 40px;
+110 -31
View File
@@ -107,6 +107,39 @@
} }
return map; return map;
}); });
// ─── All-time per-member stats ───
let allTimeByMember = $derived.by(() => {
const map = new Map<string, { points: number; cash: number; chores: number }>();
for (const m of trend as any[]) {
const pts = (m.weeks || []).reduce((s: number, w: any) => s + (Number(w.points) || 0), 0);
const cash = (m.weeks || []).reduce((s: number, w: any) => s + (Number(w.cash) || 0), 0);
const chores = (m.weeks || []).reduce((s: number, w: any) => s + (Number(w.chores) || 0), 0);
map.set(m.memberId, { points: pts, cash, chores });
}
return map;
});
let goalsAllTimeByMember = $derived.by(() => {
const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id));
const map = new Map<string, { count: number; names: string[] }>();
for (const r of rewards as any[]) {
if (!r.bonusConfigId || pmIds.has(r.bonusConfigId)) continue;
const cfg = bonusConfigs.find((c: any) => c.id === r.bonusConfigId);
const entry = map.get(r.memberId) || { count: 0, names: [] };
entry.count++;
if (cfg?.name && !entry.names.includes(cfg.name)) entry.names.push(cfg.name);
map.set(r.memberId, entry);
}
return map;
});
let pocketMoneyAllTimeByMember = $derived.by(() => {
const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id));
const map = new Map<string, number>();
for (const r of rewards as any[]) {
if (!r.bonusConfigId || !pmIds.has(r.bonusConfigId)) continue;
map.set(r.memberId, (map.get(r.memberId) || 0) + 1);
}
return map;
});
// ─── Trend chart window: show current month, expand a month at a time up to 3. ─── // ─── Trend chart window: show current month, expand a month at a time up to 3. ───
let trend = $derived.by(() => { let trend = $derived.by(() => {
@@ -413,6 +446,8 @@
{@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0} {@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0}
{@const sc = overallRateFor(s.memberId)} {@const sc = overallRateFor(s.memberId)}
{@const goals = goalsByMember.get(s.memberId)} {@const goals = goalsByMember.get(s.memberId)}
{@const goalsAll = goalsAllTimeByMember.get(s.memberId)}
{@const at = allTimeByMember.get(s.memberId)}
<div class="column col-member" style="--mc: {s.memberColor}"> <div class="column col-member" style="--mc: {s.memberColor}">
<div class="member-head"> <div class="member-head">
<span class="dot" style="background:{s.memberColor}"></span> <span class="dot" style="background:{s.memberColor}"></span>
@@ -438,30 +473,18 @@
<span class="donut-label">Overall</span> <span class="donut-label">Overall</span>
</div> </div>
</div> </div>
<div class="member-stats"> <div class="member-stats compact">
<div class="stat-row"> <div class="mstat" title="Points this week: {s.pointsEarned} · Cash: £{Number(s.moneyEarned || 0).toFixed(2)} · Pocket money: {pocketMoneyByMember.get(s.memberId) ?? 0}">
<span class="stat-lbl">Points</span> <span class="mstat-tag">Week</span>
<span class="stat-val pts">{s.pointsEarned}</span> <span class="mstat-main"><b class="pts">{s.pointsEarned}</b> pts · <b class="cash">£{Number(s.moneyEarned || 0).toFixed(2)}</b> · {wkDone}/{wkTotal}</span>
</div> {#if (goals?.count ?? 0) > 0}<span class="mstat-goals" title={goals?.names.join(' · ')}>🎉{goals?.count}</span>{/if}
<div class="stat-row">
<span class="stat-lbl">Cash</span>
<span class="stat-val cash">£{Number(s.moneyEarned || 0).toFixed(2)}</span>
</div>
<div class="stat-row">
<span class="stat-lbl">Done</span>
<span class="stat-val">{wkDone}/{wkTotal}</span>
</div>
<div class="stat-row">
<span class="stat-lbl">Goals 🎉</span>
<span class="stat-val goals" title={goals?.names.join(' · ')}
>{goals?.count ?? 0}</span
>
</div>
<div class="stat-row">
<span class="stat-lbl">Pocket Money</span>
<span class="stat-val pm">{pocketMoneyByMember.get(s.memberId) ?? 0}</span>
</div>
</div> </div>
<div class="mstat dim" title="Points all time: {at?.points ?? s.pointsEarned} · Cash: £{Number(at?.cash ?? s.moneyEarned ?? 0).toFixed(2)} · Pocket money: {pocketMoneyAllTimeByMember.get(s.memberId) ?? 0}">
<span class="mstat-tag">All time</span>
<span class="mstat-main"><b>{at?.points ?? s.pointsEarned}</b> pts · <b>£{Number(at?.cash ?? s.moneyEarned ?? 0).toFixed(2)}</b> · {sc.done}/{sc.total}</span>
{#if (goalsAll?.count ?? 0) > 0}<span class="mstat-goals" title={goalsAll?.names.join(' · ')}>🎉{goalsAll?.count}</span>{/if}
</div>
</div>
</div> </div>
{/each} {/each}
</div> </div>
@@ -671,7 +694,7 @@
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-top: 4px solid #6366f1; border-top: 4px solid #6366f1;
border-radius: 12px; border-radius: 12px;
padding: 0.75rem; padding: 0.9rem;
background: #fafbff; background: #fafbff;
} }
.col-member { .col-member {
@@ -686,6 +709,7 @@
gap: 0.4rem; gap: 0.4rem;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-bottom: 0.35rem;
} }
.dot { .dot {
display: inline-block; display: inline-block;
@@ -718,18 +742,18 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.85rem; gap: 0.7rem;
margin: 3rem 0; margin: 1.1rem 0 1.2rem;
} }
.donut-wrap { .donut-wrap {
position: relative; position: relative;
width: 62px; width: 54px;
height: 62px; height: 54px;
flex-shrink: 0; flex-shrink: 0;
} }
.donut-wrap canvas { .donut-wrap canvas {
max-width: 62px; max-width: 54px;
max-height: 62px; max-height: 54px;
} }
.donut-center { .donut-center {
position: absolute; position: absolute;
@@ -757,15 +781,70 @@
.member-stats { .member-stats {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.3rem; gap: 0.45rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.member-stats.compact .mstat {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.8rem;
line-height: 1.5;
color: #374151;
background: rgba(255, 255, 255, 0.65);
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 0.45rem 0.65rem;
white-space: nowrap;
overflow: hidden;
}
.member-stats.compact .mstat.dim {
opacity: 0.75;
}
.mstat-tag {
font-size: 0.6rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #9ca3af;
flex-shrink: 0;
}
.mstat-main {
overflow: hidden;
text-overflow: ellipsis;
}
.mstat-main b {
font-weight: 800;
}
.mstat-main b.pts {
color: #7c3aed;
}
.mstat-main b.cash {
color: #059669;
}
.mstat-goals {
margin-left: auto;
flex-shrink: 0;
font-weight: 700;
color: #059669;
}
.stat-row { .stat-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-size: 0.8rem; font-size: 0.8rem;
} }
.stat-group-lbl {
font-size: 0.65rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #9ca3af;
margin-top: 0.35rem;
}
.stat-group-lbl:first-child {
margin-top: 0;
}
.stat-lbl { .stat-lbl {
color: #6b7280; color: #6b7280;
font-weight: 500; font-weight: 500;
@@ -87,6 +87,7 @@ export async function load(event) {
verified: false, verified: false,
memberName: session?.name || '', memberName: session?.name || '',
memberColor: session?.color || '', memberColor: session?.color || '',
partyEmoji: (session as any)?.partyEmoji || '',
templates: [] as never[], templates: [] as never[],
assigned: [] as never[], assigned: [] as never[],
completions: [] as never[], completions: [] as never[],
@@ -111,6 +112,7 @@ export async function load(event) {
verified: true, verified: true,
memberName: session?.name || '', memberName: session?.name || '',
memberColor: session?.color || '', memberColor: session?.color || '',
partyEmoji: (session as any)?.partyEmoji || '',
templates: chores.templates || [], templates: chores.templates || [],
assigned: chores.assigned || [], assigned: chores.assigned || [],
completions: chores.completions || [], completions: chores.completions || [],
+497 -49
View File
@@ -17,13 +17,13 @@
clockIcon, clockIcon,
giftIcon, giftIcon,
handCoinsIcon, handCoinsIcon,
moneyIcon,
coinsIcon, coinsIcon,
walletIcon, walletIcon,
chevronLeftIcon, chevronLeftIcon,
chevronRightIcon chevronRightIcon
} from '$lib/components/icons'; } from '$lib/components/icons';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types'; import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
import PiggyBank from '@lucide/svelte/icons/piggy-bank';
import { handleOf } from '@shared/slugify'; import { handleOf } from '@shared/slugify';
import { import {
weekStart as tzWeekStart, weekStart as tzWeekStart,
@@ -84,6 +84,18 @@
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today (c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today
); );
} }
// Yesterday's completions surface in the admin view for revoke — but never
// from the week's first day (yesterday would be payday = last week).
let yesterdayParent = $derived(addDaysStr(today, -1));
let yesterdayParentInWeek = $derived(
!!summary?.weekStart ? yesterdayParent >= (summary.weekStart as string).slice(0, 10) : true
);
function yesterdayCompletionsFor(memberId: string) {
if (!yesterdayParentInWeek) return [];
return parentCompletions.filter(
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === yesterdayParent
);
}
function choreNameFor(assignedChoreId: string): string { function choreNameFor(assignedChoreId: string): string {
const a = parentAssigned.find((x: any) => x.id === assignedChoreId); const a = parentAssigned.find((x: any) => x.id === assignedChoreId);
@@ -209,6 +221,15 @@
let famId = $state(data.famId || ''); let famId = $state(data.famId || '');
let memberName = $state(data.memberName || ''); let memberName = $state(data.memberName || '');
let memberColor = $state(data.memberColor || '#6366f1'); let memberColor = $state(data.memberColor || '#6366f1');
// Party mascot: live member record first (updates without re-login),
// server load as fallback, pig as default.
let partyEmoji = $derived(
(famStore.initialized
? (famStore.members as any[]).find((m) => m.id === memberId)?.partyEmoji
: null) ||
(data as any).partyEmoji ||
'🐖'
);
let editingName = $state(false); let editingName = $state(false);
let nameInput = $state(''); let nameInput = $state('');
let showColorPicker = $state(false); let showColorPicker = $state(false);
@@ -245,9 +266,41 @@
let todayChild = $derived(todayInTz(famTz)); let todayChild = $derived(todayInTz(famTz));
let togglingIds = $state<Set<string>>(new Set()); let togglingIds = $state<Set<string>>(new Set());
// ─── Kid-created todos ───
let showTodoModal = $state(false);
let newTodoName = $state('');
let todoSaving = $state(false);
let todoError = $state('');
async function submitTodo() {
const name = newTodoName.trim();
if (!name || todoSaving) return;
todoSaving = true;
todoError = '';
try {
const res = await memberApi.createTodo(name);
// Instant UI — the SSE echo dedupes on id via applyRecord.
famStore.applyRecord('assigned_chores', res.record, 'create');
newTodoName = '';
showTodoModal = false;
} catch (e) {
todoError = e instanceof Error ? e.message : 'Could not add todo';
} finally {
todoSaving = false;
}
}
// ─── Payday countdown (child view) ─── // ─── Payday countdown (child view) ───
let nowMs = $state(Date.now()); let nowMs = $state(Date.now());
let eowFired = $state(false); let eowFired = $state(false);
// Nyan-style payday party: rainbow banner + confetti when payday unlocks.
let paydayParty = $state(false);
let partyTimer: ReturnType<typeof setTimeout> | undefined;
function startPaydayParty() {
paydayParty = true;
fire();
if (partyTimer) clearTimeout(partyTimer);
partyTimer = setTimeout(() => (paydayParty = false), 15000);
}
$effect(() => { $effect(() => {
const id = setInterval(() => (nowMs = Date.now()), 1000); const id = setInterval(() => (nowMs = Date.now()), 1000);
return () => clearInterval(id); return () => clearInterval(id);
@@ -269,6 +322,7 @@
if (!pbToken || !famId) return; if (!pbToken || !famId) return;
eowFired = true; eowFired = true;
memberApi.payday(famId).catch(() => {}); memberApi.payday(famId).catch(() => {});
startPaydayParty();
}); });
function paydayWeekStart(): string { function paydayWeekStart(): string {
@@ -290,6 +344,37 @@
let weekEnd = $derived(addDays(weekStart, 6)); let weekEnd = $derived(addDays(weekStart, 6));
const isCurrentWeek = $derived(weekStart === currentWeek); const isCurrentWeek = $derived(weekStart === currentWeek);
// ─── Yesterday catch-up mode (child only) ───
// Before 12pm fam-local, and only when yesterday is still inside the
// current week (never across payday — e.g. payday Sunday → no yesterdays
// on Monday), the hero offers a "forgot yesterday?" button that flips the
// kanban to yesterday's date so missed chores can be marked late.
let yesterdayStr = $derived(addDaysStr(todayChild, -1));
let yesterdayInWeek = $derived(yesterdayStr >= currentWeek);
let famHour = $derived.by(() => {
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: famTz,
hour: 'numeric',
hour12: false
}).formatToParts(new Date(nowMs));
return Number(parts.find((p) => p.type === 'hour')?.value ?? 0) % 24;
} catch {
return new Date(nowMs).getHours();
}
});
let yesterdayAvailable = $derived(role === 'child' && yesterdayInWeek && famHour < 12);
let viewDate = $state<'today' | 'yesterday'>('today');
// Viewed day for the kanban: today normally, yesterday in catch-up mode.
let activeDate = $derived(
viewDate === 'yesterday' && yesterdayAvailable ? yesterdayStr : todayChild
);
let inYesterday = $derived(activeDate === yesterdayStr && yesterdayAvailable);
// Snap back to today when the mode stops being valid (past noon, or the
// day rolled over) so stale yesterday state can never linger.
$effect(() => {
if (!yesterdayAvailable && viewDate === 'yesterday') viewDate = 'today';
});
const daysLeft = $derived( const daysLeft = $derived(
Math.max( Math.max(
0, 0,
@@ -360,10 +445,10 @@
}); });
let dailyPending = $derived( let dailyPending = $derived(
memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, todayChild)) memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, activeDate))
); );
let weeklyPending = $derived( let weeklyPending = $derived(
memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, todayChild)) memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, activeDate))
); );
// Traffic light: days until todo deadline (only non-emoji todos) // Traffic light: days until todo deadline (only non-emoji todos)
@@ -381,7 +466,7 @@
return 'blue'; return 'blue';
} }
let completedToday = $derived( let completedToday = $derived(
completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === todayChild) completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === activeDate)
); );
let weeklyTotal = $derived.by(() => { let weeklyTotal = $derived.by(() => {
@@ -413,9 +498,7 @@
const counts: number[] = []; const counts: number[] = [];
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const d = addDays(weekStart, i); const d = addDays(weekStart, i);
counts.push( counts.push(weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length);
weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length
);
} }
return counts; return counts;
}); });
@@ -501,6 +584,15 @@
return sum + (chore?.type === 'money' ? Number(chore.value) : 0); return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
}, 0) }, 0)
); );
// Week's cash-reward earnings (pocket money, bonuses) count while still
// pending AND once claimed — auto-created money-todo rewards are skipped
// (already counted via the chore completion above).
let weekRewardCash = $derived.by(() =>
weekRewards
.filter((r) => r.rewardType === 'cash' && !autoRewardIds.has(r.id))
.reduce((sum, r) => sum + Number(r.value), 0)
);
let weekCashTotal = $derived(weekChoreCash + weekRewardCash);
let weekChorePoints = $derived.by(() => let weekChorePoints = $derived.by(() =>
weekCompletions.reduce((sum, c) => { weekCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId); const chore = assigned.find((a) => a.id === c.assignedChoreId);
@@ -540,9 +632,14 @@
(r.status === 'unclaimed' || r.status === 'requested') (r.status === 'unclaimed' || r.status === 'requested')
) )
); );
// Payday-gated bonus rewards unlock on their settleDate (stamped server-side). // Payday-gated bonus rewards unlock on their settleDate at the fam's payday
// time (stamped server-side) — not midnight on the day.
function paydayLocked(r: any) { function paydayLocked(r: any) {
return r.claimable === 'payday' && r.settleDate && todayChild < r.settleDate; if (r.claimable !== 'payday' || !r.settleDate) return false;
const settle = (r.settleDate as string).slice(0, 10);
if (todayChild < settle) return true;
if (todayChild > settle) return false;
return nowMs < new Date(wallClockToUtc(settle, paydayTime, famTz)).getTime();
} }
let weekBonusTallies = $derived.by(() => { let weekBonusTallies = $derived.by(() => {
const map = new Map<string, number>(); const map = new Map<string, number>();
@@ -766,7 +863,7 @@
'December' 'December'
]; ];
function headDate(): Date { function headDate(): Date {
return new Date(todayInTz(famTz) + 'T00:00:00'); return new Date(activeDate + 'T00:00:00');
} }
function headDay(): number { function headDay(): number {
return headDate().getDate(); return headDate().getDate();
@@ -840,18 +937,29 @@
? c.assignedChoreId === chore.id && ? c.assignedChoreId === chore.id &&
(c.date?.slice(0, 10) || c.date) >= weekStart && (c.date?.slice(0, 10) || c.date) >= weekStart &&
(c.date?.slice(0, 10) || c.date) < addDays(weekStart, 7) (c.date?.slice(0, 10) || c.date) < addDays(weekStart, 7)
: c.assignedChoreId === chore.id && (c.date?.slice(0, 10) || c.date) === todayChild; : c.assignedChoreId === chore.id && (c.date?.slice(0, 10) || c.date) === activeDate;
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-')); const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
return optimistic || completions.find(match); return optimistic || completions.find(match);
} }
async function toggle(chore: AssignedChore) { async function toggle(chore: AssignedChore) {
if (accessDisabled) return; if (accessDisabled) return;
if (!pbToken) {
showToast('Logged out — please log in again to update chores.');
return;
}
if (chore.isTodo && isTodoExpired(chore)) return; if (chore.isTodo && isTodoExpired(chore)) return;
if (togglingIds.has(chore.id)) return; if (togglingIds.has(chore.id)) return;
togglingIds = new Set(togglingIds).add(chore.id); togglingIds = new Set(togglingIds).add(chore.id);
const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, todayChild); const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, activeDate);
// Backdated (yesterday) completions are stamped for yesterday — never
// today's wall-clock time — so period/window logic can't double-count.
const stampDate = activeDate;
const stampAt =
stampDate === todayChild
? new Date().toISOString()
: new Date(wallClockToUtc(stampDate, '12:00', famTz)).toISOString();
if (wasCompleted) { if (wasCompleted) {
const existing = findCompletion(chore); const existing = findCompletion(chore);
if (existing) { if (existing) {
@@ -865,39 +973,46 @@
famId, famId,
memberId, memberId,
assignedChoreId: chore.id, assignedChoreId: chore.id,
date: todayChild, date: stampDate,
completedAt: new Date().toISOString() completedAt: stampAt
} as Completion, } as Completion,
'create' 'create'
); );
} }
try { try {
await memberApi.toggleCompletion(famId, chore.id, todayChild); await memberApi.toggleCompletion(famId, chore.id, stampDate, stampAt);
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id); const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
if (optimistic) { if (optimistic) {
famStore.applyRecord('completions', optimistic, 'delete'); famStore.applyRecord('completions', optimistic, 'delete');
} }
} catch (e) { } catch (e) {
if (wasCompleted) { // A stale assigned ID (parent re-saved chores while this tab was
famStore.applyRecord( // open in the background) leaves the chore untogglable. init() is
'completions', // a no-op when already initialized, so force a resync() to drop
{ // the dead card. Never re-add completions for the gone chore —
id: 'revert-' + chore.id + '-' + Date.now(), // that is what kept the error reoccurring after "refresh".
famId, const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
memberId, if (optimistic) {
assignedChoreId: chore.id, famStore.applyRecord('completions', optimistic, 'delete');
date: todayChild,
completedAt: new Date().toISOString()
} as Completion,
'create'
);
} else {
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
if (optimistic) {
famStore.applyRecord('completions', optimistic, 'delete');
}
} }
const msg = e instanceof Error ? e.message : String(e);
if (/CHORE_GONE/.test(msg)) {
// Drop the dead card synchronously so a second tap can't re-enter
// before the resync round-trip finishes (the repeated 400s).
famStore.applyRecord('assigned_chores', chore, 'delete');
console.error(
`[diag] toggle CHORE_GONE(drop-card) member=${memberId} fam=${famId} chore=${chore.id}`
);
}
try {
await famStore.resync();
} catch {}
showToast(
/CHORE_GONE/.test(msg)
? 'This chore was changed — list refreshed.'
: 'Toggle failed — please try again.'
);
console.error('Toggle failed:', e); console.error('Toggle failed:', e);
} finally { } finally {
const next = new Set(togglingIds); const next = new Set(togglingIds);
@@ -977,6 +1092,7 @@
<Card cols={1} title="Members"> <Card cols={1} title="Members">
{#each parentMembers as m} {#each parentMembers as m}
{@const todays = todayCompletionsFor(m.id)} {@const todays = todayCompletionsFor(m.id)}
{@const yesterdays = yesterdayCompletionsFor(m.id)}
{@const done = todays.length} {@const done = todays.length}
{@const s = memberInSummary(m.id)} {@const s = memberInSummary(m.id)}
{@const pm = parentBonusConfigs.find( {@const pm = parentBonusConfigs.find(
@@ -1031,6 +1147,31 @@
{/each} {/each}
</ul> </ul>
{/if} {/if}
{#if yesterdays.length > 0}
<p class="section-label">Yesterday</p>
<ul class="done-list yesterday-list">
{#each yesterdays as c, i}
<li class="done-item" class:odd={i % 2 === 1}>
<span class="done-icon">{@html checkCircleIcon}</span>
<span class="done-name">{choreNameFor(c.assignedChoreId)}</span>
<span class="yesterday-tag">yesterday</span>
<form
method="POST"
action="?/revoke"
use:enhance={() => {
return async (args) => handleResult(args);
}}
class="revoke-form"
>
<input type="hidden" name="id" value={c.id} />
<button type="submit" class="revoke-btn" title="Revoke"
>{@html revokeIcon}</button
>
</form>
</li>
{/each}
</ul>
{/if}
</div> </div>
{/each} {/each}
</Card> </Card>
@@ -1096,7 +1237,12 @@
{/if} {/if}
</Card> </Card>
<Card cols={1} title="Claims" accent={hasClaimableRewards ? '#f97316' : undefined} class={hasClaimableRewards ? 'claims-card' : ''}> <Card
cols={1}
title="Claims"
accent={hasClaimableRewards ? '#f97316' : undefined}
class={hasClaimableRewards ? 'claims-card' : ''}
>
{#if claimableRewards().length === 0} {#if claimableRewards().length === 0}
<div class="claims-empty"> <div class="claims-empty">
{@html checkCircleIcon}<span>No outstanding claims</span> {@html checkCircleIcon}<span>No outstanding claims</span>
@@ -1235,7 +1381,7 @@
{/if} {/if}
{#if showCountdown} {#if showCountdown}
<div class="payday-countdown"> <div class="payday-countdown">
<div class="pd-icon">{@html moneyIcon}</div> <div class="pd-icon"><PiggyBank size={44} strokeWidth={2} /></div>
<div class="pd-body"> <div class="pd-body">
<p class="pd-title">Payday is today!</p> <p class="pd-title">Payday is today!</p>
<p class="pd-num"> <p class="pd-num">
@@ -1249,14 +1395,19 @@
</div> </div>
</div> </div>
{/if} {/if}
{#if paydayParty}
<div class="payday-party" role="status" onclick={() => (paydayParty = false)}>
<span class="party-cat">{partyEmoji}</span>
<div class="party-body">
<p class="party-title">PAYDAY!!</p>
<p class="party-sub">Your cash is ready — go collect it! 🎉</p>
</div>
<span class="party-stars">🌈⭐🌈</span>
</div>
{/if}
<!-- HERO --> <!-- HERO -->
<header class="hero"> <header class="hero">
<svg <svg class="hero-bg" viewBox="0 0 700 120" preserveAspectRatio="none" aria-hidden="true">
class="hero-bg"
viewBox="0 0 700 120"
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline <polyline
points={heroSpark.line} points={heroSpark.line}
fill="none" fill="none"
@@ -1406,6 +1557,15 @@
{:else} {:else}
<button class="hero-now" onclick={jumpToNow}><span>← Back to this week</span></button> <button class="hero-now" onclick={jumpToNow}><span>← Back to this week</span></button>
{/if} {/if}
{#if inYesterday}
<button class="hero-now hero-yesterday-on" onclick={() => (viewDate = 'today')}>
<span>← Back to today</span>
</button>
{:else if yesterdayAvailable}
<button class="hero-now hero-yesterday" onclick={() => (viewDate = 'yesterday')}>
<span>Forgot to mark a chore yesterday?</span>
</button>
{/if}
</div> </div>
<nav class="hero-nav"> <nav class="hero-nav">
@@ -1424,7 +1584,7 @@
<!-- WEEK-TO-DATE TILES --> <!-- WEEK-TO-DATE TILES -->
<div class="tiles"> <div class="tiles">
<div class="tile tile-cash"> <div class="tile tile-cash">
<span class="tile-val">£{weekChoreCash.toFixed(2)}</span> <span class="tile-val">£{weekCashTotal.toFixed(2)}</span>
<span class="tile-lbl">cash this week</span> <span class="tile-lbl">cash this week</span>
</div> </div>
<div class="tile tile-pts"> <div class="tile tile-pts">
@@ -1532,6 +1692,16 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if inYesterday}
<div class="yesterday-banner">
<span
>📝 Showing <b>yesterday</b> — tap anything you missed. Undo lives on today's view.</span
>
<button class="yesterday-back" onclick={() => (viewDate = 'today')}
>Back to today →</button
>
</div>
{/if}
<div class="kanban"> <div class="kanban">
<div class="column col-daily"> <div class="column col-daily">
<h2>{@html sunIcon} Daily ({dailyPending.length})</h2> <h2>{@html sunIcon} Daily ({dailyPending.length})</h2>
@@ -1539,7 +1709,11 @@
<p class="empty">All done!</p> <p class="empty">All done!</p>
{:else} {:else}
{#each dailyPending as chore} {#each dailyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}> <button
class="chore"
onclick={() => toggle(chore)}
disabled={togglingIds.has(chore.id)}
>
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="chore-name">{choreName(chore)}</span> <span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span> <span class="chore-value">{chore.value} {chore.type}</span>
@@ -1556,7 +1730,11 @@
<p class="empty">All done!</p> <p class="empty">All done!</p>
{:else} {:else}
{#each weeklyPending as chore} {#each weeklyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}> <button
class="chore"
onclick={() => toggle(chore)}
disabled={togglingIds.has(chore.id)}
>
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="chore-name">{choreName(chore)}</span> <span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span> <span class="chore-value">{chore.value} {chore.type}</span>
@@ -1628,6 +1806,9 @@
{/if} {/if}
{/each} {/each}
{/if} {/if}
<button class="add-todo-cta" onclick={() => (showTodoModal = true)}>
<span aria-hidden="true">+</span> Add todo
</button>
</div> </div>
<div class="column col-done"> <div class="column col-done">
<h2>{@html checkCircleIcon} Done ({completedToday.length})</h2> <h2>{@html checkCircleIcon} Done ({completedToday.length})</h2>
@@ -1637,7 +1818,15 @@
{#each completedToday as c} {#each completedToday as c}
{@const chore = assigned.find((a) => a.id === c.assignedChoreId)} {@const chore = assigned.find((a) => a.id === c.assignedChoreId)}
{#if chore} {#if chore}
<button class="chore done" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}> <!-- Yesterday view is mark-only: un-doing a backdated
completion would toggle against this week's window
and create confusing duplicates, so undo stays on today. -->
<button
class="chore done"
onclick={() => !inYesterday && toggle(chore)}
disabled={togglingIds.has(chore.id) || inYesterday}
title={inYesterday ? 'Go back to today to undo' : undefined}
>
<span class="checkbox">{@html checkCircleIcon}</span> <span class="checkbox">{@html checkCircleIcon}</span>
<span class="chore-name">{choreName(chore)}</span> <span class="chore-name">{choreName(chore)}</span>
{#if chore.type === 'emoji'} {#if chore.type === 'emoji'}
@@ -1654,6 +1843,45 @@
{/if} {/if}
<!-- WALLET / CLAIMS --> <!-- WALLET / CLAIMS -->
{#if showTodoModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="modal-overlay"
onclick={() => !todoSaving && (showTodoModal = false)}
onkeydown={(e) => e.key === 'Escape' && !todoSaving && (showTodoModal = false)}
>
<div class="modal-card" onclick={(e) => e.stopPropagation()}>
<h3>🎯 New todo</h3>
<p class="modal-sub">Something you want to get done this week? Add it here.</p>
<input
class="modal-input"
type="text"
maxlength="80"
placeholder="e.g. Tidy my desk"
bind:value={newTodoName}
disabled={todoSaving}
autofocus
onkeydown={(e) => e.key === 'Enter' && submitTodo()}
/>
{#if todoError}
<p class="modal-error">{todoError}</p>
{/if}
<div class="modal-actions">
<button
class="modal-btn modal-cancel"
onclick={() => (showTodoModal = false)}
disabled={todoSaving}>Cancel</button
>
<button
class="modal-btn modal-add"
onclick={submitTodo}
disabled={!newTodoName.trim() || todoSaving}
>{todoSaving ? 'Adding…' : 'Add todo'}</button
>
</div>
</div>
</div>
{/if}
<div class="wallet"> <div class="wallet">
<div class="wallet-top"> <div class="wallet-top">
<span class="wallet-lbl">{@html walletIcon} Wallet</span> <span class="wallet-lbl">{@html walletIcon} Wallet</span>
@@ -2196,6 +2424,52 @@
.hero-now:hover span { .hero-now:hover span {
background: #fff; background: #fff;
} }
.hero-now.hero-yesterday span {
background: rgba(255, 255, 255, 0.22);
color: #fff;
border: 1px dashed rgba(255, 255, 255, 0.7);
box-shadow: none;
font-weight: 700;
}
.hero-now.hero-yesterday:hover span {
background: rgba(255, 255, 255, 0.32);
}
.yesterday-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
background: #fef3c7;
border: 1px solid #fcd34d;
color: #92400e;
border-radius: 10px;
padding: 0.5rem 0.85rem;
font-size: 0.82rem;
margin-bottom: 0.75rem;
}
.yesterday-back {
background: none;
border: none;
color: #b45309;
font-weight: 800;
font-size: 0.82rem;
cursor: pointer;
white-space: nowrap;
}
.yesterday-back:hover {
text-decoration: underline;
}
.yesterday-tag {
font-size: 0.62rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #b45309;
background: #fef3c7;
border-radius: 999px;
padding: 0.1rem 0.45rem;
flex-shrink: 0;
}
.hero-top { .hero-top {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2810,6 +3084,93 @@
padding: 1px 5px; padding: 1px 5px;
flex-shrink: 0; flex-shrink: 0;
} }
.add-todo-cta {
width: 100%;
margin-top: 0.5rem;
padding: 0.55rem;
border: 1.5px dashed #c7d2fe;
border-radius: 10px;
background: #eef2ff;
color: #4338ca;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.add-todo-cta:hover {
background: #e0e7ff;
border-color: #818cf8;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
}
.modal-card {
background: #fff;
border-radius: 14px;
padding: 1.25rem;
width: 100%;
max-width: 380px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
}
.modal-card h3 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
}
.modal-sub {
margin: 0 0 0.85rem;
font-size: 0.85rem;
color: #6b7280;
}
.modal-input {
width: 100%;
box-sizing: border-box;
border: 1.5px solid #d1d5db;
border-radius: 10px;
padding: 0.6rem 0.75rem;
font-size: 0.95rem;
}
.modal-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px #e0e7ff;
}
.modal-error {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: #dc2626;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.modal-btn {
border: none;
border-radius: 10px;
padding: 0.55rem 1rem;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
}
.modal-btn:disabled {
opacity: 0.5;
cursor: default;
}
.modal-cancel {
background: #f3f4f6;
color: #374151;
}
.modal-add {
background: #4338ca;
color: #fff;
}
.todo-main { .todo-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3051,12 +3412,14 @@
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 1rem 1.2rem; padding: 1rem 1.2rem;
border-radius: 12px; border-radius: 12px;
background: linear-gradient(135deg, #4f46e5, #7c3aed); background: linear-gradient(135deg, #f59e0b, #fbbf24);
color: #fff; color: #451a03;
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.35); box-shadow: 0 6px 16px rgba(245, 158, 11, 0.35);
} }
.pd-icon { .pd-icon {
font-size: 1.8rem; display: flex;
align-items: center;
flex-shrink: 0;
} }
.pd-body { .pd-body {
display: flex; display: flex;
@@ -3081,6 +3444,91 @@
font-size: 0.8rem; font-size: 0.8rem;
opacity: 0.85; opacity: 0.85;
} }
/* ── Payday party: nyan-style colour-cycling celebration ── */
.payday-party {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 1rem;
padding: 1rem 1.2rem;
border-radius: 14px;
color: #fff;
cursor: pointer;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
background: linear-gradient(
90deg,
#ef4444,
#f97316,
#eab308,
#22c55e,
#3b82f6,
#a855f7,
#ec4899,
#ef4444
);
background-size: 300% 100%;
animation:
nyan-slide 3s linear infinite,
party-pop 0.45s ease-out;
box-shadow: 0 8px 24px rgba(168, 85, 247, 0.45);
}
@keyframes nyan-slide {
to {
background-position: 300% 0;
}
}
@keyframes party-pop {
from {
transform: scale(0.85);
}
to {
transform: scale(1);
}
}
.party-cat {
font-size: 2.6rem;
animation: party-bounce 0.55s ease-in-out infinite alternate;
}
@keyframes party-bounce {
from {
transform: translateY(-4px) rotate(-10deg);
}
to {
transform: translateY(4px) rotate(10deg);
}
}
.party-body {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.party-title {
margin: 0;
font-size: 1.5rem;
font-weight: 900;
letter-spacing: 0.1em;
animation: party-wiggle 1s ease-in-out infinite;
}
@keyframes party-wiggle {
0%,
100% {
transform: rotate(-2deg);
}
50% {
transform: rotate(2deg);
}
}
.party-sub {
margin: 0;
font-size: 0.85rem;
opacity: 0.95;
}
.party-stars {
margin-left: auto;
font-size: 1.4rem;
animation: party-bounce 0.55s ease-in-out infinite alternate-reverse;
}
/* Mobile: declared last so it wins over the base .goal-mini (.kanban) /* Mobile: declared last so it wins over the base .goal-mini (.kanban)
rules (same specificity, source order decides). */ rules (same specificity, source order decides). */
@media (max-width: 639px) { @media (max-width: 639px) {
@@ -116,7 +116,8 @@ export const actions = {
const startDate = fd.get('startDate') as string; const startDate = fd.get('startDate') as string;
if (type) data.type = type; if (type) data.type = type;
if (type === 'emoji') { if (type === 'emoji') {
data.value = 0; // 1, not 0: PB treats 0 as blank on the required `value` field.
data.value = 1;
data.emoji = (fd.get('emoji') as string) || '🎉'; data.emoji = (fd.get('emoji') as string) || '🎉';
} else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0; } else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0;
data.customName = customName || undefined; data.customName = customName || undefined;
@@ -160,7 +161,7 @@ export const actions = {
const memberId = fd.get('memberId') as string; const memberId = fd.get('memberId') as string;
const name = fd.get('name') as string; const name = fd.get('name') as string;
const type = fd.get('type') as string; const type = fd.get('type') as string;
const value = type === 'emoji' ? 0 : (parseFloat(fd.get('value') as string) || 0); const value = type === 'emoji' ? 1 : (parseFloat(fd.get('value') as string) || 0);
const emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '🎉') : ''; const emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '🎉') : '';
const todoStart = fd.get('todoStart') as string; const todoStart = fd.get('todoStart') as string;
const todoCompleteBy = fd.get('todoCompleteBy') as string; const todoCompleteBy = fd.get('todoCompleteBy') as string;
@@ -63,6 +63,7 @@ export async function load(event) {
pattern: me.pattern || '', pattern: me.pattern || '',
themeSize: me.themeSize || '', themeSize: me.themeSize || '',
themeOpacity: me.themeOpacity || '', themeOpacity: me.themeOpacity || '',
partyEmoji: me.partyEmoji || '',
email: me.email || '', email: me.email || '',
hasPin: false, hasPin: false,
session: true session: true
@@ -94,6 +95,7 @@ export async function load(event) {
pattern: session.pattern || '', pattern: session.pattern || '',
themeSize: session.themeSize || '', themeSize: session.themeSize || '',
themeOpacity: session.themeOpacity || '', themeOpacity: session.themeOpacity || '',
partyEmoji: session.partyEmoji || '',
email: '', email: '',
hasPin: await hasPin(session.id).catch(() => false), hasPin: await hasPin(session.id).catch(() => false),
session: true session: true
@@ -112,6 +114,7 @@ export const actions = {
const pattern = (fd.get('pattern') as string) || ''; const pattern = (fd.get('pattern') as string) || '';
const themeSize = (fd.get('themeSize') as string) || ''; const themeSize = (fd.get('themeSize') as string) || '';
const themeOpacity = (fd.get('themeOpacity') as string) || ''; const themeOpacity = (fd.get('themeOpacity') as string) || '';
const partyEmoji = String(fd.get('partyEmoji') || '').trim().slice(0, 8);
const s = servicesFor(event); const s = servicesFor(event);
const famId = session.famId; const famId = session.famId;
@@ -124,6 +127,7 @@ export const actions = {
data.pattern = pattern; data.pattern = pattern;
data.themeSize = themeSize; data.themeSize = themeSize;
data.themeOpacity = themeOpacity; data.themeOpacity = themeOpacity;
if (partyEmoji) data.partyEmoji = partyEmoji;
data.email = email || ''; data.email = email || '';
const me = await s.fam.updateProfile(famId, data); const me = await s.fam.updateProfile(famId, data);
return { return {
@@ -133,6 +137,7 @@ export const actions = {
pattern: me.pattern, pattern: me.pattern,
themeSize: me.themeSize, themeSize: me.themeSize,
themeOpacity: me.themeOpacity, themeOpacity: me.themeOpacity,
partyEmoji: me.partyEmoji,
email: me.email email: me.email
}; };
} catch (e) { } catch (e) {
@@ -148,6 +153,7 @@ export const actions = {
data.pattern = pattern; data.pattern = pattern;
data.themeSize = themeSize; data.themeSize = themeSize;
data.themeOpacity = themeOpacity; data.themeOpacity = themeOpacity;
if (partyEmoji) data.partyEmoji = partyEmoji;
const me = await s.fam.updateProfile(famId, data); const me = await s.fam.updateProfile(famId, data);
return { return {
success: true, success: true,
@@ -156,6 +162,7 @@ export const actions = {
pattern: me.pattern, pattern: me.pattern,
themeSize: me.themeSize, themeSize: me.themeSize,
themeOpacity: me.themeOpacity, themeOpacity: me.themeOpacity,
partyEmoji: me.partyEmoji,
email: '' email: ''
}; };
} catch (e) { } catch (e) {
@@ -70,6 +70,14 @@
'#8b5cf6', '#8b5cf6',
'#14b8a6' '#14b8a6'
]; ];
// Payday-party mascot — the emoji that leads the child's payday celebration.
let memberEmoji = $state(data.partyEmoji || '🐖');
const partyEmojis = [
'🐖', '🐱', '🐶', '🦄', '🐸', '🐵', '🦊', '🐼',
'🦁', '🐯', '🐰', '🐨', '🐷', '🐮', '🦖', '🐙',
'🚀', '🌈', '⭐', '🎮', '⚽', '🍕', '🍦', '🎉'
];
</script> </script>
<ViewHeader title="Preferences" subtitle={data.verified ? data.memberName : ''} hero /> <ViewHeader title="Preferences" subtitle={data.verified ? data.memberName : ''} hero />
@@ -145,6 +153,28 @@
<input type="hidden" name="color" value={memberColor} /> <input type="hidden" name="color" value={memberColor} />
</div> </div>
<div style="margin-bottom:1rem">
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
Party emoji — leads your payday celebration 🎉
</label>
<div style="display:flex;gap:0.3rem;flex-wrap:wrap">
{#each partyEmojis as e}
<button
type="button"
onclick={() => (memberEmoji = e)}
aria-label="Pick {e}"
style="font-size:1.5rem;line-height:1;padding:0.25rem;border-radius:8px;border:{e ===
memberEmoji
? '2px solid #111827'
: '2px solid transparent'};background:{e === memberEmoji
? '#f3f4f6'
: 'transparent'};cursor:pointer"
>{e}</button>
{/each}
</div>
<input type="hidden" name="partyEmoji" value={memberEmoji} />
</div>
{#if data.role === 'parent'} {#if data.role === 'parent'}
<div style="margin-bottom:1rem"> <div style="margin-bottom:1rem">
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem"> <label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
@@ -9,6 +9,7 @@
CardGrid, CardGrid,
Card, Card,
Button, Button,
SaveButton,
Accordion, Accordion,
AccordionItem, AccordionItem,
NoticeDialog NoticeDialog
@@ -80,9 +81,26 @@
detectedTz = ''; detectedTz = '';
} }
} }
// 'auto' resolves to the SERVER's timezone (UTC in prod), not the
// family's — so during BST everything lands an hour late. Pre-select
// the admin's own zone so saving stores a real IANA zone (DST-aware).
if ((timezone === 'auto' || !timezone) && detectedTz && timezoneOptions.includes(detectedTz)) {
timezone = detectedTz;
}
}); });
let qrDataUrl = $state(''); let qrDataUrl = $state('');
let copied = $state(false); let copied = $state(false);
// Save-button success feedback: each save form holds a SaveButton ref and
// flashes it green for 4s from its use:enhance callback on success.
type Flasher = { flash: () => void } | null;
let renameBtn: Flasher = $state(null);
let paydayBtn: Flasher = $state(null);
let seasonBtn: Flasher = $state(null);
let lockBtn: Flasher = $state(null);
let addMemberBtn: Flasher = $state(null);
let inviteBtn: Flasher = $state(null);
let applyCodeBtn: Flasher = $state(null);
let parentInviteName = $state(''); let parentInviteName = $state('');
let parentInviteEmail = $state(''); let parentInviteEmail = $state('');
@@ -147,6 +165,7 @@
if (result.type === 'success') { if (result.type === 'success') {
const d = result.data as { ok?: boolean; error?: string } | undefined; const d = result.data as { ok?: boolean; error?: string } | undefined;
if (d?.ok) { if (d?.ok) {
inviteBtn?.flash();
notices.success( notices.success(
'Invite sent', 'Invite sent',
`Email sent to ${parentInviteEmail} with a join link and code.` `Email sent to ${parentInviteEmail} with a join link and code.`
@@ -203,7 +222,16 @@
<code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep <code class="slug-inline">/{famSlug}</code> even if you rename it — links you've shared keep
working. working.
</p> </p>
<form method="POST" action="?/renameFam" use:enhance> <form
method="POST"
action="?/renameFam"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') renameBtn?.flash();
};
}}
>
<label class="field-label" for="fam-name">Display name</label> <label class="field-label" for="fam-name">Display name</label>
<input <input
id="fam-name" id="fam-name"
@@ -212,7 +240,7 @@
placeholder={fam?.name || 'Family name'} placeholder={fam?.name || 'Family name'}
required required
/> />
<Button type="submit" size="sm">Rename</Button> <SaveButton bind:this={renameBtn} label="Rename" />
</form> </form>
{#if fam?.slug} {#if fam?.slug}
<p class="hint slug-line"> <p class="hint slug-line">
@@ -228,6 +256,7 @@
use:enhance={() => { use:enhance={() => {
return async ({ result }) => { return async ({ result }) => {
if (result.type === 'error') alert(result.error || 'Failed to update payday'); if (result.type === 'error') alert(result.error || 'Failed to update payday');
else if (result.type === 'success') paydayBtn?.flash();
}; };
}} }}
class="payday-form" class="payday-form"
@@ -259,11 +288,12 @@
{/each} {/each}
</select> </select>
</div> </div>
<Button type="submit" size="sm">Set payday</Button> <SaveButton bind:this={paydayBtn} label="Set payday" />
</form> </form>
<p class="hint"> <p class="hint">
Payday: the week starts on this day and weekly earnings are settled at this time. Auto Payday: the week starts on this day and weekly earnings are settled at this time. Pick
timezone follows each device. your city so payday lands at the right local time — Auto uses the server's timezone
(UTC), which is an hour out during daylight saving.
</p> </p>
</Card> </Card>
<Card title="Seasons" cols={1}> <Card title="Seasons" cols={1}>
@@ -271,7 +301,17 @@
Group chores into seasons. Tick a season on to make it available for assignment; untick to Group chores into seasons. Tick a season on to make it available for assignment; untick to
disable it. disable it.
</p> </p>
<form method="POST" action="?/createSeason" use:enhance class="season-form"> <form
method="POST"
action="?/createSeason"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') seasonBtn?.flash();
};
}}
class="season-form"
>
<label class="field-label" for="season-name">New season</label> <label class="field-label" for="season-name">New season</label>
<input id="season-name" name="name" placeholder="Season name" required /> <input id="season-name" name="name" placeholder="Season name" required />
<div class="season-color-row"> <div class="season-color-row">
@@ -290,7 +330,7 @@
</div> </div>
<input type="hidden" name="color" value={seasonColor} /> <input type="hidden" name="color" value={seasonColor} />
</div> </div>
<Button type="submit" size="sm">Add</Button> <SaveButton bind:this={seasonBtn} label="Add" />
</form> </form>
<ul class="season-list"> <ul class="season-list">
@@ -378,8 +418,10 @@
return async ({ result }) => { return async ({ result }) => {
if (result.type !== 'success') return; if (result.type !== 'success') return;
const d = result.data as any; const d = result.data as any;
if (d?.ok) lockMins = String(d.lockMins); if (d?.ok) {
else if (d?.error) alert(d.error); lockMins = String(d.lockMins);
lockBtn?.flash();
} else if (d?.error) alert(d.error);
}; };
}} }}
> >
@@ -388,7 +430,7 @@
<option value="2">After 2 minutes of no use</option> <option value="2">After 2 minutes of no use</option>
<option value="10">After 10 minutes of no use</option> <option value="10">After 10 minutes of no use</option>
</select> </select>
<Button type="submit" size="sm">Save</Button> <SaveButton bind:this={lockBtn} label="Save" />
</form> </form>
</Card> </Card>
</CardGrid> </CardGrid>
@@ -398,7 +440,16 @@
<CardGrid> <CardGrid>
<Card title="New member" cols={1}> <Card title="New member" cols={1}>
<p class="hint">Add a child. They'll pick their own colour after joining.</p> <p class="hint">Add a child. They'll pick their own colour after joining.</p>
<form method="POST" action="?/addMember" use:enhance> <form
method="POST"
action="?/addMember"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') addMemberBtn?.flash();
};
}}
>
<label class="field-label" for="new-child">New child</label> <label class="field-label" for="new-child">New child</label>
<input <input
id="new-child" id="new-child"
@@ -407,7 +458,7 @@
placeholder="Child name" placeholder="Child name"
required required
/> />
<Button type="submit" size="sm">Add child</Button> <SaveButton bind:this={addMemberBtn} label="Add child" />
</form> </form>
</Card> </Card>
@@ -624,7 +675,7 @@
placeholder="parent@example.com" placeholder="parent@example.com"
required required
/> />
<Button type="submit" size="sm">Send invite</Button> <SaveButton bind:this={inviteBtn} label="Send invite" />
</form> </form>
<p class="hint">They'll receive a link with a code to set up their own password.</p> <p class="hint">They'll receive a link with a code to set up their own password.</p>
</Card> </Card>
@@ -697,6 +748,7 @@
const d = result.data as { error?: string; ok?: boolean }; const d = result.data as { error?: string; ok?: boolean };
accessMsg = d.error || 'Code applied — access enabled.'; accessMsg = d.error || 'Code applied — access enabled.';
accessCodeInput = ''; accessCodeInput = '';
if (d.ok && !d.error) applyCodeBtn?.flash();
} }
await update(); await update();
}; };
@@ -708,7 +760,7 @@
placeholder="Enter access code" placeholder="Enter access code"
autocomplete="off" autocomplete="off"
/> />
<Button type="submit" size="sm" variant="primary">Apply code</Button> <SaveButton bind:this={applyCodeBtn} label="Apply code" />
</form> </form>
{/if} {/if}
{#if accessMsg} {#if accessMsg}
@@ -5,13 +5,23 @@ import { createServices } from '$lib/server/services';
export async function POST(event: RequestEvent) { export async function POST(event: RequestEvent) {
const u = event.locals.user; const u = event.locals.user;
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized'); if (!u || !event.locals.pbToken) {
console.error(`[diag] toggle 401 role=${u?.role} hasToken=${!!event.locals.pbToken}`);
throw error(401, 'Unauthorized');
}
const pb = createPbClient(event.locals.pbToken); const pb = createPbClient(event.locals.pbToken);
const body = await event.request.json().catch(() => ({})); const body = await event.request.json().catch(() => ({}));
try { try {
const s = createServices(pb, { id: u.id, role: u.role }); const s = createServices(pb, { id: u.id, role: u.role });
return json(await s.completions.toggle(u.famId, body)); const out = await s.completions.toggle(u.famId, body);
console.log(
`[diag] toggle ok member=${u.id} chore=${body.assignedChoreId} date=${body.date} completed=${(out as any)?.completed}`
);
return json(out);
} catch (e) { } catch (e) {
console.error(
`[diag] toggle 400 member=${u.id} role=${u.role} fam=${u.famId} chore=${body.assignedChoreId} date=${body.date} err=${e instanceof Error ? e.message : e}`
);
return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 }); return json({ error: e instanceof Error ? e.message : 'toggle failed' }, { status: 400 });
} }
} }
@@ -0,0 +1,52 @@
import { json, error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { createPbClient, pbAdmin } from '$lib/server/pocketbase';
// TEMP diagnostic for the recurring prod CHORE_GONE. Compares what the client
// claims against server truth from both angles (superuser getOne — the toggle
// path — and the user's own list read — the kanban path). Authenticated,
// own-fam only, no tokens ever logged or returned.
export async function GET(event: RequestEvent) {
const u = event.locals.user;
if (!u || !event.locals.pbToken) throw error(401, 'Unauthorized');
const assignedChoreId = event.url.searchParams.get('assignedChoreId') || '';
let suChore: any = null;
let suError = '';
if (assignedChoreId) {
try {
suChore = await pbAdmin.getOne('assigned_chores', assignedChoreId);
} catch (e) {
suError = e instanceof Error ? e.message : String(e);
}
}
let userSeesIt: boolean | null = null;
let userListError = '';
try {
const pb = createPbClient(event.locals.pbToken);
const list = await pb
.collection('assigned_chores')
.getFullList({ filter: `famId = '${u.famId}'` });
if (assignedChoreId) userSeesIt = list.some((a: any) => a.id === assignedChoreId);
} catch (e) {
userListError = e instanceof Error ? e.message : String(e);
}
const out = {
session: { id: u.id, role: u.role, famId: u.famId },
asked: assignedChoreId,
superuser: suChore
? {
found: true,
famId: suChore.famId,
memberId: suChore.memberId,
isTodo: !!suChore.isTodo,
famMatch: suChore.famId === u.famId
}
: { found: false, error: suError },
userList: { seesIt: userSeesIt, error: userListError }
};
console.log(`[diag] debug-chore ${JSON.stringify(out)}`);
return json(out);
}
+57
View File
@@ -0,0 +1,57 @@
import { json, error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { pbAdmin } from '$lib/server/pocketbase';
import { weekStart, addDaysStr, todayInTz, resolveTz } from '@shared/timezone';
// Kids can create todos for THEMSELVES only. Writes go through the superuser
// client because `assigned_chores.createRule` is parent-only — but every
// privileged field is forced server-side: memberId = session user, emoji type
// with zero value (so nobody can mint cash/points for themselves), weekly
// frequency, this-week start/completeBy (so it expires and purges naturally).
export async function POST(event: RequestEvent) {
const u = event.locals.user;
if (!u) {
console.error('[diag] todos 401 (no session)');
throw error(401, 'Unauthorized');
}
const body = await event.request.json().catch(() => ({}));
const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
if (!name) throw error(400, 'Give your todo a name');
let payday = 1;
let tz = 'UTC';
try {
const fam: any = await pbAdmin.getOne('fams', u.famId);
if (fam.payday !== undefined && fam.payday !== null) payday = Number(fam.payday);
tz = resolveTz(fam.timezone || 'auto');
} catch {
tz = resolveTz('auto');
}
const today = todayInTz(tz);
const completeBy = addDaysStr(weekStart(payday, tz), 6);
try {
const record = await pbAdmin.create('assigned_chores', {
famId: u.famId,
memberId: u.id,
frequency: 'weekly',
type: 'emoji',
// Must be 1, not 0: PB treats 0 as blank on the required `value`
// number field (400 validation_required). Value is meaningless for
// emoji todos — never summed, never displayed, never rewarded.
value: 1,
customName: name,
isTodo: true,
emoji: '🎯',
startDate: today,
completeBy
});
console.log(`[diag] todo ok member=${u.id} fam=${u.famId} todo=${record.id} name=${name}`);
return json({ record });
} catch (e) {
console.error(
`[diag] todo 400 member=${u.id} role=${u.role} fam=${u.famId} name=${name} err=${e instanceof Error ? e.message : e}`
);
throw error(400, e instanceof Error ? e.message : 'Could not add todo');
}
}
+15 -2
View File
@@ -30,8 +30,21 @@ export const actions = {
// An explicit email/password login supersedes kid mode on a shared device. // An explicit email/password login supersedes kid mode on a shared device.
clearActiveChild(event.cookies); clearActiveChild(event.cookies);
const fam = await pbAdmin.getOne('fams', user.famId).catch(() => null); // Fam lookup with retries: right after a deploy/rebuild PB can blip and
const famSlug = fam?.slug || user.famId; // a single failed fetch falls back to the raw fam ID in the URL
// (/{famId}/...), which then persists via bookmarks/shortcuts.
let fam: any = null;
for (let i = 0; i < 4 && !fam; i++) {
if (i > 0) await new Promise((r) => setTimeout(r, 500 * i));
fam = await pbAdmin.getOne('fams', user.famId).catch(() => null);
}
if (!fam) {
return fail(503, {
error: 'Family data is briefly unavailable (server restarting). Please try again.',
email
});
}
const famSlug = fam.slug;
// Parents (admins) land on the fam dashboard — no username in the URL. // Parents (admins) land on the fam dashboard — no username in the URL.
if (user.role === 'parent') { if (user.role === 'parent') {
+1 -1
View File
@@ -13,5 +13,5 @@
"esbuild" "esbuild"
] ]
}, },
"version": "1.11.2" "version": "1.11.7"
} }
+6
View File
@@ -388,6 +388,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
createRule: RULE_FAM_WRITE, createRule: RULE_FAM_WRITE,
updateRule: RULE_FAM_SCOPED, updateRule: RULE_FAM_SCOPED,
deleteRule: RULE_FAM_SCOPED, deleteRule: RULE_FAM_SCOPED,
listRule: RULE_FAM_READ,
viewRule: RULE_FAM_READ,
}, },
)(ids), )(ids),
}, },
@@ -418,6 +420,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
createRule: RULE_PARENT_WRITE, createRule: RULE_PARENT_WRITE,
updateRule: RULE_PARENT_SCOPED, updateRule: RULE_PARENT_SCOPED,
deleteRule: RULE_PARENT_SCOPED, deleteRule: RULE_PARENT_SCOPED,
listRule: RULE_FAM_READ,
viewRule: RULE_FAM_READ,
}, },
)(ids), )(ids),
}, },
@@ -438,6 +442,8 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
createRule: RULE_FAM_WRITE, createRule: RULE_FAM_WRITE,
updateRule: RULE_FAM_SCOPED, updateRule: RULE_FAM_SCOPED,
deleteRule: RULE_FAM_SCOPED, deleteRule: RULE_FAM_SCOPED,
listRule: RULE_FAM_READ,
viewRule: RULE_FAM_READ,
}, },
)(ids), )(ids),
}, },