aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md11
-rw-r--r--src/lib/auto-refresh.ts59
-rw-r--r--src/lib/refresh-on-refocus.ts46
-rw-r--r--src/lib/shared/refresh-policy.test.ts43
-rw-r--r--src/lib/shared/refresh-policy.ts31
-rw-r--r--src/routes/+layout.svelte4
6 files changed, 141 insertions, 53 deletions
diff --git a/README.md b/README.md
index b047498..19b641e 100644
--- a/README.md
+++ b/README.md
@@ -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>