# Daily Stats

Stats derives durable daily values from the vault and records only review-walk answers, which freshness stamps cannot recover. bob-cli owns the metric meanings; the terminal and Obsidian views consume the same JSON model.

`bob stats` is the terminal glance. The Obsidian Stats page (`dash_stats.md`, STATS chip on the dashboard) is the same report drawn by the `bob-stats` block; see [dashboard.md](<https://bobs-org.github.io/dashboard/#child-pages>). Journal files under `.bob/stats/review/` travel with the vault; see [vault-git-sync.md](<https://bobs-org.github.io/vault-git-sync/#stats-review-journal>). The [`bob stats` CLI reference](<https://bobs-org.github.io/reference/cli/stats/index.md>) indexes the command.

This document defines the v1 review journal written by `bob-ledger-tools` and `bob-navigation-hotkeys`. The journal is append-only and per host. It contains no task text or derived metric values.

## Review journal

Each host appends to its own monthly file:

```
.bob/stats/review/YYYY-MM.<host>.jsonl
```

The vault `.gitignore` must allow `!/.bob/stats/**/*.jsonl`, following the `.bob/gkeep/imports/*.json` exception. That allowance is part of the Git sync runbook ([vault-git-sync.md](<https://bobs-org.github.io/vault-git-sync/#stats-review-journal>)). Readers union the host files; the vault `.gitattributes` also union-merges the journals so concurrent appends from different hosts survive a Git merge. Readers never rewrite them. Duplicate events are harmless because `reviewed` counts distinct row keys per day.

One line records one row answered by a committed review gesture:

```
{"v":1,"ts":"2026-10-12T05:41:07-04:00","day":"2026-10-12","host":"apollo","kind":"review.answer","tier":"next","outcome":"keep","key":"sase.md#^fix-failed-agents","plugin":"bob-navigation-hotkeys@2.19.0"}
```

| Field | Meaning |
| --- | --- |
| `v` | Event schema version, currently `1`. |
| `ts` | Local timestamp with an explicit UTC offset. |
| `day` | Local calendar day of the review landing, `YYYY-MM-DD`. |
| `host` | Sanitized `os.hostname()`: lowercase, cut at the first `.`, non-`[a-z0-9-]` → `-`, trimmed; fallback `host`. `Kellys-MBP` becomes `kellys-mbp`, matching the `vault(kellys-mbp)` label. |
| `kind` | `review.answer`. |
| `tier` | The freshness evaluator's tier string, copied verbatim. |
| `outcome` | `keep`, `done`, `drop`, `defer`, `lane`, `route`, `today`, `sequence`, `card`, `checklist`, or `decay:<action>`. A Task Card answer maps from the after-line: `[x]`/`[X]` → `done`, `[-]` → `drop`, scheduled date moved past today → `defer`, a new `dependsOn` id → `sequence`, otherwise `card`. |
| `key` | The review walk's stable row key. |
| `plugin` | `<writer>@<manifest version>`, for example `bob-navigation-hotkeys@2.19.0`. Earlier lines may carry `@2.x`. |

Readers accept both the shipped decay spellings (`decay:notNow`, `decay:level:0`, `decay:lessOften`) and the normalized forms (`decay:not_now`, `decay:level_0`, `decay:less_often`); any other `decay:<action>` without whitespace also counts as recognized, so new decay actions stay additive and journal files are never rewritten. Any other unknown outcome lands under `other` in the per-day outcome parts.

The versioned `api.stats` namespace on bob-ledger-tools exposes `append(event)` and `host()`. `host()` returns the sanitized hostname used for both the file name and the event `host` field. `append` serializes through one queue, creates the journal directory and file when needed, and rejects failed writes (validation errors carry `code: "invalid_review_event"`). Navigation calls it only after a successful task write. A journal failure never fails the answer: the event stays in an in-memory retry queue (cap 500; oldest dropped with a counted warning). Missing or old `api.stats` shows one Notice per session, “Update Bob Ledger Tools to record review stats”, and keeps retrying quietly. A write-failure burst shows one Notice, “Review answer not recorded — will retry”; a later successful drain shows “Review journal recovered: N answers written”. Validation rejects are dropped with a console warning instead of retrying forever.

## What gets recorded

The journal records an answer only when the gesture began on a validated `]s` landing and its write committed:

- Resolving answers from `continueReviewWalkAfter`: completion, lane change, route, Today link, and resolving Task Card writes.
- Stay answers made through a successful freshness stamp.
- Successful decay-card decisions (`notNow`, `level:N`, `lessOften`, `reword`, `drop`, and `keep`).
- Completed PRE/POST checklist rows. They are recorded as `checklist`, but do not contribute to `reviewed` by default.

Navigation and skips, Ctrl+Shift+M moves, off-landing stamps, refusals, no-op writes, unlink/reopen, automation, sync, and reloads do not create events. Moving a row is not an answer; a later answer on that row is recorded.

`reviewed(D)` is the number of distinct row `key`s answered on day D in decision tiers (all tiers except `pre` and `post`). `keep_rate` uses the last answer for each row key per day among PENDING and NEXT. These definitions make duplicate lines from a double press or two hosts harmless.

If the POST review checklist was closed on D but no host wrote a journal event for D, `reviewed(D)` is unavailable (`null`) with a journal-gap warning. It is never silently reported as zero. Each host's last event time provides a stale writer signal.

## Conformance vectors

The machine-readable vectors live in [`tests/fixtures/stats/review_answers.json`](<https://github.com/bobs-org/bob-cli/blob/master/tests/fixtures/stats/review_answers.json>). They pin the writer's outcome mapping and which recorded events contribute to `reviewed`; the stats reader uses the same fixture. Navigation tests mirror the same cases. `reviewed` excludes PRE/POST checklist events while still retaining them in the journal.

The selected Stats metric branch counts `#ref` reading tasks in Done, Created, and Dropped as a separate `ref` kind part. Those derived task metrics are read from vault data and are not journaled here.

## Engine principle

The engine derives every number at read time from vault data and never writes: no caches, no stamps, no daily logging habit. The journal above is the only data Stats ever records, because freshness stamps cannot recover review answers. Reports are deterministic for a fixed clock (`BOB_NOW`): one vault pass feeds all metrics, and repeated calls return byte-identical JSON.

## Metric kinds

Six configurable kinds share the common `label`, `unit`, `direction`, `color`, `since`, and `description` fields. Palette values are color names (`orange`, `violet`, `green`, `blue`, `gray`), never hex.

- `pomodoro`: closed `[x]`/`[X]` ledger rows under `## Pomodoros`. `measure: minutes` (default) uses each row's recorded `[t:: Nm]` first, then its time-range estimate (flagged `partial`); `measure: count` counts matching rows. `themes` / `exclude_themes` filter uppercase composite components (`GTD + SASE` splits its minutes across both parts, remainder to the first); unnamed rows are `(unnamed)`. Count mode attributes each row to its first matching component so parts add up to the total. `in:` / `exclude_in:` match a linked task's pre-rollup home chain with the same path, basename, and parent-ancestry rules as task `in:`. Minutes mode keeps those tasks' even shares and re-splits theme parts only when a filter removes a task share; count mode keeps the session when any task matches and picks the lead home among kept tasks. Whole review and unlinked sessions never match `in:` and are never removed by `exclude_in`. `review: include` (default), `only`, or `exclude` selects review-bucket shares: whole named or bridged review sessions are all review, while a work session's `^gtd` task share is review and its other task shares are not. `only` keeps those review shares; `exclude` drops them. Count mode counts a session once if any share remains and chooses the lead home among the kept tasks. Home and review filters AND with `themes` / `exclude_themes`.
- `tasks`: `event: created` (any status on its created date), `done` (status type `DONE` with a completion date), or `dropped` (status type `CANCELLED` with a cancelled date). `recurring: exclude` (default), `only`, or `include`. `tags_any` / `tags_all` match whole case-insensitive tokens; `task_kind: task|prj|ref` filters the kind part (precedence `^prj`, then `#ref`/`^ref`, then task); `in:` matches note paths, basenames, or parent ancestry. The canonical daily note's `^gtd` row is always excluded.
- `checklist`: chores matched by all `tags` (exact case-insensitive tokens) plus an optional normalized `match` description substring, read from any indexed note (including archive rows mapped to their source residence). Recurring instances count. Chore identity strips inline fields, tags, and block IDs, collapses whitespace, and lowercases. An instance is outstanding on \[`min(scheduled else created, completion)`, `completion`\] so an early completion is met on its completion day. `measure: ratio` (default) reports met/due in basis points with pooled `num`/`den`, `count` reports chores met, `bool` reports whether every due chore was met. Nothing due is `null`/`missing`, never zero. A reopened row with a stale `[completion::]` is outstanding.
- `field`: one typed daily value from the day's canonical daily note. `field` names the key; `source` (default `either`) picks YAML frontmatter, `key:: value` body lines, or frontmatter-first with inline fallback only when the key is absent (a present but invalid value never falls back). Counts are non-negative integers, minutes accept `30m` / `1h 30m`, bools accept `0`/`1` / `true`/`false` / `yes`/`no`.
- `journal`: `select: reviewed` counts distinct row keys answered per day in the configured tiers; `select: keep_rate` reports the keep share (last answer per key, basis points) among `pending`/`next` rows. `tiers:   decision` (every tier except `pre`/`post`), `all`, or an exact tier list. Duplicate lines and multiple hosts never inflate a day: the latest event per key wins (by instant, then path, then line).
- `formula`: integer arithmetic over metric IDs: literals, unary minus, parentheses, and `+ - * /` with checked math (half-away-from-zero division; division by zero and overflow are `null` with a reason, never a panic). Null inputs propagate null with the worst input quality.

## Built-in catalog

Built-ins are literal config entries validated exactly like custom metrics, not a separate code path. `ref_in_done = yes`: `#ref`/`^ref` reading tasks count in `done`, `created`, and `dropped` and appear as the `ref` kind part.

| ID | Kind and parameters | Unit / color / direction | Definition |
| --- | --- | --- | --- |
| `worked` | pomodoro, minutes | minutes / orange / up | Minutes in closed Pomodoros. Open Pomodoros are plans and never count. |
| `reviewed` | journal, reviewed, decision tiers | count / violet / up | Distinct walk row keys answered on day D in decision tiers. |
| `done` | tasks, done, recurring exclude | count / green / up | Non-recurring DONE tasks completed on D, minus the daily `^gtd` row. |
| `created` | tasks, created, recurring exclude | count / blue / neutral | Non-recurring tasks created on D, any status, same exclusions as Done. |
| `net` | formula, `created - done - dropped` | signed / gray / down | Daily flow balance; the headline is the window total, and the delta reads shrinking/growing/flat from that same total. |
| `review_closed` | checklist, tags gtd+post, match Morning review, bool | bool / violet / up | The `#gtd #post` Morning review chore completed on D. |
| `dropped` | tasks, dropped, recurring exclude | count / gray / neutral | Non-recurring CANCELLED tasks cancelled on D, same exclusions as Done. |
| `habits` | checklist, tags gtd+pre, ratio | ratio / green / up | `#gtd #pre` chores met on D over chores due on D. |
| `keep_rate` | journal, keep\_rate, pending+next | ratio / violet / neutral | Keep share of PENDING/NEXT answers on D. |
| `review_time` | pomodoro, minutes, review only | minutes / orange / neutral | Minutes attributed to the GTD review row, including `^gtd` task shares inside work sessions. |
| `sessions` | pomodoro, count | count / orange / neutral | Closed Pomodoro rows on D. |

## `stats:` config

```
stats:
  range: 4w            # default window; 7d/4w/3m/1y, Nd/Nw/Nm/Ny, all, or
                      # inclusive YYYY-MM-DD..YYYY-MM-DD
  overview: [worked, reviewed, done, created, net, review_closed]
  hide: []            # suppresses lists and default selection, never evaluation
  since: 2026-06-01   # optional global floor for inferred tracking starts
  metrics:
    sase_time:        # pomodoro
      label: SASE time
      kind: pomodoro
      measure: minutes
      in: [sase]
    sase_blog_time:   # still measures blog time when sase_blog rolls up
      label: SASE blog time
      kind: pomodoro
      measure: minutes
      in: [sase_blog]
    ref_done:         # tasks — note task_kind, not kind
      label: Ref done
      kind: tasks
      event: done
      task_kind: ref
    habit_count:      # checklist
      label: Habit count
      kind: checklist
      tags: [gtd, pre]
      measure: count
    energy:           # field
      label: Energy
      kind: field
      field: energy
      source: either
      unit: count
    all_reviewed:     # journal
      label: All reviewed
      kind: journal
      select: reviewed
      tiers: all
    done_and_dropped: # formula
      label: Done and dropped
      kind: formula
      expr: done + dropped
      unit: signed
```

**Defaults.** Missing config file, absent `stats:` block, and unknown keys (including plugin-only `bob_path`) all fall back to defaults: range `4w`, the six-metric overview, no hides, no global since. A custom entry whose ID matches a built-in replaces the whole definition and is marked `redefined`; an invalid replacement removes that metric with a warning instead of silently falling back.

**Validation.** A malformed/non-mapping `stats` block or an invalid top-level `range`, `overview`, `hide`, `since`, or `metrics` container is a configuration error naming `stats.<key>` and the config path (exit 2 in the future CLI); it never breaks the `plan:`, `freshness:`, or `highlights:` loaders. A malformed metric entry is a local warning naming `stats.metrics.<id>.<key>`; unrelated metrics keep working. Formula references to unknown metrics and dependency cycles invalidate the affected definitions (plus downstream dependents) with local warnings.

**Invalid values:**

- Unknown overview/hide names produce warnings, not errors.
- Unknown explicit metric IDs in a request are usage errors.
- Incompatible `unit` values for a kind drop that entry with a warning (`signed` is accepted for formulas, e.g. `net`; `unit: ratio` is rejected for formulas because they have no num/den). There is no unit fallback.
- Missing required fields (`label`, `kind`, task `event`, field `field`, formula `expr`, journal `select`) invalidate that metric entry only.

## Data model and quality

Every metric yields one value per date: an integer `value` (or `null`), an optional ratio `num`/`den` in basis points, a quality, an optional non-ok reason, and dimension parts (`area`, `project`, `theme`, `kind`, `note`, `tier`, `outcome`, `met`/`missed`). Pomodoro and task metrics add `area` and `project` homes from the shared resolver in [pomodoro-homes.md](<https://bobs-org.github.io/pomodoro-homes/index.md>). The `theme` dimension splits a named session on the same `+` components as the plan budget; catalog identity and hues are in [pomodoro-themes.md](<https://bobs-org.github.io/pomodoro-themes/index.md>). Minutes-mode days also carry `apportioned`, the share of each part that came from sessions with two or more keys in that dimension. Consumers never recalculate a display string, summary, percentage, or delta — the report carries them all.

The JSON report includes `theme_hues` beside `homes`, keyed by normalized theme key, for the theme dimensions present in the report. Values use the shared `ThemeRef` fields (`key`, `name`, `hue`, `hue_source`). Human output adds a colored `■` before known theme parts and a muted `□` for exempt themes; `NO_COLOR` keeps the existing text unchanged.

- `ok`: a real observed value (including a real zero, e.g. an empty Pomodoros section).
- `partial`: usable but estimated or incomplete (range-estimated minutes, a clipped bucket mixing known and missing days).
- `missing`: no value and no history claim (no daily note, no matching instance, a journal gap, an invalid field).
- `not_tracked`: before tracking began. Never a silent zero.

Quality precedence is `ok` \< `partial` \< `missing` \< `not_tracked`: combining inputs (formulas, grouped buckets) takes the worst.

**Since.** Dates before the effective start are `not_tracked`. An explicit metric `since` wins; otherwise the start is inferred from the earliest qualifying input (first `## Pomodoros` section, first valid task event date, first checklist instance, first daily field, first journal event day); the global `stats.since` is a floor above that. A metric with no input at all never invents an ancient start. Formula starts are the latest dependency start (every input must be tracked); literal-only formulas fall back to explicit/global since, else the range start.

**Today is provisional.** The current day gets its own point with `provisional: true`; summaries, prior windows, and grouped buckets never include it. Day grouping still shows it as the trailing point. An unmet provisional boolean point (`value: 0`) displays `not yet`; met points still display `met`, and missing values remain `–`.

**Journal gaps.** `review_closed` is evaluated as a dependency even when hidden or unselected, so a known closed-review day with no journaled event in any tier from any host reports `null`/`missing` plus a `journal_gap` warning — never a silent zero. PRE/POST-only days carry real events, so Reviewed reads a real zero there. Before the journal started, days stay `not_tracked` rather than flagging plugin gaps. If `review_closed` itself is invalid, journal metrics report a `journal_limitation` instead.

**Health.** Each report carries `health.tasks` (`invalid_dates`, `closed_without_date`, and `missing_created` when nonzero — every affected task counts once per limitation, never once per metric or day), `health.journal` (tracking start, malformed-line count, and per-host event counts with the latest timestamp and day), and `health.homes` (in-window `unlinked_minutes` / `unlinked_sessions` / `unfiled_links` / `ambiguous_links` / `unresolved_links`, plus global `rollup_invalid`, `area_color_invalid`, and hue-collision lists). Matching `warning <message>  <code>` lines use `rollup_invalid`, `area_color_invalid`, and `area_hue_collision`. `invalid_dates` counts a task when created, done, or cancelled is malformed or future, or when scheduled is malformed. A future `scheduled` date is ordinary planning data and does not count. Daily-note field extraction skips task lines and ordered-list task lines (`1. [ ] …`) so task fields never leak in as daily fields; quoted lines are skipped the same way. YAML null frontmatter (`energy:`) is absent, never the string `"null"`.

## Ranges, grouping, summaries, display

**Windows.** Presets yield N complete days ending yesterday plus the separate provisional today point: `7d`, `4w`, every positive `Nd`/`Nw` / `Nm` / `Ny`, and `all` (from the earliest effective start of the selected metrics). Months convert to whole weeks with `ceil(N*52/12)` (`3m` = 91 days); years are `N*52` weeks (`1y` = 364). `offset: K` shifts the window back `K*N` days and drops today. `prior` is the equal-length window right before the current one and always resolves (it explains the delta); `prior_points` are only filled on comparison requests. Explicit `YYYY-MM-DD..YYYY-MM-DD` ranges clamp a future end to today and reject fully future or inverted intervals with usage errors.

**Grouping.** `auto` picks day through 91 complete days, week through 371, month beyond; weeks start Monday (ISO year boundaries included) and months are calendar months. Edge-clipped buckets carry `partial: true`. Count, minutes, and signed values sum; ratios pool `num`/`den` (periods never average daily percentages); bools count met days as `N of M`, where M is days with data. All-null buckets stay null. Week and month grouping keep today as a separate final provisional point (date = end = today, `rolling_median` null) whenever the window includes today; it is never merged into a bucket.

**Summaries** always use raw complete daily values: the numeric headline is the daily median (`/day` suffix), *except* `net`, whose headline is the window total (`stat: "total"`, suffix `total`), bool is met days over days with data (`–` when that is 0 of 0), and ratios pool across days. Totals, means, linearly interpolated `p25`/`p75` (index `(n-1)*p`, half-away rounding), `days_with_data`, `days_met`, the prior headline, and a delta round it out. `summary.p25`/`p75` stay daily even when the chart is grouped. Parts summaries rank dimensions by total; only `note` and `theme` collapse to top five plus `other`. `area` and `project` keep every key (the note tree already bounds them). Rolling medians span 7 day points, 4 week buckets, or 3 month buckets, with a 6-day / 3-week / 2-month lead-in so the first displayed point can fill, need half the window in real observations, and stay `null` for today.

**Chart band.** Each metric includes `chart: {group, p25, p75}`. Quartiles reuse the summary helper but run over displayed, non-null, non-provisional **bucket values** (the same units as the bars: daily values, weekly/monthly sums, pooled ratio buckets, or bool met-day counts). Today and rolling lead-in never enter the band. Day grouping therefore matches `summary.p25`/`p75`; week and month bands differ and `chart.group` names which. An all-null window serializes `p25`/`p75` as JSON `null`.

**Daily points.** Grouped reports add `daily_points` for the requested display window only, in the same `SeriesPoint` shape with daily displays and quality. They exclude rolling lead-in and prior days and include today once when the window includes it. Day-grouped consumers use `points`; `daily_points` is omitted so the payload is not duplicated.

**Checklist presentation.** Daily checklist points (never aggregated buckets) carry `checklist_state`: `met` (every due chore met), `missed` (any due chore unmet, including a partial ratio), `not_due`, `missing`, or `not_tracked`. Quality, reason, and numeric value stay unchanged. Checklist metrics also add `chores` over complete visible days: stable key, met-day count, due-day count, and display `N of M days`, with the full roster (no top-five truncation). Today is excluded. A chore absent on a known day is not due; globally missing or untracked points stay out of those denominators. The daily grid still uses each point's `met`/`missed` parts.

**Part display.** Points and `daily_points` add `parts_display` beside numeric `parts` (same dimension/key shape). Each value uses the existing parts formatter: minutes as `29m` / `4h 05m`, other units as the raw count string. Empty maps are omitted.

**Display.** Minutes render `4h 05m` / `45m` / `0m`, counts plain, signed values `−6` / `+9` / `0`, bools `met` / `missed`, ratios whole percentages, null `–`. Deltas read `≈ same` within ±1% (and when a ratio difference rounds to 0 pts), multipliers at ≥2× or ≤0.5×, `new` against a zero prior (sign −1 when the current value is negative), `–` against a null prior, `▼ 100%` for a drop to zero, percentage points for ratios, days for bools; sign-crossing uses a raw amount. `net` instead reads shrinking/growing/flat by total sign. The net label describes the window itself, not a comparison against the prior period. Every other delta names the prior period in words. Parts summary values use minutes formatting only when the metric unit is minutes.

`all` with no history uses the same end convention as presets (end = yesterday, zero days) and still includes today as provisional.

## Schema-1 contract

Reports serialize with `ok: true`, `schema_version: 1`, ISO dates, the range (with prior metadata), per-metric definitions/summaries/chart/points/parts/ warnings, health, global warnings, a top-level `homes` catalog keyed by every home used in the report (`label`, `kind` project|area|bucket, `area`, `subarea`, `path`, `hue`; `project_label` on area-kind entries; `rolled_up` on rollup homes), and a top-level `sessions` array when the report includes at least one pomodoro metric. The catalog and `sessions` are omitted when empty. `sessions` lists every closed Pomodoro in the display window (today included) in chronological order. Each element is `{date, path, line, time_range, minutes, minutes_display, quality, name, homes}`: facts, not metric values, so themes and filters do not change them. `homes` is the shared R9 object; its keys are in the catalog. On the 2026-10-18 live vault, `bob stats -f json -r all` is 1,945,299 bytes (1.90 MiB) with 1,093 session facts. Ratio points add `num`/`den`, grouped points add `partial`, and empty maps (`parts`, `parts_summary`, `parts_display`, `apportioned`) and empty lists (`daily_points`, `chores`) are omitted. `PartSummary` rows for `area` and `project` add an optional `label` plus minutes-mode `apportioned` / `apportioned_display`. Points add an optional `apportioned` map, `{dimension: {key: minutes}}`, omitted when empty. `definition`, `limits`, `parameters`, `chart`, and `total` are always present (total and chart quartiles may be JSON `null`). Optional fields that appear when present: `since`, `reason`, `prior`, `delta`, `suffix`, `stat`, `journal.since`, `missing_created`, `checklist_state`, `daily_points`, `chores`, `parts_display`, `apportioned`, `label`, `project_label`, `rolled_up`, `health.homes` lint lists, and `sessions`.

Older schema-1 reports remain renderable: use `summary` quartiles only for day grouping; omit an unavailable grouped band or calendar with a short explanatory label; use numeric part text when `parts_display` is absent; keep table access. Never fake daily history from grouped totals.

```
{
  "ok": true,
  "schema_version": 1,
  "today": "2026-10-11",
  "range": {
    "offset": 0, "start": "2026-10-08", "end": "2026-10-10",
    "days": 3, "includes_today": true, "group": "day",
    "label": "Oct 8 – Oct 10",
    "prior": {"start": "2026-10-05", "end": "2026-10-07", "label": "Oct 5 – Oct 7"}
  },
  "metrics": [
    {
      "id": "habits", "label": "Habits", "kind": "checklist",
      "unit": "ratio", "direction": "up", "color": "green",
      "source": "derived", "origin": "builtin", "since": "2026-10-09",
      "definition": "#gtd #pre checklist chores met on D divided by chores due on D.",
      "limits": ["Renaming a chore changes its identity."],
      "parameters": {"measure": "ratio", "tags": ["gtd", "pre"]},
      "summary": {
        "headline": {"value": 2500, "display": "25%", "stat": "pooled"},
        "total": null, "mean": 2500, "p25": 1250, "p75": 3750,
        "days_with_data": 2, "days_met": null
      },
      "chart": {"group": "day", "p25": 1250, "p75": 3750},
      "points": [
        {"date": "2026-10-09", "end": "2026-10-09", "value": 0,
         "display": "0%", "quality": "ok", "provisional": false,
         "rolling_median": null, "num": 0, "den": 2,
         "parts": {"missed": {"journal": 1, "meditate": 1}},
         "parts_display": {"missed": {"journal": "1", "meditate": "1"}},
         "checklist_state": "missed"}
      ],
      "prior_points": [],
      "chores": [
        {"key": "journal", "met": 0, "due": 2, "display": "0 of 2 days"},
        {"key": "meditate", "met": 1, "due": 2, "display": "1 of 2 days"}
      ],
      "warnings": []
    }
  ],
  "health": {"journal": {"since": "2026-10-08", "malformed_lines": 1, "hosts": []},
             "tasks": {"invalid_dates": 1, "closed_without_date": 0, "missing_created": 1}},
  "warnings": []
}
```

**Errors** use a matching envelope (`ok: false`, `schema_version: 1`, `error: {code, message}`). Vault I/O failures (unreadable vault) map to CLI exit 1; configuration, request/unknown-metric, and non-Dataview format failures map to exit 2. JSON errors print that envelope on stdout; human and CSV errors print the message on stderr.

## `bob stats`

Read-only Daily workflow command after `ready`. Bare `bob stats` runs the flag-only `overview` glance.

```
bob stats [overview] [-b DIR] [-c] [-f human|json|csv] [-g auto|day|week|month] [-o N] [-r RANGE]
bob stats show <METRIC>... | -a   [same options]
bob stats metrics [-b DIR] [-f human|json]
```

Options (alphabetical; every long option has a short alias): `-a/--all` (show: every visible metric), `-b/--bob-dir`, `-c/--compare` (include the prior period's points; human `show` draws its ticks), `-f/--format`, `-g/--group`, `-h/--help`, `-o/--offset`, `-r/--range`.

Environment: `BOB_CONFIG_FILE`, `BOB_DIR`, `BOB_NOW`, `NO_COLOR`.

Exit codes: 0 report; 1 vault I/O failure; 2 usage, unknown metric, config error, or non-Dataview task format.

### Human output

Overview is a header, a sparkline table in each metric's 256-color hue (orange 208, violet 141, green 36, blue 33, gray dim), today's in-progress values, journal hosts, then `warning <message>  <code>` lines. `·` marks null days; booleans use `●` met / `○` missed. Detail (`bob stats show`) draws eighth-block columns, a hollow `▯` today column, and a weekday × week grid for booleans. The boolean overview sparkline uses `◌` for an unmet provisional today point. It prints every parts dimension in a stable order: pomodoro `area`, `project`, `theme`; tasks `kind`, `area`, `project`, `note`. Area and project cells use catalog labels; under color they prefix a `●` in the area's xterm hue, and buckets render dim. The project line shows the top five homes plus `other`, with buckets always shown outside those five. When apportioned minutes are non-zero, a dim `split` line reports the share of project minutes that came from sessions with several homes. Review bucket dots use their area's hue; `@unlinked` and `@unfiled` stay dim. Reports with area or project parts add the dim line `grouped by today's note tree · from Task Links`. Metrics is a catalog table with a dim definition line per id.

Plain text under `NO_COLOR` or a non-TTY (`Styler::detect()`) drops the glyphs.

### CSV

Tidy rows `date,end,metric,value,display,quality,provisional,num,den` (empty `num`/`den` when not a ratio).

### JSON

One line. Overview and show print the schema-1 report. `bob stats metrics -f json` prints definitions and parameters without points.

## Golden fixture and M-vector table

`tests/fixtures/stats/vault/` is a small hand-computed vault pinned to the fixed clock Sunday 2026-10-11 noon and the explicit window `2026-10-08..2026-10-11` (complete Oct 8–10, prior Oct 5–7, provisional today Oct 11). Expected values below are derived by hand from the file contents — never snapshotted from the implementation — and asserted field by field in `src/native/stats/compute.rs`. Legend: plain numbers are `ok`; `U` = `not_tracked`, `M` = `missing`, `G` = `missing` with `journal_gap`.

| \# | Metric | Oct 8 | Oct 9 | Oct 10 | Today | What it proves |
| --- | --- | --- | --- | --- | --- | --- |
| M1 | worked | U | 32 | 0 | 10·prov | Odd 7m composite splits 4/3; empty section is a real zero |
| M2 | reviewed | 0 | G | 2, last-wins | 1·prov | Pre-only day is a real zero; duplicate keys count once |
| M3 | done | U | 5, ref 1 | 4, prj 1 | 1·prov | `^gtd`, recurring, and reopened rows never count; archive maps to source |
| M4 | created | 2 | 5 | 5 | 1·prov | Archived creation surfaces on Oct 7; `^prj` and `[-]` add Oct 10 |
| M5 | net | U | U | −1 shrinking | 0·prov | Nulls stay null before the latest dependency start; 5−4−2 |
| M6 | review\_closed | U | met | met | not yet·prov | Today's POST TODO in `gtd_daily.md` is due and unmet |
| M7 | dropped | U | U | 2 | 0·prov | `[!]` and `[-]` CANCELLED rows with cancelled dates |
| M8 | habits | U | 0/2 missed | 1/2 met | 0/2·prov | Skipped Journal, late Meditate, deferred Stretch; ratios pool 25% |
| M9 | keep\_rate | M, no denom | G | 1/2 → 50% | 1/1·prov | Keep share with pooled basis points |
| M10 | review\_time | U | 25 | 0 | 0·prov | `review: only`; Oct 9 `GTD + SASE` no longer counts; SASE-only today is zero |
| M11 | sessions | U | 2 | 0 | 1·prov | Count mode counts rows once; parts still attribute GTD |
| M12 | sase\_time (custom pomodoro) | U | 3 | 0 | 10·prov | Custom theme metric through the same path |
| M13 | ref\_done (custom tasks) | U | 1 | 0 | 0·prov | `task_kind: ref` spelling with the `ref` part |
| M14 | habit\_count (custom checklist) | U | 0 | 1 | 0·prov | Count measure returns chores met |
| M15 | energy (custom field) | 3 frontmatter | 5 inline | M absent | M·prov | `either` source precedence and fallback |
| M16 | all\_reviewed (custom journal) | 1, pre tier | G | 2 | 1·prov | `tiers: all` sees PRE/POST answers; gaps apply to customs too |
| M17 | done\_and\_dropped (custom formula) | U | U | \+6 | \+1·prov | Signed display; dependency-order evaluation |

Fixture edge coverage beyond the table: the malformed `2026-02-30` created date counts once in `health.tasks` (`invalid_dates: 1`); future scheduled dates are valid planning data; the undated open task counts once as `missing_created`; the reopened open-status row with a completion date leaves Done; `gtd_daily.md` plus `done/gtd_daily_done.md` drive checklist chores from any note; a second journal host (`hermes`) carries an earlier duplicate key and one malformed line; repeated `compute` calls return byte-identical JSON without touching vault or config; an independent `[t:: Nm]` text scan sums to the Oct 9 worked total; month grouping pools one clipped October bucket plus a separate provisional today point; the default request reports 4 weeks, six overview metrics, and 29 day points.

Writer vectors in `tests/fixtures/stats/review_answers.json` (R1–R24 plus the `hosts` sanitizer vectors) pin the plugin outcome mapping, Card after-line outcomes, host naming, and which recorded events contribute to `reviewed`; navigation tests mirror the same cases. Reader vectors in `tests/fixtures/stats/journal_reader_vectors.json` pin last-answer order (instant, then path, then line — including when string order and instant order disagree), duplicate keys, two hosts, malformed and truncated lines, unknown outcomes/tiers, pre/post-only days, keep-then-done, gap days, and pre-tracking dates.

## Homes fixture and H-vector table

`tests/fixtures/stats/homes_vault/` is a separate hand-computed vault for derived Pomodoro homes. It leaves M1–M17 and `tests/fixtures/stats/vault/` untouched. The clock is Saturday 2026-10-18 noon and the window is `2026-10-15..2026-10-18` (complete Oct 15–17, provisional today Oct 18). Expected values are derived by hand from the file contents and asserted in `src/native/stats/homes.rs`. Home labels follow [pomodoro-homes.md](<https://bobs-org.github.io/pomodoro-homes/index.md>): counted minutes C are the theme-selected shares, then R5 splits C so every dimension sums to the metric total.

| \# | Metric | What it proves |
| --- | --- | --- |
| H1 | worked total 167m | Complete-window minutes; today 15m is provisional |
| H2 | worked area | gtd 45, dev 90, job 7, @unlinked 10, @unfiled 15; sums to 167 |
| H3 | worked project | sase 80 (blog rollup 50 + HACK 10 + GTD+SASE 20), @review:GTD 35, bob 10, dev 10, job 4, job\_apply 3, @unlinked 10, @unfiled 15; sums to 167 |
| H4 | apportioned | sase 10 and job 4 from multi-home sessions; gtd area 10 from HACK |
| H5 | sessions | 8 closed rows; lead homes put 2 on sase and 2 on @review:GTD |
| H6 | done | 7 completions; 2 on sase, 1 @unfiled; job area tasks use `job · area tasks` |
| H7 | sase\_time | themes:\[SASE\] counted C is 10m, then split onto sase/dev |
| H8 | health | 1 unlinked session / 10m, 1 unfiled link, rollup\_invalid on invalid.md, hue collision gtd+body orange |
| H9 | sase\_in | `in: [sase]` is 80m (blog 50 + HACK sase share 10 + GTD+SASE 20); review/unlinked never match |
| H10 | sase\_blog\_in | `in: [sase_blog]` is 50m on rolled-up sase — blog time still measures under rollup |
| H11 | exclude\_sase | `exclude_in: [sase]` is 87m (167 − 80); review 35m and unlinked 10m stay |
| H12 | review\_only | `review: only` is 35m on `@review:GTD` (named 25 + unnamed bridge 10) |
| H13 | sessions | 9 closed rows in the window including today; facts ignore metric filters |
| H14 | sase\_count\_in | `measure: count`, `in: [sase]` keeps 3 complete-window sessions when any linked task matches |
| H15 | review\_time / review\_count | Oct 14 work session splits 50m into 25m `@review:GTD` and 25m `bob`; review-only minutes and count keep the review share/session |

Oct 14 adds the separate H15 check: an unnamed 50m work session links the Oct 15 `^gtd` row and `bob`; it is not treated as the same-day review bridge. Oct 15: named GTD review 25m plus unlinked HACK 10m. Oct 16: BLOG 50m on sase\_blog (rolls to sase), HACK 30m split bob/sase/dev, JOB 7m split 4/3, MISC 15m on an article (@unfiled), GTD+SASE 20m all on sase (theme remainder gives SASE 10m), unnamed 10m review via `[[#^gtd]]`. Oct 17 is an empty Pomodoros section (real zero). Oct 18 (today) BLOG 15m is in `sessions` and provisional on metrics. Repeated `compute` calls return byte-identical JSON.

## Limitations

- Reopened tasks leave Done (only the current status type counts).
- Deleted tasks disappear from Created (history is read from live files).
- Keep imports retain their Keep date as `created`.
- Renaming a chore starts a new chore identity (history does not follow).
- Dates cannot reconstruct missing history: undatable or cancelled checklist instances report a `checklist_limitation`, never invented intervals.
- Grouped points cover complete days only; the provisional today point appears as a separate final point and never pools into a week/month bucket.
- Rolling medians need half a window of real observations, so short windows report `null` medians.
- The `^gtd` exclusion applies to the canonical daily note's own `^gtd` row (including archived rows mapped back to it).
- Until the first counted `]s` answer, `.bob/stats/review/` does not exist and Reviewed / Keep rate stay `not_tracked` ("no review answers have been recorded") rather than a silent zero. On 2026-10-11 that was still true on apollo, athena, and the MacBook.
- A live-vault snapshot on 2026-10-11 reported `health.tasks.invalid_dates: 1`, `closed_without_date: 230`, and `missing_created: 647`. Those counts are vault data quality, not engine errors; they do not change the headline metrics.
- Release-mode `bob stats` on this vault is about 340–370 ms, above the original ≤300 ms target. See [Performance](<#performance>).

## Later and rejected

Later (documented, not built): end-of-day lane sizes and walk-due-at-start (needs a nightly `snapshot` kind recorded by `bob nightly`); day start (first closed session's start); plan follow-through; manual metrics (energy, sleep, exercise — already supported by the `field` kind, none ship enabled); an approximate review backfill from vault git history (the journal starts clean and shows "Tracking since …").

Rejected: stamp counts, productivity scores, tasks per hour, automated "insights", streak counters, commits/words/lines, time in Obsidian.

## Performance

One vault pass feeds every metric; only `.bob/stats/review/*.jsonl` is read besides Markdown. An opt-in parity test (`BOB_STATS_REAL_VAULT_PARITY=1` with `BOB_NOW`, vault from `BOB_DIR` or `~/bob`) re-checks sessions, worked, done, created, and dropped against independent line-regex oracles on every live run and also runs `bob stats show worked -c -f json`. On 2026-10-11 it reconciled `done` Oct 10 exactly (engine 37 = Dataview 45 − 7 recurring − 1 daily `^gtd` row).

Rollout measurement (apollo, live `~/bob`, HEAD `efc5593`, `BOB_NOW=2026-10-11 12:00:00`, `NO_COLOR=1`, release binary):

| Path | Time |
| --- | --- |
| Engine 7-day five-metric `compute` (parity test) | 371 ms |
| `bob stats` (human overview) | 340 ms |
| `bob stats -f json` (stdout `.ok` true) | 370 ms |
| `bob stats show worked -c` | 360 ms (CLI parity test 350 ms) |
| `bob stats metrics` | 340 ms |
| `bob stats -f csv` | 360 ms |

CLI wall time is within \~20 ms of the engine pass. Earlier debug-test numbers (\~2.4 s) and a landing-fixes 573 ms read were measurement artifacts; the land-agent release figure at `f9324da` was 365–378 ms and matches this rollout. The original ≤300 ms target is not met on this vault (\~15–25% over). The extra time is the Markdown pass, not journal I/O (no journal files exist yet). Treat 370 ms as the recorded budget unless a later pass shrinks the index.

Homes-parts measurement (athena, live `~/bob`, this checkout, `BOB_NOW=2026-10-18T12:00:00`, `NO_COLOR=1`, release `bob stats -f json -r 4w`, nine runs after one warmup, Python `perf_counter`):

| Path | Time |
| --- | --- |
| Before (4w JSON baseline) | 174 ms (plan); 168 ms median on this host |
| Homes-parts after (median / min / max) | 200 ms / 193 ms / 209 ms |
| Stats-drilldown final release check (median / min / max) | 182.2 ms / 178.5 ms / 188.1 ms |

The final stats-drilldown median is 4.7% above the 174 ms plan baseline, within its 15% budget (200.1 ms). The measured 4-week JSON is 220,827 bytes. The same clock's `-r all` JSON is 1,945,299 bytes (1.90 MiB; 1,093 closed sessions).

## Obsidian Stats block

The `bob-stats` code block on `dash_stats.md` reads the same schema-1 report as `bob stats`. Open it from the dashboard STATS chip or the Open Stats command; the page contract is in [dashboard.md](<https://bobs-org.github.io/dashboard/#child-pages>). The block keeps metric values, labels, quality, definitions, and formatted parts from Bob; the plugin only lays them out and manages the current view.

On desktop the block finds `bob` by checking `stats.bob_path` in `~/.config/bob/config.yml` first (`~/` is expanded), then `~/.cargo/bin/bob`, `~/.local/bin/bob`, `/opt/homebrew/bin/bob`, and `/usr/local/bin/bob`, then `bob` on PATH. GUI Obsidian on macOS does not inherit the shell PATH, which is why the fixed locations are tried first. The error card lists exactly the paths that were tried.

The focus chart groups its bars by the report's selected day, week, or month bucket. Its legend names that grouping and, when supplied, the 25th–75th percentile range for those displayed buckets. The rolling-median line uses Bob's supplied daily rolling median. Optional prior-period ticks align by elapsed position across the two windows. Today is outlined as provisional; partial buckets, missing observations, and the tracking start are labeled separately. Metric colors identify the selected series while labels and values use Obsidian text colors.

The context panel follows the focused metric family:

- Pomodoro metrics open on **Where the time went**, grouped by the report's area and project parts. The area bar follows the configured hue-slot order, with unfiled and unlinked buckets last in gray. Rows group projects under their top-level area; area tasks use the catalog's `project_label`, rolled-up projects report their descendant count, and bucket rows include fix-it guidance. Multi-home minutes use a lighter tint for the apportioned share. The footer explains that attribution follows today's note tree and comes from Task Links.
- **Themes** remains one click away and shows the supplied theme breakdown. Reports without area/project parts default to Themes and show an update-bob hint. Reports without the additive `sessions` list omit session drill-down controls.
- Area/project rows open their home note; Ctrl/Cmd opens a new leaf. Pomodoro rows can expand contributing sessions (newest first, 20 initially) with date, time range, duration, authored name, and home shares; selecting a session opens its daily note at the ledger line. Pinning a day or bucket switches the table and title to that supplied span. Count metrics use the same groups without color tint.
- Task metrics show the Created, Done, Dropped, and Net flow series when those metrics are present, followed by the focused task metric's area/project counts.
- Review metrics show supplied answers by tier and outcome; Keep rate also shows its 90% reference line and the review-time context.
- Checklist metrics show a daily state calendar and, when supplied, the full chore summary/grid. Filled cells mean met, outlined cells mean missed, blank cells mean not due, and crossed cells mean missing or not tracked.

The **Focus metric** selector includes every metric returned in the report, even when a metric is not in the overview tiles. Selecting a tile or metric keeps the chart and its family panel together. Range chips select a window; the previous/next controls move through windows, **vs prior** requests the comparison series, **Custom…** selects inclusive dates, and **Table** toggles the point table. These view-only changes reuse the report already loaded for that selection. A range, offset, or comparison change loads the corresponding report.

Hovering or focusing a chart column displays its value, date or bucket span, quality, and top parts. Click or Enter pins the day; the separate **Open daily note** control opens the selected existing day, and Ctrl/Cmd activation opens it in a new leaf. For a week or month bar, the tooltip continues to describe the whole bucket. The day selector under Day detail chooses a supplied daily point inside that bucket; grouped totals are never presented as daily values. Left/Right moves the daily cursor, j/k changes the focused metric, h/l shifts the window, t toggles the table, and Esc clears the pin or closes the definition panel. Inputs and native controls keep their normal key behavior.

**Definition** discloses the metric's report-provided definition, unit, source, tracking start, and limits. No definition or metric value is reconstructed by the renderer.

### Day strip

Every canonical daily note (`YYYY/YYYYMMDD.md`) ends with a Day strip: a compact receipt of that note's own day — never a window, a target, or a comparison. Five tiles (`worked`, `reviewed`, `done`, `created`, `review_closed`, in that order) show each metric's D point `display`, with eight history marks for D−7…D beneath (bars for counts, dots for the review-closeout bool). A stacked area bar shows D's `worked` minutes by area in the same order and hues as the Stats page's "Where the time went" bar, with a three-entry legend plus `+N`. The meta row reads `so far · HH:MM` — the fetch time of the shown report, not the wall clock — on today's note only, then `Stats ↗` and the collapse chevron. A metric hidden by `stats.hide` is omitted from the strip, not faked.

Bob owns every value: labels, values, part displays, definitions, and hues all come from the report. The strip writes its own fixed copy only (`so far`, `Stats ↗`, error lines) plus date words derived from D.

Honest states, briefly: today is provisional with hollow marks and an unmet closeout reads `not yet`; past days use solid marks and `missed` is allowed. `not_tracked` shows `–`, `missing` (or a report warning for D) shows `–` plus `⚠`, a real `0m` worked day shows a baseline mix bar with a `no Pomodoros` legend, and a future daily note shows nothing. The first load paints a skeleton in the final geometry; a failed refresh keeps the frame with a stale dot, while a failure with nothing cached shows one muted `Day stats unavailable` line with `Retry` and `Stats ↗`. The strip never shows another date's result while loading.

Where it renders: a virtual mount after the last line of canonical daily notes in Live Preview — nothing is written to the file. A ```` ```bob-day-stats ```` fence embeds a strip elsewhere (for example a weekly note) and takes an optional `day: YYYY-MM-DD` body; without it, the fence uses its note's own date. When a daily note holds a fence for its own day, the virtual mount stands down so there is never a double strip. There is no Reading view mount; the fence still renders in Reading view. The strip renders nothing in hover popovers, embeds, canvas nodes, source mode, or on mobile.

Data: the strip reads D from the shared default report — the Stats page's default arguments, so the page and every recent strip share one cache entry. Any other day (older than the window, or the window's first week) fetches `stats show -a -f json -g day -r <D-7>..<D>`. A cached report whose `today` predates the local date is never shown as "so far"; the strip refetches instead.

Refresh budget: painting from cache costs one frame and zero spawns; typing never rebuilds the strip; an off-screen or backgrounded strip spawns nothing; a visible strip spawns at most twice per minute while the vault changes and at most six per hour while idle; strips sharing a cache key with the page settle through one spawn at a time.

Controls: `stats.day_strip` in `~/.config/bob/config.yml` (`true` by default) is the durable on/off for the virtual mount — explicit fences still render when it is `false`. The `Toggle Day stats` command is a session-only toggle. The collapse chevron collapses every strip on the device and persists per device, never in the vault. Opening, refreshing, or collapsing a strip never changes note bytes and never appends journal events. Clicking a tile, the mix bar, or `Stats ↗` opens Stats with the day pinned (and the metric focused, or "Where the time went" revealed for the mix bar).

### Browser preview and fixtures

The fixture-driven real-browser preview exercises the production `bob-stats` block renderer, SVG chart helpers, context panels, stylesheet, and event handlers. From the bob-plugins checkout, run:

```
npm run build
node scripts/preview-stats-block.cjs --out /tmp/bob-stats-preview
```

The script discovers an installed headless Chromium, creates a standalone `index.html`, captures the light/dark and narrow/wide matrix under the output directory, and writes `matrix-report.md` with the browser and renderer versions plus interaction checks. The HTML embeds the built renderer, stylesheet, and fixtures; it opens directly from disk without a server. These preview outputs are not part of `npm test` or `npm run validate`.

`scripts/fixtures/stats/README.md` records how to regenerate `canonical.json` from the bob-cli golden vault with a pinned `BOB_NOW`. The `synthetic-*.json` files are clearly labeled presentation-only cases for grouped buckets, signed values, medians, long labels, and empty or all-null states. They do not describe vault data or validate metric semantics.
