Skip to content

feat(model)!: render start as a string and share one enum codec - #251

Merged
ryanlewis merged 1 commit into
mainfrom
feat/issue-241-codecs
Sep 10, 2026
Merged

ryanlewis merged 1 commit into
mainfrom
feat/issue-241-codecs

Conversation

@ryanlewis

Copy link
Copy Markdown
Owner

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 feat(output)!: render type as a string in JSON #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.

Closes #241
Closes #215

`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]`: the name map stays the single source of truth and the decode direction is derived from it, so the two cannot drift.

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

`startBucket` stays an integer: `1` is the app's This Evening section and `0` is everything else, and only the first has a name in Things' own vocabulary.

BREAKING CHANGE: `start` in JSON output is now the string `inbox`, `anytime` or `someday` rather than Things' raw integer `0`, `1` or `2`. A caller matching on `.start==2` has to become `.start=="someday"`. The legacy integer still decodes on input. Plain text output is unchanged.
@ryanlewis
ryanlewis merged commit 7b35bd7 into main Sep 10, 2026
10 checks passed
@ryanlewis
ryanlewis deleted the feat/issue-241-codecs branch September 10, 2026 13:59
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(model)!: share one codec between Status and TaskType, and render start as a string chore(model): share one codec between Status and TaskType

1 participant