# Pomodoro homes

Pomodoro homes label work from the notes of the tasks linked under each ledger entry. The label is derived when Bob reads the ledger; Bob never writes a session name, home, or label back to a daily note. Open entries show a forecast. Closed entries show the attribution Stats counts.

This is the shared contract for Rust, the Obsidian ledger mirror, and clients that render Bob's JSON. Rust owns the meaning. Consumers may ignore new JSON fields; this contract does not require a schema-version change.

## R1 — Task homes

Resolve a dedicated Task Link relative to its daily note using Bob's link rules: an exact vault path with or without `.md`, then a unique case-insensitive basename. A missing or ambiguous link is `@unfiled` and increments `unresolved_links` or `ambiguous_links`.

The link target's note determines home; its block id only distinguishes tasks. Walk scalar `parent:` wikilinks from the linked note toward its ancestors. Aliases, `.md`, and headings are removed. List-form, missing, self, and unresolvable parents end the walk. Stop after 16 notes or when a path repeats. Archive (`done/*_done.md`, `type: [[done]]`) and reference (`type: [[ref]]`) notes have no home of their own and are walked through.

The first `type: [[area]]` or `type: [[project]]` on the chain, starting at the linked note, is the home. Closed and canceled projects remain valid homes. A note without either type is `@unfiled`.

A link to a canonical daily note's `^gtd` block (case-insensitive) is the `@review:<first exempt name>` home. An empty target resolves to the current daily note. The top-level area is the outermost area on the chain. A subarea is the nearest area below that top-level area. A home without an area uses `@unfiled` for its area dimension.

## R2 — Project rollup

`rollup: subprojects` on a project maps every descendant project to that project for reporting, at any depth and regardless of descendant status. Absent and `off` values disable rollup. Invalid values, and any `rollup` key on a non-project note, produce one `rollup_invalid` lint per note and act as off. When several projects on a chain opt in, the outermost project wins. There is no child-side opt-out in this version.

Keep the original note chain before rollup. Later filters use that chain to measure a child project on its own even when its reports are grouped under a parent. Rollup does not change blocking or `#prj_is_blocked` inheritance, Ready caps, Sub-projects lines, review tiers, capture routing, or the plan budget. See [Project Task Sync](<https://bobs-org.github.io/projects/#rollup-reporting-only>).

## R3 — Links that count

Count dedicated Task Links: direct child bullets at the Pomodoro's first child indentation whose body is exactly one block link after removing 🍅 markers and one enclosing `~~…~~` strike. Plain links, embeds, and struck links count. Mixed prose, fenced code, deferred `[[note]]#` bodies, deeper descendants, Depends-On lines, Work Log lines, and Schedule Log lines do not count. The capture start picker keeps its existing rule and continues to omit struck links.

De-duplicate resolved links by `(note path, block id)`. If resolution fails, de-duplicate by the raw `(target, block id)` pair. Open and closed entries use the same link rule. Open homes are forecasts and never count as worked time.

## R4 — Review sessions

A named session is a review session when every `+`-separated name component is in `plan.exempt` (default `[GTD]`). Its minutes go to one `@review:<NAME>` part per exempt component; composite names split their minutes evenly, with any remainder going to the first component. The bucket label is `<NAME> review`. Its task links do not attribute.

An unnamed session is also a review session when one of its dedicated links is that day's `^gtd` row. This historical bridge uses the first configured exempt name and is disabled when `plan.exempt` is empty. The link still maps to that review home in a work session. A named composite such as `GTD + BOB` is a work session unless every component is exempt.

The review bucket's area is the top-level area of the note resolved from the lowercase review name (`GTD` → `gtd.md`). If that note has no area or project, the bucket itself is the area key. Review parts inherit that area's hue.

## R5 — Splitting and lead home

For a closed session, divide its counted minutes evenly among distinct linked tasks. Each task receives the floor of the split; the entire remainder goes to the first task in ledger order (8m over 3 tasks is 4/2/2; 5m is 3/1/1). Sum those shares by home. In an open session, parts instead use the number of distinct task links. A closed entry without a recorded or range duration is still attributed: its parts carry 0 minutes and task-based shares.

Count-mode Stats assigns the whole session to its lead home: the home with the most distinct tasks, with ties decided by the first task in ledger order. Shares are whole percentages of the session and sum to 100 when there is a non-empty attribution. The percentage rounding gives remaining points to the largest fractional remainders, with ties in ledger order. No composite home names are generated. Each dimension's parts sum to its metric total.

An `apportioned` amount is the minutes in a part that came from sessions with two or more distinct homes in that dimension. A session can be apportioned in `project` but not `area` when multiple projects share one area.

## R6 — Unattributed buckets

`@unlinked` is a closed, non-review session with no dedicated Task Links. `@unfiled` is a linked task whose note has no area or project, or whose link is ambiguous or unresolved. Both buckets remain visible outside any `other` group. They are never guessed from the session name. Their repair hints are “link the task to attribute it” and “file the task in an area or project”. An open entry with no links has no parts and an empty label.

## R7 — Labels, chips, and dots

Use a lowercase note stem for project and area labels. Use `GTD review`, `unlinked`, and `unfiled` for reserved buckets. Sort labels by share, descending; show up to two joined by `·`, then append `+N` for additional homes. A session with no homes has an empty label.

The chip is the label unless suppressed for an unlinked session, a named review session, or a name whose case-insensitive component set equals the set of home labels. An unnamed review bridge chip says `GTD review`. An unfiled-only chip says `unfiled`.

Use a filled `●` for an attributed session and a hollow `○` for a forecast. The dot takes the lead home's area hue. A chip never routes a capture, changes status, reorders a session, or feeds the plan budget. Homes are not capture `#name` targets; separate sessions remain separate.

## R8 — Area hues

The palette uses these fixed categorical slots. An area's own `color:` selects one slot only when the area is top-level. Invalid top-level values produce one `area_color_invalid` lint and use the local FNV-1a fallback. A subarea, project, and review bucket inherit the top-level area's hue. `color:` elsewhere is ignored. `bob stats` reports `area_hue_collision` when top-level areas share a slot and suggests setting `color:` on one of them.

| Slot | Hue | Light | Dark | xterm-256 |
| ---: | --- | --- | --- | ---: |
| 1 | `blue` | `#2a78d6` | `#3987e5` | 33 |
| 2 | `orange` | `#eb6834` | `#d95926` | 208 |
| 3 | `aqua` | `#1baf7a` | `#199e70` | 36 |
| 4 | `yellow` | `#eda100` | `#c98500` | 214 |
| 5 | `magenta` | `#e87ba4` | `#d55181` | 211 |
| 6 | `green` | `#008300` | `#008300` | 28 |
| 7 | `violet` | `#6250d6` | `#9085e9` | 62 |
| 8 | `red` | `#e34948` | `#e66767` | 167 |
| — | `gray` (buckets) | `text-muted` | `text-muted` | dim |

Without an explicit slot, assign the hue by `palette[fnv1a32(lowercase_area_stem) % 8]`. The algorithm is local and deterministic; consumers do not need a global area catalog to resolve a hue. Stats bars order area segments by slot so adjacent colors keep the validated palette ordering.

Bryan's current top-level area assignments are `dev: blue`, `gtd: orange`, `job: aqua`, `love: yellow`, `cash: magenta`, and `body: green`. These are vault configuration, not application constants.

## R9 — JSON object

Every emitting surface uses this additive shape:

```
{
  "homes": {
    "state": "attributed",
    "kind": "work",
    "label": "bob · dev +1",
    "chip": "bob · dev +1",
    "hue": "orange",
    "parts": [
      {
        "key": "bob.md",
        "label": "bob",
        "kind": "project",
        "area": "gtd.md",
        "area_label": "gtd",
        "hue": "orange",
        "tasks": 1,
        "share": 34,
        "minutes": 17
      }
    ]
  }
}
```

`state` is `attributed` for every closed entry and `forecast` for every open entry; `kind` is `work`, `review`, or `unlinked`. `chip` and `hue` are nullable. Each part's kind is `project`, `area`, or `bucket`. `share` is a whole percentage: minute-based for attributed sessions with a duration, and task-based when a closed entry has no duration or for forecasts. `minutes` is present on every attributed part; it is `0` when a closed entry has no duration. An unlinked closed entry has one `@unlinked` part; an unlinked forecast has an empty `parts` array.

## R10 — Current note tree

Resolve homes from the current vault tree each time. Moving a task, changing a project parent, or toggling rollup can regroup past minutes, but cannot change the total time. Stats surfaces say “Grouped by today's note tree · from Task Links”. V1 does not store snapshots.

## Shared conformance vectors

[`tests/fixtures/pomodoro_homes/vectors.json`](<https://github.com/bobs-org/bob-cli/blob/master/tests/fixtures/pomodoro_homes/vectors.json>) contains a raw mini-vault and hand-calculated ledger expectations. The Rust tests run every case against both indexed and disk sources. The shared cases are also the JavaScript mirror's contract; only ambiguous basename resolution is marked `rust_only` because Obsidian's resolver owns that behavior.

| Case | What it proves |
| --- | --- |
| `rollup-archive-review` | nested rollup, archive/ref chain, struck links, de-duplication, review sessions and same-day bridge |
| `resolution-and-lints` | archived/ref/project/area homes, cycles, invalid config, explicit and fallback hues |
| `ambiguous-basename` | duplicate basename resolves to `@unfiled`; the sole `rust_only` case |
| `chips, forecast shares, empty forecasts, and session identity` | chip suppression, forecast and unlinked shapes, ties, named sessions |
| `split-rounding-and-duration` | 50m across 3 tasks is 18/16/16; `[t:: 40m]` takes precedence over a 50m range |
| `share-rounding` | equal task weights round to 34/33/33, giving the tied remainder to ledger order rather than the last part |
| `daily-review-link` | another day's canonical `^gtd` is a review-home task share; only a same-day link triggers the unnamed review bridge |
| `rollup-depth-and-parent-shapes` | direct child and grandchild rollup, list/self parents stop, the depth cap is 16, and unquoted scalar `parent: [[x]]` resolves |
| `parent-scalar-shapes` | quoted scalar wikilinks resolve; single-item YAML lists and bare strings do not |
| `unresolved-and-closed-zero` | unresolved links are `@unfiled`; closed `- [x] ()` stays attributed with zero-minute parts |
| `review-decisions` | named review sessions, same-day bridge, and mixed named work |

## Limits and rejected alternatives

- Parent walks stop after 16 notes and at missing, invalid, self, or repeated parent links.
- Homes are based on note residence and dedicated Pomodoro Task Links. Work notes, prose, and schedule/dependency logs do not attribute time.
- Session names are never rewritten from derived homes; history is not frozen at close; there is no composite `Mixed` bucket; names are never used to guess a home; and children cannot opt out of a parent's rollup in v1.
