fix some connectivity issue
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user