From 50edd380728066d0e2df76b6b841a5f0cd283de3 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Mon, 3 Aug 2026 14:32:10 -0500 Subject: [PATCH] feat(read): area-view direct to-dos dissolve to flat items[] (read-shape doctrine PR 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dissolve the area-view's direct anytime/upcoming/someday buckets into ONE flat items[]; keep projects[] (read-shape doctrine §3.13). Fourth of five PRs. An area has two order axes (project sidebar rank, direct-to-do index), so it keeps two flat lists. - data.view.items: every direct to-do in area index order, each carrying stage (kept — mixed) + when, area dropped. anytime/upcoming/someday gone. - data.view.projects: UNCHANGED (child-project rows, sidebar order, mixed stage). The projects/someday-projects/direct/upcoming/loose render variants are TTY projections of projects[] + items[]. - Per-block caps unchanged: area_limit caps the open/current direct to-dos; the "area" block reports those hidden and the flat items[] drops exactly them (later scheduled/someday rows always ride items[]; --show-later is TTY-only). - TTY + truncation byte-stable (verified byte-identical incl. the area_limit cut). The library keeps its structured direct-to-do grouping for the GUI projection; only the wire flattened. Removed the now-dead rebucketChildren card-bucketing helper (no wire surface has stage/date sub-buckets anymore). src/read/area-view.ts (adds items), src/read/truncation.ts (capAreaSections), src/read/shape.ts (shapeAreaView), src/mcp/server.ts (get_area/get_project descs). Tests: unit shape/truncation, cli e2e/loose/render, mcp. Docs: read-shape-doctrine.md, contract.md, design/contracts.md. No apiVersion/error code change. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01RYbJensXxHpJK1v1VDXYUD --- CHANGELOG.md | 6 ++ docs/contract.md | 2 +- docs/design/contracts.md | 6 +- docs/design/read-shape-doctrine.md | 4 +- src/mcp/server.ts | 22 ++-- src/read/area-view.ts | 18 +++- src/read/shape.ts | 162 ++++++----------------------- src/read/truncation.ts | 10 +- test/cli/e2e.test.ts | 33 +++++- test/cli/loose-pseudo-area.test.ts | 32 +++--- test/cli/render.test.ts | 1 + test/mcp/server.test.ts | 16 +-- test/unit/shape.test.ts | 47 ++++++--- test/unit/truncation.test.ts | 13 +++ 14 files changed, 187 insertions(+), 185 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 594b59ff..aaf412a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- **CHANGE (breaking, ALPHA) — the `area show` / `get_area` view dissolves its direct-to-do buckets into ONE flat `items[]`; `projects[]` is kept.** Fourth of five PRs implementing the read-shape doctrine (`docs/design/read-shape-doctrine.md` §3.13). An area has TWO order axes (project sidebar rank, direct-to-do index), so it keeps two flat lists. + - **`data.view.items`** — ONE flat list of every direct to-do in area `index` order, each row carrying `stage` (KEPT — the list is stage-mixed) and `when`, `area` dropped (the card states it). The `anytime` / `upcoming` / `someday` direct-to-do buckets are GONE. Area direct to-dos are never headed nor project-nested, so they are fully self-describing. + - **`data.view.projects`** — UNCHANGED (the area's child-project rows in sidebar order, mixed-stage, someday/scheduled projects included). This is the DISTINCT order axis; the `projects` / someday-projects / direct / upcoming / loose render variants collapse to TTY projections of `projects[]` + `items[]`. + - **Truncation / caps unchanged:** the per-block caps still apply (`project_limit` on project rows, `area_limit` on the open/current direct to-dos); the `area` block reports the open/current direct-to-dos hidden, and the flat `items[]` drops exactly those (the later scheduled/someday rows always ride `items[]` — the `--show-later` toggle is TTY-only, never gated the wire). + - **TTY + truncation byte-stable** — verified byte-identical (incl. the `area_limit` cut) on a rich fixture + the full render/loose suites; the library retains its structured direct-to-do grouping for the GUI projection, only the wire flattened. Removed the now-dead `rebucketChildren` card-bucketing helper (no wire surface has stage/date sub-buckets anymore). (`src/read/area-view.ts` adds `items`, `src/read/truncation.ts` `capAreaSections`, `src/read/shape.ts` `shapeAreaView`.) Tests: `test/unit/{shape,truncation}.test.ts`, `test/cli/{e2e,loose-pseudo-area,render}.test.ts`, `test/mcp/server.test.ts`. Docs: `docs/design/read-shape-doctrine.md`, `docs/contract.md`, `docs/design/contracts.md`, MCP `get_area`/`get_project` descriptions. No new `apiVersion`; no new error code. + - **CHANGE (breaking, ALPHA) — the `project show` / `get_project` `headings[]` becomes a flat catalog and `logbookHeadings` dissolves into the flat `logbook`.** Third of five PRs implementing the read-shape doctrine (`docs/design/read-shape-doctrine.md` §3.12, rulings #C3/#C3a/#C4). - **`headings[]` is now the flat catalog** `[{uuid,title,archived?}]` — EVERY heading (live AND swept archived) in project `index` order (the order axis). The PR-2 `{heading: node}` wrapper is unwrapped. A swept archived heading is now a catalog entry carrying `archived` (it was previously only inside `logbookHeadings`). Under a content scope (`--overdue`/`--tag`) a heading whose children all filtered out collapses. A heading catalog entry carries only `archived` — never `stage`/`status`; sweptness is TTY-derived, not on the wire (#C3a). - **`logbookHeadings` is GONE.** Its archived-heading children fold into the single flat **`logbook`** bucket — now ONE `stopDate DESC` list of ALL swept children (of open headings, un-headed, AND archived headings), each carrying its `heading` ref (flat title + project-scoped `headingUuid`). An odd OPEN child a Put-Back stranded under an archived heading (HEADARC2-C) rides here too with its live `stage` kept — so the flat `logbook` now **KEEPS `stage`** (the bucket is stage-mixed; it previously dropped `stage` as pure). Reconstruct an archived heading's logged children by filtering `logbook` on `heading` + reading `archived` off the catalog. diff --git a/docs/contract.md b/docs/contract.md index 63da4774..507ecb5b 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -79,7 +79,7 @@ The contract reserves a small vocabulary, and each word means exactly one thing | `candidates` | `error.detail.candidates` | The disambiguation list a not-found / ambiguous resolution (or a `show`/bare-noun did-you-mean) offers, so a caller self-corrects without another round-trip. Each entry is the ONE FIXED candidate shape — **invariant across request flags** (`--full` / `--all` never widen it; an error payload is the most determinism-critical surface) and presence-keyed like the rest of the wire: `uuid` and `title` always; `type` (`"project"` \| `"heading"` \| `"area"` \| `"tag"`) present for those kinds only — **absent `type` = to-do**, the same convention the item wire uses; a container hint `area` and/or `project` as a TITLE string only when set; and the lifecycle words `stage` / `when` (same derivations as the item wire) only for a to-do/project candidate whose row carries them. NOTHING else — no notes, counts, dates, or raw entity fields. A trashed/logged candidate needs no boolean: `stage` already reads `"trash"` / `"logbook"`. The list is capped at **8**; when more matched, the `message` states the total (e.g. "matches 12 projects … 4 more"). The POOL is domain-scoped: by default candidates are LIVE rows only (a trashed/logged row never appears); a trash/logbook-domain op (`project restore`) widens its own pool to its domain. When a name matches ZERO live rows but DEAD rows exist, the `message` gains an honest tail ("1 trashed item matches this name — see `things trash`") and `candidates` stays `[]` — no dangling-ref invitation. `not-found` carries `candidates: []`. On a TTY the human candidate LINES render the fused ref form `Title [8charPrefix]` (a duplicate-titled project/to-do candidate also gets a `· started YYYY-MM-DD` tail) — TTY-only presentation; the structured `candidates[]` payload above is unchanged. That fused string is a valid DECORATED input ref: every `` slot accepts `Title [ref]` (the bracketed uuid/partial-uuid resolves, the title is an ignored comment; a literal bracketed title wins via exact-title). | | `scope` | `meta.scope` | The container jail a response was confined to (an area or project), and where the jail came from (`flag` \| `env` \| `config`). | | `filter` | `meta.filter` | The content filter a response was scoped to — currently the `--area` view filter (resolved uuid + title). | -| `loose` | reference argument (READ surfaces only) | The reserved, case-insensitive ref that addresses the NULL area — the area-less items — as a pseudo-area: `area show loose`, `areas loose`, `show loose`, and `projects --area loose`. It renders the composite null-area view (`kind: "area-view"` with `area: null`): area-less projects first, then loose direct to-dos (`--show-later` adds the Upcoming/Someday blocks). As an `--area` FILTER value it selects the area-less rows on `today`/`anytime`/`someday`/`upcoming`/`logbook`/`search` (`--area loose`) — the effective-area-null inversion of a real `--area` (a row whose own area is null AND, for a project child, whose project carries no area either). It ALWAYS wins over a real area that happens to be named "Loose" (reach that one by uuid; a resolution disclosure names it under `meta.warnings`). READ-ONLY: `open` and every WRITE verb refuse it by name — the detach verbs stay `--loose` (a to-do) / `--no-area` (a project), and `--to-area loose` is refused. | +| `loose` | reference argument (READ surfaces only) | The reserved, case-insensitive ref that addresses the NULL area — the area-less items — as a pseudo-area: `area show loose`, `areas loose`, `show loose`, and `projects --area loose`. It renders the composite null-area view (`kind: "area-view"` with `area: null`): area-less projects (`projects[]`) plus the loose direct to-dos as one flat `items[]` (`--show-later` is TTY-only — it toggles the terminal's Upcoming/Someday render sections, not the wire, which always carries every direct to-do in `items[]`). As an `--area` FILTER value it selects the area-less rows on `today`/`anytime`/`someday`/`upcoming`/`logbook`/`search` (`--area loose`) — the effective-area-null inversion of a real `--area` (a row whose own area is null AND, for a project child, whose project carries no area either). It ALWAYS wins over a real area that happens to be named "Loose" (reach that one by uuid; a resolution disclosure names it under `meta.warnings`). READ-ONLY: `open` and every WRITE verb refuse it by name — the detach verbs stay `--loose` (a to-do) / `--no-area` (a project), and `--to-area loose` is refused. | | `uuid` | throughout | A Things object's stable identity. The one durable way to name an item across calls. | | `stage` | inside a to-do/project | The ONE derived lifecycle word: `"inbox"` \| `"upcoming"` \| `"anytime"` \| `"someday"` \| `"logbook"` \| `"trash"`. It replaces the former `start` / `logged` / `trashed` wire fields (all three deleted). Precedence: trashed → `trash` (wins over everything, incl. logged); logged (past the logbook boundary) → `logbook`; else `inbox` (start=0); else `upcoming` (a repeating template, OR a STRICTLY-FUTURE `startDate` — Upcoming membership is `COALESCE(startDate, deadline) > today`, UPC1); else `anytime` (undated-active, OR an ARRIVED `startDate` — `startDate <= today` — which is a Today + Anytime member, reading `when: "today"`, NOT Upcoming); else `someday` (undated, deferred). A completed/canceled row not YET past the logbook boundary keeps its live stage. A deadline-PULLED undated row (an unsuppressed due/overdue deadline, no When-date) derives `anytime` — R13 (BANNER1b): the GUI re-files a pulled Inbox/Someday row into Anytime + Today at pull time. So EVERY Today member derives `anytime`, and the today view is stage-PURE. Dropped only where the enclosing view/section PROVABLY states it — the stage-pure flat catalogues (`inbox`, `anytime`, `someday`, `logbook`, `trash`), the `today` view (R13), and the card sub-buckets — and KEPT everywhere it is not implied: the stage-MIXED `upcoming` catalogue (also holds deadline-forecast `anytime`/`someday` rows), plus search, changes, the projects/areas listings, and detail. Distinct from `status` (the open/canceled/completed axis). | | `when` | inside a to-do/project | The ONE derived, presence-keyed TIME-AXIS position (R12): `"today"` \| `"evening"` \| a FUTURE ISO date (`YYYY-MM-DD`), or absent. The doctrine line: **`stage` enumerates the sidebar BUCKETS an item lives in; `when` enumerates its TIME POSITIONS (today \| evening \| a future date).** `"evening"` is the This-Evening sub-bucket of Today (implies today); `"today"` is Today membership by ANY arm (an arrived `startDate <= today`, OR an undated due/overdue deadline that is not suppressed — the same predicate the star renders, so `when` can never disagree with it); a FUTURE date is a strictly-future scheduled row (`startDate > today`) or a repeating TEMPLATE's projected next occurrence; absent means unscheduled and not in Today (an unprojected template — paused / after-completion — and every logged/trashed row have none). It REPLACES the former `today` / `evening` markers (both deleted) and, for a template, the former `repeating.nextOccurrence` (same fact, one word). Someday is deliberately NOT a `when` value — it is a bucket → `stage`. A deadline-pulled Someday/Inbox row is re-filed into Anytime at pull time (R13/BANNER1b), so it reads `stage: "anytime"` + `when: "today"` — NOT `stage: "someday"`/`"inbox"` (that combination is unreachable; the `stage` derivation makes every Today member `anytime`). Dropped where the enclosing node PROVABLY states the position — inside a card `upcoming` date-group whose key equals it — and KEPT everywhere else it is present, including the `today` view's flat `items[]` (it interleaves Today-proper and This-Evening members, so each row carries which) and the flat `upcoming`/`anytime`/`inbox`/`someday`/search/changes surfaces. NOTE the read/write asymmetry: the write grammar's `--when` input mirrors the app's When control (incl. `--when someday`); reads DECOMPOSE the resulting state onto the two axes (`stage` + `when`). | diff --git a/docs/design/contracts.md b/docs/design/contracts.md index 89443534..088839ad 100644 --- a/docs/design/contracts.md +++ b/docs/design/contracts.md @@ -97,7 +97,7 @@ The `today` view's whole-view aggregate — the app's sidebar count — rides `m - `kind` ∈ `loose | area | project | projects` (`projects` = `area show`'s active project-ROWS section). `ref` is the container uuid (`null` for the loose block); `title` its name. - `shown`/`total`/`limit` are per block; the dropped remainder is `total - shown`. A block whose rows were ALL dropped still appears with `shown: 0` (so no truncated header is untraceable); a genuinely empty block (`total: 0`) is omitted. - Someday's mixed area/loose blocks additionally carry `totalProjects` / `totalTodos` (project rows list first, so the hidden split is derivable). -- **Honesty note (area view):** an `area-view` `blocks[kind=area]` count is "direct to-dos hidden", NOT a per-wire-bucket count. It counts the area's internal `active` set, whereas since R10 the wire splits those same rows across the `anytime` and arrived-`upcoming` buckets — so `total` there is the completeness figure for the direct-to-do section as a whole, not the size of any one emitted bucket. Reconciling block counts with the stage buckets is queued (`docs/up-next.md`). +- **Honesty note (area view):** an `area-view` `blocks[kind=area]` count is "open/current direct to-dos hidden", NOT the size of the flat `items[]`. It counts the area's OPEN/current direct to-dos (the always-shown rows); the future-scheduled + someday direct to-dos are the "later" rows that always ride `items[]` and are never capped. So the flat `items[]` carries every direct to-do EXCEPT the open/current ones the `area` block reports as hidden — read `truncation.truncated` for completeness. **Shape history (pre-1.0 breaks):** the block breakdown grew identity + nesting (`ref` replaced the former `uuid`; project blocks moved under `children`), `meta.truncation` grew the optional `sections`, and the 1.0 shape break folded the former standalone `meta.grouped` into `meta.truncation.blocks` so there is one completeness shape. Same defaults and metadata apply over MCP. The full consumer-facing contract — the envelope grammar, the compatibility covenant, the glossary, and the error-code registry — is in [docs/contract.md](../contract.md). @@ -136,7 +136,7 @@ Changing the host's system **timezone** is safe: it relabels wall clocks but lea Two shaping rules run at the read emit boundary — the CLI `--json` reads AND the MCP read tools — in [`src/read/shape.ts`](../../src/read/shape.ts), BEFORE omit-empty (below). They are deterministic BY VIEW KIND, never per-item heuristics, so both surfaces inherit the identical shape; the human render keeps the full unshaped entities. -**R6 — no-redundant-ancestry.** An item never states a fact its enclosing node already states. In a **project-view** every child drops `project` and `area` (the card states them) — but a HEADED child KEEPS its `heading` ref (the read-shape doctrine §3.12: the live children are one flat `items[]` in index order, so a heading's membership rides the row ref, not a bucket; the ref is a flat title + project-scoped `headingUuid` when the title would not round-trip, kept even in compact). An unheaded project-view child carries no `heading`. In an **area-view** every child item and project card drops `area`; project-child items keep `project`. In an **anytime/someday** section (`{area, items}`) items drop `area` (including the explicit `area: null` section), keeping `project`/`heading`. **Mixed-provenance lists keep every ref** — `inbox`, `today`, `upcoming`, `logbook`, `trash`, `search`, `changes`, `projects` — since those pull from many containers (in the COMPACT tier the `heading` ref is additionally dropped from these, per R7 below). The invariant that makes the drop lossless: the entity `area` is the EFFECTIVE area, and a project/heading child carries `area = NULL` in the DB, so its effective area resolves THROUGH its container to exactly the card's area (the sidebar grouper buckets by the same effective area). **Reading rule: absence of a container field INSIDE a container view = inherited from the enclosing node**, the opposite of its meaning in a mixed list (where absence means "no container"). +**R6 — no-redundant-ancestry.** An item never states a fact its enclosing node already states. In a **project-view** every child drops `project` and `area` (the card states them) — but a HEADED child KEEPS its `heading` ref (the read-shape doctrine §3.12: the live children are one flat `items[]` in index order, so a heading's membership rides the row ref, not a bucket; the ref is a flat title + project-scoped `headingUuid` when the title would not round-trip, kept even in compact). An unheaded project-view child carries no `heading`. In an **area-view** the direct to-dos (the flat `items[]`) and the project rows (`projects[]`) each drop `area` (the card states it); a direct to-do is never headed nor project-nested, so it carries no other container ref. In an **anytime/someday** section (`{area, items}`) items drop `area` (including the explicit `area: null` section), keeping `project`/`heading`. **Mixed-provenance lists keep every ref** — `inbox`, `today`, `upcoming`, `logbook`, `trash`, `search`, `changes`, `projects` — since those pull from many containers (in the COMPACT tier the `heading` ref is additionally dropped from these, per R7 below). The invariant that makes the drop lossless: the entity `area` is the EFFECTIVE area, and a project/heading child carries `area = NULL` in the DB, so its effective area resolves THROUGH its container to exactly the card's area (the sidebar grouper buckets by the same effective area). **Reading rule: absence of a container field INSIDE a container view = inherited from the enclosing node**, the opposite of its meaning in a mixed list (where absence means "no container"). **R7 — named detail tiers (compact | full).** Every list context — `items`, `sections`, and the collection arrays inside a `view` — returns a COMPACT line-item; `detail`/`show` and a `--full` (CLI) / `full: true` (MCP `read_view` / `search` / `changes_since` / `get_project` / `get_area` / `list_collections`) request return the FULL record. Compact = identity + structural facts + non-default facts: `status` (omit when `open`), `created`/`modified` (always dropped — get them from `detail`), the full `notes` string dropped for a presence-keyed `hasNotes: true` marker (absent = no notes), and the `heading` ref dropped everywhere (the GUI shows the project, never the heading, outside a project view). `--full` restores per-row density (incl. full `notes` and `heading`) but R6 still applies (ancestry redundancy is not tier-dependent). **Compact reading rule: absence = the default.** @@ -178,7 +178,7 @@ Kept even when "empty" (absence would be lossy, so these are always present on t - **Identity keys** — always present: `uuid`, `type`, and the name (`title`). An untitled to-do still carries `title: ""`. - **Booleans** — a real `false` is meaningful, never omitted where a value is emitted: an area's `visible`. (The former `logged`/`trashed` item booleans are gone — R10 folds them into the one derived `stage`; the former `today`/`evening` markers are gone too — R12 folds them into the one derived `when`; and R11 made `repeating` a presence-keyed TEMPLATE-only object whose inner `paused`/`deadlined` are default-pruned false booleans — the `isTemplate`/`isInstance` discriminators no longer exist on the wire.) - **Numeric counts** — a `0` is meaningful, never omitted by omit-empty: `openChildrenWhileResolved`, and the `today` view's `meta.counts` (`dueOrOverdue`/`other`, which live on `meta`, not `data`). (The checklist counters and the project leaf-action counters are no longer flat wire fields — the reshapes fold them into the presence-keyed `checklist` and `todos` objects above, each absent when its total is 0.) -- **Structural scaffolding** — the view shape that *carries* entities is not itself an entity and is never pruned, so its lists/markers survive empty: the `today` view's flat `items[]`, the project card sections (`items`, `headings`, `logbook`), the area card sections (`anytime`, `upcoming`, `someday`, `projects`), and a sidebar section's `area: null` — the load-bearing "top-level / loose block" marker. Only the entities *inside* the scaffolding are pruned. (This is why omit-empty is scoped to recognized entity shapes, not a blanket deep prune: a to-do's `area: null` means "no area" and is dropped, but a section's `area: null` is a discriminant and is kept — same key, opposite meaning.) +- **Structural scaffolding** — the view shape that *carries* entities is not itself an entity and is never pruned, so its lists/markers survive empty: the `today` view's flat `items[]`, the project card sections (`items`, `headings`, `logbook`), the area card sections (`items`, `projects`), and a sidebar section's `area: null` — the load-bearing "top-level / loose block" marker. Only the entities *inside* the scaffolding are pruned. (This is why omit-empty is scoped to recognized entity shapes, not a blanket deep prune: a to-do's `area: null` means "no area" and is dropped, but a section's `area: null` is a discriminant and is kept — same key, opposite meaning.) Omitted when empty, per entity: diff --git a/docs/design/read-shape-doctrine.md b/docs/design/read-shape-doctrine.md index 22d2e6d5..05b25aa8 100644 --- a/docs/design/read-shape-doctrine.md +++ b/docs/design/read-shape-doctrine.md @@ -165,7 +165,7 @@ One bucket per semantic question: the heading **order axis** (`headings`), the l Orderings: `headings` = heading `index ASC`; `items` = child `index ASC`; `logbook` = `stopDate DESC` (open odd children null-last). -### 3.13 `area-view` (`data: { view }`) — **planned (PR 4)** +### 3.13 `area-view` (`data: { view }`) — **landed (PR 4)** Current buckets: `area` (node, or `null` for loose) · `anytime[]` (direct to-dos) · `projects[]` (mixed-stage project rows) · `upcoming[{date,items}]` · `someday[]`. @@ -248,7 +248,7 @@ Ordered so each step is independently green and self-merged before the next (ALP 1. **`mg/today-dissolve` — today dissolve (§3.1) + this doctrine doc.** `sections` → flat `items[]` + `when`; `badge` → `meta.counts`; the "badge" vocabulary purge; the TTY redesign (clean header + counts at top). **Landed.** 2. **`mg/project-children-dissolve` — project-view children dissolve (§3.12).** Unheaded + per-heading stage sub-buckets → one flat `items[]` in project index order, every row carrying `stage`/`when`/`heading` ref; `headings[]` becomes the memberless live-heading catalog. TTY byte-stable (the library retains the structured groups for the GUI-faithful projection — it owns the clock + `todayIndex`; the wire is flat). **Landed.** 3. **`mg/headings-catalog-logbook-flatten` — headings catalog + logbook flatten (§3.12, rulings #C3/#C3a/#C4).** `headings[]` → index-ordered catalog `[{uuid,title,archived?}]` (all headings, live + swept archived); `logbookHeadings` dissolves; single flat `logbook` bucket (stopDate DESC) absorbing archived-heading children, each with its `heading` ref, `stage` kept (mixed). All HEADARC2 TTY invariants preserved (byte-stable — the library keeps its structured logged-region grouping for the projection). **Landed.** -4. **`mg/area-view-dissolve` — area-view dissolve (§3.13).** Direct `anytime`/`upcoming`/`someday` → one flat `items[]`; `projects[]` KEPT (sidebar-rank axis). TTY byte-stable. +4. **`mg/area-view-dissolve` — area-view dissolve (§3.13).** Direct `anytime`/`upcoming`/`someday` → one flat `items[]` (index order, `stage`/`when` per row); `projects[]` KEPT (the distinct sidebar-rank order axis). TTY + truncation byte-stable (the library keeps its structured direct-to-do grouping; the per-block `area` cap on the open/current rows is preserved and mirrored onto the flat `items[]`). **Landed.** 5. **`mg/ordering-contract-docs` — ordering contract + skill sweep (ruling #C6).** Document every kept bucket's ordering in [../contract.md](../contract.md) (+ envelope schema description strings); flag presentation-derived orders as such; sweep [contracts.md](contracts.md) and the skill for the new shapes across all five PRs; fix the §5o "the desktop GUI is stricter" lede to platform-accurate wording. Living-doc updates ride each PR (per AGENTS.md): [../contract.md](../contract.md), [contracts.md](contracts.md), `CHANGELOG.md` (Unreleased, breaking), and the capability-matrix if a read verdict changes. diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 1cfc96b4..b81a83d2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1204,11 +1204,13 @@ export function createThingsMcpServer(options: McpServerOptions = {}): McpServer "get_project", { description: - "One project's full contents: metadata plus its to-dos grouped under their headings. " + - "Open headings (and archived ones not yet swept to the logbook) stay live, carrying " + - "archived once archived; a swept archived heading moves into the logged region as a " + - "group with its children. The tag filters keep only the child to-dos matching by their " + - "own tags (a heading left with none is dropped). " + + "One project's full contents: metadata, its live child to-dos as one flat list (items, " + + "in index order — each carrying its stage, when, and, when it lives under a heading, " + + "its heading ref), the heading catalog (headings — every heading in index order, " + + "carrying archived once archived), and its logged children (logbook, most recent " + + "first, each carrying its heading ref). Reconstruct a heading's members by filtering " + + "items (or logbook) on the heading ref. The tag filters keep only the child to-dos " + + "matching by their own tags (a heading left with none is dropped). " + OMIT_EMPTY_NOTE + " " + REF_RULE_NOTE, @@ -1250,11 +1252,11 @@ export function createThingsMcpServer(options: McpServerOptions = {}): McpServer "get_area", { description: - "One area's contents: metadata plus its direct to-dos (active first), its " + - "projects in canonical order, and later (scheduled/repeating/someday). The area " + - "logbook is not returned here — read it with read_view logbook + area; trashed " + - "rows live in read_view trash. " + - `The project-rows and direct-to-dos sections are capped at ${AREA_PREVIEW_LIMIT} each ` + + "One area's contents: metadata plus its direct to-dos as one flat list (items, in " + + "index order — each row carrying its stage and when) and its projects in canonical " + + "order (projects). The area logbook is not returned here — read it with read_view " + + "logbook + area; trashed rows live in read_view trash. " + + `The project rows and the open/current direct to-dos are capped at ${AREA_PREVIEW_LIMIT} each ` + "by default (project_limit / area_limit adjust them; all: true lifts both); the " + "second result block reports the counts. " + OMIT_EMPTY_NOTE + diff --git a/src/read/area-view.ts b/src/read/area-view.ts index ddd0ab2f..3c953d17 100644 --- a/src/read/area-view.ts +++ b/src/read/area-view.ts @@ -21,6 +21,17 @@ import { tagFilter, type ViewFilter } from "./views.ts"; export interface AreaView { /** The area, or `null` for the `loose` pseudo-area (the NULL-area composite). */ area: Area | null; + /** + * ALL live (non-logbook) DIRECT to-dos of the area, in `index` order, each + * carrying `stage` + `when` — the flat wire representation (read-shape doctrine + * §3.13's area-view `items[]`). Area direct to-dos are never headed and never + * project-nested, so they are fully self-describing once `area` (the card + * states it) is dropped. The structured buckets below (`active`, `scheduled`, + * `someday`, `repeating`) re-group the SAME to-dos into the GUI render layout + * for the byte-stable TTY (the library owns the clock + `todayIndex`). + * `projects[]` is the SEPARATE order axis (sidebar rank) and is NOT folded in. + */ + items: Todo[]; /** Open, unscheduled/current direct to-dos, by index. */ active: Todo[]; /** The area's open projects in sidebar order (someday projects included). */ @@ -206,5 +217,10 @@ export function areaView( else scheduled.push({ date, items: [todo] }); } - return { area, active, projects, scheduled, someday, repeating }; + // The flat wire `items[]`: every LIVE direct to-do in area index order (the + // `todos` fetch is already `ORDER BY index ASC`). Excludes swept logged rows + // (the area view has no logbook section — they live in `things logbook --area`). + const items = todos.map((t) => t.todo).filter((td) => !td.logged); + + return { area, items, active, projects, scheduled, someday, repeating }; } diff --git a/src/read/shape.ts b/src/read/shape.ts index 1c08f4a7..4910903b 100644 --- a/src/read/shape.ts +++ b/src/read/shape.ts @@ -54,17 +54,16 @@ * substrate) and KEPT in FULL/DETAIL beside `when` — different facts: `startDate` * = what is stored, `when` = where it sits. * - a template's `repeating.nextOccurrence` is GONE from the wire — `when` replaces - * it (same fact, one word); the resting-templates `{date: null}` group is - * unchanged (an unprojected template has no `when`). - * - `when` is DROPPED inside a card/heading `upcoming` DATE-GROUP for a member whose - * `when` equals the group's date (the group states it), and KEPT everywhere else - * it is present — including the today view's flat `items[]` (it carries the - * Today-proper vs This-Evening render section), and the flat - * `upcoming`/`anytime`/`inbox`/`someday` catalogues, search, changes (a - * deadline-pulled row reads `when: "today"` in the mixed search/changes surfaces, - * informatively; note R13 re-files it to stage `anytime` and the flat - * inbox/someday views now EXCLUDE it — it appears in the `anytime` catalogue - * instead, `when: "today"` kept, stage dropped as pure). + * it (same fact, one word); an unprojected template has no `when`. + * - `when` is KEPT on every row it is present on. (The read-shape doctrine + * flattened the card `upcoming` date-groups into the project-/area-view flat + * `items[]`, so no enclosing node states the position anymore.) It rides the + * today view's flat `items[]` (Today-proper vs This-Evening), the project-/area- + * view `items[]`, and the flat `upcoming`/`anytime`/`inbox`/`someday` catalogues, + * search, changes (a deadline-pulled row reads `when: "today"` in the mixed + * search/changes surfaces, informatively; note R13 re-files it to stage `anytime` + * and the flat inbox/someday views now EXCLUDE it — it appears in the `anytime` + * catalogue instead, `when: "today"` kept, stage dropped as pure). * * ## R13 — provisional Today members + GUI-faithful pulled-row membership * BANNER1 / BANNER1b (docs/lab/banner1-research.md). Two coupled facts: @@ -397,9 +396,9 @@ function shapeItem(src: unknown, drop: ItemDrop, compact: boolean, promoter: Ref delete o["todaySection"]; if (drop.stage !== true) o["stage"] = stage; // R12 — the today/evening marker KEYS are replaced by the derived `when` on - // EVERY tier (they never appear on the wire); `when` is emitted unless the - // enclosing context provably states the position (the today view's sections; - // a card date-group — handled in rebucketChildren). + // EVERY tier (they never appear on the wire); `when` is emitted on every row it + // is present on (since the read-shape doctrine flattened the card date-groups, + // no enclosing node states the position anymore — the flat lists carry it). delete o["today"]; delete o["evening"]; // §9n — a reminder byte is presentation-dead once its `startDate` goes strictly @@ -487,101 +486,6 @@ function withShapedItems(base: Obj, drop: ItemDrop, compact: boolean, promoter: return out; } -/** A child entity carrying the fields the R10 re-bucketer needs. */ -interface Child extends Obj { - startDate?: string | null; - todayIndex?: number; -} - -/** An IsoDate group on the wire — `date` is a real string, or `null` for the resting-templates group. */ -interface WireDateGroup { - date: string | null; - items: unknown[]; -} - -/** - * Re-bucket a project's / area's / heading's live (non-logbook/trash) children - * into the R10 card shape by their derived {@link deriveStage} — so the bucket an - * item lands in ALWAYS equals its `stage`: - * - `anytime` — stage anytime, in encounter order; - * - `upcoming` — stage upcoming, date-grouped `[{date, items}]` (a dated row under - * its `startDate`, a template under its `nextOccurrence`), date ASC; date-LESS - * templates (after-completion / paused) form a trailing `{date: null, items}` - * group (explicit null per the `area: null` section precedent); - * - `someday` — stage someday. - * Items are already in view order (index / date+todayIndex) from the read layer, - * so encounter order within a date group preserves that ordering. Each item is - * then run through {@link shapeItem} with the section drop (ancestry + `stage`, - * since the bucket states it). - */ -function rebucketChildren( - children: unknown[], - drop: ItemDrop, - compact: boolean, - promoter: RefPromoter, -): { anytime: unknown[]; upcoming: WireDateGroup[]; someday: unknown[] } { - const anytime: unknown[] = []; - const someday: unknown[] = []; - const datedByKey = new Map(); - const datedOrder: string[] = []; - const restingTemplates: unknown[] = []; - const shape = (c: unknown) => shapeItem(c, drop, compact, promoter); - for (const raw of children) { - if (raw === null || typeof raw !== "object") continue; - const c = raw as Child; - const stage = stageOf(c); - if (stage === "anytime") { - anytime.push(shape(c)); - } else if (stage === "someday") { - someday.push(shape(c)); - } else if (stage === "upcoming") { - const repeating = c["repeating"] as Obj | undefined; - const nextOcc = - repeating != null && typeof repeating === "object" - ? ((repeating["nextOccurrence"] as string | null | undefined) ?? null) - : null; - const date = (c.startDate ?? null) !== null ? c.startDate! : nextOcc; - if (date === null) { - restingTemplates.push(shape(c)); - } else { - if (!datedByKey.has(date)) { - datedByKey.set(date, []); - datedOrder.push(date); - } - // R12 — inside a date-group the group states the date, so a member whose - // `when` equals it drops it (every scheduled row and every projected - // template does — that IS the group key). - const shaped = shape(c); - if (shaped !== null && typeof shaped === "object" && (shaped as Obj)["when"] === date) { - delete (shaped as Obj)["when"]; - } - datedByKey.get(date)!.push(shaped); - } - } else { - // inbox / logbook / trash should not appear among a card's live children; - // route defensively to anytime rather than drop the row. - anytime.push(shape(c)); - } - } - const upcoming: WireDateGroup[] = datedOrder - .toSorted((a, b) => a.localeCompare(b)) - .map((date) => ({ date, items: datedByKey.get(date)! })); - if (restingTemplates.length > 0) upcoming.push({ date: null, items: restingTemplates }); - return { anytime, upcoming, someday }; -} - -/** Flatten an internal IsoDateGroup[] (`[{date, items}]`) to its items, in order. */ -function flattenGroups(groups: unknown): unknown[] { - if (!Array.isArray(groups)) return []; - const out: unknown[] = []; - for (const g of groups) { - if (g !== null && typeof g === "object" && Array.isArray((g as Obj)["items"])) { - out.push(...((g as Obj)["items"] as unknown[])); - } - } - return out; -} - /** Coerce an unknown value to an array (empty when absent). */ const asArray = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); @@ -610,8 +514,13 @@ const PROJECT_LOGBOOK_DROP: ItemDrop = { area: true, keepHeading: true, }; -/** Area-view child-item buckets drop their area (the card states it) + the bucket-implied stage. */ -const AREA_CHILD_DROP: ItemDrop = { area: true, stage: true }; +/** + * The flat area-view `items[]` rows (read-shape doctrine §3.13): drop `area` (the + * card states it) but KEEP `stage` (the list is stage-MIXED — anytime + upcoming + * + someday + closed-unswept) and `when`. Area direct to-dos are never headed and + * never project-nested, so nothing else to drop. + */ +const AREA_ITEM_DROP: ItemDrop = { area: true }; /** Area-view PROJECTS list: a mixed listing of the area's project rows — keep `stage`, drop area. */ const AREA_PROJECTS_DROP: ItemDrop = { area: true }; /** @@ -735,35 +644,32 @@ function shapeProjectView(view: Obj, compact: boolean, promoter: RefPromoter): O return out; } -/** Shape every collection bucket of an area view; the area node keeps its identity (tags folded). */ +/** + * Shape an area view (read-shape doctrine §3.13). The direct to-dos dissolve into + * ONE flat `items[]` in index order — each row carrying `stage` + `when`, `area` + * dropped (the card states it). The stage/date sub-buckets (anytime/upcoming/ + * someday) are gone. `projects[]` is KEPT — the area's child-project sidebar rank + * is a DISTINCT order axis from the direct-to-do index, so it earns its own list + * (mixed-stage, someday/scheduled projects included; the render split is TTY-only). + * The area node keeps its identity (tags folded). + */ function shapeAreaView(view: Obj, compact: boolean, promoter: RefPromoter): Obj { - const looseMembers = [ - ...asArray(view["active"]), - ...flattenGroups(view["scheduled"]), - ...asArray(view["someday"]), - ...asArray(view["repeating"]), - ]; - const { anytime, upcoming, someday } = rebucketChildren( - looseMembers, - AREA_CHILD_DROP, - compact, - promoter, - ); const out: Obj = { ...view }; delete out["active"]; delete out["scheduled"]; delete out["repeating"]; + delete out["someday"]; // No `logbook` or `trash` bucket: an area's logbook is the bounded query // `things logbook --area `, and trashed rows live only in `things trash`. // Delete defensively in case an untyped source carries the old keys. delete out["logged"]; delete out["trashed"]; out["area"] = shapeArea(view["area"]); - out["anytime"] = anytime; - // The projects list is a mixed listing of the area's project rows — keep stage. + // The flat direct to-dos — stage/when kept, area dropped. + out["items"] = shapeList(view["items"], AREA_ITEM_DROP, compact, promoter); + // The projects list is a mixed listing of the area's project rows — keep stage, + // sidebar order (the distinct order axis). out["projects"] = shapeList(view["projects"], AREA_PROJECTS_DROP, compact, promoter); - out["upcoming"] = upcoming; - out["someday"] = someday; return out; } diff --git a/src/read/truncation.ts b/src/read/truncation.ts index 9a172cd7..abc6f4ef 100644 --- a/src/read/truncation.ts +++ b/src/read/truncation.ts @@ -269,8 +269,16 @@ export function capAreaSections( limit: limits.area, }); } + // The flat wire `items[]` mirrors the same cut: drop exactly the open/current + // direct to-dos the `active` cap removed (the "area" block reports them), so the + // wire item list and the truncation agree. The scheduled/someday/repeating + // direct to-dos always survive (they are the "later" rows the TTY toggles). + const droppedActive = new Set( + view.active.slice(active.length).map((t) => (t as { uuid: string }).uuid), + ); + const items = view.items.filter((t) => !droppedActive.has((t as { uuid: string }).uuid)); return { - data: { ...view, projects, active }, + data: { ...view, projects, active, items }, truncation: groupedTruncation(blocks, truncated), }; } diff --git a/test/cli/e2e.test.ts b/test/cli/e2e.test.ts index 6ca68903..fb493fc6 100644 --- a/test/cli/e2e.test.ts +++ b/test/cli/e2e.test.ts @@ -731,12 +731,37 @@ describe("cli tag filters in container views (§9a wiring — direct-on-row)", ( tagTask(fx.db, buried, focus); const json = runCli(["area", "show", "Home", "--tag", "focus", "--json", "--db", fx.path]); const data = JSON.parse(json.stdout).data.view; - expect(data.anytime.map((i: { title: string }) => i.title)).toEqual(["loose-focus"]); + expect(data.items.map((i: { title: string }) => i.title)).toEqual(["loose-focus"]); expect(data.projects.map((i: { title: string }) => i.title)).toEqual(["proj-focus"]); const all = JSON.stringify(data); expect(all).not.toContain("buried-focus"); }); + it("area show --json: direct to-dos are ONE flat items[] (index order); projects[] kept; no stage buckets", () => { + fx = buildFixtureDb(); + seedArea(fx.db, "Work", 0, "AR"); + seedTodo(fx.db, { area: "AR", title: "d-now", index: 0 }); + seedTodo(fx.db, { area: "AR", title: "d-later", startDate: "2030-01-01", index: 1 }); + seedTodo(fx.db, { area: "AR", title: "d-some", start: "someday", index: 2 }); + seedProject(fx.db, { area: "AR", title: "P-a", index: 10 }); + const view = JSON.parse(runCli(["area", "show", "AR", "--json", "--db", fx.path]).stdout).data + .view; + // ONE flat items[] in index order — all direct to-dos (current + later). + expect(view.items.map((i: { title: string }) => i.title)).toEqual([ + "d-now", + "d-later", + "d-some", + ]); + // stage/when kept per row; the dissolved buckets are gone. + for (const k of ["anytime", "upcoming", "someday"]) expect(k in view).toBe(false); + const later = view.items.find((i: { title: string }) => i.title === "d-later"); + expect(later.stage).toBe("upcoming"); + expect(later.when).toBe("2030-01-01"); + expect(view.items.find((i: { title: string }) => i.title === "d-some").stage).toBe("someday"); + // projects[] KEPT (the distinct sidebar-rank order axis). + expect(view.projects.map((p: { title: string }) => p.title)).toEqual(["P-a"]); + }); + it("things projects --tag is FLAT/inheritance-inclusive; area show --tag suppresses area inheritance", () => { fx = buildFixtureDb(); const focus = seedTag(fx.db, "focus"); @@ -1823,7 +1848,7 @@ describe("cli detail views — area show per-section caps; project show uncapped ]).stdout, ); expect(json.data.view.projects).toHaveLength(2); - expect(json.data.view.anytime).toHaveLength(3); + expect(json.data.view.items).toHaveLength(3); expect(json.meta.truncation).toEqual({ // Aggregate counts roll the per-block totals up (2+3 shown of 35+35). shown: 5, @@ -1840,7 +1865,7 @@ describe("cli detail views — area show per-section caps; project show uncapped runCli(["area", "show", "Busy", "--all", "--json", "--db", fx.path]).stdout, ); expect(all.data.view.projects).toHaveLength(35); - expect(all.data.view.anytime).toHaveLength(35); + expect(all.data.view.items).toHaveLength(35); expect(all.meta.truncation.truncated).toBe(false); expect(runCli(["area", "show", "Busy", "--all", "--db", fx.path]).stdout).not.toContain("more"); }); @@ -2530,7 +2555,7 @@ describe("overdue in container views (cli)", () => { const env = JSON.parse( runCli(["area", "show", "Home", "--overdue", "--json", "--db", fx.path]).stdout, ); - expect(env.data.view.anytime.map((i: { title: string }) => i.title)).toEqual(["todo-overdue"]); + expect(env.data.view.items.map((i: { title: string }) => i.title)).toEqual(["todo-overdue"]); expect(env.data.view.projects.map((i: { title: string }) => i.title)).toEqual(["proj-overdue"]); const tty = runCli(["area", "show", "Home", "--overdue", "--db", fx.path]).stdout; expect(tty).not.toContain("buried-overdue"); diff --git a/test/cli/loose-pseudo-area.test.ts b/test/cli/loose-pseudo-area.test.ts index 58007039..a5f9c238 100644 --- a/test/cli/loose-pseudo-area.test.ts +++ b/test/cli/loose-pseudo-area.test.ts @@ -117,28 +117,30 @@ describe("area show loose (composite null-area view)", () => { // Active section: the area-less project row + the loose active to-do. // Inbox capture, real-area rows, and project-nested rows never appear here. expect(env.data.view.projects.map((p: { title: string }) => p.title)).toEqual(["loose-proj"]); - expect(env.data.view.anytime.map((t: { title: string }) => t.title)).toEqual(["loose-active"]); + // The flat items[] holds the direct to-dos; only the arrived/current one shows + // without --show-later, so `loose-active` is the sole stage-`anytime` row here. + expect( + env.data.view.items + .filter((t: { stage?: string }) => t.stage === "anytime") + .map((t: { title: string }) => t.title), + ).toEqual(["loose-active"]); const flat = JSON.stringify(env.data); expect(flat).not.toContain("inbox-capture"); expect(flat).not.toContain("work-loose"); expect(flat).not.toContain("work-child"); }); - it("--show-later surfaces the loose someday/scheduled to-dos", () => { + it("the loose someday/scheduled to-dos ride the flat items[] (JSON carries them regardless of --show-later)", () => { seedWorld(); - const { stdout: out } = runCli([ - "area", - "show", - "loose", - "--show-later", - "--json", - "--db", - fx.path, - ]); - const env = JSON.parse(out); - const later = JSON.stringify([env.data.view.upcoming, env.data.view.someday]); - expect(later).toContain("loose-someday"); - expect(later).toContain("loose-later"); + const env = JSON.parse(runCli(["area", "show", "loose", "--json", "--db", fx.path]).stdout); + // items[] is one flat list of all direct to-dos; the later ones carry their + // stage (`someday`) / future `when` — the --show-later toggle is TTY-only. + const titles = env.data.view.items.map((t: { title: string }) => t.title); + expect(titles).toContain("loose-someday"); + expect(titles).toContain("loose-later"); + expect( + env.data.view.items.find((t: { title: string }) => t.title === "loose-someday").stage, + ).toBe("someday"); }); it("renders a human card titled Loose with no uri line", () => { diff --git a/test/cli/render.test.ts b/test/cli/render.test.ts index 74ff7990..37146da0 100644 --- a/test/cli/render.test.ts +++ b/test/cli/render.test.ts @@ -1341,6 +1341,7 @@ describe("viewHeaderLines (view title preamble)", () => { // A minimal empty area card — the footer is driven purely by `loggedCount`. const emptyArea = (title: string): AreaView => ({ area: { uuid: "area-1", title, visible: true, tags: [] }, + items: [], active: [], projects: [], scheduled: [], diff --git a/test/mcp/server.test.ts b/test/mcp/server.test.ts index ae7c1685..7551a9a2 100644 --- a/test/mcp/server.test.ts +++ b/test/mcp/server.test.ts @@ -769,8 +769,8 @@ describe("things MCP server", () => { name: "get_area", arguments: { ref: "Home", tag: ["focus"] }, }), - ) as { anytime: { title: string }[]; projects: { title: string }[] }; - expect(areaRes.anytime.map((i) => i.title)).toEqual(["loose-focus"]); + ) as { items: { title: string }[]; projects: { title: string }[] }; + expect(areaRes.items.map((i) => i.title)).toEqual(["loose-focus"]); expect(areaRes.projects.map((i) => i.title)).toEqual(["P"]); // list_collections projects tag → FLAT/inheritance-inclusive: BOTH the // directly-tagged P and the area-inheriting PBare (the projects list is not a @@ -1361,9 +1361,9 @@ describe("things MCP server", () => { } await connect([fakeVector(null).vector]); const capped = await client.callTool({ name: "get_area", arguments: { ref: "Busy" } }); - const view = textOf(capped) as { projects: unknown[]; anytime: unknown[] }; + const view = textOf(capped) as { projects: unknown[]; items: unknown[] }; expect(view.projects).toHaveLength(30); - expect(view.anytime).toHaveLength(30); + expect(view.items).toHaveLength(30); const meta = JSON.parse( (capped as { content: { text: string }[] }).content[1]?.text ?? "{}", ) as { @@ -1382,9 +1382,9 @@ describe("things MCP server", () => { const narrowed = textOf( await client.callTool({ name: "get_area", arguments: { ref: "Busy", project_limit: 2 } }), - ) as { projects: unknown[]; anytime: unknown[] }; + ) as { projects: unknown[]; items: unknown[] }; expect(narrowed.projects).toHaveLength(2); - expect(narrowed.anytime).toHaveLength(30); + expect(narrowed.items).toHaveLength(30); const conflict = await client.callTool({ name: "get_area", @@ -1452,8 +1452,8 @@ describe("things MCP server", () => { name: "get_area", arguments: { ref: "MCP Home", overdue: true }, }), - ) as { anytime: { title: string }[]; projects: { title: string }[] }; - expect(view.anytime.map((i) => i.title)).toEqual(["todo-overdue"]); + ) as { items: { title: string }[]; projects: { title: string }[] }; + expect(view.items.map((i) => i.title)).toEqual(["todo-overdue"]); expect(view.projects.map((i) => i.title)).toEqual(["proj-overdue"]); }); diff --git a/test/unit/shape.test.ts b/test/unit/shape.test.ts index f947f48b..490ea7ab 100644 --- a/test/unit/shape.test.ts +++ b/test/unit/shape.test.ts @@ -426,11 +426,22 @@ describe("shapeReadPayload — R6 no-redundant-ancestry by view kind", () => { expect("stage" in sItem).toBe(false); // stage-pure catalogue → dropped }); - it("area-view: children buckets drop area+stage; the projects list keeps stage; card node kept", () => { + it("area-view: direct to-dos dissolve into one flat items[] (drop area, keep stage/when); projects[] keeps stage (doctrine §3.13)", () => { const view = { area: { uuid: "area-1", title: "Work", visible: true, tags: [{ title: "focus" }] }, - active: [todo()], + // A direct to-do (area FK, no project/heading) + a future-scheduled one. + items: [ + todo({ uuid: "d-now", heading: null, headingProject: null, project: null }), + todo({ + uuid: "d-sched", + startDate: "2026-08-01", + heading: null, + headingProject: null, + project: null, + }), + ], projects: [project()], + active: [], scheduled: [], someday: [], repeating: [], @@ -438,22 +449,34 @@ describe("shapeReadPayload — R6 no-redundant-ancestry by view kind", () => { trashed: [], }; const out = shapeReadPayload("area-view", view, true) as Obj; - // active → anytime bucket (stage anytime); area + stage dropped. - const child = (out["anytime"] as Obj[])[0]!; - expect("area" in child).toBe(false); - expect("stage" in child).toBe(false); - expect(child["project"]).toBeDefined(); + // ONE flat items[] — stage KEPT (mixed), when kept, area dropped. + const items = out["items"] as Obj[]; + expect(items.map((i) => i["uuid"])).toEqual(["d-now", "d-sched"]); + const now = items[0]!; + expect("area" in now).toBe(false); + expect(now["stage"]).toBe("anytime"); + const sched = items[1]!; + expect(sched["stage"]).toBe("upcoming"); + expect(sched["when"]).toBe("2026-08-01"); // the projects list keeps stage (mixed listing), drops area. const projRow = (out["projects"] as Obj[])[0]!; expect("area" in projRow).toBe(false); expect(projRow["stage"]).toBe("anytime"); // The area node keeps its identity; its tags fold to names. expect(out["area"]).toEqual({ uuid: "area-1", title: "Work", visible: true, tags: ["focus"] }); - // The renamed live buckets exist; the old names are gone. An area carries NO - // `logbook` or `trash` bucket (the logbook is `things logbook --area`, trash - // is `things trash`). - for (const k of ["anytime", "upcoming", "someday"]) expect(k in out).toBe(true); - for (const k of ["active", "scheduled", "repeating", "logged", "trashed", "logbook", "trash"]) + // The dissolved stage buckets are gone; an area carries NO logbook/trash bucket. + for (const k of [ + "anytime", + "upcoming", + "someday", + "active", + "scheduled", + "repeating", + "logged", + "trashed", + "logbook", + "trash", + ]) expect(k in out).toBe(false); }); diff --git a/test/unit/truncation.test.ts b/test/unit/truncation.test.ts index 032ae337..25d169e4 100644 --- a/test/unit/truncation.test.ts +++ b/test/unit/truncation.test.ts @@ -273,6 +273,8 @@ describe("capAreaSections (area show per-section caps)", () => { const view = () => ({ area: { uuid: "a", title: "Busy" }, + // The flat wire list: all direct to-dos (the 7 active + 2 scheduled), index order. + items: [...todos(7, "t"), ...todos(2, "s")], projects: todos(5, "p"), active: todos(7, "t"), scheduled: [{ date: "2026-08-01", items: todos(2, "s") }], @@ -284,6 +286,16 @@ describe("capAreaSections (area show per-section caps)", () => { const { data, truncation } = capAreaSections(view(), { area: 4, project: 2 }); expect(data.projects).toHaveLength(2); expect(data.active).toHaveLength(4); + // The flat wire items[] drops exactly the 3 capped-out active rows (t4..t6); + // the 2 scheduled "later" rows always survive → 4 + 2 = 6. + expect(data.items.map((t) => (t as { uuid: string }).uuid)).toEqual([ + "t0", + "t1", + "t2", + "t3", + "s0", + "s1", + ]); // The later section is a container of its own — never capped here. expect(data.scheduled[0]?.items).toHaveLength(2); expect(truncation.truncated).toBe(true); @@ -297,6 +309,7 @@ describe("capAreaSections (area show per-section caps)", () => { const { data, truncation } = capAreaSections(view(), { area: null, project: null }); expect(data.projects).toHaveLength(5); expect(data.active).toHaveLength(7); + expect(data.items).toHaveLength(9); // all direct to-dos survive expect(truncation.truncated).toBe(false); expect( (truncation.blocks ?? []).every((b: GroupBlock) => b.shown === b.total && b.limit === null),