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:
cd ../frontend
pnpm install
pnpm run buildThen, copy the frontend to this directory:
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" promptThen you can run the desktop app like this:
pnpm install
pnpm startBuilding for release
- Run the snippet from above, but with a valid frontend version instead of
unstable - Change the version in
package.json(that's the one that will be used by electron-builder) pnpm installpnpm 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
pnpm installDevelopment
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.exampleas.env.local - Uncomment the
DEV_PROXYline - 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)
pnpm run devCompiles and minifies for production
pnpm run buildLints and fixes files
pnpm run lintLicense
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
# 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
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 resultingvikunja-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 toPOST /login(local accounts only)--username+--password(non-interactive; implies--use-password)
Asks you to pick a project and a Kanban view.
Bootstraps the canonical buckets if missing:
Todo,In Progress,In Review,Done,Scrapped.Creates a
bot-<repo-name>user (Vikunja bot user โ no password, no
email, can't log in interactively).Shares the project with the bot at read+write.
Mints a long-lived API token for the bot via
PUT /tokenswithowner_id, scoped to the discovered route groups (tasks, comments,
labels, relations, assignees, etc.) the server actually exposes.Stores the token in your OS keychain (or
~/.config/veans/credentials.ymlif no keychain is available).Writes
.veans.ymlto 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
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 versionTask 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.
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: 99Credentials
Resolved in order on every command:
- OS keychain (macOS Keychain, Windows Credential Manager,
libsecret/gnome-keyring on Linux), viagithub.com/zalando/go-keyring. VEANS_TOKENenv var (read-only). Intended for CI / containers.~/.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, setVEANS_SERVER to the server URL to declare it trusted.
Mage targets
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 binaryEnd-to-end tests
The suite in e2e/ assumes a running Vikunja API. Locally, point it at any
dev instance:
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:e2eCI 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 runsmage test:e2e from this directory.
E2E tests never touch the developer's keychain โ they override HOME andXDG_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 inIn 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'sscopefield 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
- Installation
- Todos
- DB Sessions
- CRUDable
- Permissions
- Handler Config
- Preprocessing
- Standard web handler
- Errors
- URL param binder
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:
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:
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:
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:
func SetSessionFactory(sessionFactory func() *xorm.Session)The closure will be called for every request.
Full Example
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:
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 justlen(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.
Search
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.
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:
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 tagparam 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
/* 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:
// 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 wholebulma.sass. Each intentionally-excluded partial has an explanatory comment. The current
exclusions fall into three buckets:
- Moved to Vue components โ e.g.
elements/buttonlives inButton.vue,components/dropdownlives in the dropdown component. - Not used โ
breadcrumb,level,panel,tabs,notification,progress,message,hero,section,footer,tiles. - Replaced by a custom implementation in
theme/โ most notablyhelpers/spacingis replaced bytheme/logical-spacing.scssso 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 inglobal.scss rather than duplicating the rules.
CSS custom-property token system
All design tokens are defined as CSS custom properties on :root incustom-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:
--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:
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 ofcolors.scss mirrors Bulma's own defaults and exists as a workaround for abulma-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
- Decide whether it's a color, shadow, or something else โ add it to the matching file.
- 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. - Add the dark-mode override inside the
&.dark { @media screen { โฆ } }block. - 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:
@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:
custom-properties/colors.scssdefines the light-mode values on:root.- The same file defines overrides inside
:root.dark { @media screen { โฆ } }. - A
darkclass is toggled on<html>at runtime (see the color scheme composable). - 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 throughMAILER_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:
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.1From the repository root, start the mail-enabled API in another terminal:
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:
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-routesskill. - 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 withCan*methods (crudableskill); 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'sCan*methods. - Permissions live in the model (
CanRead/CanWrite/CanCreate/CanDelete), never in CRUD handlers. One exception: non-CRUD v2 actions have noDo*wrapper, so the handler must load the entity and callCan*itself. Theapi-v2-routesskill shows the shape.
Frontend clients
- Resource data goes through the generated SDK and types in
frontend/src/client/generated, wrapped by the query modules infrontend/src/client/queries/. Bootstrap and session calls are the exception and call the SDK directly:/infoinstores/config.ts, token refresh inhelpers/auth.ts, login, register and logout instores/auth.ts. Use the snake_case fields directly. Importingaxiosor anymodels,modelTypesorservicespath is a lint error (no-restricted-importsinfrontend/eslint.config.js). - After adding or changing a v2 route or schema, run
mage generate:frontend-clientand commit the generated output. Never hand-edit it.mage check:frontend-clientverifies it is current and generation is repeatable. client/http.tsconfigures 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; passbaseUrl: canonicalApiBaseUrl(getApiBaseUrl())per call.throwOnError: falsehas two callers. The settings PATCH inclient/queries/account.tsuses it to accept the empty 304 AutoPatch sends for a patch that changes nothing. Token refresh throws aRefreshTokenErrorwhosefailure.kind(network,server,rate-limitedwithretryAtfromRetry-After,rejected) tells an outage or rate limit from a rejection, andrefreshToken()holds further refreshes untilretryAt. - Failed responses throw an
ApiError(client/problem.ts): anErrorcarrying the problem body's fields (VikunjaErrorModel) with top-levelstatusanddetail, pluscodewhen 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 otherErrors, so tell them apart withinstanceof ApiError. Readstatusandcodedirectly 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 withexpectBlob(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_pageso the server default applies; send a fixed size only when fewer are needed (PICKER_PAGE_SIZE). Sweep every page withfetchAllPages()only when the client needs the whole set (labels, gantt tasks, shares), and omitper_pageso the server uses itsmax_items_per_page(reported by/info). The server silently caps any largerper_pageat that value, so drive paging from the response'stotal_pages/per_page, never from the size you asked for. - Every resource gets one
XResponsetype plusnormalizeX()applied in eachqueryFn(ProjectResponse,TaskResponse). Guaranteed fields are exactly the backend fields withoutomitempty; 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:fooKeysfactory,foosQuery()viaqueryOptions(),createFooMutationOptions()etc. viamutationOptions(), thin hooksuseCreateFooMutation()=useMutation(createFooMutationOptions()), imperative readersensureFoos()/refreshFoos(), and pure lookup helpers overFoo[].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 throughuseCreateFooMutation()etc. They never touchqueryClient. *MutationOptions()are exported for tests and for the outside-component case below. Components and composables must not pass them touseMutationthemselves; use the matchinguse*Mutation()hook.- Outside components (Pinia setup stores, router, plain modules)
use*hooks have no inject context. Read viaensureFoos()/refreshFoos(); write viauseMutation(createFooMutationOptions(), queryClient)in the store setup.
Rules:
- Mutations use
contextMutationOptions(client/queries/contextMutation.ts). It dropsonSuccess/onError/onSettledonce the request context changed, toasts failures unlesstoastError(input)returns false, and itsoptimisticoption does the cancel, snapshot and rollback.toastErrorsees the input, not the error, so it cannot suppress a single status. - Failed queries toast through
QueryCache.onErrorinclient/queryClient.ts; cancellations and request-context aborts are skipped. A query whose consumer renders its own error state setsmeta: {handlesError: true}, or the user gets both. Mutations are not covered there. mutationFnshapes input, calls the generated client, returns the narrowed entity. No separatecreateFoowrapper unless something else calls it. The client is configured withthrowOnError: true, so failures throw; don't adderrorchecks.- All cache writes (
setQueryData,invalidateQueries,cancelQueries) live in mutation option callbacks. Use theclientpassed in the callback context, not thequeryClientsingleton. 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, orcurrent?.map(...)), so a query nobody mounted is never materialized. - Every mutation invalidates the affected list key in
onSettled. A targetedsetQueryDatainonSuccessalone is not enough: a refetch started during the request would overwrite it. For expensive lists thatonSuccessalready patched (the paginated project list, task lists, boards), passrefetchType: '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. cancelQueriesonly as part of a full optimistic flow:onMutatecancels, snapshots and writes;onErrorrestores the snapshot (only if one existed);onSettledinvalidates. Never as a standalone guard around a request.setQueryDatamatches keys exactly;invalidateQueriesmatches by prefix. Pass the exact key of a live query tosetQueryData, and addexact: truetoinvalidateQuerieswhen 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: keepPreviousDataonly 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:
-1is 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.
invalidateTaskMembershipforcesrefetchType: 'none'on board keys whatever the caller passes. The only board refetch is bucket delete, which also refetches the project because the server clearsdefault_bucket_id/done_bucket_id. - Paged lists are keyed by page number, not infinite queries. Removing an item rewrites
totalandtotal_pageson every cached page of that scope (key minus page segment, group byhashKey), 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 liveuseQuerycomposable. 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, andtry { await mutateAsync(x) } catch { return }with success-only code after the block. AmutateAsyncrejection that escapes reaches the global error handler and toasts a second time. useAuthStore().infois theGET /userquery (currentUserQuery, keyaccountKeys.user(id, type)), falling back to the JWT's id and username until it loads.is_adminand settings come only from the query. Write the account through the mutations inclient/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
useSecretMutationinstead ofuseMutation. It setsgcTime: 0and resets the mutation once it settles, so the component needs noreset();gcTimealone does nothing while a mounted observer still holds the mutation. Read a returned secret frommutateAsync, never fromdata.
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 runmage generate:swagger-docsunless asked.
.Agents Docs Code Style (`.agents/docs/code-style.md`)
Code style
General
- Before adding a file, function, or helper,
rgfor existing code that does the same thing, in Go and infrontend/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
gofmtpermits 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 vias.Exec,s.Query, orbuilder.Expr.
Gotcha: an argument-lessbuilder.In("col")is silently dropped byWhereand matches every row. Pass an empty typed slice ([]int64{}) to get0=1. - High-entropy random tokens are stored as plain SHA-256 (
utils.Sha256Hex), never a slow KDF โ seesessions.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.tsis 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-modelarray 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
disabledto a control that can hold focus during or after a mutation, including an empty state reached after success; focus drops to ``. Usearia-disabledand 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 (seepkg/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 themysqldialect.- Case-insensitive search goes through
db.ILIKE(ILIKE on Postgres, LIKE elsewhere). - ParadeDB
bm25indexes (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 -llists targets;pnpm runinfrontend/lists scripts.pnpm devserves the frontend on port 4173 (override withVIKUNJA_FRONTEND_PORTor--port).- Scaffolding:
mage dev:make-migration <StructName>(prompts if omitted),dev:make-event,dev:make-listener,dev:make-notification.make-listeneralso adds theevents.RegisterListener(...)call; a listener only fires if registered there. - Invoke the
migrationskill before touchingpkg/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_URLandlocalStorage.API_URLhold the server root without/api/vN;''means the frontend's own origin.frontend/index.htmlships'';pkg/routes/static.goreplaces it withservice.publicurl.- Build request URLs with
getApiBaseUrl()(appends/api/v2) orgetApiRootUrl()fromfrontend/src/helpers/apiUrl.ts, never fromwindow.API_URL.normalizeApiUrl()strips a trailing/api/vNfrom 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: alsopnpm lint:styles:fix
- backend changes:
- 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 rebaseaborts. - 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 frommerge-baseonce 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 onmaincan 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 addorgh stack link, never plaingh 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,
projectIdtoproject_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 forkeypath=andt('before committing; thesharedBytoshared_byrename 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 withgit rebase -i --autosquashso 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 --autosquashin a throwaway worktree after each fix round. - Before merge: rebase the whole stack onto
origin/mainfirst, then autosquash each layer onto the squashed layer below. Checkgit 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>. Plaingo testdoes not work. mage test:filterruns most packages with-shortbut re-runspkg/webtestswithout 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-testsskill (mage test:e2e). Never runpnpm test:e2edirectly.user/registration.spec.tsanduser/settings/email.spec.tsneed Mailpit and a mail-enabled API on port 3457, whichmage test:e2edoes 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/userTokenfixtures 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 (
dataUpdateCountuntouched, no request made,total/countbookkeeping). "Edit in the detail shows on the list and the board" is an e2e test, not aQueryClienttest.E2E tests that assert requests after navigating away and back must
page.reload()first: the in-memory query cache serves the return navigation withinstaleTime, 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/generatedmock must not adapt the SDK into a different API (agetAll(scope, {...query, s: query.q}, page)shim). Assertions then read fields the mock invented; assert thepath/querypassed to the SDK function.Unit tests:
pnpm vitest run <file>infrontend/. Mock@/client/generated(vi.mock('@/client/generated', () => sdk)) and, when the code toasts,@/message; run the real query modules andQueryClientagainst them.src/test-setup.tsseedswindow.API_URL = '', sogetApiBaseUrl()is/api/v2without stubbing@/helpers/apiUrl. Setwindow.API_URLin 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
QueryClientseeded 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 typecheckinfrontend/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:bashpnpm 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.txtPlain
vue-tsc --buildwithout--forcecan skip the build and print nothing, which looks like zero errors. Do not usevue-tsc -p tsconfig.app.json: it reports a spurious TS2589 oni18n.global.tthat 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'}), thent('your.key'). - API (mostly notifications): source strings in
pkg/i18n/lang/en.json. Usei18n.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 ---
- Files Extracted: 16
- Estimated Token Budget: ~15725 tokens
- Canonical Reference: https://codewiki.google/github.com/go-vikunja/vikunja