364 lines
11 KiB
TypeScript
364 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;
|
|
// 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 = <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();
|