From 85ea97ae9601276c079d9ceecb91724a12ed9720 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Tue, 4 Aug 2026 08:34:14 +0100 Subject: [PATCH] added todos | payday time settings | refactor payment scheduling --- MEMORY.md | 18 + config.js | 2 +- frontend/src/lib/client/api.ts | 3 + frontend/src/lib/server/hono.ts | 42 +- frontend/src/lib/types.ts | 14 +- frontend/src/routes/[fam]/+layout.server.ts | 46 +- .../routes/[fam]/[username]/+page.server.ts | 115 +- .../src/routes/[fam]/[username]/+page.svelte | 717 ++++++++-- .../[fam]/[username]/bonuses/+page.server.ts | 12 - .../[fam]/[username]/bonuses/+page.svelte | 144 +- .../[fam]/[username]/chores/+page.server.ts | 92 +- .../[fam]/[username]/chores/+page.svelte | 1169 +++++++++++++---- .../[fam]/[username]/settings/+page.server.ts | 8 +- .../[fam]/[username]/settings/+page.svelte | 456 +++++-- proxy/src/index.ts | 519 +++++++- proxy/src/migrate.ts | 179 ++- 16 files changed, 2877 insertions(+), 659 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index 7ec506c..5566757 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -92,3 +92,21 @@ - **Bug**: Both `join/[code]/+page.server.ts` and `join/[code]/[member]/+page.server.ts` called `setSessionCookie()` with `userId: memberId` (the child's PB record ID). This made `event.locals.session` truthy for children, causing `[username]/+page.server.ts` to enter the admin branch and call `hono.admin.*` endpoints. The proxy's `requireAdmin` checked `fam_admins` for the child's member ID (which doesn't exist) and returned 401. - **Fix**: Removed `setSessionCookie()` from both join pages. Children only get a `device_token` cookie. The session cookie is only for email/password-authenticated parents, set by `/login` and `/signup`. - **Lesson**: Children must never get a session cookie. The auth table in AGENTS.md says "Member → device token, no expiry" — the code must match. + +### 2026-08-03 — Family Timezone Setting + Tz-Aware Week Math + +- **Feature**: `fams.timezone` (IANA name or `"auto"`) added via `migrate.ts` 5d/5e (field + backfill `"auto"`). Exposed in settings Payday card (dropdown from `COMMON_TIMEZONES`, ~40 entries, + "Auto (detected)"). Set via `PATCH /api/admin/:famId/fam` alongside payday/paydayTime. +- **Shared module** `timezone.ts` (root, imported by both proxy and frontend): `resolveTz`, `dateStrInTz`, `weekdayInTz`, `todayInTz`, `addDaysStr` (pure UTC), `weekStart(payday, tz)`, `wallClockToUtc` (iterative 4-pass Intl, DST-safe), `COMMON_TIMEZONES`. +- **Bug fixed** ("5 days left on Monday"): old `mondayOf`/`addDays`/`daysLeft` used local `setDate` + `toISOString()`. On BST Sunday Aug 9 local midnight → UTC Aug 8, so Monday showed 5 days left instead of 6. Now the child page computes `weekStart`/`todayIso`/`daysLeft` via `weekStart(1, famTz)` + `addDaysStr` + `todayInTz`, giving 6. Verified: Mon Aug 3 → weekStart 2026-08-03, weekEnd 2026-08-09, daysLeft 6. +- **releaseWeek time gate**: now tz-aware. `weekStart(payday, tz)` for idempotency check; `target = new Date(wallClockToUtc(weekStartToday, paydayTime, tz))`. Verified: payday Mon 20:00 Europe/London (BST) → target `2026-08-03T19:00:00Z`; `auto` resolves to server tz. Returns `{settled:false, notYet:true, weekStart, target}` before the time. +- **Proxy threads `tz`** through weekly-summary, eow-preview, bonus-configs/progress, `evaluateFam`, tallies, manual trigger, complete-week, `releaseWeek`. `my-chores` returns `timezone`; child `+page.server.ts` passes it to `data.timezone`; parent passes `data.fam.timezone`. +- **Frontend child page**: `rawFamTz = data.timezone || data.fam?.timezone || 'auto'`, `famTz = resolveTz(rawFamTz)`. `todayChild`, `todayIso`, `mondayOf`, `addDays`, `isPaydayToday` (via `weekdayInTz`), `paydayTarget` (via `wallClockToUtc`), `today` all tz-aware. `mondayOf` now delegates to `tzWeekStart(1, famTz)`. +- **Smoke-tested live** (fam `v56f0f8o147kj1x`): GET fam returns timezone, PATCH sets Europe/London, time-gate returns correct target, `auto` resolves to server tz, settings page SSR shows the dropdown, child page 200. Fam restored to payday=0, paydayTime=18:00, timezone=auto, lastIssued=2026-08-02. +- **Typecheck**: proxy `tsc --noEmit` 28 errors (all pre-existing rootDir/`.ts`-import/`key: never`/implicit-any baseline); frontend `svelte-check` 9 errors (baseline; no new errors in edited files). + +### 2026-08-04 — Terminology: "Payday" + Countdown to Settlement Day + +- **Decision**: Standardize the user-facing term on **"payday"** for the weekly settlement event/day. "EOW" is ambiguous (window-close vs settlement day) and is now dropped from user-facing strings. Keep "week" for the Sun→Sat earning window. Internal identifiers (`eow*`, `simulateEow`, `eowPreview`, CSS `eow-*`) left as-is. +- **daysLeft now counts to settlement day**: `daysLeft` on the child dashboard counts to `weekStart + 7` (the next payday day) instead of `weekEnd = weekStart + 6`. So Tue Aug 4 with payday=Sun shows 5 days until payday. Hero label updated to "days until payday". +- **User-facing renames**: hero `days left` → `days until payday`; debug card `Simulate End-of-Week` → `Simulate Payday`; error `Failed to preview EOW` → `Failed to preview payday`; settings hint reworded to lead with "Payday:". + diff --git a/config.js b/config.js index 4a2514d..db8caba 100644 --- a/config.js +++ b/config.js @@ -6,4 +6,4 @@ export const PB_EMAIL = "debug@famchamp.dev"; export const PB_PASSWORD = "debug123"; export const DEBUG_RECORD_ID = "0747qjl16m6o529"; export const PUBLIC_PB_URL = `http://${SERVER_IP}:${PB_PORT}`; -//# sourceMappingURL=config.js.map \ No newline at end of file +//# sourceMappingURL=config.js.map diff --git a/frontend/src/lib/client/api.ts b/frontend/src/lib/client/api.ts index 9bdf788..b1dca22 100644 --- a/frontend/src/lib/client/api.ts +++ b/frontend/src/lib/client/api.ts @@ -39,4 +39,7 @@ export const memberApi = { async requestAllRewards(token: string, famId: string) { return memberFetch<{ count: number }>('POST', '/api/members/rewards/request-all', token, famId); }, + async payday(token: string, famId: string) { + return memberFetch('POST', `/api/fam/${famId}/payday`, token, famId); + }, }; diff --git a/frontend/src/lib/server/hono.ts b/frontend/src/lib/server/hono.ts index db04bec..b6cb0eb 100644 --- a/frontend/src/lib/server/hono.ts +++ b/frontend/src/lib/server/hono.ts @@ -24,8 +24,17 @@ async function request( headers: headers || { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || `${method} ${path} failed`); + const text = await res.text(); + let data: any = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { raw: text }; + } + if (!res.ok) { + const msg = data?.error || data?.message || `${method} ${path} failed (HTTP ${res.status})`; + throw new Error(msg); + } return data; } @@ -62,8 +71,19 @@ export const hono = { async renameFam(event: RequestEvent, famId: string, name: string) { return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event)); }, - async updatePayday(event: RequestEvent, famId: string, payday: number) { - return request('PATCH', `/api/admin/${famId}/fam`, { payday }, sessionHeaders(event)); + async updatePayday( + event: RequestEvent, + famId: string, + payday: number, + paydayTime?: string, + timezone?: string + ) { + return request( + 'PATCH', + `/api/admin/${famId}/fam`, + { payday, ...(paydayTime !== undefined ? { paydayTime } : {}), ...(timezone !== undefined ? { timezone } : {}) }, + sessionHeaders(event) + ); }, async fam(event: RequestEvent, famId: string) { return request('GET', `/api/admin/${famId}/fam`, undefined, sessionHeaders(event)); @@ -216,6 +236,20 @@ export const hono = { }, async request(event: RequestEvent, method: string, path: string, body?: unknown) { return request(method, path, body, sessionHeaders(event)); + }, + async settings(event: RequestEvent, famId: string) { + return request('GET', `/api/admin/${famId}/settings`, undefined, sessionHeaders(event)); + }, + async updateSettings(event: RequestEvent, famId: string, data: Record) { + return request('PATCH', `/api/admin/${famId}/settings`, data, sessionHeaders(event)); + }, + async eowPreview(event: RequestEvent, famId: string) { + return request( + 'GET', + `/api/admin/${famId}/debug/eow-preview`, + undefined, + sessionHeaders(event) + ); } } }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 965f46a..d57c1ca 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,5 +1,5 @@ export type Frequency = 'daily' | 'weekly'; -export type RewardType = 'points' | 'money'; +export type RewardType = 'points' | 'money' | 'emoji'; export type BonusTarget = 'individual' | 'competitive' | 'collaborative'; export type BonusType = 'threshold' | 'count' | 'manual'; export type BonusOccurrence = 'recurring' | 'once'; @@ -43,6 +43,10 @@ export interface Fam { inviteCode: string; stripeCustomerId?: string; featureFlags: Record; + payday?: number; + paydayTime?: string; + timezone?: string; + lastIssued?: string; created: string; updated: string; } @@ -70,16 +74,19 @@ export interface ChoreTemplate { updated: string; } -export interface AssignedChore { + export interface AssignedChore { id: string; famId: string; memberId: string; - templateId: string; + templateId?: string; frequency: Frequency; type: RewardType; value: number; customName?: string; seasonIds?: string[]; + isTodo?: boolean; + startDate?: string; + completeBy?: string; created: string; updated: string; } @@ -160,6 +167,7 @@ export interface Settings { id: string; famId: string; webhookUrl?: string; + simulateEow?: boolean; } export interface Session { diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index 568504f..ae70dcb 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -2,26 +2,48 @@ import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +async function paydayCheck(famId: string, headers: Record) { + try { + const res = await fetch(`${HONO_URL}/api/fam/${famId}/payday`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers + } + }); + // Best-effort: never block render on the payday heartbeat. + await res.json().catch(() => null); + } catch {} +} + export async function load(event) { const session = event.locals.session || null; const isParent = session !== null; + const deviceToken = event.cookies.get('device_token') || ''; let famId = ''; if (isParent) { famId = session.famId; - } else { - const deviceToken = event.cookies.get('device_token') || ''; - if (deviceToken) { - try { - const res = await fetch(`${HONO_URL}/api/members/seasons`, { - headers: { 'x-device-token': deviceToken }, - }); - if (res.ok) { - const body = await res.json(); - famId = body.famId || ''; - } - } catch {} + await paydayCheck(famId, { + 'x-session-famid': session.famId, + 'x-session-userid': session.userId + }); + } else if (deviceToken) { + try { + const res = await fetch(`${HONO_URL}/api/members/seasons`, { + headers: { 'x-device-token': deviceToken } + }); + if (res.ok) { + const body = await res.json(); + famId = body.famId || ''; + } + } catch {} + if (famId) { + await paydayCheck(famId, { + 'x-device-token': deviceToken, + 'x-device-famid': famId + }); } } diff --git a/frontend/src/routes/[fam]/[username]/+page.server.ts b/frontend/src/routes/[fam]/[username]/+page.server.ts index 3c0fbe3..7da268c 100644 --- a/frontend/src/routes/[fam]/[username]/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/+page.server.ts @@ -16,7 +16,17 @@ export async function load(event) { if (session.memberName && session.memberName !== username) { throw redirect(303, `/${famSlug}/${session.memberName}`); } - const [members, templates, assigned, summary, fam, rewards, bonusConfigs, completions] = await Promise.all([ + const [ + members, + templates, + assigned, + summary, + fam, + rewards, + bonusConfigs, + completions, + settings + ] = await Promise.all([ hono.admin.list(event, 'members', famId), hono.admin.list(event, 'chore-templates', famId), hono.admin.list(event, 'assigned-chores', famId), @@ -25,28 +35,68 @@ export async function load(event) { hono.admin.rewards(event, famId), hono.admin.bonusConfigs(event, famId), hono.admin.completions(event, famId), + hono.admin.settings(event, famId).catch(() => ({})) ]); - return { role: 'parent', members, templates, assigned, summary, fam, famSlug, rewards, bonusConfigs, completions }; + return { + role: 'parent', + members, + templates, + assigned, + summary, + fam, + famSlug, + rewards, + bonusConfigs, + completions, + settings + }; } // Child (device token) → kanban - const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || ''; + const deviceToken = + event.cookies.get('device_token') || event.url.searchParams.get('token') || ''; const famSlug = event.params.fam; const username = event.params.username; if (!deviceToken) { - return { role: 'child', token: '', memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} }; + return { + role: 'child', + token: '', + memberId: '', + verified: false, + famId: '', + templates: [], + assigned: [], + completions: [], + rewards: [], + bonusConfigs: [], + tallies: {} + }; } try { const res = await fetch(`${HONO_URL}/api/members/verify-token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ deviceToken, famSlug }), + body: JSON.stringify({ deviceToken, famSlug }) }); const data = await res.json(); if (!res.ok || data.name !== username) { - return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', memberName: '', memberColor: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} }; + return { + role: 'child', + token: deviceToken, + memberId: '', + verified: false, + famId: '', + memberName: '', + memberColor: '', + templates: [], + assigned: [], + completions: [], + rewards: [], + bonusConfigs: [], + tallies: {} + }; } let chores: any = {}; @@ -68,13 +118,55 @@ export async function load(event) { rewards: chores.rewards || [], bonusConfigs: chores.bonusConfigs || [], tallies: chores.tallies || {}, + payday: chores.payday, + paydayTime: chores.paydayTime || '18:00', + timezone: chores.timezone || 'auto' }; } catch { - return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} }; + return { + role: 'child', + token: deviceToken, + memberId: '', + verified: false, + famId: '', + templates: [], + assigned: [], + completions: [], + rewards: [], + bonusConfigs: [], + tallies: {}, + payday: 1, + paydayTime: '18:00', + timezone: 'auto' + }; } } export const actions = { + setEow: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const on = fd.get('on') === 'true'; + try { + const result = await hono.admin.updateSettings(event, famId, { simulateEow: on }); + return { simulateEow: result.simulateEow }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to update settings' }; + } + }, + + previewEow: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + try { + const preview = await hono.admin.eowPreview(event, famId); + return { preview }; + } catch (e) { + return { error: e instanceof Error ? e.message : 'Failed to preview payday' }; + } + }, + claim: async (event) => { if (!event.locals.session) throw redirect(303, '/login'); const famId = event.locals.session.famId; @@ -127,10 +219,15 @@ export const actions = { const memberId = fd.get('memberId') as string; if (!configId) return { error: 'Config ID required' }; try { - const result = await hono.admin.triggerBonusConfig(event, famId, configId, memberId || undefined); + const result = await hono.admin.triggerBonusConfig( + event, + famId, + configId, + memberId || undefined + ); return { records: result.records || [] }; } catch (e) { return { error: e instanceof Error ? e.message : 'Failed to trigger' }; } - }, + } }; diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index acf20a6..dac121a 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -6,6 +6,16 @@ import { memberApi } from '$lib/client/api'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types'; + import { + weekStart as tzWeekStart, + addDaysStr, + todayInTz, + weekdayInTz, + wallClockToUtc, + resolveTz, + periodStart, + periodEnd + } from '../../../../../timezone.ts'; interface Notification { id: string; @@ -22,9 +32,16 @@ let famSlug = $derived(page.params.fam); let username = $derived(page.params.username); + // ─── Family timezone (resolved) ─── + const rawFamTz = $derived(data.timezone || data.fam?.timezone || 'auto'); + const famTz = $derived(resolveTz(rawFamTz)); + // ─── Parent View (admin overview) ─── let summary = $state(data.summary); - let today = $state(new Date().toISOString().slice(0, 10)); + let today = $derived(todayInTz(famTz)); + + let simulateEow = $state(!!data.settings?.simulateEow); + let eowPreview = $state(null); const responseTags = [ '👏 well done', @@ -70,7 +87,10 @@ } function claimableRewards() { - return parentRewards.filter((r: any) => r.status === 'unclaimed' || r.status === 'requested'); + return parentRewards.filter( + (r: any) => + r.rewardType !== 'points' && (r.status === 'unclaimed' || r.status === 'requested') + ); } function memberClaimable(memberId: string) { @@ -78,17 +98,14 @@ } function memberRequested(memberId: string) { - return parentRewards.filter((r: any) => r.memberId === memberId && r.status === 'requested'); + return parentRewards.filter( + (r: any) => r.rewardType !== 'points' && r.memberId === memberId && r.status === 'requested' + ); } - function memberOutstanding(memberId: string) { - return parentRewards.filter((r: any) => r.memberId === memberId && r.status === 'unclaimed'); - } - - function memberRequestedTotal(memberId: string) { - return memberRequested(memberId) - .filter((r: any) => r.rewardType === 'cash') - .reduce((sum: number, r: any) => sum + Number(r.value), 0); + return parentRewards.filter( + (r: any) => r.rewardType !== 'points' && r.memberId === memberId && r.status === 'unclaimed' + ); } let _confetti: any; @@ -174,20 +191,41 @@ let error = $state(''); let claimError = $state(''); - let todayChild = $state(new Date().toISOString().slice(0, 10)); + let todayChild = $derived(todayInTz(famTz)); let togglingIds = $state(''); - function mondayOf(d: Date): string { - const day = d.getDay(); - const diff = d.getDate() - day + (day === 0 ? -6 : 1); - d.setDate(diff); - return d.toISOString().slice(0, 10); + // ─── Payday countdown (child view) ─── + let nowMs = $state(Date.now()); + let eowFired = $state(false); + $effect(() => { + const id = setInterval(() => (nowMs = Date.now()), 1000); + return () => clearInterval(id); + }); + const paydayDay = $derived(data.payday != null ? Number(data.payday) : 1); + const paydayTime = $derived(data.paydayTime || '18:00'); + const isPaydayToday = $derived(weekdayInTz(new Date(), famTz) === paydayDay); + const paydayTarget = $derived.by(() => + new Date(wallClockToUtc(todayInTz(famTz), paydayTime, famTz)).getTime() + ); + const secondsLeft = $derived(Math.max(0, Math.floor((paydayTarget - nowMs) / 1000))); + const countdownHours = $derived(Math.floor(secondsLeft / 3600)); + const countdownMinutes = $derived(Math.floor((secondsLeft % 3600) / 60)); + const countdownSecs = $derived(secondsLeft % 60); + const showCountdown = $derived(isPaydayToday && secondsLeft > 0); + $effect(() => { + if (role !== 'child' || !isPaydayToday) return; + if (secondsLeft > 0 || eowFired) return; + if (!deviceToken || !famId) return; + eowFired = true; + memberApi.payday(deviceToken, famId).catch(() => {}); + }); + + function paydayWeekStart(): string { + return tzWeekStart(paydayDay, famTz); } function addDays(dateStr: string, days: number): string { - const d = new Date(dateStr + 'T00:00:00'); - d.setDate(d.getDate() + days); - return d.toISOString().slice(0, 10); + return addDaysStr(dateStr, days); } function monthStartStr(): string { @@ -195,8 +233,8 @@ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`; } - const todayIso = new Date().toISOString().slice(0, 10); - const currentWeek = mondayOf(new Date()); + const todayIso = todayInTz(famTz); + const currentWeek = paydayWeekStart(); let weekStart = $state(currentWeek); let weekEnd = $derived(addDays(weekStart, 6)); @@ -205,7 +243,7 @@ Math.max( 0, Math.round( - (new Date(weekEnd + '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 ) ) @@ -216,18 +254,46 @@ const activeSeasonIds = new Set(famStore.seasons.filter((s) => s.active).map((s) => s.id)); return assigned.filter((a) => { if (a.memberId !== memberId) return false; + if (a.isTodo) return false; if (!a.seasonIds || a.seasonIds.length === 0) return true; if (activeSeasonIds.size === 0) return true; return a.seasonIds.some((sid) => activeSeasonIds.has(sid)); }); }); + // Todos — one-off items with deadlines (sorted: soonest first) + let memberTodos = $derived.by(() => + assigned + .filter((a) => a.memberId === memberId && a.isTodo) + .sort((a, b) => { + const da = a.completeBy || '9999-99-99'; + const db = b.completeBy || '9999-99-99'; + if (da !== db) return da.localeCompare(db); + return (a.startDate || '').localeCompare(b.startDate || ''); + }) + ); + let dailyPending = $derived( memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, todayChild)) ); let weeklyPending = $derived( memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, todayChild)) ); + + // Traffic light: days until todo deadline (only non-emoji todos) + function todoUrgency(todo: AssignedChore): 'blue' | 'green' | 'orange' | 'red' | 'black' { + if (!todo.completeBy) return 'blue'; + const days = Math.round( + (new Date(todo.completeBy + 'T00:00:00').getTime() - + new Date(todayChild + 'T00:00:00').getTime()) / + 86400000 + ); + if (days <= 0) return 'black'; + if (days === 1) return 'red'; + if (days === 2) return 'orange'; + if (days === 3) return 'green'; + return 'blue'; + } let completedToday = $derived( completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === todayChild) ); @@ -235,7 +301,8 @@ let weeklyTotal = $derived.by(() => { const dailyChores = memberChores.filter((a) => a.frequency === 'daily'); const weeklyChores = memberChores.filter((a) => a.frequency === 'weekly'); - return dailyChores.length * 7 + weeklyChores.length; + const activeTodos = memberTodos.filter((t) => t.type !== 'emoji').length; + return dailyChores.length * 7 + weeklyChores.length + activeTodos; }); let weekCompletions = $derived( @@ -253,46 +320,56 @@ rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart) ); - let allTimeCash = $derived.by(() => { - const myCompletions = completions.filter((c) => c.memberId === memberId); - const myRewards = rewards.filter((r) => r.memberId === memberId); - return ( - myCompletions.reduce((sum, c) => { - const chore = assigned.find((a) => a.id === c.assignedChoreId); - return sum + (chore?.type === 'money' ? Number(chore.value) : 0); - }, 0) + - myRewards.filter((r) => r.rewardType === 'cash').reduce((sum, r) => sum + Number(r.value), 0) - ); - }); + let allTimeCash = $derived.by(() => + rewards + .filter((r) => r.memberId === memberId && r.rewardType === 'cash' && r.status === 'claimed') + .reduce((sum, r) => sum + Number(r.value), 0) + ); - let allTimePoints = $derived.by(() => { - const myCompletions = completions.filter((c) => c.memberId === memberId); - const myRewards = rewards.filter((r) => r.memberId === memberId); - return ( - myCompletions.reduce((sum, c) => { - const chore = assigned.find((a) => a.id === c.assignedChoreId); - return sum + (chore?.type === 'points' ? Number(chore.value) : 0); - }, 0) + - myRewards - .filter((r) => r.rewardType === 'points') - .reduce((sum, r) => sum + Number(r.value), 0) - ); - }); + let allTimePoints = $derived.by(() => + rewards + .filter((r) => r.memberId === memberId && r.rewardType === 'points' && r.status === 'claimed') + .reduce((sum, r) => sum + Number(r.value), 0) + ); + + // Week-to-date totals from chore completions (not rewards — rewards are created at EOW) + let weekChoreCash = $derived.by(() => + weekCompletions.reduce((sum, c) => { + const chore = assigned.find((a) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'money' ? Number(chore.value) : 0); + }, 0) + ); + let weekChorePoints = $derived.by(() => + weekCompletions.reduce((sum, c) => { + const chore = assigned.find((a) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0) + ); let pendingCash = $derived.by(() => weekRewards .filter((r) => r.rewardType === 'cash' && r.status !== 'claimed') .reduce((sum, r) => sum + Number(r.value), 0) ); - let pendingPoints = $derived.by(() => + // Money owed to this member across all time (incl. carry-over from past weeks). + let owedCash = $derived.by(() => + rewards + .filter((r) => r.memberId === memberId && r.rewardType === 'cash' && r.status !== 'claimed') + .reduce((sum, r) => sum + Number(r.value), 0) + ); + let weekPointsEarned = $derived.by(() => weekRewards - .filter((r) => r.rewardType === 'points' && r.status !== 'claimed') + .filter((r) => r.rewardType === 'points') .reduce((sum, r) => sum + Number(r.value), 0) ); const pendingRewards = $derived( - weekRewards.filter((r) => r.status === 'unclaimed' || r.status === 'requested') + rewards.filter( + (r) => + r.memberId === memberId && + r.rewardType !== 'points' && + (r.status === 'unclaimed' || r.status === 'requested') + ) ); - let weekBonusTallies = $derived.by(() => { const map = new Map(); for (const r of weekRewards) { @@ -303,6 +380,55 @@ return [...map.entries()].map(([name, count]) => ({ name, count })); }); + // ─── Threshold Goals (bonus configs the member is chasing) ─── + let thresholdGoals = $derived.by(() => { + return bonusConfigs + .filter((cfg) => { + if (cfg.status !== 'active') return false; + if (cfg.type === 'manual') return false; + if (cfg.target !== 'individual') return false; + 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 periodCompletions = completions.filter( + (c) => + c.memberId === memberId && + (c.date?.slice(0, 10) || c.date) >= pStart && + (c.date?.slice(0, 10) || c.date) <= pEnd + ); + + let current = 0; + if (cfg.type === 'threshold') { + current = periodCompletions.reduce((sum, c) => { + const chore = assigned.find((a) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + } else if (cfg.type === 'count') { + current = periodCompletions.length; + } + + const existingReward = rewards.find( + (r) => r.bonusConfigId === cfg.id && r.memberId === memberId + ); + const criteria = Number(cfg.criteriaValue) || 0; + const achieved = existingReward ? true : criteria > 0 && current >= criteria; + + return { + config: cfg, + current, + criteriaValue: criteria, + achieved, + rewardStatus: existingReward?.status || null, + periodStart: pStart, + periodEnd: pEnd + }; + }); + }); + function shortDate(dateStr: string): string { const d = new Date(dateStr + 'T00:00:00'); const day = String(d.getDate()).padStart(2, '0'); @@ -598,12 +724,38 @@ {#each parentMembers as m} {@const requested = memberRequested(m.id)} {@const outstanding = memberOutstanding(m.id)} - {@const total = memberRequestedTotal(m.id)} - {#if requested.length > 0 || outstanding.length > 0} + {@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)} requested

+

£{total.toFixed(2)} owed

+ {/if} + + + {#if cashRequested.length > 0} +
{ + return async (args: any) => handleResult(args); + }} + > + + + +
{/if} {#if requested.length > 0} @@ -627,21 +779,6 @@
{/each} - {#if requested.length > 1} -
{ - return async (args: any) => handleResult(args); - }} - > - - - -
- {/if} {/if} {#if outstanding.length > 0} @@ -684,6 +821,82 @@ {/if} + + +

+ Read-only preview of what the payday rollover will produce. Nothing here is written. +

+
{ + return async ({ result }) => { + const d = (result as any).data || {}; + if (d.error) toast = d.error; + else simulateEow = !!d.simulateEow; + }; + }} + > + + +
+

+ This toggles the family debug flag only. It does not alter any live data. +

+ +
{ + return async ({ result }) => { + const d = (result as any).data || {}; + if (d.error) toast = d.error; + else eowPreview = d.preview; + }; + }} + > + +
+ + {#if eowPreview} +
+

Week {eowPreview.weekStart} → {eowPreview.weekEnd}

+

+ On rollover these {eowPreview.completionsThisWeek} completions reset; week starts anew at + {eowPreview.nextWeekStart}. +

+ +
+ {#each eowPreview.summaries as s} +
+ {s.memberName} + {s.pointsEarned} pts + £{Number(s.moneyEarned || 0).toFixed(2)} + {s.choresCompleted} chores +
+ {/each} +
+ + {#if eowPreview.predictedRewards.length} +

Rewards that would be auto-created

+
+ {#each eowPreview.predictedRewards as r} + {r.memberName} · {r.config} · {r.type === 'cash' + ? '£' + Number(r.value).toFixed(2) + : r.value + ' pts'} + {/each} +
+ {:else} +

No new rewards would be auto-created this week.

+ {/if} +
+ {/if} +
+
{:else} {#if loading} @@ -691,6 +904,27 @@ {:else if error}

{error}

{:else} + {#if owedCash > 0} +
+ 🎉 You've earned £{owedCash.toFixed(2)} — go get it from your parent! +
+ {/if} + {#if showCountdown} +
+
💰
+
+

Payday is today!

+

+ {#if countdownHours > 0} + {countdownHours}h {countdownMinutes}m left + {:else} + {countdownMinutes}m {countdownSecs}s left + {/if} +

+

Your earnings get settled at {paydayTime}

+
+
+ {/if}
@@ -822,7 +1056,7 @@ {:else} no bonuses earned yet {/if} -
{daysLeft} days left
+
{daysLeft} days until payday
- +
- £{pendingCash.toFixed(2)} - pending cash + £{weekChoreCash.toFixed(2)} + cash this week
- {pendingPoints} - pending points + {weekChorePoints} + points this week
+ + {#if thresholdGoals.length > 0} +
+ {#each thresholdGoals as goal} + {@const pct = + goal.criteriaValue > 0 + ? Math.min(100, Math.round((goal.current / goal.criteriaValue) * 100)) + : 0} +
+
+ {goal.config.name} + + {#if goal.config.rewardType === 'cash'} + £{Number(goal.config.rewardValue).toFixed(2)} + {:else if goal.config.rewardType === 'prize'} + 🎁 + {:else} + {goal.config.rewardValue} pts + {/if} + +
+
+
+
+
+ {goal.current} / {goal.criteriaValue} + {#if goal.achieved} + ✅ earned + {:else} + {pct}% + {/if} +
+
+ {/each} +
+ {/if} +
@@ -865,8 +1136,11 @@ {/if}
-

📅 Weekly ({weeklyPending.length})

- {#if weeklyPending.length === 0} +

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

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

All done!

{:else} {#each weeklyPending as chore} @@ -876,6 +1150,38 @@ {chore.value} {chore.type} {/each} + {#each memberTodos.filter((t) => !isCompleted(t.id, todayChild)) as todo} + {@const urgency = todoUrgency(todo)} + {#if todo.type === 'emoji'} +
+ 📋 + {todo.customName || 'Todo'} + 🎯 +
+ {:else} + + {/if} + {/each} {/if}
@@ -926,7 +1232,9 @@ {r.value} pts {/if} - {#if isReq} + {#if r.rewardType === 'points'} + ✓ credited + {:else if isReq} ⏳ waiting {:else} - {/if} +
{/each} @@ -358,12 +325,7 @@

Competition

{#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg} {@const prog = progressByConfigId.get(cfg.id)} -
+
{cfg.name}
@@ -391,18 +353,16 @@
{/if} - {outstandingConfigs.includes(cfg) ? 'Outstanding' : p.state} + {p.state}
{/each}
{/if}
- {#if !outstandingConfigs.includes(cfg)} - - {/if} +
{/each} @@ -416,12 +376,7 @@

Collaboration

{#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg} {@const prog = progressByConfigId.get(cfg.id)} -
+
{cfg.name}
@@ -452,17 +407,15 @@ {cfg.type === 'threshold' ? 'pts' : 'chores'} - {outstandingConfigs.includes(cfg) ? 'Outstanding' : team.state} + {team.state}
{/if} {/if}
- {#if !outstandingConfigs.includes(cfg)} - - {/if} +
{/each} @@ -472,33 +425,6 @@
- {#if outstandingConfigs.length > 0} -
-

Outstanding — Issue to complete

-
- {#each outstandingConfigs as cfg} - {@const rewards = allRewards.filter( - (r) => r.bonusConfigId === cfg.id && r.status === 'unclaimed' - )} -
-
- {cfg.name} -
-
- {formatReward(cfg.rewardType, cfg.rewardValue)} -
- {#each rewards as r} -
- {memberName(r.memberId)} - -
- {/each} -
- {/each} -
-
- {/if} - {#if doneConfigs.length > 0}

Completed

@@ -919,10 +845,6 @@ .col-card.done { opacity: 0.75; } - .col-card.outstanding { - border-color: #f59e0b; - background: #fffbeb; - } .col-card.disabled { opacity: 0.5; filter: grayscale(0.5); @@ -1003,15 +925,6 @@ width: 20px; font-weight: 600; } - .claim-btn { - background: #6366f1; - color: white; - border: none; - padding: 0.2rem 0.5rem; - border-radius: 0.25rem; - cursor: pointer; - font-size: 0.75rem; - } .overlay { position: fixed; top: 0; @@ -1109,33 +1022,6 @@ font-size: 0.85rem; cursor: pointer; } - .outstanding-section { - margin-top: 2rem; - border-top: 2px solid #f59e0b; - padding-top: 1rem; - } - .outstanding-section h3 { - font-size: 1rem; - margin: 0 0 0.75rem; - } - .outstanding-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 0.5rem; - } - .outstanding-card { - background: #fffbeb; - border: 2px solid #f59e0b; - border-radius: 0.5rem; - padding: 0.5rem; - } - .claim-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.2rem 0; - font-size: 0.8rem; - } .completed-section { margin-top: 2rem; border-top: 2px solid #10b981; diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.server.ts b/frontend/src/routes/[fam]/[username]/chores/+page.server.ts index 64d2735..74aec87 100644 --- a/frontend/src/routes/[fam]/[username]/chores/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/chores/+page.server.ts @@ -86,18 +86,33 @@ export const actions = { const famId = event.locals.session.famId; const fd = await event.request.formData(); const id = fd.get('id') as string; + const isTodo = fd.get('isTodo') === '1'; const data: Record = {}; - const frequency = fd.get('frequency'); - const type = fd.get('type'); - const value = fd.get('value'); - const customName = fd.get('customName'); - const seasonIdsRaw = fd.get('seasonIds') as string; - if (frequency) data.frequency = frequency; - if (type) data.type = type; - if (value) data.value = parseFloat(value as string) || 0; - data.customName = (customName as string) || undefined; - if (seasonIdsRaw || seasonIdsRaw === '') { - data.seasonIds = seasonIdsRaw ? seasonIdsRaw.split(',').filter(Boolean) : []; + + if (isTodo) { + const type = fd.get('type') as string; + const customName = fd.get('customName') as string; + const completeBy = fd.get('completeBy') as string; + const startDate = fd.get('startDate') as string; + if (type) data.type = type; + if (type === 'emoji') data.value = 0; + else if (fd.get('value')) data.value = parseFloat(fd.get('value') as string) || 0; + data.customName = customName || undefined; + if (completeBy) data.completeBy = completeBy; + if (startDate) data.startDate = startDate; + } else { + const frequency = fd.get('frequency'); + const type = fd.get('type'); + const value = fd.get('value'); + const customName = fd.get('customName'); + const seasonIdsRaw = fd.get('seasonIds') as string; + if (frequency) data.frequency = frequency; + if (type) data.type = type; + if (value) data.value = parseFloat(value as string) || 0; + data.customName = (customName as string) || undefined; + if (seasonIdsRaw || seasonIdsRaw === '') { + data.seasonIds = seasonIdsRaw ? seasonIdsRaw.split(',').filter(Boolean) : []; + } } try { const record = await hono.admin.update(event, 'assigned-chores', famId, id, data); @@ -107,4 +122,59 @@ export const actions = { } }, + createTodo: async (event) => { + if (!event.locals.session) throw redirect(303, '/login'); + const famId = event.locals.session.famId; + const fd = await event.request.formData(); + const memberId = fd.get('memberId') as string; + const name = fd.get('name') as string; + const type = fd.get('type') as string; + const value = type === 'emoji' ? 0 : (parseFloat(fd.get('value') as string) || 0); + const todoStart = fd.get('todoStart') as string; + const todoCompleteBy = fd.get('todoCompleteBy') as string; + const customDate = fd.get('customDate') as string; + + if (!memberId || !name || !type) { + return fail(400, { error: 'Member, name, and type are required' }); + } + + // Compute startDate + const today = new Date().toISOString().slice(0, 10); + let startDate = today; + if (todoStart === 'next-week') { + const d = new Date(); + d.setDate(d.getDate() + (7 - d.getDay() + 1)); // next Monday + startDate = d.toISOString().slice(0, 10); + } + + // Compute completeBy + let completeBy = ''; + if (todoCompleteBy === 'custom' && customDate) { + completeBy = customDate; + } else { + // Next week Saturday + const d = new Date(); + d.setDate(d.getDate() + (7 - d.getDay() + 6)); + completeBy = d.toISOString().slice(0, 10); + } + + const data: Record = { + memberId, + frequency: 'weekly', + type, + value, + customName: name, + isTodo: true, + startDate, + completeBy, + }; + + try { + const record = await hono.admin.create(event, 'assigned-chores', famId, data); + return { record }; + } catch (e) { + return fail(400, { error: e instanceof Error ? e.message : 'Failed to create todo' }); + } + }, + }; diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.svelte b/frontend/src/routes/[fam]/[username]/chores/+page.svelte index 30a7926..c514f29 100644 --- a/frontend/src/routes/[fam]/[username]/chores/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/chores/+page.svelte @@ -2,192 +2,296 @@ import { enhance } from '$app/forms'; import { page } from '$app/state'; import { famStore } from '$lib/stores/fam.svelte'; - import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; + import { ViewHeader, CardGrid, Card } from '$lib/components'; import type { ChoreTemplate, AssignedChore, Member, Season } from '$lib/types'; let { data, form } = $props(); - let showCreateModal = $state(false) - let showEditModal = $state(false) - let showEditTemplateModal = $state(false) - let editingAssignment = $state(null) - let editingTemplate = $state(null) - let editMemberId = $state('') - let draggedTemplateId = $state(null) + let showCreateModal = $state(false); + let showEditModal = $state(false); + let showEditTemplateModal = $state(false); + let showTodoModal = $state(false); + let editingAssignment = $state(null); + let editingTemplate = $state(null); + let editMemberId = $state(''); + let draggedTemplateId = $state(null); + let todoMemberId = $state(''); - let createName = $state('') - let createFreq = $state('daily') - let createType = $state('points') - let createValue = $state(10) + let createName = $state(''); + let createFreq = $state('daily'); + let createType = $state('points'); + let createValue = $state(10); - let editFreq = $state('daily') - let editType = $state('points') - let editValue = $state(10) - let editCustomName = $state('') + // Todo form state + let todoName = $state(''); + let todoType = $state<'points' | 'emoji'>('points'); + let todoValue = $state(10); + let todoStart = $state<'now' | 'next-week'>('now'); + let todoCompleteBy = $state<'next-week' | 'custom'>('next-week'); + let todoCustomDate = $state(''); - let editTplName = $state('') - let editTplFreq = $state('daily') - let editTplType = $state('points') - let editTplValue = $state(10) + let editFreq = $state('daily'); + let editType = $state('points'); + let editValue = $state(10); + let editCustomName = $state(''); + let editTodoCompleteBy = $state(''); + let editTodoStartDate = $state(''); - let seasonFilter = $state('all') - let editSeasonIds = $state([]) + let editTplName = $state(''); + let editTplFreq = $state('daily'); + let editTplType = $state('points'); + let editTplValue = $state(10); - let templates = $state(famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[] || [])) - let members = $state(famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[] || [])) - let assigned = $derived(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[] || [])) + let seasonFilter = $state('all'); + let editSeasonIds = $state([]); - let seasons = $state(famStore.initialized ? famStore.seasons : (data.seasons || [])) + let templates = $state( + famStore.initialized + ? (famStore.templates as ChoreTemplate[]) + : (data.templates as ChoreTemplate[]) || [] + ); + let members = $state( + famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[]) || [] + ); + let assigned = $derived( + famStore.initialized + ? (famStore.assigned as AssignedChore[]) + : (data.assigned as AssignedChore[]) || [] + ); + + let seasons = $state(famStore.initialized ? famStore.seasons : data.seasons || []); let filteredAssigned = $derived( - seasonFilter === 'all' - ? assigned - : assigned.filter(a => a.seasonIds?.includes(seasonFilter)) - ) + seasonFilter === 'all' ? assigned : assigned.filter((a) => a.seasonIds?.includes(seasonFilter)) + ); function assignedForMember(memberId: string): AssignedChore[] { - return filteredAssigned.filter(a => a.memberId === memberId) + return filteredAssigned.filter((a) => a.memberId === memberId && !a.isTodo); + } + + function todosForMember(memberId: string): AssignedChore[] { + return filteredAssigned.filter((a) => a.memberId === memberId && a.isTodo); } function assignedForMemberAll(memberId: string): AssignedChore[] { - return assigned.filter(a => a.memberId === memberId) + return assigned.filter((a) => a.memberId === memberId && !a.isTodo); } function isGlobalChore(a: AssignedChore): boolean { - return !a.seasonIds || a.seasonIds.length === 0 + return !a.seasonIds || a.seasonIds.length === 0; } - function templateName(templateId: string): string { - return famStore.templateMap().get(templateId)?.name || '?' + function templateName(templateId: string | undefined): string { + if (!templateId) return '?'; + return famStore.templateMap().get(templateId)?.name || '?'; } function seasonName(id: string): string { - return seasons.find(s => s.id === id)?.name || id + return seasons.find((s) => s.id === id)?.name || id; } function seasonNames(a: AssignedChore): string { - return (a.seasonIds || []).map(id => seasonName(id)).join(', ') || 'Global' + return (a.seasonIds || []).map((id) => seasonName(id)).join(', ') || 'Global'; } function getDefault(templateId: string): { frequency: string; type: string; value: number } { - const t = famStore.templateMap().get(templateId) - if (!t) return { frequency: 'daily', type: 'points', value: 10 } - return { frequency: t.defaultFrequency, type: t.defaultType, value: t.defaultValue } + const t = famStore.templateMap().get(templateId); + if (!t) return { frequency: 'daily', type: 'points', value: 10 }; + return { frequency: t.defaultFrequency, type: t.defaultType, value: t.defaultValue }; } function handleDragStart(e: DragEvent, templateId: string) { - draggedTemplateId = templateId - e.dataTransfer?.setData('text/plain', templateId) - if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy' + draggedTemplateId = templateId; + e.dataTransfer?.setData('text/plain', templateId); + if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'; } function handleDragOver(e: DragEvent) { - e.preventDefault() - if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy' + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; } async function handleDrop(e: DragEvent, memberId: string) { - e.preventDefault() - const tid = e.dataTransfer?.getData('text/plain') || draggedTemplateId - if (!tid) return - draggedTemplateId = null + e.preventDefault(); + const tid = e.dataTransfer?.getData('text/plain') || draggedTemplateId; + if (!tid) return; + draggedTemplateId = null; - const s = page.data.session as any - if (!s) return - const def = getDefault(tid) - const body: Record = { memberId, templateId: tid, frequency: def.frequency, type: def.type, value: def.value } - if (seasonFilter !== 'all') body.seasonIds = [seasonFilter] + const s = page.data.session as any; + if (!s) return; + const def = getDefault(tid); + const body: Record = { + memberId, + templateId: tid, + frequency: def.frequency, + type: def.type, + value: def.value + }; + if (seasonFilter !== 'all') body.seasonIds = [seasonFilter]; await fetch(`/api/admin/${s.famId}/assigned-chores`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'x-session-famid': s.famId, 'x-session-userid': s.userId }, - body: JSON.stringify(body), - }) + headers: { + 'Content-Type': 'application/json', + 'x-session-famid': s.famId, + 'x-session-userid': s.userId + }, + body: JSON.stringify(body) + }); + } + + // Accordion state per member: which section is open + let accordionState = $state>({}); + + function toggleAccordion(memberId: string, section: 'chores' | 'todos') { + const cur = accordionState[memberId] ?? { chores: false, todos: false }; + accordionState[memberId] = { ...cur, [section]: !cur[section] }; + } + + function openTodo(memberId: string) { + todoMemberId = memberId; + todoName = ''; + todoType = 'points'; + todoValue = 10; + todoStart = 'now'; + todoCompleteBy = 'custom'; + todoCustomDate = defaultCompleteByDate(); + showTodoModal = true; + } + + // Sort todos by completeBy (soonest first), then by startDate + function sortedTodosForMember(memberId: string): AssignedChore[] { + return todosForMember(memberId).sort((a, b) => { + const da = a.completeBy || '9999-99-99'; + const db = b.completeBy || '9999-99-99'; + if (da !== db) return da.localeCompare(db); + return (a.startDate || '').localeCompare(b.startDate || ''); + }); + } + + // Format DDMMYY for todo deadline display + function formatDDMMYY(dateStr: string | undefined): string { + if (!dateStr) return ''; + const d = new Date(dateStr + 'T00:00:00'); + 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}`; + } + + // 7 days from today as YYYY-MM-DD for the todo modal default + function defaultCompleteByDate(): string { + const d = new Date(); + d.setDate(d.getDate() + 7); + return d.toISOString().slice(0, 10); + } + + // Traffic light for admin todo cards (same logic as child page) + function todoUrgency(a: AssignedChore): 'blue' | 'green' | 'orange' | 'red' | 'black' { + if (!a.completeBy) return 'blue'; + const today = new Date().toISOString().slice(0, 10); + const days = Math.round( + (new Date(a.completeBy + 'T00:00:00').getTime() - new Date(today + 'T00:00:00').getTime()) / + 86400000 + ); + if (days <= 0) return 'black'; + if (days === 1) return 'red'; + if (days === 2) return 'orange'; + if (days === 3) return 'green'; + return 'blue'; } function openEdit(a: AssignedChore) { - editingAssignment = a - editFreq = a.frequency - editType = a.type - editValue = a.value - editCustomName = a.customName || '' - editSeasonIds = a.seasonIds ? [...a.seasonIds] : [] - showEditModal = true + editingAssignment = a; + editFreq = a.frequency; + editType = a.type; + editValue = a.value; + editCustomName = a.customName || ''; + editSeasonIds = a.seasonIds ? [...a.seasonIds] : []; + editTodoCompleteBy = a.completeBy || ''; + editTodoStartDate = a.startDate || ''; + showEditModal = true; } function closeEdit() { - showEditModal = false - editingAssignment = null + showEditModal = false; + editingAssignment = null; } function openEditTemplate(t: ChoreTemplate) { - editingTemplate = t - editTplName = t.name - editTplFreq = t.defaultFrequency - editTplType = t.defaultType - editTplValue = t.defaultValue - showEditTemplateModal = true + editingTemplate = t; + editTplName = t.name; + editTplFreq = t.defaultFrequency; + editTplType = t.defaultType; + editTplValue = t.defaultValue; + showEditTemplateModal = true; } function closeEditTemplate() { - showEditTemplateModal = false - editingTemplate = null + showEditTemplateModal = false; + editingTemplate = null; } function enhanceCreate() { return async ({ result }: any) => { if (result.type === 'success') { - showCreateModal = false - const rec = result.data?.record + showCreateModal = false; + const rec = result.data?.record; if (rec) { - templates = [rec as ChoreTemplate, ...templates] + templates = [rec as ChoreTemplate, ...templates]; } } - } + }; } function enhanceUpdateAssigned() { return async ({ result }: any) => { if (result.type === 'success' && editingAssignment) { - const idx = assigned.findIndex((a: any) => a.id === editingAssignment!.id) + const idx = assigned.findIndex((a: any) => a.id === editingAssignment!.id); if (idx !== -1) { - assigned[idx] = { - ...assigned[idx], - frequency: editFreq, - type: editType, - value: editValue, - customName: editCustomName || undefined, - seasonIds: editSeasonIds.length > 0 ? editSeasonIds : [], - } as AssignedChore - } + const updates: Partial = { + customName: editCustomName || undefined + }; + if (editingAssignment.isTodo) { + updates.type = editType; + updates.value = editValue; + updates.completeBy = editTodoCompleteBy; + updates.startDate = editTodoStartDate; + } else { + updates.frequency = editFreq; + updates.type = editType; + updates.value = editValue; + updates.seasonIds = editSeasonIds.length > 0 ? editSeasonIds : []; + } + assigned[idx] = { ...assigned[idx], ...updates } as AssignedChore; + } } - closeEdit() - } + closeEdit(); + }; } function enhanceUpdateTemplate() { return async ({ result, formData }: any) => { if (result.type === 'success' && editingTemplate) { - const tid = editingTemplate!.id - const idx = templates.findIndex((t: any) => t.id === tid) - if (idx !== -1) { - templates[idx] = { - ...templates[idx], - name: editTplName, - defaultFrequency: editTplFreq, - defaultType: editTplType, - defaultValue: editTplValue, - } as ChoreTemplate - assigned = assigned.map((a: any) => - a.templateId === tid - ? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue } - : a - ) as AssignedChore[] - closeEditTemplate() + const tid = editingTemplate!.id; + const idx = templates.findIndex((t: any) => t.id === tid); + if (idx !== -1) { + templates[idx] = { + ...templates[idx], + name: editTplName, + defaultFrequency: editTplFreq, + defaultType: editTplType, + defaultValue: editTplValue + } as ChoreTemplate; + assigned = assigned.map((a: any) => + a.templateId === tid + ? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue } + : a + ) as AssignedChore[]; + closeEditTemplate(); + } } - } + }; } -} - @@ -208,93 +312,169 @@ -
- -
-
-

Templates

- {#each templates as t} -
handleDragStart(e, t.id)} - > -
openEditTemplate(t)} role="button" tabindex="0"> - {t.name} - {t.defaultFrequency} - {t.defaultType} -
-
- {t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue} -
-
- -
{ return async ({ result, formData }) => { if (result.type === 'success') { const id = formData.get('id'); templates = templates.filter((t) => t.id !== id) as ChoreTemplate[]; } }; }}> - - -
-
-
- {/each} - {#if templates.length === 0} -

No templates yet

- {/if} -
- - {#each members as m} -
handleDrop(e, m.id)} - > -

- - {m.name} -

- {#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}
-
- {seasonNames(a)} -
+
+

+ Templates + class="add-todo-btn" + title="New Chore Template" + onclick={() => (showCreateModal = true)}>+ +

+ {#each templates as t} +
handleDragStart(e, t.id)}> +
openEditTemplate(t)} role="button" tabindex="0"> + {t.name} + {t.defaultFrequency} + {t.defaultType} +
+
+ {t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue} +
+
+ +
{ + return async ({ result, formData }) => { + if (result.type === 'success') { + const id = formData.get('id'); + templates = templates.filter((t) => t.id !== id) as ChoreTemplate[]; + } + }; + }} + > + + +
+
+
+ {/each} + {#if templates.length === 0} +

No templates yet

+ {/if} +
+ + {#each members as m} +
handleDrop(e, m.id)} + > +

+ + {m.name} + +

+ + + + {#if accordionState[m.id]?.todos} + {#each sortedTodosForMember(m.id) as a} + {@const urgency = todoUrgency(a)} +
openEdit(a)} + role="button" + tabindex="0" + > +
+ {a.customName || 'Todo'} +
+ {#if a.type === 'emoji'} + 🎯 emoji + {:else} + {a.value} {a.type} + {/if} + {#if a.completeBy} + due {formatDDMMYY(a.completeBy)} + {/if} +
+
+ +
+ {/each} + {#if todosForMember(m.id).length === 0} + + {/if} + {/if} + + + + {#if accordionState[m.id]?.chores} + {#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} +
+ +
+ {/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}
- {/each} -
- - + {#if showCreateModal} -
showCreateModal = false} role="presentation"> +
(showCreateModal = false)} role="presentation">