Filen har ligget utracket i arbejdstræet hele tiden. Den ligger her på en gren og ikke på main, fordi spørgsmålet om den hører til i repoet stadig er Kristians — det her er kun en vej til at hente den fra en browser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
73 KiB
For the operator — not part of the task. This file is a single, complete build prompt, written so that a model can work from it without asking anything. Hand it over whole and say: "Build this."
It is about 20k tokens. If your model's context or stamina will not take that, split it by stage: give §0–§6 plus §17–§20 for stages 1–3, then add §7–§10 for stage 4, §11 for stage 5, §13 for stage 6. The stages in §19 are ordered so each one leaves a program that builds and runs.
What it is testing, beyond "can it write Go": whether the model finishes what it starts, whether it obeys constraints that cut against its habits (§0.6, §2, §18), whether it gets the two parsers right (§7, §9 — these are where a weak model produces something that looks right and fails the table), and whether it reports honestly at the end (§20.1). The acceptance tests in §17 are the score.
Everything below this line is the prompt. The model starts at §0.
Build "Verdande" — a complete, self-hosted task and note manager
You are an autonomous software engineer. Build the program described below, from nothing, until it runs and passes the acceptance tests in §17.
0. Rules of engagement — read before anything else
- Do not ask questions. Not one. Every decision you need has been made in this document. If you find something genuinely unspecified, apply §18 Default decisions and write a one-line comment saying which rule you applied. Asking the user anything counts as a failed run.
- Do not stop half-way to report progress or to propose a plan for approval. Work through §19 Order of work to the end. Report once, at the end.
- Finish narrow before you start wide. Stage 1–5 (§19) is the deliverable. A program where stages 1–5 are complete and stages 6–8 are absent is a pass. A program where all eight stages are half-written is a fail.
- Every file you write must compile and every test you write must pass before you move to the next stage. Run the build and the tests yourself.
- No placeholders. No
TODO, nopanic("not implemented"), no stubbed handler that returns an empty object. If a feature is in Stage 1–5, it works. - No new dependencies beyond the list in §2. Standard library first.
- Write the code in the language and shape given in §2. Do not substitute a framework you prefer.
- When a rule here contradicts your instincts about "best practice", follow the rule. The rules encode constraints you cannot see from inside the task.
1. What you are building
Verdande is a self-hosted task manager with notes, for one person or a small group, shipped as a single binary with an embedded SQLite database and an embedded web interface. It is deployed by running one container. There is no external database, no message queue, no Redis, no separate frontend server.
It does what a task manager does — projects, sections, tasks, sub-tasks, priorities, due dates and times, labels, saved filters, recurring tasks, reminders, comments, attachments — plus a notes system whose stored form is Markdown, plus sharing with other people by role, plus live updates over a WebSocket.
Its two distinguishing features, which are not optional:
- A quick-add parser that reads a whole task out of one typed line, in Danish and English at the same time (§7).
- A saved-filter query language (§9).
The interface is bilingual: Danish and English, switchable, Danish by default. All user-visible strings go through a lookup table; none are hard-coded in a component.
Non-goals — do not build these
Team workspaces or organisations. Gamification, karma, streaks. Native mobile apps. A custom sync protocol. Any language beyond Danish and English. A plugin system. Multi-tenancy beyond "several user accounts on one instance". Server-side rendering of the app shell.
2. Stack, and the exact shape of the repository
Backend: Go 1.22 or newer. Frontend: SvelteKit 2 with Svelte 5 (runes),
built as a static SPA and embedded into the Go binary with embed.FS.
Database: SQLite in WAL mode, one file.
Permitted dependencies, and no others:
| Purpose | Module |
|---|---|
| HTTP routing | github.com/go-chi/chi/v5 |
| SQLite driver | modernc.org/sqlite (pure Go — the binary must build with CGO_ENABLED=0) |
| Password hashing | golang.org/x/crypto (for argon2) |
| WebSocket | github.com/coder/websocket |
| Frontend | svelte, @sveltejs/kit, @sveltejs/adapter-static, vite |
| Frontend tests | @playwright/test |
Everything else — JSON, HTTP, time, crypto/rand, text parsing, iCalendar output, RRULE expansion, Markdown conversion — you write with the standard library. Do not reach for a Markdown library, a date library, an RRULE library or a UI component library.
Layout
verdande/
├── go.mod
├── cmd/verdande/
│ ├── main.go flags, config, signal handling, start/stop
│ ├── embed.go //go:build embedweb — embeds webbuild/
│ ├── noembed.go //go:build !embedweb — API only, so a clean
│ │ checkout compiles with no Node installed
│ └── webbuild/ copied from web/build at release time; gitignored
├── internal/
│ ├── config/ environment → Config struct, with defaults
│ ├── store/ every SQL query in the program lives here
│ │ ├── migrations/ 0001_init.sql, 0002_… — embedded, run in order
│ │ ├── store.go open, WAL, pragmas, migrate, Tx helper
│ │ ├── id.go UUIDv7 generator
│ │ ├── users.go sessions.go invites.go projects.go sections.go
│ │ ├── tasks.go labels.go filters.go comments.go attachments.go
│ │ ├── reminders.go activity.go notes.go note_shares.go search.go
│ │ └── access.go role resolution and permission checks
│ ├── auth/ Argon2id hashing, token generation, TOTP
│ ├── quickadd/ the one-line parser (§7)
│ ├── filterquery/ the filter language: lexer, parser, SQL compiler (§9)
│ ├── recurrence/ RRULE parse, format, next-occurrence (§8)
│ ├── ics/ iCalendar writer for the feed and the export
│ ├── realtime/ the WebSocket hub
│ ├── jobs/ reminder sweep, backup, session cleanup
│ └── httpapi/ one file per resource group; handlers only
├── web/
│ ├── package.json svelte.config.js vite.config.js playwright.config.js
│ ├── src/
│ │ ├── app.html app.css
│ │ ├── lib/
│ │ │ ├── api.js one fetch wrapper; every call goes through it
│ │ │ ├── stores.svelte.js app state as runes
│ │ │ ├── i18n.svelte.js t(key, vars)
│ │ │ ├── locales/da.js en.js
│ │ │ ├── richtext.js Markdown ↔ HTML for the note editor (§11)
│ │ │ ├── when.js date formatting and relative days
│ │ │ └── components/ Sidebar, QuickAdd, TaskList, TaskRow,
│ │ │ TaskDetail, BoardView, CalendarView,
│ │ │ CommandPalette, NoteEditor, SignIn
│ │ └── routes/ see §13
│ ├── static/ manifest.webmanifest, icons
│ └── e2e/ Playwright specs
├── Dockerfile
└── README.md
Rules about this layout that are not negotiable:
- No SQL outside
internal/store. A handler that writes a query is a bug. - No HTTP types inside
internal/store. The store knows nothing about requests, users-from-context or JSON. internal/httpapihandlers do four things and nothing else: decode, check permission, call the store, encode.- The frontend never talks to the database and never sees a SQL error message.
3. Conventions that apply everywhere
Identifiers
Every row's primary key is a UUIDv7 rendered lowercase with hyphens, generated
in Go, never by the database. UUIDv7 because the id sorts by creation time, which
means an index on the key is also an index on age, and a list of ids is stable.
Implement it yourself in internal/store/id.go: 48-bit big-endian millisecond
timestamp, version 7 in the high nibble of byte 6, 12 bits of monotonic sequence,
variant bits in byte 8, the rest from crypto/rand. Two ids generated in the same
millisecond must differ and must sort in generation order.
Time
- Stored as Unix seconds in an INTEGER column, except a date-without-a-time,
which is stored as a
YYYY-MM-DDstring. - A task can have a due date (
due_date, a day) or a due datetime (due_datetime, an instant, plusdue_timezonenaming the zone it was written in).due_dateis always set when either is;due_datetimeis the refinement. "Due Tuesday" and "due Tuesday at 10" must both be answerable by one index ondue_date. - Every user row carries a
timezone(IANA name, defaultEurope/Copenhagen). "Today" is always computed in the reader's zone, never the server's. - The API speaks RFC 3339 for instants and
YYYY-MM-DDfor days.
Ordering
Manual order is a REAL column called sort_order. To move an item between
two neighbours, write the midpoint of their orders. To append, take the current
maximum plus 1000. Never renumber a whole list on a single move. Provide a
.../reorder endpoint that takes the full array of ids in the new order and
rewrites them as 1000, 2000, 3000… — used when the client has the whole list
anyway, and as the repair path if the midpoints ever get too close to distinguish.
Soft delete
Rows people can throw away carry deleted_at INTEGER NULL. Every read excludes
non-null. A trash view lists them, restore clears the column, and a job deletes
rows older than 30 days. Rows that are records rather than content — activity,
sessions, notifications — hard-delete.
The JSON error envelope
Every non-2xx response from /api/ has this body and no other shape:
{ "code": "validation_error", "error": "one or more fields are not valid",
"fields": { "email": "must be an email address" } }
fields appears only for validation errors. The codes, and no others:
unauthorized, forbidden, validation_error, not_found, conflict,
rate_limited, payload_too_large, internal_error.
error is English prose for a log. The frontend decides what a person reads, by
switching on code — never by displaying the server's sentence. An internal
error must never leak a SQL string or a file path to the client; log it with a
request id and return that id in the message.
Request limits
JSON bodies are capped at 1 MiB, uploads at 25 MiB. /api/v1/auth/login,
/signup, /password/forgot are rate-limited to 10 attempts per IP per 15
minutes; the rest of the API to 600 requests per minute per session.
Colours
Anything colourable (project, group, label, filter) stores one of exactly these
names, never a hex value: graphite (the default), tomato, amber, lime,
green, teal, blue, indigo, violet, magenta. The CSS maps the name to
a value per theme, so a project keeps its identity when the theme changes.
Language of the code
Identifiers, types and commit messages in English. User-facing strings in the locale files only. Comments explain why, not what — a comment restating the line above it is noise; a comment recording the trap that made the line necessary is the reason comments exist.
4. The data model
Write these as numbered migration files under internal/store/migrations/,
embedded with //go:embed and applied in filename order inside a transaction, with
the applied version recorded in a schema_migrations table. A migration is never
edited after it ships; the next change is a new file.
Open the database with these pragmas, in this order:
journal_mode=WAL, busy_timeout=5000, foreign_keys=ON, synchronous=NORMAL.
foreign_keys=ON matters: half the delete behaviour below is expressed as
ON DELETE CASCADE and is silently absent without it.
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
totp_last_step INTEGER, -- last accepted TOTP step, so a code is single-use
avatar_color TEXT NOT NULL DEFAULT '#8a8f98',
timezone TEXT NOT NULL DEFAULT 'Europe/Copenhagen',
locale TEXT NOT NULL DEFAULT 'da',
is_admin INTEGER NOT NULL DEFAULT 0,
ics_token TEXT, -- the calendar feed's secret path segment
sidebar_collapsed TEXT NOT NULL DEFAULT '[]',
nav_order TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX idx_users_email ON users (lower(email));
CREATE UNIQUE INDEX idx_users_ics_token ON users (ics_token) WHERE ics_token IS NOT NULL;
CREATE TABLE sessions (
id TEXT PRIMARY KEY, -- the SHA-256 of the cookie value, not the value
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
ua TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
pending_totp INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX idx_sessions_user ON sessions (user_id);
CREATE INDEX idx_sessions_expires ON sessions (expires_at);
CREATE TABLE invites (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE, -- null = to the instance
role TEXT NOT NULL DEFAULT 'editor',
token_hash TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
accepted_at INTEGER,
CHECK (role IN ('owner', 'editor', 'viewer'))
);
CREATE INDEX idx_invites_email ON invites (lower(email));
CREATE TABLE password_resets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER
);
CREATE TABLE project_groups (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'graphite',
collapsed INTEGER NOT NULL DEFAULT 0,
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_project_groups_owner ON project_groups (owner_id);
CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT 'graphite',
icon TEXT,
view_mode TEXT NOT NULL DEFAULT 'list',
owner_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
group_id TEXT REFERENCES project_groups (id) ON DELETE SET NULL,
is_inbox INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
CHECK (view_mode IN ('list', 'board', 'calendar'))
);
CREATE INDEX idx_projects_owner ON projects (owner_id) WHERE deleted_at IS NULL;
-- Exactly one inbox per person, enforced by the schema rather than by a check in
-- code that one code path will eventually skip.
CREATE UNIQUE INDEX idx_projects_one_inbox ON projects (owner_id) WHERE is_inbox = 1;
CREATE TABLE project_members (
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
role TEXT NOT NULL,
added_at INTEGER NOT NULL,
PRIMARY KEY (project_id, user_id),
CHECK (role IN ('owner', 'editor', 'viewer'))
);
CREATE INDEX idx_project_members_user ON project_members (user_id);
CREATE TABLE sections (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
name TEXT NOT NULL,
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE INDEX idx_sections_project ON sections (project_id) WHERE deleted_at IS NULL;
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
section_id TEXT REFERENCES sections (id) ON DELETE SET NULL,
parent_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
content TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 4,
due_date TEXT, -- YYYY-MM-DD
due_datetime INTEGER, -- unix seconds, when there is a clock time
due_timezone TEXT,
duration_min INTEGER,
recurrence_rule TEXT, -- RFC 5545 RRULE, without the RRULE: prefix
snoozed_until INTEGER,
assignee_id TEXT REFERENCES users (id) ON DELETE SET NULL,
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
completed_at INTEGER,
completed_by TEXT REFERENCES users (id) ON DELETE SET NULL,
sort_order REAL NOT NULL DEFAULT 0,
source_key TEXT NOT NULL DEFAULT '', -- de-duplication key for imports
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
-- A Danish transliteration kept beside the text, so search can match
-- "gron" against "grøn". See §10 — this column is why that works.
fold TEXT GENERATED ALWAYS AS (
replace(replace(replace(replace(replace(replace(
content || ' ' || description,
'ø', 'o'), 'Ø', 'o'),
'æ', 'ae'), 'Æ', 'ae'),
'å', 'aa'), 'Å', 'aa')
) STORED,
CHECK (priority BETWEEN 1 AND 4)
);
CREATE INDEX idx_tasks_project ON tasks (project_id, sort_order)
WHERE deleted_at IS NULL AND completed_at IS NULL;
CREATE INDEX idx_tasks_due ON tasks (due_date)
WHERE deleted_at IS NULL AND completed_at IS NULL AND due_date IS NOT NULL;
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id)
WHERE deleted_at IS NULL AND completed_at IS NULL AND assignee_id IS NOT NULL;
CREATE INDEX idx_tasks_parent ON tasks (parent_id) WHERE parent_id IS NOT NULL;
CREATE UNIQUE INDEX tasks_once_per_source ON tasks (created_by, source_key)
WHERE source_key <> '' AND created_by IS NOT NULL;
CREATE VIRTUAL TABLE tasks_fts USING fts5 (
content, description, fold,
id UNINDEXED, project_id UNINDEXED,
content = 'tasks', content_rowid = 'rowid',
tokenize = 'unicode61 remove_diacritics 2'
);
-- Three triggers keep it in step: insert, delete, and update as delete+insert.
-- An external-content FTS table that is not maintained by triggers goes stale
-- silently, and a search that quietly stops finding new rows is the worst kind
-- of bug: nothing fails.
CREATE TABLE labels (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT 'graphite',
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX idx_labels_user_name ON labels (user_id, lower(name));
CREATE TABLE task_labels (
task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
label_id TEXT NOT NULL REFERENCES labels (id) ON DELETE CASCADE,
PRIMARY KEY (task_id, label_id)
);
CREATE INDEX idx_task_labels_label ON task_labels (label_id);
CREATE TABLE reminders (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
remind_at INTEGER, -- absolute
offset_min INTEGER, -- or relative to the due time; exactly one of the two
sent_at INTEGER,
created_at INTEGER NOT NULL,
CHECK ((remind_at IS NULL) <> (offset_min IS NULL))
);
CREATE INDEX idx_reminders_pending ON reminders (remind_at) WHERE sent_at IS NULL;
CREATE TABLE comments (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE INDEX idx_comments_task ON comments (task_id) WHERE deleted_at IS NULL;
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
comment_id TEXT REFERENCES comments (id) ON DELETE CASCADE,
note_id TEXT REFERENCES notes (id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size INTEGER NOT NULL,
path TEXT NOT NULL, -- relative to VERDANDE_DATA_DIR/files
uploaded_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
CHECK ((task_id IS NOT NULL) + (comment_id IS NOT NULL) + (note_id IS NOT NULL) = 1)
);
CREATE TABLE activity (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
task_id TEXT REFERENCES tasks (id) ON DELETE SET NULL,
-- SET NULL, not CASCADE: the record of what happened outlives the account
-- that did it. Deleting a person must not rewrite the project's history.
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
event TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL
);
CREATE INDEX idx_activity_project ON activity (project_id, created_at DESC);
CREATE INDEX idx_activity_recent ON activity (created_at DESC, id DESC);
CREATE TABLE filters (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
query TEXT NOT NULL,
color TEXT NOT NULL DEFAULT 'graphite',
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
actor_id TEXT REFERENCES users (id) ON DELETE SET NULL,
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
kind TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
read_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_notifications_user ON notifications (user_id, created_at DESC);
CREATE TABLE api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL, -- the first 8 characters, so a token is recognisable in a list
created_at INTEGER NOT NULL,
last_used_at INTEGER,
expires_at INTEGER
);
CREATE TABLE notes (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '', -- derived; see §11
body TEXT NOT NULL DEFAULT '', -- Markdown, the whole note
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
pinned INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
archived_at INTEGER,
deleted_at INTEGER
);
CREATE INDEX notes_by_project ON notes (project_id, deleted_at, updated_at DESC);
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
WHERE deleted_at IS NULL;
CREATE TABLE note_links (
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('task', 'note', 'project')),
target_id TEXT NOT NULL, -- deliberately NOT a foreign key; see §11
PRIMARY KEY (note_id, kind, target_id)
);
CREATE INDEX note_links_backwards ON note_links (kind, target_id);
CREATE TABLE note_shares (
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'viewer',
linked INTEGER NOT NULL DEFAULT 0, -- 1 = derived from a [[link]], see §11
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (note_id, user_id)
);
CREATE INDEX note_shares_by_user ON note_shares (user_id);
CREATE TABLE user_settings (
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'general',
values_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, scope)
);
CREATE TABLE error_log (
id TEXT PRIMARY KEY,
at INTEGER NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
status INTEGER NOT NULL,
what TEXT NOT NULL,
message TEXT NOT NULL,
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
request_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX idx_error_log_at ON error_log (at DESC);
CREATE TABLE backup_runs (
id TEXT PRIMARY KEY,
started_at INTEGER NOT NULL,
finished_at INTEGER,
path TEXT,
size_bytes INTEGER,
error TEXT
);
Two schema decisions to keep, and why
created_byisON DELETE SET NULLeverywhere, notCASCADE. Removing an account must not remove the shared project's tasks with it. The onlyCASCADEs are the ones where the child is meaningless without the parent: a task's labels, a project's sections, a session's user.foldis a stored generated column, not something the application writes. Anything the application maintains alongside the text can disagree with the text.
5. Accounts, sessions and permissions
There is no open registration
The first POST /api/v1/auth/setup on an empty instance creates the first account
and makes it an administrator. It returns 409 forever after. Everybody else arrives
through an invite link: either to a project (an administrator or a project owner
issues it) or to the instance itself (an administrator, from Settings → Users).
POST /api/v1/auth/signup therefore takes an invite token and the new person's
name and password. Nobody's password is ever chosen by somebody else — an
invite carries no password and the creator never sees one.
If SMTP is not configured, the invite endpoint returns the link in its response and the interface shows it to be copied by hand. That is the correct behaviour for a single-user instance, and it must not be an error.
Passwords
Argon2id, at m=19456,t=2,p=1, 16-byte random salt, 32-byte output, stored in the
standard encoded form:
$argon2id$v=19$m=19456,t=2,p=1$<base64 salt>$<base64 hash>
Verify by parsing the parameters out of the stored string, not from the current
constants — a hash written by an older build must keep working. Compare with
subtle.ConstantTimeCompare. Minimum password length 10, no other composition
rules.
Sessions
A session token is 32 bytes from crypto/rand, base64url. The database stores
the SHA-256 of it, never the token, so a stolen database yields no live sessions.
It is set as a cookie:
- Name
__Host-verdande_sessionwhen serving over HTTPS,verdande_sessionotherwise. The__Host-prefix ties the cookie to the exact host with noDomain, which is what stops a neighbouring subdomain from setting one. HttpOnly,Securewhen HTTPS,SameSite=Lax,Path=/.SameSite=Laxand notStrict:Strictwould mean following an invite link from an email lands you logged out.- Lifetime from
VERDANDE_SESSION_TTL, default 720 h.last_seen_atis touched at most once a minute, not on every request.
CSRF: every state-changing request must carry Sec-Fetch-Site: same-origin
(or no Sec-Fetch-Site header at all, for old clients) or a valid
Authorization: Bearer token. A cookie-authenticated POST with
Sec-Fetch-Site: cross-site is rejected 403.
Two-factor
TOTP, RFC 6238, 30-second step, SHA-1, 6 digits, ±1 step of drift accepted. On
login, if totp_enabled, the session is created with pending_totp = 1 and grants
nothing until POST /auth/login/totp succeeds. A code is single-use: store the
accepted step in users.totp_last_step and refuse a step less than or equal to it,
or an intercepted code is replayable for thirty seconds. Ten single-use recovery
codes are generated at enrolment and shown once.
API tokens
vrd_ plus 32 random bytes base64url. Hash stored, prefix stored for display.
Sent as Authorization: Bearer. A token has its owner's full rights.
Roles
Three, ordered: viewer < editor < owner.
| Role | May |
|---|---|
| viewer | read everything in the project; comment |
| editor | everything a viewer may, plus create, edit, complete, move and delete tasks, sections and notes |
| owner | everything an editor may, plus rename or delete the project, invite people, change roles, remove members |
A person's role on a project is: owner if they own it, otherwise their
project_members role, otherwise none. Every handler that touches a project or
anything inside it resolves the role first and refuses before it reads. Put that
in one function in internal/store/access.go and call it from every handler; a
permission check written a second time is a permission check that will diverge.
A missing permission answers 404, not 403, for anything addressed by id.
Telling a stranger "that is not yours" confirms the row exists. 403 is only for
"you are signed in, this thing exists and you may see it, but not do this to it".
Labels and filters are personal: they belong to one user and are never shared, even on a shared task. Two people can label the same task with two different private labels of the same name.
6. The HTTP API
/api/v1 for everything, JSON in and out, plus a few non-JSON endpoints listed at
the end. Collections return {"tasks": [...]} — an object with a named array, never
a bare array, so a field can be added later without breaking a client.
Build the whole list. Where the shape is obvious from the schema, it is not repeated here; where it is not, it is.
Auth
| Method | Path | Notes |
|---|---|---|
| GET | /auth/setup |
{"needed": true} while the instance has no users |
| POST | /auth/setup |
{name, email, password} → first account, admin, signed in |
| POST | /auth/login |
{email, password} → session, or {"totp_required": true} |
| POST | /auth/login/totp |
{code} on a pending session |
| POST | /auth/signup |
{token, name, password} — accepts an invite |
| POST | /auth/password/forgot |
Always 204, whether or not the address exists |
| POST | /auth/password/reset |
{token, password} |
| GET | /auth/me |
the user, their inbox project id, and their settings |
| PATCH | /auth/me |
name, timezone, locale, avatar colour |
| POST | /auth/logout |
|
| POST | /auth/password/change |
{current, new} — revokes every other session |
| POST | /auth/totp/setup |
returns secret and otpauth:// URL |
| POST | /auth/totp/confirm |
{code} → enables, returns recovery codes |
| POST | /auth/totp/disable |
{password} |
| GET/DELETE | /auth/sessions, /auth/sessions/{id} |
the device list, and signing one out |
Projects, groups, sections
| Method | Path |
|---|---|
| GET/POST | /projects |
| GET/PATCH/DELETE | /projects/{id} |
| POST | /projects/reorder — {"ids": [...]} |
| GET/POST | /projects/{id}/sections |
| POST | /projects/{id}/sections/reorder |
| PATCH/DELETE | /sections/{id} |
| GET/POST/DELETE | /projects/{id}/members, /projects/{id}/members/{userID} |
| POST | /projects/{id}/invites |
| GET | /projects/{id}/activity |
| GET/POST | /project-groups |
| PATCH/DELETE | /project-groups/{id} |
| POST | /project-groups/reorder |
| GET | /trash/projects |
| POST | /trash/projects/{id}/restore |
Every user gets an Inbox project on account creation: is_inbox = 1, undeletable,
unrenamable, and the destination for any task created without a project.
Tasks
| Method | Path | Notes |
|---|---|---|
| GET | /tasks |
?project=§ion=&label=&assignee=&completed=&parent= |
| POST | /tasks |
explicit fields |
| GET/PATCH/DELETE | /tasks/{id} |
|
| POST | /tasks/{id}/complete |
see §8 for what this does to a recurring task |
| POST | /tasks/{id}/reopen |
|
| POST | /tasks/{id}/move |
{project_id, section_id, before_id, after_id} |
| POST | /tasks/{id}/snooze |
{until} or {until: null} |
| POST | /tasks/quick-add |
{text, project_id?} → the created task |
| POST | /tasks/quick-add/preview |
the same parse, without creating anything, returning the spans it recognised so the input can tint them live |
| GET/POST | /tasks/{id}/comments, PATCH/DELETE /comments/{id} |
|
| GET/POST | /tasks/{id}/attachments, GET/DELETE /attachments/{id} |
|
| GET/POST | /tasks/{id}/reminders, DELETE /reminders/{id} |
The preview response is what makes the typing field feel alive, so it is not optional:
{ "content": "betal moms",
"due_date": "2026-03-16", "due_datetime": "2026-03-16T10:00:00+01:00",
"priority": 1, "project": {"id": "…", "name": "Firma"},
"labels": ["regnskab"], "recurrence_rule": null,
"spans": [ {"start": 11, "end": 27, "kind": "date"},
{"start": 28, "end": 30, "kind": "priority"},
{"start": 31, "end": 37, "kind": "project"},
{"start": 38, "end": 47, "kind": "label"} ] }
start and end are rune offsets into the original string, not byte offsets.
Danish text is not ASCII and a byte offset will slice a character in half.
Views
| Method | Path | Notes |
|---|---|---|
| GET | /today |
overdue and due-today, in the reader's zone, in one response, plus completed-today |
| GET | /upcoming |
?days=7 (1–30), grouped per day |
| GET | /delegated |
tasks you created that are assigned to somebody else, grouped by person |
| GET | /search |
?q= over tasks and notes |
| GET | /people |
everybody who shares a project with you |
| GET | /activity |
recent activity across everything you can see |
Labels, filters
GET/POST /labels, PATCH/DELETE /labels/{id}, GET/POST /filters,
PATCH/DELETE /filters/{id}, GET /filters/{id}/tasks,
POST /filters/preview ({query} → the tasks it matches, or a parse error naming
the part it could not read).
A filter is compiled before it is saved. Saving an unparseable filter is refused with a validation error. A filter that cannot run is worse than none: you find out later, from a list that is empty for a reason nobody can see.
Notes
| Method | Path | Notes |
|---|---|---|
| GET | /notes |
?q= searches, ?project= narrows, ?archived=1 |
| POST | /notes |
|
| GET/PATCH/DELETE | /notes/{id} |
|
| POST | /notes/bulk |
archive or delete several |
| GET | /notes/linking/{kind}/{targetID} |
what points at this task/note/project |
| GET/POST | /notes/{id}/shares |
owner only |
| DELETE | /notes/{id}/shares/{userID} |
|
| POST/GET | /notes/{id}/attachments |
|
| GET | /export/notes.zip |
every note as a .md file |
| POST | /notes/import |
a zip of Markdown, back in |
The list response must not carry the notes' bodies. Send title, a preview
of the first ~400 characters, and an empty body. Twelve hundred notes with
their bodies is nearly a megabyte of text nobody will read on that screen, fetched
on every visit. Empty rather than truncated, and this is the important half: a
field called body holding part of the body is a trap — the editor would open it,
the debounced save would write it back, and the note would be cut down to its own
preview by nothing but being looked at. The client fetches the whole note when it
opens one.
Realtime, feeds, health
| Path | Notes |
|---|---|
GET /api/v1/ws |
WebSocket, authenticated by the session cookie |
GET /api/v1/version |
the running version |
GET /healthz |
200 and the word ok, no auth, no database access |
GET /ics/{token} |
the personal calendar feed — see §12 |
7. Quick add — the one-line parser
This is the feature people use fifty times a day. Get it exactly right.
Input: one line of text. Output: a task, and the spans that were consumed.
betal moms i morgen kl 10 p1 #Firma @regnskab
→ content "betal moms", due tomorrow 10:00, priority 1, project Firma, label regnskab
file the VAT return tomorrow at 10am p1 #Accounts @tax
→ the same thing in English
Tokens
| Written | Means |
|---|---|
#Name |
that project. #"Q3 report" for a name with spaces. Case-insensitive match; an unknown name creates nothing and stays in the title |
/Name |
that section within the chosen project |
@label |
add that label; several allowed; unknown labels are created |
p1 p2 p3 p4 |
priority; !!! = p1, !! = p2, ! = p3 |
i dag / today |
today |
i morgen / tomorrow |
tomorrow |
i overmorgen |
the day after tomorrow |
i går / yesterday |
yesterday |
mandag…søndag, monday…sunday, på fredag, on friday |
the next such weekday, not today if today is that day |
næste mandag / next monday |
the Monday of next week, counted from Monday — not merely the next one to come round |
om 3 dage, in 3 days, om en uge, in a week, næste måned, next month |
relative |
15. marts, 15 marts, march 15, 15/3, 15-03, 2026-03-15 |
written dates |
kl 10, kl. 14:30, klokken 16, at 10am, at 14:30, 10pm |
a clock time |
hver mandag, every monday, hverdage, weekdays, hver 2. uge, every 2 weeks, den 1. i måneden, monthly, årligt, yearly |
makes it repeat (§8) |
Rules
- Both languages are read at once, not behind a setting. People mix them
mid-sentence:
ring til Anders on friday kl 14 #Arbejdemust work. - The only thing the locale decides is a bare hour after English
at:at 3means 15:00, while Danishkl 3means 03:00. Danish has no am/pm habit and means the 24-hour clock. - Numeric dates are day-first.
15/3is 15 March. Guessing month-first moves a date by months silently instead of failing visibly. - A date in the past that was written without a year rolls forward to the next
occurrence.
15/3typed in April 2026 means 15 March 2027. - A bare clock time with no day means today, even if it has already passed:
kl 3at eight in the morning is 03:00 today, not tomorrow. Guessing tomorrow moves a task a day on the strength of an assumption about what somebody meant. - A recurrence with no date of its own starts today.
review every 2 weekstyped on the 16th is due the 16th. - Anything not recognised stays in the title, verbatim. Never return an error from quick add. A task with a clumsy title beats an error message and a lost thought.
- Recognised tokens are cut out of the title along with the whitespace they leave behind; the remaining text is collapsed to single spaces and trimmed.
- A
#projectthat names nothing goes to the Inbox and stays in the title — it was a word beginning with a hash, not a reference. - Matching is case-insensitive and diacritic-tolerant on both sides.
- Order does not matter.
p1 i morgen betal momsis the same task. - The parser is a pure function with no database access. It takes the text plus the list of known project and label names and returns a struct. That is what makes it testable, and it must have a table-driven test with at least the forty cases implied by the table above.
8. Recurrence
Store an RFC 5545 RRULE string, without the RRULE: prefix, e.g.
FREQ=WEEKLY;BYDAY=MO. Support FREQ of DAILY, WEEKLY, MONTHLY, YEARLY,
with INTERVAL, BYDAY, BYMONTHDAY, COUNT and UNTIL. Not the whole
standard — those, correctly.
Phrase mappings, both directions (the interface shows the phrase, the file stores the rule):
| Phrase | Rule |
|---|---|
| hver dag / daily | FREQ=DAILY |
| hverdage / weekdays | FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR |
| hver mandag / every monday | FREQ=WEEKLY;BYDAY=MO |
| hver 2. uge / every 2 weeks | FREQ=WEEKLY;INTERVAL=2 |
| den 1. i måneden / monthly on the 1st | FREQ=MONTHLY;BYMONTHDAY=1 |
| årligt / yearly | FREQ=YEARLY |
Completing a repeating task moves it; it does not close it
The same row keeps its id and its due date advances to the next occurrence. Its sub-tasks, comments, attachments and reminders stay attached, because a weekly review is one thing that recurs, not fifty-two things.
The completion is still recorded — write an activity row and, if you want
"what did I finish this week" to include chores, a completion log entry — but
completed_at on the task goes back to NULL at the new occurrence.
If COUNT or UNTIL is exhausted, the task completes normally and stays completed.
9. The filter query language
A saved filter is a small expression. Write a proper lexer, a recursive-descent
parser producing an AST, and a compiler from the AST to a parameterised SQL
WHERE clause. Never build SQL by string concatenation of user input.
Terms
| Term | Matches |
|---|---|
today, i dag |
due today (reader's zone) |
tomorrow, i morgen |
due tomorrow |
overdue, forsinket |
due before today, not completed |
7 days, 14 dage, 2 weeks, 2 uger |
due between now and then — a window, not a single day |
no date, ingen dato |
no due date |
p1…p4 |
that priority |
#Firma |
in that project |
@regnskab |
carrying that label (yours, §5) |
assigned to: me, tildelt: mig |
assigned to you |
assigned to: anders@example.dk |
assigned to that person |
assigned to: none |
assigned to nobody |
recurring, gentagen |
has a recurrence rule |
completed, færdig |
already done |
subtask, underopgave |
has a parent |
2026-12-24 |
due on that exact day |
| anything else | free text, searched in title and description |
Operators
& = and, | or , = or, ! = not, ( ) = grouping.
& binds tighter than |. So overdue | today & p1 parses as
overdue | (today & p1).
Errors
A parse failure names the part it could not read, with the offset:
{"code":"validation_error","fields":{"query":"could not read \"assigned\" at 12"}}.
10. Search, and why Danish needs a second column
Two layers.
- FTS5,
unicode61 remove_diacritics 2, ranked with bm25, title weighted ten times the body. A word in the title is what the thing is about; the same word once in a long body is a mention. - The folded column.
remove_diacriticshandlesé→e. It does nothing forø→o,æ→ae,å→aa, because Unicode does not considerøan accentedo— it is its own letter. Danes on a foreign keyboard typegronandaben, and a Danish-first app that cannot findgrønfromgronis broken for its own users. So the generatedfoldcolumn carries the transliteration and is indexed alongside. Fold the query the same way before searching.
Search matches tasks and notes, returns them in one response under separate keys, and is reachable from ⌘K.
11. Notes
The shape
id, project_id (nullable), title (derived), body (Markdown),
created_by, pinned, archived_at, deleted_at, created_at, updated_at
Markdown in a column, not rich text and not a format of your own. Export was a requirement before the first line was written, and if the stored form is the exported form there is no conversion left to lose anything in. A note stays readable by any editor on the day the program is gone.
The cost is real and you accept it deliberately: everything the editor can do has to survive a round trip through Markdown, and anything Markdown cannot say, the editor must not offer. There is no place to hide a feature.
The title is the first line, re-derived on every save — not "when it is empty".
Strip leading # marks and whitespace, cap at 120 runes. No caller may set it; it
is a cache of the body, not a field beside it. Derived once and then left alone, a
title goes stale the moment somebody rewrites the opening of a note.
That forces one editor rule: a new note opens on its title, and the first line is styled as a heading. Otherwise a note whose first line is a sentence is a note named by its first sentence.
Archive and trash are two different bits. The trash says this was a mistake, and in thirty days it is gone. The archive says this is finished, and I do not want to read past it. Both are nullable timestamps, not booleans: when was this put away answers questions a flag cannot, at the same cost.
Filing: no hierarchy of its own
A note belongs to a project, or to nobody. Projects already exist, are already shared, already have roles, already have a trash — a second hierarchy to file things in is one too many. A note in a project is readable by everyone who can read the project; a loose note is its author's.
Links, parsed out of the text
The body is the truth. note_links is an index over it, torn down and rebuilt on
every save, never diffed — a diff is a second description of the same fact, and
it can disagree with the text.
project (?:^|\s)#([\p{L}\p{N}_-]{1,64}) stored lowercased; it is a key, not a label
note \[\[([^\]\n]{1,200})\]\] by title
task (?:/opgave/|task:)([0-9a-fA-F-]{36})
#Firma in a note means the same project as #Firma in the quick-add box, not
a second kind of tag that happens to look alike. target_id is deliberately not
a foreign key: a link to something deleted is a fact about the note; it should
read as a dead link rather than vanish, and it must not block the delete.
The index exists so the question can be asked backwards — what has been written about this task? — which is why a task's detail pane can list the notes that mention it without reading every note's prose.
Sharing a note directly, and what follows it
Besides the project path, a note's owner may share it with named people at
viewer or editor (note_shares). Access to a note is the most that any path
allows.
The notes a shared note links to are shared with it. A [[link]] in a shared
note that leads nowhere is the single most-reported thing about this feature, and
"the link is right there and does nothing" is worse than no link. So:
note_shares.linked = 0means somebody chose this share.linked = 1means it was derived from a link. The distinction is the whole design: the interface can say why a person has a note, and the derived rows can be recomputed from scratch after every change without touching the chosen ones.- After any of the owner's own actions — sharing, unsharing, a save that changed
the note's links or title, a delete — recompute: walk
[[links]]transitively from every note the owner has directly shared, resolving each title only among notes that owner owns, and rewrite alllinked = 1rows for that owner. Cap the walk at 100 notes. The role is inherited from the note you arrived from, and the stronger role wins when a note is reachable two ways. - Only the owner's own notes follow, and only the owner's own saves recompute.
This is not an optimisation. A link points at a title, and an editor may write
titles — if a link could fetch anything, somebody you shared a note with could
type
[[Bank details]]into it and read along. - Following a link that resolves to nothing must not fall back to "the first search result". In a shared note the first result is almost always the note you are already standing in, because it contains the title it links to — so the link appears to do nothing at all. Match the title exactly or say so: "That note does not exist, or it is not shared with you."
The editor: Markdown ↔ HTML
The editor is contenteditable rich text; the file is Markdown. Two functions,
markdownToHtml and htmlToMarkdown, are the seam, and they must agree exactly
or a note changes shape a little every time it is opened and saved. Write a
round-trip test over a document using every construct below and assert
md === htmlToMarkdown(markdownToHtml(md)).
| Style | Markdown |
|---|---|
| Title | # |
| Heading | ## |
| Subheading | ### |
| Body | nothing |
| Bulleted list | - , two spaces per nesting level |
| Numbered list | 1. , and the numbers are not renumbered — a list that starts at 10 starts at 10 |
| Quote | > , and a quote containing blocks keeps them |
| Code block | ``` fenced, with the language after the fence |
| Bold / italic / strikethrough | ** / * / ~~ |
| Underline | <u>…</u> — there is no Markdown for it; inline HTML passes through every reader, and that is better than dropping a style the source program has |
| Inline code | ` |
| Image |  |
| Note link | [[title]] |
Rules the seam has to obey:
- Every line becomes exactly one block. Markdown would normally join consecutive lines into a paragraph; here it must not. Somebody typed a line and expects to get it back, and an editor that silently reflows what was written is one they stop trusting.
- An empty line is still a line — render it as a block containing
<br>, or the browser collapses it and the caret has nowhere to stand. - Escape before you convert, and lift out what must not be touched. Images and
note links are pulled out into placeholders before escaping, then put back
after — otherwise the
alttext gets escaped twice and a note containing "Coffee & tea" grows an&every time it is opened. - Only your own attachment URLs become images. An
from pasted text would fetch from a foreign host when the note is opened and tell it the note has been read. A note is the last place anyone wants a tracking pixel. Everything else stays as the text it is. - A note link renders as an atomic
contenteditable="false"element carrying the title in a data attribute, so it moves and deletes as one thing. - The toolbar's
</>button is inline code, because that is what the symbol means in every other program. The Markdown source view gets its own button and its own icon. Do not put the source toggle on</>. - Insert inline code by building a DOM node, not with
document.execCommand('insertHTML'): Chromium sanitises that through the paste path and turns<code>into a<span>with the font written into it, which the converter cannot write back down as code.
Import and export
Export: a zip of .md files, one per note, filename from the title, collisions
suffixed. Import: a zip of .md, each file becoming one note, first line becoming
the title. Round-tripping an export through an import must produce the same notes.
12. Realtime, feeds and background work
WebSocket
GET /api/v1/ws, authenticated by the session cookie. One hub, keyed by user id,
several connections per user. When a task, project, section, note or comment
changes, broadcast a small envelope to everybody who can see it:
{"type": "task.updated", "project_id": "…", "id": "…", "at": 1788800000}
The envelope carries no content. The client refetches what it needs. A payload that carried the row would need the same permission filtering as the endpoint, in a second place — and the second place is the one that gets it wrong.
Ping every 30 seconds, drop a connection that misses two. The client reconnects with backoff and refetches the current view on reconnect, because it has no idea what it missed.
ICS feed
GET /ics/{token} where the token is users.ics_token — a secret path, no cookie,
because a calendar client cannot log in. Return text/calendar with one VEVENT
per task with a due date (VTODO support in calendar clients is patchy; events
show up everywhere). All-day for a date, timed for a datetime with duration_min
as the length. Include RRULE for recurring tasks. The token can be rotated, which
invalidates the old URL.
Jobs
One goroutine per job, all stopped on shutdown by a context.
| Job | Every | Does |
|---|---|---|
| reminders | 60 s | finds due, unsent reminders; writes a notification; marks sent |
| backups | daily, 03:00 local | VACUUM INTO a timestamped file in /data/backups, keeps 14, records a backup_runs row |
| cleanup | hourly | expired sessions, expired invites, resets, trash older than 30 days |
VACUUM INTO and not a file copy: copying verdande.db while the server runs
gives you a database missing its most recent writes, because the -wal file is
part of it.
13. The web interface
SvelteKit with adapter-static, ssr = false, prerender = false, a single
fallback page. It is an SPA served by the Go binary, which serves the built
assets and rewrites any unknown non-/api path to the fallback so deep links work.
Routes
| Path | What is there |
|---|---|
/ |
Today — overdue first, then due today, then what you finished today |
/upcoming |
the next N days (choosable 1–30), one section per day, empty days visible |
/projekt/[id] |
one project, in list, board or calendar mode |
/gruppe/[id] |
a project group, and the projects in it |
/opgave/[id] |
one task, full page (the same content as the detail pane) |
/etiket/[navn] |
everything with that label |
/filter/[id] |
a saved filter's result |
/uddelegeret |
what you have delegated, grouped by person |
/faerdige |
recently completed |
/kalender |
a week grid; tasks with times are blocks, all-day tasks a strip |
/noter |
the notes list on the left, the editor on the right |
/genveje |
the keyboard shortcut reference |
/indstillinger |
account, appearance, language, timezone |
/indstillinger/notifikationer, /tokens, /data, /brugere, /historik, /fejl |
settings sub-pages; the last three admin-only |
/invite |
accept an invite: name and a password you choose |
/reset |
set a new password from a reset link |
The shell
A sidebar (Today, Upcoming, Delegated, Inbox, Notes, Calendar; then filters, project groups with their projects, then labels), a top bar with search and the theme toggle, and the view. The sidebar's groups fold, and the fold state persists per user. The whole sidebar collapses on a narrow screen.
Quick add, everywhere
Press any letter when nothing else has focus and you are writing a task — with the letter you pressed already in the field. The thought does not have to survive a journey to find somewhere to put it. It must not fire when a field, a button or a link has focus; a key pressed at a field belongs to that field.
Under the field, a line naming what the parser reads (# project, / section, @
label, p1 priority, an example date) — shown while you type, which is the one
moment a placeholder cannot help.
The recognised parts are tinted underneath the text as you type, from
/tasks/quick-add/preview, debounced 150 ms.
Keyboard
| Key | Does |
|---|---|
| any letter | starts a quick add |
| ⌘K / Ctrl-K | the command palette: search and navigation |
/ |
focus search |
? |
shortcut reference |
| Escape | close the pane, the palette, or the menu |
| Enter | open the selected task |
| ⌘Enter | save and add another |
| j / k | move the selection down and up |
| e | complete the selected task |
No single-letter navigation shortcuts. One keyboard cannot both spell
tal med Anders and jump to Today on the same t, and capturing is the thing done
fifty times a day. The palette does navigation.
Drag
Reorder within a list and a board, drag a task onto another project in the sidebar,
onto another day in the week grid, and drag a project into a group. Use the HTML5
drag-and-drop API, with a keyboard-accessible alternative in the task's menu
("Move to…"). A drop writes a midpoint sort_order and re-renders optimistically;
a failed request puts it back and toasts.
Themes and looks — two separate axes
Six themes (how bright): dark (default), charcoal, dusk, light,
paper, contrast. Four looks (how it reads): verdande, rolig (serif),
taet (dense), terminal (monospace). They are separate settings because they are
separate questions — somebody who wants a serif face at night should not have to
give up the dark palette to get one, and two axes give twenty-four appearances from
ten small blocks of CSS.
Both are applied as data-theme / data-look on <html> before first paint,
from a tiny inline script in app.html reading localStorage. Applied afterwards,
the page is laid out once in one face and again in another, and the flash is the
thing people notice.
Colours are CSS custom properties on :root, redefined per theme. Components never
name a colour.
Language
t('key', vars) reading locales/da.js and locales/en.js, which must have
identical key sets — add a test that fails on a key present in one and missing
from the other. Default da, following the account's locale. No user-visible
string outside the locale files.
PWA
A manifest.webmanifest with icons at 192 and 512 plus an apple-touch-icon, and
a service worker that caches the shell and the built assets, serves them offline,
and never caches /api. A stale API response is worse than no response.
Accessibility, and the traps that cost real bugs
- Every icon-only button has an
aria-label. - The toolbar of a rich-text editor must
preventDefaultonmousedown, or the click moves focus out of the editor, the selection collapses first, and the format lands on nothing — the button appears to do nothing at all. - Safari ignores
autofocuson anything inserted after the page has loaded. Any inline form that opens on a click must call.focus()itself, or on Safari it opens with no cursor and what you type goes nowhere. It does not look like a bug; it looks like the feature does not work. - Contrast at least 4.5:1 in every theme.
14. Configuration
Environment variables only. No config file — the deployment target has nowhere to put one that the operator would ever see.
| Variable | Default | Does |
|---|---|---|
VERDANDE_BASE_URL |
http://localhost:8080 |
the public address; invite links, resets and feeds are built from it |
VERDANDE_ADDR |
:8080 |
listen address |
VERDANDE_DATA_DIR |
/data |
database, files, backups |
VERDANDE_SESSION_TTL |
720h |
|
VERDANDE_INVITE_TTL |
168h |
|
VERDANDE_RESET_TTL |
1h |
|
VERDANDE_SMTP_HOST |
— | blank disables mail; invite links are shown in the app instead |
VERDANDE_SMTP_PORT |
587 |
|
VERDANDE_SMTP_USER / _PASS / _FROM |
— / — / verdande@localhost |
|
VERDANDE_SMTP_STARTTLS |
true unless port 465 |
465 is TLS from the first byte |
VERDANDE_DEV |
false |
human-readable logs at debug level |
/data holds verdande.db, files/ and backups/. Back up the whole directory.
15. Logging and error handling
Structured JSON via log/slog (text when VERDANDE_DEV). One line per request:
method, path without its query string (tokens live in query strings), status,
byte count, duration, remote address, request id. A request id is generated per
request and returned in X-Request-Id.
Every 5xx also writes an error_log row, so an administrator can read what went
wrong after a restart without shell access. That page is /indstillinger/fejl.
A panic in a handler is recovered, logged with the stack, and answered 500 with the request id. One broken handler must never take the process down.
16. Build, test, ship
go test ./... # everything
go test -race ./... # what CI runs
gofmt -l . # must print nothing
# development: two processes
VERDANDE_DATA_DIR=./data VERDANDE_DEV=true go run ./cmd/verdande
cd web && npm install && npm run dev # Vite on 5173, proxying /api to 8080
# one binary, the way it ships
cd web && npm run build && cd ..
cp -r web/build cmd/verdande/webbuild
go build -tags embedweb -o verdande ./cmd/verdande
The frontend is embedded only under -tags embedweb, so a clean checkout
compiles with no Node toolchain installed. The Vite dev server proxies /api to
8080 rather than calling it cross-origin, so the session cookie stays first-party
exactly as it is in production.
Dockerfile: a multi-stage build — Node builds web/, Go builds the binary with
CGO_ENABLED=0 -tags embedweb, and the final stage is gcr.io/distroless/static
or alpine with just the binary and a VOLUME /data.
17. Definition of done — the acceptance tests
You are finished when every line below passes, and not before. Write these as real tests: Go tests for the first three groups, Playwright specs for the last. Run them. If one fails, fix the program, not the test.
A. Unit — the parser (internal/quickadd)
Table-driven, evaluated against a fixed "now" of Monday 16 March 2026, 08:00,
Europe/Copenhagen, with projects Firma, Q3 report and labels regnskab,
tax known. The parser takes "now" as an argument; a parser that reads the clock
itself cannot be tested and you will not know it is wrong until March.
| Input | content | due | time | pri | project | labels | rrule |
|---|---|---|---|---|---|---|---|
betal moms i morgen kl 10 p1 #Firma @regnskab |
betal moms | 2026-03-17 | 10:00 | 1 | Firma | regnskab | — |
file the VAT return tomorrow at 10am p1 #Accounts @tax |
file the VAT return #Accounts | 2026-03-17 | 10:00 | 1 | Inbox | tax | — |
ring til Anders on friday kl 14 #Firma |
ring til Anders | 2026-03-20 | 14:00 | 4 | Firma | — | — |
køb mælk |
køb mælk | — | — | 4 | Inbox | — | — |
standup hverdage kl 9 |
standup | 2026-03-16 | 09:00 | 4 | Inbox | — | FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR |
review every 2 weeks |
review | 2026-03-16 | — | 4 | Inbox | — | FREQ=WEEKLY;INTERVAL=2 |
betal husleje den 1. i måneden |
betal husleje | 2026-04-01 | — | 4 | Inbox | — | FREQ=MONTHLY;BYMONTHDAY=1 |
møde 15/3 |
møde | 2027-03-15 | — | 4 | Inbox | — | — |
møde 15/4 |
møde | 2026-04-15 | — | 4 | Inbox | — | — |
deadline 2026-12-24 !!! |
deadline | 2026-12-24 | — | 1 | Inbox | — | — |
opgave fredag |
opgave | 2026-03-20 | — | 4 | Inbox | — | — |
opgave næste fredag |
opgave | 2026-03-27 | — | 4 | Inbox | — | — |
rapport #"Q3 report" om 3 dage |
rapport | 2026-03-19 | — | 4 | Q3 report | — | — |
kl 3 vågn op |
vågn op | 2026-03-16 | 03:00 | 4 | Inbox | — | — |
wake up at 3 (locale en) |
wake up | 2026-03-16 | 15:00 | 4 | Inbox | — | — |
Two rows carry most of the weight. Line 2: #Accounts names nothing, so the task
lands in the Inbox and the text stays in the title. And the fredag /
næste fredag pair: fredag is the next Friday to come round (20 March), while
næste fredag is the Friday of next week (27 March). A parser that treats them
as synonyms passes every other row in this table and is still wrong.
B. Unit — the filter language (internal/filterquery)
today & p1parses toAnd(Today, Priority(1)).overdue | today & p1parses toOr(Overdue, And(Today, Priority(1)))— precedence, not left-to-right.(overdue | today) & p1parses toAnd(Or(...), Priority(1)).7 daysis a window from now to now+7d, not a single day.!@venternegates.assigned to: mecompiles with the reader's id as a bound parameter.#Firma & @regnskab & !completedcompiles to one parameterised statement with no user text in the SQL string. Assert that: take the generated SQL and check it contains no substring of the input.today &fails with an error naming offset 6.
C. Integration — the API (internal/httpapi, real HTTP, a temp database)
GET /auth/setupsays needed on an empty database;POSTcreates the first admin; a secondPOSTreturns 409.POST /auth/loginwith the wrong password returns 401 and takes measurably as long as the right one (no user-enumeration by timing or by message).- An unauthenticated request to any
/api/v1path returns 401 withcode: "unauthorized". - A cookie-authenticated
POSTcarryingSec-Fetch-Site: cross-siteis 403. - A task created without a project lands in the caller's Inbox.
- Person B, not a member, gets 404 — not 403 — for person A's project, task and note, by id.
- B added as
viewercan read the project and comment, and gets 403 creating a task in it. Promoted toeditor, the same request succeeds. - Completing a task with
FREQ=WEEKLY;BYDAY=MOkeeps the same id, sets the due date seven days on, and leavescompleted_atnull. Its sub-tasks and comments are still attached. - Reordering three tasks and reading them back returns the new order; moving one
between two others writes a
sort_orderstrictly between theirs. - Deleting a project soft-deletes it; it appears in the trash and restores with its tasks.
- Deleting the account that created a task in a shared project leaves the task
with
created_bynull, and the project's activity intact. - Searching
gronfinds a task calledgrøn maling. Searchingabentfindsåbent hus. GET /notesreturns notes with an emptybodyand a non-emptypreview;GET /notes/{id}returns the whole body.- Saving a note whose body is
# Titel\nse [[Anden]] og #Firmawrites exactly twonote_linksrows,note→Andenandproject→firma(lowercased). Rewriting the body without the[[Anden]]leaves exactly one. - A note's title is re-derived from the first line on every save, and a
PATCHthat tries to settitleis ignored. - Sharing note X, which contains
[[Y]]and where Y contains[[Z]], with person B gives B access to X, Y and Z, and not to unlinked note W. The share response names Y and Z. Unsharing X removes all three. - B, an editor on shared note X, writes
[[Bank details]]into X. B still gets 404 on the owner'sBank detailsnote. GET /ics/{token}returnstext/calendarwith aVEVENTper dated task and anRRULEon the recurring one; a rotated token 404s the old URL.- A note round-trips through
GET /export/notes.zipandPOST /notes/importunchanged, byte for byte in the body. GET /healthzanswers 200 without touching the database.
D. End-to-end (Playwright, against the real built binary)
Run these against the binary with the frontend embedded, not against the Vite dev server. Two bugs that survived longest in the original were a route written outside the routes tree and a manifest promising files that were never generated; neither is visible to a Go test or a Vite build.
- Create the first account, land in the app, see Today.
- Type
betal moms i morgen kl 10 p1 #Firmainto the quick add and see the task appear with the date, the priority and the project shown on it. - Press a letter with nothing focused; the quick add opens containing that letter.
- ⌘K opens the palette; typing a project name and pressing Enter navigates there.
- Create a project, a section, and a task inside the section; reload; it is still there in the same place.
- Drag a task from one section to another; reload; it stayed.
- Complete a recurring task; the row stays in the list with next week's date.
- Invite a second person from Settings → Users, accept the invite in a second browser context with a self-chosen password, share a project, and see a change made by one appear in the other without a reload.
- Write a note with a heading, a bulleted list, a quote, a fenced code block, bold and inline code; blur; reload; the rendering is identical, and the source view shows the Markdown you would expect.
- Type
[[in a note, choose another note from the suggestions, save, reload, click the link, and land on that note. - Share a note that links to another; in the second account, follow that link and arrive. Click a link to a note that does not exist and read the message saying so — the app must not navigate anywhere.
- Select a word, press the
</>button, and get inline code — the note must not switch to source view. - Switch theme and look; reload; both persist and neither changed the other.
- Switch the language to English and back; no untranslated key appears on screen.
- The console logs nothing at
errorlevel and no API request returns ≥400 during any of the above (a 401 from/auth/mewhile signed out excepted).
18. Default decisions — apply these instead of asking
| If you are unsure… | Do this |
|---|---|
| a field's type | prefer the narrowest that holds the value; text ids, integer times |
| whether to add a column or a table | a column, until a row would need more than one of them |
| a name in the API | snake_case in JSON, camelCase in JS, PascalCase in Go |
| a name in the UI | Danish, in the locale file, matching the route names in §13 |
| what a list returns when empty | {"tasks": []}, 200 — never 404 |
| pagination | none below 500 rows; above that, ?limit=&cursor= with an opaque cursor |
| whether an endpoint should exist | if §6 does not list it, it does not exist |
| how to handle an unknown field in a request body | ignore it |
| how to handle a missing optional field in a PATCH | leave the column alone; null explicitly clears it |
| the status code for a successful create | 201 with the created object |
| the status code for a successful delete | 204, no body |
| whether an action should be undoable | if it destroys content, yes — soft delete plus an undo toast |
| an animation's duration | 120 ms, ease-out, and none at all under prefers-reduced-motion |
| a spacing or a size | a 4 px scale, exposed as custom properties |
| whether to show a spinner | not under 300 ms; show the old content until then |
| what happens on a failed write in the UI | roll back the optimistic change and toast the reason |
| an error message's wording | say what happened and what to do; never "an error occurred" |
| whether to log something | log the unexpected; never log a token, a password, a session or a query string |
| whether to add a dependency | no (see §2) |
| whether to add a test | if the behaviour is described in §17, yes; if it is a trap you just hit, yes |
| whether to write a comment | only if it records why; delete any comment that restates the code |
| a commit message | Conventional Commits, imperative, body explaining the reason |
19. Order of work
Do these in order. Finish each stage — including its tests — before starting the next. At the end of every stage the program must build, run, and be usable for what that stage added.
Stage 1 — the spine. go.mod, config, store with migrations and the pragmas,
UUIDv7, the Tx helper, main.go, /healthz, structured logging, the error
envelope, graceful shutdown. Nothing user-facing. Test: the database opens,
migrates, and a second open is a no-op.
Stage 2 — accounts. users, sessions, invites, resets, Argon2id, the cookie, CSRF, rate limiting, setup/login/logout/me, the inbox created with the account. Tests C1–C4.
Stage 3 — the core objects. projects, groups, sections, tasks, sub-tasks, labels, ordering, move, complete/reopen, comments, attachments, the permission function, activity. Tests C5–C11.
Stage 4 — the two hard parsers and the views. quickadd, filterquery,
recurrence, search with folding, /today, /upcoming, /delegated, /search,
saved filters. Tests A, B, C12.
Stage 5 — notes. the model, links, sharing including the derived shares, the Markdown seam, import and export. Tests C13–C19.
Stage 6 — the interface. SvelteKit, the shell, every route in §13, quick add with live tinting, the task pane, the note editor, drag, themes and looks, i18n, the palette, keyboard, PWA. Tests D1–D15.
Stage 7 — realtime and jobs. the WebSocket hub, reminders, backups, cleanup, the ICS feed. Test C18 and D8.
Stage 8 — shipping. the Dockerfile, -tags embedweb, the README with the
configuration table, and a final full run of go test -race ./..., gofmt -l .
and the Playwright suite.
20. What to report at the end, and only at the end
One message. In it:
- The stages you completed, and any acceptance test in §17 that does not pass, named, with the reason. Do not claim a test passes without having run it.
- Anything you decided under §18, and which rule you applied.
- The exact commands to build and run it.
- Nothing else — no summary of the architecture you were handed, no list of files you created, no offer to continue.