Skip to content

fix(projects): report start, startBucket, startDate and deadline - #204

Merged
ryanlewis merged 2 commits into
mainfrom
fix/issue-202-project-startdate
Sep 10, 2026
Merged

ryanlewis merged 2 commits into
mainfrom
fix/issue-202-project-startdate

Conversation

@ryanlewis

Copy link
Copy Markdown
Owner

Closes #202

What changed

things projects -j returned no startDate 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.

model.Project now carries Start, StartBucket, StartDate and Deadline, and ListProjects selects and decodes those columns. The JSON names and encodings are the ones the to-do output already uses, so callers read one vocabulary: start and startBucket are the raw Things integers, startDate and deadline are YYYY-MM-DD strings and are omitted when unset. Plain text project output is unchanged.

A second commit extracts the nullable-date decode into a thingsDate helper, after review pointed out that the copied decode shadowed the *DB receiver with a local d. scanTask in internal/db/tasks.go keeps 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 show per project just to learn which ones are scheduled.

How tested

make test (with -race) and make lint are green.

Two tests added:

  • TestListProjectsCarriesScheduling in internal/db/projects_test.go seeds a scheduled project and an unscheduled one, and asserts the decoded start, bucket, start date and deadline, plus nil dates for the unscheduled row.
  • TestPrintProjectsJSONScheduling in internal/output/output_test.go locks the JSON field names and the YYYY-MM-DD rendering.

Checked against the real database with the project from the issue:

$ things projects -j | jq -c '[.[] | select(.startDate != null)][0]'
{"uuid":"X28bim63xxvGvWLncbfd7f","title":"Runbook audit","status":"open","start":1,"startBucket":0,"startDate":"2026-09-07","areaUUID":"...","areaTitle":"LHV","tags":["Work"],"taskCount":3,"openCount":3}

Docs

  • internal/skill/SKILL.md: a bullet in "Output and --json" and a note on the things projects reference line.
  • docs/content/commands.md: a paragraph in "Listing".
  • docs/content/agents.md: the matching bullet in its --json section.

`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
ryanlewis merged commit d66e26f into main Sep 10, 2026
10 checks passed
@ryanlewis
ryanlewis deleted the fix/issue-202-project-startdate branch September 10, 2026 09:56
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.
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.
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.

bug: things projects reports startDate null when the project has a start date

1 participant