tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
# Recurring issues

A **series** is an issue whose frontmatter states a **rule**; the issues generated
from that rule are its **occurrences**. `tix` never runs a daemon, never schedules
anything, and never creates an issue behind your back: it stores the rule, tells
you what is due when you ask (or when the TUI starts), and creates an occurrence
only when you confirm it.

This guide is the reference for the rule grammar, the identity, the three
surfaces that read and change a series, and the boundaries the design refuses to
cross.

## A series is an issue

Recurrence lives in the `series:` frontmatter key — the same issue file you edit,
lint and move like any other:

```yaml
---
number: 12
title: "Nightly close"
status: todo
series:
  id: s-3KQ7W2M9
  name: Nightly close
  state: active
  rule:
    kind: daily
    anchor: 2026-01-01
  next_cursor: 2026-03-01
  skipped:
    - 2026-02-14
---
```

An **occurrence** is a normal issue whose `series:` block names the definition it
came from:

```yaml
series:
  id: s-3KQ7W2M9
  name: Nightly close
  date: 2026-03-02
  key: s-3KQ7W2M9@2026-03-02
```

That is the whole coupling. An occurrence is a normal issue on the board: it can
be edited, moved, linked and closed, and nothing about the rule changes when it
is. `tix recurring show <sel>` prints the definition and the dates already
generated from it.

## Identity: the stable id, and the key

| Concept | Example | What it is |
|---|---|---|
| Series id | `s-3KQ7W2M9` | The immutable handle. Set at creation (or with `--id`), never derived from the title, never changed by a rename. |
| Display name | `Nightly close` | A label. Accepted wherever a series is named because a human types it, but an ambiguous name is refused rather than guessed. |
| Occurrence key | `s-3KQ7W2M9@2026-03-02` | `<series-id>@<YYYY-MM-DD>`. The idempotency key: it is **stored**, not re-derived, so a file whose key today's rule would no longer produce is still readable and still a no-op. |
| Date | `2026-03-02` | A canonical `YYYY-MM-DD` calendar date. Keys sort chronologically as plain strings. |

Identity is the id everywhere: the CLI, the schedule page and the MCP tools all
resolve a series to its id before doing anything, and every decision is recorded
against the key.

## Rules

| Family | `kind` | Fields | What it means |
|---|---|---|---|
| Daily | `daily` | `anchor` | Every day, starting at the anchor. |
| Weekly | `weekly` | `anchor` | Every 7 days from the anchor. |
| Monthly | `monthly` | `anchor` | Every month on the anchor's day, **re-anchored to the month end** when the target month is shorter: a `31` anchor means the 28th/29th/30th where there is no 31st, and a leap-day anchor becomes the 28th in a common year. |
| Interval | `interval` | `anchor`, `interval` (1–1000), `unit` (`days`/`weeks`/`months`) | Every *n* units from the anchor. |
| Cron-like | `cron-like` | `anchor`, `expression` (`@daily`, `@weekly`, `@monthly`) | The bounded macro vocabulary, mapped onto the arithmetic above. |

Every family needs an `anchor`, and `anchor` is where the schedule starts — not
"today". A rule is validated *before* anything is stored, so a stored rule is
always a valid one: an out-of-range interval, a missing unit, a unit on a
non-interval rule, a leap day in a common year, an expression on a non-cron rule
or a nonexistent month-end are all refused with a named diagnostic
(`invalid_series_rule`), naming the field and the value it got.

### Timezones

A rule may state a **fixed UTC offset** (`+02:00`, `-05:30`, `UTC`). A named IANA
zone (`Europe/Berlin`) is refused with `invalid_series_rule`: resolving a local
date through a daylight-saving transition needs a reviewed tzdb, and this project
uses exactly one time library (`time`, without a tzdb) deliberately. A named zone
in a *rule* is therefore an escalation (see below), not a gap.

### Dates are injected, never read

The schedule arithmetic reads **no clock**. Every command that evaluates a
schedule takes an explicit date (`--date 2026-03-01`), and when the flag is
omitted the *boundary* resolves today once and hands the domain a context. That
is what makes `tix recurring detect --date 2026-03-01` reproducible in a test, in
a script and in a cron job — and what makes the results below deterministic.

## What is due

```
$ tix recurring detect --date 2026-03-05
series_id	occurrence_key	due_date	rule
s-3KQ7W2M9	s-3KQ7W2M9@2026-03-02	2026-03-02	daily
```

Detection is **read-only**, and that is a contract rather than an
implementation detail: it creates no issue, appends no History entry and changes
no series (a byte snapshot of the whole issue tree before and after is identical,
and there is a test that says so). A series that is examined and is *not* due is
reported as a note naming why — `series_paused`, `series_ended`,
`cursor_already_taken` — so an empty list never reads like a bug.

The candidate is the first date the rule has not consumed yet. A fresh series
with an anchor in the past is due **at its anchor**, not at today: the rule's
schedule is what generates dates, and nothing re-anchors it silently.

## The three surfaces

| | Read | Change |
|---|---|---|
| CLI | `tix recurring list`, `show <sel>`, `detect [--date D]` | `generate`, `pause`, `resume`, `end`, `skip`, `defer`, `ignore` — each needs `--yes` |
| TUI | `F8` opens the Schedule page; the board raises the due warning on start and on refresh | the warning answers `g`/`d`/`i`, and the page's keys act on the selected series |
| MCP | `list_recurring_series`, `get_recurring_series`, `detect_recurring_due` | `generate_recurring_occurrence`, `pause_`/`resume_`/`end_recurring_series`, `skip_`/`defer_`/`ignore_recurring_occurrence` — each needs `confirm: true` |

All three call one resolver, one detector, one generator and one lifecycle
service. A surface renders differently; it does not own a schedule, a parser or a
writer — which is why the same inputs give the same due candidate, the same
state, the same outcome word and the same History entry on all three.

The board's due warning is that same detector's answer, and it is raised **only**
from the board: a warning never interrupts a form, a prompt or another modal.
Opening it, moving in it and closing it write nothing. Closing answers nothing —
the next refresh asks again, because a dismissal is not a decision.

### The Schedule page (F8)

The page is a tab line (`Series | Cycle schedules | Capacity` — the Series tab is
the one that ships with recurrence), the key hints, a `Series (n)` table on the
left, and a `Series detail` pane on the right holding one `key: value` line per
fact (`id`, `name`, `state`, `rule`, `anchor`, `next_cursor`, `skipped`,
`occurrences`, `due`, `issue`). A series that is due is marked `[due]` in the
table and each row carries its rule in words beneath it. With no series yet, the
page says how to create one instead of showing an empty frame — the
`tix recurring create …` sentence **and** `n`, the page's own create key.

`n` opens a form for the three fields `tix recurring create` requires: `title`,
`rule` (the vocabulary `--rule` accepts) and `anchor` (`YYYY-MM-DD`). Its save
calls the same service the command line calls, with the same rule and anchor
parsing and the same validation order, so a series defined from `F8` is the write
`tix recurring create` makes — `--body`/`--status`/`--priority`/`--labels`/
`--assignee` keep their CLI defaults and `--name` defaults to the title. A blank
title, an unknown rule or a malformed anchor is refused on the form with the
service's own wording, the form stays open with everything typed, and nothing is
written when the form opens, on a refusal, or on `Esc`.

| Key | Field | Action |
|---|---|---|
| `n` | `series_new` | define a first series (the same write as `tix recurring create`) |
| `g` | `series_generate` | generate the due occurrence |
| `p` / `r` / `x` | `series_pause` / `series_resume` / `series_end` | change the lifecycle state |
| `s` / `d` / `i` | `series_skip` / `series_defer` / `series_ignore` | decide about the due date |
| `↑`/`k`, `↓`/`j`, `?`, `q`/`Esc` | `move_up`, `move_down`, `help`, `close` | navigate and close |

The occurrence keys act on the selected series' **due candidate**. A series with
nothing due says so rather than acting on a date the surface invented. `F8` is the
page's shell key and every page key is a normal bound action: rebindable in
`tix.toml`, listed in the help overlay, and documented in
[key bindings](key-bindings.md#schedule).

## Lifecycle and decisions

| Action | Effect | Repeat |
|---|---|---|
| `pause` | state `paused`: generation stops until an explicit resume | `no-op` |
| `resume` | state `active`. Never inferred from a refresh, never automatic | `no-op` |
| `end` | state `ended`, terminal for generation; every other action on an ended series is refused | `no-op` |
| `skip` | records the date in `skipped`, so it is never generated, and moves the cursor past it | `no-op` |
| `ignore` | the same schedule effect as `skip`; the difference is the word the History entry records | `no-op` |
| `defer` | moves the cursor past the date **without** recording it as skipped | `no-op` |

An occurrence that already exists cannot be skipped, deferred or ignored: those
actions decide about a date that has not happened, and `invalid_series_rule`'s
sibling diagnostic `occurrence_already_generated` says so. Removing an occurrence
is `tix delete`, as for any issue.

Every applied action appends exactly **one** `transition_accepted` History entry
to the series issue, naming the action, the occurrence where there is one, the
state change where there was one, and the caller's `--reason` when given. A
repeated action is a no-op that appends nothing, so the presence of an entry is
the record that the change was accepted. The history is append-only: prior
entries are never rewritten.

## Idempotency

Generation is keyed on `<series-id>@<date>`:

* a key that already has an occurrence returns `no-op` and writes nothing;
* a key the series recorded as skipped also returns `no-op` — the key is taken,
  so the request is satisfied by doing nothing;
* a second call for the same key never creates a second issue.

The occurrence, the typed link to the definition, and the series' cursor move in
**one transaction**: either all three land or none do, so a refused board create
cannot leave a half-generated occurrence behind.

## Escalation boundaries

The following are deliberately **not** implemented, and each is an escalation —
a decision to record in an issue with its reasoning, not a quiet feature request:

* **A general cron parser.** A five-field expression (`*/5 * * * *`) is refused
  with `malformed_cron_expression`. Only the `@daily`/`@weekly`/`@monthly` macros
  are accepted, because implementing a field grammar by hand is itself an
  escalation. The dependency review that closed this gate — including the crates
  examined and the licence/MSRV findings — is in
  [recurrence dependency review]recurrence-dependency-review.md.
* **A named IANA timezone in a rule.** Needs a reviewed tzdb and a second
  date/time stack; refused with `invalid_series_rule`.
* **A daemon, a background scheduler, or any generation without confirmation.**
  `tix` generates when asked, and only then.
* **Remote or hosted schedules.** A series is a file in this repository.

## Command reference

```
tix recurring create --title T --rule KIND --anchor YYYY-MM-DD [--id ID] \
                     [--name N] [--body B] [--status COL] [--priority P] \
                     [--labels L] [--assignee A] [--interval N] [--unit U] \
                     [--expression EXPR] [--timezone TZ]
tix recurring edit <sel> [--name N] [rule flags]      # keeps the id
tix recurring list                                     # id, name, state, cursor, skipped
tix recurring show <sel>                               # the definition and its occurrences
tix recurring detect [--date D] [--timezone TZ]        # read-only

tix recurring generate <sel> --date D --yes            # idempotent
tix recurring pause|resume|end <sel> --yes [--reason R]
tix recurring skip|defer|ignore <sel> --date D --yes [--reason R]
```

Only the flags you name change on `edit`, so `tix recurring edit <id> --name X`
is a rename and nothing else.

## Where the evidence lives

Every requirement of the mission that built this surface has a named test, and
the tests live with the code they prove:

| Evidence | File |
|---|---|
| Rule families, month-end and leap-day arithmetic, refused zones, the cron gate | `tests/recurrence_rule.rs` |
| Series identity, keys, index consistency | `tests/recurring_series.rs` |
| Read-only detection and its notes | `tests/recurring_detection.rs` |
| Idempotent generation, its controls and inertness | `tests/recurring_generation.rs` |
| Lifecycle transitions, one event per applied action | `tests/recurring_lifecycle.rs` |
| The CLI's wording, `--yes` gate and byte inertness | `tests/recurring_cli.rs` |
| The due warning overlay and the F8 page | `tests/tui_state.rs` |
| The MCP tools and their CLI parity | `tests/mcp.rs` |
| Cross-surface parity, one-pass detection, repeated keys, consent | `tests/recurring.rs` |

## See also

* [key bindings]key-bindings.md#schedule — the Schedule page's keys.
* [MCP]mcp.md#recurring-series-due-warnings-and-lifecycle — the tool contracts
  and the confirmation rule.
* [recurrence dependency review]recurrence-dependency-review.md — why no cron
  crate is used.