merge: engelske skærmbilleder, og otte steder hvor fladen blev ved med at være dansk
Release / Build and publish image (push) Failing after 9m54s

Skærmbillederne er engelske, fjorten i alt, og der er to mennesker i dem. Det
sidste er ikke pynt: halvdelen af det, programmet er til, findes ikke med én
konto — en ansvarlig, et navn i historikken, en medlemsliste, og "Venter på
andre" som andet end en tom side.

Sproget kommer fra kontoens locale og ikke fra strenge skrevet i hånden, hvilket
er dét, der fandt fejlene: otte steder, hvor grænsefladen blev ved med at være
dansk, uanset hvad kontoen sagde. "Forsinket" og "I dag" skrevet ind i markup,
"Uden sektion" som boardets første kolonne, gentagelsesreglerne halvt oversat,
alle nitten navne på hændelser i historikken, og et `da-DK` hårdkodet i Kommende.
Gentagelsesreglerne er flyttet til `when.js`, så menuen og mærket ikke kan blive
uenige igen — det er præcis, hvad den fil er til.

README's billeder havde været brudte siden `37605ba`, som lavede PNG'erne om til
WebP og slettede dem, uden at teksten fulgte med: forsidebilledet og alle seks i
tabellen var 404 på GitHub. Og noterne — hele funktionen — stod ikke nævnt hverken
i README, på forsiden eller i dokumentationen.

Nu skrives PNG til docs og WebP til site, altså præcis dét, de to steder læser.
Før efterlod hver kørsel tolv usporede PNG'er i site, som intet pegede på.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-20 07:34:16 +02:00
co-authored by Claude Opus 5
45 changed files with 597 additions and 142 deletions
+19 -9
View File
@@ -6,7 +6,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="site/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900"> <img src="docs/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
</p> </p>
--- ---
@@ -35,6 +35,11 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
in Firma, labelled regnskab. Danish and English, mixed freely in one line. in Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar - **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
views, and foldable groups over the projects in the sidebar. views, and foldable groups over the projects in the sidebar.
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
lists, quotes, fenced code. A note is shared by filing it in a project;
`#project` and `[[another note]]` are links, and a task shows the notes that
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
the exporter in [`tools/`](tools/) — and comes back out the same shape.
- **Drag where it means something.** Reorder a list or a board, file a project under - **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day. a group, drop a task on another project, or drop it on another day.
- **Sharing.** Owner, editor and viewer roles. There is no open registration: - **Sharing.** Owner, editor and viewer roles. There is no open registration:
@@ -90,6 +95,8 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Project templates | Done, tested | | Project templates | Done, tested |
| Nightly backups with rotation | Done, tested | | Nightly backups with rotation | Done, tested |
| Comments, attachments, notifications, Web Push | Built | | Comments, attachments, notifications, Web Push | Built |
| Notes: rich text, Markdown on disk, links to projects, notes and tasks | Done, tested |
| Notes in and out: a zip of Markdown, both directions | Done, tested |
| Todoist CSV import and export, account export | Done, tested | | Todoist CSV import and export, account export | Done, tested |
| MCP server for Claude | Done, tested | | MCP server for Claude | Done, tested |
| CalDAV server (two-way VTODO) | Done, tested | | CalDAV server (two-way VTODO) | Done, tested |
@@ -116,17 +123,20 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
| | | | | |
|---|---| |---|---|
| ![Board view](site/screenshots/board.png) | ![Light theme](site/screenshots/today-light.png) | | ![Board view](docs/screenshots/board.png) | ![Light theme](docs/screenshots/today-light.png) |
| Board view — one column per section. | The light theme. | | Board view — one column per section. | The light theme. |
| ![A week on Upcoming](site/screenshots/week.png) | ![A task, opened](site/screenshots/detail.png) | | ![A week on Upcoming](docs/screenshots/week.png) | ![A task, opened](docs/screenshots/detail.png) |
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. | | A week at a time, so a task can be dragged across a month boundary. | A task, opened: description, priority, labels, sub-tasks and its files. |
| ![The five themes](site/screenshots/themes.png) | ![Instance history](site/screenshots/history.png) | | ![Notes](docs/screenshots/notes.png) | ![The same note as Markdown](docs/screenshots/notes-source.png) |
| Five themes, all measured for contrast. | What has been done on the server. | | Notes, filed in a project to share them. | The same note as its source — which is what the export writes to disk. |
| ![Themes and looks](docs/screenshots/themes.png) | ![Instance history](docs/screenshots/history.png) |
| Five themes and four looks: how bright, and how it reads. | What has been done on the server. |
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
the real binary, creates its own account, fills it with demo data and photographs the real binary, creates its own account and a second one to share with, fills them
eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots` with demo data and photographs fourteen surfaces — nothing to set up and nothing to
still works, and is the way to photograph an instance that is already running.) remember. (`go run ./tools/shots` still works, and is the way to photograph an
instance that is already running.)
## Running it ## Running it
+30
View File
@@ -22,6 +22,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
Firma, labelled regnskab. Danish and English, mixed freely in one line. Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List, - **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
board and calendar views, and foldable groups over the projects in the sidebar. board and calendar views, and foldable groups over the projects in the sidebar.
- **Notes, beside the work.** Rich text to write in, Markdown on disk. A note is
shared by filing it in a project; `#project` and `[[another note]]` are links,
and a task shows what has been written about it.
- **Drag where it means something** — reorder a list or a board, file a project - **Drag where it means something** — reorder a list or a board, file a project
under a group, drop a task on another project, or drop it on another day. under a group, drop a task on another project, or drop it on another day.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance - **Sharing** with owner, editor and viewer roles. Anyone outside your instance
@@ -31,6 +34,33 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over - **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
open standards rather than private formats. open standards rather than private formats.
## What it looks like
![Upcoming as a month](screenshots/upcoming.png)
*Upcoming, as a month. A week and a plain list are the other two, and a task can be
dragged from one day onto another.*
![A project as a list](screenshots/sections.png)
*The same project as a list, where sections read as bands. As a board they are
columns.*
![Waiting on others](screenshots/delegated.png)
*What you have handed to somebody else, grouped by whoever has it.*
![Settings, integrations](screenshots/settings.png)
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
[mail-to-task address](mail.md) are connected.*
![Settings, people](screenshots/admin.png)
*Inviting somebody to the instance. There is no open registration — everybody
arrives through a link, and picks their own password. See
[signing in](signing-in.md).*
## Getting it running ## Getting it running
verdande is built to be deployed as a **Rune** in verdande is built to be deployed as a **Rune** in
Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 180 KiB

+16 -6
View File
@@ -621,13 +621,13 @@
</figure> </figure>
<figure class="shot"> <figure class="shot">
<img <img
src="screenshots/today-light.webp" src="screenshots/notes.webp"
alt="The same Today view in the light theme." alt="Notes: a list of notes on the left, one open on the right with headings, bullet lists, a quotation and a shell command in a code block."
width="1400" width="1400"
height="900" height="900"
loading="lazy" loading="lazy"
/> />
<figcaption>The light theme.</figcaption> <figcaption>Notes. Rich text on top, Markdown underneath.</figcaption>
</figure> </figure>
<figure class="shot"> <figure class="shot">
<img <img
@@ -642,7 +642,7 @@
<figure class="shot"> <figure class="shot">
<img <img
src="screenshots/detail.webp" src="screenshots/detail.webp"
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments." alt="A task open in the detail drawer, with its description, priority, due date, labels, sub-tasks and an attached file."
width="1400" width="1400"
height="900" height="900"
loading="lazy" loading="lazy"
@@ -652,12 +652,12 @@
<figure class="shot"> <figure class="shot">
<img <img
src="screenshots/themes.webp" src="screenshots/themes.webp"
alt="The settings page showing the five themes as swatches." alt="The settings page showing five themes as swatches and, below them, four looks written in the typeface each one selects."
width="1400" width="1400"
height="900" height="900"
loading="lazy" loading="lazy"
/> />
<figcaption>Five themes, all of them measured for contrast.</figcaption> <figcaption>Five themes and four looks: how bright, and how it reads.</figcaption>
</figure> </figure>
<figure class="shot"> <figure class="shot">
<img <img
@@ -689,6 +689,16 @@
invite link. Changes appear live. invite link. Changes appear live.
</p> </p>
</div> </div>
<div class="cell">
<h3>Notes, beside the work</h3>
<p>
Rich text to write in, Markdown on disk. File a note in a project and
everybody in it can read it; <code>#project</code> and
<code>[[another note]]</code> are links, and a task shows what has been
written about it. A zip of Markdown goes in and comes back out the same
shape, so nothing is trapped here.
</p>
</div>
<div class="cell"> <div class="cell">
<h3>A real CalDAV server</h3> <h3>A real CalDAV server</h3>
<p> <p>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+4 -2
View File
@@ -5,7 +5,7 @@
// cookie. Kept in the repository so the images can be regenerated when the // cookie. Kept in the repository so the images can be regenerated when the
// interface changes, rather than being a thing somebody did once by hand. // interface changes, rather than being a thing somebody did once by hand.
// //
// go run ./tools/shots -url http://localhost:8096 -cookie <session> -out site/screenshots // go run ./tools/shots -url http://localhost:8096 -cookie <session> -out docs/screenshots
package main package main
import ( import (
@@ -28,7 +28,9 @@ var chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
func main() { func main() {
base := flag.String("url", "http://localhost:8096", "the running instance") base := flag.String("url", "http://localhost:8096", "the running instance")
cookie := flag.String("cookie", "", "session cookie value") cookie := flag.String("cookie", "", "session cookie value")
out := flag.String("out", "site/screenshots", "where to write the PNGs") // docs/, not site/: site/screenshots holds the WebP the landing page reads, and
// PNGs dropped in beside them are files nothing points at.
out := flag.String("out", "docs/screenshots", "where to write the PNGs")
width := flag.Int("width", 1400, "viewport width") width := flag.Int("width", 1400, "viewport width")
height := flag.Int("height", 900, "viewport height") height := flag.Int("height", 900, "viewport height")
flag.Parse() flag.Parse()
+370 -70
View File
@@ -9,17 +9,22 @@
* It replaces the hand-driven half of `go run ./tools/shots`, which needs a * It replaces the hand-driven half of `go run ./tools/shots`, which needs a
* running instance and a session cookie pasted in by hand — and which therefore * running instance and a session cookie pasted in by hand — and which therefore
* ran rarely enough that the images fell many rounds behind the app they show. * ran rarely enough that the images fell many rounds behind the app they show.
* This one starts the real binary, creates its own account, fills it with the * This one starts the real binary, creates its own two accounts, fills them with
* demo data, and takes the pictures. Nothing to set up and nothing to remember. * the demo data, and takes the pictures. Nothing to set up and nothing to remember.
* *
* The binary, not the dev server: the screenshots should show what ships. That * The binary, not the dev server: the screenshots should show what ships. That
* means building the frontend and copying it into cmd/verdande/webbuild first, * means building the frontend and copying it into cmd/verdande/webbuild first,
* exactly as the smoke tests do — a capture of a stale embedded build would be a * exactly as the smoke tests do — a capture of a stale embedded build would be a
* picture of an app nobody is running. * picture of an app nobody is running.
*
* English, because the people who read the landing page and the docs read English.
* Not by translating anything here: the account is created with an English locale
* and the app is then in English by itself, which also means a string nobody
* translated shows up in a picture instead of hiding until a user finds it.
*/ */
import { chromium } from '@playwright/test'; import { chromium } from '@playwright/test';
import { spawn, execSync } from 'node:child_process'; import { spawn, execSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs'; import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
@@ -31,13 +36,21 @@ const dataDir = join(web, '.screenshot-data');
const PORT = 8098; const PORT = 8098;
const baseURL = `http://127.0.0.1:${PORT}`; const baseURL = `http://127.0.0.1:${PORT}`;
const USER = { name: 'Kristian', email: 'kristian@example.dk', password: 'et langt kodeord' }; const USER = { name: 'Kristian', email: 'kristian@example.dk', password: 'et langt kodeord' };
// Somebody to share with. Half of what the app is for only exists once there are
// two people in it: an assignee on a task, a name in the history, a face in the
// members list, and "Waiting on others" as something other than an empty page.
const COLLEAGUE = { name: 'Mette', email: 'mette@example.dk', password: 'et andet langt kodeord' };
// 1400×900 is what the landing page's <img> declares. A capture at another size // 1400×900 is what the landing page's <img> declares. A capture at another size
// would be resampled by the browser and look soft next to the text beside it. // would be resampled by the browser and look soft next to the text beside it.
const WIDTH = 1400; const WIDTH = 1400;
const HEIGHT = 900; const HEIGHT = 900;
const OUT = [join(repo, 'site', 'screenshots'), join(repo, 'docs', 'screenshots')]; // The docs and the README read the PNGs; the landing page reads the WebP, which is
// a fifth of the weight over the wire. Both are written from the same capture, so
// the two sets can never drift apart.
const PNG_DIR = join(repo, 'docs', 'screenshots');
const WEBP_DIR = join(repo, 'site', 'screenshots');
/** /**
* The pictures, and why each one is here. * The pictures, and why each one is here.
@@ -54,31 +67,64 @@ const SHOTS = [
// The same project as a list, which is where sections read as bands rather than // The same project as a list, which is where sections read as bands rather than
// as columns — and the shape most people actually work in. // as columns — and the shape most people actually work in.
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList }, { name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask }, // By address rather than by clicking the first row: the drawer has to be over
// the one task that was given a description, sub-tasks, a file, a reminder and a
// conversation, or the picture shows an empty form and proves nothing.
{ 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 },
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' }, { name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
{ name: 'themes', path: '/indstillinger', theme: 'dark' }, // Scrolled, because the themes are the second panel on the page and a picture
// captioned "five themes" that shows the profile form is a caption about
// nothing.
{ name: 'themes', path: '/indstillinger', theme: 'dark', prepare: showThemes },
{ name: 'admin', path: '/indstillinger/brugere', theme: 'dark' }, { name: 'admin', path: '/indstillinger/brugere', theme: 'dark' },
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' }, { name: 'history', path: '/indstillinger/historik', theme: 'dark' }
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
]; ];
const NOTE_TITLE = 'Launch meeting — agenda';
async function showList(page) { async function showList(page) {
await page.getByRole('button', { name: 'Liste', exact: true }).click(); await page.getByRole('button', { name: 'List', exact: true }).click();
await page.waitForTimeout(600); await page.waitForTimeout(600);
} }
async function showMonth(page) { async function showMonth(page) {
await page.getByRole('button', { name: 'Måned', exact: true }).click(); await page.getByRole('button', { name: 'Month', exact: true }).click();
await page.waitForTimeout(800); await page.waitForTimeout(800);
} }
async function showWeek(page) { async function showWeek(page) {
await page.getByRole('button', { name: 'Uge', exact: true }).click(); await page.getByRole('button', { name: 'Week', exact: true }).click();
await page.waitForTimeout(800); await page.waitForTimeout(800);
} }
async function openFirstTask(page) { async function openMeetingNote(page) {
await page.locator('.row').first().click(); // By text inside the row rather than by its accessible name: a row's name is its
// title, its date, the first line of its body and the project it is filed in,
// all run together.
await page.locator('button.row', { hasText: NOTE_TITLE }).first().click();
await page.waitForTimeout(800);
}
async function showNoteSource(page) {
await openMeetingNote(page);
await page.getByRole('button', { name: 'Show as code' }).click();
await page.waitForTimeout(600);
}
async function showThemes(page) {
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
// already a few pixels inside the viewport when the page loads, so "if needed"
// decides nothing is needed and the picture is of the profile form above it.
//
// `.first()`, because the looks below the themes carry the same heading — one
// panel, two questions, and both of them called Appearance.
await page
.getByRole('heading', { name: 'Appearance' })
.first()
.evaluate((el) => el.closest('section').scrollIntoView({ block: 'start' }));
await page.waitForTimeout(600); await page.waitForTimeout(600);
} }
@@ -114,29 +160,42 @@ let browser;
try { try {
await waitForHealth(); await waitForHealth();
browser = await chromium.launch(); // The browser's own language, which is not the same setting as the context's.
const page = await browser.newPage({ // `<input type="date">` is drawn by Chromium, not by the app, and it takes its
viewport: { width: WIDTH, height: HEIGHT }, // order from this one — so without it the drawer shows an American date inside
// The same pinning the smoke tests use, and for the same reason: the sign-up // a day-month app, which is the exact thing i18n.svelte.js warns about.
// form sends navigator.language as the account's locale, and every due date browser = await chromium.launch({ args: ['--lang=en-GB'] });
// is resolved in the account's zone. const context = await browser.newContext(viewing());
locale: 'da-DK', const page = await context.newPage();
timezoneId: 'Europe/Copenhagen',
deviceScaleFactor: 2
});
await page.goto(baseURL); await page.goto(baseURL);
// The sign-up form is the one screen that is always Danish: it is drawn before
// there is an account to read a language off, and Danish is the fallback. So
// these labels are Danish on purpose, and the app is English from the moment
// the account exists — which is every screen this script photographs.
await page.getByLabel(/Navn/).fill(USER.name); await page.getByLabel(/Navn/).fill(USER.name);
await page.getByLabel(/E-mail/).fill(USER.email); await page.getByLabel(/E-mail/).fill(USER.email);
await page.getByLabel(/Adgangskode/).fill(USER.password); await page.getByLabel(/Adgangskode/).fill(USER.password);
await page.getByRole('button', { name: 'Opret konto' }).click(); await page.getByRole('button', { name: 'Opret konto' }).click();
await page.getByRole('navigation', { name: 'Hovedmenu' }).waitFor(); await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
console.log('seeding…'); console.log('seeding…');
const ids = await page.evaluate(seed); const ids = await page.evaluate(seed, { colleague: COLLEAGUE.email, noteTitle: NOTE_TITLE });
mkdirSync(OUT[0], { recursive: true }); console.log('adding the second person…');
mkdirSync(OUT[1], { recursive: true }); const ids2 = await addColleague(browser, ids);
Object.assign(ids, ids2);
await page.reload();
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
mkdirSync(PNG_DIR, { recursive: true });
mkdirSync(WEBP_DIR, { recursive: true });
// A page of its own for the WebP encoding, on about:blank: the app's pages send
// a content security policy, and an encoder that borrows whichever page was last
// photographed would be one policy change away from silently producing nothing.
const encoder = await context.newPage();
await encoder.goto('about:blank');
for (const shot of SHOTS) { for (const shot of SHOTS) {
const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path; const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path;
@@ -151,9 +210,9 @@ try {
await page.waitForTimeout(400); await page.waitForTimeout(400);
} }
const png = await page.screenshot(); const png = await page.screenshot();
writeFileSync(join(OUT[0], `${shot.name}.png`), png); writeFileSync(join(PNG_DIR, `${shot.name}.png`), png);
copyFileSync(join(OUT[0], `${shot.name}.png`), join(OUT[1], `${shot.name}.png`)); writeFileSync(join(WEBP_DIR, `${shot.name}.webp`), await toWebP(encoder, png));
console.log(` ${shot.name}.png`); console.log(` ${shot.name}`);
} }
} finally { } finally {
await browser?.close(); await browser?.close();
@@ -166,6 +225,25 @@ try {
rmSync(dataDir, { recursive: true, force: true }); rmSync(dataDir, { recursive: true, force: true });
} }
/** The browser settings every context here shares. */
function viewing() {
return {
viewport: { width: WIDTH, height: HEIGHT },
// The locale is not a formatting preference: the sign-up form sends
// navigator.language as the account's language, so this is what makes the
// interface, the Inbox's name and every date English. The zone is pinned for
// the same reason the smoke tests pin it — every due date is resolved in it.
locale: 'en-GB',
timezoneId: 'Europe/Copenhagen',
// Dark, because that is what the shots are forced to below. app.html reads
// prefers-color-scheme before first paint, and Playwright's default is light
// — which left the settings page rendering dark with the *light* theme
// ringed as the chosen one.
colorScheme: 'dark',
deviceScaleFactor: 2
};
}
async function waitForHealth() { async function waitForHealth() {
for (let i = 0; i < 240; i++) { for (let i = 0; i < 240; i++) {
try { try {
@@ -179,6 +257,92 @@ async function waitForHealth() {
throw new Error('the server never came up'); throw new Error('the server never came up');
} }
/**
* Encodes a PNG as WebP, in the browser that is already open.
*
* Chromium encodes WebP itself, so the alternative — shelling out to `cwebp` —
* would be something to install first, in a script whose whole point is that there
* is nothing to install. Resized on the way through: the landing page shows these
* 1400 CSS pixels wide, and a full-resolution WebP is twice the bytes for detail
* no screen renders.
*/
async function toWebP(encoder, png) {
const base64 = await encoder.evaluate(
async ({ data, width, quality }) => {
const image = new Image();
image.src = `data:image/png;base64,${data}`;
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = Math.round((image.naturalHeight / image.naturalWidth) * width);
const ctx = canvas.getContext('2d');
// The default resampler leaves the interface's hairlines ragged, which on a
// screenshot of a screenshot is the first thing the eye finds.
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/webp', quality).split(',')[1];
},
// 0.75 is cwebp's default and where these stop getting smaller for anything
// the eye finds: 0.85 is a fifth more bytes for a difference that survives
// only under magnification, and the landing page's first image is the one
// somebody waits for.
{ data: png.toString('base64'), width: 1800, quality: 0.75 }
);
return Buffer.from(base64, 'base64');
}
/**
* Signs the second person up through the invite the seed left behind, has them say
* something, and hands their work back to the first.
*
* A context of its own because a session is a cookie: signing up in the page the
* screenshots are taken from would replace the account they are taken as.
*/
async function addColleague(browser, ids) {
const context = await browser.newContext(viewing());
const page = await context.newPage();
try {
await page.goto(ids.invite);
// Danish labels again, and for the same reason: nobody is signed in yet.
await page.getByLabel(/Navn/).fill(COLLEAGUE.name);
await page.getByLabel(/Adgangskode/).fill(COLLEAGUE.password);
await page.getByRole('button', { name: 'Opret konto' }).click();
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
return await page.evaluate(handOver, ids);
} finally {
await context.close();
}
}
/**
* What the second person does, from inside their own session: answer the task they
* were opened on, and take the three they are being waited for.
*
* The assignments are made here rather than by the owner because an editor may
* assign, and doing it from this side proves it. Written as one function because it
* is passed to the browser as source.
*/
async function handOver(ids) {
const api = async (path, body, method = 'POST') => {
const r = await fetch(`/api/v1${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
return r.status === 204 ? null : r.json();
};
const me = await api('/auth/me', null, 'GET');
for (const id of ids.delegate) await api(`/tasks/${id}`, { assignee_id: me.id }, 'PATCH');
await api(`/tasks/${ids.detail}/comments`, {
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
});
return { colleague: me.id };
}
/** /**
* The demo data, created through the app's own API from inside the page. * The demo data, created through the app's own API from inside the page.
* *
@@ -186,11 +350,11 @@ async function waitForHealth() {
* header the API insists on come along without being reconstructed here. Written * header the API insists on come along without being reconstructed here. Written
* as one function because it is passed to the browser as source. * as one function because it is passed to the browser as source.
* *
* The content is ordinary Danish household and work tasks. Screenshots of "Task * Ordinary English household and work tasks, the same shape the Danish set had.
* 1, Task 2, Task 3" show the layout and hide whether the app is any good at the * Screenshots of "Task 1, Task 2, Task 3" show the layout and hide whether the app
* thing it is for. * is any good at the thing it is for.
*/ */
async function seed() { async function seed({ colleague, noteTitle }) {
const api = async (path, body, method = 'POST') => { const api = async (path, body, method = 'POST') => {
const r = await fetch(`/api/v1${path}`, { const r = await fetch(`/api/v1${path}`, {
method, method,
@@ -210,63 +374,199 @@ async function seed() {
}; };
// Two headings over the projects, which is what the sidebar grew for. // Two headings over the projects, which is what the sidebar grew for.
const arbejde = await api('/project-groups', { name: 'Arbejde' }); const work = await api('/project-groups', { name: 'Work' });
const privat = await api('/project-groups', { name: 'Privat' }); const home = await api('/project-groups', { name: 'Home' });
await api(`/project-groups/${arbejde.id}`, { color: 'indigo' }, 'PATCH'); await api(`/project-groups/${work.id}`, { color: 'indigo' }, 'PATCH');
await api(`/project-groups/${privat.id}`, { color: 'green' }, 'PATCH'); await api(`/project-groups/${home.id}`, { color: 'green' }, 'PATCH');
const work = await api('/projects', { const launch = await api('/projects', {
name: 'Sæsonstart', name: 'Autumn launch',
color: 'indigo', color: 'indigo',
view_mode: 'board', view_mode: 'board',
group_id: arbejde.id group_id: work.id
}); });
const house = await api('/projects', { name: 'Huset', color: 'amber', group_id: privat.id }); const house = await api('/projects', { name: 'House', color: 'amber', group_id: home.id });
const garden = await api('/projects', { name: 'Haven', color: 'green', group_id: privat.id }); const garden = await api('/projects', { name: 'Garden', color: 'green', group_id: home.id });
const planning = await api(`/projects/${work.id}/sections`, { name: 'Planlægning' }); const planning = await api(`/projects/${launch.id}/sections`, { name: 'Planning' });
const inFlight = await api(`/projects/${work.id}/sections`, { name: 'I gang' }); const inFlight = await api(`/projects/${launch.id}/sections`, { name: 'In progress' });
const done = await api(`/projects/${work.id}/sections`, { name: 'Til gennemsyn' }); const review = await api(`/projects/${launch.id}/sections`, { name: 'Up for review' });
const tasks = [ const tasks = [
// One thing that slipped. Today with an overdue band on it is what a Thursday
// actually looks like, and it is the only way to photograph the band at all.
{
content: 'Chase the printer about the labels',
due_date: day(-2),
priority: 2,
project_id: launch.id,
section_id: inFlight.id,
labels: ['waiting']
},
// Today, in the Inbox and across the projects: the Today view is the first // Today, in the Inbox and across the projects: the Today view is the first
// picture and it has to look like a real day. // picture and it has to look like a real day.
{ content: 'Ring til tømreren om taget', due_date: day(0), priority: 1, project_id: house.id }, { content: 'Call the roofer about the ridge tiles', due_date: day(0), priority: 1, project_id: house.id },
{ content: 'Aflever cyklen til service', due_date: day(0), priority: 3, labels: ['ærinder'] },
{ {
content: 'Skriv oplæg til sæsonmødet', content: 'Write the opening notes for the launch meeting',
due_date: day(0), due_date: day(0),
priority: 2, priority: 2,
project_id: work.id, project_id: launch.id,
section_id: planning.id, section_id: planning.id,
duration_min: 90 duration_min: 90,
labels: ['meeting']
}, },
{ content: 'Vand drivhuset', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id }, { content: 'Drop the bike off for its service', due_date: day(0), priority: 3, labels: ['errands'] },
{ content: 'Hent pakken på posthuset', due_date: day(0), labels: ['ærinder'] }, { content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
// The next couple of weeks, so Kommende and the month grid have something in // The next couple of weeks, so Upcoming and the month grid have something in
// them rather than a row of empty cells. // them rather than a row of empty cells.
{ content: 'Book håndværker til badeværelset', due_date: day(1), priority: 2, project_id: house.id }, { content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
{ content: 'Beskær den grønne hæk', due_date: day(2), project_id: garden.id }, { content: 'Cut the beech hedge back', due_date: day(2), project_id: garden.id },
{ content: 'Gennemgå budgettet', due_date: day(3), priority: 1, project_id: work.id, section_id: inFlight.id }, { content: 'Go through the budget', due_date: day(3), priority: 1, project_id: launch.id, section_id: inFlight.id },
{ content: 'Bestil løg til efteråret', due_date: day(5), project_id: garden.id }, { content: 'Order bulbs for the autumn planting', due_date: day(5), project_id: garden.id },
{ content: 'Skift dæk', due_date: day(8), priority: 3 }, { content: 'Swap the winter tyres on', due_date: day(8), priority: 3 },
{ content: 'Send referat ud', due_date: day(9), project_id: work.id, section_id: done.id }, { content: 'Send the minutes round', due_date: day(9), project_id: launch.id, section_id: review.id },
{ content: 'Male vindueskarmene', due_date: day(12), project_id: house.id, priority: 2 }, { content: 'Paint the window sills', due_date: day(12), project_id: house.id, priority: 2 },
{ content: 'Så vinterurter', due_date: day(15), project_id: garden.id }, { content: 'Sow winter herbs in the cold frame', due_date: day(15), project_id: garden.id },
{ content: 'Kvartalsopfølgning', due_date: day(21), priority: 1, project_id: work.id, section_id: planning.id }, { content: 'Quarterly review', due_date: day(21), priority: 1, project_id: launch.id, section_id: planning.id },
{ content: 'Rens tagrenderne', due_date: day(26), project_id: house.id }, { content: 'Clear the gutters before the autumn rain', due_date: day(26), project_id: house.id },
// A few without dates, so the board has cards that are not all deadlines. // A few without dates, so the board has cards that are not all deadlines.
{ content: 'Find ny leverandør af emballage', project_id: work.id, section_id: planning.id, priority: 3 }, { content: 'Find a new packaging supplier', project_id: launch.id, section_id: planning.id, priority: 3 },
{ content: 'Skriv stillingsopslag', project_id: work.id, section_id: inFlight.id }, { content: 'Write the job advert', project_id: launch.id, section_id: inFlight.id },
{ content: 'Opdater prislisten', project_id: work.id, section_id: done.id, labels: ['gennemsyn'] } { content: 'Update the price list', project_id: launch.id, section_id: review.id, labels: ['review'] }
]; ];
for (const task of tasks) await api('/tasks', task); const made = {};
for (const task of tasks) made[task.content] = await api('/tasks', task);
// A saved filter, because the sidebar shows them and an empty heading reads as // A saved filter, because the sidebar shows them and an empty heading reads as
// a feature that does not work. // a feature that does not work.
await api('/filters', { name: 'Vigtigt i denne uge', query: 'p1 & 7 days' }); await api('/filters', { name: 'Important this week', query: 'p1 & 7 days' });
return { work: work.id, house: house.id, garden: garden.id }; // The one task that is photographed open. Everything the drawer can hold, so the
// picture answers "what is in there" rather than showing an empty form.
const detail = made['Write the opening notes for the launch meeting'];
await api(
`/tasks/${detail.id}`,
{
description:
'Fifteen minutes, no slides. What shipped over the summer, what is left, and what we are deliberately not doing before the first.'
},
'PATCH'
);
for (const content of [
'Pull the numbers from last season',
'Ask Mette for the packaging quotes',
'Book the room'
]) {
await api('/tasks', { content, parent_id: detail.id, project_id: launch.id });
}
await api(`/tasks/${detail.id}/reminders`, { offset_min: 60 });
await api(`/tasks/${detail.id}/comments`, {
body: 'Keeping it to three points. Anything else goes on the list for the week after.'
});
// A file on the task, because "comments, attachments" is a claim and an empty
// Files heading is the opposite of proof.
const form = new FormData();
form.append('file', new Blob([new Uint8Array(9400)], { type: 'application/pdf' }), 'packaging-quote.pdf');
await fetch(`/api/v1/tasks/${detail.id}/attachments`, { method: 'POST', body: form });
// The notes, newest last: the list opens on what was touched most recently, so
// the one the screenshots open is the one written last.
const notes = [
{
body: `# Reading list
- *Shape Up* — the six-week bit, not the pitch bit
- The SQLite WAL paper, again
- Anything at all about pricing`
},
{
project_id: garden.id,
body: `# The bulb order
Two hundred, split three ways, in before the first frost:
1. Narcissus along the south wall
2. Alliums in the bed by the shed
3. Crocus wherever there is room
The bed by the shed is heavy — grit in the hole, or they rot. #Garden`
},
{
body: `# What the roofer said
Ridge tiles have moved, maybe a metre of them. Not urgent, not something to leave
over a winter either.
- Quote by the end of the month
- Two days' work, scaffold on the north side
- Ask whether the gutters can be done at the same time
#House`
},
{
project_id: launch.id,
body: `# Price list — what changes
Nothing has been looked at since March.
- 250 g bag: **79 → 85**
- 1 kg bag: **265 → 279**
- Subscription: **199 → 209**
Subscribers keep the old price for three months. That is the whole of the goodwill
budget and it is worth it.`
},
{
pinned: true,
project_id: launch.id,
body: `# ${noteTitle}
Thursday, fifteen minutes, no slides.
## What shipped over the summer
- The new packaging, minus the labels
- A CalDAV feed nobody asked for and everybody uses
## What is left
- Labels. Still with the printer, chased on Thursday
- The price list: [[Price list — what changes]]
> If it is not on the list by Friday it is not in the launch.
The build we cut the release from:
\`\`\`bash
cd web && npm run build && cd ..
go build -tags embedweb -o verdande ./cmd/verdande
\`\`\`
Room is booked. Coffee is **not** my job this week.
The opening notes are a task, so they can be ticked off: task:${detail.id}`
}
];
for (const note of notes) await api('/notes', note);
// Shared with somebody, and an invite left standing to the instance itself: two
// of the three states the members page has, and the one it never showed before.
const invite = await api(`/projects/${launch.id}/invites`, { email: colleague, role: 'editor' });
await api('/users', { email: 'anders@example.dk' });
return {
work: launch.id,
detail: detail.id,
invite: invite.link,
delegate: [
made['Send the minutes round'].id,
made['Update the price list'].id,
made['Find a new packaging supplier'].id
]
};
} }
+1 -1
View File
@@ -64,7 +64,7 @@
// The unsectioned column comes first, as it does in the list view: it is where // The unsectioned column comes first, as it does in the list view: it is where
// quick add puts things, so it is where new work appears. // quick add puts things, so it is where new work appears.
let columns = $derived([ let columns = $derived([
{ id: '', name: 'Uden sektion' }, { id: '', name: t('project.noSection') },
...sections.map((s) => ({ id: s.id, name: s.name })) ...sections.map((s) => ({ id: s.id, name: s.name }))
]); ]);
+3 -13
View File
@@ -14,6 +14,7 @@
import { app, projectName } from '$lib/stores.svelte.js'; import { app, projectName } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js'; import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js'; import { t } from '$lib/i18n.svelte.js';
import { REPEATS } from '$lib/when.js';
let { task, onclose } = $props(); let { task, onclose } = $props();
@@ -29,17 +30,6 @@
/** Who the task can be given to. Only ever more than one on a shared project. */ /** Who the task can be given to. Only ever more than one on a shared project. */
let members = $state([]); let members = $state([]);
// The rules worth a menu entry. Anything more specific is typed into the
// title, where the parser understands far more than a list ever could.
const REPEATS = [
{ rule: '', label: 'Aldrig' },
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
{ rule: 'FREQ=MONTHLY', label: t('detail.monthly') },
{ rule: 'FREQ=YEARLY', label: t('detail.yearly') }
];
let subtasks = $state([]); let subtasks = $state([]);
let comments = $state([]); let comments = $state([]);
let attachments = $state([]); let attachments = $state([]);
@@ -452,13 +442,13 @@
<label for="repeat">{t('detail.repeats')}</label> <label for="repeat">{t('detail.repeats')}</label>
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}> <select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
{#each REPEATS as option (option.rule)} {#each REPEATS as option (option.rule)}
<option value={option.rule}>{option.label}</option> <option value={option.rule}>{t(option.label)}</option>
{/each} {/each}
<!-- A rule typed as "hver anden tirsdag" has no entry in the list; <!-- A rule typed as "hver anden tirsdag" has no entry in the list;
showing it rather than snapping to the nearest preset is the showing it rather than snapping to the nearest preset is the
difference between a menu and a menu that quietly edits. --> difference between a menu and a menu that quietly edits. -->
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)} {#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
<option value={recurrence}>{task.recurrence_text ?? 'Som skrevet'}</option> <option value={recurrence}>{task.recurrence_text ?? t('detail.asWritten')}</option>
{/if} {/if}
</select> </select>
</div> </div>
+7 -4
View File
@@ -10,9 +10,12 @@
*/ */
import { app } from '$lib/stores.svelte.js'; import { app } from '$lib/stores.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js'; import { t, tag } from '$lib/i18n.svelte.js';
import { repeatLabel } from '$lib/when.js';
let { task, onedit } = $props(); let { task, onedit } = $props();
let repeats = $derived(repeatLabel(task.recurrence_rule, task.recurrence_text));
let leaving = $state(false); let leaving = $state(false);
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p })); const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
@@ -84,7 +87,7 @@
<span class="description">{task.description}</span> <span class="description">{task.description}</span>
{/if} {/if}
{#if assignee || due || task.labels?.length || task.recurrence_text || task.subtask_count || task.attachment_count} {#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
<span class="meta"> <span class="meta">
{#if assignee} {#if assignee}
<!-- First in the row, because "who" changes what the rest of the line <!-- First in the row, because "who" changes what the rest of the line
@@ -102,15 +105,15 @@
{due.text}{#if time}&nbsp;{time}{/if} {due.text}{#if time}&nbsp;{time}{/if}
</span> </span>
{/if} {/if}
{#if task.recurrence_text} {#if repeats}
<span class="repeat" title={t('task.repeats', { rule: task.recurrence_text })}> <span class="repeat" title={t('task.repeats', { rule: repeats })}>
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M17 2l4 4-4 4" /> <path d="M17 2l4 4-4 4" />
<path d="M3 11V9a4 4 0 014-4h14" /> <path d="M3 11V9a4 4 0 014-4h14" />
<path d="M7 22l-4-4 4-4" /> <path d="M7 22l-4-4 4-4" />
<path d="M21 13v2a4 4 0 01-4 4H3" /> <path d="M21 13v2a4 4 0 01-4 4H3" />
</svg> </svg>
{task.recurrence_text} {repeats}
</span> </span>
{/if} {/if}
<!-- What is underneath, said as a fraction rather than a total: "3" tells <!-- What is underneath, said as a fraction rather than a total: "3" tells
+32 -24
View File
@@ -1,40 +1,48 @@
/** /**
* What each recorded activity event is called. * What each recorded activity event is called.
* *
* The keys are the server's and they are stable; this is the only place they * The keys are the server's and they are stable; this is the only place they get a
* become Danish. It lives in its own module because two surfaces read the same log * name. It lives in its own module because two surfaces read the same log now — a
* now — a project's own history panel, and the administrator's instance-wide audit * project's own history panel, and the administrator's instance-wide audit page —
* page — and two copies of nineteen strings is two places for them to drift apart. * and two copies of twenty strings is two places for them to drift apart.
*
* The names themselves are in the dictionaries rather than here. They were written
* out in Danish, which made the whole of both history pages Danish for somebody
* reading the rest of the app in English.
* *
* An unknown key falls through to the key itself rather than being hidden: a build * An unknown key falls through to the key itself rather than being hidden: a build
* that records something this table has not learned yet should still show that it * that records something this table has not learned yet should still show that it
* happened. * happened.
*/ */
import { t } from './i18n.svelte.js';
export const EVENTS = { export const EVENTS = {
'project.created': 'oprettede projektet', 'project.created': 'event.projectCreated',
'project.updated': 'ændrede projektet', 'project.updated': 'event.projectUpdated',
'project.deleted': 'slettede projektet', 'project.deleted': 'event.projectDeleted',
'project.imported': 'importerede projektet', 'project.imported': 'event.projectImported',
'section.created': 'oprettede en sektion', 'section.created': 'event.sectionCreated',
'section.updated': 'ændrede en sektion', 'section.updated': 'event.sectionUpdated',
'section.deleted': 'slettede en sektion', 'section.deleted': 'event.sectionDeleted',
'member.invited': 'inviterede', 'member.invited': 'event.memberInvited',
'member.added': 'tilføjede', 'member.added': 'event.memberAdded',
'member.removed': 'fjernede', 'member.removed': 'event.memberRemoved',
'member.role_changed': 'ændrede rollen for', 'member.role_changed': 'event.memberRoleChanged',
'task.created': 'oprettede en opgave', 'task.created': 'event.taskCreated',
'task.updated': 'ændrede en opgave', 'task.updated': 'event.taskUpdated',
'task.completed': 'lukkede en opgave', 'task.completed': 'event.taskCompleted',
'task.reopened': 'genåbnede en opgave', 'task.reopened': 'event.taskReopened',
'task.moved': 'flyttede en opgave', 'task.moved': 'event.taskMoved',
'task.deleted': 'slettede en opgave', 'task.deleted': 'event.taskDeleted',
'task.split': 'delte en opgave op', 'task.split': 'event.taskSplit',
'comment.created': 'skrev en kommentar' 'comment.created': 'event.commentCreated',
'attachment.added': 'event.attachmentAdded'
}; };
/** The name of an event, or the raw key when it is one this build does not know. */ /** The name of an event, or the raw key when it is one this build does not know. */
export function eventName(event) { export function eventName(event) {
return EVENTS[event] ?? event; const key = EVENTS[event];
return key ? t(key) : event;
} }
/** The bit of context an entry carries, when it has one worth reading. */ /** The bit of context an entry carries, when it has one worth reading. */
+33
View File
@@ -318,6 +318,9 @@ export const da = {
'users.taskOne': 'opgave', 'users.taskOne': 'opgave',
'users.taskMany': 'opgaver', 'users.taskMany': 'opgaver',
'users.never': 'aldrig set', 'users.never': 'aldrig set',
'users.toProject': 'til {name}',
'users.toInstance': 'til instansen',
'users.invitedBy': 'inviteret af {name}',
// --- settings: history and errors ----------------------------------------------- // --- settings: history and errors -----------------------------------------------
'history.title': 'Historik', 'history.title': 'Historik',
@@ -333,6 +336,30 @@ export const da = {
'history.noMatch': 'Ingen hændelser matcher de valgte filtre.', 'history.noMatch': 'Ingen hændelser matcher de valgte filtre.',
'history.nothing': 'Der er ikke registreret noget endnu.', 'history.nothing': 'Der er ikke registreret noget endnu.',
'history.deletedAccount': 'En slettet konto', 'history.deletedAccount': 'En slettet konto',
// Hvad hver hændelse i loggen hedder. Nøglerne er serverens og ligger i
// events.js; her står de ord, de bliver til.
'event.projectCreated': 'oprettede projektet',
'event.projectUpdated': 'ændrede projektet',
'event.projectDeleted': 'slettede projektet',
'event.projectImported': 'importerede projektet',
'event.sectionCreated': 'oprettede en sektion',
'event.sectionUpdated': 'ændrede en sektion',
'event.sectionDeleted': 'slettede en sektion',
'event.memberInvited': 'inviterede',
'event.memberAdded': 'tilføjede',
'event.memberRemoved': 'fjernede',
'event.memberRoleChanged': 'ændrede rollen for',
'event.taskCreated': 'oprettede en opgave',
'event.taskUpdated': 'ændrede en opgave',
'event.taskCompleted': 'lukkede en opgave',
'event.taskReopened': 'genåbnede en opgave',
'event.taskMoved': 'flyttede en opgave',
'event.taskDeleted': 'slettede en opgave',
'event.taskSplit': 'delte en opgave op',
'event.commentCreated': 'skrev en kommentar',
'event.attachmentAdded': 'vedhæftede en fil',
'errors.title': 'Serverfejl', 'errors.title': 'Serverfejl',
'errors.hint': 'errors.hint':
'Hver 500, API’et har svaret, med hvad handleren var i gang med og fejlen den ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra ved hver genstart, så forklaringen er som regel væk, når nogen kigger. Ryddes efter 30 dage.', 'Hver 500, API’et har svaret, med hvad handleren var i gang med og fejlen den ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra ved hver genstart, så forklaringen er som regel væk, når nogen kigger. Ryddes efter 30 dage.',
@@ -512,8 +539,13 @@ export const da = {
'detail.labelsPlaceholder': 'adskilt af komma', 'detail.labelsPlaceholder': 'adskilt af komma',
'detail.project': 'Projekt', 'detail.project': 'Projekt',
'detail.repeats': 'Gentages', 'detail.repeats': 'Gentages',
'detail.never': 'Aldrig',
'detail.daily': 'Hver dag',
'detail.weekly': 'Hver uge',
'detail.fortnightly': 'Hver anden uge',
'detail.monthly': 'Hver måned', 'detail.monthly': 'Hver måned',
'detail.yearly': 'Hvert år', 'detail.yearly': 'Hvert år',
'detail.asWritten': 'Som skrevet',
'detail.assignee': 'Ansvarlig', 'detail.assignee': 'Ansvarlig',
'detail.subtasks': 'Undertasks', 'detail.subtasks': 'Undertasks',
'detail.addSubtask': 'Tilføj en undertask', 'detail.addSubtask': 'Tilføj en undertask',
@@ -703,6 +735,7 @@ export const da = {
'push.upToDate': 'Ingen nyere version fundet.', 'push.upToDate': 'Ingen nyere version fundet.',
'view.nothingMoreToday': 'Ikke mere i dag.', 'view.nothingMoreToday': 'Ikke mere i dag.',
'view.overdue': 'Forsinket',
'project.roleFor': 'Rolle for {name}', 'project.roleFor': 'Rolle for {name}',
'project.more': 'Flere handlinger', 'project.more': 'Flere handlinger',
+33
View File
@@ -313,6 +313,9 @@ export const en = {
'users.taskOne': 'task', 'users.taskOne': 'task',
'users.taskMany': 'tasks', 'users.taskMany': 'tasks',
'users.never': 'never seen', 'users.never': 'never seen',
'users.toProject': 'to {name}',
'users.toInstance': 'to the instance',
'users.invitedBy': 'invited by {name}',
// --- settings: history and errors ----------------------------------------------- // --- settings: history and errors -----------------------------------------------
'history.title': 'History', 'history.title': 'History',
@@ -328,6 +331,30 @@ export const en = {
'history.noMatch': 'Nothing matches the filters.', 'history.noMatch': 'Nothing matches the filters.',
'history.nothing': 'Nothing has been recorded yet.', 'history.nothing': 'Nothing has been recorded yet.',
'history.deletedAccount': 'A deleted account', 'history.deletedAccount': 'A deleted account',
// What each recorded event is called. The keys are the server's and live in
// events.js; these are the words they become.
'event.projectCreated': 'created the project',
'event.projectUpdated': 'changed the project',
'event.projectDeleted': 'deleted the project',
'event.projectImported': 'imported the project',
'event.sectionCreated': 'created a section',
'event.sectionUpdated': 'changed a section',
'event.sectionDeleted': 'deleted a section',
'event.memberInvited': 'invited',
'event.memberAdded': 'added',
'event.memberRemoved': 'removed',
'event.memberRoleChanged': 'changed the role of',
'event.taskCreated': 'created a task',
'event.taskUpdated': 'changed a task',
'event.taskCompleted': 'closed a task',
'event.taskReopened': 'reopened a task',
'event.taskMoved': 'moved a task',
'event.taskDeleted': 'deleted a task',
'event.taskSplit': 'split a task up',
'event.commentCreated': 'wrote a comment',
'event.attachmentAdded': 'attached a file',
'errors.title': 'Server errors', 'errors.title': 'Server errors',
'errors.hint': 'errors.hint':
'Every 500 the API has answered, with what the handler was attempting and the error it hit. Written to the database rather than only to the log — a container’s log starts over on every restart, so the explanation is usually gone by the time anybody looks. Kept for 30 days.', 'Every 500 the API has answered, with what the handler was attempting and the error it hit. Written to the database rather than only to the log — a container’s log starts over on every restart, so the explanation is usually gone by the time anybody looks. Kept for 30 days.',
@@ -508,8 +535,13 @@ export const en = {
'detail.labelsPlaceholder': 'separated by commas', 'detail.labelsPlaceholder': 'separated by commas',
'detail.project': 'Project', 'detail.project': 'Project',
'detail.repeats': 'Repeats', 'detail.repeats': 'Repeats',
'detail.never': 'Never',
'detail.daily': 'Every day',
'detail.weekly': 'Every week',
'detail.fortnightly': 'Every other week',
'detail.monthly': 'Every month', 'detail.monthly': 'Every month',
'detail.yearly': 'Every year', 'detail.yearly': 'Every year',
'detail.asWritten': 'As written',
'detail.assignee': 'Assigned to', 'detail.assignee': 'Assigned to',
'detail.subtasks': 'Sub-tasks', 'detail.subtasks': 'Sub-tasks',
'detail.addSubtask': 'Add a sub-task', 'detail.addSubtask': 'Add a sub-task',
@@ -698,6 +730,7 @@ export const en = {
'push.upToDate': 'No newer version found.', 'push.upToDate': 'No newer version found.',
'view.nothingMoreToday': 'Nothing more today.', 'view.nothingMoreToday': 'Nothing more today.',
'view.overdue': 'Overdue',
'project.roleFor': 'Role for {name}', 'project.roleFor': 'Role for {name}',
'project.more': 'More actions', 'project.more': 'More actions',
+29
View File
@@ -49,3 +49,32 @@ export function fullDate(iso) {
year: 'numeric' year: 'numeric'
}); });
} }
/**
* The repeat rules worth a menu entry, and what to call them.
*
* Here rather than in the drawer that draws the menu, because the row draws the
* same rule as a badge and the two had already disagreed: the menu said "Hver
* dag" in both languages, and the badge said whatever the server said.
*
* The server sends `recurrence_text` with every task and sends it in Danish. It
* has to: one payload goes over the WebSocket to everybody in the project at
* once, and they do not all read the same language. So the rules that have a name
* are named here, on the side that knows whose screen this is, and anything more
* specific — "hver anden tirsdag", typed into the title — falls back to what the
* server wrote.
*/
export const REPEATS = [
{ rule: '', label: 'detail.never' },
{ rule: 'FREQ=DAILY', label: 'detail.daily' },
{ rule: 'FREQ=WEEKLY', label: 'detail.weekly' },
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'detail.fortnightly' },
{ rule: 'FREQ=MONTHLY', label: 'detail.monthly' },
{ rule: 'FREQ=YEARLY', label: 'detail.yearly' }
];
/** What a repeating task says on its row. */
export function repeatLabel(rule, fallback = '') {
const known = REPEATS.find((option) => option.rule && option.rule === rule);
return known ? t(known.label) : fallback;
}
+2 -2
View File
@@ -56,7 +56,7 @@
{#if liveOverdue.length} {#if liveOverdue.length}
<section> <section>
<h2 class="overdue">Forsinket</h2> <h2 class="overdue">{t('view.overdue')}</h2>
{#each liveOverdue as task (task.id)} {#each liveOverdue as task (task.id)}
<TaskRow {task} /> <TaskRow {task} />
{/each} {/each}
@@ -65,7 +65,7 @@
<section> <section>
{#if liveOverdue.length} {#if liveOverdue.length}
<h2>I dag</h2> <h2>{t('nav.today')}</h2>
{/if} {/if}
{#each liveToday as task (task.id)} {#each liveToday as task (task.id)}
<TaskRow {task} /> <TaskRow {task} />
@@ -177,9 +177,11 @@
<div class="what"> <div class="what">
<span class="primary-line">{pending.email}</span> <span class="primary-line">{pending.email}</span>
<span class="secondary"> <span class="secondary">
{pending.project_name ? `til ${pending.project_name}` : 'til instansen'} {pending.project_name
? t('users.toProject', { name: pending.project_name })
: t('users.toInstance')}
· {t('tokens.expiresOn', { when: expires(pending.expires_at) })} · {t('tokens.expiresOn', { when: expires(pending.expires_at) })}
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if} {#if pending.invited_by}· {t('users.invitedBy', { name: pending.invited_by })}{/if}
</span> </span>
</div> </div>
<button class="secondary" onclick={() => revoke(pending)}>{t('users.revoke')}</button> <button class="secondary" onclick={() => revoke(pending)}>{t('users.revoke')}</button>
@@ -209,7 +211,8 @@
</span> </span>
<span class="secondary"> <span class="secondary">
{user.email} · {ago(user.last_seen_at, { never: 'when.neverSignedIn' })} {user.email} · {ago(user.last_seen_at, { never: 'when.neverSignedIn' })}
{#if user.project_count}· {count(user.project_count, 'projekt', 'projekter')}{/if} {#if user.project_count}·
{count(user.project_count, 'users.projectOne', 'users.projectMany')}{/if}
</span> </span>
</div> </div>
@@ -159,9 +159,12 @@
<span class="who" class:gone={!entry.user_name} <span class="who" class:gone={!entry.user_name}
>{entry.user_name || t('history.deletedAccount')}</span >{entry.user_name || t('history.deletedAccount')}</span
> >
<!-- The space is written out. Svelte drops the whitespace that
opens a block, so "invited" and the address ran together. -->
<span class="event" <span class="event"
>{eventName(entry.event)}{#if eventDetail(entry)} >{eventName(entry.event)}{#if eventDetail(entry)}{' '}<span class="detail"
<span class="detail">{eventDetail(entry)}</span>{/if}</span >{eventDetail(entry)}</span
>{/if}</span
> >
</span> </span>
<span class="secondary">{entry.project_name}</span> <span class="secondary">{entry.project_name}</span>
+3 -2
View File
@@ -581,8 +581,9 @@
>{entry.user_name || t('history.deletedAccount')}</span >{entry.user_name || t('history.deletedAccount')}</span
> >
<span class="what"> <span class="what">
{eventName(entry.event)}{#if eventDetail(entry)} {eventName(entry.event)}{#if eventDetail(entry)}{' '}<span class="detail"
<span class="detail">{eventDetail(entry)}</span>{/if} >{eventDetail(entry)}</span
>{/if}
</span> </span>
<span class="when">{when(entry.created_at)}</span> <span class="when">{when(entry.created_at)}</span>
</li> </li>
+4 -4
View File
@@ -20,7 +20,7 @@
import TaskRow from '$lib/components/TaskRow.svelte'; import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte'; import QuickAdd from '$lib/components/QuickAdd.svelte';
import CalendarView from '$lib/components/CalendarView.svelte'; import CalendarView from '$lib/components/CalendarView.svelte';
import { t } from '$lib/i18n.svelte.js'; import { t, tag } from '$lib/i18n.svelte.js';
let days = $state([]); let days = $state([]);
@@ -48,9 +48,9 @@
const today = new Date(new Date().toDateString()); const today = new Date(new Date().toDateString());
const diff = Math.round((day - today) / 86400000); const diff = Math.round((day - today) / 86400000);
if (diff === 0) return 'I dag'; if (diff === 0) return t('task.today');
if (diff === 1) return 'I morgen'; if (diff === 1) return t('task.tomorrow');
return day.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'short' }); return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'short' });
} }
// Reading tasks back out of the store keeps a completed row disappearing // Reading tasks back out of the store keeps a completed row disappearing