diff --git a/src/lib/components/dashboard/DashboardCards.svelte b/src/lib/components/dashboard/DashboardCards.svelte index 4446d94f..848f20dc 100644 --- a/src/lib/components/dashboard/DashboardCards.svelte +++ b/src/lib/components/dashboard/DashboardCards.svelte @@ -18,6 +18,7 @@ } from '$lib/sensor-labels'; import { m } from '$lib/paraglide/messages.js'; import { goto } from '$app/navigation'; + import { onAppForeground } from '$lib/utils/onAppForeground'; interface Filters { group: string; @@ -216,12 +217,21 @@ }); }); + let removeForegroundListener: (() => void) | null = null; + onMount(() => { pollTimer = setInterval(refreshLoaded, REFRESH_INTERVAL_MS); + // Pull fresh data whenever the user returns to the tab/app, so devices + // that went "stale" while the page sat in the background don't linger as + // false offline indicators. + removeForegroundListener = onAppForeground(() => { + void refreshLoaded(); + }); }); onDestroy(() => { if (pollTimer) clearInterval(pollTimer); + removeForegroundListener?.(); }); diff --git a/src/lib/components/dashboard/DashboardTable.svelte b/src/lib/components/dashboard/DashboardTable.svelte index ffb0b8f4..dbf887cb 100644 --- a/src/lib/components/dashboard/DashboardTable.svelte +++ b/src/lib/components/dashboard/DashboardTable.svelte @@ -8,6 +8,7 @@ import { getAppContext } from '$lib/appContext.svelte'; import { formatSensorValue, labelFor } from '$lib/sensor-labels'; import { m } from '$lib/paraglide/messages.js'; + import { onAppForeground } from '$lib/utils/onAppForeground'; interface Filters { group: string; @@ -82,14 +83,21 @@ return { rows: page.rows, total: page.total }; } + // Bumped whenever the user returns to the tab/app, to force a fresh pull so + // devices that went stale in the background don't show as false offline. + let refreshToken = $state(0); + $effect(() => onAppForeground(() => (refreshToken += 1))); + // Mapped into CwDataTable's `Record` filter shape so the // table re-runs `loadData` whenever the dashboard filters (incl. the search - // box) change. The actual values are read from `filters` inside `loadData`. + // box) or the refresh token change. The actual filter values are read from + // `filters` inside `loadData`; `_refresh` only exists to trigger a reload. const tableFilters = $derived({ group: filters.group ? [filters.group] : [], locationGroup: filters.locationGroup ? [filters.locationGroup] : [], location: filters.location ? [filters.location] : [], - name: filters.name ? [filters.name] : [] + name: filters.name ? [filters.name] : [], + _refresh: [String(refreshToken)] }); diff --git a/src/lib/utils/onAppForeground.ts b/src/lib/utils/onAppForeground.ts new file mode 100644 index 00000000..69426266 --- /dev/null +++ b/src/lib/utils/onAppForeground.ts @@ -0,0 +1,34 @@ +/** + * Invoke `callback` when the app returns to the foreground — i.e. the browser + * tab becomes visible again, or the window regains focus after the user + * switched to another tab or application. + * + * Both `visibilitychange` and `focus` are observed because switching *apps* + * (rather than browser tabs) does not reliably fire `visibilitychange` on every + * platform. A short cooldown collapses the burst that happens when both events + * fire together on return, so `callback` runs at most once per return. + * + * Returns a cleanup function. Safe to call during SSR (it becomes a no-op). + */ +export function onAppForeground(callback: () => void): () => void { + if (typeof document === 'undefined') return () => {}; + + const COOLDOWN_MS = 1000; + let lastFired = 0; + + const trigger = () => { + if (document.visibilityState !== 'visible') return; + const now = Date.now(); + if (now - lastFired < COOLDOWN_MS) return; + lastFired = now; + callback(); + }; + + document.addEventListener('visibilitychange', trigger); + window.addEventListener('focus', trigger); + + return () => { + document.removeEventListener('visibilitychange', trigger); + window.removeEventListener('focus', trigger); + }; +} diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte index ab2fd381..25a8d165 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte @@ -15,6 +15,7 @@ import { resolveExportTimeZone } from './csvExport'; import type { PageProps } from './$types'; import { onDestroy } from 'svelte'; + import { onAppForeground } from '$lib/utils/onAppForeground'; import DeviceDashboardHeader from './DeviceDashboardHeader.svelte'; import { DEFAULT_RANGE_SELECTION, @@ -323,6 +324,18 @@ return isRelayDevice ? await refreshRelayDisplayData() : await fetchLatestData(); } + // Full pull whenever the user returns to the tab/app. A detail page left in + // the background goes stale — the device can look offline purely because the + // page sat idle past its upload interval. Refresh the active history range + // first, then the latest reading so it prepends onto fresh history. + async function refreshOnForeground(): Promise { + if (!authToken || !devEui) return; + await selectRange(activeRange ?? DEFAULT_RANGE_SELECTION); + await refreshDisplayedData(); + } + + $effect(() => onAppForeground(() => void refreshOnForeground())); + async function queueRelayCommand( relay: RelayNumber, targetState: RelayTargetState,