diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index 28bbfee..1b99a76 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -1,11 +1,12 @@ import { SCHEMA_PLAN } from '@shared/pb/schema'; import { PB_ENDPOINT } from '$lib/server/pocketbase'; -import { PB_EMAIL, PB_PASSWORD } from '$app/env/private'; +import { PB_EMAIL, PB_PASSWORD, MEMBER_SECRET } from '$app/env/private'; let token: string | null = null; +let tokenExpiry = 0; async function auth(): Promise { - if (token) return token; + if (token && Date.now() < tokenExpiry) return token; const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -14,6 +15,8 @@ async function auth(): Promise { const data = await res.json(); if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`); token = data.token; + // PB JWTs expire in 1 hour; refresh 5 min early. + tokenExpiry = Date.now() + 55 * 60 * 1000; return token!; } @@ -441,6 +444,8 @@ export async function migrate(): Promise { await ensureAccessCodes(); await ensurePlatform(); await ensureDefaultSeasons(); + await seedDemoFamily(); + await seedDemoCompletions(); console.log('[migrate] Done'); } @@ -774,4 +779,367 @@ async function seedGlobalTemplates(): Promise { console.log('[migrate] Seeded global templates.'); } +// ── Demo family seed ──────────────────────────────────────────────────────── +// Creates a "Showboaters" family with one admin (demo) and three kids +// (Juno, Jackson, Jalipo). Idempotent — skips if the family already exists. +// The parent password is `demo1234` (PB enforces ≥8 chars). Children's +// derived passwords are handled by the normal member-otp flow. + +const DEMO_FAM = { + name: 'Showboaters', + slug: 'showboaters', + parentName: 'Demo', + parentEmail: 'demo@famdone.xyz', + parentPassword: 'demo1234', + children: [ + { name: 'Juno', color: '#f472b6' }, + { name: 'Jackson', color: '#60a5fa' }, + { name: 'Jalipo', color: '#34d399' } + ] +}; + +async function seedDemoFamily(): Promise { + const t = await auth(); + + // Check if the demo family already exists + const famRes = await fetch( + `${PB_ENDPOINT}/api/collections/fams/records?filter=slug='${DEMO_FAM.slug}'&perPage=1`, + { headers: { Authorization: `Bearer ${t}` } } + ); + const famData = await famRes.json(); + if (famData?.items?.length) return; // already seeded + + console.log('[migrate] Seeding demo family "Showboaters"...'); + + // 1. Create the family + const createdFam = await fetch(`${PB_ENDPOINT}/api/collections/fams/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ + name: DEMO_FAM.name, + slug: DEMO_FAM.slug, + timezone: 'auto', + paymentMode: 'none', + active: false + }) + }); + const fam = await createdFam.json(); + if (!createdFam.ok) throw new Error(`Seed demo fam failed: ${JSON.stringify(fam)}`); + const famId = fam.id; + + // 2. Create the parent (admin) user + const parentUsername = `${DEMO_FAM.slug}:${DEMO_FAM.parentName.toLowerCase().replace(/[^a-z0-9]/g, '')}`; + const createdParent = await fetch(`${PB_ENDPOINT}/api/collections/users/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ + username: parentUsername, + name: DEMO_FAM.parentName, + email: DEMO_FAM.parentEmail, + emailVisibility: false, + password: DEMO_FAM.parentPassword, + passwordConfirm: DEMO_FAM.parentPassword, + famId, + role: 'parent' + }) + }); + if (!createdParent.ok) { + const pd = await createdParent.json(); + throw new Error(`Seed demo parent failed: ${JSON.stringify(pd)}`); + } + + // 3. Create settings for the family + await fetch(`${PB_ENDPOINT}/api/collections/settings/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ famId }) + }); + + // 4. Apply access code (dev123) to unlock the family + const codeRes = await fetch( + `${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='dev123'&perPage=1`, + { headers: { Authorization: `Bearer ${t}` } } + ); + const codeData = await codeRes.json(); + const accessCode = codeData?.items?.[0]; + if (accessCode) { + await fetch(`${PB_ENDPOINT}/api/collections/fams/records/${famId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ + paymentMode: 'code', + active: true, + accessCodeId: accessCode.id, + accessCodeEnteredAt: new Date().toISOString() + }) + }); + } + + // 5. Create child users (via the same derive-password path as member-otp) + for (const child of DEMO_FAM.children) { + const childHandle = child.name.toLowerCase().replace(/[^a-z0-9]/g, ''); + const childUsername = `${DEMO_FAM.slug}:${childHandle}`; + const childPassword = `${String(MEMBER_SECRET)}${DEMO_FAM.slug}${childHandle}`; + + await fetch(`${PB_ENDPOINT}/api/collections/users/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ + username: childUsername, + name: child.name, + color: child.color, + password: childPassword, + passwordConfirm: childPassword, + famId, + role: 'child' + }) + }).catch((e) => { + console.warn(`[migrate] Failed to create demo child ${child.name}:`, e); + }); + + // Auto-create pocket-money droplet + await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ + famId, + name: 'Pocket Money', + target: 'individual', + type: 'threshold', + thresholdType: 'percent', + occurrence: 'recurring', + rewardType: 'cash', + rewardValue: '', + criteriaValue: 50, + period: 'weekly', + status: 'active', + isPocketMoney: true + }) + }).catch(() => {}); + } + + console.log('[migrate] ✓ Demo family "Showboaters" seeded'); + console.log(`[migrate] Login: ${DEMO_FAM.parentEmail} / ${DEMO_FAM.parentPassword}`); +} + +// ── Demo rotating data ────────────────────────────────────────────────────── +// On every boot, if the demo family exists and doesn't already have today's +// completions, wipe and regenerate 21 days of deterministic completion data. +// This ensures the demo always shows a realistic 3-week history relative to +// the current date, without manual re-seeding. + +// Simple seeded PRNG (mulberry32) so the same data is produced every time. +function demoRng(seed: number): () => number { + let s = seed | 0; + return () => { + s = (s + 0x6d2b79f5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function dateStr(d: Date): string { + return d.toISOString().slice(0, 10); +} + +function addDays(d: Date, n: number): Date { + const r = new Date(d); + r.setDate(r.getDate() + n); + return r; +} + +// Each kid's "completion personality" — Juno is reliable, Jackson solid, Jalipo spotty. +// Maps assigned chore customName → weekday (0=Sun) → probability of completion. +const COMPLETION_PROFILES: Record>> = { + 'Juno': { + 0: { default: 0.85 }, // Sun + 1: { default: 0.95 }, // Mon + 2: { default: 0.9 }, + 3: { default: 0.95 }, + 4: { default: 0.9 }, + 5: { default: 0.85 }, // Fri + 6: { default: 0.8 } // Sat + }, + 'Jackson': { + 0: { default: 0.7 }, + 1: { default: 0.9 }, + 2: { default: 0.85 }, + 3: { default: 0.8 }, + 4: { default: 0.75 }, + 5: { default: 0.7 }, + 6: { default: 0.6 } + }, + 'Jalipo': { + 0: { default: 0.4 }, + 1: { default: 0.75 }, + 2: { default: 0.65 }, + 3: { default: 0.7 }, + 4: { default: 0.6 }, + 5: { default: 0.5 }, + 6: { default: 0.35 } + } +}; + +async function seedDemoCompletions(): Promise { + const t = await auth(); + + // Find the demo family + const famRes = await fetch( + `${PB_ENDPOINT}/api/collections/fams/records?filter=slug='${DEMO_FAM.slug}'&perPage=1`, + { headers: { Authorization: `Bearer ${t}` } } + ); + const famData = await famRes.json(); + const fam = famData?.items?.[0]; + if (!fam) return; // demo family not seeded yet + + const famId = fam.id; + + // Check if today's completions already exist (idempotent) + const today = dateStr(new Date()); + const todayComps = await fetch( + `${PB_ENDPOINT}/api/collections/completions/records?filter=${encodeURIComponent(`famId='${famId}' && date='${today}'`)}&perPage=1`, + { headers: { Authorization: `Bearer ${t}` } } + ); + if ((await todayComps.json())?.items?.length) { + console.log('[migrate] Demo data already rotated for today — skipping.'); + return; + } + + console.log('[migrate] Rotating demo data for Showboaters...'); + + // Fetch members, assigned chores + const [membersRes, assignedRes] = await Promise.all([ + fetch(`${PB_ENDPOINT}/api/collections/users/records?filter=${encodeURIComponent(`famId='${famId}' && role='child'`)}&perPage=10`, { + headers: { Authorization: `Bearer ${t}` } + }), + fetch(`${PB_ENDPOINT}/api/collections/assigned_chores/records?filter=famId='${famId}'&perPage=100`, { + headers: { Authorization: `Bearer ${t}` } + }) + ]); + const members: any[] = (await membersRes.json())?.items || []; + const assigned: any[] = (await assignedRes.json())?.items || []; + + if (!members.length || !assigned.length) return; + + // Delete existing completions and weekly_history for the demo family + const [oldCompsRes, oldHistoryRes] = await Promise.all([ + fetch(`${PB_ENDPOINT}/api/collections/completions/records?filter=famId='${famId}'&perPage=500`, { + headers: { Authorization: `Bearer ${t}` } + }), + fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records?filter=famId='${famId}'&perPage=500`, { + headers: { Authorization: `Bearer ${t}` } + }) + ]); + const oldComps: any[] = (await oldCompsRes.json())?.items || []; + const oldHistory: any[] = (await oldHistoryRes.json())?.items || []; + for (const r of oldComps) { + await fetch(`${PB_ENDPOINT}/api/collections/completions/records/${r.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${t}` } + }).catch(() => {}); + } + for (const r of oldHistory) { + await fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records/${r.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${t}` } + }).catch(() => {}); + } + + // Generate 21 days of completions (today - 20 → today) + const todayDate = new Date(); + todayDate.setUTCHours(12, 0, 0, 0); // anchor at noon UTC + const startDate = addDays(todayDate, -20); + + // Track per-member-per-week totals for weekly_history + type WeekKey = string; // "YYYY-MM-DD" (Monday) + const weekTotals: Record> = {}; + + for (const member of members) { + const profile = COMPLETION_PROFILES[member.name] || COMPLETION_PROFILES['Jalipo']; + + for (let d = 0; d < 21; d++) { + const day = addDays(startDate, d); + const ds = dateStr(day); + const dow = day.getUTCDay(); // 0=Sun + + // Compute week start (Monday) for weekly_history tracking + const weekMonday = new Date(day); + weekMonday.setUTCDate(weekMonday.getUTCDate() - ((weekMonday.getUTCDay() + 6) % 7)); + const ws = dateStr(weekMonday); + + if (!weekTotals[member.id]) weekTotals[member.id] = {}; + if (!weekTotals[member.id][ws]) weekTotals[member.id][ws] = { points: 0, money: 0, count: 0 }; + + const dayProfile = profile[dow] || profile[0]; + + // Find this member's assigned chores + const memberChores = assigned.filter( + (a: any) => a.memberId === member.id || (!a.memberId && a.shared) + ); + + for (const chore of memberChores) { + // Weekly chores: only complete on Monday (dow=1) + if (chore.frequency === 'weekly' && dow !== 1) continue; + + const prob = dayProfile[chore.customName] || dayProfile.default; + const rng = demoRng( + famId.length * 1000 + + member.id.length * 100 + + d * 10 + + (chore.id?.charCodeAt(0) || 0) + ); + + if (rng() > prob) continue; // missed this day + + // Create completion record + await fetch(`${PB_ENDPOINT}/api/collections/completions/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ + famId, + memberId: member.id, + assignedChoreId: chore.id, + date: ds + }) + }).catch(() => {}); + + // Track totals + const val = Number(chore.value) || 0; + if (chore.type === 'points') { + weekTotals[member.id][ws].points += val; + } else if (chore.type === 'money') { + weekTotals[member.id][ws].money += val; + } + weekTotals[member.id][ws].count++; + } + } + } + + // Create weekly_history records for each week that has data + for (const memberId of Object.keys(weekTotals)) { + for (const ws of Object.keys(weekTotals[memberId])) { + const totals = weekTotals[memberId][ws]; + if (totals.count === 0) continue; + + await fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, + body: JSON.stringify({ + famId, + memberId, + weekStart: ws, + pointsEarned: totals.points, + moneyEarned: totals.money, + choresCompleted: totals.count, + bonusEarned: 0 + }) + }).catch(() => {}); + } + } + + console.log('[migrate] ✓ Demo completions rotated (21 days)'); +} + export { GLOBAL_TEMPLATE_ICONS }; +export { seedDemoCompletions }; diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index 0e76164..aeb214f 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -2,6 +2,7 @@ import { redirect } from '@sveltejs/kit'; import { createPbClient } from '$lib/server/pocketbase'; import { createServices, type ChatActor } from '$lib/server/services'; import { ensureFamAccess } from '$lib/server/access'; +import { seedDemoCompletions } from '$lib/server/migrate'; async function paydayCheck(famId: string, pbToken: string) { try { @@ -46,6 +47,11 @@ async function resolveChatIdentity( } export async function load(event) { + // Demo family: rotate 3-week completion data on every visit (idempotent). + if (event.params.fam === 'showboaters') { + seedDemoCompletions().catch(() => {}); + } + const session = event.locals.user; const role = session?.role || 'child'; const isParent = role === 'parent';