Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 4.8 6db61b0cc7 test(e2e): block the service worker except where the test is about it
The caching worker controls every page once it registers, and Playwright's
page.route() does not see the fetches a worker makes — so the failed-load test,
which forces a 500 to check the error path, got the real 200 through the worker
and read as a broken feature. Block the worker by default and turn it back on only
for the offline test, which is the one that needs a real one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 08:12:15 +02:00
kristianwindandClaude Opus 4.8 57689ddfbb feat(offline): hold through a short network drop with a caching worker
The service worker did Web Push and nothing else, with a note that a cache
serving yesterday's data was worse than an honest "cannot connect". This adds a
cache that does not do that: network-first for everything that can go stale, so a
reader online always gets the live answer and the cache is reached only when the
network cannot answer. The task and note lists stay live; they just no longer go
blank the moment the signal does — a reload through a tunnel, a lift or a
base-station handover comes back to the app instead of the browser's offline page.

Writes are untouched and still go straight to the network; api.js already retries
them across the same short drop. The shell cache is versioned so a deploy lands in
a fresh cache and the old one is dropped on activate; the data cache is cleared on
sign-out so the next person on the browser does not inherit it.

The old hand-registered /sw.js becomes SvelteKit's src/service-worker.js, which it
registers itself, and the push handlers move across unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 22:46:35 +02:00
Kristian Wind 7770ba9844 Merge pull request #54 from kristianwind/tasks-first-tap-rounded
CI / OpenAPI is valid (push) Successful in 14s
CI / End-to-end (push) Successful in 11m2s
CI / Docker image builds (push) Successful in 2m40s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 15m27s
Rows: open on the first tap on iPhone, round the hover highlight
2026-09-03 22:23:08 +02:00
kristianwindandClaude Opus 4.8 55b8e438ad fix(rows): open on the first tap on iPhone, and round the hover highlight
On a touch screen a :hover on the tap target makes iOS spend the first tap
applying the hover and only open on the second — the double-tap Kristian hit
on tasks (and notes). The row hover is gated behind @media (hover: hover)
now, so a touch has no hover to apply and the first tap opens. The note
row's action buttons, which the hover used to reveal, are shown outright on
touch instead. And the task-row hover highlight is a rounded box (border
radius on the row), the same corner as a note in the list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 22:15:23 +02:00
Kristian Wind 1607a22f0c Merge pull request #53 from kristianwind/charcoal-separators
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m56s
CI / Docker image builds (push) Successful in 2m2s
Release / Build and publish image (push) Failing after 5s
CI / Go (push) Failing after 15m10s
Charcoal: lighter day separators in the note list
2026-09-03 20:54:56 +02:00
kristianwindandClaude Opus 4.8 8a5dfef41a fix(theme): lighter day separators in Charcoal
In Charcoal the note-list day separators used --ground, the darkest colour
in the theme, so they read as dark bars on the lighter list. They take the
menu's own resting grey (#2d3133) now — the colour "Projekter" sits on —
via a --note-group-bg fallback token, so every other theme keeps --ground.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 20:46:51 +02:00
Kristian Wind 1a610eaac4 Merge pull request #52 from kristianwind/notes-single-tap
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m25s
CI / Docker image builds (push) Successful in 1m58s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 15m11s
Notes: open on the first tap, not the second
2026-09-03 20:35:30 +02:00
kristianwindandClaude Opus 4.8 c2d4ce0179 fix(notes): open a note on the first tap, not the second
On a phone the list/editor switch keyed on the loaded note, which arrives a
moment after the tap — so the first tap looked like nothing happened and
people tapped again. It keys on the selected id now, set the instant a row
is tapped, so the pane switches at once; the back button is there
immediately and the body fills in when it arrives, with a quiet pane rather
than the "pick a note" prompt in the half-second between.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 20:27:28 +02:00
42 changed files with 313 additions and 83 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+58 -4
View File
@@ -298,11 +298,65 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
const appleIcon = await request.get('/apple-touch-icon.png');
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
// The service worker is what Web Push needs; without it a subscription cannot
// be created at all.
const worker = await request.get('/sw.js');
// The service worker is what Web Push needs — and, since it also caches, what
// carries the app through a short network drop. SvelteKit serves it under this
// name and registers it itself; the old hand-registered /sw.js is gone.
const worker = await request.get('/service-worker.js');
expect(worker.ok()).toBeTruthy();
expect(await worker.text()).toContain("addEventListener('push'");
// The source is minified, so the quotes around the event names are whatever the
// minifier chose — match either kind rather than one it might change under us.
const source = await worker.text();
expect(source).toMatch(/addEventListener\(['"`]push['"`]/);
expect(source).toMatch(/addEventListener\(['"`]fetch['"`]/);
});
/**
* The app survives a network drop: reloaded with the network cut, it opens from
* the cache and still shows the tasks it last saw, instead of the browser's own
* "you are offline" page.
*
* This is the whole point of the service worker's cache — not offline authoring,
* which the app does not claim, but a reload through a tunnel or a handover coming
* back to the app rather than to a dead tab. The retry in api.js covers the write
* side of the same drop; this covers the read side.
*/
test.describe('med service worker', () => {
// The rest of the suite blocks the worker (see playwright.config.js); this one
// test is about the worker, so it turns it back on for its own context.
test.use({ serviceWorkers: 'allow' });
test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => {
// First load, online: the worker installs, and once it controls the page the
// shell and the reads it serves are in the cache.
await page.goto('/');
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
// Wait until the worker actually controls this page. Until it does, a reload
// still goes to the network and the test proves nothing — the first load
// registers the worker but the page that triggered the install is not itself
// controlled until it activates and claims.
await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, {
timeout: 15000
});
// A controlled reload, still online: now the navigation and its API reads pass
// through the worker and land in the cache, ready for the drop.
await page.reload();
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
// The network is gone.
await context.setOffline(true);
try {
await page.reload();
// The shell came from the cache — the sidebar is here, not a browser error
// page — and so did the task list the app renders into it.
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible({ timeout: 15000 });
await expect(page.getByRole('link', { name: 'Indbakke' })).toBeVisible();
} finally {
// Never leave the shared context offline for whatever test runs next.
await context.setOffline(false);
}
});
});
/**
+6
View File
@@ -51,6 +51,12 @@ export default defineConfig({
use: {
baseURL,
// Off by default. The app now ships a caching service worker, and once it
// controls a page Playwright's page.route() no longer sees the fetches it
// makes — so a test that forces a 500 to check the error path gets the real
// 200 through the worker instead, and reads as a broken feature. The one test
// that needs the real worker turns it back on for its own context.
serviceWorkers: 'block',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
// Pinned, because the app reads both. The sign-up form sends
+1
View File
@@ -242,6 +242,7 @@
--note-on-ink: #1e1e1e; /* dark text on the gold */
--nav-active-ink: #1e1e1e; /* dark text on the amber menu item */
--heading-ink: #8a8e90; /* PROJEKTER and the group names — quiet grey */
--note-group-bg: #2d3133; /* day separators take the menu's resting grey, not the darker ground */
--p1: #cf6b6b;
--p2: #d69f5c;
+10
View File
@@ -31,6 +31,16 @@
async function signOut() {
await api.logout();
// Empty the offline data cache before leaving: the service worker keeps the
// last-seen task and note lists so a network drop does not blank the app, and
// the next person to sign in on this browser must not find them.
try {
navigator.serviceWorker?.controller?.postMessage({ type: 'clear-api-cache' });
await caches?.delete('verdande-api');
} catch {
// No service worker / no Cache API (older browser, private mode): nothing
// was cached, so nothing to clear.
}
location.href = '/';
}
+10 -2
View File
@@ -228,13 +228,21 @@
align-items: flex-start;
padding: var(--s3) var(--s2);
border-bottom: 1px solid var(--line);
border-radius: var(--radius);
transition:
opacity var(--medium) var(--ease),
transform var(--medium) var(--ease);
}
.row:hover {
background: var(--surface);
/* Hover only where there is a real pointer. On a touch screen a :hover on the tap
target makes iOS spend the first tap applying the hover and only open on the
second — the "double-tap" on iPhone. With no hover to apply, the first tap opens
the task. The rounded corners are on the row itself, so the highlight is a
rounded box like a note in the list. */
@media (hover: hover) {
.row:hover {
background: var(--surface);
}
}
/* The exit: a short lift and fade, so completing something reads as the task
+5 -5
View File
@@ -1,11 +1,11 @@
/**
* Connectivity, as the app experiences it — not as the browser reports it.
*
* This is for short drops: a few seconds of lost Wi-Fi, a tunnel, a base station
* handing over. It is deliberately NOT offline support. The app is already loaded
* and its data is already in memory, so there is nothing to cache and nothing to
* open from cold; what a blip needs is for the save you just made to survive it,
* not for the whole app to run without a server.
* This is the WRITE side of surviving a short drop — a few seconds of lost Wi-Fi, a
* tunnel, a base station handing over. The READ side lives in the service worker,
* which keeps the last-seen task and note lists so a reload through a drop is not a
* blank screen. This store is the other half: it is what keeps the save you just made
* from being reverted under your hands while the network is briefly gone.
*
* The request layer drives this: it retries a failed request for a short window
* (see api.js) and tells this store while it is trying, so the interface can say
+7 -3
View File
@@ -26,9 +26,13 @@ export function permission() {
}
async function registration() {
// `ready` waits for an active worker; register() alone resolves while the
// worker is still installing, and subscribing against that fails.
await navigator.serviceWorker.register('/sw.js');
// SvelteKit registers src/service-worker.js itself on load, so normally there
// is already a registration here. Register it by hand only if there is not yet
// one — a push subscribe that races the app's own registration would otherwise
// find nothing. `ready` waits for an active worker; register() alone resolves
// while the worker is still installing, and subscribing against that fails.
const existing = await navigator.serviceWorker.getRegistration();
if (!existing) await navigator.serviceWorker.register('/service-worker.js');
return navigator.serviceWorker.ready;
}
+32 -5
View File
@@ -710,7 +710,7 @@
<!-- `has-note` drives the phone layout: one column, showing the list until a note
is opened and the editor once one is. On a wide screen both are always up and
the class does nothing. -->
<div class="notes" class:has-note={selected}>
<div class="notes" class:has-note={selectedId}>
<aside>
<div class="head">
<h1>{t('notes.title')}</h1>
@@ -919,10 +919,15 @@
</aside>
<section class="editor">
{#if selected}
{#if selectedId}
<!-- Keyed on the id, which is set the instant a row is tapped — not on the
loaded note, which arrives a moment later. On a phone that moment was the
whole bug: the pane did not switch until the body had loaded, so the first
tap looked like nothing and people tapped again. -->
<!-- Phone only (hidden on a wide screen by CSS): the way back to the list,
since there the editor fills the screen and the list is not beside it. -->
<button class="back" onclick={() => open(null)}>‹ {t('notes.title')}</button>
{#if selected}
<NoteEditor
note={selected}
notes={notes}
@@ -1056,6 +1061,12 @@
{/if}
</div>
</footer>
{:else}
<!-- Chosen, but its full text is still loading. Keep the pane (so the tap
felt like it landed) and stay quiet — the "pick a note" line is for
when nothing is chosen at all, not for this half-second. -->
<p class="empty" aria-busy="true"></p>
{/if}
{:else}
<p class="empty">{t('notes.pickOne')}</p>
{/if}
@@ -1395,7 +1406,10 @@
top: 0;
z-index: 1;
margin: 0 0 var(--s1);
background: var(--ground);
/* --ground everywhere but Kul, where the ground is the darkest thing on screen
and a day separator on it reads as a dark bar. There it takes the menu's own
resting colour instead, via --note-group-bg. */
background: var(--note-group-bg, var(--ground));
border-radius: var(--radius);
}
@@ -1481,8 +1495,21 @@
white-space: nowrap;
}
.row:hover {
background: var(--surface);
/* Pointer only. On a touch screen a :hover on the tap target costs a tap — iOS
spends the first applying the hover and opens on the second. Gated, the first
tap opens the note. The row's buttons are shown outright on touch below, since
there is no hover to reveal them. */
@media (hover: hover) {
.row:hover {
background: var(--surface);
}
}
@media (hover: none) {
.star,
.archive-one {
opacity: 1;
}
}
.row.on {
+184
View File
@@ -0,0 +1,184 @@
/// <reference types="@sveltejs/kit" />
/* global self, caches, fetch, Response */
/**
* verdande's service worker: Web Push, and a cache that holds through a short
* network drop.
*
* It used to do Push and nothing else, with a note that a cache serving yesterday's
* data was worse than an honest "cannot connect". The cache here does not do that.
* It is network-FIRST for everything that can go stale — a reader online always gets
* the live answer, and the cache is only reached for when the network is not — so it
* is not a copy of the app you work in offline, it is the app surviving a tunnel, a
* lift, a base-station handover. The five themes' worth of live task list stays live;
* it just no longer goes blank the moment the signal does.
*
* Writes are never touched here — they go straight to the network, and api.js already
* retries them across a short drop. Caching a write would mean a queue and conflicts,
* which is a different and much larger feature.
*/
import { build, files, version } from '$service-worker';
// Versioned, so a deploy's worth of new hashed assets lands in a fresh cache and the
// old one is dropped on activate — the app can never be served half of two builds.
const SHELL = `verdande-shell-${version}`;
// Not versioned: the data does not belong to a build, and clearing it every deploy
// would throw away the very copy that lets a reload survive a drop. Cleared on sign-out.
const API = 'verdande-api';
// The shell: every hashed build asset, the static files, and the SPA fallback so a
// navigation can be answered from the cache when the network cannot answer it.
const SHELL_ASSETS = [...build, ...files, '/'];
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL);
await cache.addAll(SHELL_ASSETS);
// Take over at the next opportunity rather than waiting for every tab to
// close — paired with clients.claim below and the versioned cache, a deploy
// reaches the user on their next load.
await self.skipWaiting();
})()
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
for (const key of await caches.keys()) {
// Drop old shell versions; keep this shell and the data cache.
if (key !== SHELL && key !== API) await caches.delete(key);
}
await self.clients.claim();
})()
);
});
const isAsset = (pathname) => build.includes(pathname) || files.includes(pathname);
self.addEventListener('fetch', (event) => {
const { request } = event;
// Writes go to the network, full stop — the app retries them itself, and a cached
// write is a promise this cannot keep. Cross-origin is none of our business.
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Hashed build assets and static files never change for a given URL: cache-first,
// so a repeat load and an offline load are both instant.
if (isAsset(url.pathname)) {
event.respondWith(cacheFirst(request, SHELL));
return;
}
// The data: network-first so an online reader always sees the live list, with the
// last good copy kept for when the network is gone.
if (url.pathname.startsWith('/api/v1/')) {
event.respondWith(networkFirst(request, API));
return;
}
// A navigation (reload, or opening the app): the live page when there is one, the
// cached shell when there is not — so the app opens through a drop instead of
// showing the browser's dinosaur.
if (request.mode === 'navigate') {
event.respondWith(navigate(request));
return;
}
});
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const hit = await cache.match(request);
if (hit) return hit;
const res = await fetch(request);
if (res.ok) cache.put(request, res.clone());
return res;
}
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const res = await fetch(request);
// Only a real answer is worth keeping — a 401 or a 500 must not overwrite the
// last good copy, or an expired session would be served back offline as fact.
if (res.ok) cache.put(request, res.clone());
return res;
} catch (err) {
const hit = await cache.match(request);
if (hit) return hit;
throw err;
}
}
async function navigate(request) {
try {
return await fetch(request);
} catch {
const cache = await caches.open(SHELL);
const shell = await cache.match('/');
return shell ?? Response.error();
}
}
// Sign-out asks for the data cache to be emptied: the next person on this browser must
// not find the last one's tasks sitting in it.
self.addEventListener('message', (event) => {
if (event.data?.type === 'clear-api-cache') {
event.waitUntil(caches.delete(API));
}
});
// --- Web Push (unchanged in behaviour, moved here from the old static/sw.js) --------
self.addEventListener('push', (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
// A push whose body is not our JSON still deserves to be shown: browsers show
// their own "site updated in the background" notice otherwise, which looks like
// a bug in the app.
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
}
const title = payload.title || 'verdande';
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || '',
icon: '/icon.svg',
badge: '/icon.svg',
// The tag collapses repeats: five comments on one task should replace each
// other rather than stack into five separate notifications.
tag: payload.tag || payload.url || 'verdande',
data: { url: payload.url || '/' }
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = event.notification.data?.url || '/';
event.waitUntil(
(async () => {
const clientList = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true
});
// Focus a tab that already has the app open rather than opening a second one.
// Somebody with the app pinned does not want a new tab per reminder.
for (const client of clientList) {
if (new URL(client.url).origin === self.location.origin) {
await client.focus();
if ('navigate' in client) await client.navigate(target);
return;
}
}
await self.clients.openWindow(target);
})()
);
});
-64
View File
@@ -1,64 +0,0 @@
/**
* verdande's service worker.
*
* It exists for Web Push and nothing else. There is deliberately no offline cache:
* the app is a live view of a task list that other people are editing, and a cached
* shell serving yesterday's data is worse than a browser saying it cannot connect.
* Adding one later means versioning and invalidating it, which is a real feature
* rather than a few lines here.
*/
// Take over as soon as the browser is willing. Without these two, the first push
// after granting permission arrives with no worker in control to show it.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
self.addEventListener('push', (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
// A push whose body is not our JSON still deserves to be shown: browsers
// display their own "site updated in the background" notice otherwise, which
// looks like a bug in the app.
payload = { title: 'verdande', body: event.data ? event.data.text() : '' };
}
const title = payload.title || 'verdande';
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || '',
icon: '/icon.svg',
badge: '/icon.svg',
// The tag collapses repeats: five comments on one task should replace each
// other rather than stack into five separate notifications.
tag: payload.tag || payload.url || 'verdande',
data: { url: payload.url || '/' }
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = event.notification.data?.url || '/';
event.waitUntil(
(async () => {
const clientList = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true
});
// Focus a tab that already has the app open rather than opening a second
// one. Somebody with the app pinned does not want a new tab per reminder.
for (const client of clientList) {
if (new URL(client.url).origin === self.location.origin) {
await client.focus();
if ('navigate' in client) await client.navigate(target);
return;
}
}
await self.clients.openWindow(target);
})()
);
});