fix(projects): report start, startBucket, startDate and deadline - #204
Merged
Merged
Conversation
`things projects -j` returned no start date for a project that had one, while `things show <uuid> -j` on the same project returned it. ListProjects selected eight columns and model.Project had nowhere to put the scheduling ones, so a caller could not tell a scheduled project from an anytime one without a `show` per project. Select start, startBucket, startDate and deadline in ListProjects and decode them the way scanTask does, under the same JSON names and encodings the Task output uses. Plain text output is unchanged. Closes #202
Review of the previous commit flagged that the copied decode shadows the `d *DB` receiver with a `d` of its own inside ListProjects. Extract thingsDate and call it there. scanTask keeps its inline copy for now: internal/db/tasks.go is being edited in parallel for issue #201 and a conflict there costs more than the duplication.
ryanlewis
added a commit
that referenced
this pull request
Sep 10, 2026
Closes #206 ## What changed `someday` and `logbook` now list projects alongside to-dos, the same rule #205 applied to `today`, `upcoming` and `anytime`. A project is deferred exactly as a to-do is deferred, and completed exactly as a to-do is completed, so the app shows the project itself in Someday and in the Logbook. The CLI's two view filters were pinned to `t.type = 0`, so those project rows were excluded by construction, not by data. Both views now select `t.type IN (0, 1)` through the existing `todoOrProject` constant. Nothing else about them moved: repeating templates and the to-dos inside them stay out of `someday`, trashed rows stay out of both, and headings (type 2) stay out. `logbook` keeps its literal reading of the database — it is still one of the two views that report trashed-project children and templates as they are stored. `inbox`, `trash`, `deadlines` and the bare-filter view stay to-do only. ## Telling a project row from a to-do No new marker was needed, and no output change either. `printTasks` already appends a dim `(project)` for `type = 1` rows, and the header-folding fix from #205 is view-agnostic, so a completed project sitting directly above its own completed to-dos in the Logbook prints its title once. ## Verified against the real database Read from a checkpointed copy of the live Things database, compared against the app's own list membership via AppleScript (`id of to dos of list "Logbook"` / `list "Someday"`), uuids not titles. - `logbook`: 36 project rows appear that the previous build did not return, and all 36 are in the app's Logbook list. No false positives. Total rows go from 1154 to 1190. The newest is `L16wNJD3D84AEJwr14QWni` "OQ-11 — Salesforce to AWS threat model", which is also the first row of the app's Logbook. - `someday`: no project currently sits in Someday, so this was checked by inserting one row into a copy of the real database. `PROBE-someday-project` is absent before the change and present after, with `(project)` in plain output. The six rows the app does show in Someday all have `start = 2`, `startDate IS NULL`, `status = 0` and no parent project, which is the filter minus the type pin. ## How tested `make test` (race) and `make lint` (0 issues) both pass. New tests, all confirmed to fail with the filters reverted: - `internal/db`: a deferred project appears in `someday` and a completed project in `logbook`, both with `type = model.TypeProject`; a Someday-start repeating project template, the to-dos inside it, trashed projects and headings stay out; the Logbook order still keys on `stopDate` with a project in it; `--project` never matches a project row while `--area` does. - `cmd/things`: `things someday` and `things logbook` end to end — JSON carries the project row, plain text marks it `(project)` and does not mark to-dos. No new `internal/output` test. The header-folding behaviour these views can hit is already covered by `TestPrintTasksNoRepeatedProjectHeader`, which is written against `Print` rather than a view. ## Docs `internal/skill/SKILL.md` (the "Output and `--json`" list and the `things list` block), `docs/content/commands.md` (Listing) and `docs/content/agents.md` — extending the wording #205 added rather than adding a parallel paragraph. Two knock-on corrections in both SKILL.md and commands.md. They said `trash` and `logbook` are to-do lists, so a project template never shows in either; `logbook` now carries projects, so that is only true of `trash`. And "the other views stay to-do only" now names which ones, since `repeating` has carried project templates since #165. The comment on the header fold in `internal/output/output.go` named only the three views from #205 and said a project's to-dos sort straight after it. It now names all five and says the fold applies where the view's order puts them there, which is what the code actually tests for. ## Not addressed here Two Someday/Logbook parity gaps found while verifying, both pre-existing and unrelated to project rows: - The `logbook` filter is `t.status = 3`, so cancelled items never appear, but the app's Logbook shows them. 138 cancelled to-dos and 6 cancelled projects are missing. - `things someday` returns 8 to-dos that the app's Someday list does not show. Each is a Someday to-do inside an Anytime project; the app keeps those inside the project rather than in the global Someday list. `trash` and `deadlines` are the two views left pinned to `t.type = 0`. Trashing a project in the app leaves no CLI route to see it, since `things projects` filters trashed rows and `trash` excludes projects. And `things deadlines` skips a project with a deadline, though `things projects` reports `deadline` since #204. Both are behaviour changes outside this issue and want their own tests.
This was referenced Sep 10, 2026
ryanlewis
added a commit
that referenced
this pull request
Sep 10, 2026
Closes #213 ## What changed `deadlines` now lists projects alongside to-dos. A project takes a deadline exactly as a to-do does, `things projects -j` has reported it since #204, and agents.md advertises this view as the way to sweep what is due — so an agent following the docs missed every project deadline. The view was pinned to `t.type = 0`, so those rows were excluded by construction, not by data. The view now selects `t.type IN (0, 1)` through the existing `todoOrProject` constant, the same rule #205, #209 and #216 applied to the other views. Its `ORDER BY t.deadline ASC` is unchanged, so project rows fall in among the to-dos by date rather than forming a block of their own. `--on`, `--from` and `--to` already filter `t.deadline` on this view and now filter project rows by their own deadline. `inbox` is now the only named view that stays to-do only, which is a property of the Inbox rather than a limitation: an inbox item has not been filed anywhere yet, so it is never a project. A bare `-p`/`-a`/`-t` filter with no view named is the other exception, and the docs now say so explicitly — it routes to the internal catch-all view, which is still pinned to to-dos. ## Verification: negative only, and worth being plain about There is nothing on this database that exercises the change, and I would rather say so than imply a check I did not make. The app has no Deadlines list to compare against, so the fallback is `things projects -j`, which reports no project deadlines at all. Reading the live database with `sqlite3 -readonly` shows why. Seven projects carry a deadline, and every one is already excluded by the `status = 0 AND trashed = 0` clause this PR does not touch: | why excluded | count | |---|---| | completed or cancelled | 4 | | trashed | 3 | | would list | 0 | So `things deadlines` returns the same 13 to-do rows before and after this change, and the SQL the new filter generates selects those same 13. What I have verified is that no project is wrongly admitted and none is wrongly held back for a reason this PR introduces. What I have not verified is a project deadline appearing in the app and then in the CLI, because no such project exists here. The tests below cover what the live data cannot. ## Tests Six tests in `internal/db/tasks_test.go` over a new `seedDeadlines` helper. The fixture interleaves a project between two to-dos by deadline while running `"index"` against that order, so an ordering that fell back to `"index"` or grouped by type would fail: - projects with a deadline are listed and carry `model.TypeProject` - the project sorts between the two to-dos, by deadline - a project with no deadline, a completed one, a trashed one and a heading all stay out - `--on` matches a project row by its own deadline - `--area` finds a project through its own area; `--project` cannot match one - a repeating project template with a deadline, and the to-do inside it, stay out — the one exclusion path that comes from `viewsIncludingTemplates` rather than the view filter ## Docs `internal/skill/SKILL.md`, `docs/content/commands.md`, `docs/content/agents.md` and `README.md`: the view lists now say every named view except `inbox` carries projects, and name the bare-filter exception so an agent knows to name a view when project rows matter. The README gains the general rule it never stated, so its `deadlines` table row no longer reads as though that view is the only one carrying projects. `internal/output/output.go` had a comment enumerating the old set of views.
This was referenced Sep 10, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #202
What changed
things projects -jreturned nostartDatefor a project that had one, whilethings show <uuid> -jon the same project returned it.ListProjectsselected eight columns andmodel.Projecthad nowhere to put the scheduling ones, so a caller could not tell a scheduled project from an anytime one without ashowper project.model.Projectnow carriesStart,StartBucket,StartDateandDeadline, andListProjectsselects and decodes those columns. The JSON names and encodings are the ones the to-do output already uses, so callers read one vocabulary:startandstartBucketare the raw Things integers,startDateanddeadlineareYYYY-MM-DDstrings and are omitted when unset. Plain text project output is unchanged.A second commit extracts the nullable-date decode into a
thingsDatehelper, after review pointed out that the copied decode shadowed the*DBreceiver with a locald.scanTaskininternal/db/tasks.gokeeps its inline copy: that file is being edited in parallel for #201 and a conflict there costs more than the duplication.Why
Reported in #202. An agent or script listing projects had to fan out one
things showper project just to learn which ones are scheduled.How tested
make test(with-race) andmake lintare green.Two tests added:
TestListProjectsCarriesSchedulingininternal/db/projects_test.goseeds a scheduled project and an unscheduled one, and asserts the decoded start, bucket, start date and deadline, plus nil dates for the unscheduled row.TestPrintProjectsJSONSchedulingininternal/output/output_test.golocks the JSON field names and theYYYY-MM-DDrendering.Checked against the real database with the project from the issue:
Docs
internal/skill/SKILL.md: a bullet in "Output and--json" and a note on thethings projectsreference line.docs/content/commands.md: a paragraph in "Listing".docs/content/agents.md: the matching bullet in its--jsonsection.