Files
verdande/REBUILD-VERDANDE.md
T
kristianwindandClaude Opus 5 861820b785 docs: byggeprompten, så den kan hentes uden en terminal
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>
2026-09-10 13:14:52 +02:00

73 KiB
Raw Blame History

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. No placeholders. No TODO, no panic("not implemented"), no stubbed handler that returns an empty object. If a feature is in Stage 1–5, it works.
  6. No new dependencies beyond the list in §2. Standard library first.
  7. Write the code in the language and shape given in §2. Do not substitute a framework you prefer.
  8. 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/httpapi handlers 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-DD string.
  • A task can have a due date (due_date, a day) or a due datetime (due_datetime, an instant, plus due_timezone naming the zone it was written in). due_date is always set when either is; due_datetime is the refinement. "Due Tuesday" and "due Tuesday at 10" must both be answerable by one index on due_date.
  • Every user row carries a timezone (IANA name, default Europe/Copenhagen). "Today" is always computed in the reader's zone, never the server's.
  • The API speaks RFC 3339 for instants and YYYY-MM-DD for 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_by is ON DELETE SET NULL everywhere, not CASCADE. Removing an account must not remove the shared project's tasks with it. The only CASCADEs are the ones where the child is meaningless without the parent: a task's labels, a project's sections, a session's user.
  • fold is 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_session when serving over HTTPS, verdande_session otherwise. The __Host- prefix ties the cookie to the exact host with no Domain, which is what stops a neighbouring subdomain from setting one.
  • HttpOnly, Secure when HTTPS, SameSite=Lax, Path=/.
  • SameSite=Lax and not Strict: Strict would mean following an invite link from an email lands you logged out.
  • Lifetime from VERDANDE_SESSION_TTL, default 720 h. last_seen_at is 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=&section=&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

  1. Both languages are read at once, not behind a setting. People mix them mid-sentence: ring til Anders on friday kl 14 #Arbejde must work.
  2. The only thing the locale decides is a bare hour after English at: at 3 means 15:00, while Danish kl 3 means 03:00. Danish has no am/pm habit and means the 24-hour clock.
  3. Numeric dates are day-first. 15/3 is 15 March. Guessing month-first moves a date by months silently instead of failing visibly.
  4. A date in the past that was written without a year rolls forward to the next occurrence. 15/3 typed in April 2026 means 15 March 2027.
  5. A bare clock time with no day means today, even if it has already passed: kl 3 at 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.
  6. A recurrence with no date of its own starts today. review every 2 weeks typed on the 16th is due the 16th.
  7. 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.
  8. 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.
  9. A #project that names nothing goes to the Inbox and stays in the title — it was a word beginning with a hash, not a reference.
  10. Matching is case-insensitive and diacritic-tolerant on both sides.
  11. Order does not matter. p1 i morgen betal moms is the same task.
  12. 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.

  1. 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.
  2. The folded column. remove_diacritics handles é→e. It does nothing for ø→o, æ→ae, å→aa, because Unicode does not consider ø an accented o — it is its own letter. Danes on a foreign keyboard type gron and aben, and a Danish-first app that cannot find grøn from gron is broken for its own users. So the generated fold column 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.

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 = 0 means somebody chose this share. linked = 1 means 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 all linked = 1 rows 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 ![alt](/api/v1/attachments/<id>)
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 alt text gets escaped twice and a note containing "Coffee & tea" grows an &amp; every time it is opened.
  • Only your own attachment URLs become images. An ![](http://elsewhere/…) 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 preventDefault on mousedown, 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 autofocus on 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 & p1 parses to And(Today, Priority(1)).
  • overdue | today & p1 parses to Or(Overdue, And(Today, Priority(1))) — precedence, not left-to-right.
  • (overdue | today) & p1 parses to And(Or(...), Priority(1)).
  • 7 days is a window from now to now+7d, not a single day.
  • !@venter negates.
  • assigned to: me compiles with the reader's id as a bound parameter.
  • #Firma & @regnskab & !completed compiles 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)

  1. GET /auth/setup says needed on an empty database; POST creates the first admin; a second POST returns 409.
  2. POST /auth/login with the wrong password returns 401 and takes measurably as long as the right one (no user-enumeration by timing or by message).
  3. An unauthenticated request to any /api/v1 path returns 401 with code: "unauthorized".
  4. A cookie-authenticated POST carrying Sec-Fetch-Site: cross-site is 403.
  5. A task created without a project lands in the caller's Inbox.
  6. Person B, not a member, gets 404 — not 403 — for person A's project, task and note, by id.
  7. B added as viewer can read the project and comment, and gets 403 creating a task in it. Promoted to editor, the same request succeeds.
  8. Completing a task with FREQ=WEEKLY;BYDAY=MO keeps the same id, sets the due date seven days on, and leaves completed_at null. Its sub-tasks and comments are still attached.
  9. Reordering three tasks and reading them back returns the new order; moving one between two others writes a sort_order strictly between theirs.
  10. Deleting a project soft-deletes it; it appears in the trash and restores with its tasks.
  11. Deleting the account that created a task in a shared project leaves the task with created_by null, and the project's activity intact.
  12. Searching gron finds a task called grøn maling. Searching abent finds åbent hus.
  13. GET /notes returns notes with an empty body and a non-empty preview; GET /notes/{id} returns the whole body.
  14. Saving a note whose body is # Titel\nse [[Anden]] og #Firma writes exactly two note_links rows, note → Anden and project → firma (lowercased). Rewriting the body without the [[Anden]] leaves exactly one.
  15. A note's title is re-derived from the first line on every save, and a PATCH that tries to set title is ignored.
  16. 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.
  17. B, an editor on shared note X, writes [[Bank details]] into X. B still gets 404 on the owner's Bank details note.
  18. GET /ics/{token} returns text/calendar with a VEVENT per dated task and an RRULE on the recurring one; a rotated token 404s the old URL.
  19. A note round-trips through GET /export/notes.zip and POST /notes/import unchanged, byte for byte in the body.
  20. GET /healthz answers 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.

  1. Create the first account, land in the app, see Today.
  2. Type betal moms i morgen kl 10 p1 #Firma into the quick add and see the task appear with the date, the priority and the project shown on it.
  3. Press a letter with nothing focused; the quick add opens containing that letter.
  4. ⌘K opens the palette; typing a project name and pressing Enter navigates there.
  5. Create a project, a section, and a task inside the section; reload; it is still there in the same place.
  6. Drag a task from one section to another; reload; it stayed.
  7. Complete a recurring task; the row stays in the list with next week's date.
  8. 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.
  9. 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.
  10. Type [[ in a note, choose another note from the suggestions, save, reload, click the link, and land on that note.
  11. 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.
  12. Select a word, press the </> button, and get inline code — the note must not switch to source view.
  13. Switch theme and look; reload; both persist and neither changed the other.
  14. Switch the language to English and back; no untranslated key appears on screen.
  15. The console logs nothing at error level and no API request returns ≥400 during any of the above (a 401 from /auth/me while 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:

  1. 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.
  2. Anything you decided under §18, and which rule you applied.
  3. The exact commands to build and run it.
  4. Nothing else — no summary of the architecture you were handed, no list of files you created, no offer to continue.