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