Files
famdone/frontend/src/lib/stores/fam.svelte.ts
T
2026-09-14 13:49:14 +01:00

354 lines
11 KiB
TypeScript

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<Fam | null>(null);
members = $state<Member[]>([]);
templates = $state<ChoreTemplate[]>([]);
assigned = $state<AssignedChore[]>([]);
completions = $state<Completion[]>([]);
history = $state<WeeklyHistory[]>([]);
rewards = $state<Reward[]>([]);
bonusConfigs = $state<BonusConfig[]>([]);
bonusTemplates = $state<BonusTemplate[]>([]);
seasons = $state<Season[]>([]);
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<string, Member> {
return new Map(this.members.map((m) => [m.id, m]));
}
templateMap(): Map<string, ChoreTemplate> {
return new Map(this.templates.map((t) => [t.id, t]));
}
bonusConfigMap(): Map<string, BonusConfig> {
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<void> | 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<Member[]>,
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<Member[]>,
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;
} catch (e) {
console.error('FamStore.resync failed:', e);
} 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(() => {});
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 — resync when the network/tab comes back.
const onOnline = () => {
this.connectionDown = false;
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);
});
});
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 = <T extends { id: string }>(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();