# Per-issue time tracking
**Time tracking** records the actual effort spent on an issue: a whole number of
minutes, an optional `YYYY-MM-DD` calendar date, an author and an optional note.
It answers "how much work went into this?" — the question a plan's estimate cannot
answer after the fact — and it feeds the same numbers to the CLI, the TUI, MCP and
the portable export.
Everything is local. Current entries live in the issue's own frontmatter
(`time_entries`), every correction and deletion appends to the issue's ordinary
`## History`, and there is no database, no daemon, no clock read and no network.
The doc this one sits beside is [cycle capacity](capacity.md) (what a scope may
*commit* to); time tracking is what a scope *actually spent*.
## The entry
| `id` | yes | The **stable identity** (`e-…`). Minted once, never reused — not even after the entry is deleted. |
| `duration_minutes` | yes | The effort, in whole minutes (documented precision), at or above zero. |
| `date` | no | The calendar date the work belongs to, `YYYY-MM-DD`. |
| `author` | yes | Who did the work. An entry without attribution is not auditable. |
| `note` | no | Free text, one line. |
| *anything else* | no | Preserved verbatim, never interpreted. |
An entry is written into the issue's frontmatter under `time_entries`:
```yaml
time_entries:
- id: e-1a2b3c4d
duration_minutes: 90
date: "2026-03-01"
author: "ada"
note: "parser review"
- id: e-5f6e7d8c
duration_minutes: 30
author: "ada"
```
The precision is **whole minutes**; there is no seconds, fractional-hours or
start/stop representation, and a duration is never an estimate. The issue's own
free-form `estimate` is a different field and is never converted into minutes.
**A malformed entry stays readable.** A missing or negative duration, a date that is
not a calendar date and a blank author are each *reported* with their field name and
a remedy; they are never silently dropped and a missing duration is never read as
zero minutes. That keeps a hand-edited file repairable and lets an unrelated read
succeed while one entry is diagnosed. Unknown metadata a newer `tix` wrote beside
the typed fields survives every edit unchanged.
## The CLI
| Command | What it does |
| --- | --- |
| `tix time add <ISSUE> --minutes <N> --author <IDENTITY> [--date YYYY-MM-DD] [--note <TEXT>]` | Append one entry; prints the id it minted. |
| `tix time edit <ISSUE> --entry <ID> [--minutes <N>] [--date <D> \| --clear-date] [--note <TEXT> \| --clear-note] [--author <IDENTITY>] --reason <TEXT>` | Correct one entry, naming only the fields that change. |
| `tix time delete <ISSUE> --entry <ID> --reason <TEXT> --confirm` | Remove one entry from the **current entries** only. |
| `tix time show <ISSUE>` | The issue's current entries and its total. |
| `tix time history <ISSUE>` | The audit view: the current count beside what History retains. |
| `tix time total [ISSUE] [--issue <N>] [--assignee <IDENTITY>] [--from <D>] [--to <D>] [--audit] [--by issue\|assignee\|date]` | One scope's total, optionally broken down. |
```bash
tix time add 42 --minutes 90 --date 2026-03-01 --author ada --note "parser review"
tix time edit 42 --entry e-1a2b3c4d --minutes 120 --reason "recount"
tix time delete 42 --entry e-1a2b3c4d --reason "wrong issue" --confirm
tix time show 42
tix time history 42
tix time total --assignee ada --from 2026-03-01 --to 2026-03-31 --by issue
```
**Every applied mutation is exactly one attributable History event.** Its detail
names the entry and its before/after values, so a correction shows *both* values and
a deletion keeps the value it removed:
```text
entry e-1a2b3c4d; updated 90m on 2026-03-01 by ada: parser review -> 120m on 2026-03-01 by ada; reason: recount
entry e-1a2b3c4d; deleted 120m on 2026-03-01 by ada; reason: wrong issue
```
No prior event is ever rewritten, reordered or dropped. A request that asks for
exactly what is already there is a no-op: it appends no event and writes nothing.
**A refusal writes nothing.** An unconfirmed deletion, a missing or blank reason, an
entry id that does not exist, a negative duration and a blank author are each refused
before the write seam, so the issue's bytes and its History are byte-identical. An
invalid scope (`--from not-a-date`, an inverted period, an assignee that names no
identity) is a **refusal**, not a `-` total: no entry was evaluated, so there is no
total to print, and the diagnostic names the field and the fix.
`--by` groups the same selection: one block per issue, identity or calendar date.
The groups partition the scope's entries, so no entry is counted twice and none is
dropped — an entry that cannot be attributed shows as `(unattributed)` and one a
period cannot place shows as `(undated)`.
## The TUI
Per-issue time appears on two surfaces:
* the **`F7` Stats page's Time tab** — `Tab` switches between `[Health]` and
`[Time]`; `j`/`k` move, `Enter` opens the selected row's issue, `a`/`e`/`x` add,
correct and delete, `f` edits the scope filter, `s` cycles the breakdown, and `h`
moves focus to the navigation rail; `H` toggles the audit view. The tab is a
*reader* with the three mutations on it, and
it takes **one** read per refresh rather than one per frame.
* the **issue detail pane**, which shows the issue's own entries and total beside
its other fields when you open it.
Both render exactly the CLI's numbers: the tab's total, its rows and its audit come
from the same shared calculation `tix time total` and `tix time show` print, so the
TUI cannot show a figure the CLI would not. The empty state is explicit — a scope
with nothing recorded says so rather than showing a blank `0`.
## MCP
Six MCP tools mirror the CLI (all read-only except the three mutations), each with a
structured payload and a text channel that is the CLI's own output verbatim:
| `time_show` | `tix time show` — one issue's entries and total. |
| `time_history` | `tix time history` — the audit view. |
| `time_total` | `tix time total` — one scope's total and its breakdown. |
| `time_add` | `tix time add` — one create. |
| `time_edit` | `tix time edit` — one correction; a `reason` is required. |
| `time_delete` | `tix time delete` — one deletion; `reason` and `confirm: true` are required. |
An absent value is `null` in the structure and `-` in the text, never `0`, and
preserved unknown metadata goes into a **nested** `extensions` object rather than
being flattened beside the typed fields, so a preserved key can never shadow a real
one. `time_add`/`time_edit`/`time_delete` own no writer: they call the same service
the CLI and the TUI call.
## The portable export
`tix export --target time` writes one versioned JSON document describing a scope's
entries, totals, audit context and diagnostics:
```bash
tix export --target time # the document on stdout
tix export --target time --output time.json
```
The document carries `format: "tix-time"`, `version: 1` and
`precision: "minutes"`, the scope it was evaluated under, the scope's total, the
audit context, one row per issue (with its entries and its retained deletions and
corrections) and every diagnostic. Its numbers are the CLI's numbers — the document
is built from the same snapshot under the same filter — and it round-trips: reading
a document back and re-serializing it yields the same bytes, with every entry id,
duration, date, author, note and preserved key intact.
**A remote target retains the local data.** GitLab and Linear have no time field, so
`tix export --target gitlab|linear` is unchanged and does not carry time; the export
reports the boundary as an `unsupported_remote_field` diagnostic rather than dropping
anything. Nothing is ever converted, invented or sent for a remote tracker to store.
## The never-zero rule
Three different facts must not print the same bytes:
| `nothing` | The scope selected no entry at all | `-` / `null` |
| `unknown` | The scope selected entries and none carried a usable duration | `-` / `null` |
| `recorded` | At least one entry was counted; `0` is a genuinely recorded zero | the number, e.g. `0` |
Two counts beside every total say whether the sum is the whole story:
`malformed_count` (entries excluded from the sum, each with its own finding) and
`undated_count` (valid entries a period-bounded scope could not place because they
carry no date). A total with either is **partial**, and each affected entry is
reported with its kind and remedy so nothing is hidden by the number.
## Boundaries and escalation
Time tracking is deliberately narrow. Each of the following is an **escalation
trigger**, not a missing flag — the model cannot express it and no surface may drift
into it:
* **No timer.** There is no start/stop state and no running clock; the model is
duration-first, and an entry's only instant is the `## History` event's timestamp.
Reading an entry's `date` resolves no `today`.
* **No timezone or overlap policy.** Dates are plain calendar dates; there is no
timezone conversion and no rule about entries overlapping in time.
* **No billing, payroll or invoicing.** There are no rates, currencies, invoices or
exports for them, and no hosted service, account or second issue store.
* **No destructive History rewrite.** Corrections and deletions append; deleting an
entry never erases the event that recorded it.
* **No change to an issue's workflow.** A time operation changes the issue's
`time_entries` (and its `updated` stamp); it never changes status, ownership,
estimate, body, labels or relationships.
## Where the code lives
The entry model and its validation are `src/time_tracking/entry.rs`; the one
mutation service is `src/time_tracking/service.rs`; the shared aggregation, filters
and diagnostics are `src/time_tracking/{aggregate,filters,diagnostics}.rs`; the
portable document is `src/time_tracking/portable.rs`. The CLI is
`src/commands/time.rs`, the TUI surfaces are the `StatsTimeState` seam in
`src/app.rs` and the block in `src/ui/detail.rs`, and the MCP tools are in
`src/mcp/tools.rs` — every one of them consuming the shared DTOs rather than
re-deriving a value.