docs(phase6): documentation site, landing page and open-source scaffolding

Material for MkDocs under docs/, covering self-hosting, the Rune, the
quick-add and filter languages, importing from Todoist, CalDAV,
mail-to-task, MCP, the AI layer and the API. The version is pinned: the
maintainers have announced that MkDocs 2.0 removes the plugin system
with no migration path, and an unpinned install would break this build
on the day it lands.

The landing page shows the quick-add parser as actual text rather than a
screenshot of it — that is the thing worth showing, and it reads better
as the real thing. It shares the app's design tokens, so the site and
what it describes look like one project.

MIT licence, contributing guide, security policy and issue templates.
Pages deployment is behind workflow_dispatch, so the docs build on every
push but nothing is published until the owner chooses to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 08:08:36 +02:00
co-authored by Claude Opus 5
parent 6b19ed062d
commit 7c9bde2cfd
25 changed files with 1716 additions and 14 deletions
+67
View File
@@ -0,0 +1,67 @@
# AI features
Optional, off by default, and bring your own key. verdande works completely without
them — this is a convenience on top of a to-do app, not something the app depends
on.
## Setting up a provider
**Settings → AI**. Four kinds:
=== "Anthropic"
- **Provider**: Anthropic
- **Model**: `claude-sonnet-5`
- **API key**: from [console.anthropic.com](https://console.anthropic.com)
=== "OpenAI"
- **Provider**: OpenAI
- **Model**: `gpt-4o`
- **API key**: from platform.openai.com
=== "Google"
- **Provider**: Google
- **Model**: `gemini-2.0-flash`
- **API key**: from Google AI Studio
=== "Your own model"
Anything speaking the OpenAI-compatible shape — Ollama, vLLM, LM Studio, a
company gateway.
- **Provider**: Compatible
- **Base URL**: `http://localhost:11434/v1`
- **Model**: `llama3.1` — whatever you have pulled
- **API key**: usually blank
This is why the abstraction exists at all: an integration that only spoke to a
hosted API would be no use to somebody running their own models.
The key is stored on your account and is **never sent back** to the settings page —
a page that repopulates a password field is one that will eventually leak it into a
screenshot.
## What it can do
**Split a task into sub-tasks.** On any task: *Split with AI*. It suggests two to
seven concrete actions and creates them as sub-tasks. Written in your language.
**A weekly summary.** A short note over what is outstanding: what looks urgent and
what appears to be slipping. At most six lines — it is a prompt to think, not a
report.
## What is sent
Only what the feature needs: for a split, the task's title and notes; for a
summary, the titles, dates and priorities of your open tasks. Never comments,
attachments, or anything from a project the feature was not invoked on.
Nothing is sent to anybody until you configure a provider. With a local model,
nothing leaves your machine at all.
## Turning it off
Clear the model field. Every AI feature reports itself as unavailable rather than
failing, and the rest of the app is unchanged.
+150
View File
@@ -0,0 +1,150 @@
# API
REST under `/api/v1`, JSON in and out. Everything the web interface does goes
through it, so anything you can do there you can script.
## Authenticating
Two ways, accepted everywhere.
**A session cookie**, which the web interface uses. `httpOnly`, so nothing but the
browser handles it.
**A personal API token**, which is what scripts use:
```bash
curl -H "Authorization: Bearer vrd_..." https://todo.example.dk/api/v1/tasks
```
Make one under **Settings → API tokens**. It carries your permissions exactly —
there is no separate service identity.
## Errors
Every failure has the same shape:
```json
{
"code": "validation_failed",
"error": "one or more fields are not valid",
"fields": { "due_date": "must be a date like 2026-03-15" }
}
```
`code` is stable and meant to be matched on. `error` is English prose for a log —
what a person reads is decided by the client, which knows what language they are in.
| Code | Status | Means |
|---|---|---|
| `bad_request` | 400 | The body could not be read. |
| `unauthorized` | 401 | Not signed in, or the token is wrong. |
| `totp_required` | 401 | The login still needs its verification code. |
| `forbidden` | 403 | Cross-site request, or admin needed. |
| `not_found` | 404 | Does not exist, or you cannot see it. |
| `conflict` | 409 | Already exists, or the state does not allow it. |
| `validation_failed` | 422 | Check `fields`. |
| `rate_limited` | 429 | Too many attempts. |
| `internal_error` | 500 | Our fault. |
!!! note "404 rather than 403"
Anything you may not see answers 404. A 403 would confirm the thing exists,
which is what somebody trying ids is hoping to learn.
## The endpoints
### Tasks
| Method | Path | |
|---|---|---|
| `GET` | `/tasks` | List. Filter with `project_id`, `label_id`, `priority`, `due_before`, `due_from`, `no_date`, `assignee_id=me`, `q`, `completed=include\|only`. |
| `POST` | `/tasks` | Create. |
| `POST` | `/tasks/quick-add` | Create from one line of natural language. |
| `GET` | `/tasks/quick-add/preview?text=` | Parse without saving. |
| `GET` | `/tasks/{id}` | One task. |
| `PATCH` | `/tasks/{id}` | Change it. Only the fields you send. |
| `DELETE` | `/tasks/{id}` | To the trash, recoverable for 30 days. |
| `POST` | `/tasks/{id}/complete` | Tick off. A repeating task advances instead. |
| `POST` | `/tasks/{id}/reopen` | Un-tick. |
| `POST` | `/tasks/{id}/move` | Reorder: `{"after_id": …, "before_id": …}`. |
```bash
curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \
-H "Authorization: Bearer vrd_..." \
-H "Content-Type: application/json" \
-d '{"text": "betal moms i morgen kl 10 p1 #Firma @regnskab"}'
```
### Projects and sections
| Method | Path | |
|---|---|---|
| `GET` `POST` | `/projects` | List, create. |
| `GET` `PATCH` `DELETE` | `/projects/{id}` | Read, change, trash. Owner only for the last two. |
| `GET` `POST` | `/projects/{id}/sections` | |
| `PATCH` `DELETE` | `/sections/{id}` | |
| `GET` | `/projects/{id}/members` | |
| `POST` | `/projects/{id}/invites` | Share it. `{"email": …, "role": "editor"\|"viewer"}` |
| `DELETE` | `/projects/{id}/members/{userID}` | |
| `GET` | `/projects/{id}/activity` | |
### Views, filters and labels
| Method | Path | |
|---|---|---|
| `GET` | `/today` | Overdue and due today, in your timezone. |
| `GET` | `/upcoming?days=7` | One entry per day, empty days included. |
| `GET` | `/search?q=` | Across everything you can see. |
| `GET` `POST` | `/filters` | |
| `GET` | `/filters/{id}/tasks` | Run one. |
| `GET` | `/filters/preview?query=` | Run an expression without saving it. |
| `GET` `POST` | `/labels` | |
### Comments and attachments
| Method | Path | |
|---|---|---|
| `GET` `POST` | `/tasks/{id}/comments` | |
| `PATCH` `DELETE` | `/comments/{id}` | Author only for editing. |
| `POST` | `/tasks/{id}/attachments` | `multipart/form-data`, field `file`, 25 MB. |
| `GET` `DELETE` | `/attachments/{id}` | |
### Import and export
| Method | Path | |
|---|---|---|
| `POST` | `/import/todoist` | A Todoist CSV, `multipart/form-data`. |
| `POST` | `/import/csv` | Mapped rows as JSON. |
| `GET` | `/export/account` | Everything, as JSON. |
| `GET` | `/export/projects/{id}.csv` | Todoist-compatible. |
| `GET` | `/export/projects/{id}.ics` | A calendar file. |
### Everything else
| Method | Path | |
|---|---|---|
| `GET` | `/ws` | WebSocket. Live changes to projects you can see. |
| `GET` | `/notifications` | |
| `GET` `POST` | `/feed`, `/feed/rotate` | The [calendar feed](caldav.md) URL. |
| `GET` `POST` | `/mail-address`, `/mail-address/rotate` | The [mail-to-task](mail.md) address. |
| `POST` | `/mcp` | The [MCP](mcp.md) endpoint. |
| `GET` | `/healthz` | Unauthenticated. Reads the database rather than merely answering. |
## Live updates
```javascript
const socket = new WebSocket('wss://todo.example.dk/api/v1/ws');
socket.onmessage = (event) => {
const { type, project_id, payload } = JSON.parse(event.data);
// task.created, task.updated, task.completed, task.deleted,
// task.moved, comment.created, notification, reminder
};
```
One-way by design: nothing a client sends over the socket changes anything. Writes
go through the REST API, where the permission checks are.
## Rate limits
Only where guessing matters: ten login attempts per address per fifteen minutes,
five password-reset requests per hour. The rest of the API is not limited — it is
your server.
+88
View File
@@ -0,0 +1,88 @@
# Calendar and CalDAV
Two ways to get your tasks into a calendar, and they are not the same thing.
| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) |
|---|---|---|
| Direction | Read only | Two-way |
| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ |
| You can tick things off | No | Yes |
| Setup | Paste a URL | Username and token |
## Subscribing to a feed
**Settings → Calendar feed** gives you a URL. Paste it into whatever subscribes to
calendars.
=== "Apple Calendar"
**File → New Calendar Subscription**, paste the URL, and set auto-refresh to
every hour.
=== "Google Calendar"
**Other calendars → + → From URL**. Google refetches on its own schedule,
which can be many hours — this is Google's behaviour and nothing here can
change it.
=== "Thunderbird"
**New Calendar → On the Network → iCalendar (ICS)**.
Tasks go out as `VTODO`. Google ignores those entirely, so tasks with a clock time
are *also* sent as events — which is what makes the feed useful in the calendar
most people actually keep.
!!! warning "The URL is the password"
Anybody with the link can read your tasks. Calendar clients cannot log in, so
there is nothing else it could be. If it ends up somewhere it should not,
**Settings → Calendar feed → Rotate** issues a new one and breaks the old
immediately.
## Connecting a CalDAV client
This is the one where ticking something off in Apple Reminders ticks it off here.
**First, make a token.** **Settings → API tokens → New token**. Copy it — it is
shown once.
Then:
- **Server**: your verdande address
- **Username**: your email address
- **Password**: the API token, **not your account password**
=== "Apple Reminders (macOS/iOS)"
**Settings → Accounts → Add Other Account → CalDAV account**, choose *Manual*,
and fill in the three fields above.
=== "Thunderbird"
**New Calendar → On the Network**, enter your email as the username and the
address as the location. It will find the collections itself.
=== "DAVx⁵ (Android)"
**Add account → Login with URL and username**. Then pick which projects to
sync.
Each project appears as its own calendar, so you can turn them on and off
individually.
!!! note "Why a token and not your password"
A CalDAV client stores what you give it and sends it on every request. A token
can only read and write tasks — it cannot sign in to the app or change your
password — and you can revoke one without touching anything else.
## What syncs
Task titles, notes, due dates and times, priorities, repetition, and completion —
in both directions.
Not sections, labels, sub-task relationships, comments or attachments. CalDAV's
VTODO has nowhere to put them, and inventing an encoding would produce something no
other client could read.
Completed tasks stay in the collection. A client that stops seeing one treats it as
deleted and drops its own record of having done it.
+48
View File
@@ -0,0 +1,48 @@
# Configuration
Everything is an environment variable. There is no config file — a Rune has
nowhere to put one that the operator would ever see.
## Core
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_BASE_URL` | `http://localhost:8080` | The public address. Invite links, password resets, calendar feeds and the mail-to-task address are all built from it. |
| `VERDANDE_ADDR` | `:8080` | Listen address. |
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
## Sessions and links
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. |
| `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. |
| `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. |
Any Go duration: `720h`, `30m`, `45s`.
## Mail
| Variable | Default | What it does |
|---|---|---|
| `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail entirely. |
| `VERDANDE_SMTP_PORT` | `587` | |
| `VERDANDE_SMTP_USER` | — | |
| `VERDANDE_SMTP_PASS` | — | |
| `VERDANDE_SMTP_FROM` | `verdande@localhost` | Sender address. Also decides the domain of the [mail-to-task address](mail.md). |
| `VERDANDE_SMTP_STARTTLS` | `true` unless port 465 | Port 465 is TLS from the first byte; everything else negotiates. |
| `VERDANDE_SMTP_INSECURE` | `false` | Skip certificate verification. For a self-signed internal mail server, and nothing else. |
!!! info "Running without a mail server"
Perfectly supported. Invite links are shown in the app for you to send
yourself, password resets are written to the log, and reminders arrive in the
app and as Web Push rather than by email.
## What is *not* configured here
**AI providers** are per-user, under settings in the app — see [AI](ai.md). One
person's key is not the instance's key.
**The VAPID keypair** for Web Push is generated on first use and stored in the
database. There is nothing to set.
+65
View File
@@ -0,0 +1,65 @@
# Saved filters
A filter is a small expression describing a slice of your tasks. The grammar is
Todoist's, so if you are coming from there you already know it.
```
today & p1
#Firma & @regnskab
overdue | (today & assigned to: me)
7 days & !@venter
```
## What you can write
| Term | Matches |
|---|---|
| `today`, `i dag` | Due today. |
| `tomorrow`, `i morgen` | Due tomorrow. |
| `overdue`, `forsinket` | Due before today. |
| `7 days`, `14 dage`, `2 weeks` | Due between now and then. A window, not a single day. |
| `no date`, `ingen dato` | No due date at all. |
| `p1` … `p4` | That priority. |
| `#Firma` | In that project. |
| `@regnskab` | Carrying that label. |
| `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` | Repeats. |
| `completed`, `færdig` | Already done. |
| `subtask`, `underopgave` | Has a parent. |
| `2026-12-24` | Due on that exact day. |
| anything else | Searched for in the title and description. |
## Combining them
| Operator | Means |
|---|---|
| `&` | Both. |
| `|` or `,` | Either. |
| `!` | Not. |
| `( )` | Grouping. |
`&` binds tighter than `|`, so `overdue | today & p1` means
`overdue | (today & p1)`. Use brackets when you mean otherwise.
## Some that are worth saving
```
today & p1 What has to happen today
overdue What slipped
7 days & !@venter The week, minus what you are blocked on
#Firma & assigned to: me Your part of a shared project
p1 & no date Important, unscheduled — usually a mistake
recurring & overdue Habits you have stopped doing
```
## Why an expression is refused
A filter is compiled before it is saved. A filter that cannot run is worse than
none: you would find out later, from a list that is empty for a reason nobody can
see. If you get an error, it names the part it could not read.
!!! note "Labels are personal"
`@regnskab` matches *your* label of that name. Somebody else using the same
word on their own tasks is a different label, and your filter will not find it.
+66
View File
@@ -0,0 +1,66 @@
# Importing from Todoist
## Getting the file out of Todoist
For one project: open it, **⋯ → Export as template → Export as CSV**.
For everything: **Settings → Backups**, then take the most recent one. That gives
you one CSV per project.
## Bringing it in
**Settings → Import → From Todoist**, then pick the file. One project comes in per
file.
## What comes across
| Todoist | verdande |
|---|---|
| Tasks, in their tree | Tasks, with sub-tasks nested the same way |
| Sections | Sections |
| Notes | Comments |
| Priority | Priority — see below |
| Due dates | Due dates, and recurrence where it can be read |
| Descriptions | Descriptions |
!!! info "Priorities are renumbered on purpose"
Todoist's CSV numbers priorities the opposite way to how its interface shows
them: what you see as P1 is written as `4`. verdande stores what the interface
means, so the two are converted. A P1 in Todoist is a P1 here.
## Dates
Todoist stores what you typed rather than a date — `every Monday`, `tomorrow`,
`15 Mar`. Machine-readable dates come across exactly. Anything else goes through
the same [quick-add parser](quick-add.md) the app uses, which understands a great
deal of it in both languages — including recurrence, so `every Monday` arrives as a
real repeating task.
Anything it genuinely cannot read is listed in the import result rather than
dropped silently.
## What does not come across
**Assignees.** A person in your Todoist workspace is not a person here. The import
lists them so you can reassign.
**Attachments.** Todoist's CSV does not include them; they are links into Todoist's
own storage.
**Reminders and filters.** Not in the export format.
## Any other CSV
**Settings → Import → From a CSV file** takes anything with a header row, and asks
you which column means what. The only one it needs is the task text.
## Getting back out
Nothing here is a one-way door.
- **A project as Todoist CSV** — the same format, so it opens in Todoist.
- **A project as ICS** — a calendar file.
- **Your whole account as JSON** — every project, task, comment, label and filter.
A CSV that goes in and comes back out again is the same project: the round trip is
tested, and two exports of an unchanged project are byte-identical.
+49
View File
@@ -0,0 +1,49 @@
# verdande
Self-hosted tasks and projects, shared with the people you share the work with.
One binary with SQLite inside it — no database to provision, no second process.
**Verdande** is one of the three Norns: the one who spins *that which is becoming*.
Not what has been, and not what is fated to be — the thing you are in the middle
of. Which is what a to-do list is.
## What it is for
Todoist Pro, on your own hardware, with your own data. And with the integrations a
hosted product will not build for you: a real CalDAV server, an address you can
forward mail to, and an MCP endpoint so Claude can work your task list directly.
## What it does
- **[Quick add](quick-add.md) that reads what you type.** `betal moms i morgen kl
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
board and calendar views.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
joins through an invite link.
- **Live sync** — a change made by somebody else appears without a refresh.
- **Repeating tasks** as RRULE, reminders, comments and attachments.
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
open standards rather than private formats.
## Getting it running
```bash
docker run -d --name verdande \
-p 8080:8080 \
-v verdande-data:/data \
-e VERDANDE_BASE_URL=https://todo.example.dk \
ghcr.io/kristianwind/verdande:latest
```
Open the address, create the first account — that account is the administrator.
Running Yggdrasil Panel? [Install it as a Rune](rune.md) instead.
## What it will not do
No karma or points. No team workspaces. No native apps. No mail integration beyond
Gmail and the forwarding address. No languages beyond Danish and English. And no
sync protocol of its own — [CalDAV](caldav.md) and the [API](api.md) *are* the
sync.
+69
View File
@@ -0,0 +1,69 @@
# Mail to task
Forward an email and it becomes a task.
## Your address
**Settings → Mail to task** gives you something like:
```
todo+kJ8xN2pQ...@example.dk
```
The token is in the local part rather than the domain, which is what lets a single
mail alias route it without a wildcard domain or a DNS change per person.
## Using it
Forward anything to it. The **subject becomes the task** and the **body becomes the
description**.
The subject goes through the same [quick-add parser](quick-add.md) as everything
else, so this works:
> **Subject:** Send årsregnskab i morgen p1 #Firma
and arrives as a task called *Send årsregnskab*, due tomorrow, priority 1, in
Firma.
## Routing the mail
verdande does not run a mail server. Something has to hand it the message.
=== "Mailcow"
Make an alias for `todo+*@yourdomain` that pipes to a small script, and have
the script POST the parsed message to:
```
POST https://todo.example.dk/inbound/mail
Content-Type: application/json
{
"to": "todo+TOKEN@example.dk",
"from": "afsender@example.dk",
"subject": "Send årsregnskab i morgen p1 #Firma",
"body": "Vedhæftet er sidste års tal."
}
```
=== "Anything else"
The endpoint takes that JSON from anywhere — a Postfix pipe, a Cloudflare Email
Worker, an IMAP poller. The token in `to` is the whole credential, so no other
authentication is needed and none is accepted.
## Security
The token *is* the credential — anybody who can send mail to that address can
create tasks in your account. It cannot read anything, and it cannot reach anything
else.
An unknown token and a malformed address answer identically, so the endpoint cannot
be used to work out which addresses are live.
**Settings → Mail to task → Rotate** issues a new address and stops the old one
immediately.
!!! tip "Keep the address to yourself"
Treat it like a secret. If you publish it somewhere, rotate it.
+55
View File
@@ -0,0 +1,55 @@
# Claude (MCP)
verdande has a built-in [Model Context Protocol](https://modelcontextprotocol.io)
server, so you can add it to Claude as a connector and work your task list in
conversation.
> *"What's on my plate this week?"*
>
> *"Add a task to call the accountant on Friday, priority 1, in Firma."*
>
> *"What did I not get to that was due last week?"*
## Connecting it
**Make a token.** **Settings → API tokens → New token**. It is shown once.
**Add the connector.** In Claude, add a custom connector with:
- **URL**: `https://todo.example.dk/api/v1/mcp`
- **Authentication**: Bearer token, using the token you just made
!!! warning "It has to be reachable from the internet"
Claude connects from Anthropic's side, not from your browser. An instance only
reachable on your LAN or over a VPN cannot be used this way.
## What Claude can do
| Tool | What it does |
|---|---|
| `list_projects` | List your projects. |
| `search_tasks` | Find tasks — by text, or with the [filter language](filters.md). |
| `create_task` | Create one, from natural language or explicit fields. |
| `update_task` | Change a title, priority, date or project. |
| `complete_task` | Tick one off. |
| `add_comment` | Comment on a task. |
`create_task` prefers natural language, so *"add betal moms i morgen kl 10 p1
#Firma"* goes through the same parser the app uses — dates, priorities, projects,
labels and recurrence in either language.
## What it can reach
**Exactly what you can reach, and nothing more.** A token is a person, and a
person's permissions are already decided: shared projects you are a member of, yes;
somebody else's private projects, no. There is no separate service identity and no
scope to configure.
## What it cannot do
Delete projects, manage members, change settings or read another account. The tools
are the six above; there is no general escape hatch.
!!! note "Revoking it"
**Settings → API tokens**, delete the token. The connector stops working
immediately, and nothing else about your account is affected.
+76
View File
@@ -0,0 +1,76 @@
# Quick add
One line in, a fully-formed task out.
```
betal moms i morgen kl 10 p1 #Firma @regnskab
```
becomes a task called **betal moms**, due tomorrow at 10:00, priority 1, in the
Firma project, labelled regnskab.
The parts it understands are tinted underneath the text as you type, so you can see
it being read before you commit to it.
## What it reads
| You type | It means |
|---|---|
| `#Firma` | Put it in that project. `#"Q3 rapport"` for a name with spaces. |
| `@regnskab` | Add that label. As many as you like. |
| `p1` … `p4` | Priority. `!!!`, `!!` and `!` also work. |
| `i dag`, `i morgen`, `i overmorgen` | Today, tomorrow, the day after. |
| `mandag`, `på fredag` | The next such weekday. |
| `næste mandag` | Next week's Monday, counted from Monday — not merely the next one to come round. |
| `om 3 dage`, `om en uge`, `næste måned` | Relative dates. |
| `15. marts`, `15/3`, `2026-03-15` | Written dates. Day first. |
| `kl 10`, `kl 14:30`, `klokken 16` | A clock time. |
| `hver mandag`, `hverdage`, `hver 2. uge` | Makes it [repeat](#repeating-tasks). |
English works everywhere Danish does: `tomorrow`, `next friday`, `in 3 days`,
`every 2 weeks`, `at 10am`, `weekdays`.
## Mixing the two
```
ring til Anders on friday kl 14 #Arbejde
```
Both languages are read at once rather than behind a setting, because people mix
them mid-sentence. The only thing the language setting decides is a bare hour after
English `at`: `at 3` means three in the afternoon, while Danish `kl 3` means three
in the morning — Danish has no am/pm habit and the 24-hour clock is what people
mean.
## Repeating tasks
```
vand planterne hver mandag
standup hverdage kl 9
betal husleje den 1. i måneden
review every 2 weeks
```
Stored as an RFC 5545 RRULE, so a task exported to Apple Reminders keeps repeating
there.
Ticking off a repeating task **moves it to its next occurrence** rather than
closing it. The same task keeps its id, so its sub-tasks and comments stay
attached — a weekly review is one thing that recurs, not fifty-two things. The
completion is still recorded, so "what did I get done this week" includes the
chores that repeat.
## Dates it cannot read
Anything it does not recognise stays in the title. A task with a clumsy title beats
an error message and a lost thought.
!!! tip "Numeric dates are day-first"
`15/3` is the fifteenth of March. That is how both Danish and British English
write it, and guessing month-first would silently move a date by months rather
than failing visibly.
## The shortcuts
Press ++q++ to jump to the quick-add box from anywhere, ++t++ for Today, ++u++ for
Upcoming, ++cmd+k++ to search, and ++question++ for the full list.
+47
View File
@@ -0,0 +1,47 @@
# Installing as a Rune
verdande ships with a Rune manifest for
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
## Installing
1. **Runes → Carve a rune (upload)** and pick
[`rune/verdande.yaml`](https://github.com/kristianwind/verdande/blob/main/rune/verdande.yaml)
from the repository.
2. Create a server from it.
3. Fill in the settings below.
4. Start it, open the URL, create the first account.
## Settings
| Setting | What to put |
|---|---|
| **Public address** | The address you reach it on. `{{PUBLIC_URL}}` is the default and usually right. |
| **SMTP host** | Your mail server. Leave blank and invite links are shown in the app instead of emailed. |
| **SMTP port** | 587 for STARTTLS, 465 for implicit TLS. |
| **SMTP username / password** | As your mail server expects. |
| **Sender address** | What invites and reminders come from. |
| **Use STARTTLS** | On, unless you are on port 465. |
| **How long a login lasts** | `720h` — thirty days. |
!!! note "Why the public address cannot be guessed"
The host port is not chosen until the server is created, so verdande has no way
to work out its own address. The panel does, and passes it in as
`{{PUBLIC_URL}}`.
## Reaching it from outside
Give the server a **Subdomain** under its settings and enable
**Cloudflare Tunnel (subdomains)** under **Settings → Network**. No port forwarding
and no public IP.
## Backups
The Rune's backup includes the whole `/data` directory, which is what you want —
see [the warning about copying the database alone](self-hosting.md#what-lives-in-data).
## Watchers
The manifest ships log watchers for database errors, repeated failed logins and
HTTP 5xx. They appear under **Settings → Kvasir Watchers** as ordinary per-server
rules you can edit or turn off.
+108
View File
@@ -0,0 +1,108 @@
# Self-hosting
verdande is one static binary and one directory. There is no database server, no
cache, no queue and no second process.
## Docker
```bash
docker run -d --name verdande \
--restart unless-stopped \
-p 8080:8080 \
-v verdande-data:/data \
-e VERDANDE_BASE_URL=https://todo.example.dk \
ghcr.io/kristianwind/verdande:latest
```
`VERDANDE_BASE_URL` has to be the address you actually reach it on. Invite links,
password resets and calendar feeds are all built from it, so getting it wrong
means the links in your email point somewhere that does not answer.
## Docker Compose
```yaml
services:
verdande:
image: ghcr.io/kristianwind/verdande:latest
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/data
environment:
VERDANDE_BASE_URL: https://todo.example.dk
VERDANDE_SMTP_HOST: mail.example.dk
VERDANDE_SMTP_USER: verdande@example.dk
VERDANDE_SMTP_PASS: ${SMTP_PASSWORD}
VERDANDE_SMTP_FROM: verdande@example.dk
```
## Behind a reverse proxy
verdande expects to be terminated by something else. It reads `X-Forwarded-For`,
so rate limiting and the activity log see the real caller rather than the proxy.
=== "Caddy"
```
todo.example.dk {
reverse_proxy localhost:8080
}
```
=== "Cloudflare Tunnel"
```yaml
ingress:
- hostname: todo.example.dk
service: http://localhost:8080
- service: http_status:404
```
The WebSocket needs no special handling; Cloudflare proxies it as-is. A silent
socket is closed after 100 seconds, which is why verdande pings every 30.
!!! warning "Use HTTPS"
Over plain HTTP the session cookie cannot be `Secure`, and verdande will not
use the `__Host-` prefix that stops a subdomain overwriting it. Fine on a
laptop; not fine on anything reachable from outside.
## First run
Open the address and create an account. The first one is the administrator, and
the endpoint that creates it refuses once any account exists — so there is no
window in which somebody else can claim your instance.
Everybody after that joins by invite.
## What lives in `/data`
```
/data
├── verdande.db SQLite, WAL mode
├── files/ attachments, content-addressed
└── backups/ nightly snapshots, 14 kept
```
Back up the whole directory.
!!! danger "Do not copy verdande.db on its own"
It runs in WAL mode, so recent writes live in `verdande.db-wal` until they are
checkpointed. A copy of the `.db` file alone can be missing them. Copy the
whole directory, or take one of the nightly snapshots from `backups/` — those
are written with `VACUUM INTO` and are complete on their own.
## Backups
A snapshot is written once a day and the most recent fourteen are kept. They are
counted rather than aged out, so a container that was off for a month comes back
with its backups intact.
## Updating
```bash
docker compose pull && docker compose up -d
```
Migrations run at startup, each in its own transaction. Take a copy of `/data`
first anyway.
+16
View File
@@ -0,0 +1,16 @@
/* The app's accent, so the docs and the thing they describe look like one project. */
:root {
--md-primary-fg-color: #2f8f63;
--md-accent-fg-color: #2f8f63;
}
[data-md-color-scheme='slate'] {
--md-primary-fg-color: #5fbf8f;
--md-accent-fg-color: #6fcf9f;
--md-default-bg-color: #111312;
}
.md-header__title {
font-weight: 560;
letter-spacing: -0.02em;
}