fix some connectivity issue

This commit is contained in:
JCEEE
2026-09-14 13:49:14 +01:00
parent de61a6368b
commit 6aab203bbe
3 changed files with 154 additions and 0 deletions
+22
View File
@@ -3,11 +3,13 @@
announcement = '', announcement = '',
role = '', role = '',
seasons = [], seasons = [],
connected = true,
children, children,
}: { }: {
announcement?: string announcement?: string
role?: string role?: string
seasons?: { id: string; name: string; color: string; active: boolean }[] seasons?: { id: string; name: string; color: string; active: boolean }[]
connected?: boolean
children?: any children?: any
} = $props(); } = $props();
</script> </script>
@@ -17,6 +19,14 @@
{#if role} {#if role}
<span class="role-pill {role}">{role}</span> <span class="role-pill {role}">{role}</span>
{/if} {/if}
<span
class="conn-dot"
class:up={connected}
class:down={!connected}
title={connected ? 'Live — realtime connected' : 'Reconnecting — live updates paused'}
role="status"
aria-label={connected ? 'Realtime connected' : 'Realtime reconnecting'}
></span>
</div> </div>
<div class="topnav-announcement"> <div class="topnav-announcement">
{#if announcement}<span class="announcement-text">{announcement}</span>{/if} {#if announcement}<span class="announcement-text">{announcement}</span>{/if}
@@ -68,6 +78,18 @@
} }
.role-pill.parent { background: #fef3c7; color: #b45309; } .role-pill.parent { background: #fef3c7; color: #b45309; }
.role-pill.child { background: #dbeafe; color: #1d4ed8; } .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 { .season-pill {
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
+113
View File
@@ -35,9 +35,15 @@ class FamStore {
seasons = $state<Season[]>([]); seasons = $state<Season[]>([]);
initialized = $state(false); initialized = $state(false);
famId = $state(''); 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 unsubs: (() => void)[] = [];
private destroyed = false; private destroyed = false;
private seenConnect = false;
private resyncing = false;
memberMap(): Map<string, Member> { memberMap(): Map<string, Member> {
return new Map(this.members.map((m) => [m.id, m])); return new Map(this.members.map((m) => [m.id, m]));
@@ -137,12 +143,115 @@ class FamStore {
} }
await this.subscribe(); await this.subscribe();
this.watchConnection();
this.initPromise = null; this.initPromise = null;
})(); })();
return this.initPromise!; 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() { private async subscribe() {
const subs: { collection: CollectionName; filter: string }[] = [ const subs: { collection: CollectionName; filter: string }[] = [
{ collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` }, { collection: 'users', filter: `famId = '${this.famId}' && role = 'child'` },
@@ -233,6 +342,10 @@ class FamStore {
this.destroyed = true; this.destroyed = true;
for (const unsub of this.unsubs) unsub(); for (const unsub of this.unsubs) unsub();
this.unsubs = []; this.unsubs = [];
for (const stop of this.connWatchers) stop();
this.connWatchers = [];
this.seenConnect = false;
this.connectionDown = false;
this.initialized = false; this.initialized = false;
} }
} }
+19
View File
@@ -209,6 +209,7 @@
role={data.session ? role : ''} role={data.session ? role : ''}
seasons={famStore.seasons.filter((s) => s.active !== false)} seasons={famStore.seasons.filter((s) => s.active !== false)}
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''} announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
connected={!famStore.connectionDown}
> >
<button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat"> <button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat">
{@html chatIcon} {@html chatIcon}
@@ -251,6 +252,9 @@
{claimToast} — view dashboard → {claimToast} — view dashboard →
</a> </a>
{/if} {/if}
{#if famStore.connectionDown}
<div class="conn-toast" role="status">Reconnecting… live updates paused</div>
{/if}
<Footer sidebar /> <Footer sidebar />
</div> </div>
{#if chatStore.open} {#if chatStore.open}
@@ -279,6 +283,21 @@
.claim-toast:hover { .claim-toast:hover {
background: #d97706; 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) { @media (max-width: 1023px) {
.claim-toast { .claim-toast {
left: 56px; left: 56px;