diff --git a/REBUILD-VERDANDE.md b/REBUILD-VERDANDE.md new file mode 100644 index 0000000..8fb8b36 --- /dev/null +++ b/REBUILD-VERDANDE.md @@ -0,0 +1,1537 @@ +> **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: + +```json +{ "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. + +```sql +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 `CASCADE`s + 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$$ +``` + +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=§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: + +```json +{ "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. + +### 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 = 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 | `…` — 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/)` | +| 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 `
`, 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 `&` 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 `` into a `` 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: + +```json +{"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 `` **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 + +```bash +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.