add showboat fam and rotating data

This commit is contained in:
JCEEE
2026-09-10 15:03:27 +01:00
parent a270d965fe
commit 4730ab3eb9
2 changed files with 376 additions and 2 deletions
+370 -2
View File
@@ -1,11 +1,12 @@
import { SCHEMA_PLAN } from '@shared/pb/schema'; import { SCHEMA_PLAN } from '@shared/pb/schema';
import { PB_ENDPOINT } from '$lib/server/pocketbase'; 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 token: string | null = null;
let tokenExpiry = 0;
async function auth(): Promise<string> { async function auth(): Promise<string> {
if (token) return token; if (token && Date.now() < tokenExpiry) return token;
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, { const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -14,6 +15,8 @@ async function auth(): Promise<string> {
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`); if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
token = data.token; token = data.token;
// PB JWTs expire in 1 hour; refresh 5 min early.
tokenExpiry = Date.now() + 55 * 60 * 1000;
return token!; return token!;
} }
@@ -441,6 +444,8 @@ export async function migrate(): Promise<void> {
await ensureAccessCodes(); await ensureAccessCodes();
await ensurePlatform(); await ensurePlatform();
await ensureDefaultSeasons(); await ensureDefaultSeasons();
await seedDemoFamily();
await seedDemoCompletions();
console.log('[migrate] Done'); console.log('[migrate] Done');
} }
@@ -774,4 +779,367 @@ async function seedGlobalTemplates(): Promise<void> {
console.log('[migrate] Seeded global templates.'); 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<void> {
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<string, Record<number, Record<string, number>>> = {
'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<void> {
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<string, Record<string, { points: number; money: number; count: number }>> = {};
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 { GLOBAL_TEMPLATE_ICONS };
export { seedDemoCompletions };
@@ -2,6 +2,7 @@ import { redirect } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase'; import { createPbClient } from '$lib/server/pocketbase';
import { createServices, type ChatActor } from '$lib/server/services'; import { createServices, type ChatActor } from '$lib/server/services';
import { ensureFamAccess } from '$lib/server/access'; import { ensureFamAccess } from '$lib/server/access';
import { seedDemoCompletions } from '$lib/server/migrate';
async function paydayCheck(famId: string, pbToken: string) { async function paydayCheck(famId: string, pbToken: string) {
try { try {
@@ -46,6 +47,11 @@ async function resolveChatIdentity(
} }
export async function load(event) { 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 session = event.locals.user;
const role = session?.role || 'child'; const role = session?.role || 'child';
const isParent = role === 'parent'; const isParent = role === 'parent';