---
@@ -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.
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
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
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:
@@ -90,6 +95,8 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Project templates | Done, tested |
| Nightly backups with rotation | Done, tested |
| 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 |
| MCP server for Claude | 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 — one column per section. | The light theme. |
-|  |  |
-| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
-|  |  |
-| Five themes, all measured for contrast. | What has been done on the server. |
+|  |  |
+| 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. |
+|  |  |
+| Notes, filed in a project to share them. | The same note as its source — which is what the export writes to disk. |
+|  |  |
+| 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
-the real binary, creates its own account, fills it with demo data and photographs
-eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots`
-still works, and is the way to photograph an instance that is already running.)
+the real binary, creates its own account and a second one to share with, fills them
+with demo data and photographs fourteen surfaces — nothing to set up and nothing to
+remember. (`go run ./tools/shots` still works, and is the way to photograph an
+instance that is already running.)
## Running it
diff --git a/docs/index.md b/docs/index.md
index 57f26e5..e6e8da6 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -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.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
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
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
@@ -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
open standards rather than private formats.
+## What it looks like
+
+
+
+*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.*
+
+
+
+*The same project as a list, where sections read as bands. As a board they are
+columns.*
+
+
+
+*What you have handed to somebody else, grouped by whoever has it.*
+
+
+
+*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
+[mail-to-task address](mail.md) are connected.*
+
+
+
+*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
verdande is built to be deployed as a **Rune** in
diff --git a/docs/screenshots/admin.png b/docs/screenshots/admin.png
index 3449f0d..244e64f 100644
Binary files a/docs/screenshots/admin.png and b/docs/screenshots/admin.png differ
diff --git a/docs/screenshots/board.png b/docs/screenshots/board.png
index 5abe8df..1638200 100644
Binary files a/docs/screenshots/board.png and b/docs/screenshots/board.png differ
diff --git a/docs/screenshots/delegated.png b/docs/screenshots/delegated.png
index a14ee86..0715438 100644
Binary files a/docs/screenshots/delegated.png and b/docs/screenshots/delegated.png differ
diff --git a/docs/screenshots/detail.png b/docs/screenshots/detail.png
index 894b945..784fa93 100644
Binary files a/docs/screenshots/detail.png and b/docs/screenshots/detail.png differ
diff --git a/docs/screenshots/history.png b/docs/screenshots/history.png
index b0be2ec..78ef54e 100644
Binary files a/docs/screenshots/history.png and b/docs/screenshots/history.png differ
diff --git a/docs/screenshots/notes-source.png b/docs/screenshots/notes-source.png
new file mode 100644
index 0000000..4faf0ae
Binary files /dev/null and b/docs/screenshots/notes-source.png differ
diff --git a/docs/screenshots/notes.png b/docs/screenshots/notes.png
new file mode 100644
index 0000000..f36b4b6
Binary files /dev/null and b/docs/screenshots/notes.png differ
diff --git a/docs/screenshots/sections.png b/docs/screenshots/sections.png
index b8e198a..3214963 100644
Binary files a/docs/screenshots/sections.png and b/docs/screenshots/sections.png differ
diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png
index dcbe578..9b1d08d 100644
Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ
diff --git a/docs/screenshots/themes.png b/docs/screenshots/themes.png
index e56bc18..c2882e8 100644
Binary files a/docs/screenshots/themes.png and b/docs/screenshots/themes.png differ
diff --git a/docs/screenshots/today-light.png b/docs/screenshots/today-light.png
index c85b213..32d6078 100644
Binary files a/docs/screenshots/today-light.png and b/docs/screenshots/today-light.png differ
diff --git a/docs/screenshots/today.png b/docs/screenshots/today.png
index 032ab28..e28abea 100644
Binary files a/docs/screenshots/today.png and b/docs/screenshots/today.png differ
diff --git a/docs/screenshots/upcoming.png b/docs/screenshots/upcoming.png
index ae5617b..eb94dbd 100644
Binary files a/docs/screenshots/upcoming.png and b/docs/screenshots/upcoming.png differ
diff --git a/docs/screenshots/week.png b/docs/screenshots/week.png
index 6b79242..05fa78a 100644
Binary files a/docs/screenshots/week.png and b/docs/screenshots/week.png differ
diff --git a/site/index.html b/site/index.html
index 9edf909..df203d6 100644
--- a/site/index.html
+++ b/site/index.html
@@ -621,13 +621,13 @@
- The light theme.
+ Notes. Rich text on top, Markdown underneath.
- Five themes, all of them measured for contrast.
+ Five themes and four looks: how bright, and how it reads.
+
+
Notes, beside the work
+
+ Rich text to write in, Markdown on disk. File a note in a project and
+ everybody in it can read it; #project and
+ [[another note]] 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.
+
+
A real CalDAV server
diff --git a/site/screenshots/admin.webp b/site/screenshots/admin.webp
index 87d7cd5..32082b7 100644
Binary files a/site/screenshots/admin.webp and b/site/screenshots/admin.webp differ
diff --git a/site/screenshots/board.webp b/site/screenshots/board.webp
index c902c66..d24030a 100644
Binary files a/site/screenshots/board.webp and b/site/screenshots/board.webp differ
diff --git a/site/screenshots/delegated.webp b/site/screenshots/delegated.webp
index ea91c17..e8cb87d 100644
Binary files a/site/screenshots/delegated.webp and b/site/screenshots/delegated.webp differ
diff --git a/site/screenshots/detail.webp b/site/screenshots/detail.webp
index 8980591..0e138cd 100644
Binary files a/site/screenshots/detail.webp and b/site/screenshots/detail.webp differ
diff --git a/site/screenshots/history.webp b/site/screenshots/history.webp
index 6842c07..15d92e4 100644
Binary files a/site/screenshots/history.webp and b/site/screenshots/history.webp differ
diff --git a/site/screenshots/notes-source.webp b/site/screenshots/notes-source.webp
new file mode 100644
index 0000000..b2d29ea
Binary files /dev/null and b/site/screenshots/notes-source.webp differ
diff --git a/site/screenshots/notes.webp b/site/screenshots/notes.webp
new file mode 100644
index 0000000..f1ea12d
Binary files /dev/null and b/site/screenshots/notes.webp differ
diff --git a/site/screenshots/sections.webp b/site/screenshots/sections.webp
index cf295d6..0e07240 100644
Binary files a/site/screenshots/sections.webp and b/site/screenshots/sections.webp differ
diff --git a/site/screenshots/settings.webp b/site/screenshots/settings.webp
index 48bf56b..5e38b43 100644
Binary files a/site/screenshots/settings.webp and b/site/screenshots/settings.webp differ
diff --git a/site/screenshots/themes.webp b/site/screenshots/themes.webp
index 4fea5f3..95a339e 100644
Binary files a/site/screenshots/themes.webp and b/site/screenshots/themes.webp differ
diff --git a/site/screenshots/today-light.webp b/site/screenshots/today-light.webp
index 0b46265..dca1f14 100644
Binary files a/site/screenshots/today-light.webp and b/site/screenshots/today-light.webp differ
diff --git a/site/screenshots/today.webp b/site/screenshots/today.webp
index 1e48836..0447cd7 100644
Binary files a/site/screenshots/today.webp and b/site/screenshots/today.webp differ
diff --git a/site/screenshots/upcoming.webp b/site/screenshots/upcoming.webp
index 8df50c3..1eb3399 100644
Binary files a/site/screenshots/upcoming.webp and b/site/screenshots/upcoming.webp differ
diff --git a/site/screenshots/week.webp b/site/screenshots/week.webp
index 71d38e1..c0d5fad 100644
Binary files a/site/screenshots/week.webp and b/site/screenshots/week.webp differ
diff --git a/tools/shots/main.go b/tools/shots/main.go
index 31f0144..ffd39f6 100644
--- a/tools/shots/main.go
+++ b/tools/shots/main.go
@@ -5,7 +5,7 @@
// 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.
//
-// go run ./tools/shots -url http://localhost:8096 -cookie -out site/screenshots
+// go run ./tools/shots -url http://localhost:8096 -cookie -out docs/screenshots
package main
import (
@@ -28,7 +28,9 @@ var chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
func main() {
base := flag.String("url", "http://localhost:8096", "the running instance")
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")
height := flag.Int("height", 900, "viewport height")
flag.Parse()
diff --git a/web/scripts/generate-screenshots.js b/web/scripts/generate-screenshots.js
index 57fe251..ab7b8d9 100644
--- a/web/scripts/generate-screenshots.js
+++ b/web/scripts/generate-screenshots.js
@@ -9,17 +9,22 @@
* 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
* 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
- * demo data, and takes the pictures. Nothing to set up and nothing to remember.
+ * This one starts the real binary, creates its own two accounts, fills them with
+ * 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
* 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
* 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 { 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 { dirname, join } from 'node:path';
@@ -31,13 +36,21 @@ const dataDir = join(web, '.screenshot-data');
const PORT = 8098;
const baseURL = `http://127.0.0.1:${PORT}`;
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 declares. A capture at another size
// would be resampled by the browser and look soft next to the text beside it.
const WIDTH = 1400;
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.
@@ -54,31 +67,64 @@ const SHOTS = [
// 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.
{ 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: '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: 'history', path: '/indstillinger/historik', theme: 'dark' },
- { name: 'delegated', path: '/uddelegeret', theme: 'dark' }
+ { name: 'history', path: '/indstillinger/historik', theme: 'dark' }
];
+const NOTE_TITLE = 'Launch meeting — agenda';
+
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);
}
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);
}
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);
}
-async function openFirstTask(page) {
- await page.locator('.row').first().click();
+async function openMeetingNote(page) {
+ // 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);
}
@@ -114,29 +160,42 @@ let browser;
try {
await waitForHealth();
- browser = await chromium.launch();
- const page = await browser.newPage({
- viewport: { width: WIDTH, height: HEIGHT },
- // The same pinning the smoke tests use, and for the same reason: the sign-up
- // form sends navigator.language as the account's locale, and every due date
- // is resolved in the account's zone.
- locale: 'da-DK',
- timezoneId: 'Europe/Copenhagen',
- deviceScaleFactor: 2
- });
+ // The browser's own language, which is not the same setting as the context's.
+ // `` is drawn by Chromium, not by the app, and it takes its
+ // order from this one — so without it the drawer shows an American date inside
+ // a day-month app, which is the exact thing i18n.svelte.js warns about.
+ browser = await chromium.launch({ args: ['--lang=en-GB'] });
+ const context = await browser.newContext(viewing());
+ const page = await context.newPage();
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(/E-mail/).fill(USER.email);
await page.getByLabel(/Adgangskode/).fill(USER.password);
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…');
- const ids = await page.evaluate(seed);
+ const ids = await page.evaluate(seed, { colleague: COLLEAGUE.email, noteTitle: NOTE_TITLE });
- mkdirSync(OUT[0], { recursive: true });
- mkdirSync(OUT[1], { recursive: true });
+ console.log('adding the second person…');
+ 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) {
const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path;
@@ -151,9 +210,9 @@ try {
await page.waitForTimeout(400);
}
const png = await page.screenshot();
- writeFileSync(join(OUT[0], `${shot.name}.png`), png);
- copyFileSync(join(OUT[0], `${shot.name}.png`), join(OUT[1], `${shot.name}.png`));
- console.log(` ${shot.name}.png`);
+ writeFileSync(join(PNG_DIR, `${shot.name}.png`), png);
+ writeFileSync(join(WEBP_DIR, `${shot.name}.webp`), await toWebP(encoder, png));
+ console.log(` ${shot.name}`);
}
} finally {
await browser?.close();
@@ -166,6 +225,25 @@ try {
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() {
for (let i = 0; i < 240; i++) {
try {
@@ -179,6 +257,92 @@ async function waitForHealth() {
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.
*
@@ -186,11 +350,11 @@ async function waitForHealth() {
* header the API insists on come along without being reconstructed here. Written
* as one function because it is passed to the browser as source.
*
- * The content is ordinary Danish household and work tasks. Screenshots of "Task
- * 1, Task 2, Task 3" show the layout and hide whether the app is any good at the
- * thing it is for.
+ * Ordinary English household and work tasks, the same shape the Danish set had.
+ * Screenshots of "Task 1, Task 2, Task 3" show the layout and hide whether the app
+ * 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 r = await fetch(`/api/v1${path}`, {
method,
@@ -210,63 +374,199 @@ async function seed() {
};
// Two headings over the projects, which is what the sidebar grew for.
- const arbejde = await api('/project-groups', { name: 'Arbejde' });
- const privat = await api('/project-groups', { name: 'Privat' });
- await api(`/project-groups/${arbejde.id}`, { color: 'indigo' }, 'PATCH');
- await api(`/project-groups/${privat.id}`, { color: 'green' }, 'PATCH');
+ const work = await api('/project-groups', { name: 'Work' });
+ const home = await api('/project-groups', { name: 'Home' });
+ await api(`/project-groups/${work.id}`, { color: 'indigo' }, 'PATCH');
+ await api(`/project-groups/${home.id}`, { color: 'green' }, 'PATCH');
- const work = await api('/projects', {
- name: 'Sæsonstart',
+ const launch = await api('/projects', {
+ name: 'Autumn launch',
color: 'indigo',
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 garden = await api('/projects', { name: 'Haven', color: 'green', group_id: privat.id });
+ const house = await api('/projects', { name: 'House', color: 'amber', group_id: home.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 inFlight = await api(`/projects/${work.id}/sections`, { name: 'I gang' });
- const done = await api(`/projects/${work.id}/sections`, { name: 'Til gennemsyn' });
+ const planning = await api(`/projects/${launch.id}/sections`, { name: 'Planning' });
+ const inFlight = await api(`/projects/${launch.id}/sections`, { name: 'In progress' });
+ const review = await api(`/projects/${launch.id}/sections`, { name: 'Up for review' });
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
// 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: 'Aflever cyklen til service', due_date: day(0), priority: 3, labels: ['ærinder'] },
+ { content: 'Call the roofer about the ridge tiles', due_date: day(0), priority: 1, project_id: house.id },
{
- content: 'Skriv oplæg til sæsonmødet',
+ content: 'Write the opening notes for the launch meeting',
due_date: day(0),
priority: 2,
- project_id: work.id,
+ project_id: launch.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: 'Hent pakken på posthuset', due_date: day(0), labels: ['ærinder'] },
+ { content: 'Drop the bike off for its service', due_date: day(0), priority: 3, labels: ['errands'] },
+ { 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.
- { content: 'Book håndværker til badeværelset', 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: 'Gennemgå budgettet', due_date: day(3), priority: 1, project_id: work.id, section_id: inFlight.id },
- { content: 'Bestil løg til efteråret', due_date: day(5), project_id: garden.id },
- { content: 'Skift dæk', due_date: day(8), priority: 3 },
- { content: 'Send referat ud', due_date: day(9), project_id: work.id, section_id: done.id },
- { content: 'Male vindueskarmene', due_date: day(12), project_id: house.id, priority: 2 },
- { content: 'Så vinterurter', 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: 'Rens tagrenderne', due_date: day(26), project_id: house.id },
+ { content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
+ { content: 'Cut the beech hedge back', due_date: day(2), project_id: garden.id },
+ { content: 'Go through the budget', due_date: day(3), priority: 1, project_id: launch.id, section_id: inFlight.id },
+ { content: 'Order bulbs for the autumn planting', due_date: day(5), project_id: garden.id },
+ { content: 'Swap the winter tyres on', due_date: day(8), priority: 3 },
+ { content: 'Send the minutes round', due_date: day(9), project_id: launch.id, section_id: review.id },
+ { content: 'Paint the window sills', due_date: day(12), project_id: house.id, priority: 2 },
+ { content: 'Sow winter herbs in the cold frame', due_date: day(15), project_id: garden.id },
+ { content: 'Quarterly review', due_date: day(21), priority: 1, project_id: launch.id, section_id: planning.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.
- { content: 'Find ny leverandør af emballage', project_id: work.id, section_id: planning.id, priority: 3 },
- { content: 'Skriv stillingsopslag', project_id: work.id, section_id: inFlight.id },
- { content: 'Opdater prislisten', project_id: work.id, section_id: done.id, labels: ['gennemsyn'] }
+ { content: 'Find a new packaging supplier', project_id: launch.id, section_id: planning.id, priority: 3 },
+ { content: 'Write the job advert', project_id: launch.id, section_id: inFlight.id },
+ { 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 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
+ ]
+ };
}
diff --git a/web/src/lib/components/BoardView.svelte b/web/src/lib/components/BoardView.svelte
index c62e587..f11e47e 100644
--- a/web/src/lib/components/BoardView.svelte
+++ b/web/src/lib/components/BoardView.svelte
@@ -64,7 +64,7 @@
// 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.
let columns = $derived([
- { id: '', name: 'Uden sektion' },
+ { id: '', name: t('project.noSection') },
...sections.map((s) => ({ id: s.id, name: s.name }))
]);
diff --git a/web/src/lib/components/TaskDetail.svelte b/web/src/lib/components/TaskDetail.svelte
index 4d26252..fd94c79 100644
--- a/web/src/lib/components/TaskDetail.svelte
+++ b/web/src/lib/components/TaskDetail.svelte
@@ -14,6 +14,7 @@
import { app, projectName } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
+ import { REPEATS } from '$lib/when.js';
let { task, onclose } = $props();
@@ -29,17 +30,6 @@
/** Who the task can be given to. Only ever more than one on a shared project. */
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 comments = $state([]);
let attachments = $state([]);
@@ -452,13 +442,13 @@
diff --git a/web/src/lib/components/TaskRow.svelte b/web/src/lib/components/TaskRow.svelte
index 57f77b5..b4470dd 100644
--- a/web/src/lib/components/TaskRow.svelte
+++ b/web/src/lib/components/TaskRow.svelte
@@ -10,9 +10,12 @@
*/
import { app } from '$lib/stores.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js';
+ import { repeatLabel } from '$lib/when.js';
let { task, onedit } = $props();
+ let repeats = $derived(repeatLabel(task.recurrence_rule, task.recurrence_text));
+
let leaving = $state(false);
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
@@ -84,7 +87,7 @@
{task.description}
{/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}
{#if assignee}
{eventName(entry.event)}{#if eventDetail(entry)}
- {eventDetail(entry)}{/if}{eventName(entry.event)}{#if eventDetail(entry)}{' '}{eventDetail(entry)}{/if}{entry.project_name}
diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte
index 5ed5a8e..a8df623 100644
--- a/web/src/routes/projekt/[id]/+page.svelte
+++ b/web/src/routes/projekt/[id]/+page.svelte
@@ -581,8 +581,9 @@
>{entry.user_name || t('history.deletedAccount')}
- {eventName(entry.event)}{#if eventDetail(entry)}
- {eventDetail(entry)}{/if}
+ {eventName(entry.event)}{#if eventDetail(entry)}{' '}{eventDetail(entry)}{/if}
{when(entry.created_at)}
diff --git a/web/src/routes/upcoming/+page.svelte b/web/src/routes/upcoming/+page.svelte
index 9caf3a1..635f807 100644
--- a/web/src/routes/upcoming/+page.svelte
+++ b/web/src/routes/upcoming/+page.svelte
@@ -20,7 +20,7 @@
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.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([]);
@@ -48,9 +48,9 @@
const today = new Date(new Date().toDateString());
const diff = Math.round((day - today) / 86400000);
- if (diff === 0) return 'I dag';
- if (diff === 1) return 'I morgen';
- return day.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'short' });
+ if (diff === 0) return t('task.today');
+ if (diff === 1) return t('task.tomorrow');
+ return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'short' });
}
// Reading tasks back out of the store keeps a completed row disappearing