diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-10-18 21:26:24 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-10-18 21:26:24 +0200 |
| commit | 031f99e626f52ea7baac9155b0cbe6e4fcfbfb31 (patch) | |
| tree | 5c6851213527ce7c8f5945c56a11448c7e8fdeac | |
| parent | 66939bab93cdcb9b7732c45d7bc3b99b6951fe15 (diff) | |
feat: poll server data so shared pages stay near-live
Refetch the current page every 15s while the tab is visible, gated by a
pure refresh policy: edit pages, hidden or offline tabs and in-flight
navigations are left alone. Replaces refresh-on-refocus with auto-refresh.
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | src/lib/auto-refresh.ts | 59 | ||||
| -rw-r--r-- | src/lib/refresh-on-refocus.ts | 46 | ||||
| -rw-r--r-- | src/lib/shared/refresh-policy.test.ts | 43 | ||||
| -rw-r--r-- | src/lib/shared/refresh-policy.ts | 31 | ||||
| -rw-r--r-- | src/routes/+layout.svelte | 4 |
6 files changed, 141 insertions, 53 deletions
@@ -3,13 +3,15 @@ chores). ## Features -- **Refocus refresh** — returning to the app after 10 seconds away refetches - the current page's data; edit pages are left untouched. +- **Auto refresh** — the current page's data is refetched every 15 seconds + while the tab is visible, and on returning to the app after at least 10 + seconds away; edit pages, hidden or offline tabs and in-flight navigations + are left untouched. No server push: polling. - **Groceries** — two concepts: the **catalog** (every item ever added) and the **list** (unchecked items — what to buy next). Quick add with autocomplete, bulk category updates while planning, aisle-grouped view - while shopping. Shared across devices, but live sync is not implemented — - refresh to see your partner's changes. + while shopping. Shared across devices — every tab polls the server, so your + partner's changes land within ~15 seconds. - **Product references** — for items like "coffee" or "pasta": one or more reference brands with a price per unit (€/kg or €/L) and a quality score (0–100), comparable from the item while in the store. One reference can be @@ -79,7 +81,6 @@ mise run deploy ## TODO -- Live updates from server - Auto-categorize new items (LLM-assisted) - https://automerge.org/docs/tutorial/ - Shared > Modal actions diff --git a/src/lib/auto-refresh.ts b/src/lib/auto-refresh.ts new file mode 100644 index 0000000..72bcdb9 --- /dev/null +++ b/src/lib/auto-refresh.ts @@ -0,0 +1,59 @@ +import { invalidateAll } from '$app/navigation'; +import { navigating, page } from '$app/stores'; +import { get } from 'svelte/store'; +import { shouldAutoRefresh } from '$lib/shared/refresh-policy'; + +/** Away for at least this long before refocus re-runs the page load. */ +const MIN_AWAY_MS = 10_000; + +/** Poll cadence: near-live for a shared list, cheap for one open tab. */ +const POLL_INTERVAL_MS = 15_000; + +/** + * Keeps the current page near-live: polls the server while the tab is visible, + * and refetches on refocus after an absence. Call from `onMount`; the returned + * function tears the listeners and timer down. + */ +export function startAutoRefresh(): () => void { + // When the tab stopped being the active one; null while it is active. + let deactivatedAt: number | null = null; + + const conditions = () => ({ + routeId: get(page).route.id, + visible: document.visibilityState === 'visible', + online: navigator.onLine, + navigating: get(navigating) !== null, + }); + + const deactivate = () => { + deactivatedAt ??= Date.now(); + }; + + const activate = () => { + if (deactivatedAt === null) return; + const awayMs = Date.now() - deactivatedAt; + deactivatedAt = null; + if (awayMs >= MIN_AWAY_MS && shouldAutoRefresh(conditions())) + void invalidateAll(); + }; + + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') deactivate(); + else activate(); + }; + + const timer = setInterval(() => { + if (shouldAutoRefresh(conditions())) void invalidateAll(); + }, POLL_INTERVAL_MS); + + document.addEventListener('visibilitychange', onVisibilityChange); + window.addEventListener('blur', deactivate); + window.addEventListener('focus', activate); + + return () => { + clearInterval(timer); + document.removeEventListener('visibilitychange', onVisibilityChange); + window.removeEventListener('blur', deactivate); + window.removeEventListener('focus', activate); + }; +} diff --git a/src/lib/refresh-on-refocus.ts b/src/lib/refresh-on-refocus.ts deleted file mode 100644 index fcb042a..0000000 --- a/src/lib/refresh-on-refocus.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { invalidateAll } from '$app/navigation'; -import { page } from '$app/stores'; -import { get } from 'svelte/store'; - -// Skip the refocus refresh on login and edit pages (in-progress edits). -const EXCLUDED_ROUTES: Record<string, true> = { - '/login': true, - '/grocery/[id]': true, - '/projects/[projectId]/routine/[routineId]': true, -}; - -const MIN_AWAY_MS = 10_000; - -/** Re-run page loads on refocus after MIN_AWAY_MS away; call from onMount. */ -export function refreshOnRefocus(): () => void { - let deactivatedAt: number | null = null; - - const deactivate = () => { - deactivatedAt ??= Date.now(); - }; - - const activate = () => { - if (deactivatedAt === null || document.visibilityState !== 'visible') - return; - const awayMs = Date.now() - deactivatedAt; - deactivatedAt = null; - if (awayMs < MIN_AWAY_MS) return; - if ((get(page).route.id ?? '') in EXCLUDED_ROUTES) return; - invalidateAll(); - }; - - const onVisibilityChange = () => { - if (document.visibilityState === 'hidden') deactivate(); - else activate(); - }; - - document.addEventListener('visibilitychange', onVisibilityChange); - window.addEventListener('blur', deactivate); - window.addEventListener('focus', activate); - - return () => { - document.removeEventListener('visibilitychange', onVisibilityChange); - window.removeEventListener('blur', deactivate); - window.removeEventListener('focus', activate); - }; -} diff --git a/src/lib/shared/refresh-policy.test.ts b/src/lib/shared/refresh-policy.test.ts new file mode 100644 index 0000000..5cab234 --- /dev/null +++ b/src/lib/shared/refresh-policy.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { shouldAutoRefresh } from './refresh-policy'; + +const idle = { visible: true, online: true, navigating: false }; + +describe('shouldAutoRefresh', () => { + it('refreshes a list page when the tab is visible and idle', () => { + expect(shouldAutoRefresh({ routeId: '/groceries', ...idle })).toBe(true); + expect(shouldAutoRefresh({ routeId: '/groceries/catalog', ...idle })).toBe( + true, + ); + expect(shouldAutoRefresh({ routeId: '/projects/[id]', ...idle })).toBe( + true, + ); + }); + + it('never refreshes a page holding an in-progress edit', () => { + expect(shouldAutoRefresh({ routeId: '/grocery/[id]', ...idle })).toBe( + false, + ); + expect( + shouldAutoRefresh({ + routeId: '/projects/[projectId]/routine/[routineId]', + ...idle, + }), + ).toBe(false); + expect(shouldAutoRefresh({ routeId: '/login', ...idle })).toBe(false); + }); + + it('holds off while hidden, offline or mid-navigation', () => { + const routeId = '/groceries'; + expect(shouldAutoRefresh({ routeId, ...idle, visible: false })).toBe(false); + expect(shouldAutoRefresh({ routeId, ...idle, online: false })).toBe(false); + expect(shouldAutoRefresh({ routeId, ...idle, navigating: true })).toBe( + false, + ); + }); + + it('holds off when the route is not resolved yet', () => { + expect(shouldAutoRefresh({ routeId: null, ...idle })).toBe(false); + expect(shouldAutoRefresh({ routeId: undefined, ...idle })).toBe(false); + }); +}); diff --git a/src/lib/shared/refresh-policy.ts b/src/lib/shared/refresh-policy.ts new file mode 100644 index 0000000..d79e6b7 --- /dev/null +++ b/src/lib/shared/refresh-policy.ts @@ -0,0 +1,31 @@ +/** + * Routes whose page data must not be refetched in the background: edit pages + * hold in-progress work, and refreshing the login form is pointless. + */ +export const EXCLUDED_ROUTES: Record<string, true> = { + '/login': true, + '/grocery/[id]': true, + '/projects/[projectId]/routine/[routineId]': true, +}; + +export interface RefreshConditions { + /** `$page.route.id`; null when no route matched yet. */ + routeId: string | null | undefined; + /** Document is visible (not a backgrounded tab). */ + visible: boolean; + /** Browser reports a network connection. */ + online: boolean; + /** A navigation or form action is already in flight. */ + navigating: boolean; +} + +/** A background refetch may re-run the page load only if nothing is at stake. */ +export function shouldAutoRefresh({ + routeId, + visible, + online, + navigating, +}: RefreshConditions): boolean { + if (!routeId || routeId in EXCLUDED_ROUTES) return false; + return visible && online && !navigating; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2956cd8..8a59b96 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,11 +3,11 @@ import { page } from '$app/stores'; import { onMount } from 'svelte'; import Nav from '$lib/components/Nav.svelte'; - import { refreshOnRefocus } from '$lib/refresh-on-refocus'; + import { startAutoRefresh } from '$lib/auto-refresh'; let { children } = $props(); - onMount(refreshOnRefocus); + onMount(startAutoRefresh); </script> <svelte:head> |
