Skip to content

fix(output): emit [] instead of null for empty --json results - #124

Merged
ryanlewis merged 1 commit into
mainfrom
fix-json-empty
Aug 8, 2026
Merged

ryanlewis merged 1 commit into
mainfrom
fix-json-empty

Conversation

@ryanlewis

Copy link
Copy Markdown
Owner

Two of the v0.5.1 scout findings, both in the db layer:

  1. Empty `--json` output was `null`, not `[]` — nil slices from `collectTasks`/`ListProjects`/`ListAreas`/`ListTags` encode as `null`, so `jq '.[]'` fails (exit 5) on any empty result. The bundled SKILL.md ships pipelines that hit exactly this path on an empty Today. Fixed at the db layer (non-nil initialisation) so `len()` checks in future callers also behave; regression tests at both the db and output layers.

  2. Date filters on `someday` could never match — the view predicate requires `startDate IS NULL` while `--on/--from/--to` compare `startDate`. Removed `someday` from `dateFilterableViews` so it now gets the existing clear rejection error instead of silent empty output. README + SKILL.md corrected (README previously advertised the impossible combination).

Verified against the live Things DB: empty search prints `[]`; `things list someday --on …` errors clearly.

Nil slices from the db layer JSON-encode as null, which breaks the
documented `--json | jq '.[]'` pipelines (jq exits 5 on null). Initialise
result slices in collectTasks/ListProjects/ListAreas/ListTags so every
list-shaped command emits [] when nothing matches.

Also drop someday from the date-filterable views: its predicate requires
startDate IS NULL while --on/--from/--to compare startDate, so the
combination could never match anything — now rejected with the existing
clear error instead of silently returning nothing. Docs updated.
@ryanlewis
ryanlewis merged commit 176b23e into main Aug 8, 2026
7 checks passed
@ryanlewis
ryanlewis deleted the fix-json-empty branch August 8, 2026 21:23
ryanlewis added a commit that referenced this pull request Aug 8, 2026
Release notes for v0.5.1. Merge after #123/#124/#125 — goreleaser reads
this file at tag time (`readFile .github/releases/v0.5.1.md`), so it
must be on main before the tag is pushed.
ryanlewis added a commit that referenced this pull request Sep 10, 2026
Closes #211

Things' Someday list holds the deferred things you have not filed under a
project. A to-do inside a project stays inside it however it is deferred:
the app shows it greyed within the project rather than in the global list.
The CLI returned those to-dos, so `things list someday` answered a
different question from the one the app answers.

The view gains p.uuid IS NULL. Because p resolves through
COALESCE(t.project, h.project), a to-do filed under a project heading is
excluded by the same clause rather than slipping through as unparented.
Project rows have no parent project, so they still list (issue #206).

The rule was measured, not assumed. The issue proposed excluding Someday
to-dos whose parent project is not itself in Someday, but the database
held no Someday projects, so that could not be told apart from the
simpler rule. With the user's approval a throwaway project was created in
Things, in Someday and holding one Someday to-do, and the app's own list
membership read back: the project appeared in Someday, the child did not.
The test is the presence of a parent, not the parent's bucket. The probe
was deleted afterwards and the app's list is back to its 6 rows.

Verified by uuid: the app lists 6, the CLI listed 15 before and 6 after,
with no difference in either direction. The nine dropped are the eight
named in the issue plus GXHKQn2Y12ChTiGXLdqQvB.

--project on someday now errors instead of returning an empty list: the
clauses contradict, so it could never match. It names the view and points
at `things --project NAME`, the same call issue #124 made for date
filters on this view.
ryanlewis added a commit that referenced this pull request Sep 10, 2026
Closes #211

## What changed

`someday` now returns only rows with no parent project. Things' Someday
list is the deferred things you have not filed under a project: a to-do
inside a project stays inside that project however it is deferred, and
the app shows it greyed within the project rather than in the global
list. The CLI returned those to-dos, so `things list someday` answered a
different question from the one the app answers.

The view gains `p.uuid IS NULL`. Because `p` is resolved through
`COALESCE(t.project, h.project)`, a to-do filed under a project
*heading* is excluded by the same clause rather than slipping through as
unparented. Project rows have no parent project of their own, so they
still list, which is what #206 established.

## The rule was measured, not assumed

The issue proposed excluding Someday to-dos "whose parent project is not
itself in Someday". That is not the rule, and the difference mattered
enough to test rather than guess.

Two rules fit the existing data equally well, because every one of the
nine to-dos the CLI wrongly returned had an Anytime parent:

- **A.** exclude a Someday to-do whose parent project is not itself in
Someday (the issue's hypothesis)
- **B.** exclude a Someday to-do that has any parent project

They differ only for a Someday to-do inside a Someday project, and this
database contained no Someday projects at all, so the discriminating
case did not exist. With the user's approval I created it through the
app and measured it.

A throwaway project `PROBE-someday-20260910-122504` was created in
Things via AppleScript, moved to Someday, given a deadline, and one
to-do added inside it via the `things:///add` URL scheme with
`when=someday`, so that both project and child sat in Someday with the
child properly parented. Reading the app's own list membership:

| | in the app's Someday list |
|---|---|
| the probe project (Someday, has a deadline) | yes |
| its child to-do (Someday, parent is that Someday project) | no |

The app's Someday list went from 6 rows to 7, gaining only the project.
So the test is the presence of a parent, not the parent's bucket: **rule
B**. The probe project also appeared in the app's `Later Projects` list,
which is where Things files deferred projects, and that list was empty
before and after.

Both probe items were then deleted through the app. The app's Someday
list is back to 6, `Later Projects` back to 0, and the four probe rows
sit in the Trash.

## Verified against the real database

With rule B implemented, `things list someday` returns 6 rows and the
app's Someday list holds 6, with no difference in either direction by
uuid.

| | count |
|---|---|
| App's Someday list | 6 |
| CLI `list someday` before | 15 |
| CLI `list someday` after | 6 |
| Rows in one and not the other | 0 |

The nine dropped are the eight named in the issue plus
`GXHKQn2Y12ChTiGXLdqQvB`, which the issue did not list. All nine are
Someday to-dos with an Anytime parent project.

## `--project` on `someday` is now an error

Narrowing this view to a project asks for the contents of a project the
view has already excluded, so the two clauses contradict and the listing
is empty whatever the project holds. On `main` that command listed a
project's deferred to-dos, so leaving it silently empty would have been
a regression with no explanation attached.

It now fails with an error naming the view and pointing at `things
--project "Name"`, which does return a project's own deferred to-dos.
That follows the precedent this repo already set twice: #124 rejects
date filters on this same view for the same reason, and
`--include-completed` is rejected everywhere but `today`. Both the flag
form and the positional form (`things someday "Name"`) are covered.

This is a small CLI surface change rather than something #211 asked for,
so it is worth a reviewer's eye. The alternative is a command that
returns nothing and says nothing.

## Tests

Five tests in `internal/db/tasks_test.go` over a new
`seedSomedayParents` helper, which seeds an Anytime parent and a Someday
parent so the discriminating case is in the fixture rather than only in
the prose:

- to-dos inside a project are excluded; the Someday project and the
unparented to-dos remain
- the discriminating case on its own — a Someday to-do inside a Someday
project stays hidden while its parent lists
- a to-do reaching its project through a heading is excluded by the same
clause
- narrowing does not empty the view: `--area` still finds the project
row and the unparented to-do, and `--project` can no longer match
anything here
- the narrowing is someday-only — a parented to-do still lists in
`anytime`

`TestRunListSomedayRejectsProjectFilter` in `cmd/things/run_test.go`
covers the new error in both its flag and positional forms, and asserts
bare `someday` and `someday --area` still work.

`TestTemplateProjectChildrenExcludedFromOpenViews` drove each view with
a template's child and an ordinary project's child, expecting the
ordinary one to list. Someday no longer carries either, so that sibling
cannot exist there and the case has moved to
`TestTemplateChildExcludedFromSomeday`, which asserts both children stay
out while a top-level to-do and a Someday project row still list.
Coverage of the template guard on this view is kept, not dropped.

## Docs

`internal/skill/SKILL.md`, `docs/content/commands.md`,
`docs/content/agents.md` and `README.md` describe Someday as the
deferred things not filed under a project, say explicitly that the rule
holds even when the parent project is itself in Someday, and point at
`things --project "Name"` for a project's own deferred to-dos.
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.

1 participant