diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f94134..71e567a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- **CHANGE (breaking, ALPHA) — the `project show` / `get_project` view dissolves its live child buckets into ONE flat `items[]`.** Second of five PRs implementing the read-shape doctrine (`docs/design/read-shape-doctrine.md`, §3.12): a heading's membership is a per-row attribute, not a bucket. + - **`data.view` shape (breaking).** The unheaded `anytime` / `upcoming` / `someday` buckets AND the per-heading stage sub-buckets are GONE. In their place, `data.view.items` is ONE flat list of every LIVE child (unheaded and headed) in project `index` order — each row carrying `stage` (KEPT; the list is stage-mixed) and `when`, and each HEADED row carrying its `heading` ref (a flat title + a project-scoped `headingUuid` when the title would not round-trip, kept even in compact). Reconstruct a heading's members by filtering `items` on `heading`. An unheaded row omits `heading`. + - **`headings[]` is now the memberless live-heading catalog** — `[{heading:{uuid,title,archived?}}]` in index order (incl. empty headings; an archived-but-unswept heading still appears here carrying `archived`). Its per-heading member sub-buckets are gone (membership rides the row refs). PR 3 will unwrap it to `[{uuid,title,archived?}]`, merge swept archived headings in, and dissolve `logbookHeadings`. + - **Unchanged this PR:** the flat `logbook` rows, the `logbookHeadings` archived-heading groups, `openChildrenWhileResolved` / `openChildrenUnderArchivedHeading`, and the project card node. + - **TTY byte-stable.** `project show` output is byte-for-byte identical (verified by before/after capture on a rich fixture + the full render suite). The library retains its structured render grouping (it owns the clock + `todayIndex`) to project the GUI-faithful layout; only the wire flattened. (`src/read/project-view.ts` adds `items`, `src/read/shape.ts` `shapeProjectView`.) Tests: `test/unit/shape.test.ts` (flat items[] + memberless catalog + heading refs), `test/cli/e2e.test.ts` (CLI wire shape), `test/unit/stage.test.ts` (per-row stage property), `test/mcp/server.test.ts` (get_project). Docs: `docs/design/read-shape-doctrine.md`, `docs/contract.md`, `docs/design/contracts.md`. No new `apiVersion`; no new error code. + - **CHANGE (breaking, ALPHA) — the `today` view dissolves its Today/This-Evening `sections` into one flat `items[]`, and its count moves to `meta.counts`.** First of five PRs implementing the ratified read-shape doctrine (`docs/design/read-shape-doctrine.md`): JSON models semantic state, the TTY projects GUI placement. The Today-proper / This-Evening split is GUI placement recoverable per row from `when`, so it is no longer a wire bucket: - **`data` shape (breaking).** `today`'s `data` is now `{ items }` — one flat list of Today members in comparator order (Today-proper and This-Evening interleaved by their SQL position), each row carrying its `when` (`"today"` / `"evening"`) and `provisional`; `stage` is still dropped (every Today member is stage-pure `anytime`, R13). The former `data.sections` (`[{key:"today"},{key:"evening"}]`) is gone — read `data.items` and split on `when === "evening"`. The `when` field, previously section-dropped, is KEPT on every today row. - **Count → `meta.counts` (breaking).** The former `data.badge {dueOrOverdue, other}` moves to `meta.counts {dueOrOverdue, other}` (the app's sidebar count over OPEN members: due/overdue by deadline vs. the rest), so `data` stays pure domain rows. Over MCP the today count rides the result's metadata block alongside `truncation`. diff --git a/docs/contract.md b/docs/contract.md index 28ca640..9dbf943 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -101,7 +101,7 @@ The contract reserves a small vocabulary, and each word means exactly one thing | `match` | inside a `search` hit | Match provenance — WHERE the query matched when it was NOT the title. `{field, text}` with `field` ∈ `"heading"` \| `"notes"` \| `"checklist"` and `text` the matched heading title, a bounded notes snippet (~80 chars centered on the hit), or the matched checklist item's title. **PRESENCE-KEYED: a title match carries NO `match`** (absence = matched where you'd expect). At most one annotation; when several fields match, precedence is title (none) > heading > notes > checklist. A heading match credits the parent PROJECT, a checklist match the parent TO-DO (the GUI has no bare heading/checklist row; checklist-item uuids appear on no surface). Rides the compact tier (a non-default fact). | | compact / full | read TIER (not a wire field) | The data DENSITY a row is returned at. Compact (the list default) keeps identity + structural + non-default facts; full (`detail`/`show`, or a list forced with `--full` / `full: true`) is the whole record. Selected by view kind + the flag — never a caller-supplied field list. | -**Two reading rules the tiers add — absence is meaningful.** (1) *Inside a single-container view* — a project or area card, an `anytime`/`someday` section — an item omits any ancestry its enclosing node already states (a project-view child carries no `project`/`area`; an area-section item no `area`; a heading-group member no `heading`), so **absence of a container field there means inherited from the enclosing node**, not "no container". This is the opposite of a mixed list (`inbox`, `today`, `search`, `changes`), where every row still names its own `project`/`area` (the `heading` ref is compact-dropped everywhere — the GUI shows the project, never the heading, outside a project view; `--full` keeps it). ONE deliberate exception, the two-view sublabel asymmetry (HEADARC2-B): a project-view `logbook` row — a swept child of an OPEN heading — KEEPS its `heading` ref even in compact (with a project-scoped `headingUuid` when the title would not round-trip), because the in-project logged toggle labels the HEADING; the GLOBAL `logbook` view (`read_view logbook`) labels the PROJECT instead — same row, different sublabel by view. A swept child of an ARCHIVED heading is not here at all: it nests under its heading in `logbookHeadings` (the group header states the heading, so the child drops the ref). (2) *In the compact tier* a field at its default is omitted, so **absence = the default**: no `status` means `open`, no `checklist` means none, no `todos` means no child to-dos, no `repeating` means not a repeating template and no `instanceOf` means not an instance (a plain row carries neither — R11), no `when` means the item is neither in Today nor scheduled forward, and `startDate` (the raw substrate behind `when`) is dropped from compact entirely (full/detail only); the full `notes` string is dropped and a presence-keyed `hasNotes: true` marks a row that has notes, and `created`/`modified` are dropped (read them from `detail`, or pass `--full`). The `stage` word (which replaced `start`/`logged`/`trashed`) is itself dropped inside a view or card section that already states it (see the `stage` glossary row). +**Two reading rules the tiers add — absence is meaningful.** (1) *Inside a single-container view* — a project or area card, an `anytime`/`someday` section — an item omits any ancestry its enclosing node already states (a project-view child carries no `project`/`area`; an area-section item no `area`), so **absence of a container field there means inherited from the enclosing node**, not "no container". This is the opposite of a mixed list (`inbox`, `today`, `search`, `changes`), where every row still names its own `project`/`area`. The `heading` ref is compact-dropped in mixed lists (the GUI shows the project, never the heading, outside a project view; `--full` keeps it), but the **project view is the exception where a heading ref is self-describing state**: a project-view row (the flat `items[]`, and a `logbook` row) that lives under a heading KEEPS its `heading` ref even in compact — a flat title with a project-scoped `headingUuid` when the title would not round-trip — because a heading's membership is now a per-row attribute, not a bucket, and reconstructing a heading's members means filtering `items` on `heading`. An unheaded project-view row omits `heading`. (The two-view sublabel asymmetry, HEADARC2-B: a `logbook` row's heading labels the HEADING in-project, while the GLOBAL `logbook` view labels the PROJECT instead — same row, different sublabel by view.) A swept child of an ARCHIVED heading is not in the flat `logbook`: it nests under its heading in `logbookHeadings` (the group header states the heading, so the child drops the ref). (2) *In the compact tier* a field at its default is omitted, so **absence = the default**: no `status` means `open`, no `checklist` means none, no `todos` means no child to-dos, no `repeating` means not a repeating template and no `instanceOf` means not an instance (a plain row carries neither — R11), no `when` means the item is neither in Today nor scheduled forward, and `startDate` (the raw substrate behind `when`) is dropped from compact entirely (full/detail only); the full `notes` string is dropped and a presence-keyed `hasNotes: true` marks a row that has notes, and `created`/`modified` are dropped (read them from `detail`, or pass `--full`). The `stage` word (which replaced `start`/`logged`/`trashed`) is itself dropped inside a view or card section that already states it (see the `stage` glossary row). ## The error-code registry diff --git a/docs/design/contracts.md b/docs/design/contracts.md index 58e1669..96222da 100644 --- a/docs/design/contracts.md +++ b/docs/design/contracts.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 (in any bucket, incl. heading-group members) drops `project` and `area`; a heading-group member additionally drops `heading` (the card and the group state them). 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** 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"). **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`/`area` card sections (`anytime`, `headings`, `upcoming`, `someday`, `logbook`, `trash`, `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`, `logbookHeadings`), 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.) Omitted when empty, per entity: diff --git a/docs/design/read-shape-doctrine.md b/docs/design/read-shape-doctrine.md index 6a94f77..751b368 100644 --- a/docs/design/read-shape-doctrine.md +++ b/docs/design/read-shape-doctrine.md @@ -134,7 +134,9 @@ Order: sidebar (area rank → active-first → drag order); `--later` appends sc Order: `TMArea.index` (sidebar rank). This listing is the natural home for the area-rank axis §3.3/§3.4/§3.10 depend on. -### 3.12 `project-view` (`data: { view }`) — **planned (PRs 2–3)** +### 3.12 `project-view` (`data: { view }`) — **PR 2 landed (children dissolve); PR 3 planned (headings catalog + logbook flatten)** + +PR 2 has dissolved the unheaded + per-heading stage sub-buckets into one flat `items[]` (index order, each headed row carrying its `heading` ref); `headings[]` is now the memberless catalog of live-heading nodes (`[{heading:{uuid,title,archived?}}]`), and `logbook` / `logbookHeadings` are unchanged pending PR 3 (which unwraps `headings[]` to `[{uuid,title,archived?}]`, merges swept archived headings in, and dissolves `logbookHeadings` into the flat `logbook`). Current buckets: `project` (card node) · `anytime[]` · `upcoming[{date,items}]` · `someday[]` (all UNHEADED) · `headings[{heading:{uuid,title,archived?}, anytime[], upcoming[{date,items}], someday[]}]` (live heading groups with per-heading stage sub-buckets) · `logbook[]` (flat swept rows) · `logbookHeadings[{heading, items[]}]` (archived-heading groups) · `openChildrenWhileResolved` · `openChildrenUnderArchivedHeading`. @@ -244,7 +246,7 @@ Two orders are purely presentation-derived (trash recency, search rank) and are Ordered so each step is independently green and self-merged before the next (ALPHA-CONTRACT: break freely, no shims). Each PR carries its own contract + schema regen + CHANGELOG, per-view snapshot/fixture updates, JSON-shape regression tests, and (PRs 2–4) TTY byte-stability tests. 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. TTY reconstructs the current GUI-faithful rendering from row attributes (byte-stable). +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?}]`; `logbookHeadings` dissolves; single flat `logbook` bucket (stopDate DESC) absorbing archived-heading children. All HEADARC2 TTY invariants preserved (byte-stable). 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. 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. diff --git a/src/read/project-view.ts b/src/read/project-view.ts index e405f15..7fc3590 100644 --- a/src/read/project-view.ts +++ b/src/read/project-view.ts @@ -57,6 +57,18 @@ export interface LoggedHeadingGroup { export interface ProjectView { project: Project; + /** + * ALL live (non-logbook, non-trash) children of the project — unheaded AND + * headed — in project `index` order, each headed row carrying its `heading` + * ref (and `headingProject`). This is the flat wire representation (the read + * doctrine's project-view `items[]`): membership in a heading is a per-row + * attribute, not a bucket. The structured buckets below (`active`, `headings`, + * `scheduled`, …) are the SAME children re-grouped into the GUI's render layout + * for the TTY projection — the library owns the clock + `todayIndex`, so the + * renderer reconstructs the exact GUI placement from them without re-deriving + * dates. The two are always consistent (built from one child fetch). + */ + items: Todo[]; /** Open, unscheduled/current UNHEADED children, by index. */ active: Todo[]; /** OPEN headings in project order, each with its own sub-buckets. An archived heading never renders here. */ @@ -334,8 +346,18 @@ export function projectView( })) .filter((g) => !contentScoped || g.items.length > 0); + // The flat wire `items[]`: every LIVE child in project index order (the + // `todos` fetch is already `ORDER BY index ASC`). Excludes swept logged rows + // (→ `logged`) and children of swept archived headings (→ `loggedHeadings`) — + // exactly the rows that leave the live region. Each headed row already carries + // its `heading` ref + `headingProject` (stamped in the loop above). + const items = todos + .map((t) => t.todo) + .filter((td) => !td.logged && !(td.heading !== null && sweptHeadingSet.has(td.heading.uuid))); + return { project, + items, active, headings: headingGroups, scheduled, diff --git a/src/read/shape.ts b/src/read/shape.ts index 92a7d1a..3c6838c 100644 --- a/src/read/shape.ts +++ b/src/read/shape.ts @@ -585,10 +585,15 @@ function flattenGroups(groups: unknown): unknown[] { /** Coerce an unknown value to an array (empty when absent). */ const asArray = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); -/** The R6 ref drop for every child bucket of a project view (unheaded members). */ -const PROJECT_CHILD_DROP: ItemDrop = { project: true, area: true, stage: true }; -/** Heading-group members drop the heading ref too (the group states it). */ -const HEADING_MEMBER_DROP: ItemDrop = { project: true, area: true, heading: true, stage: true }; +/** + * The flat project-view `items[]` rows (read-shape doctrine §3.12): drop + * project/area (the card states them) but KEEP `stage` (the list is stage-MIXED: + * anytime + upcoming + someday + closed-unswept), `when`, and the `heading` ref. + * The heading ref is kept + flattened even in compact (keepHeading), scoped to + * the owning project — flat title + `headingUuid` when the title would not + * round-trip — so a consumer reconstructs a heading's members from the rows. + */ +const PROJECT_ITEM_DROP: ItemDrop = { project: true, area: true, keepHeading: true }; /** * Project-view LOGBOOK (flat logged) rows: drop project/area (the card states * them) + the bucket-implied stage (all rows are logged), but KEEP the heading @@ -670,27 +675,18 @@ function shapeHeadingNode(src: unknown): unknown { return h; } -/** Shape every collection bucket of a project view; the card node is left full + ancestry-intact. */ +/** + * Shape a project view (read-shape doctrine §3.12). The live children dissolve + * into ONE flat `items[]` in project index order — each row carrying `stage`, + * `when`, and its `heading` ref (flat title + project-scoped `headingUuid` + * promotion) — so a consumer reconstructs a heading's members by filtering + * `items` on `heading`. The stage/date sub-buckets (unheaded anytime/upcoming/ + * someday and the per-heading nests) are gone; `headings[]` is the live-heading + * catalog (its per-heading membership now rides the row refs). The logged region + * (`logbook` flat rows + `logbookHeadings` archived groups) is unchanged. The + * card node is left full + ancestry-intact. + */ function shapeProjectView(view: Obj, compact: boolean, promoter: RefPromoter): Obj { - const cd = PROJECT_CHILD_DROP; - const hd = HEADING_MEMBER_DROP; - const shapeHeadingGroup = (g: unknown): unknown => { - if (g === null || typeof g !== "object") return g; - const grp = g as Obj; - const out: Obj = {}; - out["heading"] = shapeHeadingNode(grp["heading"]); - const members = [ - ...asArray(grp["items"]), - ...flattenGroups(grp["scheduled"]), - ...asArray(grp["someday"]), - ...asArray(grp["repeating"]), - ]; - const { anytime, upcoming, someday } = rebucketChildren(members, hd, compact, promoter); - out["anytime"] = anytime; - out["upcoming"] = upcoming; - out["someday"] = someday; - return out; - }; // An archived-heading GROUP in the logged region: the archived heading node // (carrying `archived`) + its children nested flat (`items`), each dropping // project/area/heading but KEEPING stage (the group is stage-mixed). @@ -702,20 +698,20 @@ function shapeProjectView(view: Obj, compact: boolean, promoter: RefPromoter): O items: shapeList(grp["items"], LOGGED_HEADING_MEMBER_DROP, compact, promoter), }; }; + // The live-heading catalog: each entry is its heading node (index order, incl. + // empty headings). Membership rides the flat `items[]` rows' `heading` refs. const headings = Array.isArray(view["headings"]) - ? (view["headings"] as unknown[]).map(shapeHeadingGroup) + ? (view["headings"] as unknown[]).map((g) => + g !== null && typeof g === "object" + ? { heading: shapeHeadingNode((g as Obj)["heading"]) } + : g, + ) : view["headings"]; - const looseMembers = [ - ...asArray(view["active"]), - ...flattenGroups(view["scheduled"]), - ...asArray(view["someday"]), - ...asArray(view["repeating"]), - ]; - const { anytime, upcoming, someday } = rebucketChildren(looseMembers, cd, compact, promoter); const out: Obj = { ...view }; delete out["active"]; delete out["scheduled"]; delete out["repeating"]; + delete out["someday"]; delete out["logged"]; delete out["loggedHeadings"]; // Trashed children live only in `things trash` — never a project-view bucket. @@ -724,9 +720,10 @@ function shapeProjectView(view: Obj, compact: boolean, promoter: RefPromoter): O // The project card NODE keeps everything (children derive their container from // it), but is still an item DTO, so the universal + R10 reshapes apply. out["project"] = shapeItem(view["project"], NO_DROP, false, promoter); - out["anytime"] = anytime; - out["upcoming"] = upcoming; - out["someday"] = someday; + // The flat live children — stage/when/heading kept, project/area dropped (the + // card states them). The heading ref is kept + flattened even in compact + // (keepHeading), project-scoped like the logbook rows. + out["items"] = shapeList(view["items"], PROJECT_ITEM_DROP, compact, promoter); out["headings"] = headings; // A project keeps its in-context `logbook` (a project is a bounded object with // a real done-state); trashed children live only in `things trash`. The flat diff --git a/test/cli/e2e.test.ts b/test/cli/e2e.test.ts index fdaf9ec..db60c1d 100644 --- a/test/cli/e2e.test.ts +++ b/test/cli/e2e.test.ts @@ -625,16 +625,50 @@ describe("cli tag filters in container views (§9a wiring — direct-on-row)", ( // focus directly (the project's own focus is inherited by all, suppressed). const direct = runCli(["project", "show", "P", "--tag", "focus", "--json", "--db", fx.path]); expect( - JSON.parse(direct.stdout).data.view.anytime.map((i: { title: string }) => i.title), + JSON.parse(direct.stdout).data.view.items.map((i: { title: string }) => i.title), ).toEqual(["child-focus"]); // --untagged (direct-only) keeps the child with no direct tag, even though // it inherits focus from the project. const untagged = runCli(["project", "show", "P", "--untagged", "--json", "--db", fx.path]); expect( - JSON.parse(untagged.stdout).data.view.anytime.map((i: { title: string }) => i.title), + JSON.parse(untagged.stdout).data.view.items.map((i: { title: string }) => i.title), ).toEqual(["child-bare"]); }); + it("project show --json: live children are ONE flat items[] (index order) with heading refs; no stage buckets", () => { + fx = buildFixtureDb(); + const proj = seedProject(fx.db, { title: "Ship it", uuid: "SHIP" }); + seedTodo(fx.db, { project: "SHIP", title: "loose-now", index: 0 }); + seedTodo(fx.db, { project: "SHIP", title: "loose-later", startDate: "2030-01-01", index: 1 }); + seedHeading(fx.db, { project: "SHIP", title: "Phase 1", uuid: "PH1", index: 2 }); + seedTodo(fx.db, { heading: "PH1", project: null, title: "headed-now", index: 3 }); + const env = JSON.parse(runCli(["project", "show", proj, "--json", "--db", fx.path]).stdout); + const view = env.data.view; + // ONE flat items[] in index order — headed + unheaded interleaved. + expect(view.items.map((i: { title: string }) => i.title)).toEqual([ + "loose-now", + "loose-later", + "headed-now", + ]); + // The dissolved stage/date buckets are gone from the wire. + for (const k of ["anytime", "upcoming", "someday"]) expect(k in view).toBe(false); + // stage + when kept per row; a future row reads its date. + const later = view.items.find((i: { title: string }) => i.title === "loose-later"); + expect(later.stage).toBe("upcoming"); + expect(later.when).toBe("2030-01-01"); + // A headed row carries its heading ref (title); unheaded rows omit it. + const headed = view.items.find((i: { title: string }) => i.title === "headed-now"); + expect(headed.heading).toBe("Phase 1"); + expect("heading" in view.items.find((i: { title: string }) => i.title === "loose-now")).toBe( + false, + ); + // headings[] is the memberless catalog (its members ride the row refs). + expect(view.headings.map((g: { heading: { title: string } }) => g.heading.title)).toEqual([ + "Phase 1", + ]); + expect("items" in view.headings[0]).toBe(false); + }); + it("area show --tag filters both row kinds by direct tag; no recursion into projects", () => { fx = buildFixtureDb(); const focus = seedTag(fx.db, "focus"); @@ -1791,7 +1825,7 @@ describe("cli detail views — area show per-section caps; project show uncapped const json = JSON.parse( runCli(["project", "show", "Big Proj", "--json", "--db", fx.path]).stdout, ); - expect(json.data.view.anytime).toHaveLength(60); + expect(json.data.view.items).toHaveLength(60); expect(json.meta.truncation).toBeUndefined(); // No --limit exists on project show at all — commander rejects it as an // unknown option (error + non-zero exit in the real CLI). @@ -2416,8 +2450,13 @@ describe("overdue in container views (cli)", () => { runCli(["project", "show", "Launch", "--overdue", "--json", "--db", fx.path]).stdout, ); expect(env.data.view.project.title).toBe("Launch"); - expect(env.data.view.anytime.map((i: { title: string }) => i.title)).toEqual(["loose-overdue"]); - // Phase 2 collapsed (no surviving child); Phase 1 kept. + // The flat items[] holds the surviving overdue children (loose + headed); the + // due-today row is filtered out. + expect(env.data.view.items.map((i: { title: string }) => i.title).toSorted()).toEqual([ + "loose-overdue", + "p1-overdue", + ]); + // Phase 2 collapsed (no surviving child); Phase 1 kept in the catalog. expect(env.data.view.headings).toHaveLength(1); expect(env.data.view.headings[0].heading.title).toBe("Phase 1"); // The TTY render omits the collapsed heading entirely. diff --git a/test/mcp/server.test.ts b/test/mcp/server.test.ts index 68dceba..471e9cb 100644 --- a/test/mcp/server.test.ts +++ b/test/mcp/server.test.ts @@ -354,8 +354,8 @@ describe("things MCP server", () => { const compact = textOf( await client.callTool({ name: "get_project", arguments: { uuid: proj } }), - ) as { anytime: Array> }; - const child = compact.anytime[0]!; + ) as { items: Array> }; + const child = compact.items[0]!; // R6: a project-view child drops project + area (the card states them). expect("project" in child).toBe(false); expect("area" in child).toBe(false); @@ -363,8 +363,8 @@ describe("things MCP server", () => { const full = textOf( await client.callTool({ name: "get_project", arguments: { uuid: proj, full: true } }), - ) as { anytime: Array> }; - const fchild = full.anytime[0]!; + ) as { items: Array> }; + const fchild = full.items[0]!; expect("created" in fchild).toBe(true); // full restores density expect("project" in fchild).toBe(false); // R6 still applies under --full }); @@ -759,8 +759,8 @@ describe("things MCP server", () => { name: "get_project", arguments: { uuid: "P", tag: ["focus"] }, }), - ) as { anytime: { title: string }[] }; - expect(proj.anytime.map((i) => i.title)).toEqual(["child-focus"]); + ) as { items: { title: string }[] }; + expect(proj.items.map((i) => i.title)).toEqual(["child-focus"]); // get_area tag → single-container semantics: loose to-dos + child projects // carrying focus DIRECTLY (Home's inherited focus is suppressed, so PBare — // which only inherits — is excluded). @@ -1419,11 +1419,15 @@ describe("things MCP server", () => { }), ) as { project: { title: string }; - anytime: { title: string }[]; - headings: { heading: { title: string }; items: { title: string }[] }[]; + items: { title: string; heading?: string | null }[]; + headings: { heading: { title: string } }[]; }; expect(view.project.title).toBe("MCP Launch"); - expect(view.anytime.map((i) => i.title)).toEqual(["loose-overdue"]); + // The flat items[] carries the surviving overdue children — loose AND headed; + // the due-today row (not overdue) is filtered out. loose-due is gone. + expect(view.items.map((i) => i.title).toSorted()).toEqual(["loose-overdue", "p1-overdue"]); + // The headed overdue row carries its heading ref; the empty Phase 2 collapses. + expect(view.items.find((i) => i.title === "p1-overdue")?.heading).toBe("Phase 1"); expect(view.headings).toHaveLength(1); expect(view.headings[0]?.heading.title).toBe("Phase 1"); }); diff --git a/test/unit/shape.test.ts b/test/unit/shape.test.ts index 9054373..d48d614 100644 --- a/test/unit/shape.test.ts +++ b/test/unit/shape.test.ts @@ -457,17 +457,26 @@ describe("shapeReadPayload — R6 no-redundant-ancestry by view kind", () => { expect(k in out).toBe(false); }); - it("project-view: children re-bucket by stage; heading groups become {anytime,upcoming,someday}", () => { + it("project-view: live children dissolve into one flat items[]; headings[] is the memberless catalog (doctrine §3.12)", () => { + // The library return: one flat `items[]` (index order, heading ref stamped on + // headed rows) + heading catalog nodes + the logged region. The stage/date + // sub-buckets are GONE from both the library and the wire. const view = { project: project(), - active: [todo({ uuid: "loose-anytime" })], // stage anytime - scheduled: [ - { date: "2026-08-01", items: [todo({ uuid: "loose-up", startDate: "2026-08-01" })] }, - ], - someday: [todo({ uuid: "loose-some", start: "someday", startDate: null })], - repeating: [ + items: [ + todo({ uuid: "loose-anytime", heading: null, headingProject: null }), // stage anytime, unheaded + todo({ uuid: "loose-up", startDate: "2026-08-01", heading: null, headingProject: null }), // upcoming + todo({ + uuid: "loose-some", + start: "someday", + startDate: null, + heading: null, + headingProject: null, + }), // someday todo({ uuid: "loose-tmpl", + heading: null, + headingProject: null, repeating: { isTemplate: true, isInstance: false, @@ -475,6 +484,17 @@ describe("shapeReadPayload — R6 no-redundant-ancestry by view kind", () => { nextOccurrence: null, }, }), + todo({ + uuid: "h-anytime", + heading: { uuid: "head-1", title: "Phase 1" }, + headingProject: { uuid: "proj-1", title: "Q3" }, + }), + todo({ + uuid: "h-up", + startDate: "2026-08-05", + heading: { uuid: "head-1", title: "Phase 1" }, + headingProject: { uuid: "proj-1", title: "Q3" }, + }), ], headings: [ { @@ -485,62 +505,53 @@ describe("shapeReadPayload — R6 no-redundant-ancestry by view kind", () => { status: "open", project: { uuid: "proj-1", title: "Q3" }, }, - items: [todo({ uuid: "h-anytime" })], - scheduled: [ - { date: "2026-08-05", items: [todo({ uuid: "h-up", startDate: "2026-08-05" })] }, - ], - someday: [], - repeating: [], }, ], - logged: [todo({ uuid: "gone-log", status: "completed", logged: true })], - trashed: [todo({ uuid: "gone-trash", trashed: true })], + logged: [ + todo({ + uuid: "gone-log", + status: "completed", + logged: true, + heading: { uuid: "head-1", title: "Phase 1" }, + headingProject: { uuid: "proj-1", title: "Q3" }, + }), + ], + loggedHeadings: [], openChildrenWhileResolved: 0, }; const out = shapeReadPayload("project-view", view, true) as Obj; - // Loose children re-bucketed by stage. - expect((out["anytime"] as Obj[]).map((i) => i["uuid"])).toEqual(["loose-anytime"]); - expect((out["someday"] as Obj[]).map((i) => i["uuid"])).toEqual(["loose-some"]); - // Upcoming: the dated child under its date, then a trailing null group for the date-less template. - const upcoming = out["upcoming"] as Array<{ date: string | null; items: Obj[] }>; - expect(upcoming.map((g) => g.date)).toEqual(["2026-08-01", null]); - expect(upcoming[0]!.items.map((i) => i["uuid"])).toEqual(["loose-up"]); - expect(upcoming[1]!.items.map((i) => i["uuid"])).toEqual(["loose-tmpl"]); - // R12: inside a date-group `when` drops (the group states the date); the - // full tier still keeps the raw `startDate` substrate. - expect("when" in upcoming[0]!.items[0]!).toBe(false); - expect(upcoming[0]!.items[0]!["startDate"]).toBe("2026-08-01"); - expect("when" in upcoming[1]!.items[0]!).toBe(false); // resting template — no projection anyway - // A re-bucketed child drops project/area/stage. - const anyChild = (out["anytime"] as Obj[])[0]!; - expect("project" in anyChild).toBe(false); - expect("area" in anyChild).toBe(false); - expect("stage" in anyChild).toBe(false); - // logbook (renamed) carries the logged rows; the project view has NO `trash` - // bucket — trashed children live only in `things trash` (the seeded - // `gone-trash` row is dropped entirely). - expect((out["logbook"] as Obj[]).map((i) => i["uuid"])).toEqual(["gone-log"]); - // A flat logbook row KEEPS its heading ref (the in-project toggle labels the - // heading — Part 2.3 / HEADARC2-B), flattened with the project-scoped uuid. - const logRow = (out["logbook"] as Obj[])[0]!; - expect(logRow["heading"]).toBe("Phase 1"); - expect(logRow["headingUuid"]).toBe("head-1"); - expect("project" in logRow).toBe(false); // R6 drops the redundant container - expect("stage" in logRow).toBe(false); // stage-pure logbook rows - // No archived headings here → an empty logbookHeadings sibling. - expect(out["logbookHeadings"]).toEqual([]); - expect("trash" in out).toBe(false); - for (const k of ["active", "scheduled", "repeating", "logged", "loggedHeadings", "trashed"]) + // ONE flat items[] in the given (index) order — headed + unheaded interleaved. + const items = out["items"] as Obj[]; + expect(items.map((i) => i["uuid"])).toEqual([ + "loose-anytime", + "loose-up", + "loose-some", + "loose-tmpl", + "h-anytime", + "h-up", + ]); + // The old stage/date buckets are gone from the wire. + for (const k of ["anytime", "upcoming", "someday", "active", "scheduled", "repeating"]) expect(k in out).toBe(false); - // Heading group reshaped to {heading, anytime, upcoming, someday}. + // Each row keeps stage + when, drops project/area. + const looseUp = items.find((i) => i["uuid"] === "loose-up")!; + expect(looseUp["stage"]).toBe("upcoming"); + expect(looseUp["when"]).toBe("2026-08-01"); + expect("project" in looseUp).toBe(false); + expect("area" in looseUp).toBe(false); + const looseSome = items.find((i) => i["uuid"] === "loose-some")!; + expect(looseSome["stage"]).toBe("someday"); + // A headed row CARRIES its heading ref (flat title + project-scoped uuid) — the + // membership that the dissolved buckets used to express structurally. + const headed = items.find((i) => i["uuid"] === "h-anytime")!; + expect(headed["heading"]).toBe("Phase 1"); + expect(headed["headingUuid"]).toBe("head-1"); + expect(headed["stage"]).toBe("anytime"); + // An unheaded row carries no heading ref. + expect(items.find((i) => i["uuid"] === "loose-anytime")!["heading"]).toBeNull(); + // headings[] is the memberless catalog: each entry its heading node only. const grp = (out["headings"] as Obj[])[0]!; - expect(Object.keys(grp).toSorted()).toEqual(["anytime", "heading", "someday", "upcoming"]); - expect((grp["anytime"] as Obj[]).map((i) => i["uuid"])).toEqual(["h-anytime"]); - const hup = grp["upcoming"] as Array<{ date: string | null; items: Obj[] }>; - expect(hup[0]!.date).toBe("2026-08-05"); - // Heading-group members drop heading; the heading NODE drops project + type + - // status, and an OPEN heading carries no `archived`. - expect("heading" in (grp["anytime"] as Obj[])[0]!).toBe(false); + expect(Object.keys(grp)).toEqual(["heading"]); const headNode = grp["heading"] as Obj; expect("project" in headNode).toBe(false); expect("type" in headNode).toBe(false); // positional: always a heading @@ -548,6 +559,14 @@ describe("shapeReadPayload — R6 no-redundant-ancestry by view kind", () => { expect("archived" in headNode).toBe(false); // open heading expect(headNode["uuid"]).toBe("head-1"); expect(headNode["title"]).toBe("Phase 1"); + // logbook (flat swept rows) unchanged — KEEPS its heading ref, drops project/stage. + const logRow = (out["logbook"] as Obj[])[0]!; + expect(logRow["heading"]).toBe("Phase 1"); + expect(logRow["headingUuid"]).toBe("head-1"); + expect("project" in logRow).toBe(false); + expect("stage" in logRow).toBe(false); + expect(out["logbookHeadings"]).toEqual([]); + for (const k of ["logged", "loggedHeadings", "trashed", "trash"]) expect(k in out).toBe(false); // The project card node keeps its own area + stage. expect((out["project"] as Obj)["area"]).toBeDefined(); expect((out["project"] as Obj)["stage"]).toBe("anytime"); @@ -694,10 +713,15 @@ describe("shapeReadPayload — projectIsTemplate container marker (the JSON twin nextOccurrence: null, }, }), - active: [todo({ uuid: "loose", project: tmplProject, heading: null, headingProject: null })], - scheduled: [], - someday: [], - repeating: [], + items: [ + todo({ uuid: "loose", project: tmplProject, heading: null, headingProject: null }), + todo({ + uuid: "h-loose", + project: null, + heading: { uuid: "head-1", title: "Section" }, + headingProject: tmplProject, + }), + ], headings: [ { heading: { @@ -707,29 +731,17 @@ describe("shapeReadPayload — projectIsTemplate container marker (the JSON twin status: "open", project: { uuid: "proj-1", title: "Weekly Review" }, }, - items: [ - todo({ - uuid: "h-loose", - project: null, - heading: { uuid: "head-1", title: "Section" }, - headingProject: tmplProject, - }), - ], - scheduled: [], - someday: [], - repeating: [], }, ], logged: [], - trashed: [], + loggedHeadings: [], openChildrenWhileResolved: 0, }; const out = shapeReadPayload("project-view", view, false) as Obj; - const loose = (out["anytime"] as Obj[])[0]!; + const loose = (out["items"] as Obj[]).find((i) => i["uuid"] === "loose")!; expect("project" in loose).toBe(false); // R6 drops the container in a project view expect("projectIsTemplate" in loose).toBe(false); // marker drops WITH the project ref - const grp = (out["headings"] as Obj[])[0]!; - const hChild = (grp["anytime"] as Obj[])[0]!; + const hChild = (out["items"] as Obj[]).find((i) => i["uuid"] === "h-loose")!; expect("project" in hChild).toBe(false); expect("projectIsTemplate" in hChild).toBe(false); // The project card node exposes its OWN template nature via `repeating` (R11) — diff --git a/test/unit/stage.test.ts b/test/unit/stage.test.ts index d119fd9..99bfd05 100644 --- a/test/unit/stage.test.ts +++ b/test/unit/stage.test.ts @@ -469,7 +469,7 @@ describe("property — the emitted stage equals deriveStage, present exactly whe expect(upWire.find((r) => r["uuid"] === fcSome)?.["stage"]).toBe("someday"); }); - it("project card sub-buckets: the bucket an item lands in equals its derived stage", () => { + it("project flat items[]: each row's kept `stage` equals its derived stage", () => { fx = buildFixtureDb(); const proj = seedProject(fx.db, { title: "P" }); const children = [ @@ -493,53 +493,38 @@ describe("property — the emitted stage equals deriveStage, present exactly whe seedTodo(fx.db, { title: "h-upcoming", heading: head, startDate: "2026-08-05" }); const view = projectView(fx.db, proj, NOW); - // Map every child uuid to its derived stage from the UNSHAPED entities. + // Map every LIVE child uuid to its derived stage from the UNSHAPED entities. const stageOf = new Map(); const record = (i: ListItem) => stageOf.set(i.uuid, deriveStage(i)); - view.active.forEach(record); - view.scheduled.forEach((g) => g.items.forEach(record)); - view.someday.forEach(record); - view.repeating.forEach(record); + view.items.forEach(record); view.logged.forEach(record); - for (const g of view.headings) { - g.items.forEach(record); - g.scheduled.forEach((d) => d.items.forEach(record)); - g.someday.forEach(record); - g.repeating.forEach(record); - } const shaped = shapeReadPayload("project-view", view, true) as Record; - type Row = { uuid: string }; - type Grp = { date: string | null; items: Row[] }; - const checkBucket = (items: Row[], stage: string) => { - for (const i of items) expect(stageOf.get(i.uuid)).toBe(stage); - }; - checkBucket(shaped["anytime"] as Row[], "anytime"); - checkBucket(shaped["someday"] as Row[], "someday"); - checkBucket(shaped["logbook"] as Row[], "logbook"); - for (const g of shaped["upcoming"] as Grp[]) checkBucket(g.items, "upcoming"); - // The heading group is bucketed the same way. - const grp = (shaped["headings"] as Array>)[0]!; - checkBucket(grp["anytime"] as Row[], "anytime"); - checkBucket(grp["someday"] as Row[], "someday"); - for (const g of grp["upcoming"] as Grp[]) checkBucket(g.items, "upcoming"); - - // And the shape actually placed each stage where expected. - expect((shaped["anytime"] as Row[]).length).toBeGreaterThan(0); - expect((shaped["upcoming"] as Grp[]).some((g) => g.date === "2026-08-01")).toBe(true); - expect((shaped["upcoming"] as Grp[]).some((g) => g.date === null)).toBe(true); // the template + type Row = { uuid: string; stage?: string }; + // The flat items[] KEEPS `stage` on every row (stage-mixed list); it must + // equal the derived stage from the entity — the single-source guarantee that + // survives the dissolve of the stage sub-buckets. + const items = shaped["items"] as Row[]; + for (const r of items) expect(r.stage).toBe(stageOf.get(r.uuid)); + expect(items.length).toBeGreaterThan(0); + // Every live stage is represented on the flat rows. + const stages = new Set(items.map((r) => r.stage)); + expect(stages.has("anytime")).toBe(true); + expect(stages.has("upcoming")).toBe(true); + expect(stages.has("someday")).toBe(true); + // The logbook rows are the swept ones (stage logbook, dropped in the bucket). expect((shaped["logbook"] as Row[]).length).toBe(1); // Trashed children are excluded from the project view entirely — no `trash` // bucket, and `c-trash` appears in no bucket (GUI-faithful, §6½/PLOG1-a). expect("trash" in shaped).toBe(false); const cTrash = children[6]!; - expect(stageOf.has(cTrash)).toBe(false); - // R10.2: the arrived (today) child `c-uptoday` sits in `anytime`, and NO - // upcoming group is keyed on its arrived date — Upcoming holds only future - // dates + the date-less template group. + expect(items.some((r) => r.uuid === cTrash)).toBe(false); + // R10.2: the arrived (today) child `c-uptoday` reads stage `anytime` (Upcoming + // is strictly future) with `when: "today"`, NOT stage `upcoming`. const cUpToday = children[2]!; - expect((shaped["anytime"] as Row[]).some((r) => r.uuid === cUpToday)).toBe(true); - expect((shaped["upcoming"] as Grp[]).some((g) => g.date === "2026-07-02")).toBe(false); + const upTodayRow = items.find((r) => r.uuid === cUpToday)!; + expect(upTodayRow.stage).toBe("anytime"); + expect((upTodayRow as { when?: string }).when).toBe("today"); }); });