diff --git a/frontend/src/lib/components/TopNav.svelte b/frontend/src/lib/components/TopNav.svelte
index cc01ce9..02f799b 100644
--- a/frontend/src/lib/components/TopNav.svelte
+++ b/frontend/src/lib/components/TopNav.svelte
@@ -3,11 +3,13 @@
announcement = '',
role = '',
seasons = [],
+ connected = true,
children,
}: {
announcement?: string
role?: string
seasons?: { id: string; name: string; color: string; active: boolean }[]
+ connected?: boolean
children?: any
} = $props();
@@ -17,6 +19,14 @@
{#if role}
{role}
{/if}
+
{#if announcement}
{announcement}{/if}
@@ -68,6 +78,18 @@
}
.role-pill.parent { background: #fef3c7; color: #b45309; }
.role-pill.child { background: #dbeafe; color: #1d4ed8; }
+ .conn-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+ .conn-dot.up { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); }
+ .conn-dot.down { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.2); animation: conn-pulse 1.2s infinite; }
+ @keyframes conn-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.35; }
+ }
.season-pill {
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
diff --git a/frontend/src/lib/stores/fam.svelte.ts b/frontend/src/lib/stores/fam.svelte.ts
index 91c1ef4..216da0d 100644
--- a/frontend/src/lib/stores/fam.svelte.ts
+++ b/frontend/src/lib/stores/fam.svelte.ts
@@ -35,9 +35,15 @@ class FamStore {
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]));
@@ -137,12 +143,115 @@ class FamStore {
}
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;
+ } 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'` },
@@ -233,6 +342,10 @@ class FamStore {
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;
}
}
diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte
index edee81a..8ec5b22 100644
--- a/frontend/src/routes/[fam]/+layout.svelte
+++ b/frontend/src/routes/[fam]/+layout.svelte
@@ -209,6 +209,7 @@
role={data.session ? role : ''}
seasons={famStore.seasons.filter((s) => s.active !== false)}
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
+ connected={!famStore.connectionDown}
>
{#if chatStore.open}
@@ -279,6 +283,21 @@
.claim-toast:hover {
background: #d97706;
}
+ .conn-toast {
+ position: fixed;
+ bottom: 1rem;
+ left: 50%;
+ transform: translateX(-50%);
+ background: #1f2937;
+ color: #fff;
+ padding: 0.5rem 1.1rem;
+ border-radius: 999px;
+ font-size: 0.8rem;
+ font-weight: 600;
+ z-index: 95;
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
+ animation: fadein 0.2s;
+ }
@media (max-width: 1023px) {
.claim-toast {
left: 56px;