From 8388868bee7ff606f63bfa86fc560401d19a25e1 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Thu, 24 Sep 2026 12:50:20 +0100 Subject: [PATCH] enable kids todos --- frontend/src/lib/client/api.ts | 3 + frontend/src/lib/stores/fam.svelte.ts | 7 +- frontend/src/routes/[fam]/+page.svelte | 18 ++- .../src/routes/[fam]/[username]/+page.svelte | 152 ++++++++++++++++++ frontend/src/routes/api/todos/+server.ts | 47 ++++++ 5 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 frontend/src/routes/api/todos/+server.ts diff --git a/frontend/src/lib/client/api.ts b/frontend/src/lib/client/api.ts index c4e0d66..52556c5 100644 --- a/frontend/src/lib/client/api.ts +++ b/frontend/src/lib/client/api.ts @@ -23,6 +23,9 @@ export const memberApi = { async toggleCompletion(famId: string, assignedChoreId: string, date: string, completedAt?: string) { return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date, completedAt }); }, + async createTodo(name: string) { + return memberFetch<{ record: unknown }>('POST', '/api/todos', { name }); + }, async claimReward(famId: string, rewardId: string) { return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`); }, diff --git a/frontend/src/lib/stores/fam.svelte.ts b/frontend/src/lib/stores/fam.svelte.ts index 8d55738..f536d76 100644 --- a/frontend/src/lib/stores/fam.svelte.ts +++ b/frontend/src/lib/stores/fam.svelte.ts @@ -321,7 +321,12 @@ class FamStore { // and from PB subscribe SSE for multi-user realtime. applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') { const apply = (list: T[]): T[] => { - if (action === 'create') return [record, ...list]; + // A create for an id we already hold (optimistic add + SSE echo) is + // an update, not a second row. + if (action === 'create') + return list.some((x) => x.id === record.id) + ? list.map((x) => (x.id === record.id ? { ...x, ...record } : x)) + : [record, ...list]; if (action === 'update') return list.map((x) => (x.id === record.id ? { ...x, ...record } : x)); if (action === 'delete') return list.filter((x) => x.id !== record.id); diff --git a/frontend/src/routes/[fam]/+page.svelte b/frontend/src/routes/[fam]/+page.svelte index f40df27..f9f7895 100644 --- a/frontend/src/routes/[fam]/+page.svelte +++ b/frontend/src/routes/[fam]/+page.svelte @@ -694,7 +694,7 @@ border: 1px solid #e5e7eb; border-top: 4px solid #6366f1; border-radius: 12px; - padding: 0.75rem; + padding: 0.9rem; background: #fafbff; } .col-member { @@ -709,6 +709,7 @@ gap: 0.4rem; flex: 1; min-width: 0; + margin-bottom: 0.35rem; } .dot { display: inline-block; @@ -741,8 +742,8 @@ display: flex; justify-content: center; flex-wrap: wrap; - gap: 0.6rem; - margin: 0.9rem 0 0.7rem; + gap: 0.7rem; + margin: 1.1rem 0 1.2rem; } .donut-wrap { position: relative; @@ -780,19 +781,20 @@ .member-stats { display: flex; flex-direction: column; - gap: 0.3rem; + gap: 0.45rem; margin-bottom: 0.5rem; } .member-stats.compact .mstat { display: flex; align-items: baseline; - gap: 0.4rem; - font-size: 0.76rem; + gap: 0.5rem; + font-size: 0.8rem; + line-height: 1.5; color: #374151; background: rgba(255, 255, 255, 0.65); border: 1px solid #e5e7eb; - border-radius: 8px; - padding: 0.28rem 0.5rem; + border-radius: 10px; + padding: 0.45rem 0.65rem; white-space: nowrap; overflow: hidden; } diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 2177664..b12304a 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -266,6 +266,29 @@ let todayChild = $derived(todayInTz(famTz)); let togglingIds = $state>(new Set()); + // ─── Kid-created todos ─── + let showTodoModal = $state(false); + let newTodoName = $state(''); + let todoSaving = $state(false); + let todoError = $state(''); + async function submitTodo() { + const name = newTodoName.trim(); + if (!name || todoSaving) return; + todoSaving = true; + todoError = ''; + try { + const res = await memberApi.createTodo(name); + // Instant UI — the SSE echo dedupes on id via applyRecord. + famStore.applyRecord('assigned_chores', res.record, 'create'); + newTodoName = ''; + showTodoModal = false; + } catch (e) { + todoError = e instanceof Error ? e.message : 'Could not add todo'; + } finally { + todoSaving = false; + } + } + // ─── Payday countdown (child view) ─── let nowMs = $state(Date.now()); let eowFired = $state(false); @@ -1765,6 +1788,9 @@ {/if} {/each} {/if} +

{@html checkCircleIcon} Done ({completedToday.length})

@@ -1799,6 +1825,45 @@ {/if} + {#if showTodoModal} + + + {/if}
{@html walletIcon} Wallet @@ -3001,6 +3066,93 @@ padding: 1px 5px; flex-shrink: 0; } + .add-todo-cta { + width: 100%; + margin-top: 0.5rem; + padding: 0.55rem; + border: 1.5px dashed #c7d2fe; + border-radius: 10px; + background: #eef2ff; + color: #4338ca; + font-size: 0.82rem; + font-weight: 700; + cursor: pointer; + } + .add-todo-cta:hover { + background: #e0e7ff; + border-color: #818cf8; + } + .modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 1000; + } + .modal-card { + background: #fff; + border-radius: 14px; + padding: 1.25rem; + width: 100%; + max-width: 380px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25); + } + .modal-card h3 { + margin: 0 0 0.25rem; + font-size: 1.1rem; + } + .modal-sub { + margin: 0 0 0.85rem; + font-size: 0.85rem; + color: #6b7280; + } + .modal-input { + width: 100%; + box-sizing: border-box; + border: 1.5px solid #d1d5db; + border-radius: 10px; + padding: 0.6rem 0.75rem; + font-size: 0.95rem; + } + .modal-input:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px #e0e7ff; + } + .modal-error { + margin: 0.5rem 0 0; + font-size: 0.8rem; + color: #dc2626; + } + .modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; + } + .modal-btn { + border: none; + border-radius: 10px; + padding: 0.55rem 1rem; + font-size: 0.85rem; + font-weight: 700; + cursor: pointer; + } + .modal-btn:disabled { + opacity: 0.5; + cursor: default; + } + .modal-cancel { + background: #f3f4f6; + color: #374151; + } + .modal-add { + background: #4338ca; + color: #fff; + } .todo-main { display: flex; flex-direction: column; diff --git a/frontend/src/routes/api/todos/+server.ts b/frontend/src/routes/api/todos/+server.ts new file mode 100644 index 0000000..346c8b1 --- /dev/null +++ b/frontend/src/routes/api/todos/+server.ts @@ -0,0 +1,47 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { pbAdmin } from '$lib/server/pocketbase'; +import { weekStart, addDaysStr, todayInTz, resolveTz } from '@shared/timezone'; + +// Kids can create todos for THEMSELVES only. Writes go through the superuser +// client because `assigned_chores.createRule` is parent-only — but every +// privileged field is forced server-side: memberId = session user, emoji type +// with zero value (so nobody can mint cash/points for themselves), weekly +// frequency, this-week start/completeBy (so it expires and purges naturally). +export async function POST(event: RequestEvent) { + const u = event.locals.user; + if (!u) throw error(401, 'Unauthorized'); + const body = await event.request.json().catch(() => ({})); + const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : ''; + if (!name) throw error(400, 'Give your todo a name'); + + let payday = 1; + let tz = 'UTC'; + try { + const fam: any = await pbAdmin.getOne('fams', u.famId); + if (fam.payday !== undefined && fam.payday !== null) payday = Number(fam.payday); + tz = resolveTz(fam.timezone || 'auto'); + } catch { + tz = resolveTz('auto'); + } + const today = todayInTz(tz); + const completeBy = addDaysStr(weekStart(payday, tz), 6); + + try { + const record = await pbAdmin.create('assigned_chores', { + famId: u.famId, + memberId: u.id, + frequency: 'weekly', + type: 'emoji', + value: 0, + customName: name, + isTodo: true, + emoji: '🎯', + startDate: today, + completeBy + }); + return json({ record }); + } catch (e) { + throw error(400, e instanceof Error ? e.message : 'Could not add todo'); + } +}