Compare commits

...

2 Commits

Author SHA1 Message Date
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
5 changed files with 171 additions and 16 deletions
+2 -2
View File
@@ -20,8 +20,8 @@ async function memberFetch<T = unknown>(
}
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`);
@@ -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
+1 -1
View File
@@ -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)
+155 -10
View File
@@ -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 @@
<Card cols={1} title="Members">
{#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}
</ul>
{/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>
{/each}
</Card>
@@ -1452,6 +1528,15 @@
{:else}
<button class="hero-now" onclick={jumpToNow}><span>← Back to this week</span></button>
{/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>
<nav class="hero-nav">
@@ -1578,6 +1663,12 @@
{/each}
</div>
{/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="column col-daily">
<h2>{@html sunIcon} Daily ({dailyPending.length})</h2>
@@ -1683,7 +1774,15 @@
{#each completedToday as c}
{@const chore = assigned.find((a) => a.id === c.assignedChoreId)}
{#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="chore-name">{choreName(chore)}</span>
{#if chore.type === 'emoji'}
@@ -2242,6 +2341,52 @@
.hero-now:hover span {
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 {
display: flex;
align-items: center;
+1 -1
View File
@@ -13,5 +13,5 @@
"esbuild"
]
},
"version": "1.11.4"
"version": "1.11.5"
}