diff --git a/frontend/src/lib/client/api.ts b/frontend/src/lib/client/api.ts index 8486269..c4e0d66 100644 --- a/frontend/src/lib/client/api.ts +++ b/frontend/src/lib/client/api.ts @@ -20,8 +20,8 @@ async function memberFetch( } export const memberApi = { - async toggleCompletion(famId: string, assignedChoreId: string, date: string) { - return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date }); + async toggleCompletion(famId: string, assignedChoreId: string, date: string, completedAt?: string) { + return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date, completedAt }); }, async claimReward(famId: string, rewardId: string) { return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`); diff --git a/frontend/src/lib/server/services/completions.ts b/frontend/src/lib/server/services/completions.ts index a1f1aac..4664f25 100644 --- a/frontend/src/lib/server/services/completions.ts +++ b/frontend/src/lib/server/services/completions.ts @@ -45,9 +45,19 @@ 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; 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(); + } // Chore existence is checked as superuser so a stale client ID (parent // re-saved the chores grid → old assigned rows deleted, new ids issued) @@ -113,7 +123,7 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a memberId, assignedChoreId, date, - completedAt: new Date().toISOString(), + completedAt, ...(rewardId ? { rewardId } : {}) }); // Claim a shared one-off todo by setting memberId (recurring shared diff --git a/frontend/src/lib/server/services/index.ts b/frontend/src/lib/server/services/index.ts index 2596963..9c68a0d 100644 --- a/frontend/src/lib/server/services/index.ts +++ b/frontend/src/lib/server/services/index.ts @@ -56,7 +56,7 @@ export function createServices( cleanupExpiredTodos: (famId: string) => choresSvc.cleanupExpiredTodos(pb, famId) }, 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), revoke: (famId: string, completionId: string) => completionsSvc.revoke(pb, famId, completionId) diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index bcd928d..2177664 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -84,6 +84,18 @@ (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 { const a = parentAssigned.find((x: any) => x.id === assignedChoreId); @@ -309,6 +321,37 @@ let weekEnd = $derived(addDays(weekStart, 6)); 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( Math.max( 0, @@ -379,10 +422,10 @@ }); 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( - 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) @@ -400,7 +443,7 @@ return 'blue'; } 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(() => { @@ -799,7 +842,7 @@ 'December' ]; function headDate(): Date { - return new Date(todayInTz(famTz) + 'T00:00:00'); + return new Date(activeDate + 'T00:00:00'); } function headDay(): number { return headDate().getDate(); @@ -873,7 +916,7 @@ ? c.assignedChoreId === chore.id && (c.date?.slice(0, 10) || c.date) >= weekStart && (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-')); return optimistic || completions.find(match); } @@ -888,7 +931,14 @@ if (togglingIds.has(chore.id)) return; 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) { const existing = findCompletion(chore); if (existing) { @@ -902,15 +952,15 @@ famId, memberId, assignedChoreId: chore.id, - date: todayChild, - completedAt: new Date().toISOString() + date: stampDate, + completedAt: stampAt } as Completion, 'create' ); } 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); if (optimistic) { famStore.applyRecord('completions', optimistic, 'delete'); @@ -1013,6 +1063,7 @@ {#each parentMembers as m} {@const todays = todayCompletionsFor(m.id)} + {@const yesterdays = yesterdayCompletionsFor(m.id)} {@const done = todays.length} {@const s = memberInSummary(m.id)} {@const pm = parentBonusConfigs.find( @@ -1067,6 +1118,31 @@ {/each} {/if} + {#if yesterdays.length > 0} + + + {/if} {/each} @@ -1452,6 +1528,15 @@ {:else} {/if} + {#if inYesterday} + + {:else if yesterdayAvailable} + + {/if}