import { pb } from '$lib/pocketbase'; import type { Member, ChoreTemplate, AssignedChore, Completion, WeeklyHistory, Reward, BonusConfig, BonusTemplate, Fam, Season } from '$lib/types'; type CollectionName = | 'users' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'bonus_templates' | 'rewards' | 'seasons'; class FamStore { fam = $state(null); members = $state([]); templates = $state([]); assigned = $state([]); completions = $state([]); history = $state([]); rewards = $state([]); bonusConfigs = $state([]); bonusTemplates = $state([]); seasons = $state([]); initialized = $state(false); famId = $state(''); // True while the realtime SSE stream is down (subscriptions exist but the // socket dropped, e.g. ERR_QUIC_PROTOCOL_ERROR). The SDK reconnects on its // own; we resync missed events on PB_CONNECT (below) + browser signals. connectionDown = $state(false); private unsubs: (() => void)[] = []; private destroyed = false; private seenConnect = false; private resyncing = false; memberMap(): Map { return new Map(this.members.map((m) => [m.id, m])); } templateMap(): Map { return new Map(this.templates.map((t) => [t.id, t])); } bonusConfigMap(): Map { return new Map(this.bonusConfigs.map((b) => [b.id, b])); } assignedForMember(memberId: string): AssignedChore[] { return this.assigned.filter((a) => a.memberId === memberId); } completionsForDate(date: string): Completion[] { return this.completions.filter((c) => (c.date?.slice(0, 10) || c.date) === date); } isCompleted(assignedChoreId: string, date: string): boolean { return this.completions.some( (c) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === date ); } private initPromise: Promise | null = null; async init(famId: string, initialFam?: Fam) { if (this.initialized && this.famId === famId) return; // Wait for any in-flight init to finish first if (this.initPromise) { await this.initPromise; if (this.initialized && this.famId === famId) return; } this.cleanup(); this.destroyed = false; this.famId = famId; this.initPromise = (async () => { try { // fams is superadmin-only (non-realtime, one-way writes). It is always // fetched server-side by the layout load and passed in — never via the // unauthenticated client PB SDK. this.fam = initialFam || this.fam || ({} as Fam); const [ membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, bonusTemplatesRes, rewardsRes, seasonsRes ] = await Promise.all([ pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }) 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< AssignedChore[] >, pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise< Completion[] >, pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise< BonusConfig[] >, pb.collection('bonus_templates').getFullList({ filter: `(famId = '${famId}' || global = true)` }) as Promise< BonusTemplate[] >, pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise< Reward[] >, pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }) as Promise< Season[] > ]); this.members = membersRes; this.templates = templatesRes; this.assigned = assignedRes; this.completions = completionsRes; this.bonusConfigs = bonusConfigsRes; this.bonusTemplates = bonusTemplatesRes; this.rewards = rewardsRes; this.seasons = seasonsRes; this.initialized = true; } catch (e) { console.error('FamStore.init failed:', e); this.initPromise = null; throw e; } await this.subscribe(); this.watchConnection(); this.initPromise = null; })(); return this.initPromise!; } // Refetch every list and replace local state. Used after a realtime // reconnect (the SDK re-establishes SSE itself but never replays events // missed during the outage) and on browser online/visible signals. // Acting on stale state is what produced the phantom "resource not found" // 400s after a tab silently disconnected. async resync() { if (!this.initialized || this.destroyed || this.resyncing) return; this.resyncing = true; try { const [ membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, bonusTemplatesRes, rewardsRes, seasonsRes ] = await Promise.all([ pb.collection('users').getFullList({ filter: `famId = '${this.famId}' && role = 'child'` }) as Promise, pb.collection('chore_templates').getFullList({ filter: `(famId = '${this.famId}' || global = true)` }) as Promise< ChoreTemplate[] >, pb.collection('assigned_chores').getFullList({ filter: `famId = '${this.famId}'` }) as Promise< AssignedChore[] >, pb.collection('completions').getFullList({ filter: `famId = '${this.famId}'` }) as Promise< Completion[] >, pb.collection('bonus_configs').getFullList({ filter: `famId = '${this.famId}'` }) as Promise< BonusConfig[] >, pb.collection('bonus_templates').getFullList({ filter: `(famId = '${this.famId}' || global = true)` }) as Promise< BonusTemplate[] >, pb.collection('rewards').getFullList({ filter: `famId = '${this.famId}'` }) as Promise< Reward[] >, pb.collection('seasons').getFullList({ filter: `famId = '${this.famId}'` }) as Promise< Season[] > ]); if (this.destroyed) return; this.members = membersRes; this.templates = templatesRes; this.assigned = assignedRes; this.completions = completionsRes; this.bonusConfigs = bonusConfigsRes; this.bonusTemplates = bonusTemplatesRes; this.rewards = rewardsRes; this.seasons = seasonsRes; // A completed resync proves the pipe is healthy again. this.connectionDown = false; } catch (e) { console.error('FamStore.resync failed:', e); if (!this.destroyed) this.connectionDown = true; } finally { this.resyncing = false; } } private connWatchers: (() => void)[] = []; private watchConnection() { if (typeof window === 'undefined' || this.connWatchers.length) return; // SDK-level: PB_CONNECT fires on every (re)connect, including the first. pb.realtime .subscribe('PB_CONNECT', () => { if (this.destroyed) return; this.connectionDown = false; if (this.seenConnect) { // Reconnect after an outage — replay what we missed. this.resync().catch(() => {}); } this.seenConnect = true; }) .then((unsub) => { this.connWatchers.push(unsub); }) .catch(() => { // We never even got the connect signal — surface it. if (!this.destroyed) this.connectionDown = true; }); const onDisc = pb.realtime.onDisconnect; pb.realtime.onDisconnect = (active: string[]) => { try { onDisc?.(active); } catch {} // Only flag drops with live subscriptions (not our own cleanup). if (active.length > 0 && !this.destroyed) this.connectionDown = true; }; // Browser-level: sleep/wake and offline/online can kill SSE without the // SDK noticing promptly. Don't touch the flag here — resync() sets it // from the actual outcome (clearing it early is what left the dot // green while the console showed errors). const onOnline = () => { this.resync().catch(() => {}); }; const onVisible = () => { if (document.visibilityState === 'visible') this.resync().catch(() => {}); }; window.addEventListener('online', onOnline); document.addEventListener('visibilitychange', onVisible); this.connWatchers.push(() => window.removeEventListener('online', onOnline)); this.connWatchers.push(() => document.removeEventListener('visibilitychange', onVisible)); } private async subscribe() { const subs: { collection: CollectionName; filter: string }[] = [ { collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` }, { 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}' || global = true)` }, { collection: 'rewards', filter: `famId = '${this.famId}'` }, { collection: 'seasons', filter: `famId = '${this.famId}'` } ]; const promises = subs.map(({ collection, filter }) => { return pb .collection(collection) .subscribe( '*', (data: any) => { if (this.destroyed) return; this.handleRealtime(collection, data.action, data.record); }, { filter: filter || undefined } ) .then((unsub) => { if (this.destroyed) { unsub(); return; } this.unsubs.push(unsub); }) .catch((err: Error) => { console.error(`[famStore] subscribe failed for ${collection}:`, err); // A failed subscribe IS the outage the console shows while // the dot stayed green — flag it; PB_CONNECT/resync clears it. if (!this.destroyed) this.connectionDown = true; }); }); await Promise.allSettled(promises); } // Called from form action callbacks for instant UI feedback, // 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]; 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); return list; }; switch (collection) { case 'users': this.members = apply(this.members); break; case 'chore_templates': this.templates = apply(this.templates); break; case 'assigned_chores': this.assigned = apply(this.assigned); break; case 'completions': this.completions = apply(this.completions); break; case 'bonus_configs': this.bonusConfigs = apply(this.bonusConfigs); break; case 'bonus_templates': this.bonusTemplates = apply(this.bonusTemplates); break; case 'rewards': this.rewards = apply(this.rewards); break; case 'seasons': this.seasons = apply(this.seasons); break; } } private handleRealtime(collection: string, action: string, record: any) { this.applyRecord( collection as CollectionName, record, action as 'create' | 'update' | 'delete' ); } cleanup() { // TODO - we need to properly unsubscribe from pocketbase this.destroyed = true; for (const unsub of this.unsubs) unsub(); this.unsubs = []; for (const stop of this.connWatchers) stop(); this.connWatchers = []; this.seenConnect = false; this.connectionDown = false; this.initialized = false; } } export const famStore = new FamStore();