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>
1538 lines
73 KiB
Markdown
1538 lines
73 KiB
Markdown
> **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$<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=§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 | `<u>…</u>` — there is no Markdown for it; inline HTML passes through every reader, and that is better than dropping a style the source program has |
|
||
| Inline code | `` ` `` |
|
||
| Image | `` |
|
||
| Note link | `[[title]]` |
|
||
|
||
Rules the seam has to obey:
|
||
|
||
- **Every line becomes exactly one block.** Markdown would normally join
|
||
consecutive lines into a paragraph; here it must not. Somebody typed a line and
|
||
expects to get it back, and an editor that silently reflows what was written is
|
||
one they stop trusting.
|
||
- **An empty line is still a line** — render it as a block containing `<br>`, or
|
||
the browser collapses it and the caret has nowhere to stand.
|
||
- **Escape before you convert, and lift out what must not be touched.** Images and
|
||
note links are pulled out into placeholders *before* escaping, then put back
|
||
after — otherwise the `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 ``
|
||
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:
|
||
|
||
```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 `<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
|
||
|
||
```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.
|