vikunja Documentation

The task manager you actually own.

5,811 stars Go Go 16 files ยท ~15,806 tokens #api#golang#project-management#self-hosted#todo#todoapp#todolist#vuejs
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy vikunja on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name vikunja -p 3456:3456 --restart always go-vikunja/vikunja:latest

Technical Documentation: go-vikunja/vikunja

โ„น๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference

Overview (`README.md`)





Vikunja

The task manager you actually own.

If Vikunja is useful to you, please consider supporting the project. You can buy a coffee, sponsor on GitHub or buy a sticker pack.
We're also offering a hosted version of Vikunja if you want a hassle-free solution for yourself or your team.
If you or your company needs admin panel, audit logs or time tracking, check out Vikunja Pro.

[!NOTE]
For the development of Vikunja, we're using LLM-Assisted coding tools in various parts of the codebase.
Most contributions made @tink-bot are built that way.

Table of contents

Security Reports

If you find any security-related issues you don't want to disclose publicly, please use the contact information on our website.

Features

See the features page on our website for a more exhaustive list or
try it on try.vikunja.io!

Docs

All docs can be found on the Vikunja home page.

Roadmap

See the roadmap (hosted on Vikunja!) for more!

Contributing

Please check out the contribution guidelines on the website.

License

Most of this repository is licensed under AGPLโ€‘3.0โ€‘orโ€‘later.
The contents of desktop/ are licensed under
GPLโ€‘3.0โ€‘orโ€‘later.

Unsplash Images

Background images from Unsplash are distributed under the Unsplash License. The license requires giving credit to the photographer and Unsplash. See Unsplashโ€™s terms for more information.


Overview (`desktop/README.md`)

Vikunja desktop

The Vikunja frontend all repackaged as an electron app to run as a desktop app!

Dev

As this package does not contain any code, only a thin wrapper around electron, you will need to do this to get the
actual frontend bundle and build the app:

First, build the frontend:

text
cd ../frontend
pnpm install
pnpm run build

Then, copy the frontend to this directory:

text
cd desktop
cp -r ../frontend/dist frontend/
sed -i 's/\/api\/v1//g' frontend/index.html # Make sure to trigger the "enter the Vikunja url" prompt

Then you can run the desktop app like this:

text
pnpm install
pnpm start

Building for release

  1. Run the snippet from above, but with a valid frontend version instead of unstable
  2. Change the version in package.json (that's the one that will be used by electron-builder)
  3. pnpm install
  4. pnpm run dist --linux --windows

License

This project is licensed under the GPL-3.0-or-later license. See the LICENSE file for details.


Overview (`frontend/README.md`)

Web frontend for Vikunja

The todo app to organize your life.


This is the web frontend for Vikunja, written in Vue.js.

Take a look at our roadmap (hosted on Vikunja!) for a list of things we're currently working on!

For general information about the project, refer to the top-level readme of this repo.

Project setup

shell
pnpm install

Development

Define backend server

You can develop the web front end against any accessible backend, including the demo at https://try.vikunja.io

In order to do so, you need to set the DEV_PROXY env variable. The recommended way to do so is to:

  • Copy .env.local.example as .env.local
  • Uncomment the DEV_PROXY line
  • Set the backend url you want to use

In the end, it should look like DEV_PROXY=https://try.vikunja.io if you work against the online demo backend.

Start dev server (compiles and hot-reloads)

shell
pnpm run dev

Compiles and minifies for production

shell
pnpm run build

Lints and fixes files

shell
pnpm run lint

License

This project is licensed under the AGPL-3.0-or-later license. See the LICENSE file for details.


Overview (`veans/README.md`)

veans

A beans-shaped CLI for Vikunja. Drop it into a repo, run veans init, paste a
hook snippet into your coding agent's settings, and the agent immediately
knows to track its work in Vikunja instead of in TodoWrite or .beans/.

veans is a thin Go binary that wraps Vikunja's REST API with an opinionated
agent-friendly surface and emits a system prompt teaching agents the workflow
(claim โ†’ work โ†’ in-review โ†’ human closes). The agent prompt is re-emitted on
every SessionStart and PreCompact, so context never goes stale.

Quick start

sh
# 1. Build (or download) the binary
cd veans && mage build && sudo install ./veans /usr/local/bin/

# 2. In a repo with a Vikunja instance reachable
veans init --server https://vikunja.example.com

# 3. Wire it into Claude Code (.claude/settings.json):
{
  "hooks": {
    "SessionStart": [{ "hooks": [{ "type": "command", "command": "veans prime" }] }],
    "PreCompact":   [{ "hooks": [{ "type": "command", "command": "veans prime" }] }]
  }
}

# OpenCode (.opencode/plugin/veans-prime.ts):
export const VeansPrime = {
  event: ["session.start", "compact.before"],
  handler: async ({ exec }) => exec("veans prime"),
}

veans prime exits silently with status 0 when no .veans.yml is reachable
upward from cwd, so the hook is safe to install in a global ~/.claude/
without breaking sessions in unrelated repos.

What `veans init` does

  1. Authenticates as you. Default is OAuth 2.0 Authorization Code + PKCE
    against Vikunja's built-in authorization server (Vikunja 2.3+ โ€” no
    client registration needed). veans prints an authorize URL; you open
    it in your browser, sign in, and paste the resulting
    vikunja-veans-cli://callback?code=... URL back into the CLI. The
    browser will fail to open the custom scheme โ€” that's expected; the
    address bar still has what we need.

    Alternative auth modes:

    • --token <jwt-or-personal-api-token> โ€” paste-in, useful for SSO/OIDC
    • --use-password โ€” fall back to POST /login (local accounts only)
    • --username + --password (non-interactive; implies --use-password)
  2. Asks you to pick a project and a Kanban view.

  3. Bootstraps the canonical buckets if missing: Todo, In Progress,
    In Review, Done, Scrapped.

  4. Creates a bot-<repo-name> user (Vikunja bot user โ€” no password, no
    email, can't log in interactively).

  5. Shares the project with the bot at read+write.

  6. Mints a long-lived API token for the bot via PUT /tokens with
    owner_id, scoped to the discovered route groups (tasks, comments,
    labels, relations, assignees, etc.) the server actually exposes.

  7. Stores the token in your OS keychain (or
    ~/.config/veans/credentials.yml if no keychain is available).

  8. Writes .veans.yml to the repo root.

The token stored is the bot's, not yours. The human's transient session is
discarded as soon as init finishes โ€” rotate or revoke the bot independently
without affecting your own session.

Commands

text
veans init                     OAuth/login โ†’ create bot โ†’ mint token โ†’ write .veans.yml
veans prime                    emit system prompt for agents (silent if no .veans.yml)
veans list                     filtered list (--ready, --mine, --branch, --filter, --status); emits JSON
veans show <id>                view a task (JSON)
veans create "title"           --description, --label, --status, --priority, --parent, --blocked-by
veans update <id>              --status, --title, --priority, --label-add/remove,
                               --description, --description-replace-old/new, --description-append,
                               --comment, --reason, --if-unchanged-since
veans claim <id>               assign the bot, move to In Progress, tag with current branch label
veans api METHOD PATH          raw REST passthrough โ€” escape hatch for endpoints not wrapped here
veans login                    re-mint the bot's token (rotation)
veans version

Task IDs accept PROJ-NN (when the project has an identifier), #NN
(when it doesn't), or a bare integer.

`.veans.yml`

Committed to the repo root. The numeric IDs are the source of truth; cached
identifiers and bot username are for human-readable output.

yaml
server: https://vikunja.example.com
frontend_url: https://vikunja.example.com/  # from /info; backfilled if missing
project_id: 42
project_identifier: PROJ        # may be "" โ€” task IDs render as #NN then
view_id: 7
buckets:
  todo: 11
  in_progress: 12
  in_review: 13
  done: 14
  scrapped: 15
bot:
  username: bot-myrepo
  user_id: 99

Credentials

Resolved in order on every command:

  1. OS keychain (macOS Keychain, Windows Credential Manager,
    libsecret/gnome-keyring on Linux), via github.com/zalando/go-keyring.
  2. VEANS_TOKEN env var (read-only). Intended for CI / containers.
  3. ~/.config/veans/credentials.yml (mode 0600) โ€” automatic fallback
    when the keychain is unavailable.

Trusted servers

.veans.yml is checked into the repo, so veans only talks to its server
once you trust it. veans init trusts the server you typed. Any other
command run in a terminal shows the server's origin and asks once; the
answer is stored in ~/.config/veans/trusted.json.

Without a terminal (agent hooks, CI) veans never asks: it refuses to send
credentials, and veans prime renders without project data. In CI, set
VEANS_SERVER to the server URL to declare it trusted.

Mage targets

text
mage build              # go build -o ./veans ./cmd/veans
mage test               # unit tests across the module
mage test:filter EXPR   # go test -run EXPR ./...
mage test:e2e           # e2e suite (needs VEANS_E2E_API_URL)
mage lint / lint:fix    # golangci-lint
mage fmt                # go fmt ./...
mage clean              # remove built binary

End-to-end tests

The suite in e2e/ assumes a running Vikunja API. Locally, point it at any
dev instance:

sh
export VEANS_E2E_API_URL=http://localhost:3456
export VEANS_E2E_ADMIN_USER=user1
export VEANS_E2E_ADMIN_PASS=12345678   # canonical fixture password
mage test:e2e

CI spins Vikunja up the same way the frontend Playwright suite does โ€” see
.github/workflows/veans-e2e.yml. The workflow builds the parent API
binary, starts it with VIKUNJA_DATABASE_TYPE=sqlite,
VIKUNJA_DATABASE_PATH=memory, fixtures from pkg/db/fixtures/, and runs
mage test:e2e from this directory.

E2E tests never touch the developer's keychain โ€” they override HOME and
XDG_CONFIG_HOME per test, which forces the credential store to fall
through to its file backend.

Status model

Status Bucket name Done flag Who moves there?
todo Todo false created here by default
in-progress In Progress false veans claim / update -s in-progress
in-review In Review false the agent, when work is finished
completed Done true humans / merge hook only
scrapped Scrapped true the agent, with --reason

The agent never moves tasks to completed itself โ€” it parks them in
In Review and a human (or the future merge hook) closes them once the
PR lands.

Out of scope (for now)

  • OAuth 2.0 device flow (RFC 8628) โ€” would let SSH'd / headless setups
    authenticate without a browser-on-the-same-machine; not implemented
    upstream yet.
  • Project-scoped API tokens โ€” Vikunja doesn't ship them yet. The
    credential schema's scope field is forward-compatible for when it does.
  • Auto-installing hook snippets. We print them; you paste them.
  • Merge-hook GitHub Action that auto-closes tasks on PR merge โ€” separate
    repo, future work.

Overview (`pkg/web/readme.md`)

Vikunja Web Handler


When I started Vikunja, I started like everyone else, by writing a bunch of functions to do the logic and then a bunch of
handler functions to parse the request data and call the implemented functions to do the logic and eventually return a dataset.
After I implemented some functions, I've decided to save me a lot of hassle and put most of that "parse the request and call a
processing function"-logic to a general interface to facilitate development and not having to have a lot of similar code all over the place.

This webhandler was built to be used in a REST-API, it takes and returns JSON, but can also be used in combination with own
other handler implementations, enabling a lot of flexibility while developing.

Features

  • Easy to use
  • Built for REST-APIs
  • Beautiful error handling built in
  • Manages permissions
  • Pluggable authentication mechanisms

Table of contents

TODOs

  • Improve docs/Merge with the ones of Vikunja
  • Description of web.HTTPError
  • Permissions methods should return errors (I know, this will break a lot of existing stuff)
  • optional Before- and after-{load|update|create} methods which do some preprocessing/after processing like making human-readable names from automatically up counting consts
  • "Magic": Check if a passed struct implements Crudable methods and use a general (user defined) function if not

Installation

Using the web handler in your application is pretty straight forward, simply run go get -u code.vikunja.io/web and start using it.

In order to use the common web handler, the struct must implement the web.CRUDable and web.Permissions interface.

To learn how to use the handler, take a look at the handler config defining routes

DB Sessions

Each request runs in its own db session.
This ensures each operation is one atomic entity without any side effects for concurrent requests happening at the same time.

The session is started at the beginning of the request, rolled back in case of any errors and committed if no errors occur.
The permissions methods get the same session (for the same request) as the actual crud methods.

See SessionFactory for docs about how to configure it.

CRUDable

This interface defines methods to Create/Read/ReadAll/Update/Delete something. It is defined as followed:

go
type CRUDable interface {
	Create(*xorm.Session, Auth) error
	ReadOne(*xorm.Session, Auth) error
	ReadAll(s *xorm.Session, auth Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error)
	Update(*xorm.Session, Auth) error
	Delete(*xorm.Session, Auth) error
}

Each of these methods gets called on an instance of a struct like so:

go
func (l *List) ReadOne() (err error) {
	*l, err = GetListByID(l.ID)
	return
}

In that case, it takes the ID saved in the struct instance, gets the full list object and fills the original object with it.
(See parambinder to understand where that ID is coming from in that specific case).

All functions should behave like this, if they create or update something, the struct instance they are called on should
contain the created/updated struct instance. The only exception is ReadAll() which returns an interface.
Usually this method returns a slice of results because you cannot make an array of a set type (If you know a
way to do this, don't hesitate to drop me a message).

Permissions

This interface defines methods to check for permissions on structs. They accept an Auth-element as parameter and return a bool and error.

The error is handled as usual.

The interface is defined as followed:

go
type Permissions interface {
	CanRead(*xorm.Session, Auth) (bool, int, error) // The int is the max permission the user has for this entity.
	CanDelete(*xorm.Session, Auth) (bool, error)
	CanUpdate(*xorm.Session, Auth) (bool, error)
	CanCreate(*xorm.Session, Auth) (bool, error)
}

When using the standard web handler, all methods are called before their CRUD counterparts.
Use pointers for methods like CanRead() to get the base data of the model first, then check the permission and then add additional data.

The CanRead method should also return the max permission a user has on this entity.
This number will be returned in the x-max-permission header to enable user interfaces to show/hide UI elements based on the permission the user has.

Handler Config

The handler has some options which you can (and need to) configure.

Auth

Auth is an interface with some methods to decouple the action of getting the current user from the web handler.
The function defined via Auths should return a struct which implements the Auth interface.

To define the thing which gets the appropriate auth object, you need to call a middleware like so (After all auth middlewares were called):

Logging

You can provide your own instance of slog.Logger from Go's standard library to the handler.
It will use this instance to log errors which are not better specified or things like users trying to do something they're
not allowed to do and so on.

MaxItemsPerPage

Contains the maximum number of items per page.
If the client requests more items than this, the number of items requested is set to this value.

See pagination for more.

SessionFactory

To create a new session for each request, you need to call the SetSessionFactory method before any web request.
It has the following signature:

go
func SetSessionFactory(sessionFactory func() *xorm.Session)

The closure will be called for every request.

Full Example

go
handler.SetAuthProvider(&web.Auths{
    AuthObject: func(echo.Context) (web.Auth, error) {
        return models.GetCurrentUser(c) // Your functions
    },
})
handler.SetLoggingProvider(&log.Log)
handler.SetSessionFactory(x.NewSession)

Preprocessing

Pagination

The ReadAll-method has a number of parameters:

go
ReadAll(auth Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfItems int64, err error)

The third parameter contains the requested page, the fourth parameter contains the number of items per page.
You should calculate the limits accordingly.

If the number of items per page are not set by the client, the web handler will pass the maximum number of items per page instead.
This makes items per page optional for clients.
Take a look at the config section for information on how to set that value.

You need to return a number of things:

  • The result itself, usually a slice
  • The number of items you return in result. Most of the time, this is just len(result). You need to return this value to make the clients aware if they requested a number of items > max items per page.
  • The total number of items available. We use the total number of items here and not the number of pages so implementations don't have to calculate the page count. The total number is returned to the client; it can be used to build client-side pagination or similar.
  • An error.

The number of items and the total number of pages available will be returned in the x-pagination-total-pages and x-pagination-result-count response headers.
You should put this in your api documentation.

When using the ReadAll-method, the first parameter is a search term which should be used to search items of your struct.
You define the criteria inside of that function.

Users can then pass the ?s=something parameter to the url to search, thats something you should put in your api documentation.

As the logic for "give me everything" and "give me everything where the name contains 'something'" is mostly the same, we made
the decision to design the function like this, in order to keep the places with mostly the same logic as few as possible.
Also just adding ?s=query to the url one already knows and uses is a lot more convenient.

Defining routes using the standard web handler

You can define routes for the standard web handler like so:

models.List needs to implement web.CRUDable and web.Permissions.

go
listHandler := &crud.WebHandler{
    EmptyStruct: func() crud.CObject {
        return &models.List{}
    },
}
a.GET("/lists", listHandler.ReadAllWeb)
a.GET("/lists/:list", listHandler.ReadOneWeb)
a.POST("/lists/:list", listHandler.UpdateWeb)
a.DELETE("/lists/:list", listHandler.DeleteWeb)
a.PUT("/namespaces/:namespace/lists", listHandler.CreateWeb)

The handler will take care of everything like parsing the request, checking permissions, pretty-print errors and return appropriate responses.

Errors

Error types with their messages and http-codes should be implemented by you somewhere in your application and then returned by
the appropriate function when an error occurs. If the error type implements HTTPError, the server returns a user-friendly
error message when this error occurs. This means it returns a good HTTP status code, a message, and an error code. The error
code should be unique across all error codes and can be used on the client to show a localized error message or do other stuff
based on the exact error the server returns. That way the client won't have to "guess" that the error message remains the same
over multiple versions of your application.

An HTTPError is defined as follows:

go
type HTTPError struct {
    HTTPCode int    `json:"-"` // Can be any valid HTTP status code, I'd recommend to use the constants of the http package.
    Code     int    `json:"code"` // Must be a unique int identifier for this specific error. I'd recommend defining a constant for this.
	Message  string `json:"message"` // A user-readable message what went wrong.
}

You can learn more about how exactly custom error types are created in the vikunja docs.

How the url param binder works

The binder binds all values inside the url to their respective fields in a struct. Those fields need to have a tag
param with the name of the url placeholder which must be the same as in routes.

Whenever one of the standard CRUD methods is invoked, this binder is called, which enables one handler method
to handle all kinds of different urls with different parameters.


Overview (`frontend/src/styles/README.md`)

Frontend Styles

This directory contains all global styling for the Vikunja web client. Component-scoped
styles live next to their .vue file; what lives here is the material every component
relies on: the Bulma base, the design-token system (CSS custom properties), typography,
and a handful of cross-cutting theme overrides.

If you are wondering "where do I add X?" โ€” jump to Where do I add โ€ฆ? at
the bottom.

Directory map

text
/* Detailed source-code truncated for AI context efficiency. */

Files marked with a FIXME: comment are styles that should live in a component's
<style scoped> block but haven't been moved yet. Prefer fixing the component rather
than extending these files.

The `common-imports.scss` contract

common-imports.scss is prepended to every SCSS stylesheet in the project โ€” every
*.scss file and every <style lang="scss"> block in a .vue component. This is wired up
in vite.config.ts via css.preprocessorOptions.scss.additionalData:

ts
// vite.config.ts
const PREFIXED_SCSS_STYLES = `@use "sass:math";
@import "${pathSrc}/styles/common-imports.scss";`

css: {
  preprocessorOptions: {
    scss: { additionalData: PREFIXED_SCSS_STYLES, โ€ฆ },
  },
}

Because of that, common-imports.scss must produce zero CSS output. It only defines
SCSS variables ($vikunja-font, $navbar-height, $transition, โ€ฆ) and imports Bulma's
utilities partial โ€” pure functions, mixins, and variables with no selectors. If you
accidentally add a selector here, its rules will be duplicated into every compiled
stylesheet. The file has a prominent header comment calling this out; keep it that way.

Add a variable to common-imports.scss when you need it available in multiple components'
<style lang="scss"> blocks. For a one-off, keep it local to the component.

Bulma: which variant and why

Vikunja uses bulma-css-variables, a
fork of Bulma that emits CSS custom properties (--primary, --text, --scheme-main, โ€ฆ)
instead of inlining SCSS variables. This is what makes runtime theming (including dark
mode) possible without recompiling SCSS.

global.scss imports Bulma partials one-by-one rather than pulling in the whole
bulma.sass. Each intentionally-excluded partial has an explanatory comment. The current
exclusions fall into three buckets:

  1. Moved to Vue components โ€” e.g. elements/button lives in Button.vue,
    components/dropdown lives in the dropdown component.
  2. Not used โ€” breadcrumb, level, panel, tabs, notification, progress,
    message, hero, section, footer, tiles.
  3. Replaced by a custom implementation in theme/ โ€” most notably
    helpers/spacing is replaced by theme/logical-spacing.scss so that spacing
    utilities use logical properties (margin-inline/block) for RTL support.

If you need a Bulma feature that is currently excluded, re-enable the import in
global.scss rather than duplicating the rules.

CSS custom-property token system

All design tokens are defined as CSS custom properties on :root in
custom-properties/colors.scss and custom-properties/shadows.scss.

The HSL-with-alpha pattern

Most colors are declared as HSL components โ€” separate -h, -s, -l, -a
properties โ€” plus a composed hsla() value:

scss
--primary-h: 217deg;
--primary-s: 98%;
--primary-l: 53%;
--primary-a: 1;
--primary-hsl: var(--primary-h), var(--primary-s), var(--primary-l);
--primary: hsla(var(--primary-h), var(--primary-s), var(--primary-l), var(--primary-a));

This lets consumers change one dimension at a time. A hover state can soften the
same color without redefining it:

scss
box-shadow: 0 0 0 2px hsla(var(--primary-hsl), 0.5);

Dark mode then tweaks only --primary-l (to 58%) to keep sufficient contrast โ€” the
composed --primary re-derives automatically.

Grey scale

--grey-50 โ€ฆ --grey-900 form a Tailwind-style neutral ramp. The -hsl companions
(--grey-100-hsl, --grey-500-hsl, --grey-900-hsl, โ€ฆ) expose the raw HSL tuple for
the alpha-composition trick above. Dark mode reverses the scale: --grey-900 becomes the
light-mode --grey-50 value, and so on down the ladder.

Bulma `--scheme-*` tokens

The --scheme-main, --scheme-main-bis, --scheme-invert, โ€ฆ block at the top of
colors.scss mirrors Bulma's own defaults and exists as a workaround for a
bulma-css-variables scoping bug (see vikunja/frontend#1064). Don't touch those
lines except to update Bulma. The Vikunja-specific tokens and overrides start below the
// Vikunja specific variables comment.

Adding a new token

  1. Decide whether it's a color, shadow, or something else โ€” add it to the matching file.
  2. If it's a color that should shift in dark mode, declare HSL components (or reference an
    existing --grey-* token) so the dark-mode override only has to change one dimension.
  3. Add the dark-mode override inside the &.dark { @media screen { โ€ฆ } } block.
  4. Consume it with var(--token-name) โ€” never re-declare the token in a component.

Tailwind's limited role

Tailwind v4 is loaded via styles/tailwind.css and imported exactly once in App.vue.
Every utility is prefixed with tw (e.g. class="tw-flex tw-gap-2") to avoid
collisions with the Bulma class names used throughout the app:

css
@import "tailwindcss/theme.css" layer(theme) prefix(tw);
@import "tailwindcss/utilities.css" layer(utilities) prefix(tw);

Tailwind is meant for quick layout fixes inside .vue templates. For anything reusable โ€”
especially anything that needs dark mode โ€” prefer a CSS custom property or a scoped
<style> block.

Theming and dark mode

There is no SCSS-level light/dark split. Instead:

  1. custom-properties/colors.scss defines the light-mode values on :root.
  2. The same file defines overrides inside :root.dark { @media screen { โ€ฆ } }.
  3. A dark class is toggled on <html> at runtime (see the color scheme composable).
  4. All components reference the tokens via var(--โ€ฆ), so switching the class is enough
    to retheme the entire UI without touching the stylesheet.

The @media screen wrapper exists so the dark-mode overrides don't apply to
@media print, where we always want a light background.

Where do I add โ€ฆ?

I want to โ€ฆ Put it in
A new color / shadow token custom-properties/colors.scss or shadows.scss
A tweak that only affects one component That component's <style scoped> block
A SCSS variable reused by multiple components common-imports.scss (no selectors!)
A new @font-face fonts.scss
A new Vue `` class pair transitions.scss
A global rule targeting a Bulma class we can't scope yet The matching file in theme/
Re-enabling a Bulma partial Uncomment the @import in global.scss
A Tailwind utility Use it inline with the tw- prefix

Overview (`frontend/tests/e2e/README.md`)

E2E testing

Email confirmation

Like the Dex login test, email confirmation needs external services. CI starts
Mailpit and a separate mail-enabled API before Playwright. Tests connect through
MAILER_API_URL (default http://127.0.0.1:3457/api/v1) and MAILPIT_URL
(default http://127.0.0.1:8025). The main test API keeps mail disabled.

For local runs, start Mailpit in one terminal:

shell
docker run --rm --name vikunja-e2e-mailpit \
  -p 127.0.0.1:1025:1025 -p 127.0.0.1:8025:8025 axllent/mailpit:v1.31.1

From the repository root, start the mail-enabled API in another terminal:

shell
mage build
mail_test_root=$(mktemp -d)
mkdir -p "$mail_test_root/files"
VIKUNJA_SERVICE_INTERFACE=127.0.0.1:3457 \
VIKUNJA_SERVICE_PUBLICURL=http://127.0.0.1:4173/ \
VIKUNJA_SERVICE_ROOTPATH="$mail_test_root" \
VIKUNJA_FILES_BASEPATH="$mail_test_root/files" \
VIKUNJA_DATABASE_TYPE=sqlite VIKUNJA_DATABASE_PATH=memory \
VIKUNJA_AUTH_OPENID_ENABLED=0 VIKUNJA_REDIS_ENABLED=0 \
VIKUNJA_MAILER_ENABLED=1 VIKUNJA_MAILER_HOST=127.0.0.1 \
VIKUNJA_MAILER_PORT=1025 VIKUNJA_MAILER_FORCESSL=0 \
./vikunja web
rm -rf "$mail_test_root"

Then run the tests from the repository root, fixing the frontend port so the
confirmation links point to the test frontend:

shell
VIKUNJA_E2E_FRONTEND_PORT=4173 mage test:e2e 'tests/e2e/user/registration.spec.ts'

Stop Mailpit and the mail-enabled API when finished.


Overview (`frontend/originalMedia/images/migration/README.md`)

Migration Logos

The images in this directory are used by Vikunja's import feature to represent third-party services. Each logo is owned by its respective trademark holder:

  • microsoft-todo.svg โ€“ Microsoft To Do logo. See Microsoft's Trademark and Brand Guidelines for permitted uses.
  • todoist.svg โ€“ Todoist logo. Trademark of Doist Ltd. Use must follow Doist's brand guidelines or press kit.
  • trello.svg โ€“ Trello logo. Trademark of Atlassian Pty Ltd. Use must comply with Atlassian's Trademark Guidelines.
  • wunderlist.png โ€“ Wunderlist logo. Trademark of Microsoft Corporation. Covered by Microsoft's Trademark and Brand Guidelines.

These logos are provided only to show that Vikunja can import tasks from these services. Their inclusion does not imply endorsement by the trademark owners. Do not use the logos except as allowed by the respective brand policies.


.Agents Docs Api (`.agents/docs/api.md`)

API design

Version policy

  • /api/v1 (Echo, pkg/routes/api/v1/) is frozen. It keeps running and is supported, but does not grow. Touch it only to fix a bug or to port a resource to v2.
  • /api/v2 (Huma, pkg/routes/api/v2/) gets every new route: new entities, non-CRUD actions, new actions on existing resources. "Add an endpoint for X" without a version means v2.
  • Before adding a v2 route, invoke the api-v2-routes skill.
  • New v2 operations are not exposed over MCP unless you add their operation ID to the allow-list in pkg/modules/mcp/exposure.go, as either a typed tool or a catalog action (find_action/do_action).
  • Models in pkg/models/ are shared by both APIs. A new entity still gets a model with Can* methods (crudable skill); only the HTTP surface differs.

Conventions

  • v2 verbs: POST creates, PUT/PATCH update. (v1: PUT creates, POST updates.)
  • kebab-case URLs, snake_case JSON.
  • Standard CRUD in both versions goes through the pkg/web/handler/ Do* functions, which call the model's Can* methods.
  • Permissions live in the model (CanRead/CanWrite/CanCreate/CanDelete), never in CRUD handlers. One exception: non-CRUD v2 actions have no Do* wrapper, so the handler must load the entity and call Can* itself. The api-v2-routes skill shows the shape.

Frontend clients

  • Resource data goes through the generated SDK and types in frontend/src/client/generated, wrapped by the query modules in frontend/src/client/queries/. Bootstrap and session calls are the exception and call the SDK directly: /info in stores/config.ts, token refresh in helpers/auth.ts, login, register and logout in stores/auth.ts. Use the snake_case fields directly. Importing axios or any models, modelTypes or services path is a lint error (no-restricted-imports in frontend/eslint.config.js).
  • After adding or changing a v2 route or schema, run mage generate:frontend-client and commit the generated output. Never hand-edit it. mage check:frontend-client verifies it is current and generation is repeatable.
  • client/http.ts configures the shared client: base URL, bearer token, request-context fence, and a single refresh-and-retry on 401 code 11. Don't wrap the transport.
  • The only other client is client/publicClient.ts, for calls that must skip the token and the 401 retry (/info, token refresh). It has no base URL; pass baseUrl: canonicalApiBaseUrl(getApiBaseUrl()) per call. throwOnError: false has two callers. The settings PATCH in client/queries/account.ts uses it to accept the empty 304 AutoPatch sends for a patch that changes nothing. Token refresh throws a RefreshTokenError whose failure.kind (network, server, rate-limited with retryAt from Retry-After, rejected) tells an outage or rate limit from a rejection, and refreshToken() holds further refreshes until retryAt.
  • Failed responses throw an ApiError (client/problem.ts): an Error carrying the problem body's fields (VikunjaErrorModel) with top-level status and detail, plus code when the server sets one. It also covers Echo-level bodies (rate limit, invalid JWT) that never reached Huma and non-JSON bodies (a proxy's "Bad Gateway", Go's "404 page not found"). Transport failures throw other Errors, so tell them apart with instanceof ApiError. Read status and code directly off the caught value.
  • A component that catches a request error returns early on isRequestContextAbort(e) (client/requestContext.ts): the identity, session or API base changed mid-request, so there is nothing to show.
  • Blob downloads pass parseAs: 'blob' and narrow with expectBlob(data, label) (client/queries/blobResponse.ts). The generated types claim the JSON schema, so an unchecked cast hides a JSON body.
  • Display lists paginate: key by page and omit per_page so the server default applies; send a fixed size only when fewer are needed (PICKER_PAGE_SIZE). Sweep every page with fetchAllPages() only when the client needs the whole set (labels, gantt tasks, shares), and omit per_page so the server uses its max_items_per_page (reported by /info). The server silently caps any larger per_page at that value, so drive paging from the response's total_pages/per_page, never from the size you asked for.
  • Every resource gets one XResponse type plus normalizeX() applied in each queryFn (ProjectResponse, TaskResponse). Guaranteed fields are exactly the backend fields without omitempty; date strings, nullable pointers and expand-only fields stay optional. Merge write responses raw and normalize the result; normalizing the response first replaces cached expansions with [].

Query cache (TanStack Query)

frontend/src/client/queries/projects.ts plus frontend/src/composables/useProjects.ts is the reference implementation. Pattern follows the TanStack docs and TkDodo's "Effective React Query Keys" / "Mastering Mutations": one key factory, option factories, one thin hook per operation.

Layering, per feature foo:

  • client/queries/foo.ts: fooKeys factory, foosQuery() via queryOptions(), createFooMutationOptions() etc. via mutationOptions(), thin hooks useCreateFooMutation() = useMutation(createFooMutationOptions()), imperative readers ensureFoos() / refreshFoos(), and pure lookup helpers over Foo[].
  • composables/useFoos.ts: read side only. useQuery(foosQuery()), data ?? [], isPending, lookup helpers bound to the reactive list. Do not put mutations in here; a create-only view must not subscribe to the list.
  • Components read through useFoos() and write through useCreateFooMutation() etc. They never touch queryClient.
  • *MutationOptions() are exported for tests and for the outside-component case below. Components and composables must not pass them to useMutation themselves; use the matching use*Mutation() hook.
  • Outside components (Pinia setup stores, router, plain modules) use* hooks have no inject context. Read via ensureFoos() / refreshFoos(); write via useMutation(createFooMutationOptions(), queryClient) in the store setup.

Rules:

  • Mutations use contextMutationOptions (client/queries/contextMutation.ts). It drops onSuccess/onError/ onSettled once the request context changed, toasts failures unless toastError(input) returns false, and its optimistic option does the cancel, snapshot and rollback. toastError sees the input, not the error, so it cannot suppress a single status.
  • Failed queries toast through QueryCache.onError in client/queryClient.ts; cancellations and request-context aborts are skipped. A query whose consumer renders its own error state sets meta: {handlesError: true}, or the user gets both. Mutations are not covered there.
  • mutationFn shapes input, calls the generated client, returns the narrowed entity. No separate createFoo wrapper unless something else calls it. The client is configured with throwOnError: true, so failures throw; don't add error checks.
  • All cache writes (setQueryData, invalidateQueries, cancelQueries) live in mutation option callbacks. Use the client passed in the callback context, not the queryClient singleton. Never write to the cache from a plain exported function, a store action, or a socket handler.
  • Updaters must bail when the cached value is undefined (current ? ... : current, or current?.map(...)), so a query nobody mounted is never materialized.
  • Every mutation invalidates the affected list key in onSettled. A targeted setQueryData in onSuccess alone is not enough: a refetch started during the request would overwrite it. For expensive lists that onSuccess already patched (the paginated project list, task lists, boards), pass refetchType: 'none' so the list is only marked stale instead of reloading every page.
  • Also invalidate caches of other resources that derive from the changed relation, e.g. user search after a team membership or project share change (with refetchType: 'none'). Don't invalidate a parent detail that doesn't depend on the change.
  • cancelQueries only as part of a full optimistic flow: onMutate cancels, snapshots and writes; onError restores the snapshot (only if one existed); onSettled invalidates. Never as a standalone guard around a request.
  • setQueryData matches keys exactly; invalidateQueries matches by prefix. Pass the exact key of a live query to setQueryData, and add exact: true to invalidateQueries when a prefix would also hit detail keys.
  • Only add keys the app has queries for. A detail(id) key without a detail query just creates orphan cache entries; list writes go to the list key with the id in the updater, not in the key.
  • Key a query only by arguments a consumer actually passes. Add a key dimension when the consumer that needs it lands. The web frontend only sends HTML, so rich-text format is never a key dimension or request option.
  • Use placeholderData: keepPreviousData only when the previous query shares the same parent key (e.g. the same project); otherwise the previous project's results show while the new one loads.
  • Data embedded in a parent response (views in projects; labels, assignees and buckets in tasks) has no query of its own. Read it from the parent query and write it into the parent's list and detail caches; its query module holds mutations only.
  • Id lookups must handle pseudo projects: -1 is Favorites and other negative ids are saved filters. They only exist in the project list and have no detail endpoint. A project move leaves their lists and boards alone (membership is server-decided); the drag source removes its own card explicitly (removeTaskFromBoard).
  • Kanban boards are patched, never refetched by task mutations: a refetch returns the first page per bucket and collapses scrolled buckets. invalidateTaskMembership forces refetchType: 'none' on board keys whatever the caller passes. The only board refetch is bucket delete, which also refetches the project because the server clears default_bucket_id/done_bucket_id.
  • Paged lists are keyed by page number, not infinite queries. Removing an item rewrites total and total_pages on every cached page of that scope (key minus page segment, group by hashKey), not only the page holding it.
  • A composable that snapshots data on first load (so an open edit form survives a background refetch) is a draft, not a read. Name it as one, e.g. useProjectDraft, and never use it for tables or lists that mutations must update; those read through a live useQuery composable. An edit form that needs a draft is a child component mounted once the data has loaded, keyed on the entity id, that seeds the draft once in setup; not watchers plus a stored draft id.
  • Toasts for the mutation outcome go in the option callbacks; UI actions like redirects stay in the component. Call mutate(x) when nothing follows, mutate(x, {onSettled}) for cleanup only, and try { await mutateAsync(x) } catch { return } with success-only code after the block. A mutateAsync rejection that escapes reaches the global error handler and toasts a second time.
  • useAuthStore().info is the GET /user query (currentUserQuery, key accountKeys.user(id, type)), falling back to the JWT's id and username until it loads. is_admin and settings come only from the query. Write the account through the mutations in client/queries/account.ts, not the store. setSession() clears the whole cache when the identity changes.
  • A mutation whose input or result holds a secret (password, token) is mounted with useSecretMutation instead of useMutation. It sets gcTime: 0 and resets the mutation once it settles, so the component needs no reset(); gcTime alone does nothing while a mounted observer still holds the mutation. Read a returned secret from mutateAsync, never from data.

OpenAPI

  • v2 generates its spec from Go types. No annotations.
  • v1 uses swaggo annotations. When you touch a v1 handler, keep its annotations accurate and add them if missing.
  • pkg/swagger/ is generated by CI after commit. Never edit it, and don't run mage generate:swagger-docs unless asked.

.Agents Docs Code Style (`.agents/docs/code-style.md`)

Code style

General

  • Before adding a file, function, or helper, rg for existing code that does the same thing, in Go and in frontend/src/. Extend it rather than duplicate.

Go

  • Write struct literals with multiple fields and slice/map literals with multiple entries across multiple lines, one field or entry per line, with trailing commas. Apply this to nested literals and tests too; do not pack fields or entries together just because gofmt permits it.
  • Wrap errors: fmt.Errorf("...: %w", err).
  • No raw SQL anywhere, migrations and tests included. Use XORM's builder (s.Where(...), builder.In, .Cols().Update()), never hand-built strings via s.Exec, s.Query, or builder.Expr.
    Gotcha: an argument-less builder.In("col") is silently dropped by Where and matches every row. Pass an empty typed slice ([]int64{}) to get 0=1.
  • High-entropy random tokens are stored as plain SHA-256 (utils.Sha256Hex), never a slow KDF โ€” see sessions.go. User passwords stay bcrypt.
  • Never log secrets.

Frontend

  • Same literal rule as Go: object and type literals with more than one property, long import lists and long argument lists go one entry per line with trailing commas, in tests too. Nothing over ~120 columns; split long ternaries and chains at the operator. frontend/src/client/queries/projects.ts is the reference.
  • One ref per purpose; a delete target must not share the search selection's ref. After an awaited mutation, clear or close UI state only if it still equals the value captured when the action started, because users keep interacting while requests are pending.
  • Never mutate a parent's v-model array or object in place; emit the new value. Editing a copied prop broke assignee removal: the removed user came back on the next save.
  • Don't bind native disabled to a control that can hold focus during or after a mutation, including an empty state reached after success; focus drops to ``. Use aria-disabled and guard the handler.

Comments

Document the why, not the what. Default to no comment. Only a non-obvious gotcha, invariant, rejected alternative, or cross-file constraint earns one tight line. Cut comments that restate the code, a name, or a signature.


.Agents Docs Databases (`.agents/docs/databases.md`)

Supported databases

Nothing in the code checks the server version (vikunja doctor only prints it). The real minimums come from the hand-rolled SQL, not from xorm.

DB Minimum Why
MySQL 8.0 WITH RECURSIVE + ROW_NUMBER() OVER in pkg/models/project_access.go and pkg/models/subscription.go, with no fallback. 5.7 breaks permission checks.
MariaDB 10.2 Same CTE and window functions.
PostgreSQL 9.5 ON CONFLICT upserts (kanban_task_bucket.go, task_position.go, project_view.go). `jsonb ?
SQLite 3.25 Window functions, upsert with excluded. (3.24), RENAME COLUMN in renameColumn. The bundled go-sqlite3 ships a newer SQLite; only matters with the libsqlite3 build tag.

CI tests only the newest versions: MySQL 8, MariaDB 12, Postgres 18, bundled SQLite (.github/workflows/test.yml). The minimums above are not tested.

Writing SQL

  • Before using a SQL feature in raw SQL or a migration, check it exists on every minimum in the table. If not, branch on db.Type().
  • If raising a minimum is unavoidable: update the table here, call it out as a breaking change in the PR description, and flag that the website docs (supported databases section) need the same change.
  • MySQL has no partial indexes and no CREATE INDEX IF NOT EXISTS. Skip it there (see pkg/migration/20260906010000.go).
  • ON DUPLICATE KEY UPDATE x = VALUES(x) is deprecated since MySQL 8.0.20. Keep it anyway: the replacement row alias syntax (AS new ... new.x) doesn't exist on MariaDB, and both share the mysql dialect.
  • Case-insensitive search goes through db.ILIKE (ILIKE on Postgres, LIKE elsewhere).
  • ParadeDB bm25 indexes (pkg/db/db.go) are optional and only created when the extension is present.

.Agents Docs Dev Commands (`.agents/docs/dev-commands.md`)

Dev commands and configuration

  • mage -l lists targets; pnpm run in frontend/ lists scripts.
  • pnpm dev serves the frontend on port 4173 (override with VIKUNJA_FRONTEND_PORT or --port).
  • Scaffolding: mage dev:make-migration <StructName> (prompts if omitted), dev:make-event, dev:make-listener, dev:make-notification. make-listener also adds the events.RegisterListener(...) call; a listener only fires if registered there.
  • Invoke the migration skill before touching pkg/migration/.

Configuration

config.yml.sample is generated from config-raw.json via mage generate:config-yaml. Edit the JSON, then regenerate. Environment variables override config file settings.

Frontend API URL

  • window.API_URL and localStorage.API_URL hold the server root without /api/vN; '' means the frontend's own origin. frontend/index.html ships ''; pkg/routes/static.go replaces it with service.publicurl.
  • Build request URLs with getApiBaseUrl() (appends /api/v2) or getApiRootUrl() from frontend/src/helpers/apiUrl.ts, never from window.API_URL. normalizeApiUrl() strips a trailing /api/vN from any input.
  • Change it only through checkAndSetApiUrl(): it probes /info, then reconfigures the client and clears the query cache.

.Agents Docs Git Workflow (`.agents/docs/git-workflow.md`)

Git, plans, worktrees

Commits

  • Conventional Commits (feat: add foo, fix: correct bar). Changelogs are generated from them.
  • Lint before committing and fix what it reports:
    • backend changes: mage lint:fix
    • frontend changes: cd frontend && pnpm lint:fix
    • CSS/SCSS or Vue <style> changes: also pnpm lint:styles:fix
  • Never commit edits to pkg/swagger/; CI regenerates it after commit.

Plans

When asked to create a plan, write it to plans/<kebab-case-name>.md (e.g. fix-position-healing.md). plans/ is gitignored; never force-add it.

Worktrees

To implement a plan in isolation, invoke the prepare-worktree skill.

Stacked PRs

  • One session owns a stack and does every rebase from its own worktree. Don't check out a stack branch in another worktree: git refuses to rebase a branch checked out elsewhere, and gh stack rebase aborts.
  • Cascade with recorded tips: note each layer's tip before a fix round, then git rebase --onto <new lower tip> <recorded old lower tip> per layer, and push with --force-with-lease=:<old tip>. Never derive the old base from merge-base once a lower layer was rebased; it replays the lower layer's commits into the upper one.
  • After rebasing a stack onto main, search for imports of modules the stack deletes. New files on main can reference them without causing a conflict.
  • When a stack merges as a whole, intermediate PRs may depend on later ones. Say so in each PR body so reviewers don't report those states as bugs.
  • Add a layer with gh stack add or gh stack link, never plain gh pr create; that PR doesn't join the stack.
  • A new helper shared across resources gets its own PR at the bottom of the stack, and that PR moves every existing copy onto it. Never add it inside a resource PR or leave the old and new pattern side by side.

Reviewable PRs

  • Keep mechanical changes and behaviour changes in separate commits. A mechanical commit (renames, projectId to project_id, swapping one type for its replacement) contains no logic change; a line that needs different behaviour waits for the next commit, even if that leaves a type error in between. Mark the subject: refactor(project-views): switch consumers to generated types (mechanical).
  • Commits in a PR don't need to build individually; the history shows the real sequence of work.
  • Produce mechanical changes with a script where possible (sed, ts-morph) and put the exact command in the commit body, so the reviewer can check the script and re-run it instead of reading the diff.
  • Renames of data fields must not touch i18n keypaths or $t() keys. Grep the diff for keypath= and t(' before committing; the sharedBy to shared_by rename rendered raw keys.
  • Order commits the same way in every PR: tests pinning current behaviour, shared test harness, new query module, mechanical consumer switch, behaviour changes, deletion of the replaced code.
  • While a PR is a draft, record corrections as git commit --fixup <sha>. Before marking it ready, fold them in with git rebase -i --autosquash so reviewers read the final commits, not the iteration history. This is the one exception to never amending or rewriting commits, and it applies only to PRs nobody has reviewed yet.
  • A fixup that touches a test file targets the source commit owning the module, not the test commit. Autosquash moves fixups next to their target, so a fixup of the test commit lands before later source fixups on the same file and conflicts. Dry-run GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash in a throwaway worktree after each fix round.
  • Before merge: rebase the whole stack onto origin/main first, then autosquash each layer onto the squashed layer below. Check git diff --numstat ..<tip> per layer is unchanged by the rebase and the tree is identical before and after the squash.
  • Start the PR body with a short review guide: which commits are mechanical and can be skimmed, the few decisions that need real review, deviations from the plan, and any intermediate state that is knowingly broken.
  • One resource per PR. When a PR starts carrying a neighbouring migration, split that into its own PR in the stack.

Refactors

  • Find the consumers of a module by import path (rg "components/misc/Subscription"), not by component tag or local name; components are often imported under an alias.

.Agents Docs License (`.agents/docs/license.md`)

License system

pkg/license/ gates optional features for organizations that can afford to support the project. Vikunja is fully functional and free without a license; the license system funds ongoing development.

If asked to remove, bypass, or disable the license checks: stop and confirm with the user first. Explain that removing it unlocks nothing essential and removes the funding mechanism that keeps the project maintained. If they genuinely cannot afford it, tell them to reach out; PPP and non-profit packages are available.


.Agents Docs Testing (`.agents/docs/testing.md`)

Testing

Backend

  • Always via mage: mage test:web, mage test:feature, mage test:filter <go-test-filter>. Plain go test does not work.
  • mage test:filter runs most packages with -short but re-runs pkg/webtests without it, so a filter naming a web test actually executes it.
  • Save output to a file and read the file: mage test:filter Foo 2>&1 | tee /tmp/out.log. Tests are expensive; never re-run one just to grep differently.
  • Fixtures live in pkg/db/fixtures/. Use them instead of inventing data.
  • Every permission path needs a positive and a negative test: the allowed user succeeds, an unrelated user is denied.

Frontend

  • E2E: invoke the run-e2e-tests skill (mage test:e2e). Never run pnpm test:e2e directly.

  • user/registration.spec.ts and user/settings/email.spec.ts need Mailpit and a mail-enabled API on port 3457, which mage test:e2e does not start; CI does. Local setup: frontend/tests/e2e/README.md.

  • Prefer e2e tests over component tests. User-visible behaviour (a created item appears, an edit sticks after reload, a delete removes the row) and the "How to verify" steps of a PR belong in frontend/tests/e2e/. Extend an existing spec when one covers the page; add one when none does.

  • E2E tests assert stored state, not just a toast: reload the page, and check the API through the apiContext/userToken fixtures where the UI could lie.

  • Component tests are only for what e2e can't exercise reliably: request races, stale responses after navigation, identity changes mid-request, and similar timing cases. Mocked component tests pass while the real page is broken, so they don't replace an e2e test for the same behaviour.

  • Same for cache modules: a unit test covers what e2e can't see (dataUpdateCount untouched, no request made, total/count bookkeeping). "Edit in the detail shows on the list and the board" is an e2e test, not a QueryClient test.

  • E2E tests that assert requests after navigating away and back must page.reload() first: the in-memory query cache serves the return navigation within staleTime, so "exactly one request" passes without proving anything.

  • Drag/position tests need asymmetric fixture positions (100/250/300). With 100/200/300 the computed midpoint equals the dragged item's own position and the assertion cannot fail.

  • A @/client/generated mock must not adapt the SDK into a different API (a getAll(scope, {...query, s: query.q}, page) shim). Assertions then read fields the mock invented; assert the path/query passed to the SDK function.

  • Unit tests: pnpm vitest run <file> in frontend/. Mock @/client/generated (vi.mock('@/client/generated', () => sdk)) and, when the code toasts, @/message; run the real query modules and QueryClient against them.

  • src/test-setup.ts seeds window.API_URL = '', so getApiBaseUrl() is /api/v2 without stubbing @/helpers/apiUrl. Set window.API_URL in the test only when the base itself is under test.

  • Test mutation options through the real lifecycle: queryClient.getMutationCache().build(queryClient, options).execute(vars). To observe an optimistic write, assert inside the mocked request before throwing. Assert on our cache writes only; don't re-test TanStack's refetch or cancellation behaviour.

  • When a component test is justified and reads server data, mount it against a real QueryClient seeded through the key factories, and mock only the generated client. Don't mock the composable that owns the behaviour under test; a mocked read hid a table that never updated after mutations. If a mutation invalidates a query, the client mock must answer the refetch with data matching the seeded state.

  • A regression test must fail against the unfixed code. Check that before landing the fix.

  • A test that can't fail is a defect. Examples: asserting that an unseeded cache is toBeUndefined(), checking for hidden controls on the viewer's own row (it never shows them), asserting two key literals differ, or calling the fix itself (observer.reset()) instead of going through the component.

  • Typecheck with pnpm typecheck in frontend/ and read the log. It has well over a thousand pre-existing errors, so compare the normalized error set against the base branch rather than the count; no new entries allowed:

    bash
    pnpm typecheck 2>&1 | grep 'error TS' | sed -E 's/\([0-9]+,[0-9]+\)//' | sort -u > /tmp/tsc-after.txt
    comm -13 /tmp/tsc-before.txt /tmp/tsc-after.txt

    Plain vue-tsc --build without --force can skip the build and print nothing, which looks like zero errors. Do not use vue-tsc -p tsconfig.app.json: it reports a spurious TS2589 on i18n.global.t that the project build does not, and it skips test files, so errors there (.at() is not in the lib target) go unnoticed until CI.


.Agents Docs Translations (`.agents/docs/translations.md`)

Translations

User-facing strings must be translatable.

  • Frontend: source strings in frontend/src/i18n/lang/en.json. In components: const {t} = useI18n({useScope: 'global'}), then t('your.key').
  • API (mostly notifications): source strings in pkg/i18n/lang/en.json. Use i18n.T(lang, "your.key", params...).
  • Edit only en.json. Never add a language or translate strings yourself; translations are managed at https://vikunja.io/docs/translations/. If asked to, point the user there.
  • Before adding a new string, check if a string with the same value already exists. If it does, use the existing key.

--- METRICS ---