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 @@
- - - {#each parentMembers as m} - {@const total = totalChoresFor(m.id)} - {@const todays = todayCompletionsFor(m.id)} - {@const done = todays.length} - {@const pct = total > 0 ? Math.round((done / total) * 100) : 0} - {@const s = memberInSummary(m.id)} -
-
- - {m.name} - Kanban -
-
- Points: {s?.pointsEarned ?? 0} - Money: £{(s?.moneyEarned ?? 0).toFixed(2)} -
-
- Today: -
-
+ + + {#each parentMembers as m} + {@const total = totalChoresFor(m.id)} + {@const todays = todayCompletionsFor(m.id)} + {@const done = todays.length} + {@const pct = total > 0 ? Math.round((done / total) * 100) : 0} + {@const s = memberInSummary(m.id)} +
+
+ + {m.name} + Kanban +
+
+ Points: {s?.pointsEarned ?? 0} + Money: £{(s?.moneyEarned ?? 0).toFixed(2)} +
+
+ Today: +
+
+
+ {done}/{total} +
+ {#if done > 0} +
    + {#each todays as c} +
  • + ✅ {choreNameFor(c.assignedChoreId)} +
    { + return async (args) => handleResult(args); + }} + class="revoke-form" + > + + +
    +
  • + {/each} +
+ {/if}
- {done}/{total} -
- {#if done > 0} -
    - {#each todays as c} -
  • - ✅ {choreNameFor(c.assignedChoreId)} + {/each} + + + + {#if manualConfigs().length === 0} +

    No manual bonus configs.

    + {:else} + {#each manualConfigs() as bc} + {@const val = + bc.rewardType === 'cash' + ? `£${Number(bc.rewardValue).toFixed(2)}` + : bc.rewardType === 'points' + ? `${bc.rewardValue} pts` + : bc.rewardValue} + {@const targeted = bc.memberId + ? parentMembers.find((m: any) => m.id === bc.memberId) + : null} +
    +
    + {bc.name} + {val} +
    + {#if targeted}
    { - return async (args) => handleResult(args); - }} - class="revoke-form" - > - - -
    -
  • - {/each} -
- {/if} -
- {/each} - - - - {#if manualConfigs().length === 0} -

No manual bonus configs.

- {:else} - {#each manualConfigs() as bc} - {@const val = - bc.rewardType === 'cash' - ? `£${Number(bc.rewardValue).toFixed(2)}` - : bc.rewardType === 'points' - ? `${bc.rewardValue} pts` - : bc.rewardValue} - {@const targeted = bc.memberId - ? parentMembers.find((m: any) => m.id === bc.memberId) - : null} -
-
- {bc.name} - {val} -
- {#if targeted} -
{ - return async (args: any) => handleResult(args); - }} - > - - -
- ■ {targeted.name} - -
-
- {:else} -
{ - return async (args: any) => handleResult(args); - }} - > - -
- - -
-
- {/if} -
- {/each} - {/if} -
- - - {#if claimableRewards().length === 0} -

No outstanding claims

- {:else} - {#each parentMembers as m} - {@const requested = memberRequested(m.id)} - {@const outstanding = memberOutstanding(m.id)} - {@const claimable = memberClaimable(m.id)} - {@const cashClaimable = claimable.filter((r: any) => r.rewardType === 'cash')} - {@const cashRequested = requested.filter((r: any) => r.rewardType === 'cash')} - {@const requestedTotal = cashRequested.reduce( - (sum: number, r: any) => sum + Number(r.value), - 0 - )} - {@const total = cashClaimable.reduce((sum: number, r: any) => sum + Number(r.value), 0)} - {@const hasClaims = requested.length > 0 || outstanding.length > 0} - {#if hasClaims} -
-

{m.name}

- {#if total > 0} -

£{total.toFixed(2)} owed

- {/if} - - - {#if cashRequested.length > 0} -
{ - return async (args: any) => handleResult(args); - }} - > - - - -
- {/if} - - {#if requested.length > 0} - - {#each requested as r} -
{ return async (args: any) => handleResult(args); }} > - - -
- {r.label} -
- {rewardLabel(r)} - -
+ + +
+ ■ {targeted.name} +
- {/each} - {/if} - - {#if outstanding.length > 0} - - {#each outstanding as r} -
- {r.label} - - {rewardLabel(r)} - {#if paydayLocked(r)} - 🔒 {formatShortDate(r.settleDate)} - {/if} - -
- {/each} - {/if} - -
-

Response:

-
- {#each responseTags as tag} - - {/each} -
- { - if (customMessage[m.id]) selectedMessage[m.id] = customMessage[m.id]; - }} - /> + {:else} +
{ + return async (args: any) => handleResult(args); + }} + > + +
+ + +
+
+ {/if}
-
+ {/each} {/if} - {/each} - {/if} - - + + + + {#if claimableRewards().length === 0} +

No outstanding claims

+ {:else} + {#each parentMembers as m} + {@const requested = memberRequested(m.id)} + {@const outstanding = memberOutstanding(m.id)} + {@const claimable = memberClaimable(m.id)} + {@const cashClaimable = claimable.filter((r: any) => r.rewardType === 'cash')} + {@const cashRequested = requested.filter((r: any) => r.rewardType === 'cash')} + {@const requestedTotal = cashRequested.reduce( + (sum: number, r: any) => sum + Number(r.value), + 0 + )} + {@const total = cashClaimable.reduce( + (sum: number, r: any) => sum + Number(r.value), + 0 + )} + {@const hasClaims = requested.length > 0 || outstanding.length > 0} + {#if hasClaims} +
+

{m.name}

+ {#if total > 0} +

£{total.toFixed(2)} owed

+ {/if} + + + {#if cashRequested.length > 0} +
{ + return async (args: any) => handleResult(args); + }} + > + + + +
+ {/if} + + {#if requested.length > 0} + + {#each requested as r} +
{ + return async (args: any) => handleResult(args); + }} + > + + +
+ {r.label} +
+ {rewardLabel(r)} + +
+
+
+ {/each} + {/if} + + {#if outstanding.length > 0} + + {#each outstanding as r} +
+ {r.label} + + {rewardLabel(r)} + {#if paydayLocked(r)} + 🔒 {formatHumanDate(r.settleDate)} + {/if} + +
+ {/each} + {/if} + +
+

Response:

+
+ {#each responseTags as tag} + + {/each} +
+ { + if (customMessage[m.id]) selectedMessage[m.id] = customMessage[m.id]; + }} + /> +
+
+ {/if} + {/each} + {/if} +
+
@@ -966,8 +991,7 @@ {:else} {#if simulateEow}
- 👀 Payday preview — your parent is checking this week's payday. Nothing is paid - out yet. + 👀 Payday preview — your parent is checking this week's payday. Nothing is paid out yet.
{/if} {#if owedCash > 0} @@ -1205,10 +1229,9 @@

- 📅 Weekly ({weeklyPending.length + - memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length}) + 📅 Weekly ({weeklyPending.length + memberTodos.filter((t) => !isTodoDone(t.id)).length})

- {#if weeklyPending.length === 0 && memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length === 0} + {#if weeklyPending.length === 0 && memberTodos.filter((t) => !isTodoDone(t.id)).length === 0}

All done!

{:else} {#each weeklyPending as chore} @@ -1218,7 +1241,7 @@ {chore.value} {chore.type} {/each} - {#each memberTodos.filter((t) => !isCompleted(t.id, todayChild)) as todo} + {#each memberTodos.filter((t) => !isTodoDone(t.id)) as todo} {@const urgency = todoUrgency(todo)} {#if todo.type === 'emoji'}
⏳ waiting {:else if paydayLocked(r)} - 🔒 pays out {formatShortDate(r.settleDate)}🔒 pays out {formatHumanDate(r.settleDate)} {:else}
-
diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.svelte b/frontend/src/routes/[fam]/[username]/chores/+page.svelte index 5cb637d..20bfb09 100644 --- a/frontend/src/routes/[fam]/[username]/chores/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/chores/+page.svelte @@ -3,12 +3,14 @@ import { page } from '$app/state'; import { famStore } from '$lib/stores/fam.svelte'; import { ViewHeader, CardGrid, Card } from '$lib/components'; - import { formatShortDate } from '$lib/format'; + import { formatHumanDate } from '$lib/format'; import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types'; // ── Chevron SVG icons ── - const CHEVRON_DOWN = ''; - const CHEVRON_UP = ''; + const CHEVRON_DOWN = + ''; + const CHEVRON_UP = + ''; let { data, form } = $props(); @@ -354,7 +356,6 @@

No templates yet

{/if} - @@ -369,53 +370,106 @@ ondragover={handleDragOver} ondrop={(e) => handleDrop(e, m.id)} > -

- - {m.name} -

+

+ + {m.name} +

- -
- - {#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} + +
+ + {#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} + + {/if} +
+ {/each} + -
- {a.customName || 'Todo'} -
- {#if a.type === 'emoji'} - 🎯 emoji - {:else} - {a.value} pts - {/if} - {#if a.completeBy} - due {formatShortDate(a.completeBy)} +
+ {/if} +
+ + +
+ + {#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`} +
- {/if} -
- {/each} - -
- {/if} -
- - -
- - {#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`} -
- -
- {/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} + + + + + + + + + + + + + {#each sortedRewards as r} + {@const outstanding = isOutstanding(r)} + + + + + + + + + {/each} + + + + + + + + + + + +
DateMemberDescriptionAmountStatus
{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(); + }; + }} + > + + +
+ {/if} +
Outstanding cash£{totalOutstanding.toFixed(2)}
+ {/if} +
+ {:else if activeTab === 'chores'} + + {#if sortedChoreCompletions.length === 0} +

No chore completions yet.

+ {:else} + + + + + + + + + + + {#each sortedChoreCompletions as c} + {@const a = assignedMap.get(c.assignedChoreId)} + + + + + + + {/each} + +
DateMemberChoreValue
{formatDDMMYY(c.date)} + + {memberName(c.memberId)} + {choreName(a)} + {#if a?.type === 'money'}£{Number(a.value).toFixed(2)} + {:else}{a?.value ?? 0} pts{/if} +
+ {/if} +
+ {:else} + + {#if sortedTodoCompletions.length === 0} +

No todos completed yet.

+ {:else} + + + + + + + + + + + {#each sortedTodoCompletions as c} + {@const a = assignedMap.get(c.assignedChoreId)} + + + + + + + {/each} + +
CompletedMemberTodoDue
{formatDDMMYY(c.date)} + + {memberName(c.memberId)} + {choreName(a)}{a?.completeBy ? formatDDMMYY(a.completeBy) : '—'}
+ {/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} - - - - - - - - - - - - - {#each sorted as r} - {@const outstanding = isOutstanding(r)} - - - - - - - - - {/each} - - - - - - - - - - - -
DateMemberDescriptionAmountStatus
{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(); } }; }}> - - -
- {/if} -
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);