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.