From d0988cfa0550cf735e777b62d29fa8b87c54fe37 Mon Sep 17 00:00:00 2001 From: Ryan Lewis Date: Thu, 10 Sep 2026 12:35:16 +0100 Subject: [PATCH] fix(list): keep project to-dos out of someday 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. --- README.md | 4 +- cmd/things/main.go | 7 ++ cmd/things/run_test.go | 26 ++++++ docs/content/agents.md | 10 +++ docs/content/commands.md | 8 ++ internal/db/repeating_test.go | 38 +++++++- internal/db/tasks.go | 28 +++++- internal/db/tasks_test.go | 158 ++++++++++++++++++++++++++++++++++ internal/skill/SKILL.md | 1 + 9 files changed, 276 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a8f9ff5e..8ad8f0b3 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ project literally called `Inbox` would need `things -p Inbox`. | `inbox` | Inbox | | `upcoming` | Scheduled tasks and deadlines | | `anytime` | Anytime list | -| `someday` | Someday list | +| `someday` | Someday projects and unparented Someday to-dos | | `repeating` | Repeating to-do and project templates | | `logbook` | Completed and cancelled tasks | | `trash` | Trashed tasks | @@ -264,7 +264,7 @@ Filters: | Flag | Description | | --- | --- | -| `-p, --project NAME` | Filter by project name or UUID | +| `-p, --project NAME` | Filter by project name or UUID (not on `someday`, which lists only unparented items) | | `-a, --area NAME` | Filter by area name or UUID | | `-t, --tag NAME` | Filter by tag name | | `--on DATE` | Only tasks scheduled on `YYYY-MM-DD` (or RFC3339); on `deadlines`, filters by deadline | diff --git a/cmd/things/main.go b/cmd/things/main.go index b1bf3fbf..ff729f60 100644 --- a/cmd/things/main.go +++ b/cmd/things/main.go @@ -217,6 +217,13 @@ func (c *ListCmd) Run(d *Deps) error { return fmt.Errorf("--include-completed is only supported on the %q view, not %q; name the view explicitly, e.g. `things today --project NAME`", "today", view) } + // someday lists only what has no parent project, so narrowing it to one + // could never match a row (issue #211). Say so rather than print an empty + // list, the same way an impossible date filter is rejected. + if project != "" && !db.ProjectFilterableView(view) { + return fmt.Errorf("--project is not supported on the %q view: it lists only items with no parent project; use `things --project %q` for a project's own to-dos", view, project) + } + filter := db.TaskFilter{ Project: project, Area: c.Area, diff --git a/cmd/things/run_test.go b/cmd/things/run_test.go index 935febfd..7811f4f7 100644 --- a/cmd/things/run_test.go +++ b/cmd/things/run_test.go @@ -279,6 +279,32 @@ func TestRunListDateFilterRejectsView(t *testing.T) { } } +// someday carries no row with a parent project (issue #211), so --project on +// it — flag or positional — could never match. It is rejected rather than +// silently printing an empty list, as an impossible date filter is. +func TestRunListSomedayRejectsProjectFilter(t *testing.T) { + database := seedFullDB(t) + + err := runWith(t, database, "list", "someday", "--project", "Launch v2") + if err == nil || !strings.Contains(err.Error(), "not supported on the \"someday\" view") { + t.Fatalf("--project: expected view-rejection error, got: %v", err) + } + + // The positional form resolves to the same filter, so it is rejected too. + err = runWith(t, database, "list", "someday", "Launch v2") + if err == nil || !strings.Contains(err.Error(), "not supported on the \"someday\" view") { + t.Fatalf("positional project: expected view-rejection error, got: %v", err) + } + + // The view itself still lists, and --area still narrows it. + if err := runWith(t, database, "list", "someday"); err != nil { + t.Fatalf("bare someday: %v", err) + } + if err := runWith(t, database, "list", "someday", "--area", "Home"); err != nil { + t.Fatalf("someday --area: %v", err) + } +} + func TestRunListIncludeCompletedRejectsView(t *testing.T) { database := seedFullDB(t) diff --git a/docs/content/agents.md b/docs/content/agents.md index 8f4dfd7d..6684fc99 100644 --- a/docs/content/agents.md +++ b/docs/content/agents.md @@ -226,6 +226,16 @@ a script that acts on a listing should say which kind it means. It matters: every to-do inside it, so it asks first and refuses outright under `--json` without `--yes`. +`someday` matches the app's Someday list, which holds the deferred things not +filed under a project. A to-do inside a project stays inside it however it is +deferred, so an agent asked what is in Someday sees Someday projects and +unparented Someday to-dos, not the deferred contents of other projects. That +holds even when the parent project is itself in Someday: the project lists, its +to-dos do not. To sweep a project's own deferred to-dos, name the project: +`things --project "Name" -j`. `things someday --project "Name"` is an error, +not an empty list — nothing in the view has a parent project, so the filter +could never match. + `logbook` is everything closed, not just everything finished: a cancelled to-do or project is logged beside the completed ones, as the app's Logbook shows them. `status` separates them, `"completed"` or `"cancelled"`, so an diff --git a/docs/content/commands.md b/docs/content/commands.md index 046b959d..5fc30215 100644 --- a/docs/content/commands.md +++ b/docs/content/commands.md @@ -55,6 +55,14 @@ way the app's Logbook shows both, so the view returns cancelled rows too. `[~]` in plain output — so filter on it when you mean finished rather than closed: `things logbook -j | jq '.[] | select(.status=="completed")'`. +`someday` is the app's Someday list: the deferred things you have not filed +under a project. A to-do inside a project stays inside it however it is +deferred, so `someday` returns Someday projects and unparented Someday to-dos, +not the deferred to-dos of an Anytime project. Open the project to see those — +`things --project "Name"` — or use `anytime`, which carries the project itself. +Because nothing in `someday` has a parent project, `--project` there could +never match; the CLI rejects the combination rather than print an empty list. + `repeating` lists repeating to-do and project templates. The items a template generates are ordinary to-dos and projects and appear in `today`, `upcoming`, `things projects` and the rest; the template itself appears only here — plus diff --git a/internal/db/repeating_test.go b/internal/db/repeating_test.go index d73c84af..ebd8ae40 100644 --- a/internal/db/repeating_test.go +++ b/internal/db/repeating_test.go @@ -250,6 +250,39 @@ func TestRepeatingViewExcludesHeadings(t *testing.T) { } } +// Someday keeps no to-do that sits inside a project, whether that project is a +// repeating template or an ordinary one (issues #171, #211), so the template +// guard cannot be told apart from the parent guard by presence alone. What +// still has to hold is that the view is not simply empty: a top-level Someday +// to-do, and a Someday project row, both list. +func TestTemplateChildExcludedFromSomeday(t *testing.T) { + d := newTestDB(t) + + mustExec(t, d, `INSERT INTO TMTask + (uuid, title, type, status, trashed, start, startBucket, "index", rt1_recurrenceRule) VALUES + ('p-tmpl', 'Weekly review', 1, 0, 0, 2, 0, 1, x'0102')`) + mustExec(t, d, `INSERT INTO TMTask + (uuid, title, type, status, trashed, start, startBucket, "index") VALUES + ('p-real', 'Ship it', 1, 0, 0, 2, 0, 2)`) + mustExec(t, d, `INSERT INTO TMTask + (uuid, title, type, status, trashed, start, startBucket, project, "index") VALUES + ('t-in-tmpl', 'Inside the template', 0, 0, 0, 2, 0, 'p-tmpl', 1), + ('t-in-real', 'Inside a real one', 0, 0, 0, 2, 0, 'p-real', 2)`) + mustExec(t, d, `INSERT INTO TMTask + (uuid, title, type, status, trashed, start, startBucket, "index") VALUES + ('t-toplevel', 'Deferred on its own', 0, 0, 0, 2, 0, 3)`) + + got, err := d.ListTasks("someday", TaskFilter{}) + if err != nil { + t.Fatalf("ListTasks(someday): %v", err) + } + // p-real is a Someday project and lists as a row of its own; p-tmpl is a + // template and belongs to the repeating view. + if !sameSet(uuidsOf(got), []string{"t-toplevel", "p-real"}) { + t.Errorf("someday: got %v, want [t-toplevel p-real]", uuidsOf(got)) + } +} + // A to-do inside a repeating project template must not list as an ordinary // task: `things projects` does not report its project, so it would show // against a project the user cannot see (issue #171). Each case seeds the row @@ -269,7 +302,10 @@ func TestTemplateProjectChildrenExcludedFromOpenViews(t *testing.T) { {"today", "start, startBucket, startDate", fmt.Sprintf("1, 0, %d", today)}, {"upcoming", "start, startBucket, startDate", fmt.Sprintf("2, 0, %d", tomorrow)}, {"anytime", "start, startBucket", "1, 0"}, - {"someday", "start, startBucket", "2, 0"}, + // someday is absent deliberately: since issue #211 it carries no + // to-do with a parent project at all, so the sibling this table + // relies on cannot exist there. TestTemplateChildExcludedFromSomeday + // covers that view instead. {"deadlines", "start, startBucket, deadline", fmt.Sprintf("1, 0, %d", tomorrow)}, {"project", "start, startBucket", "1, 0"}, } diff --git a/internal/db/tasks.go b/internal/db/tasks.go index bf91f23a..a1033c4e 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -40,6 +40,21 @@ func DateFilterableView(view string) bool { return dateFilterableViews[view] } +// viewsWithoutProjectFilter lists the views a --project filter can never match +// in. someday keeps only rows with no parent project (issue #211), so pairing +// it with --project asks for the contents of a project the view has already +// excluded: the two clauses contradict, and the listing is empty whatever the +// project holds. Rejecting the combination beats printing an empty list, the +// same call issue #124 made for date filters on this view. +var viewsWithoutProjectFilter = map[string]bool{ + "someday": true, +} + +// ProjectFilterableView reports whether --project applies to the view. +func ProjectFilterableView(view string) bool { + return !viewsWithoutProjectFilter[view] +} + // repeatingPlaceholder is substituted with the probed recurrence column // reference by (*DB).taskQuery — the column name varies across Things schema // versions, and a schema carrying none resolves it to NULL. @@ -164,7 +179,18 @@ var viewFilters = map[string]string{ "inbox": "t.start = 0 AND t.status = 0 AND t.trashed = 0 AND t.type = 0", "upcoming": "t.start = 2 AND t.startDate IS NOT NULL AND t.status = 0 AND t.trashed = 0 AND " + todoOrProject, "anytime": "t.start = 1 AND t.status = 0 AND t.trashed = 0 AND " + todoOrProject, - "someday": "t.start = 2 AND t.startDate IS NULL AND t.status = 0 AND t.trashed = 0 AND " + todoOrProject, + // Someday is the app's list of 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 and keeps it out of the global + // Someday list, so "p.uuid IS NULL" is the parity rule (issue #211). + // Measured against the app rather than assumed — the discriminating case is + // a Someday to-do whose parent project is itself in Someday, and the app + // hides that one too, so the test is the presence of a parent, not the + // parent's own bucket. Project rows have no parent project, so they pass and + // stay listed (issue #206). Resolving p through COALESCE(t.project, + // h.project) means a to-do under a project heading is filed by its heading's + // project, not left looking unparented. + "someday": "t.start = 2 AND t.startDate IS NULL AND t.status = 0 AND t.trashed = 0 AND p.uuid IS NULL AND " + todoOrProject, // The Logbook is where Things files everything closed, not just everything // finished: cancelling a to-do or a project logs it under its stopDate // beside the completed ones, so the view carries status 2 as well as 3 diff --git a/internal/db/tasks_test.go b/internal/db/tasks_test.go index 030d3833..dd81f87c 100644 --- a/internal/db/tasks_test.go +++ b/internal/db/tasks_test.go @@ -2178,3 +2178,161 @@ func TestEveryTaskOrderingEndsInTheUUIDTiebreak(t *testing.T) { } } } + +func seedSomedayParents(t *testing.T, d *DB) { + t.Helper() + + mustExec(t, d, `INSERT INTO TMArea (uuid, title, visible, "index") VALUES + ('area-den', 'Den', 1, 1)`) + + // Two parent projects in different buckets. The Someday one is the case + // that separates "no parent project" from "no parent project outside + // Someday": measured against Things, the app hides that child too. + mustExec(t, d, `INSERT INTO TMTask + (uuid, title, type, status, trashed, start, startBucket, startDate, area, "index") VALUES + ('sd-parent-anytime', 'Anytime project', 1, 0, 0, 1, 0, NULL, 'area-den', 1), + ('sd-parent-someday', 'Someday project', 1, 0, 0, 2, 0, NULL, 'area-den', 2)`) + + // Someday to-dos: one under each parent, one under a heading of the + // Anytime parent, and two with no parent project at all. + mustExec(t, d, `INSERT INTO TMTask + (uuid, title, type, status, trashed, start, startBucket, startDate, project, "index") VALUES + ('sd-in-anytime', 'Filed under anytime', 0, 0, 0, 2, 0, NULL, 'sd-parent-anytime', 3), + ('sd-in-someday', 'Filed under someday', 0, 0, 0, 2, 0, NULL, 'sd-parent-someday', 4)`) + mustExec(t, d, `INSERT INTO TMTask + (uuid, title, type, status, trashed, project, "index") VALUES + ('sd-head', 'Phase one', 2, 0, 0, 'sd-parent-anytime', 5)`) + mustExec(t, d, `INSERT INTO TMTask + (uuid, title, type, status, trashed, start, startBucket, startDate, heading, "index") VALUES + ('sd-under-head', 'Filed under a heading', 0, 0, 0, 2, 0, NULL, 'sd-head', 6)`) + mustExec(t, d, `INSERT INTO TMTask + (uuid, title, type, status, trashed, start, startBucket, startDate, area, "index") VALUES + ('sd-loose-area', 'Deferred in an area', 0, 0, 0, 2, 0, NULL, 'area-den', 7), + ('sd-loose', 'Deferred on its own', 0, 0, 0, 2, 0, NULL, NULL, 8)`) +} + +// Things' Someday list is the deferred things not filed under a project. A +// to-do inside a project stays inside it however it is deferred, so the app +// keeps it out of the global list while the CLI returned it (issue #211). +// +// The rule is the presence of a parent project, not the parent's bucket. That +// was measured, not assumed: a probe project created in Someday through the +// app, holding one Someday to-do, put the project in the app's Someday list +// and left the child out. +func TestListTasksSomedayExcludesProjectChildren(t *testing.T) { + d := newTestDB(t) + seedSomedayParents(t, d) + + got, err := d.ListTasks("someday", TaskFilter{}) + if err != nil { + t.Fatalf("ListTasks(someday): %v", err) + } + want := []string{"sd-parent-someday", "sd-loose-area", "sd-loose"} + if !sameSet(uuidsOf(got), want) { + t.Errorf("someday: got %v, want %v", uuidsOf(got), want) + } +} + +// The discriminating case, called out on its own because it is the one the +// issue guessed wrong: a Someday to-do whose parent project is itself in +// Someday is still hidden, while the parent project lists. +func TestListTasksSomedayHidesChildOfSomedayProject(t *testing.T) { + d := newTestDB(t) + seedSomedayParents(t, d) + + got, err := d.ListTasks("someday", TaskFilter{}) + if err != nil { + t.Fatal(err) + } + uuids := uuidsOf(got) + + var sawParent, sawChild bool + for _, u := range uuids { + switch u { + case "sd-parent-someday": + sawParent = true + case "sd-in-someday": + sawChild = true + } + } + if !sawParent { + t.Errorf("someday: parent project sd-parent-someday should list, got %v", uuids) + } + if sawChild { + t.Errorf("someday: sd-in-someday sits inside a Someday project and should not list, got %v", uuids) + } +} + +// A to-do filed under a project heading carries a NULL project and reaches its +// project through the heading, so it must be excluded by the same rule rather +// than slipping through as unparented. +func TestListTasksSomedayExcludesHeadingNestedChildren(t *testing.T) { + d := newTestDB(t) + seedSomedayParents(t, d) + + got, err := d.ListTasks("someday", TaskFilter{}) + if err != nil { + t.Fatal(err) + } + for _, task := range got { + if task.UUID == "sd-under-head" { + t.Errorf("someday: sd-under-head reaches a project through its heading and should not list") + } + } +} + +// Narrowing someday must not empty it: a project row and an unparented to-do +// both still list, and --area still finds them through the area they carry. +func TestListTasksSomedayKeepsUnparentedRows(t *testing.T) { + d := newTestDB(t) + seedSomedayParents(t, d) + + byArea, err := d.ListTasks("someday", TaskFilter{Area: "area-den"}) + if err != nil { + t.Fatal(err) + } + if !sameSet(uuidsOf(byArea), []string{"sd-parent-someday", "sd-loose-area"}) { + t.Errorf("--area area-den: got %v, want [sd-parent-someday sd-loose-area]", uuidsOf(byArea)) + } + + // --project can never match on this view now: every row that survives has + // no parent project. The CLI rejects the combination outright rather than + // print an empty list (see TestRunListSomedayRejectsProjectFilter); the + // query layer stays literal and simply matches nothing. + byProject, err := d.ListTasks("someday", TaskFilter{Project: "sd-parent-anytime"}) + if err != nil { + t.Fatal(err) + } + if len(byProject) != 0 { + t.Errorf("--project sd-parent-anytime: got %v, want none", uuidsOf(byProject)) + } +} + +// The narrowing is someday-only: the same parented to-do still lists in the +// other views, which show a project's contents. +func TestListTasksProjectChildrenStayInOtherViews(t *testing.T) { + d := newTestDB(t) + seedSomedayParents(t, d) + + // The seeded children are all someday-shaped (start=2, no startDate), so + // none of them can appear in another view as they stand. Seed one more + // child of the same parent in anytime shape to prove the guard is + // someday-only rather than global. + mustExec(t, d, `INSERT INTO TMTask + (uuid, title, type, status, trashed, start, startBucket, project, "index") VALUES + ('sd-anytime-child', 'Inside, anytime', 0, 0, 0, 1, 0, 'sd-parent-anytime', 9)`) + + got, err := d.ListTasks("anytime", TaskFilter{}) + if err != nil { + t.Fatal(err) + } + var found bool + for _, task := range got { + if task.UUID == "sd-anytime-child" { + found = true + } + } + if !found { + t.Errorf("anytime: sd-anytime-child should still list, got %v", uuidsOf(got)) + } +} diff --git a/internal/skill/SKILL.md b/internal/skill/SKILL.md index b799a4e0..8dbe2520 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -37,6 +37,7 @@ Most commands accept `--json` / `-j`. Prefer it when parsing. It also guarantees - `"repeating": true` marks an item Things treats as repeating; the field is omitted otherwise. A project appearing as a row in a task listing carries `"type": "project"`. - `things projects` reports `start`, `startBucket`, `startDate` and `deadline` under the same names and encodings a to-do uses, so a scheduled project reads the same way without a per-project `show`. `startDate` and `deadline` are omitted when unset. - Every named view except `inbox` lists projects alongside to-dos, as the app does — scheduled in `today`/`upcoming`/`anytime`, deferred in `someday`, closed in `logbook`, trashed in `trash`, due in `deadlines`. Split them on `"type"` — `jq '.[] | select(.type=="project")'` for the projects, `.[] | select(.type=="task")` for the to-dos. Plain output tags a project row `(project)`. +- `someday` is the deferred things not filed under a project: Someday projects and unparented Someday to-dos. A to-do inside a project stays inside it however it is deferred, so it is not a `someday` row even when its project is in Someday too. Reach those with `things --project "Name"` — `things someday --project "Name"` is rejected, because no `someday` row has a parent project for it to match. - `logbook` is everything closed, not just everything finished: cancelled items sit beside completed ones, as they do in the app's Logbook. Split them on `"status"` — `"completed"` or `"cancelled"`; plain output prints `[x]` and `[~]`. Filter with `jq '.[] | select(.status=="completed")'` when you mean finished rather than closed. - `things projects` also reports `taskCount` and `openCount`. `taskCount` is every untrashed to-do in the project; `openCount` is the ones still open. The difference is the ones that are no longer open, which means completed or cancelled. To-dos filed under a project heading count towards both; the heading rows themselves never do, and neither do trashed to-dos or checklist items. Both numbers are Things' own bookkeeping, read straight from the database rather than recounted by the CLI. - Human output is styled and column-aligned; colour auto-disables when piping or under `NO_COLOR`. `--color=always|never` overrides. JSON is unaffected.