Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d390d021f | ||
|
|
18db79c063 | ||
|
|
1612f53966 | ||
|
|
4d5f1a56b1 | ||
|
|
274e392708 | ||
|
|
6db61b0cc7 | ||
|
|
57689ddfbb |
@@ -85,7 +85,7 @@ what a person reads is decided by the client, which knows what language they are
|
||||
curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
|
||||
-H "Authorization: Bearer vrd_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text": "betal moms i morgen kl 10 p1 #Firma @regnskab"}'
|
||||
-d '{"text": "file the VAT return tomorrow at 10am p1 #Accounts @tax"}'
|
||||
```
|
||||
|
||||
### Projects and sections
|
||||
|
||||
@@ -18,9 +18,10 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
|
||||
## What it does
|
||||
|
||||
- **[Quick add](quick-add.md) that reads what you type.** `betal moms i morgen kl
|
||||
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
|
||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||
- **[Quick add](quick-add.md) that reads what you type.** `file the VAT return
|
||||
tomorrow at 10am p1 #Accounts @tax` becomes a task due tomorrow at 10:00,
|
||||
priority 1, in Accounts, labelled tax. Danish and English, mixed freely in one
|
||||
line.
|
||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||
The calendar draws each task to its length — drag the foot to resize, the body
|
||||
@@ -39,8 +40,17 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
||||
- **Live sync** — a change made by somebody else appears without a refresh.
|
||||
- **Repeating tasks** as RRULE, comments and attachments, and reminders that go
|
||||
out as Web Push to an iPhone or a Mac.
|
||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
||||
open standards rather than private formats.
|
||||
- **[Your mail as an inbox](mailboxes.md).** Star a mail in Gmail, or flag one
|
||||
over IMAP in iCloud or Fastmail, and it becomes a task — and the star or flag
|
||||
comes off once it has, so a starred inbox empties as the tasks are made instead
|
||||
of only filling up.
|
||||
- **[Calendar](caldav.md) and [Claude](mcp.md)** connected over open standards
|
||||
rather than private formats.
|
||||
- **Six themes**, three dark and three light — including *Charcoal*, a warm
|
||||
near-black after Apple Notes — and a size and typeface chosen apart from them.
|
||||
- **Holds through a short network drop.** A save rides out a few seconds of no
|
||||
signal and goes through when it comes back; a reload opens from what it last saw
|
||||
rather than the browser's offline page. It installs as a PWA.
|
||||
|
||||
## What it looks like
|
||||
|
||||
@@ -60,6 +70,16 @@ columns.*
|
||||
marked with an arrow to them, so a delegated task reads as delegated wherever you
|
||||
meet it.*
|
||||
|
||||

|
||||
|
||||
*Charcoal, the newest of six themes — a warm near-black after Apple Notes, with
|
||||
amber where the accent green usually is.*
|
||||
|
||||

|
||||
|
||||
*Six themes — three dark, three light — chosen on the settings page, with the
|
||||
typeface and how tightly it sits set apart from them.*
|
||||
|
||||

|
||||
|
||||
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
|
||||
|
||||
@@ -57,9 +57,9 @@ Both endpoints run the same server and reach the same things.
|
||||
| `complete_task` | Tick one off. |
|
||||
| `add_comment` | Comment on a task. |
|
||||
|
||||
`create_task` prefers natural language, so *"add betal moms i morgen kl 10 p1
|
||||
#Firma"* goes through the same parser the app uses — dates, priorities, projects,
|
||||
labels and recurrence in either language.
|
||||
`create_task` prefers natural language, so *"add file the VAT return tomorrow at
|
||||
10am p1 #Accounts"* goes through the same parser the app uses — dates, priorities,
|
||||
projects, labels and recurrence in either language.
|
||||
|
||||
## What it can reach
|
||||
|
||||
|
||||
@@ -832,9 +832,9 @@ paths:
|
||||
tags: [tasks]
|
||||
summary: Create a task from one line of natural language.
|
||||
description: |
|
||||
Danish and English. `betal moms i morgen kl 10 p1 #Firma @vigtigt` becomes
|
||||
a task due tomorrow at 10:00, priority 1, in the project Firma, labelled
|
||||
vigtigt. An unknown `#project` is not an error — the task goes to the
|
||||
Danish and English. `file the VAT return tomorrow at 10am p1 #Accounts @tax`
|
||||
becomes a task due tomorrow at 10:00, priority 1, in the project Accounts,
|
||||
labelled tax. An unknown `#project` is not an error — the task goes to the
|
||||
Inbox rather than a thought being thrown away over a typo.
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
One line in, a fully-formed task out.
|
||||
|
||||
```
|
||||
betal moms i morgen kl 10 p1 #Firma @regnskab
|
||||
file the VAT return tomorrow at 10am p1 #Accounts @tax
|
||||
```
|
||||
|
||||
becomes a task called **betal moms**, due tomorrow at 10:00, priority 1, in the
|
||||
Firma project, labelled regnskab.
|
||||
becomes a task called **file the VAT return**, due tomorrow at 10:00, priority 1,
|
||||
in the Accounts project, labelled tax.
|
||||
|
||||
The parts it understands are tinted underneath the text as you type, so you can see
|
||||
it being read before you commit to it.
|
||||
@@ -16,8 +16,8 @@ it being read before you commit to it.
|
||||
|
||||
| You type | It means |
|
||||
|---|---|
|
||||
| `#Firma` | Put it in that project. `#"Q3 rapport"` for a name with spaces. |
|
||||
| `@regnskab` | Add that label. As many as you like. |
|
||||
| `#Accounts` | Put it in that project. `#"Q3 report"` for a name with spaces. |
|
||||
| `@tax` | Add that label. As many as you like. |
|
||||
| `p1` … `p4` | Priority. `!!!`, `!!` and `!` also work. |
|
||||
| `i dag`, `i morgen`, `i overmorgen` | Today, tomorrow, the day after. |
|
||||
| `mandag`, `på fredag` | The next such weekday. |
|
||||
|
||||
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 367 KiB |
|
Before Width: | Height: | Size: 355 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 220 KiB |
@@ -992,12 +992,22 @@
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/themes.webp"
|
||||
alt="The settings page showing five themes as swatches and, below them, four looks written in the typeface each one selects."
|
||||
alt="The settings page showing six themes as swatches and, below them, four looks written in the typeface each one selects."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Five themes and four looks: how bright, and how it reads.</figcaption>
|
||||
<figcaption>Six themes and four looks: how bright, and how it reads.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/notes-charcoal.webp"
|
||||
alt="Notes in the Charcoal theme: a warm near-black modelled on Apple Notes, the note list on the left and a note open on the right, with amber where the accent green usually is."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Charcoal — the newest theme, a warm near-black after Apple Notes, amber for the accent.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
@@ -1040,6 +1050,75 @@
|
||||
<figcaption>Integrations: your mailbox, your calendar feed, your address.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<h3 class="sub">Six themes, the same day</h3>
|
||||
<p class="muted">
|
||||
Three dark and three light — a property of the screen you are sitting at, a
|
||||
laptop in the sun and a monitor in a dark room wanting different answers. The
|
||||
typeface and how tight it sits are chosen apart from them.
|
||||
</p>
|
||||
<div class="shots">
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/theme-northern-lights.webp"
|
||||
alt="The Today view in the Northern lights theme: dark, with a green accent."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Northern lights — dark, a green accent. The default.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/theme-charcoal.webp"
|
||||
alt="The Today view in the Charcoal theme: a warm near-black modelled on Apple Notes, with amber for the accent."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Charcoal — a warm near-black after Apple Notes, amber accent.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/theme-dusk.webp"
|
||||
alt="The Today view in the Dusk theme: dark and warm, with an amber accent."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Dusk — dark and warm.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/theme-daylight.webp"
|
||||
alt="The Today view in the Daylight theme: light and coolly neutral."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Daylight — light and coolly neutral.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/theme-paper.webp"
|
||||
alt="The Today view in the Paper theme: light and warm, like paper."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Paper — light and warm, like paper.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/theme-contrast.webp"
|
||||
alt="The Today view in the Contrast theme: black on white, for bright light."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Contrast — black on white, for bright light.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="features">
|
||||
@@ -1120,6 +1199,15 @@
|
||||
by itself — without touching when it is actually due.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Holds through a drop</h3>
|
||||
<p>
|
||||
A tunnel, a lift, a base station handing over — the app does not go
|
||||
blank when the signal does. A save you make rides out a few seconds of
|
||||
no connection and goes through when it comes back, and a reload opens
|
||||
from what it last saw rather than the browser's offline page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="group-label">Connected to what you already use</p>
|
||||
@@ -1154,9 +1242,10 @@
|
||||
<h3>Your mail, as an inbox</h3>
|
||||
<p>
|
||||
Star a mail in Gmail, or flag one over IMAP in iCloud or Fastmail, and
|
||||
it becomes a task. Forward one to your own address instead and the
|
||||
subject is parsed like anything else you type. One way: unstarring it
|
||||
later does nothing to the task you made.
|
||||
it becomes a task — and the star or flag comes off once it has, so a
|
||||
starred inbox empties as the tasks are made instead of only filling up.
|
||||
Forward one to your own address instead and the subject is parsed like
|
||||
anything else you type.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
@@ -1237,6 +1326,25 @@
|
||||
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
|
||||
</p>
|
||||
<ul class="lately">
|
||||
<li>
|
||||
<span class="ver">0.42</span>
|
||||
A sixth theme, <strong>Charcoal</strong> — a warm near-black after Apple
|
||||
Notes, with amber for the accent. And the app now holds through a short
|
||||
network drop: a save rides out a few seconds of no signal, and a reload
|
||||
opens from what it last saw rather than the browser's offline page.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.42</span>
|
||||
A starred Gmail message loses its star once it has become a task, the way
|
||||
a flagged IMAP mail already did — so a starred inbox empties itself as the
|
||||
tasks are made instead of only filling up.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.42</span>
|
||||
Notes read as three lines, the last one the project they are filed under;
|
||||
the calendar lightened to pastel days with today marked at the top; and
|
||||
two-factor set-up shows a QR code to scan.
|
||||
</li>
|
||||
<li>
|
||||
<span class="ver">0.41</span>
|
||||
A task you have delegated is marked where you meet it, not only on the
|
||||
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 38 KiB |
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -73,6 +73,19 @@ const SHOTS = [
|
||||
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
|
||||
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
|
||||
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
|
||||
// Charcoal, the newest theme — a warm near-black modelled on Apple Notes, with
|
||||
// amber where the green usually is. The notes list shows a theme's surfaces, text
|
||||
// and one accent at once, so that is where the landing page carries it up close.
|
||||
{ name: 'notes-charcoal', path: '/noter', theme: 'charcoal', prepare: openMeetingNote },
|
||||
// The same Today view in each of the six themes, for a gallery that shows the
|
||||
// whole range at a glance. One view throughout, so the only thing that changes
|
||||
// from picture to picture is the theme itself. Names are the themes' own.
|
||||
{ name: 'theme-northern-lights', path: '/', theme: 'dark' },
|
||||
{ name: 'theme-charcoal', path: '/', theme: 'charcoal' },
|
||||
{ name: 'theme-dusk', path: '/', theme: 'dusk' },
|
||||
{ name: 'theme-daylight', path: '/', theme: 'light' },
|
||||
{ name: 'theme-paper', path: '/', theme: 'paper' },
|
||||
{ name: 'theme-contrast', path: '/', theme: 'contrast' },
|
||||
// The new work, and in the light theme, which is what most people want to look
|
||||
// at a landing page in. The calendar drawing tasks to their length, and a note
|
||||
// somebody shared into the "Shared with me" group.
|
||||
|
||||
@@ -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 = '/';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
})()
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
})()
|
||||
);
|
||||
});
|
||||