add showboat fam and rotating data
This commit is contained in:
@@ -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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
await ensureAccessCodes();
|
||||
await ensurePlatform();
|
||||
await ensureDefaultSeasons();
|
||||
await seedDemoFamily();
|
||||
await seedDemoCompletions();
|
||||
console.log('[migrate] Done');
|
||||
}
|
||||
|
||||
@@ -774,4 +779,367 @@ async function seedGlobalTemplates(): Promise<void> {
|
||||
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 { seedDemoCompletions };
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user