From 6aab203bbe9e607bb65c2407d9eaf58d7fe4ae2b Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Mon, 14 Sep 2026 13:49:14 +0100 Subject: [PATCH] fix some connectivity issue --- frontend/src/lib/components/TopNav.svelte | 22 +++++ frontend/src/lib/stores/fam.svelte.ts | 113 ++++++++++++++++++++++ frontend/src/routes/[fam]/+layout.svelte | 19 ++++ 3 files changed, 154 insertions(+) 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;