Skip to content

feat(output)!: render type as a string in JSON - #214

Merged
ryanlewis merged 1 commit into
mainfrom
feat/issue-208-type-string
Sep 10, 2026
Merged

ryanlewis merged 1 commit into
mainfrom
feat/issue-208-type-string

Conversation

@ryanlewis

Copy link
Copy Markdown
Owner

Closes #208

What changed

type in JSON output is now the string todo, project or heading instead of Things' raw TMTask integer. status already rendered as a string, so the two fields were in two different styles on the same object; they now share one.

The codec is model.TaskType, a named int that mirrors model.Status: a typeNames map as the single source of truth for String, MarshalJSON and UnmarshalJSON; an unrecognized raw code preserved as its integer rather than collapsing to a lossy "unknown"; UnmarshalJSON accepting the name or the legacy integer, and treating a JSON null as a no-op. Task.Type changes from int to model.TaskType, so comparisons against model.TypeTask / model.TypeProject / model.TypeHeading compile unchanged.

Every JSON surface picks this up without its own change, because they all marshal model.Task through the single encoder in internal/output.

Plain text is unchanged, including the (project) marker on a project row and the Type: project line in a project's detail block. model.Project still has no type field.

One non-obvious knock-on: notHeading in internal/db/tasks.go builds a SQL fragment with fmt.Sprintf("... != %d", model.TypeHeading). That constant is now a fmt.Stringer, so %v or %s would splice the word heading into the SQL where 2 belongs. The constant is wrapped in int() with a comment, so the fragment no longer depends on the format verb.

Why

A bare integer is an internal Things code. It reads badly for a human and forces an agent or a jq filter to carry a lookup table that appears nowhere in the output.

How tested

make test and make lint both clean, on top of #206.

  • TestTaskTypeMarshalJSON, TestTaskTypeUnmarshalJSON, TestTaskTypeRoundTripJSON and TestTaskTypeString lock the three wire names, the integer fallback, the null no-op and the rejection of unknown names.
  • TestRunJSONRendersTypeAsString walks the JSON surfaces end to end and asserts the raw JSON text, not an unmarshalled model.Task. That distinction matters: UnmarshalJSON still accepts the legacy integer, so a decode-and-compare test would keep passing even if the encoder regressed to emitting ints. It also fails on any type field holding a bare number, matched by regex so it is independent of the encoder's indentation and catches an unmapped code rather than only 0, 1 and 2.
  • Both new tests were mutation-checked: reverting MarshalJSON to emit the raw int fails them.
  • Verified against the real Things database as well as the seeded one, uuids not titles. list today returns Vf6GPsvdnJYqTdD5f41H3A as todo and X28bim63xxvGvWLncbfd7f as project; show returns the same two individually; search returns BEFn6McBh2DFfU577KBaq7 as project; logbook returns L16wNJD3D84AEJwr14QWni as project across 1190 rows. someday currently holds no project, so only todo appears there. things projects carries no type field at all, across 23 rows.

Breaking change

Old New
to-do "type": 0 "type": "todo"
project "type": 1 "type": "project"
heading "type": 2 "type": "heading"

Any caller matching on .type==1 has to become .type=="project".

Docs updated in internal/skill/SKILL.md, docs/content/commands.md, docs/content/agents.md and README.md, including the worked jq example in agents.md that filtered on select(.type==0). Each page describing JSON output carries a note naming the old integers, anchored to v0.7.0 as the last release that emitted them.

Two things the docs now say that they did not before, both found in review:

  • type rides on task rows only, and headings are never returned by any command, so "heading" never actually appears in output. It exists in the codec because the codec has to be total over the three codes the database uses.
  • The value is not the vocabulary a things import payload takes. That format is Things' own JSON URL scheme, which spells a to-do "to-do". An agent that copies .type from a listing into an import item gets an item Things drops silently, since validateImportJSON only checks array shape.

For whoever cuts the release

The release body is read from .github/releases/<Tag>.md and .github/release.yml has no breaking-change category, so a feat! commit files under "Features" with no compatibility warning. The status break in 8656bd8 hit the same gap. The release note wants the table above, and a reminder to run things skill install so agents pick up the new SKILL.md.

Note for the open MCP branches

#98 and #99 serialise the same model.Task and will need a rebase onto this. They pick up the string automatically; what needs checking is any assertion or tool schema in those branches that describes type as an integer.

`type` was Things' raw TMTask code — 0, 1 or 2 — in every JSON payload
that carried it. A bare integer reads badly for humans and agents alike,
and `status` already renders as a string, so the two fields were in two
different styles.

`type` now renders as `todo`, `project` or `heading`, through a
`model.TaskType` codec that mirrors `model.Status`: a name<->code map as
the single source of truth, a `MarshalJSON` that falls back to the raw
integer for an unrecognized code, and an `UnmarshalJSON` that accepts
either form so values round-trip.

Every JSON surface picks this up, because they all marshal `model.Task`
through one encoder: list views, show, search and repeating. Plain text
output is unchanged, including the `(project)` marker on a project row.

BREAKING CHANGE: `type` in JSON output is now the string `todo`,
`project` or `heading` instead of the integer 0, 1 or 2. A caller
matching on `.type==1` has to become `.type=="project"`.

Closes #208
@ryanlewis
ryanlewis merged commit 5cedda2 into main Sep 10, 2026
10 checks passed
@ryanlewis
ryanlewis deleted the feat/issue-208-type-string branch September 10, 2026 10:44
ryanlewis added a commit that referenced this pull request Sep 10, 2026
Closes #212

## What changed

`trash` now lists projects alongside to-dos, the same rule #205 and #209
applied to the other views. Trashing a project in Things puts the
project row itself in the app's Trash, and `things projects` filters
trashed rows, so a trashed project was visible nowhere in the CLI —
excluded by construction, not by data.

The view now selects `t.type IN (0, 1)` through the existing
`todoOrProject` constant. Nothing else about it moved: headings (type 2)
stay out, and `trash` keeps the literal reading of the database it
shares with `logbook`, so the children of a trashed project and any
repeating template that ends up in the bin still list as they are
stored.

`inbox` and `deadlines` stay to-do only. #213 covers `deadlines`.

## Telling a project row from a to-do

No output change was needed. `printTasks` already appends a dim
`(project)` for project rows, and since #214 the JSON carries `"type":
"project"`.

## Verified against the real database

Read the live database with `sqlite3 -readonly`, and compared against
the app's own list membership via AppleScript (`id of to dos of list
"Trash"`), by uuid rather than title.

| | count |
|---|---|
| App's Trash list | 162 |
| CLI `list trash` before | 148 |
| CLI `list trash` after | 165 |
| Trashed project rows gained | 17 |

All 17 project rows the CLI now returns are in the app's Trash, and
nothing the app shows is missing from the CLI. No false positives: the
database holds no trashed headings and no trashed repeating templates,
so neither could leak in.

The 3 rows the CLI returns that the app does not are unchanged by this
PR. All three are to-dos whose parent project is itself trashed; the app
shows the trashed project as one row and folds its children into it.
They were already returned before this change, under the
`viewsIncludingTrashedProjects` decision from #155, and narrowing that
is a separate question from this issue.

## Tests

Three tests in `internal/db/tasks_test.go` over a new
`seedTrashedProjects` helper: trashed projects of both statuses are
listed and carry `model.TypeProject`; headings and live rows of either
kind stay out; `--area` matches a trashed project through its own area
while `--project` cannot.

`TestTrashAndLogbookKeepTrashedProjectChildren` seeds a trashed project
as the parent of its fixture, so its trash expectation gained the
project row it was already seeding. Its subject, that the child is kept,
is unchanged.

Docs updated in `internal/skill/SKILL.md`, `docs/content/commands.md`
and `docs/content/agents.md`: the sentence naming which views carry
projects, and the repeating-template note that said a project template
never reaches `trash`.
ryanlewis added a commit that referenced this pull request Sep 10, 2026
Closes #218

## What changed

`type` on a to-do row in JSON output is now `"task"` instead of
`"todo"`. `model.typeNames` is the single source of truth
(`internal/model/model.go`), so every JSON surface picks this up without
its own change.

Mechanical rename, nothing else touched:
- `internal/model/model.go`, `internal/model/model_test.go` — codec map
and its round-trip/marshal/unmarshal/string tests
- `cmd/things/run_test.go` — raw-JSON assertions from #214
- `internal/skill/SKILL.md`, `docs/content/commands.md`,
`docs/content/agents.md` — every `"todo"` value in prose and `jq`
examples

The legacy integer `0` still decodes on input; no compatibility shim for
the string `todo` is needed since #214 is unreleased. Plain text output,
`model.Status`, and the codec's structure are unchanged.

## Why

#214 introduced the string values `todo`/`project`/`heading`. Review on
that PR flagged `todo` as a fourth spelling of one concept — the
codebase already says `task`/`to-do` elsewhere — and the issue calls for
`task` as the word the CLI should use generally, before 0.8.0 ships the
contract.

## How tested

- `make test` and `make lint` pass
- `/code-review --fix` run against the diff; its two suggested fixes (a
`db.go`/`tasks.go` cleanup) were out of scope for this issue and were
not applied
ryanlewis added a commit that referenced this pull request Sep 10, 2026
Closes #219

The `--json` error payload spelled one concept two ways. `kind` was
`"task"` everywhere except the `not a project` failure, where it was
`"to-do"`, while `type` on a task row has said `"task"` since #214. One
word wins, and that word is `task`.

## What changed

`ProjectEditCmd.Run` now builds its `wrongKindError` with `Kind:
"task"`. That field feeds both the JSON `kind` and the rendered message,
so the message moves with it: `"Post letter" is a task; use things
edit`. Every other producer already said `task`, `project`, `area` or
`tag`.

The docs that print or explain the payload follow: the error-payload
example in the bundled skill, the token paragraph on the agents page,
and the README paragraph explaining what `kind` carries. The skill gains
a one-line note in its `--json` section saying a to-do is a `task` in
every JSON value the CLI emits, naming `import` payloads as the one
exception.

`commands.md` and `configuration.md` are untouched. Neither documents
the error payload, and no command or flag changed.

## Not a breaking change

`kind: "to-do"` was introduced in #193, which is not an ancestor of
`v0.7.0`. The value has never appeared in a release, so no published
contract moves and there is nothing for the 0.8.0 notes to warn about.
That is the same reasoning #223 applied to the `type` rename. The
`error` token is untouched either way: it stays `not a project`.

## Out of scope, deliberately

`import` payloads still spell a to-do `"to-do"`. That is Things' own
JSON URL scheme format and it stays documented as the exception.

Three other `kind` variables hold `"to-do"` and were left alone, because
none of them reaches JSON. They only compose English prose: the
repeating-item refusal in `verify.go`, the per-item refusal line in
`importcheck.go`, and the agent brief's opening sentence in
`internal/output/agent.go`. The issue scopes itself to JSON values, and
renaming these would rewrite prose and its tests well outside that. The
new comment on the `Kind` field says so explicitly, so the next reader
does not take the rule as wider than it is.

## How tested

`make test` and `make lint` both green.
`TestRunProjectEditRefusesTodoReference` asserts the new `kind` and
message and carries a comment saying why. The built binary was run
against the live Things database to confirm the payload it prints.
ryanlewis added a commit that referenced this pull request Sep 10, 2026
Closes #241
Closes #215

## What changed

`Status` and `TaskType` each carried their own name map, `String`,
`MarshalJSON` and `UnmarshalJSON`, written piece for piece the same way.
They now share one generic `enumCodec[T ~int]` in `internal/model`. The
name map stays the single source of truth and the decode direction is
derived from it at construction, so the two directions cannot drift.
Behaviour is unchanged for both types, down to the error text.

`start` was the last enum the CLI shipped as a bare integer. It is now a
named `model.Start` on `Task` and on `Project`, using the same codec, so
it renders `inbox`, `anytime` or `someday` in JSON and still accepts the
legacy integer on input. The ad hoc `switch` in `whenText` is gone: the
agent brief and JSON now take their list names from one place.

## Breaking change

`start` in every JSON output goes from an integer to a string. 0.8.0
already carries the same kind of change for `type`, so this belongs in
the same release note.

| Things code | v0.7.0 and earlier | 0.8.0 | Means |
| --- | --- | --- | --- |
| 0 | `"start": 0` | `"start": "inbox"` | Inbox |
| 1 | `"start": 1` | `"start": "anytime"` | Anytime, or Today when it
carries a date |
| 2 | `"start": 2` | `"start": "someday"` | Someday, or Upcoming when it
carries a date |

A filter matching on the integer has to be updated: `jq '.[] |
select(.start==2)'` becomes `jq '.[] | select(.start=="someday")'`. An
unrecognised raw code is still emitted as its integer rather than
collapsing to a lossy `"unknown"`, so a value the CLI does not know
round-trips. `UnmarshalJSON` accepts the name or the legacy integer, and
treats a JSON null as a no-op, matching `type` and `status`.

The field appears on to-do rows and, since #204, on `things projects`
rows, so both surfaces change together.

## The `startBucket` decision

`startBucket` stays an integer, and the reason is in the data rather
than in caution. Every row in the live database holds `0` or `1`, and
`1` only ever appears on a dated `anytime` to-do: it is the Evening
split within a scheduled day, the app's This Evening section.

Only one of those two values has a name. Things' own vocabulary has
`evening`, which the CLI already exposes as `--when evening`, and it has
no word at all for the other side. Naming the pair would have meant
inventing a token for `0` and then asserting it on every row that is not
an evening row, in a public contract that would take another breaking
change to undo. `0` says nothing, which is what it means. The field now
carries a doc comment on `model.Task` saying exactly this.

If it ever does want names, the shared codec makes it a five-line
change.

## Verification

Beyond the existing suites, which stay untouched and green:

- Plain text output is byte-identical to `origin/main` across twenty-one
surfaces, checked by diffing the two binaries against the live database:
nine list views with and without `--include-completed`, plus `projects`,
`areas` and `tags`.
- The agent brief is byte-identical across thirty-nine real to-dos,
covering the dated, `anytime` and `someday` paths through `whenText`.
- The JSON diff between the two binaries touches exactly one field,
`start`, on every list view and on `projects`. Nothing else moved.
- `TestRunJSONRendersStartAsString` asserts against raw JSON rather than
an unmarshalled `model.Task`, the way #214 did for `type`:
`Start.UnmarshalJSON` still accepts the legacy integer, so decoding
would keep passing even if the encoder regressed. A regex fails the test
if a bare number reaches the `start` field.
- The guards were mutation-tested. Marshalling `Start` as a raw int
fails all six subtests of the new raw-JSON test; changing the codec's
fallback name fails nine tests across three packages; wiring the `Start`
codec with another type's name fails the new
`TestEnumCodecErrorsNameTheirOwnType`, which exists because nothing else
would notice that mistake.

`TestStatusString` and the zero-value round-trip case that the #214
review noted were missing are both added, which is the rest of #215.

## One deliberate behaviour change beyond `start`

`whenText` used to fall through to `"anytime"` for any `start` it did
not recognise, and now renders `"unknown"`, matching how `type` and
`status` render an unknown code. `internal/db` coalesces a NULL `start`
to `0` and Things only ever writes `0`, `1` or `2`, so this is
unreachable against a real database; `"unknown"` is the more honest
answer if it ever were reached.

## Review

`/code-review --fix` at high effort raised two points, both low. One was
an ambiguous "both of these" in `commands.md` sitting two paragraphs
after a sentence naming three fields, which could have led a reader to
rewrite a working `.status=="open"` filter as part of the migration;
that is fixed, and the sentence now names `type` and `start`. The other
was a comment on `whenText` claiming the brief and the JSON field
"cannot drift apart", which overstates it: for an unrecognised code JSON
keeps the integer while the brief says `"unknown"`. The comment now says
so.

The review separately cleared the scan path (`database/sql` handles a
named `int` through the same reflection path `Status` already relied
on), confirmed no non-test code outside `internal/model` and
`internal/output` reads either `Start` field, confirmed `"start"` is
emitted from exactly one place so there is no second encoder to keep in
step, and confirmed the on-disk cache stores UUIDs only, so there is no
stale JSON to migrate.

## Not touched

`README.md` shows no JSON `start` value anywhere, and documents neither
`status` nor `type` as string enums, so there was nothing in it to
update. It stays the short version and points at the site. The `t.start
= 0/1/2` literals in `internal/db/tasks.go` are left alone: that file
belongs to #240, and #238 is in it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: render type as a string in JSON output (todo, project, heading)

1 participant