diff --git a/frontend/package.json b/frontend/package.json index 2c8da25..5fa09a9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@hiseb/confetti": "^2.0.2", + "@lucide/svelte": "^1.34.0", "@stripe/stripe-js": "^9.13.0", "chart.js": "^4.4.0", "pocketbase": "^0.27.0", diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index 56145ea..eda4e65 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -322,15 +322,47 @@ async function ensureFamFields(): Promise { } export async function migrate(): Promise { - console.log("[migrate] Checking PB collection schemas..."); - await ensureSchema(); - // Runs even when the schema already exists (unlike ensureSchema's early - // return) so new platform collections/fields/seed land on existing installs. - await ensureFamFields(); - await ensureBonusFields(); - await ensureAccessCodes(); - await ensurePlatform(); - console.log("[migrate] Done"); + console.log("[migrate] Checking PB collection schemas..."); + await ensureSchema(); + // Runs even when the schema already exists (unlike ensureSchema's early + // return) so new platform collections/fields/seed land on existing installs. + await ensureFamFields(); + await ensureBonusFields(); + await ensureTemplateFields(); + await ensureAssignedChoreFields(); + await ensureAccessCodes(); + await ensurePlatform(); + console.log("[migrate] Done"); +} + +// Add colour / icon / description to `assigned_chores` on existing installs so +// family-admins can override a template's look per assignment. Idempotent. +async function ensureAssignedChoreFields(): Promise { + const col = await getCollection("assigned_chores"); + if (!col) return; + const has = (n: string) => col.fields.some((f: any) => f.name === n); + const needed: any[] = []; + if (!has("description")) needed.push({ name: "description", type: "text", required: false }); + if (!has("icon")) needed.push({ name: "icon", type: "text", required: false }); + if (!has("color")) needed.push({ name: "color", type: "text", required: false }); + if (!has("emoji")) needed.push({ name: "emoji", type: "text", required: false }); + // Todos can be celebration-only (emoji) as well as points/money. + let changed = !!needed.length; + const typeField = col.fields.find((f: any) => f.name === "type"); + if (typeField && Array.isArray(typeField.values) && !typeField.values.includes("emoji")) { + typeField.values = [...typeField.values, "emoji"]; + changed = true; + } + if (changed) { + await updateCollection(col.id, { ...col, fields: [...col.fields] }); + } + const comp = await getCollection("completions"); + if (comp && !comp.fields.some((f: any) => f.name === "rewardId")) { + await updateCollection(comp.id, { + ...comp, + fields: [...comp.fields, { name: "rewardId", type: "text", required: false }], + }); + } } // Add the pocket-money fields to bonus collections on existing installs, and @@ -367,6 +399,21 @@ async function ensureBonusFields(): Promise { async function backfillPocketMoney(): Promise { const t = await auth(); + // Flag legacy pocket-money configs that predate the isPocketMoney field so + // the dashboard recognises them (and we don't create duplicates below). + const legacyRes = await fetch( + `${PB_ENDPOINT}/api/collections/bonus_configs/records?filter=${encodeURIComponent( + "isPocketMoney!=true && name~'pocket' && thresholdType='percent'" + )}&perPage=500`, + { headers: { Authorization: `Bearer ${t}` } } + ); + for (const rec of (await legacyRes.json())?.items || []) { + await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records/${rec.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify({ isPocketMoney: true }), + }).catch(() => {}); + } const childrenRes = await fetch( `${PB_ENDPOINT}/api/collections/users/records?filter=role='child'&perPage=500`, { headers: { Authorization: `Bearer ${t}` } } @@ -399,4 +446,143 @@ async function backfillPocketMoney(): Promise { }).catch(() => {}); } console.log("[migrate] Pocket-money droplets ensured."); -} \ No newline at end of file +} + +// Promote chore_templates / bonus_templates to platform-owned: add global/icon/ +// color fields, make famId optional, and relax read rules so any family can see +// global (platform) templates as assignable droplets. Idempotent. Seeds a +// starter set of global templates on first run for out-of-the-box usage. +async function ensureTemplateFields(): Promise { + const readRule = "global = true || famId = @request.auth.famId"; + for (const name of ["chore_templates", "bonus_templates"]) { + const col = await getCollection(name); + if (!col) continue; + const has = (n: string) => col.fields.some((f: any) => f.name === n); + let changed = false; + const fields = [...col.fields]; + for (const f of [ + { name: "global", type: "bool", required: false }, + { name: "icon", type: "text", required: false }, + { name: "color", type: "text", required: false }, + ]) { + if (!has(f.name)) { + fields.push(f); + changed = true; + } + } + const famId = fields.find((f: any) => f.name === "famId"); + if (famId && famId.required) { + famId.required = false; + changed = true; + } + if (col.listRule !== readRule || col.viewRule !== readRule) { + col.listRule = readRule; + col.viewRule = readRule; + changed = true; + } + if (changed) await updateCollection(col.id, { ...col, fields }); + } + await seedGlobalTemplates(); +} + +const GLOBAL_TEMPLATE_ICONS = [ + "Bed", + "Sparkles", + "BookOpen", + "Utensils", + "Wallet", + "Trophy", + "Star", + "Moon", + "Sun", + "Apple", + "Car", + "Gamepad2", + "Music", + "Shirt", + "Leaf", + "Heart", + "Gift", + "Timer", +]; + +async function seedGlobalTemplates(): Promise { + const t = await auth(); + const countGlobal = async (name: string) => { + const res = await fetch( + `${PB_ENDPOINT}/api/collections/${name}/records?filter=global=true&perPage=1`, + { headers: { Authorization: `Bearer ${t}` } } + ); + return ((await res.json())?.items?.length) || 0; + }; + if ((await countGlobal("chore_templates")) > 0 || (await countGlobal("bonus_templates")) > 0) { + console.log("[migrate] Global templates already present โ€” skipping seed."); + return; + } + + const choreSeeds = [ + { name: "Make Bed", defaultFrequency: "daily", defaultType: "points", defaultValue: 2, icon: "Bed", color: "#6366f1" }, + { name: "Tidy Room", defaultFrequency: "weekly", defaultType: "points", defaultValue: 10, icon: "Sparkles", color: "#8b5cf6" }, + { name: "Homework", defaultFrequency: "daily", defaultType: "points", defaultValue: 5, icon: "BookOpen", color: "#0ea5e9" }, + { name: "Set Table", defaultFrequency: "daily", defaultType: "money", defaultValue: 1, icon: "Utensils", color: "#10b981" }, + ]; + const bonusSeeds = [ + { + name: "Pocket Money", + target: "individual", + type: "threshold", + thresholdType: "percent", + occurrence: "recurring", + rewardType: "cash", + rewardValue: "", + criteriaValue: 50, + period: "weekly", + isPocketMoney: true, + icon: "Wallet", + color: "#22c55e", + }, + { + name: "Reading Bonus", + target: "individual", + type: "count", + occurrence: "recurring", + rewardType: "points", + rewardValue: "50", + criteriaValue: 5, + period: "weekly", + icon: "BookOpen", + color: "#f59e0b", + }, + { + name: "Star of the Week", + target: "competitive", + type: "threshold", + thresholdType: "points", + occurrence: "weekly", + rewardType: "prize", + rewardValue: "Treat", + criteriaValue: 100, + period: "weekly", + icon: "Trophy", + color: "#ef4444", + }, + ]; + + for (const s of choreSeeds) { + await fetch(`${PB_ENDPOINT}/api/collections/chore_templates/records`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify({ ...s, global: true }), + }).catch(() => {}); + } + for (const s of bonusSeeds) { + await fetch(`${PB_ENDPOINT}/api/collections/bonus_templates/records`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` }, + body: JSON.stringify({ ...s, global: true }), + }).catch(() => {}); + } + console.log("[migrate] Seeded global templates."); +} + +export { GLOBAL_TEMPLATE_ICONS }; \ No newline at end of file diff --git a/frontend/src/lib/server/services/completions.ts b/frontend/src/lib/server/services/completions.ts index c8166ab..d47fc61 100644 --- a/frontend/src/lib/server/services/completions.ts +++ b/frontend/src/lib/server/services/completions.ts @@ -53,15 +53,33 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a .getFullList({ filter }); if (existing?.length > 0) { await pb.collection('completions').delete(existing[0].id); + // Un-completing a cash todo removes its one-time reward. + if (isTodo && existing[0].rewardId) { + await pb.collection('rewards').delete(existing[0].rewardId).catch(() => {}); + } evaluateFam(pb, famId).catch(() => {}); return { completed: false }; } + let rewardId: string | undefined; + if (isTodo && chore?.type === 'money' && Number(chore.value) > 0) { + const reward = await pb.collection('rewards').create({ + famId, + memberId, + label: chore.customName || 'Cash todo', + value: Number(chore.value), + rewardType: 'cash', + status: 'unclaimed', + date + }); + rewardId = reward.id; + } const record = await pb.collection('completions').create({ famId, memberId, assignedChoreId, date, - completedAt: new Date().toISOString() + completedAt: new Date().toISOString(), + ...(rewardId ? { rewardId } : {}) }); evaluateFam(pb, famId).catch(() => {}); return { completed: true, record }; diff --git a/frontend/src/lib/server/services/crud.ts b/frontend/src/lib/server/services/crud.ts index faf9583..dedf775 100644 --- a/frontend/src/lib/server/services/crud.ts +++ b/frontend/src/lib/server/services/crud.ts @@ -6,10 +6,10 @@ const RESOURCES: Record< string, { col: string; listFilter: (famId: string) => string; bonus?: 'config' | 'template' } > = { - 'chore-templates': { col: 'chore_templates', listFilter: (f) => `famId = '${f}'` }, + 'chore-templates': { col: 'chore_templates', listFilter: (f) => `(famId = '${f}' || global = true)` }, members: { col: 'users', listFilter: (f) => `famId = '${f}' && role = 'child'` }, 'assigned-chores': { col: 'assigned_chores', listFilter: (f) => `famId = '${f}'` }, - 'bonus-templates': { col: 'bonus_templates', listFilter: (f) => `famId = '${f}'`, bonus: 'template' }, + 'bonus-templates': { col: 'bonus_templates', listFilter: (f) => `(famId = '${f}' || global = true)`, bonus: 'template' }, 'bonus-configs': { col: 'bonus_configs', listFilter: (f) => `famId = '${f}'`, bonus: 'config' }, completions: { col: 'completions', listFilter: (f) => `famId = '${f}'` }, rewards: { col: 'rewards', listFilter: (f) => `famId = '${f}'` }, diff --git a/frontend/src/lib/stores/fam.svelte.ts b/frontend/src/lib/stores/fam.svelte.ts index 6b1a369..91c1ef4 100644 --- a/frontend/src/lib/stores/fam.svelte.ts +++ b/frontend/src/lib/stores/fam.svelte.ts @@ -99,7 +99,7 @@ class FamStore { pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }) as Promise, - pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise< + pb.collection('chore_templates').getFullList({ filter: `(famId = '${famId}' || global = true)` }) as Promise< ChoreTemplate[] >, pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise< @@ -111,7 +111,7 @@ class FamStore { pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise< BonusConfig[] >, - pb.collection('bonus_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise< + pb.collection('bonus_templates').getFullList({ filter: `(famId = '${famId}' || global = true)` }) as Promise< BonusTemplate[] >, pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise< @@ -146,11 +146,11 @@ class FamStore { private async subscribe() { const subs: { collection: CollectionName; filter: string }[] = [ { collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` }, - { collection: 'chore_templates', filter: `famId = '${this.famId}'` }, + { collection: 'chore_templates', filter: `(famId = '${this.famId}' || global = true)` }, { collection: 'assigned_chores', filter: `famId = '${this.famId}'` }, { collection: 'completions', filter: `famId = '${this.famId}'` }, { collection: 'bonus_configs', filter: `famId = '${this.famId}'` }, - { collection: 'bonus_templates', filter: `famId = '${this.famId}'` }, + { collection: 'bonus_templates', filter: `(famId = '${this.famId}' || global = true)` }, { collection: 'rewards', filter: `famId = '${this.famId}'` }, { collection: 'seasons', filter: `famId = '${this.famId}'` } ]; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index de14161..a562fc3 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -23,6 +23,9 @@ export interface BonusTemplate { criteriaValue?: number; period?: BonusPeriod; isPocketMoney?: boolean; + global?: boolean; + icon?: string; + color?: string; created: string; updated: string; } @@ -71,6 +74,9 @@ export interface ChoreTemplate { defaultFrequency: Frequency; defaultType: RewardType; defaultValue: number; + global?: boolean; + icon?: string; + color?: string; created: string; updated: string; } @@ -84,6 +90,10 @@ export interface AssignedChore { type: RewardType; value: number; customName?: string; + description?: string; + icon?: string; + color?: string; + emoji?: string; seasonIds?: string[]; isTodo?: boolean; startDate?: string; diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 4c9a13f..f190094 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -124,7 +124,7 @@ setTimeout(() => (toast = ''), 4000); } - function handleResult({ result }, onSuccess = null) { + function handleResult({ result }: any, onSuccess: ((d: any) => void) | null = null) { const d = result.data || {}; if (d.error) showToast(d.error); else if (result.type === 'success') { @@ -273,6 +273,38 @@ }) ); + // Celebration badge โ€” completed todos from the last 24h, dismissed per-device + // via localStorage (no PB storage; auto-expires after 24h). + let celebrateDismissed = $state([]); + if (typeof localStorage !== 'undefined') { + celebrateDismissed = JSON.parse(localStorage.getItem('celebrateDismissed') || '[]'); + } + function dismissCelebrate(id: string) { + if (!celebrateDismissed.includes(id)) { + celebrateDismissed = [...celebrateDismissed, id]; + try { + localStorage.setItem('celebrateDismissed', JSON.stringify(celebrateDismissed)); + } catch {} + } + } + let celebrations = $derived.by(() => { + const cutoff = Date.now() - 24 * 3600 * 1000; + return completions + .filter((c: Completion) => { + if (c.id.startsWith('optimistic-')) return false; + const at = c.completedAt ? new Date(c.completedAt).getTime() : 0; + if (!at || at < cutoff) return false; + const chore = assigned.find((a) => a.id === c.assignedChoreId); + return !!chore?.isTodo && !celebrateDismissed.includes(c.id); + }) + .map((c: Completion) => { + const chore = assigned.find((a) => a.id === c.assignedChoreId); + const emoji = + chore?.type === 'emoji' ? chore.emoji || '๐ŸŽ‰' : chore?.type === 'money' ? '๐Ÿ’ธ' : 'โญ'; + return { id: c.id, emoji, name: chore?.customName || 'Todo' }; + }); + }); + let dailyPending = $derived( memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, todayChild)) ); @@ -640,11 +672,13 @@
{m.name} - + {#if !(pm && pm.rewardValue)} + + {/if}
Points: {s?.pointsEarned ?? 0} @@ -1163,6 +1197,17 @@ const res = await fetch('/api/members', {
{:else} + {#if celebrations.length > 0} +
+ {#each celebrations as cel} +
+ {cel.emoji} + {cel.name} complete โ€” amazing work! + +
+ {/each} +
+ {/if}

๐ŸŽฏ Daily ({dailyPending.length})

@@ -1206,7 +1251,7 @@ const res = await fetch('/api/members', { ๐Ÿ“‹ {todo.customName || 'Todo'} todo - ๐ŸŽฏ + {todo.emoji || '๐ŸŽฏ'}
{:else} {/if} {/each} @@ -1879,6 +1928,48 @@ const res = await fetch('/api/members', { } /* โ”€โ”€ Kanban โ”€โ”€ */ + .celebrations { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.75rem; + } + .celebrate-card { + display: flex; + align-items: center; + gap: 0.6rem; + background: linear-gradient(90deg, #fef3c7, #fce7f3); + border: 1px solid #fcd34d; + border-radius: 12px; + padding: 0.55rem 0.85rem; + animation: celebrate-pop 0.35s ease-out; + } + @keyframes celebrate-pop { + from { + transform: scale(0.9); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } + } + .celebrate-emoji { + font-size: 1.6rem; + } + .celebrate-text { + flex: 1; + font-size: 0.85rem; + color: #78350f; + } + .celebrate-close { + background: none; + border: none; + color: #92400e; + cursor: pointer; + font-size: 0.85rem; + padding: 0.15rem; + } .kanban { display: grid; grid-template-columns: 1fr 1fr 1fr; diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts b/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts index 0c8cd34..dcccc5b 100644 --- a/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts @@ -198,7 +198,8 @@ export const actions = { criteriaValue: parseInt(fd.get('criteriaValue') as string, 10) || 0, period: fd.get('period') || '', memberId: fd.get('memberId') || '', - status + status, + isPocketMoney: !!fd.get('isPocketMoney') }; if (data.occurrence === 'once') data.period = ''; try { diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte index 5ca9b93..3c393ea 100644 --- a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte @@ -4,6 +4,8 @@ import { enhance } from '$app/forms'; import { famStore } from '$lib/stores/fam.svelte'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; + import TemplateIcon from '$lib/components/TemplateIcon.svelte'; + import Clock from '@lucide/svelte/icons/clock'; import type { BonusConfig, BonusTemplate, @@ -15,10 +17,8 @@ let { data }: { data: PageData } = $props(); - let showCreateModal = $state(false); let showEditModal = $state(false); let editingConfig = $state(null); - let editingTemplate = $state(null); let creatingFromTemplate = $state(null); let createVals = $state({ @@ -74,7 +74,7 @@ let createIsPercent = $derived(createVals.type === 'threshold' && createVals.thresholdType === 'percent'); let editIsPercent = $derived(editVals.type === 'threshold' && editVals.thresholdType === 'percent'); - let templateConfigs = $derived(templates as BonusTemplate[]); + let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global)); let allBonusConfigs = $derived( configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[] ); @@ -137,7 +137,6 @@ if (!tpl) return; creatingFromTemplate = tpl; editingConfig = null; - editingTemplate = null; editVals = { name: tpl.name, description: tpl.description || '', @@ -173,26 +172,18 @@ if (raw) openCreateFromTemplate(raw, 'collaborative'); } - function handleCreate() { - return async ({ result }: any) => { - if (result.type === 'success') showCreateModal = false; - }; - } - function handleUpdate() { return async ({ result }: any) => { if (result.type === 'success') { showEditModal = false; creatingFromTemplate = null; editingConfig = null; - editingTemplate = null; } }; } function openEditConfig(cfg: BonusConfig) { editingConfig = cfg; - editingTemplate = null; creatingFromTemplate = null; editVals = { name: cfg.name, @@ -245,58 +236,32 @@

Templates โ€” Drag to assign

- {#each templateConfigs as cfg} -
handleDragStart(e, cfg)}> -
- {cfg.name} -
-
- {cfg.target} - {cfg.type} - {cfg.occurrence} - {#if cfg.period} - {cfg.period} - {/if} - {#if cfg.isPocketMoney} - Pocket Money - {/if} -
-
- {formatReward(cfg.rewardType, cfg.rewardValue)} -
- + {#each templateConfigs as cfg} +
handleDragStart(e, cfg)} style="border-left-color:{cfg.color}"> +
+ + {cfg.name}
- {/each} +
+ {cfg.target} + {cfg.type} + {cfg.occurrence} + {#if cfg.period} + {cfg.period} + {/if} + {#if cfg.isPocketMoney} + Pocket Money + {/if} +
+
+ {formatReward(cfg.rewardType, cfg.rewardValue)} +
+
+ {/each} {#if templateConfigs.length === 0}

No templates yet

{/if}
-
@@ -305,7 +270,14 @@

Member

{#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg} -
+
openEditConfig(cfg)} + onkeydown={(e) => e.key === 'Enter' && openEditConfig(cfg)} + >
{cfg.name}
@@ -316,6 +288,9 @@ Pocket Money {/if}
+ + {cfg.rewardType === 'cash' ? `ยฃ${Number(cfg.rewardValue).toFixed(2)}` : `${cfg.rewardValue} pts`} +
{#each members as m} {#if !cfg.memberId || cfg.memberId === m.id} @@ -344,16 +319,14 @@
{/if} - {p ? p.state : 'Pending'} + {#if !p || p.state === 'pending'}{:else}{p.state}{/if}
{/if} {/if} {/each}
- + Click to edit
{/each} @@ -367,7 +340,14 @@

Competition

{#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg} {@const prog = progressByConfigId.get(cfg.id)} -
+
openEditConfig(cfg)} + onkeydown={(e) => e.key === 'Enter' && openEditConfig(cfg)} + >
{cfg.name}
@@ -375,6 +355,9 @@ {cfg.type} {cfg.occurrence}
+ + {cfg.rewardType === 'cash' ? `ยฃ${Number(cfg.rewardValue).toFixed(2)}` : `${cfg.rewardValue} pts`} +
{#if prog}
@@ -397,16 +380,14 @@
{/if} - {p.state} + {#if p.state === 'pending'}{:else}{p.state}{/if}
{/each}
{/if}
- + Click to edit
{/each} @@ -420,7 +401,14 @@

Collaboration

{#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg} {@const prog = progressByConfigId.get(cfg.id)} -
+
openEditConfig(cfg)} + onkeydown={(e) => e.key === 'Enter' && openEditConfig(cfg)} + >
{cfg.name}
@@ -428,6 +416,9 @@ {cfg.type} {cfg.occurrence}
+ + {cfg.rewardType === 'cash' ? `ยฃ${Number(cfg.rewardValue).toFixed(2)}` : `${cfg.rewardValue} pts`} +
{#if prog} {@const team = prog.progress[0]} @@ -453,15 +444,13 @@ {cfg.type === 'threshold' ? 'pts' : 'chores'} - {team.state} + {#if team.state === 'pending'}{:else}{team.state}{/if}
{/if} {/if}
- + Click to edit
{/each} @@ -471,146 +460,32 @@
- {#if showCreateModal} -
(showCreateModal = false)} role="presentation"> - -
- {/if} - - {#if showEditModal && (editingConfig || editingTemplate || creatingFromTemplate)} - {@const targetCfg = creatingFromTemplate || editingConfig || editingTemplate!} + {#if showEditModal && (editingConfig || creatingFromTemplate)} + {@const targetCfg = creatingFromTemplate || editingConfig!}
{ showEditModal = false; creatingFromTemplate = null; editingConfig = null; - editingTemplate = null; }} role="presentation" >
@@ -822,22 +697,14 @@ font-size: 1rem; } .template-list { - display: grid; + display: flex; + flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - @media (max-width: 1100px) { - .template-list { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - } - @media (max-width: 720px) { - .template-list { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } } .template-card { + flex: 0 1 auto; + min-width: 160px; background: white; border: 1px solid #e5e7eb; border-left: 4px solid #8b5cf6; @@ -877,8 +744,9 @@ } .card-head { display: flex; - justify-content: space-between; + justify-content: flex-start; align-items: center; + gap: 0.4rem; margin-bottom: 0.4rem; } .card-head strong { @@ -957,6 +825,7 @@ border-radius: 10px; padding: 0.65rem 0.85rem; margin-bottom: 0.6rem; + cursor: pointer; transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s; } .col-card:hover { @@ -964,6 +833,27 @@ box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08); border-color: #c7d2fe; } + .col-card:focus-visible { + outline: 2px solid #6366f1; + outline-offset: 2px; + } + .reward-total { + margin-left: auto; + font-size: 0.8rem; + font-weight: 700; + white-space: nowrap; + } + .reward-total.cash { + color: #16a34a; + } + .reward-total.points { + color: #9333ea; + } + .click-edit { + margin-left: auto; + font-size: 0.68rem; + color: #9ca3af; + } .col-card.done { opacity: 0.75; } @@ -986,6 +876,7 @@ .col-card-foot { margin-top: 0.5rem; display: flex; + justify-content: flex-end; gap: 0.3rem; } .member-progress { @@ -1027,6 +918,9 @@ padding: 0.1rem 0.25rem; border-radius: 0.25rem; background: #e5e7eb; + display: inline-flex; + align-items: center; + gap: 0.15rem; } .stat.pending { background: #e5e7eb; diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.server.ts b/frontend/src/routes/[fam]/[username]/chores/+page.server.ts index d304621..e4b8cfd 100644 --- a/frontend/src/routes/[fam]/[username]/chores/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/chores/+page.server.ts @@ -7,7 +7,7 @@ export async function load(event) { const famId = event.locals.user.famId; const pb = pbUser(event); const [templates, members, assigned, seasons, completions] = await Promise.all([ - pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }), + pb.collection('chore_templates').getFullList({ filter: `(famId = '${famId}' || global = true)` }), pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }), @@ -111,8 +111,10 @@ export const actions = { 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; + if (type === 'emoji') { + data.value = 0; + data.emoji = (fd.get('emoji') as string) || '๐ŸŽ‰'; + } 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; @@ -146,6 +148,7 @@ export const actions = { 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 emoji = type === 'emoji' ? ((fd.get('emoji') as string) || '๐ŸŽ‰') : ''; const todoStart = fd.get('todoStart') as string; const todoCompleteBy = fd.get('todoCompleteBy') as string; const customDate = fd.get('customDate') as string; @@ -181,6 +184,7 @@ export const actions = { value, customName: name, isTodo: true, + emoji, startDate, completeBy }; diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.svelte b/frontend/src/routes/[fam]/[username]/chores/+page.svelte index 81a5fc6..1c3cc00 100644 --- a/frontend/src/routes/[fam]/[username]/chores/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/chores/+page.svelte @@ -3,6 +3,7 @@ import { page } from '$app/state'; import { famStore } from '$lib/stores/fam.svelte'; import { ViewHeader, CardGrid, Card } from '$lib/components'; + import TemplateIcon from '$lib/components/TemplateIcon.svelte'; import { formatHumanDate } from '$lib/format'; import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types'; @@ -14,48 +15,53 @@ let { data, form } = $props(); - 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); + // Assign-from-template modal (platform templates are copied into the family + // as assigned chores; the fam admin amends before saving). + let assignModal = $state<{ t: ChoreTemplate; memberId: string } | null>(null); + let assignFreq = $state('daily'); + let assignType = $state('points'); + let assignValue = $state(10); + let assignSeasonIds = $state([]); // Todo form state let todoName = $state(''); - let todoType = $state<'points' | 'emoji'>('points'); + let todoType = $state<'points' | 'money' | 'emoji'>('points'); let todoValue = $state(10); + let todoEmoji = $state('๐ŸŽ‰'); + let showTodoEmojiPicker = $state(false); let todoStart = $state<'now' | 'next-week'>('now'); let todoCompleteBy = $state<'next-week' | 'custom'>('next-week'); let todoCustomDate = $state(''); + // Curated celebration emojis for todos + const EMOJI_CHOICES = [ + '๐ŸŽ‰', 'โญ', '๐Ÿ†', '๐Ÿ’ช', '๐Ÿงน', '๐Ÿ“š', '๐Ÿฆธ', 'โœจ', '๐ŸŒŸ', '๐Ÿฅ‡', + '๐ŸŽฏ', '๐Ÿ’š', '๐Ÿš€', '๐Ÿฆ„', '๐ŸŒˆ', '๐Ÿ€', '๐ŸŽจ', '๐ŸŽธ', 'โšฝ', '๐Ÿฐ' + ]; + let editFreq = $state('daily'); let editType = $state('points'); let editValue = $state(10); let editCustomName = $state(''); + let editEmoji = $state('๐ŸŽ‰'); let editTodoCompleteBy = $state(''); let editTodoStartDate = $state(''); - let editTplName = $state(''); - let editTplFreq = $state('daily'); - let editTplType = $state('points'); - let editTplValue = $state(10); - let seasonFilter = $state('all'); let editSeasonIds = $state([]); - let templates = $state( - famStore.initialized - ? (famStore.templates as ChoreTemplate[]) - : (data.templates as ChoreTemplate[]) || [] + // Platform-owned templates only โ€” families assign these, they don't create. + let templates = $derived( + ((famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[])) || []).filter( + (t: ChoreTemplate) => t.global + ) ); let members = $state( famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[]) || [] @@ -122,20 +128,29 @@ async function handleDrop(e: DragEvent, memberId: string) { e.preventDefault(); const tid = e.dataTransfer?.getData('text/plain') || draggedTemplateId; - if (!tid) return; draggedTemplateId = null; + if (!tid) return; + const t = templates.find((x) => x.id === tid); + if (!t) return; + assignModal = { t, memberId }; + assignFreq = t.defaultFrequency; + assignType = t.defaultType; + assignValue = t.defaultValue; + assignSeasonIds = seasonFilter !== 'all' ? [seasonFilter] : []; + } + async function assignChore() { + if (!assignModal) return; 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 + memberId: assignModal.memberId, + templateId: assignModal.t.id, + frequency: assignFreq, + type: assignType, + value: assignValue }; - if (seasonFilter !== 'all') body.seasonIds = [seasonFilter]; + if (assignSeasonIds.length) body.seasonIds = assignSeasonIds; await fetch(`/api/admin/${s.famId}/assigned-chores`, { method: 'POST', headers: { @@ -145,6 +160,7 @@ }, body: JSON.stringify(body) }); + assignModal = null; } // Accordion state per member: which section is open @@ -160,6 +176,8 @@ todoName = ''; todoType = 'points'; todoValue = 10; + todoEmoji = '๐ŸŽ‰'; + showTodoEmojiPicker = false; todoStart = 'now'; todoCompleteBy = 'custom'; todoCustomDate = defaultCompleteByDate(); @@ -209,6 +227,7 @@ editType = a.type; editValue = a.value; editCustomName = a.customName || ''; + editEmoji = a.emoji || '๐ŸŽ‰'; editSeasonIds = a.seasonIds ? [...a.seasonIds] : []; editTodoCompleteBy = a.completeBy || ''; editTodoStartDate = a.startDate || ''; @@ -220,32 +239,6 @@ editingAssignment = null; } - function openEditTemplate(t: ChoreTemplate) { - editingTemplate = t; - editTplName = t.name; - editTplFreq = t.defaultFrequency; - editTplType = t.defaultType; - editTplValue = t.defaultValue; - showEditTemplateModal = true; - } - - function closeEditTemplate() { - showEditTemplateModal = false; - editingTemplate = null; - } - - function enhanceCreate() { - return async ({ result }: any) => { - if (result.type === 'success') { - showCreateModal = false; - const rec = result.data?.record; - if (rec) { - templates = [rec as ChoreTemplate, ...templates]; - } - } - }; - } - function enhanceUpdateAssigned() { return async ({ result }: any) => { if (result.type === 'success' && editingAssignment) { @@ -256,7 +249,8 @@ }; if (editingAssignment.isTodo) { updates.type = editType; - updates.value = editValue; + updates.value = editType === 'emoji' ? 0 : editValue; + updates.emoji = editType === 'emoji' ? editEmoji : undefined; updates.completeBy = editTodoCompleteBy; updates.startDate = editTodoStartDate; } else { @@ -271,30 +265,6 @@ 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(); - } - } - }; - } @@ -321,8 +291,9 @@
{#each templates as t} -
handleDragStart(e, t.id)}> -
openEditTemplate(t)} role="button" tabindex="0"> +
handleDragStart(e, t.id)} style="border-left-color:{t.color}"> +
+ {t.name} {t.defaultFrequency} {t.defaultType} @@ -330,18 +301,12 @@
{t.defaultType === 'money' ? `ยฃ${Number(t.defaultValue).toFixed(2)}` : t.defaultValue}
-
- -
{/each} {#if templates.length === 0}

No templates yet

{/if}
-
@@ -389,7 +354,9 @@ {a.customName || 'Todo'}
{#if a.type === 'emoji'} - ๐ŸŽฏ emoji + {a.emoji || '๐ŸŽ‰'} celebration + {:else if a.type === 'money'} + ยฃ{Number(a.value).toFixed(2)} cash {:else} {a.value} pts - -{#if showCreateModal} -
(showCreateModal = false)} role="presentation"> + +{#if assignModal} +
(assignModal = null)} role="presentation"> -
-{/if} - - -{#if showEditTemplateModal && editingTemplate} - @@ -608,7 +510,8 @@ Type {#if editType === 'points'} @@ -616,6 +519,37 @@ Points value + {:else if editType === 'money'} + + {:else} + +
+ {editEmoji} + +
+ {#if showTodoEmojiPicker} +
+ {#each EMOJI_CHOICES as e} + + {/each} +
+ {/if} {/if} {#if todoType === 'points'} @@ -729,6 +664,34 @@ Points value + {:else if todoType === 'money'} + + {:else} + +
+ {todoEmoji} + +
+ {#if showTodoEmojiPicker} +
+ {#each EMOJI_CHOICES as e} + + {/each} +
+ {/if} {/if}