diff --git a/MEMORY.md b/MEMORY.md
index 146ff0a..6d56102 100644
--- a/MEMORY.md
+++ b/MEMORY.md
@@ -10,7 +10,7 @@
├── [fam]/+page.svelte ← fam dashboard
├── [fam]/{username}/+page.svelte ← parent=admin overview, child=kanban
├── [fam]/{username}/chores/+page.svelte ← parent only
- ├── [fam]/{username}/rewards/+page.svelte ← parent only
+ ├── [fam]/{username}/ledger/+page.svelte ← parent only (rewards/chores/todos)
├── [fam]/{username}/bonuses/+page.svelte ← parent only
├── [fam]/{username}/settings/+page.svelte ← parent only
└── [fam]/{username}/preferences/+page.svelte ← both roles
@@ -63,7 +63,7 @@
/{fam} Fam dashboard
/{fam}/{username} Parent → admin overview, Child → kanban
/{fam}/{username}/chores Parent: chore management
-/{fam}/{username}/rewards Parent: reward ledger
+/{fam}/{username}/ledger Parent: rewards / chores / todos ledger
/{fam}/{username}/bonuses Parent: bonus configs
/{fam}/{username}/settings Parent: family settings
/{fam}/{username}/preferences Both: edit name/color
diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte
index 2898d1f..fba39d3 100644
--- a/frontend/src/lib/components/Sidebar.svelte
+++ b/frontend/src/lib/components/Sidebar.svelte
@@ -1,6 +1,15 @@
@@ -68,7 +88,8 @@
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index 0b67a43..ee67950 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -1,20 +1,34 @@
-// Format a YYYY-MM-DD (or ISO) string as DDMMYY for user-facing dates.
+// Two date display views used across the app:
+// - Data view: `08-08-26` (dashed DD-MM-YY) — compact, tabular-friendly.
+// - Human view: weekday in a badge (`Thursday`) with month/year added per requirement.
+
+// Format a YYYY-MM-DD (or ISO) string as a compact dashed data-view date.
export function formatDDMMYY(dateStr: string | undefined): string {
if (!dateStr) return '';
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
+ if (Number.isNaN(d.getTime())) return '';
const day = String(d.getDate()).padStart(2, '0');
const mon = String(d.getMonth() + 1).padStart(2, '0');
const yr = String(d.getFullYear()).slice(2);
- return `${day}${mon}${yr}`;
+ return `${day}-${mon}-${yr}`;
}
-// Human-friendly date for due dates etc: "5 Aug" (adds year when not the
-// current one, e.g. "5 Aug 26"). Better than the compact DDMMYY code.
-export function formatShortDate(dateStr: string | undefined): string {
+// Human view — the weekday name ("Thursday"). Render this inside a badge.
+export function formatWeekday(dateStr: string | undefined): string {
if (!dateStr) return '';
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
if (Number.isNaN(d.getTime())) return '';
+ return d.toLocaleDateString('en-GB', { weekday: 'long' });
+}
+
+// Human view — weekday + short date ("Thursday 7 Aug", adds year when not the
+// current one, e.g. "Thursday 7 Aug 26").
+export function formatHumanDate(dateStr: string | undefined): string {
+ if (!dateStr) return '';
+ const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
+ if (Number.isNaN(d.getTime())) return '';
+ const weekday = d.toLocaleDateString('en-GB', { weekday: 'long' });
const mon = d.toLocaleDateString('en-GB', { month: 'short' });
const sameYear = d.getFullYear() === new Date().getFullYear();
- return `${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`;
+ return `${weekday} ${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`;
}
diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte
index c633002..f1b6fac 100644
--- a/frontend/src/routes/[fam]/[username]/+page.svelte
+++ b/frontend/src/routes/[fam]/[username]/+page.svelte
@@ -4,7 +4,7 @@
import { onMount } from 'svelte';
import { famStore } from '$lib/stores/fam.svelte';
import { memberApi } from '$lib/client/api';
- import { formatDDMMYY, formatShortDate } from '$lib/format';
+ import { formatDDMMYY, formatHumanDate } from '$lib/format';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
import {
@@ -15,7 +15,8 @@
wallClockToUtc,
resolveTz,
periodStart,
- periodEnd
+ periodEnd,
+ isCompleteForPeriod
} from '../../../../../timezone.ts';
interface Notification {
@@ -254,7 +255,8 @@
Math.max(
0,
Math.round(
- (new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() - new Date(todayIso + 'T00:00:00').getTime()) /
+ (new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() -
+ new Date(todayIso + 'T00:00:00').getTime()) /
86400000
)
)
@@ -541,14 +543,34 @@
}
function isCompleted(assignedChoreId: string, date: string): boolean {
+ const a = assigned.find((x) => x.id === assignedChoreId);
+ // Weekly chores are "done for the week" — complete if completed any day
+ // in the current week, not just today.
+ if (a?.frequency === 'weekly') {
+ const dates = completions
+ .filter((c) => c.assignedChoreId === assignedChoreId)
+ .map((c) => c.date);
+ return isCompleteForPeriod('weekly', paydayDay, famTz, dates);
+ }
return completions.some(
(c) => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date
);
}
- function findCompletion(assignedChoreId: string) {
+ // Todos are one-off: done once the completion row exists, regardless of date.
+ function isTodoDone(todoId: string): boolean {
+ return completions.some((c) => c.assignedChoreId === todoId);
+ }
+
+ function findCompletion(chore: AssignedChore) {
const match = (c: Completion) =>
- c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === todayChild;
+ chore.isTodo
+ ? c.assignedChoreId === chore.id
+ : chore.frequency === 'weekly'
+ ? 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;
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
return optimistic || completions.find(match);
}
@@ -557,9 +579,9 @@
if (togglingIds) return;
togglingIds = chore.id;
- const wasCompleted = isCompleted(chore.id, todayChild);
+ const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, todayChild);
if (wasCompleted) {
- const existing = findCompletion(chore.id);
+ const existing = findCompletion(chore);
if (existing) {
famStore.applyRecord('completions', existing, 'delete');
}
@@ -653,225 +675,228 @@
-
(showCreateModal = true)}>+ New template
@@ -369,53 +370,106 @@
ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, m.id)}
>
-
-
- {m.name}
-
+
+
+ {m.name}
+
-
-
-
toggleAccordion(m.id, 'todos')}>
- 📋 Todos ({todosForMember(m.id).length})
- {@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}
-
- {#if accordionState[m.id]?.todos ?? true}
-
- {#each sortedTodosForMember(m.id) as a}
- {@const completed = isTodoCompleted(a.id)}
- {@const urgency = todoUrgency(a)}
-
!completed && openEdit(a)}
- role="button"
- tabindex={completed ? -1 : 0}
+
+
+
toggleAccordion(m.id, 'todos')}>
+ 📋 Todos ({todosForMember(m.id).length})
+ {@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}
+
+ {#if accordionState[m.id]?.todos ?? true}
+
+ {#each sortedTodosForMember(m.id) as a}
+ {@const completed = isTodoCompleted(a.id)}
+ {@const urgency = todoUrgency(a)}
+
!completed && openEdit(a)}
+ role="button"
+ tabindex={completed ? -1 : 0}
+ >
+
+
{a.customName || 'Todo'}
+
+ {#if a.type === 'emoji'}
+ 🎯 emoji
+ {:else}
+ {a.value} pts
+ {/if}
+ {#if a.completeBy}
+ due {formatHumanDate(a.completeBy)}
+ {/if}
+
+
+ {#if completed}
+
✅ TBC completed
+ {:else}
+
{
+ e.stopPropagation();
+ const s = page.data.session as any;
+ if (!s) return;
+ await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
+ method: 'DELETE',
+ headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId }
+ });
+ }}>×
+ {/if}
+
+ {/each}
+
openTodo(m.id)}
+ >+ Add a todo
-
-
{a.customName || 'Todo'}
-
- {#if a.type === 'emoji'}
- 🎯 emoji
- {:else}
- {a.value} pts
- {/if}
- {#if a.completeBy}
- due {formatShortDate(a.completeBy)}
+
+ {/if}
+
+
+
+
+
toggleAccordion(m.id, 'chores')}>
+ Chores ({assignedForMember(m.id).length})
+ {@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}
+
+ {#if accordionState[m.id]?.chores ?? true}
+
+ {#each assignedForMember(m.id) as a}
+ {@const tName = templateName(a.templateId)}
+
openEdit(a)} role="button" tabindex="0">
+
+ {a.customName || tName}
+ {a.frequency}
+ {a.type}
+ {#if seasonFilter !== 'all' && isGlobalChore(a)}
+ Add to
{/if}
-
- {#if completed}
-
✅ TBC completed
- {:else}
+
+ {a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : `${a.value} pts`}
+
{
e.stopPropagation();
const s = page.data.session as any;
@@ -426,57 +480,14 @@
});
}}>×
- {/if}
-
- {/each}
-
openTodo(m.id)}>+ Add a todo
-
- {/if}
-
-
-
-
-
toggleAccordion(m.id, 'chores')}>
- Chores ({assignedForMember(m.id).length})
- {@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}
-
- {#if accordionState[m.id]?.chores ?? true}
-
- {#each assignedForMember(m.id) as a}
- {@const tName = templateName(a.templateId)}
-
openEdit(a)} role="button" tabindex="0">
-
- {a.customName || tName}
- {a.frequency}
- {a.type}
- {#if seasonFilter !== 'all' && isGlobalChore(a)}
- Add to
- {/if}
-
- {a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : `${a.value} pts`}
-
-
{
- e.stopPropagation();
- const s = page.data.session as any;
- if (!s) return;
- await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
- method: 'DELETE',
- headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId }
- });
- }}>×
-
- {/each}
- {#if assignedForMember(m.id).length === 0}
-
Drop a chore here
- {/if}
-
- {/if}
-
+ {/each}
+ {#if assignedForMember(m.id).length === 0}
+
Drop a chore here
+ {/if}
+
+ {/if}
+
{/each}
{#if members.length < 3}
@@ -819,7 +830,9 @@
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
- transition: background 0.15s, border-color 0.15s;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
text-align: center;
}
.template-section > .add-inline {
@@ -828,13 +841,13 @@
.accordion-add {
margin-bottom: 0;
margin-top: 0.35rem;
- background: rgba(255,255,255,0.25);
- border-color: rgba(255,255,255,0.4);
+ background: rgba(255, 255, 255, 0.25);
+ border-color: rgba(255, 255, 255, 0.4);
color: #fff;
}
.accordion-add:hover {
- background: rgba(255,255,255,0.35);
- border-color: rgba(255,255,255,0.6);
+ background: rgba(255, 255, 255, 0.35);
+ border-color: rgba(255, 255, 255, 0.6);
color: #fff;
}
.add-inline:hover {
@@ -852,7 +865,10 @@
align-items: center;
gap: 0.5rem;
position: relative;
- transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
+ transition:
+ transform 0.15s,
+ box-shadow 0.15s,
+ border-color 0.15s;
}
.card:hover {
transform: translateY(-1px);
@@ -905,7 +921,9 @@
line-height: 1;
flex-shrink: 0;
border-radius: 4px;
- transition: color 0.15s, background 0.15s;
+ transition:
+ color 0.15s,
+ background 0.15s;
}
.edit-btn:hover {
color: #6366f1;
@@ -1056,7 +1074,7 @@
width: 100%;
padding: 0.55rem 0.85rem;
border: none;
- border-left: 4px solid rgba(0,0,0,0.2);
+ border-left: 4px solid rgba(0, 0, 0, 0.2);
background: transparent;
cursor: pointer;
font-size: 0.82rem;
@@ -1072,7 +1090,7 @@
}
.accordion-body {
padding: 0.5rem 0.65rem 0.65rem;
- background: rgba(0,0,0,0.08);
+ background: rgba(0, 0, 0, 0.08);
}
.accordion-chevron {
display: flex;
@@ -1093,7 +1111,10 @@
display: flex;
align-items: center;
gap: 0.4rem;
- transition: transform 0.15s, box-shadow 0.15s, opacity 0.2s;
+ transition:
+ transform 0.15s,
+ box-shadow 0.15s,
+ opacity 0.2s;
}
.todo-admin-card:hover {
transform: translateY(-1px);
@@ -1185,8 +1206,8 @@
45deg,
transparent,
transparent 8px,
- rgba(0,0,0,0.02) 8px,
- rgba(0,0,0,0.02) 16px
+ rgba(0, 0, 0, 0.02) 8px,
+ rgba(0, 0, 0, 0.02) 16px
);
border-style: dashed;
opacity: 0.5;
diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts b/frontend/src/routes/[fam]/[username]/ledger/+page.server.ts
similarity index 72%
rename from frontend/src/routes/[fam]/[username]/rewards/+page.server.ts
rename to frontend/src/routes/[fam]/[username]/ledger/+page.server.ts
index 9939d46..f4da3a0 100644
--- a/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts
+++ b/frontend/src/routes/[fam]/[username]/ledger/+page.server.ts
@@ -4,11 +4,14 @@ import { hono } from '$lib/server/hono';
export async function load(event) {
if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId;
- const [rewards, members] = await Promise.all([
+ const [rewards, members, assigned, templates, completions] = await Promise.all([
hono.admin.rewards(event, famId),
hono.admin.list(event, 'members', famId),
+ hono.admin.list(event, 'assigned-chores', famId),
+ hono.admin.list(event, 'chore-templates', famId),
+ hono.admin.completions(event, famId)
]);
- return { rewards, members };
+ return { rewards, members, assigned, templates, completions };
}
export const actions = {
@@ -23,5 +26,5 @@ export const actions = {
} catch (e) {
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
}
- },
+ }
};
diff --git a/frontend/src/routes/[fam]/[username]/ledger/+page.svelte b/frontend/src/routes/[fam]/[username]/ledger/+page.svelte
new file mode 100644
index 0000000..aef2e74
--- /dev/null
+++ b/frontend/src/routes/[fam]/[username]/ledger/+page.svelte
@@ -0,0 +1,359 @@
+
+
+
(activeTab = v)
+ }}
+/>
+
+{#if toast}
+ {toast}
+{/if}
+
+
+ {#if activeTab === 'rewards'}
+
+ {#if rewards.length === 0}
+
+ No rewards yet. Points earned will automatically create bonus rewards when thresholds are
+ met.
+
+ {:else}
+
+
+
+ Date
+ Member
+ Description
+ Amount
+ Status
+
+
+
+
+ {#each sortedRewards as r}
+ {@const outstanding = isOutstanding(r)}
+
+ {r.date ? formatDDMMYY(r.date) : '—'}
+
+
+ {memberName(r.memberId)}
+
+ {r.label}
+ {rewardAmount(r)}
+
+
+ {rewardStatus(r)}
+
+
+
+ {#if outstanding}
+ {
+ return async (args: any) => {
+ const d = args.result.data || {};
+ if (d.error) showToast(d.error);
+ else if (args.result.type === 'success') fire();
+ };
+ }}
+ >
+
+ Claim
+
+ {/if}
+
+
+ {/each}
+
+
+
+
+
+ Outstanding cash
+ £{totalOutstanding.toFixed(2)}
+
+
+
+
+
+ {/if}
+
+ {:else if activeTab === 'chores'}
+
+ {#if sortedChoreCompletions.length === 0}
+ No chore completions yet.
+ {:else}
+
+
+
+ Date
+ Member
+ Chore
+ Value
+
+
+
+ {#each sortedChoreCompletions as c}
+ {@const a = assignedMap.get(c.assignedChoreId)}
+
+ {formatDDMMYY(c.date)}
+
+
+ {memberName(c.memberId)}
+
+ {choreName(a)}
+
+ {#if a?.type === 'money'}£{Number(a.value).toFixed(2)}
+ {:else}{a?.value ?? 0} pts{/if}
+
+
+ {/each}
+
+
+ {/if}
+
+ {:else}
+
+ {#if sortedTodoCompletions.length === 0}
+ No todos completed yet.
+ {:else}
+
+
+
+ Completed
+ Member
+ Todo
+ Due
+
+
+
+ {#each sortedTodoCompletions as c}
+ {@const a = assignedMap.get(c.assignedChoreId)}
+
+ {formatDDMMYY(c.date)}
+
+
+ {memberName(c.memberId)}
+
+ {choreName(a)}
+ {a?.completeBy ? formatDDMMYY(a.completeBy) : '—'}
+
+ {/each}
+
+
+ {/if}
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte
deleted file mode 100644
index 5f99ddd..0000000
--- a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte
+++ /dev/null
@@ -1,141 +0,0 @@
-
-
-
-
-{#if toast}
- {toast}
-{/if}
-
-
-
- {#if rewards.length === 0}
- No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.
- {:else}
-
-
-
- Date
- Member
- Description
- Amount
- Status
-
-
-
-
- {#each sorted as r}
- {@const outstanding = isOutstanding(r)}
-
- {r.date?.slice(0, 10) || '—'}
-
-
- {memberName(r.memberId)}
-
- {r.label}
- {rewardAmount(r)}
-
-
- {rewardStatus(r)}
-
-
-
- {#if outstanding}
- { return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { fire(); } }; }}>
-
- Claim
-
- {/if}
-
-
- {/each}
-
-
-
-
-
- Outstanding cash
- £{totalOutstanding.toFixed(2)}
-
-
-
-
-
- {/if}
-
-
-
-
diff --git a/package.json b/package.json
index 160d4b8..4f0975d 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,8 @@
"name": "famchamp-monorepo",
"private": true,
"scripts": {
- "dev": "pnpm -r --parallel dev",
+ "dev": "lsof -ti tcp:3456 | xargs -r kill -9 && pnpm -r --parallel dev",
+ "start": "pnpm dev",
"build": "pnpm -r build"
},
"version": "0.2.0"
diff --git a/proxy/scripts/seed.ts b/proxy/scripts/seed.ts
index f87da52..4868cd5 100644
--- a/proxy/scripts/seed.ts
+++ b/proxy/scripts/seed.ts
@@ -345,6 +345,7 @@ async function main() {
rel("memberId", ids.members!, true),
rel("assignedChoreId", ids.assigned_chores!, true),
date("date"),
+ date("completedAt"),
],
});
diff --git a/proxy/src/index.ts b/proxy/src/index.ts
index 72159be..b363431 100644
--- a/proxy/src/index.ts
+++ b/proxy/src/index.ts
@@ -10,6 +10,7 @@ import {
wallClockToUtc,
resolveTz,
nextPaydayAfter as nextPaydayAfterTz,
+ periodWindow,
} from "../../timezone.ts";
const app = new Hono();
@@ -613,7 +614,11 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
payday: record.payday,
});
}
- if (body.payday !== undefined || body.paydayTime !== undefined || body.timezone !== undefined) {
+ if (
+ body.payday !== undefined ||
+ body.paydayTime !== undefined ||
+ body.timezone !== undefined
+ ) {
const patch: Record = {};
if (body.payday !== undefined) {
const payday = Number(body.payday);
@@ -629,8 +634,14 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
}
if (body.timezone !== undefined) {
const timezone = String(body.timezone);
- if (timezone !== "auto" && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone))
- return c.json({ error: "timezone must be an IANA name or 'auto'" }, 400);
+ if (
+ timezone !== "auto" &&
+ !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)
+ )
+ return c.json(
+ { error: "timezone must be an IANA name or 'auto'" },
+ 400,
+ );
patch.timezone = timezone;
}
const record = await pb.update("fams", famId, patch);
@@ -782,9 +793,7 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
async function getFamSettings(famId: string): Promise {
try {
return (
- (
- await pb.getList("settings", `famId = '${famId}'`)
- ).items?.[0] || {}
+ (await pb.getList("settings", `famId = '${famId}'`)).items?.[0] || {}
);
} catch {
return {};
@@ -818,7 +827,10 @@ app.patch("/api/admin/:famId/settings", requireAdmin, async (c) => {
} else if (Object.keys(patch).length) {
s = await pb.update("settings", s.id, patch);
}
- return c.json({ simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || "" });
+ return c.json({
+ simulateEow: !!s.simulateEow,
+ webhookUrl: s.webhookUrl || "",
+ });
} catch (err) {
return handleError(c, err);
}
@@ -842,7 +854,9 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
pb
.getList("bonus_configs", `famId = '${famId}' && status = 'active'`)
.catch(() => ({ items: [] })),
- pb.getList("rewards", `famId = '${famId}'`).catch(() => ({ items: [] })),
+ pb
+ .getList("rewards", `famId = '${famId}'`)
+ .catch(() => ({ items: [] })),
]);
let rewardPointsList: any[] = [];
@@ -914,7 +928,9 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
let current = 0;
if (cfg.type === "threshold")
current = sourceComps.reduce((sum: number, c: any) => {
- const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
+ const ch = assignedList.find(
+ (a: any) => a.id === c.assignedChoreId,
+ );
return sum + (ch?.type === "points" ? Number(ch.value) : 0);
}, 0);
else if (cfg.type === "count") current = sourceComps.length;
@@ -927,7 +943,10 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
: members.items;
for (const m of targets) {
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
- const current = tryEval(m, periodCompletions.filter((c: any) => c.memberId === m.id));
+ const current = tryEval(
+ m,
+ periodCompletions.filter((c: any) => c.memberId === m.id),
+ );
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
predictedRewards.push({
config: cfg.name,
@@ -969,9 +988,7 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
const eligible = qualified.length
? qualified
: scored.filter((st) => st.current > 0);
- const winner = eligible.sort(
- (aa, bb) => bb.current - aa.current,
- )[0];
+ const winner = eligible.sort((aa, bb) => bb.current - aa.current)[0];
if (winner)
predictedRewards.push({
config: cfg.name,
@@ -1299,7 +1316,9 @@ async function evaluateFam(famId: string): Promise {
const tzEval = await getFamTimezone(famId);
for (const cfg of configs) {
- const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : "";
+ const pStart2 = cfg.period
+ ? periodStart(cfg.period, paydayEval, tzEval)
+ : "";
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : "";
const periodCompletions = cfg.period
? allCompletions.items.filter(
@@ -1397,7 +1416,9 @@ async function evaluateFam(famId: string): Promise {
if (!achieved && existingRewards.length > 0) {
for (const r of existingRewards) {
if (r.status !== "claimed") {
- try { await pb.delete("rewards", r.id); } catch {}
+ try {
+ await pb.delete("rewards", r.id);
+ } catch {}
}
}
continue;
@@ -1454,11 +1475,11 @@ async function evaluateFam(famId: string): Promise {
if (existingRewards.length > 0) {
const existing = existingRewards[0];
const stillValid =
- winner &&
- existing.memberId === winner.memberId &&
- winner.current > 0;
+ winner && existing.memberId === winner.memberId && winner.current > 0;
if (!stillValid && existing.status !== "claimed") {
- try { await pb.delete("rewards", existing.id); } catch {}
+ try {
+ await pb.delete("rewards", existing.id);
+ } catch {}
}
}
@@ -1653,13 +1674,26 @@ app.post("/api/completions/toggle", requireDeviceToken, async (c) => {
if (!assignedChoreId || !date) {
return c.json({ error: "assignedChoreId and date required" }, 400);
}
- const nextDay = new Date(new Date(date + "T00:00:00Z").getTime() + 86400000)
- .toISOString()
- .slice(0, 10);
- const existing = await pb.getList(
- "completions",
- `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${date}' && date < '${nextDay}'`,
- );
+ // Todos are one-off: any existing completion means it's done, regardless of date.
+ const chore = await pb
+ .getList(
+ "assigned_chores",
+ `famId = '${famId}' && id = '${assignedChoreId}'`,
+ )
+ .then((r) => r.items?.[0]);
+ const isTodo = chore?.isTodo;
+ let filter: string;
+ if (isTodo) {
+ filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
+ } else {
+ // Scope to the chore's period: daily = today, weekly = the current week.
+ // Otherwise a weekly chore completed yesterday would be toggleable again today.
+ const payday = await getFamPayday(famId);
+ const tz = await getFamTimezone(famId);
+ const { from, to } = periodWindow(chore?.frequency, payday, tz);
+ filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`;
+ }
+ const existing = await pb.getList("completions", filter);
if (existing.items?.length > 0) {
await pb.delete("completions", existing.items[0].id);
evaluateFam(famId).catch(() => {});
@@ -1670,6 +1704,7 @@ app.post("/api/completions/toggle", requireDeviceToken, async (c) => {
memberId,
assignedChoreId,
date,
+ completedAt: new Date().toISOString(),
});
evaluateFam(famId).catch(() => {});
return c.json({ completed: true, record });
@@ -1971,7 +2006,10 @@ app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
const memberId = c.get("memberId");
const now = new Date().toISOString();
const tz = await getFamTimezone(famId);
- const found = await pb.getList("rewards", `famId = '${famId}' && id = '${id}'`);
+ const found = await pb.getList(
+ "rewards",
+ `famId = '${famId}' && id = '${id}'`,
+ );
const reward = found.items?.[0];
if (!reward) return c.json({ error: "Reward not found" }, 404);
try {
@@ -2097,7 +2135,12 @@ async function releaseWeek(famId: string) {
const paydayTime = fam.paydayTime || "18:00";
const target = new Date(wallClockToUtc(wsToday, paydayTime, tz));
if (Date.now() < target.getTime()) {
- return { settled: false, notYet: true, weekStart: wsToday, target: target.toISOString() };
+ return {
+ settled: false,
+ notYet: true,
+ weekStart: wsToday,
+ target: target.toISOString(),
+ };
}
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
@@ -2119,7 +2162,10 @@ async function releaseWeek(famId: string) {
const unpaid = (cashRewards.items || []).filter(
(r: any) => r.memberId === m.id && r.status !== "claimed",
);
- const total = unpaid.reduce((sum: number, r: any) => sum + Number(r.value), 0);
+ const total = unpaid.reduce(
+ (sum: number, r: any) => sum + Number(r.value),
+ 0,
+ );
if (total > 0) {
// Auto-claim: flip every unpaid cash reward to 'requested' so they land on
// the parent's Issue list, but keep them as individual rows (Issue All totals).
@@ -2145,7 +2191,11 @@ async function releaseWeek(famId: string) {
memberId: m.id,
name: m.name,
total,
- rewards: unpaid.map((r: any) => ({ id: r.id, label: r.label, value: Number(r.value) })),
+ rewards: unpaid.map((r: any) => ({
+ id: r.id,
+ label: r.label,
+ value: Number(r.value),
+ })),
});
}
}
diff --git a/proxy/src/migrate.ts b/proxy/src/migrate.ts
index f599aee..c8e2f5f 100644
--- a/proxy/src/migrate.ts
+++ b/proxy/src/migrate.ts
@@ -1377,5 +1377,35 @@ export async function migrate(): Promise {
console.log(` ↳ assigned_chores collection not found (will be created by seed)`);
}
+ // ── 8. Add completedAt timestamp to completions ──
+ const complCol = await getCollection("completions");
+ if (complCol) {
+ const hasCompletedAt = complCol.fields.some((f: any) => f.name === "completedAt");
+ if (!hasCompletedAt) {
+ console.log("[migrate] Adding completions.completedAt...");
+ complCol.fields.push({
+ name: "completedAt",
+ type: "date",
+ required: false,
+ hidden: false,
+ });
+ await updateCollection(complCol.id, {
+ name: "completions",
+ type: "base",
+ listRule: complCol.listRule,
+ viewRule: complCol.viewRule,
+ createRule: complCol.createRule,
+ updateRule: complCol.updateRule,
+ deleteRule: complCol.deleteRule,
+ fields: complCol.fields,
+ });
+ console.log(" ✓ completions.completedAt added");
+ } else {
+ console.log(` ↳ completions.completedAt already exists`);
+ }
+ } else {
+ console.log(` ↳ completions collection not found (will be created by seed)`);
+ }
+
console.log("[migrate] Done");
}
diff --git a/timezone.ts b/timezone.ts
index d4793db..d8084d8 100644
--- a/timezone.ts
+++ b/timezone.ts
@@ -53,13 +53,17 @@ export function addDaysStr(dateStr: string, days: number): string {
export function weekStart(payday: number, tz: string): string {
const today = todayInTz(tz);
const wd = weekdayInTz(new Date(), tz);
- const back = ((wd - payday) % 7 + 7) % 7;
+ const back = (((wd - payday) % 7) + 7) % 7;
return addDaysStr(today, -back);
}
// The first configured-payday day strictly after dateStr (used to gate
// payday-settled bonus rewards). Weekly: periodEnd (weekStart+6) → next payday.
-export function nextPaydayAfter(dateStr: string, payday: number, tz: string): string {
+export function nextPaydayAfter(
+ dateStr: string,
+ payday: number,
+ tz: string,
+): string {
let d = addDaysStr(dateStr, 1);
while (weekdayInTz(new Date(d + "T12:00:00Z"), tz) !== payday) {
d = addDaysStr(d, 1);
@@ -82,7 +86,11 @@ function monthEndStr(month?: string): string {
return `${month}-${String(lastDay).padStart(2, "0")}`;
}
-export function periodStart(period: string, payday: number, tz: string): string {
+export function periodStart(
+ period: string,
+ payday: number,
+ tz: string,
+): string {
if (period === "daily") return todayInTz(tz);
if (period === "weekly") return weekStart(payday, tz);
if (period === "monthly") return monthStartStr();
@@ -96,7 +104,42 @@ export function periodEnd(period: string, start: string): string {
return start;
}
-export function wallClockToUtc(dateStr: string, time: string, tz: string): number {
+// The completion window a chore of a given frequency is "done for" in the
+// current period. Weekly chores are done once per week ([weekStart, +7)),
+// daily chores once per day ([today, +1)). Half-open [from, to).
+export function periodWindow(
+ frequency: string,
+ payday: number,
+ tz: string,
+): { from: string; to: string } {
+ if (frequency === "weekly") {
+ const from = weekStart(payday, tz);
+ return { from, to: addDaysStr(from, 7) };
+ }
+ const from = todayInTz(tz);
+ return { from, to: addDaysStr(from, 1) };
+}
+
+// True if any completion date falls within the current period window for the
+// chore's frequency. Dates may be YYYY-MM-DD or ISO (time portion ignored).
+export function isCompleteForPeriod(
+ frequency: string,
+ payday: number,
+ tz: string,
+ dates: string[],
+): boolean {
+ const { from, to } = periodWindow(frequency, payday, tz);
+ return dates.some((d) => {
+ const day = (d || "").slice(0, 10);
+ return day >= from && day < to;
+ });
+}
+
+export function wallClockToUtc(
+ dateStr: string,
+ time: string,
+ tz: string,
+): number {
const [y, m, d] = dateStr.split("-").map(Number);
const [h, min] = time.split(":").map(Number);
let epoch = Date.UTC(y, m - 1, d, h, min);