- Rust 99.9%
- Makefile 0.1%
This is a optional feature. You can now set a custom field for a project called `stage`. You can define custom values in the settings, like "in review", "needs review", "approved", "ready for deploy" and so on. |
||
|---|---|---|
| src | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| Makefile | ||
| README.md | ||
kimai-tui
A terminal UI for Kimai, written in Rust.
┌────────────────────┬──────────────────────────────────────────────────────┐
│ ▸ Dashboard 1 │ Good morning, Alex · Thursday, 6 August 2026 │
│ Tasks 2 │ │
│ Projects 3 │ ┌ Running ───────────┐┌ Today ──┐┌ Week ───┐┌ Tasks ┐│
│ Customers 4 │ │ ▶ 0:12:34 ││ 6:15 ││ 28:45 ││ 12 ││
│ Activities 5 │ │ Acme GmbH · Website││ 4 entr. ││ of 40:00││ open ││
│ Timesheets 6 │ └────────────────────┘└─────────┘└─────────┘└───────┘│
│ Settings 7 │ │
│ │ This week By project │
│ │ Mon ████████░░ 8:00 Acme · Websi… █████████ 12:30 │
│ │ Tue ███████░░░ 7:15 Globex · Sho… ████░░░░░ 7:15 │
│ │ Wed ███████░░░ 7:45 Initech · Ad… ██░░░░░░░ 4:00 │
│ ────────────────── │ Thu █████░░░░░ 6:15 ◀ │
│ ▶ 0:12:34 │ ──────────────────── │
│ Website │ Week ███████░░░ 28:45 / 40:00 · 72% │
│ Development │ │
│ x stop │ Today 6:15 │
├────────────────────┴──────────────────────────────────────────────────────┤
│ q quit ⇥ focus ↑↓ move Esc back r reload ? help │
└───────────────────────────────────────────────────────────────────────────┘
The bottom of the sidebar is the running timer: elapsed time, what it is booked against, and how to stop it. It is drawn from every section and re-checked every 30 seconds, so starting or stopping a timer in Kimai's web UI shows up here too.
Install
make install # builds --release and copies to ~/.local/bin
PREFIX and BINDIR are overridable: make install BINDIR=/usr/local/bin.
make help lists every target; make uninstall removes the binary again.
First run
Starting kimai without a configuration opens a wizard asking for your
instance URL and an API token. Create the token in Kimai under your avatar →
API access. The wizard checks the credentials against /api/version and
/api/users/me before writing anything, so a rejected token never lands on disk.
The configuration is stored at ~/.config/kimai-tui/config.toml
($XDG_CONFIG_HOME is honoured) with mode 0600:
url = "https://your-instance.kimai.cloud"
api_token = "…"
[preferences]
weekly_hours = 40
week_start = "monday"
landing_section = "Dashboard"
timesheet_page = 50
recent_entries = 5
dashboard_projects = 6
task_filter = "open"
visible_filter = "visible"
Everything under [preferences] is optional and has a default, so a file with
only the two credentials works — and the wizard carries the whole block through
untouched, so --reconfigure will not eat it. The Settings section edits all
of it in place; see below for what each one does.
Re-open the wizard any time with kimai --reconfigure, or from Settings with
↵ on the Instance row.
Keys
| Key | Action |
|---|---|
↑ ↓ / j k |
move or scroll within the focused pane |
Tab |
switch between sidebar and content |
Enter |
open what the cursor is on; leave the sidebar |
Esc |
back one step: close a page, drop a filter, leave the pane |
g / G |
jump to first / last |
f |
cycle the section's filter |
/ |
search the list — every section that has one |
O |
order the list — Tasks, Projects, Customers |
n / e |
create / edit, in every section |
S |
start a timer, from any section |
x |
stop the running timer, from any section |
^S |
save the open form |
1 … 9 |
jump straight to a section |
r |
reload the current section |
s |
open Settings |
? |
help overlay |
q / ^C |
quit |
Following the hierarchy — customer, project, activity, task — is four keys named after where they go. They work from a list row and from an open detail page alike:
| Key | Goes to |
|---|---|
c |
the customer of the selected record |
p |
its project, or a customer's projects |
a |
its activity, or a project's activities |
t |
the tasks of the selected record |
Arriving that way narrows the target section to the record you came from, which
its title says (Projects · Acme GmbH · visible). Esc drops the
narrowing and shows everything again.
Two of them are already taken in the Tasks section, where t tracks time and
c closes a task, so a task links only to its project (p) and its activity
(a) — its customer is one further hop, through the project. Mind the reverse
too: arriving in Tasks with t and pressing t again starts a timer.
| Key | Only in |
|---|---|
t |
Tasks: track time on the selected task |
c / o |
Tasks: close the task / reopen a closed one |
m / F |
Tasks: set the stage / filter by it — once one is configured |
← → |
Timesheets: switch between the two lists |
c |
Timesheets: start new work like an entry |
S and x belong to the shell rather than to a section, so they also work with
a detail page open — a page only keeps the keys it scrolls with, and hands the
rest back.
Sorting a list
O opens a picker of every column the list has, each way round — Due ↑
ascending, Due ↓ descending — and the column it is ordered by carries the
arrow in its heading from then on. Type to narrow the picker: due finds one
column, desc finds one direction.
| Section | Columns |
|---|---|
| Tasks | ID, Title, Status, Stage*, Project, Activity, Due, Est. |
| Projects | ID, Name, Number, Customer, Ends |
| Customers | ID, Name, Number, Company |
The choice is written to config.toml straight away, so it survives leaving
the section and closing the program; the same three orders are rows in Settings.
Nothing is refetched — the records are already loaded, so the order changes in
the same key press, and the cursor stays on the record it was on rather than on
the row it was on.
* Stage is offered only once a stage field is configured — see Stages — and it orders by the vocabulary's own order rather than alphabetically, since a workflow rarely runs A to Z.
A record with nothing in the column sorts last whichever way the list runs: a task with no due date is not the one due first, and a task nobody has given a stage is not at the first one. Records that tie keep the order Kimai sent them in, which is by name for projects and customers.
Sorting happens in the client rather than in the query, and that is a finding rather than a preference — see Sorting is client-side because the endpoints cannot do it.
Searching a list
/ opens a bar along the bottom of the screen, where the key hints usually are,
and the list narrows as you type. Matching ignores case and matches anywhere in
a field.
| Section | Matches on |
|---|---|
| Tasks | title, to do |
| Customers | name, number, company, comment |
| Projects | name, number, customer, comment |
| Activities | name, number, project, comment |
| Timesheets | description, customer, project, activity, tags |
The rule is: the columns the row actually draws, plus the record's own free-text note. A task's description is the exception — on a task that field is the template Kimai copies onto entries started from it, so searching it would match every task sharing one.
While the bar is open the list stays live underneath: ↑ ↓ pick among the
matches, and every other letter is typed rather than bound, so n is a letter
and not new. ↵ closes the bar and leaves the list narrowed, handing the
letters back — opening what you found therefore takes a second ↵. From then on
the pane title carries the query (Tasks · open · “consent”), which is the only
thing saying why rows are missing. Esc clears the search and shows everything
again; it does that before it drops a parent, so a scoped and searched list takes
two presses to widen fully. Reloading with r and cycling the filter with f
both keep the query — they ask the same question again rather than widening it.
In Timesheets the query narrows both lists, the resume block and the entries,
so ← → between them keeps it.
Matching happens in the client, over the records already loaded — the same
trade-off sorting
makes, and it costs no round trip. The limit is the same one too: /tasks and
/timesheets page, so a record past the page that came back is not there to be
found. Customers, projects and activities return everything and are unaffected.
/tasks does accept a searchTerm parameter, but which fields it covers is
documented nowhere, so a query sent there could not be said to search title and
to do — only to search something.
Inside a form
Everything you type into — the five entity modals, a picker's filter, the search bar, the settings prompt, the setup wizard — edits with a caret rather than only appending, so a typo in the middle of a value is reachable without deleting back to it:
| Key | Action |
|---|---|
← → |
move the caret one character |
^← ^→ |
move it a whole word |
Home / End |
start / end of the line — ^A / ^E do the same |
Backspace / Delete |
the character before / under the caret |
⇥ / ⇧⇥ |
next / previous field |
↵ |
choose or move on — a new line in a paragraph field |
↑ ↓ |
previous / next field, once the caret has left a paragraph |
^U |
clear the field |
^S / Esc |
save / cancel |
The fields with rows to spare are the ones holding a paragraph — a task's to
do and description, a time entry's description, and the comment on
a customer, project or activity. ↵ and ⇧↵ both make a line in one of those:
most terminals cannot report the difference between them at all, so a newline
that needed the modifier would work in some and silently jump to the next field
in others. The key line at the foot of every form says which of the two ↵
currently means.
Dashboard
The section the app opens on, and the only read-only one: it binds no keys of its
own, so r, S, x, 1…9 and ⇥ all keep their usual meanings and nothing
on this screen writes to Kimai.
Four cards across the top — what is running, what is booked today, what is booked this week, and how many tasks are open — then the week as a bar per day beside the same week split by project, then what is due today, then today's entries in full.
Due today lists the open tasks that have run out of time: those due today,
and the overdue ones above them, oldest first and with the date they slipped in
red. It shows five before the rest becomes +N more, and a ▶ marks a task
that has a timer running. The band is absent entirely when nothing is due —
it costs no rows on a quiet day — which is also how it behaves without the Task
Management plugin. Like the rest of the pane it takes no keys: press 2 for the
Tasks section to do anything with what it lists.
A due date is read as the calendar date Kimai sent, not converted to this machine's timezone, so a task appears here on the same day the Tasks list prints for it.
Kimai has no stats endpoint, so every figure is summed here from one
GET /api/timesheets over the current week. Two consequences worth knowing:
- The totals are what Kimai booked, not the time between start and stop. On an instance that rounds up to the quarter hour, a minute of work counts as 0:15 — which is what Kimai's own reports show, and the reason the numbers agree with them.
- A running entry is counted from its start, so today's total and the card climb in step with the sidebar clock. The running entry itself comes from the same 30-second poll the sidebar widget uses, so the two can never disagree.
The week runs Monday to Sunday, or Sunday to Saturday — Kimai has a per-user "start of week" preference but does not expose it anywhere in the API, so this one comes from Settings → Week starts on instead.
Set a Weekly target and the total row becomes a progress bar against it
(28:45 / 40:00 · 72%); leave it blank and it reports an average over the days
that were actually worked. Bars use each project's own Kimai colour.
The Tasks card degrades on its own: without the Task Management plugin it shows
— and unavailable, and the rest of the pane is unaffected. A short terminal
drops whole bands from the bottom rather than scrolling — the due list first,
then the charts, then the cards — because the dashboard is meant to be one
screenful.
Tasks
The list shows id, title, status, project, activity, due date and estimate, with
a green ▶ on any task that has a running timer.
Kimai hides finished tasks by default, so f cycles the status filter —
open (pending + progress) → closed → all — and the active one is
shown in the pane title (Tasks · closed). / narrows the list further, by
title and by to do — see Searching a list. The two are
independent, and the title names both.
n creates a task and e edits the selected one (also available from the
detail page). Ctrl-S saves, Esc cancels.
c closes the selected task and o reopens a closed one. Two keys rather than
one toggle, so neither can do the opposite of what the status bar just said: the
hint names whichever transition applies to the task under the cursor, and swaps
to o reopen on a finished one. They are transitions rather than a status
picker because that is the whole of what the plugin offers — see below.
Closing a task also stops a timer running on it, which Kimai does by itself,
and a closed task drops t from its hints because starting a timer on one is
HTTP 403. Closing under the default filter makes the task disappear from the
list it was closed from, so the confirmation says how to get it back.
Stages
Kimai gives a task three statuses and no way to add a fourth. A stage is the second dimension beside them — needs review, on hold, approved — for where the work sits with the customer rather than whether it is being worked on. The two are independent on purpose: a task in review is still in progress, and closing one leaves its stage alone.
It is off until you set it up, and while it is off nothing about the Tasks section changes — no column, no keys, no form row.
- In Kimai, create a custom field for tasks. Its machine name is what the app
needs;
stageis the obvious one. - In Settings, put that name in Stage field and your vocabulary in Stages, comma separated in workflow order.
Then the list grows a Stage column beside Status, m sets the stage of the
selected task — from the detail page too — and F narrows the list to one stage,
to no stage for whatever has not been triaged, or back to all. O can order by
it, and the order is the one you configured rather than the alphabet.
F works like f: the preference is where the list starts, and the key changes
it for this session without touching the file. Both it and the search narrow what
was already fetched — /tasks cannot filter on a custom field at all — so the
same page-of-results limit applies as everywhere else in
Searching a list.
Writing a stage is its own request, because Kimai rejects custom fields on the calls that create and update a task. If the field name does not exist the write is a 404, and the message says to check Settings or create it in Kimai — see Stages are a custom field, not a status.
The form covers title, project, activity, assignee, due date, estimate, tags, to do and description. Project, activity and assignee use a type-to-filter picker; choosing a different project drops an activity that does not belong to it, and the assignee list starts with an entry that leaves the task unassigned.
To do and Description are deliberately separate blocks, because they mean different things: to do stays on the task, while the description is a template Kimai copies onto time entries started from that task — as are the tags.
Dates take YYYY-MM-DD, optionally with HH:MM. Estimates take h:mm, 90m,
1.5h or a bare number of hours. Tags are comma-separated. Everything is
validated before anything is sent, and the offending field is highlighted; a
rejected save shows Kimai's own per-field messages.
To do, Description and the time entry's description are drawn as three
wrapped rows rather than one line, so a paragraph is readable rather than cut at
the panel edge. A value taller than that scrolls, marked with ⋮. The one-line
fields scroll sideways instead: while a field has focus it follows the end of
what you are typing, with … where the start is hidden, and once focus moves on
it goes back to reading from the beginning.
Editing sends only the fields on the form, so a task's team and status are left as they were.
Tags are created on sight. Kimai turns any unfamiliar tag name into a new tag entity — and it does so while binding the form, so the tag survives even when the request fails validation and no task is saved. The field lists the existing tags while it has focus to make near-duplicates less likely. The API can delete a tag (
DELETE /api/tags/{id}) butGET /api/tagsreturns only names, so removing a stray one means using the web UI.
Enter opens the detail page, which carries everything the API returns for that
task: status, due date, estimate against time actually logged (with a progress
bar that turns red past 100 %), customer and company, project and activity in
their own Kimai colours, assignee, team, tags, the to do and description text,
any custom fields — and, from GET /api/tasks/{id}/timesheets, every time entry
with its date, span, duration, rate and export flag, plus the total. A running
entry counts up live. Fields the model does not recognise are listed under
Other fields rather than dropped, so a plugin update stays visible.
Customers, projects and activities
Three sections of the same shape, one per level of Kimai's hierarchy. Each is a
list, a detail page behind ↵, and a create/edit modal on n and e. There is
no delete — see the note on hiding below.
f cycles which records the list asks for: visible → hidden → all,
shown in the pane title. A hidden row is marked ∅. / narrows the list by name
and by the parent it names — see Searching a list.
Hiding is this client's archive. Kimai's collections return visible records only unless asked otherwise, and nothing here deletes, so clearing Visible on a record is how it is retired — and
fis the only way back to it. A hidden customer also stops being offered for new projects: the endpoint rejects one with "The selected choice is invalid".
A customer page carries what the list cannot: GET /customers leaves out the
address, the contact block and the budgets, so both the page and the edit form
read GET /customers/{id} first. The form covers name, number, company,
visibility, billing, colour, the locale trio, the contact block, the postal
address, VAT and invoicing, and the budgets. Country, currency and timezone have
no endpoint listing what Kimai accepts, so ↵ on those offers the values this
instance already uses; a new customer starts on your own account's timezone.
A project belongs to exactly one customer and cannot be saved without one.
Its page lists the activities it can book, which is Kimai's own answer and not
simply "the ones belonging to it": the shared ones are included when Global
activities is set. Dates take YYYY-MM-DD.
An activity is either global — every project can book it — or bound to one project. The list says which, the picker leads with the global entry, and a new activity created from inside a project-scoped list is bound to that project.
A binding can be given up but never moved.
POST /api/activitiestakes a project;PATCHaccepts onlynullthere and rejects an id outright as an extra field, failing the whole request. So the edit form offers releasing an activity to global and nothing else, and says so on the row.
Every edit sends only the fields that actually changed. That is what makes a partial update safe here: the collections omit fields the entity has, so a form that wrote back everything it displayed would blank whatever it never read.
One field is write-only. invoiceText is accepted on customers, projects and
activities but never returned by any route, so it cannot be prefilled — leaving
it blank keeps whatever is stored.
Time tracking
The Timesheets section is two lists. Resume on top is Kimai's own recent
activities — the last five distinct project/activity combinations — and below it
are the entries themselves, newest first. ←/→ switch between them, and the
running entry counts up live in both the list and the sidebar. / narrows
both lists at once — see Searching a list.
↵ opens whatever it is pointing at, which is not the same thing in the two
lists: on Resume it starts a new timer modelled on that combination, on the
entries list it opens that entry for editing. They are deliberately separate,
because treating a real entry as a template moves its date to today — save
without noticing and the time lands on the wrong day. n starts from scratch,
e edits, and c starts new work modelled on an entry too old to be on the
shortcut list. The status bar names whichever ↵ currently does.
All of them open the same modal, which only prefills — nothing is sent until
^S. The fields narrow each other from the top down: a customer limits the
projects, a project limits the activities, and picking a project fills its
customer in above.
S opens that same modal, blank, from any section — the counterpart of x,
and the reason neither of them is bound by a section. It is owned by the shell,
so it draws over whatever you were reading and leaves it exactly as it was when
you press Esc; deciding to track something does not cost you your place. The
warning about which timer it is replacing comes from the shell's own poll rather
than from a loaded list, so it is right even in the sections that never fetch a
timesheet.
Date, Start and Duration decide when the entry sits and how long it lasted. An empty duration means the entry is running — that is the whole difference between starting a timer and logging finished work, and the modal's heading follows along: Start a timer, Log time, or Edit entry #43. Clearing the duration of a finished entry puts it back on the clock.
Duration is the one Kimai booked. The API has no duration field at all — the server derives it from the start and end and rounds it by its own configuration. This instance rounds up to the next quarter hour, so an entry that ran 14:36–14:37 is booked as 0:15, and two thirds of the entries on a real instance have a booked duration that differs from their clock span. Booked is the figure on the list, in the web UI and on the invoice, so booked is what the field prefills; the clock span is disclosed underneath while the field has focus.
The consequence is that start + duration is not always the stored end. So editing an entry keeps its original timestamps and only rebuilds them when date, start or duration is actually changed — otherwise editing a description would move that 14:37 end out to 14:51.
Editing sends only the fields on the form, so an entry's rates, its billable flag and its export flag are left exactly as they were.
t in the Tasks section starts a timer on the selected task instead. That is a
different route on purpose: PATCH /api/tasks/{id}/start links the entry back to
the task and copies the task's own project, activity, description and tags — a
link nothing else can create. It also moves the task to progress. So t and
S are not two spellings of the same thing: S is free-form work, t is work
that belongs to a task, and only t can say so.
Starting a timer stops whichever one is running. That is Kimai's own behaviour under
activeEntriesHardLimit(1 on this instance): it stops the old entry rather than refusing the new one. The modal says which timer it is about to replace.
Settings
A list of rows rather than a form. ↑↓ moves, ↵ opens an editor for the
selected row and nothing else, and every global key keeps working the rest of the
time — a form would swallow q, and a section you cannot quit from is a bad
section. Each row gets the editor that suits it: the shared picker for a choice,
a small prompt for a number, and the setup wizard for the connection, which is
the one thing that has to be checked against Kimai before it is written.
A change is written to config.toml immediately and handed to every section, so
it takes effect where you are rather than at the next launch.
| Row | Key | Does |
|---|---|---|
| Instance | — | opens the connection wizard (URL and token) |
| Weekly target | weekly_hours |
the dashboard's progress bar; blank for none |
| Week starts on | week_start |
monday or sunday, for the day chart |
| Projects listed | dashboard_projects |
1–12, before the rest becomes +N more |
| Opens on | landing_section |
the section shown at startup |
| Time entries | timesheet_page |
5–500, how many the Timesheets list fetches |
| Resume shortcuts | recent_entries |
1–15 rows in the resume list |
| Tasks start on | task_filter |
open, closed or all |
| Records start on | visible_filter |
visible, hidden or all |
| Tasks order | task_sort |
e.g. due asc, estimation desc |
| Projects order | project_sort |
e.g. name asc, customer desc |
| Customers order | customer_sort |
e.g. number asc |
| Stage field | task_stage_field |
the task custom field holding the stage; blank turns stages off |
| Stages | task_stages |
the stages offered, in workflow order |
| Tasks stage | task_stage_filter |
all, (none), or one stage — where the Tasks list starts |
The two filters are where a list starts. Setting one applies it straight away
and it is what the section opens on next time; f still cycles from there
without touching the file.
The three orders are not a starting point but the order itself: O in a section
writes what it was given straight back here, so the row and the list can never
disagree. Each is one string — a column and a direction, asc when the
direction is left out — and a value naming no column falls back to the default
rather than refusing to start.
The counts are bounded, and a value outside its range is refused with the reason rather than quietly clamped — the file would otherwise say one thing while the screen showed another. A value hand-written outside the range is clamped on load, since there is nobody there to tell.
The last row or rows of the modal are the timesheet's custom fields — the
remote task among them. Their names are read off the most recent entry rather
than from GET /api/metafields, which predates that endpoint being found; the
form has one row per field it finds. They also cannot be set while creating an
entry, so they follow as a second request; see the table below.
Inspecting the API
probe performs a raw GET against any path below /api using the stored
token — handy before writing a view for a new endpoint:
kimai probe /tasks
kimai probe /timesheets -q size=5 -q order=DESC
-X and -d send a body instead, which is how the write side of an endpoint
gets mapped: which fields it requires, which it rejects as extra, and whether a
PATCH really is partial. It writes to the instance, so point it at a
throwaway record:
kimai probe -X POST /customers -d '{"name":"zz-scratch"}'
kimai probe -X PATCH /customers/13 -d '{"city":"Berlin"}'
A path that does not exist answers 404; one that exists but refuses the method
answers 405. So a plain GET maps the route surface without writing anything —
that is how the tables below were built.
Adding a section
The sidebar is the list of registered views, so a new entry is:
- a file in
src/tui/views/implementing theViewtrait (src/tui/views/mod.rs), - a variant in
ApiMessage(src/tui/event.rs) for whatever it fetches, - one line in
build_views()insrc/tui/app.rs.
Nothing in the event loop or the layout changes. src/tui/views/tasks.rs and
customers.rs are the worked examples: a list, a detail page and a modal each,
with the parts that hold no state living in views/form.rs (field rows, toggles,
the scrolling panel) and views/detail.rs (field lines, wrapping, scrolling).
A section that links to another one returns Action::Goto from handle_key
carrying a Jump (src/tui/nav.rs). The app resolves the target by title, hands
it the Scope through View::scope_to — before on_enter, so it fetches for
the new question rather than the old one — and moves the focus. A Jump names
its target by title, so app.rs has a test that every one of them still resolves
to a section; a typo would otherwise be a key that quietly does nothing.
Every list carries the ListRequest it was asked for back with its reply and
drops the reply if the scope or the filter has changed since. A fetch is slower
than a key press, and without that guard an older answer overwrites a newer one.
A view reports to the user with ctx.notice(…) or ctx.error(…), which put the
message in the status bar. It has no way to draw one into its own pane on
purpose: a message that appears and disappears would push the rows below it down
and back up, moving whatever was under the cursor.
The status row itself is the shell's, and View::footer is the one way into it:
a section returns the Line it wants drawn there in place of the key hints while
it is being typed into, and ui.rs renders it. That is content rather than
drawing — the view never sees a Rect — which is what lets the shell decide it
outranks a passing notice, and hide it while a modal or the sidebar has the keys.
View::hints stays &'static str on purpose: it is a fixed set of literals
joined into a list, and a live value formatted into it would appear between the
global keys rather than replacing them.
A section that needs to pick an entity gets a filterable modal from
src/tui/views/picker.rs rather than writing its own list, one that needs a
single typed value gets src/tui/views/prompt.rs, and one that narrows its list
by typing gets src/tui/views/search.rs. Messages a view does not claim
are passed to the next one, which is how a started timer refreshes the Tasks
list, the Timesheets list and the sidebar widget from one reply.
A section that reads a preference implements View::on_settings, which the app
calls on every view after Settings changes one. That is a broadcast rather than a
rebuild on purpose: rebuilding the stack would throw away each section's cursor,
filter and loaded list to change one number. A view takes the new value there and
refetches only when the value actually changes what it would ask for.
That chain is why a section fetching something another one already has a variant
for needs its own (DashboardTasks, ActivityTasks): sent as Tasks, the Tasks
section would claim the reply and replace its list with a question it never
asked. The order in build_views() is the order replies are offered in, and the
Dashboard is first — so no view may have a catch-all claiming arm, or every
section behind it would starve.
Status
Implemented: setup wizard, configuration handling, sidebar shell, the Dashboard,
Tasks list with status filtering, task detail, creating and editing tasks, the
Customers, Projects and Activities sections with their detail pages and forms,
the links between all four, the Timesheets section, starting and stopping timers,
the running-timer widget, / search in every list, and an editable Settings
section.
Not implemented: deleting anything (hiding is the archive), and teams and rates on any record.
/api/tasks is provided by Kimai's Task Management plugin, whose response schema
is not publicly documented. The models in src/api/models.rs were derived from a
live instance with probe, and every field stays optional so a plugin update
cannot break the whole response.
The filters were found the same way. /tasks accepts searchTerm, customers,
projects, activities, tags, users, teams, status, size, page,
order and orderBy. status is a multi-value choice, so it must be sent
as repeated status[]=…; a scalar status=closed is rejected with HTTP 400.
The only accepted values are pending, progress and closed — anything else
is a 400 rather than being silently ignored, which is what makes the set
verifiable.
Sorting is client-side because the endpoints cannot do it
orderBy exists on all three collections, and on all three it takes a short list
of fields — not the ones a list is most often wanted in. Probed against the live
instance, an unaccepted value is rejected rather than ignored, so the sets below
are verifiable:
| Collection | orderBy accepts |
Rejected with HTTP 400 |
|---|---|---|
/api/tasks |
title, status, end, project, activity |
id, estimation, due, number, budget |
/api/projects |
id, name, customer |
number, end, orderNumber, budget, visible |
/api/customers |
id, name |
number, company, budget, visible |
order takes ASC and DESC everywhere. Ordering by id or by estimate — two of
the three things worth sorting a task list by — is therefore not on offer at all,
which is why src/sort.rs orders the records once they are here. That also means
the order changes without a round trip, and that every column each list draws can
be ordered by, not just the ones Kimai happens to index.
The one thing sorting here cannot do is reach past a page, and only /tasks and
/timesheets page at all; the other three collections return everything. Kimai's
own default order for /tasks is due date ascending with the undated last, which
is what TaskSort defaults to so an upgrade changes nothing on screen.
/ search is client-side for the same reason and carries
the same limit — it narrows what came back, not what exists. searchTerm does
exist on /tasks, but nothing documents which fields it looks at, so it could
not be described the way the table in that section describes what is matched.
Wiring it up would trade a verifiable answer for an unverifiable one.
The task API surface
Mapped against Kimai 2.63.0 / TaskManagementBundle 2.18.1. Unknown paths return
404, while a path that exists but rejects the method returns 405 — so a GET
probe distinguishes "no such route" from "route exists, wrong method":
| Route | Methods | Used by |
|---|---|---|
/api/tasks |
GET, POST | list, create |
/api/tasks/{id} |
GET, PATCH, DELETE | edit |
/api/tasks/{id}/timesheets |
GET | task detail |
/api/tasks/{id}/start |
PATCH | t in the Tasks list — the only route that links an entry to a task |
/api/tasks/{id}/stop |
PATCH | not wired up — x stops the timesheet instead |
/api/tasks/{id}/close |
PATCH | c |
/api/tasks/{id}/reopen |
PATCH | c, on a closed task |
/api/tasks/{id}/meta |
PATCH | the stage — m, and the form's Stage row |
/tasks/{id}/start takes no parameters: it ignores the request body and
starts immediately, returning the timesheet it created. There is no way to
preview or adjust what it will book, which is worth knowing before binding it to
a key.
Task status is a one-way street through pending
status is not a writable field — PATCH /api/tasks/{id} rejects it as an
extra field. The only transitions are the two routes above, and neither takes
parameters:
| Route | From | To |
|---|---|---|
/close |
pending, progress |
closed |
/reopen |
closed |
progress |
Repeating a transition that does not apply is a bare 400 Bad Request with no
explanation, so ApiError supplies its own hint.
pending cannot be restored. It is the status a task is created with, and
nothing puts it back: /status, /pending, /open and /progress all 404, and
reopen always lands on progress. That is why the UI offers a close/reopen
toggle instead of a picker — a picker would list a status that cannot be reached.
/start also moves a task to progress, but starts a timer as a side effect, so
it is not a status control.
This is why stages exist. Three statuses is all the plugin has, and none of
them can be added to. States like in review or on hold are not alternatives to
progress anyway — a task in review is still in progress — so they are a second,
independent dimension rather than more values for this one. See
Stages.
Stages are a custom field, not a status
A stage is stored in a Kimai task custom field and written with
PATCH /api/tasks/{id}/meta, taking {"name": …, "value": …} — the same body
/api/timesheets/{id}/meta takes. Established against the live instance:
-
The route exists. A
GETreturns405 Method Not Allowed, where an unknown path returns404, so the route is there and only the method is wrong. -
Tasks carry
metaFieldsin every response, alongside customers, projects, activities and timesheets. An instance with no custom field defined for tasks returns an empty array. -
metaFieldsis rejected onPOST /api/tasksandPATCH /api/tasks/{id}alike, exactly as it is on/timesheets, so a stage always goes out as a second request after the task itself is written. -
An unknown field name is a bare
500, not the404that/timesheets/{id}/metagives for the same mistake. The plugin crashes rather than validating — the same way a blank task title does — so the two collections genuinely disagree about how this fails. Confirmed by writing to a field that was not defined.Both shapes are matched, and the arm order is load-bearing in two directions:
/tasks/12/metaalso starts with/tasks, so the 404 arm has to precede the one blaming a missing plugin, and the 500 arm has to precede the general one — otherwise a stage write fails with "check the title and dates" on a request that carried neither. -
The reply is thinner than a list row. On
/timesheetsthis route reports the related entities as bare ids, which is why onlymetaFieldsis taken off what comes back — splicing the whole record into the list would blank the Project and Activity columns. -
A record only has slots for the fields that existed when it was created. Define
stagetoday and every task made afterwards reports{"name":"stage","value":null}and takes the write; every task made before reportsmetaFields: []and answers the write with the same HTTP 500 as an undefined field. Verified by defining the field and comparing an old task with a new one on the same instance.So the app asks
Stages::is_attachedbefore offeringm, and says the task is older than the field instead of making a request that can only fail. This is also why the 500 hint names both causes: from the outside they are the same error.
Creating a definition is not possible through the API. GET /api/metafields
is the whole surface: OPTIONS reports Allow: GET, and /api/metafields/{id}
does not exist under any method — checked against this instance and against
demo.kimai.org, with /api/timesheets (Allow: GET, POST) as the control
proving the probe does reveal writes. The endpoint comes from the paid
MetaFieldsBundle, which only ever shipped the admin UI for it; Kimai core says
the same of its own invoice fields — "You cannot create unknown custom-fields."
Definitions are made under System → Custom fields, gated by the
configure_meta_fields permission, which ships assigned to ROLE_SUPER_ADMIN
alone.
The app therefore checks rather than creates: Settings reads
GET /api/metafields?entity=task, names the fields that exist in the prompt's
caption, and refuses a stage field name the instance does not have — pointing at
System → Custom fields for an account that could add one, and at an
administrator for one that could not. Roles come from GET /api/users/me; Kimai
computes permissions from roles with voters and never serialises them, so a
role is the closest the API comes to saying what a user may do.
There is no endpoint for custom field definitions anywhere in the API, so the
vocabulary cannot be discovered: task_stages is the list, and it is also the
order the column sorts in. That matters more than it sounds — unapproved,
needs review, approved is very nearly its own reverse alphabetically, so a
plain text sort would run the workflow backwards.
The timesheet API surface
| Route | Methods | Used by |
|---|---|---|
/api/timesheets |
GET, POST | the list, the dashboard's week, n and S — starting a timer |
/api/timesheets/{id} |
GET, PATCH, DELETE | e — editing an entry |
/api/timesheets/active |
GET | the sidebar widget |
/api/timesheets/recent |
GET | the resume list |
/api/timesheets/{id}/stop |
PATCH | x |
/api/timesheets/{id}/meta |
PATCH | the custom fields on the start modal |
/api/timesheets/{id}/restart |
PATCH | not wired up |
/api/timesheets/{id}/duplicate |
PATCH | not wired up |
/api/timesheets/{id}/export |
PATCH | not wired up |
Starting a timer is POST /api/timesheets with no end — that is what makes
the entry a running one; there is no separate start route. Established against
the live instance:
beginaccepts RFC 3339 with an offset, and honours it. The offset is always sent, so a machine in another timezone than the Kimai account still books the right time.tagsmust be a comma-separated string. An array is rejected with "This value is not valid." — the exact opposite of/api/tasks, which wants a list. The two endpoints really do disagree; seeTimesheetPayload.metaFieldsis not accepted ("This form should not contain extra fields"), so custom fields go out afterwards asPATCH /api/timesheets/{id}/metawith{"name": …, "value": …}. An unknown name is a 404 rather than a new field.- The collection reports
project,activityanduseras bare IDs unlessfull=trueis passed;/activeand/recentexpand them either way. - A running entry reports
duration: 0, so elapsed time is counted frombeginrather than read off the payload. GET /api/config/timesheetreportsactiveEntriesHardLimit. At the default of 1, starting a second timer stops the first instead of failing.
Reading the collection takes a date range, which is what the dashboard sums — established against the live instance:
beginandendare accepted and really do narrow it: a Monday-to-Sunday range returned that week's three entries and left out the previous Friday's.- Both need the full
%Y-%m-%dT%H:%M:%S. A barebegin=2026-08-03is HTTP 400, the same rule asendon/api/tasks. sizeis honoured here rather than ignored —size=2over a whole year came back with two entries,size=200with all 37 — so a range wider than the page is silently short rather than paged./api/timesheetsand/api/taskspage; the customer, project and activity collections do not.- Whether a running entry passes the filter was not established: nothing was
running when this was probed, and starting one would have written to the live
instance. The dashboard therefore merges the entry from
/timesheets/activein by id rather than depending on the answer.
PATCH /api/timesheets/{id} takes the same fields and is a true partial
update — verified by patching one field and diffing the entry before and after.
Beyond that:
- There is no
durationfield; sending one is rejected as an extra field. A duration can only be expressed by movingend, and what comes back is rounded — soend - beginanddurationroutinely disagree. "end": nullreopens a finished entry, putting it back on the clock."tags": ""clears every tag, and"description": nullclears the text.- An inverted span comes back as a form-level error ("The end date must not be earlier than the start date"), not one attached to a field.
Custom field definitions are exposed, at GET /api/metafields — a flat
list carrying entityType, name, label, type, required and visible.
There is no /api/metafields/{id} (404), and the entityType query parameter is
ignored, so the list comes back whole and is filtered here.
(An earlier note in this file said definitions were not exposed anywhere. That was wrong — or true of an older release; re-probed against 2.65.0.)
The timesheet form predates that finding and still reads its field names off the most recent entry, which works because every timesheet lists all configured fields whether they hold a value or not.
Writing a task (POST /api/tasks, PATCH /api/tasks/{id}) requires title,
project and activity; todo, end, estimation, description, tags,
user and team are optional. Established against the live instance:
titlemust be at least 2 characters. A blank one makes the plugin return HTTP 500 instead of a validation error, soTaskFormrefuses to send it.endneeds a full%Y-%m-%dT%H:%M:%S; a bare date is rejected.estimationis whole seconds;tagsaccepts an array or a comma-separated string, and[]clears every tag.usertakes a user ID, andnullleaves the task unassigned.PATCHis a true partial update — omitted fields keep their value, and an explicitnullclears one. Verified by patching a single field and diffing the task before and after.
The customer, project and activity API surface
Mapped the same way, against the same instance. All three behave alike, so the differences below are the whole of what sets them apart.
| Route | Methods | Used by |
|---|---|---|
/api/customers |
GET, POST | the list, create |
/api/customers/{id} |
GET, PATCH | the detail page, edit |
/api/customers/{id}/meta |
PATCH | not wired up |
/api/customers/{id}/rates |
GET, POST | not wired up |
/api/projects, /api/activities |
the same four routes each | |
/api/{entity}/{id}/duplicate |
— | 404, no such route |
The collection is a subset of the entity. GET /customers omits the budgets and
the entire address and contact block; GET /projects and GET /activities omit the
budgets. So a list row is not enough to edit from — the forms read GET /{id} first,
and send only the fields that actually changed. Seeding a form from a row and PATCHing
it back would blank whatever the row did not carry.
These three collections ignore size and page and return every record, unlike
/tasks and /timesheets, which honour both. Nothing here needs paging.
visibleselects which records come back:1visible (the default),2hidden,3all.0— or anything else — is HTTP 400, which is what makes the set verifiable. With no delete route wired up, hiding is the archive, and this parameter is the only way back to a hidden record./projects?customers[]={id}narrows to one customer. A scalarcustomer={id}works too, but an unknown id is silently ignored and the full list comes back, so a filter that does nothing looks exactly like one that works./activities?projects[]={id}returns that project's own activities, plus the global ones only when the project hasglobalActivities: true. That flag is honoured server-side, which the client-side narrowing in the two forms does not do./tasksnarrows onprojects[],customers[]andactivities[]. The brackets are required —project=8is HTTP 400 — and unlike/projects, an unknown id is rejected rather than ignored.- A project row carries
parentTitle, which is its customer's name; an activity row'sparentTitleis its project's name,nullwhen it is global. That is why the lists need no second request to show a parent, even though/projectsstill reportscustomeras a bare ID.
Writing them:
- Only
nameis required for a customer and for an activity; a project also needscustomer. Country, currency and timezone are filled from the instance defaults, so the customer form does not have to ask for them. - A hidden customer cannot be given a project:
customercomes back as "The selected choice is invalid". The picker offers visible customers only. PATCHis a true partial update on all three — verified by patching a single field and diffing the record before and after.addressandbudgetRolloverare rejected as extra fields. The postal address isaddressLine1…addressLine3,cityandpostCode; the flataddressin the response is a read-only legacy field.invoiceTextis accepted on write but never returned, on customers and projects alike. It cannot be prefilled, so the form leaves it blank and only sends it when something is typed.timeBudgetis written in Kimai's duration format and read back in seconds:1is one hour,1:30and90mare both 5400.budgetaccepts12.50and12,50alike.budgetTypeismonthor null;yearis a per-field validation error.- A project's
start,endandorderDateneed a full%Y-%m-%dT%H:%M:%S— a bare date is "Please enter a valid date" — but they come back as bare dates. - An activity's project is create-only.
POST /activitiestakesproject, butPATCHrejects an id as an extra field. It does accept"project": null, which releases a bound activity to global — so the binding can be given up but never changed or reassigned.
Task comments are not available via the API
Investigated and ruled out — please don't re-investigate. Comments are a Task Management feature in the web UI, but no API route exposes them:
/api/tasks/{id}/comments,/comments,/task-comments,/taskComments,/task_comment,/notesand the singular forms all return404, i.e. no route matched, under any method.- The task payload contains no comments field, and
full=true,comments=trueandexpand=commentschange nothing.
The web UI reaches comments through a session-authenticated route that an API token cannot use. Listing or adding them needs upstream support in the plugin's API first.