diff --git a/MEMORY.md b/MEMORY.md index fbfdc40..766dee1 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -321,6 +321,20 @@ - **Settings accordion order:** Family (name/payday/**seasons**, opens by default via ``) → Invites → **Account** (Access + Subscription) → App last. - **`clearLegacyCookies(cookies)`** added to `$lib/server/session.ts`; auth/join/logout use it instead of inline `device_token` deletes. +### 2026-08-31 — Bonus progress window: `completeBy` + `startDate` implemented + +- **Problem**: a standalone cash bonus (core reward with a points threshold) displayed progress differently on the child dashboard (current week → "0/500") vs the admin dashboard (cumulative since records began). Both were "correct" because `bonus_configs` had no way to scope tracking — with no `period` set, progress()/evaluateFam totalled *all* completions, and the child dashboard forced `cfg.period || 'weekly'`. +- **Fix**: added to `bonus_configs` (schema + `ensureBonusFields` idempotent field-add in migrate.ts, so existing installs upgrade without wiping): `completeBy` (select `unlimited|week|custom`), `startDate` (text), `completeByDate` (text). New shared helpers in `shared/timezone.ts`: `bonusWindow(cfg, payday, tz)` + `completionInWindow(c, {from,to})` ('' = unbounded side). +- **Window semantics** (unified across server `progress()`/`evaluateFam()` and the child `thresholdGoals`): recurring configs (period set) keep their period window; standalone configs use `completeBy`: + - `unlimited` (default) → `[startDate, ∞]` cumulative + - `week` → current week `[weekStart, weekEnd]` + - `custom` → `[startDate, completeByDate]` + - No `startDate`/`completeBy` (legacy) → unbounded both sides (== the pre-fix admin behaviour, so existing cash bonuses stay correct). +- **UI**: rewards modal gained a "Progress counts" group (shown when no period / not manual): `completeBy` radios (Unlimited | Until a date) + optional `completeByDate` date input + "Progress starts on" `startDate` date input. Creating from a template's `startMode` (Today/Next week) now sets `startDate` (Today = fam-tz today; Next week = +7 days). `openEditConfig`/`openCreateFromTemplate` default `startDate` to today, so saving an existing cash bonus re-scopes it to count from today (progress since the reward begins), not since records started. +- **Server actions** (`rewards/+page.server.ts`): `createConfig`/`updateConfig`/`createFromTemplate` now persist `completeBy`/`startDate`/`completeByDate`. +- **Typecheck/build**: `svelte-check` still at the 6 pre-existing canary errors (chores RewardType/Frequency, qrcode decl, settings unknown→string) — none in edited files; `pnpm build` (adapter-node) clean. +- **Ops note**: the schema fields are added to live PB by `ensureBonusFields()` on next `migrateOnBoot` (dev server restart). A restart of the dev frontend is needed to apply the migration + load the new code. + ### 2026-08-22 — Graceful post-checkout activation (webhook-lag UX) - `[fam]/+layout.svelte` owns the `?checkout=return` flow (moved out of the fam page). On landing: if unlocked → welcome notice. If still gated (webhook lag) → `activating` state: paused overlay swaps to a spinner card ("Activating your subscription…"), TopNav paused announcement suppressed, and `invalidateAll()` revalidates every 1.5s (max 12 tries). The `$effect` watching `activating && !disabled` cancels polling and fires "Subscription active!" the instant the gate lifts; exhaustion degrades to a refresh-hint warning. diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index a05d3d3..1016afe 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -440,6 +440,25 @@ async function ensureBonusFields(): Promise { fields.push({ name: "isPocketMoney", type: "bool", required: false }); changed = true; } + // Standalone-reward progress window (bonus_configs only): how far progress + // counts (`completeBy`) and the window's start/custom-end dates. + if (name === "bonus_configs") { + if (!has("completeBy")) { + fields.push({ + name: "completeBy", type: "select", required: false, + values: ["unlimited", "week", "custom"], maxSelect: 1, + }); + changed = true; + } + if (!has("startDate")) { + fields.push({ name: "startDate", type: "text", required: false }); + changed = true; + } + if (!has("completeByDate")) { + fields.push({ name: "completeByDate", type: "text", required: false }); + changed = true; + } + } // rewardValue must be optional so an unset pocket-money droplet can exist. const rv = fields.find((f: any) => f.name === "rewardValue"); if (rv && rv.required) { diff --git a/frontend/src/lib/server/services/bonuses.ts b/frontend/src/lib/server/services/bonuses.ts index 6abcb3d..7f03670 100644 --- a/frontend/src/lib/server/services/bonuses.ts +++ b/frontend/src/lib/server/services/bonuses.ts @@ -4,7 +4,9 @@ import { periodStart, periodEnd, nextPaydayAfter, - weekStart + weekStart, + bonusWindow, + completionInWindow } from '@shared/timezone'; import { famMeta } from './fam'; @@ -55,13 +57,8 @@ export async function evaluateFam(pb: any, famId: string) { for (const cfg of configs) { // Pocket money pauses until the parent sets an amount. if (cfg.isPocketMoney && !cfg.rewardValue) continue; - const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : ''; - const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : ''; - const periodCompletions = cfg.period - ? allCompletions.filter( - (c: any) => (c.date || '').slice(0, 10) >= pStart2 && (c.date || '').slice(0, 10) <= pEnd - ) - : allCompletions; + const win = bonusWindow(cfg, paydayEval, tzEval); + const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win)); const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); let createdReward = false; @@ -241,11 +238,10 @@ export async function progress(pb: any, famId: string) { for (const cfg of configsData) { const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); - const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : ''; - const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : ''; - const periodCompletions = cfg.period - ? completionsList.filter((c: any) => c.date >= pStart && c.date <= pEnd) - : completionsList; + const win = bonusWindow(cfg, payday, tz); + const pStart = win.from; + const pEnd = win.to; + const periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win)); const progressRows: any[] = []; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e5656bf..ce537fa 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -138,6 +138,9 @@ export interface BonusConfig { period?: BonusPeriod; status: BonusStatus; isPocketMoney?: boolean; + completeBy?: 'unlimited' | 'week' | 'custom'; + startDate?: string; + completeByDate?: string; created: string; updated: string; } diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 19e1883..a92280f 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -32,9 +32,9 @@ weekdayInTz, wallClockToUtc, resolveTz, - periodStart, - periodEnd, - isCompleteForPeriod + isCompleteForPeriod, + bonusWindow, + completionInWindow } from '@shared/timezone'; let { data } = $props(); @@ -466,20 +466,20 @@ return !cfg.memberId || cfg.memberId === memberId; }) .map((cfg) => { - const per = cfg.period || 'weekly'; - const pStart = periodStart(per, paydayDay, famTz); - const pEnd = periodEnd(per, pStart); + const win = bonusWindow(cfg, paydayDay, famTz); + const pStart = win.from || ''; + const pEnd = win.to || ''; const periodDays = - Math.round( - (new Date(pEnd + 'T00:00:00').getTime() - new Date(pStart + 'T00:00:00').getTime()) / - 86400000 - ) + 1; + pStart && pEnd + ? Math.round( + (new Date(pEnd + 'T00:00:00').getTime() - + new Date(pStart + 'T00:00:00').getTime()) / + 86400000 + ) + 1 + : 1; const periodCompletions = completions.filter( - (c) => - c.memberId === memberId && - (c.date?.slice(0, 10) || c.date) >= pStart && - (c.date?.slice(0, 10) || c.date) <= pEnd + (c) => c.memberId === memberId && completionInWindow(c, win) ); // Percent thresholds are "X% of the chores done this period" — the diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts b/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts index dcccc5b..48463bc 100644 --- a/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts @@ -119,6 +119,12 @@ export const actions = { if (description) data.description = description; const memberId = fd.get('memberId'); if (memberId) data.memberId = memberId; + const completeBy = fd.get('completeBy'); + if (completeBy) data.completeBy = completeBy; + const startDate = fd.get('startDate'); + if (startDate) data.startDate = startDate; + const completeByDate = fd.get('completeByDate'); + if (completeByDate) data.completeByDate = completeByDate; try { const s = servicesFor(event); const record = await s.crud.create('bonus-configs', famId, data); @@ -157,6 +163,12 @@ export const actions = { if (description) data.description = description; const memberId = fd.get('memberId'); if (memberId !== null) data.memberId = memberId || null; + const completeBy = fd.get('completeBy'); + if (completeBy) data.completeBy = completeBy; + const startDate = fd.get('startDate'); + if (startDate !== null) data.startDate = startDate || ''; + const completeByDate = fd.get('completeByDate'); + if (completeByDate !== null) data.completeByDate = completeByDate || ''; try { const s = servicesFor(event); const record = await s.crud.update('bonus-configs', famId, id, data); @@ -199,7 +211,10 @@ export const actions = { period: fd.get('period') || '', memberId: fd.get('memberId') || '', status, - isPocketMoney: !!fd.get('isPocketMoney') + isPocketMoney: !!fd.get('isPocketMoney'), + completeBy: fd.get('completeBy') || 'unlimited', + startDate: fd.get('startDate') || '', + completeByDate: fd.get('completeByDate') || '' }; if (data.occurrence === 'once') data.period = ''; try { diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte index 96f4c91..07b135a 100644 --- a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte @@ -5,8 +5,23 @@ import { famStore } from '$lib/stores/fam.svelte'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import TemplateIcon from '$lib/components/TemplateIcon.svelte'; + import { resolveTz, todayInTz, addDaysStr } from '@shared/timezone'; import { chevronRight } from '$lib/components/icons'; import Clock from '@lucide/svelte/icons/clock'; + import Tag from '@lucide/svelte/icons/tag'; + import UsersRound from '@lucide/svelte/icons/users-round'; + import Target from '@lucide/svelte/icons/target'; + import Settings2 from '@lucide/svelte/icons/settings-2'; + import CalendarDays from '@lucide/svelte/icons/calendar-days'; + import Coins from '@lucide/svelte/icons/coins'; + import Gift from '@lucide/svelte/icons/gift'; + import PoundSterling from '@lucide/svelte/icons/pound-sterling'; + import ArrowLeft from '@lucide/svelte/icons/arrow-left'; + import ArrowRight from '@lucide/svelte/icons/arrow-right'; + import Save from '@lucide/svelte/icons/save'; + import Trash2 from '@lucide/svelte/icons/trash-2'; + import Crown from '@lucide/svelte/icons/crown'; + import Ban from '@lucide/svelte/icons/ban'; import type { BonusConfig, BonusTemplate, @@ -18,9 +33,24 @@ let { data }: { data: PageData } = $props(); + // Today in the family's timezone (used for the default start date of a + // standalone reward — progress must not count points from before it began). + function todayIso(): string { + const famTz = resolveTz((famStore.fam as any)?.timezone || 'auto'); + return todayInTz(famTz); + } + + // Numbered-circle marker used for the Who / How / What step tabs and field + // labels (SVG string, same style as lib/components/icons.ts). + function stepIcon(n: number): string { + return ``; + } + let showEditModal = $state(false); let editingConfig = $state(null); let creatingFromTemplate = $state(null); + // Multi-step rule editor: 1 = who, 2 = how, 3 = what. + let step = $state(1); // Kanban columns can collapse (accordion) so templates can be dragged onto // the desired area on tablet/mobile without cramming three columns. @@ -56,7 +86,10 @@ rewardValue: '100', criteriaValue: 10, period: '' as string, - startMode: 'today' as string + startMode: 'today' as string, + completeBy: 'unlimited' as string, + startDate: '' as string, + completeByDate: '' as string }); let configs = $derived( @@ -79,8 +112,9 @@ let showPeriod = $derived(editVals.occurrence !== 'once'); let showCriteria = $derived(editVals.type !== 'manual'); - let createIsPercent = $derived(createVals.type === 'threshold' && createVals.thresholdType === 'percent'); let editIsPercent = $derived(editVals.type === 'threshold' && editVals.thresholdType === 'percent'); + // Standalone (no period) rewards can scope progress by completeBy + dates. + let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period); let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global)); let allBonusConfigs = $derived( @@ -157,8 +191,12 @@ rewardValue: tpl.rewardValue, criteriaValue: tpl.criteriaValue || 10, period: tpl.occurrence === 'once' ? '' : tpl.period || '', - startMode: 'today' + startMode: 'today', + completeBy: 'unlimited', + startDate: todayIso(), + completeByDate: '' }; + step = 1; showEditModal = true; } @@ -205,8 +243,12 @@ rewardValue: cfg.rewardValue, criteriaValue: cfg.criteriaValue || 10, period: cfg.occurrence === 'once' ? '' : cfg.period || '', - startMode: cfg.status === 'disabled' ? 'disabled' : 'today' + startMode: cfg.status === 'disabled' ? 'disabled' : 'today', + completeBy: cfg.completeBy || 'unlimited', + startDate: cfg.startDate || todayIso(), + completeByDate: cfg.completeByDate || '' }; + step = 1; showEditModal = true; } @@ -496,9 +538,32 @@ role="presentation" >