diff --git a/README.md b/README.md index b7cb6ee..058196e 100644 --- a/README.md +++ b/README.md @@ -257,8 +257,11 @@ Project rows are marked `(project)` in plain output and carry `"type": the same rule. Trashing a project in Things leaves its to-dos untrashed in the database, so -every view above hides to-dos whose project is in the trash — `trash` and -`logbook` excepted, since those report what the database holds. +every view above hides to-dos whose project is in the trash. A closed project +is one `logbook` row rather than a row plus its contents, and a trashed +project is one `trash` row the same way: the app folds their to-dos into the +project row. Name the project to read them — `things --project ` on a +closed or trashed project returns its contents whatever their status. Filters: @@ -324,7 +327,9 @@ items have no start date, so they can't be date-filtered, and neither can needs the view spelled out: `things today -p "Launch v2" --include-completed`. It shows the items you ticked off in Today that Things still keeps there; `logbook` holds every other closed item, today's closes outside Today included, -so a closed item is in exactly one of the two lists. +so a closed item whose project is still open is in exactly one of the two +lists. One closed inside a project that is itself closed or trashed is in +neither — see the fold described above. Examples: diff --git a/cmd/things/agent.go b/cmd/things/agent.go index 9f78d50..7f7ef9d 100644 --- a/cmd/things/agent.go +++ b/cmd/things/agent.go @@ -36,8 +36,12 @@ func printAgentHint(d *Deps, listed int) error { } // showAgentBrief renders the Markdown brief `things show --agent` prints. A -// project also lists its open to-dos, each with the UUID an agent needs to act -// on it. The to-do UUIDs are deliberately not written to the last-list cache: +// project also lists the to-dos filed under it, each with the UUID an agent +// needs to act on it — the open ones while the project is open, and its whole +// contents once the project is closed or trashed, which is what the catch-all +// view answers for a named project since issue #229. +// +// The to-do UUIDs are deliberately not written to the last-list cache: // the cache backs the numeric refs from the last listing, and a brief is not a // listing. func showAgentBrief(d *Deps, database *db.DB, task *model.Task, items []model.ChecklistItem) error { diff --git a/docs/content/agents.md b/docs/content/agents.md index b05898a..4da0097 100644 --- a/docs/content/agents.md +++ b/docs/content/agents.md @@ -247,11 +247,24 @@ every logbook row is a success. An item you tick off in Today is not in `logbook` yet — Things keeps it under Today until the day rolls over, or until `things log` files it early. Anything closed outside Today goes straight to `logbook`, including today's closes. The -two lists partition the closed items and neither one is a whole day on its own, -so an agent reporting on a day's work sweeps both — `things today +two lists partition the closed items whose project is still open, and neither +one is a whole day on its own, so an agent reporting on a day's work sweeps +both — `things today --include-completed -j` plus `things logbook -j` filtered on `stopDate` — and an agent reporting on history needs `logbook` alone. +A closed project is one `logbook` row, not a row plus its contents, and a +trashed project is one `trash` row the same way — the app folds their to-dos +into the project row and so does the CLI. An agent counting what got done from +`logbook` counts projects once, not once plus every to-do inside them — which +also means the day sweep above reports the project rather than the to-dos +`things complete --yes` closed along with it. To read +the contents, name the project: `things --project -j` on a closed or +trashed project returns its to-dos whatever their status, and +`things show --agent` lists them under `## To-dos` with `[x]`, `[~]` or +`[ ]` on each row. A to-do thrown away out of a project that is itself in the +Trash is reachable nowhere, matching the app. + Some patterns that fall out of this: ```sh diff --git a/docs/content/commands.md b/docs/content/commands.md index b3063a9..a2df9b6 100644 --- a/docs/content/commands.md +++ b/docs/content/commands.md @@ -63,10 +63,22 @@ files the day's closed items straight away. `things today --include-completed` shows the ones still waiting. Everything else closed goes to `logbook` at once, today's closes included: a to-do ticked off in the Inbox, in Anytime, or ahead of its date in Upcoming was never under Today, so nothing holds it back. A -closed item is therefore in exactly one of the two lists at any moment, never -both and never neither. `--include-completed` works on `today` alone; with a +closed item whose project is still open is therefore in exactly one of the two +lists at any moment, never both and never neither; one closed inside a project +that is itself closed or trashed is in neither, for the reason the next +paragraph gives. `--include-completed` works on `today` alone; with a filter, name the view: `things today -p "Launch v2" --include-completed`. +A closed project is one row in `logbook`, not a row plus its contents. The +app folds a closed project's to-dos into the project's own row and lists none +of them separately, and `trash` does the same for a trashed project. To reach +those to-dos, name the project: `things --project ` on a closed or +trashed project returns its contents whatever their status, which is what the +app answers for the same question. A to-do you threw away out of a project is +the exception — it keeps its own `trash` row, because it is in the Trash on +its own account rather than through its project. A to-do thrown away out of a +project that is itself in the Trash is reachable nowhere, as in the app. + `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, diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 8712086..44b3c21 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -199,14 +199,17 @@ const todayScheduled = "t.start = 1 AND t.startBucket IN (0, 1) AND t.startDate // on start/startBucket/startDate and the Logbook rejected them on the day // (issue #230). // -// The parent clause is the last piece of the same argument: ListTasks drops a -// to-do whose project is trashed from every view but trash and logbook, so -// Today cannot be holding one, and the Logbook has to keep it. +// It used to carry a trashed-parent clause of its own, back when trash and +// logbook were exempt from untrashedParent and the Logbook had to keep a +// to-do under a trashed project. Issue #229 made untrashedParent +// unconditional, so the Logbook — the only caller — already sees none of +// those rows and the clause could never change the answer. // -// COALESCE makes the negation null-safe. start and startBucket are nullable -// columns, and a NULL there would leave the AND chain NULL, which "NOT" leaves -// NULL too — dropping the row out of the Logbook by accident. -const heldByToday = todayScheduled + " AND " + stillUnderToday + " AND COALESCE(p.trashed, 0) = 0" +// COALESCE at the call site makes the negation null-safe. start and +// startBucket are nullable columns, and a NULL there would leave the AND chain +// NULL, which "NOT" leaves NULL too — dropping the row out of the Logbook by +// accident. +const heldByToday = todayScheduled + " AND " + stillUnderToday // todayWhere builds the today view's WHERE clause. By default only open tasks // are returned. With includeCompleted, completed/cancelled items are kept while @@ -242,17 +245,36 @@ var viewFilters = map[string]string{ // beside the completed ones, so the view carries status 2 as well as 3 // (issue #210). Callers tell the two apart by `status`, which reads // "cancelled" or "completed" in JSON and prints [~] or [x] in plain output. - // Logbook is the exact complement of what Today keeps under - // --include-completed, so a closed item is in one list or the other and - // never in both: Things moves an item out of Today and into the Logbook at - // the same moment (issue #230). The complement is taken over heldByToday, - // not over the day alone — a closed item Today never held is logged - // straight away, whatever day it closed on. - "logbook": "t.status IN (2, 3) AND t.trashed = 0 AND COALESCE(" + heldByToday + ", 0) = 0 AND " + todoOrProject, + // Over the rows both views can carry, Logbook is the exact complement of + // what Today keeps under --include-completed, so such a closed item is in + // one list or the other and never in both: Things moves an item out of + // Today and into the Logbook at the same moment (issue #230). The + // complement is taken over heldByToday, not over the day alone — a closed + // item Today never held is logged straight away, whatever day it closed + // on. The fold below narrows what "both views can carry" means: a closed + // to-do inside a closed or trashed project is in neither list, and is + // reached by naming the project. + // + // A closed project is one row, not a row plus its contents: the app folds + // the to-dos of a closed project into the project's own Logbook row and + // lists none of them separately. Measured on 10 Sep 2026, the app's Logbook + // held no to-do at all whose parent project was closed, against 328 such + // rows in the CLI (issue #229). COALESCE keeps an unparented row — p.uuid + // NULL — in the view. The trashed-parent half of the fold is the clause + // ListTasks appends for every view. + "logbook": "t.status IN (2, 3) AND t.trashed = 0 AND COALESCE(" + heldByToday + ", 0) = 0 AND COALESCE(p.status, 0) NOT IN (2, 3) AND " + todoOrProject, // Trash carries projects as well as to-dos: trashing a project in the // app puts the project row itself in Trash, and `things projects` filters // trashed rows, so pinning t.type = 0 here left a trashed project visible // nowhere (issue #212). + // + // Trash folds a trashed project's children into its row, the way the + // Logbook folds a closed project's, and that fold is the trashed-parent + // clause ListTasks appends. It deliberately does not fold a *closed* + // project's children: the app's Trash held 23 to-dos whose parent project + // was closed but not trashed, and none whose parent was trashed. Throwing + // away a to-do out of a finished project is an ordinary thing to do, and + // the project is not in Trash to fold it into (issue #229). "trash": "t.trashed = 1 AND " + todoOrProject, // Deadlines carries projects too: a project takes a deadline exactly as a // to-do does, `things projects` reports it, and agents.md advertises this @@ -271,11 +293,12 @@ var viewFilters = map[string]string{ // (issue #165). "repeating": repeatingPlaceholder + " IS NOT NULL AND t.status = 0 AND t.trashed = 0 AND " + todoOrProject, // The catch-all open set: also the default view for a bare --project/ - // --area/--tag filter. It carries projects for the same reason the named - // views do — `things --area Work` is a sweep of that area, and the area's - // own projects are part of what the app shows there (issue #222). A - // --project filter still returns no project rows: a project has no parent - // project of its own, so p.uuid never matches. + // --area/--tag filter. `things --project X` on a closed or trashed project + // widens past "open" — see closedProjectContents. It carries projects for + // the same reason the named views do — `things --area Work` is a sweep of + // that area, and the area's own projects are part of what the app shows + // there (issue #222). A --project filter still returns no project rows: a + // project has no parent project of its own, so p.uuid never matches. "project": "t.status = 0 AND t.trashed = 0 AND " + todoOrProject, } @@ -340,16 +363,17 @@ var viewOrderBy = map[string]string{ // it. const indexOrderBy = `ORDER BY t."index" ASC` + uuidTiebreak -// viewsIncludingTrashedProjects lists the views that keep to-dos whose project -// is in the trash. Trashing a project in Things leaves its child rows at -// trashed = 0, so without a guard they outlive the project and go on listing -// as ordinary open tasks (issue #155, the same class as #142/#143). trash and -// logbook are the exceptions: they report what the database holds, and a to-do -// whose project was trashed belongs in trash. -var viewsIncludingTrashedProjects = map[string]bool{ - "trash": true, - "logbook": true, -} +// untrashedParent excludes to-dos whose project is in the trash, in every +// view. Trashing a project in Things leaves its child rows at trashed = 0, so +// without this they outlive the project and go on listing as ordinary open +// tasks (issue #155, the same class as #142/#143). +// +// trash and logbook used to be exempt, on the argument that they report what +// the database holds. The app disagrees: measured on 10 Sep 2026 it showed no +// such row in either list, folding those to-dos into the trashed project's own +// row instead — 77 rows in the CLI's logbook and 4 in its trash (issue #229). +// With the exemption gone the clause is unconditional. +const untrashedParent = "COALESCE(p.trashed, 0) = 0" // viewsIncludingTemplates lists the views that keep repeating templates in // their results. Everywhere else templates are filtered out: Things files a @@ -368,6 +392,34 @@ func ValidView(name string) bool { return ok } +// closedProjectContents is the catch-all view's WHERE clause when --project +// names a project that is itself closed or trashed. Asking for such a project's +// contents and getting nothing back is the wrong answer: since issue #229 the +// Logbook and Trash fold a closed or trashed project's to-dos into the project +// row, so naming the project is the only way left to reach them, and the +// catch-all view's "t.status = 0" would return none. +// +// It is what the app answers. `to dos of project id ` returned +// 78 for a project holding 65 completed, 13 cancelled and 3 trashed children, +// and 49 for one holding 42 and 7 — so the status pin drops and t.trashed = 0 +// stays. A trashed child of a closed project is in Trash on its own account +// and is not part of the project's contents. +// +// The parent test is evaluated against the named project because --project +// constrains p to it. When that project is open the clause reduces to the +// ordinary open set, so `things --project ` is unchanged. +const closedProjectContents = "(" + parentClosedOrTrashed + " OR t.status = 0) AND t.trashed = 0 AND " + todoOrProject + +// parentClosedOrTrashed is true for a row whose parent project has been closed +// or thrown away. p is resolved through COALESCE(t.project, h.project), so a +// to-do filed under a project heading is judged by its heading's project. +// +// Both halves COALESCE so an unparented row — p.uuid NULL — reads false rather +// than NULL. A bare "p.status IN (2, 3)" would be NULL there, and NULL OR +// false is NULL, which would drop every closed unparented row out of any +// caller that ORs this with a status test. +const parentClosedOrTrashed = "(COALESCE(p.status, 0) IN (2, 3) OR COALESCE(p.trashed, 0) = 1)" + func (d *DB) ListTasks(view string, opts TaskFilter) ([]model.Task, error) { where, ok := viewFilters[view] if !ok { @@ -376,8 +428,14 @@ func (d *DB) ListTasks(view string, opts TaskFilter) ([]model.Task, error) { if view == "today" && opts.IncludeCompleted { where = todayWhere(true) } - if !viewsIncludingTrashedProjects[view] { - where += " AND COALESCE(p.trashed, 0) = 0" + // Naming a closed or trashed project asks for its contents, so the + // catch-all view widens past the open set and past the trashed-parent + // guard, which would otherwise strip exactly the rows being asked for. + contentsOfClosedProject := view == "project" && opts.Project != "" + if contentsOfClosedProject { + where = closedProjectContents + } else { + where += " AND " + untrashedParent } if !viewsIncludingTemplates[view] { // The template row itself, which carries the recurrence rule. diff --git a/internal/db/tasks_test.go b/internal/db/tasks_test.go index 25787fb..888cb80 100644 --- a/internal/db/tasks_test.go +++ b/internal/db/tasks_test.go @@ -340,7 +340,7 @@ func TestLogbookKeepsItemsClosedTodayOutsideToday(t *testing.T) { // Trashing a project leaves its children at trashed = 0, and every view but // trash and logbook drops them, so the Logbook is the only list that can hold // a to-do closed today under a trashed project (issue #230). -func TestLogbookKeepsClosedTodayUnderTrashedProject(t *testing.T) { +func TestClosedTodayUnderTrashedProjectIsReachable(t *testing.T) { d := newTestDB(t) today := int64(model.ThingsDateFromTime(time.Now())) mustExec(t, d, `INSERT INTO TMTask (uuid, title, type, status, trashed, "index") @@ -357,12 +357,22 @@ func TestLogbookKeepsClosedTodayUnderTrashedProject(t *testing.T) { if len(inToday) != 0 { t.Errorf("today = %v, want empty — the parent is trashed", uuidsOf(inToday)) } + // It is not in the logbook either: issue #229 folds a trashed project's + // to-dos into the project's Trash row. What issue #230 needs is that the + // row has somewhere to be, and naming the project is where. logged, err := d.ListTasks("logbook", TaskFilter{}) if err != nil { t.Fatal(err) } - if !sameSet([]string{"t-closed"}, uuidsOf(logged)) { - t.Errorf("logbook = %v, want {t-closed}", uuidsOf(logged)) + if len(logged) != 0 { + t.Errorf("logbook = %v, want empty — the parent is trashed", uuidsOf(logged)) + } + contents, err := d.ListTasks("project", TaskFilter{Project: "proj-binned"}) + if err != nil { + t.Fatal(err) + } + if !sameSet([]string{"t-closed"}, uuidsOf(contents)) { + t.Errorf("--project proj-binned = %v, want {t-closed}", uuidsOf(contents)) } } @@ -938,10 +948,11 @@ func TestListTasksExcludesTrashedProjectThroughHeading(t *testing.T) { } } -// trash and logbook report what the database holds rather than what Things -// would show as actionable, so they keep the children of a trashed project. -// Without this the guard would silently swallow them. -func TestTrashAndLogbookKeepTrashedProjectChildren(t *testing.T) { +// A trashed project is one row in Trash, not a row plus its contents: the app +// folds its to-dos into the project's row and lists none of them separately, +// in trash or in logbook (issue #229). Naming the project is what returns +// them, so nothing here is swallowed — it is reached a different way. +func TestTrashAndLogbookFoldTrashedProjectChildren(t *testing.T) { d := newTestDB(t) mustExec(t, d, `INSERT INTO TMTask (uuid, title, type, status, trashed, "index") VALUES @@ -951,14 +962,15 @@ func TestTrashAndLogbookKeepTrashedProjectChildren(t *testing.T) { ('t-binned', 'Trashed child', 0, 0, 1, 1, 0, 'proj-gone', 1), ('t-logged', 'Completed child', 0, 3, 0, 1, 0, 'proj-gone', 2)`) - // trash also carries the trashed project row itself now (issue #212); - // logbook does not, because a trashed row is not logged. + // trash carries the trashed project row itself (issue #212); logbook + // carries nothing here, because a trashed row is not logged and the + // project's children are folded into its Trash row. for _, tc := range []struct { view string want []string }{ - {"trash", []string{"proj-gone", "t-binned"}}, - {"logbook", []string{"t-logged"}}, + {"trash", []string{"proj-gone"}}, + {"logbook", nil}, } { got, err := d.ListTasks(tc.view, TaskFilter{}) if err != nil { @@ -968,6 +980,124 @@ func TestTrashAndLogbookKeepTrashedProjectChildren(t *testing.T) { t.Errorf("view %q: got %v, want %v", tc.view, uuidsOf(got), tc.want) } } + + // The folded child is reachable by naming the project. The trashed child + // is not, and matches the app: asking Things for a trashed project's + // contents returns nothing for a row already in the Trash on its own + // account. + contents, err := d.ListTasks("project", TaskFilter{Project: "proj-gone"}) + if err != nil { + t.Fatal(err) + } + if !sameSet(uuidsOf(contents), []string{"t-logged"}) { + t.Errorf("--project proj-gone: got %v, want [t-logged]", uuidsOf(contents)) + } +} + +// seedClosedProjectContents builds the shape issue #229 is about: a closed +// project and a trashed one, each holding children in several states, plus an +// open project as the control. +func seedClosedProjectContents(t *testing.T, d *DB) { + t.Helper() + + stop := model.TimeToUnix(time.Now().Add(-26 * time.Hour)) + mustExec(t, d, `INSERT INTO TMTask (uuid, title, type, status, trashed, stopDate, "index") VALUES + ('proj-done', 'Finished', 1, 3, 0, ?, 1), + ('proj-binned', 'Binned', 1, 0, 1, NULL, 2), + ('proj-open', 'Live', 1, 0, 0, NULL, 3)`, stop) + + mustExec(t, d, `INSERT INTO TMTask + (uuid, title, type, status, trashed, start, startBucket, stopDate, project, "index") VALUES + ('done-completed', 'Shipped', 0, 3, 0, 1, 0, ?, 'proj-done', 4), + ('done-cancelled', 'Dropped', 0, 2, 0, 1, 0, ?, 'proj-done', 5), + ('done-trashed', 'Binned', 0, 0, 1, 1, 0, NULL, 'proj-done', 6), + ('done-open', 'Left over', 0, 0, 0, 1, 0, NULL, 'proj-done', 7), + ('binned-logged', 'Logged', 0, 3, 0, 1, 0, ?, 'proj-binned', 8), + ('open-todo', 'To do', 0, 0, 0, 1, 0, NULL, 'proj-open', 9)`, + stop, stop, stop) +} + +// The app folds a closed project's to-dos into the project's own Logbook row +// and lists none of them separately, so the CLI does too (issue #229). Trash +// is not the same case: a to-do thrown away out of a finished project is in +// Trash on its own account, and the project is not there to fold it into. +func TestLogbookFoldsClosedProjectChildren(t *testing.T) { + d := newTestDB(t) + seedClosedProjectContents(t, d) + + logged, err := d.ListTasks("logbook", TaskFilter{}) + if err != nil { + t.Fatal(err) + } + // Only the closed project itself. Its completed and cancelled children are + // folded into it, and binned-logged is folded into the trashed project. + if !sameSet(uuidsOf(logged), []string{"proj-done"}) { + t.Errorf("logbook: got %v, want [proj-done]", uuidsOf(logged)) + } + + binned, err := d.ListTasks("trash", TaskFilter{}) + if err != nil { + t.Fatal(err) + } + // done-trashed keeps its Trash row even though its project is closed. + if !sameSet(uuidsOf(binned), []string{"proj-binned", "done-trashed"}) { + t.Errorf("trash: got %v, want [proj-binned done-trashed]", uuidsOf(binned)) + } +} + +// Naming a closed or trashed project returns its contents whatever their +// status — otherwise the rows the Logbook and Trash now fold away would be +// reachable nowhere. It is what the app answers for `to dos of project id`: +// the closed and cancelled children, and not the trashed one, which is in the +// Trash on its own account (issue #229). +func TestProjectFilterReturnsClosedProjectContents(t *testing.T) { + d := newTestDB(t) + seedClosedProjectContents(t, d) + + cases := []struct { + name string + project string + want []string + }{ + {"closed project", "proj-done", []string{"done-completed", "done-cancelled", "done-open"}}, + {"closed project by title", "Finished", []string{"done-completed", "done-cancelled", "done-open"}}, + {"trashed project", "proj-binned", []string{"binned-logged"}}, + // The control: an open project is unchanged, still open rows only. + {"open project", "proj-open", []string{"open-todo"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := d.ListTasks("project", TaskFilter{Project: tc.project}) + if err != nil { + t.Fatal(err) + } + if !sameSet(uuidsOf(got), tc.want) { + t.Errorf("--project %s: got %v, want %v", tc.project, uuidsOf(got), tc.want) + } + }) + } +} + +// The widening is scoped to a named project. A bare --area sweep is still the +// open set: it must not start returning the closed contents of every closed +// project in the area. +func TestAreaFilterDoesNotWidenToClosedContents(t *testing.T) { + d := newTestDB(t) + seedClosedProjectContents(t, d) + mustExec(t, d, `INSERT INTO TMArea (uuid, title, visible, "index") VALUES ('ar', 'Work', 1, 1)`) + mustExec(t, d, `UPDATE TMTask SET area = 'ar' WHERE uuid IN ('proj-done', 'proj-open')`) + + got, err := d.ListTasks("project", TaskFilter{Area: "ar"}) + if err != nil { + t.Fatal(err) + } + // The open project, its open to-do, and the closed project's one open + // child. Nothing closed, and no row of the closed project's contents. + want := []string{"proj-open", "open-todo", "done-open"} + if !sameSet(uuidsOf(got), want) { + t.Errorf("--area ar: got %v, want %v", uuidsOf(got), want) + } } // A filter spanning several projects must keep each project's tasks contiguous, diff --git a/internal/output/agent.go b/internal/output/agent.go index e3ca5bb..336b779 100644 --- a/internal/output/agent.go +++ b/internal/output/agent.go @@ -9,8 +9,10 @@ import ( ) // AgentBrief is the material `things show --agent` renders: the item -// itself, its checklist, and — when the item is a project — the open to-dos -// filed under it. +// itself, its checklist, and — when the item is a project — the to-dos filed +// under it. For an open project those are its open to-dos; for a closed or +// trashed one they are its contents whatever their status, since issue #229 +// made naming the project the only way to reach them. type AgentBrief struct { Task *model.Task Checklist []model.ChecklistItem @@ -70,12 +72,24 @@ func PrintAgentBrief(w io.Writer, b AgentBrief) error { } if t.Type == model.TypeProject { - s.WriteString("\n## Open to-dos\n\n") + // A closed or trashed project has no open to-dos by definition, and + // since issue #229 the listing returns its contents whatever their + // status — so "Open to-dos" would be a lie and a bare title would read + // as something still to do. Mark each row instead. + heading, empty, marked := "Open to-dos", "no open to-dos", false + if t.Status != model.StatusOpen || t.Trashed { + heading, empty, marked = "To-dos", "no to-dos", true + } + fmt.Fprintf(&s, "\n## %s\n\n", heading) if len(b.Todos) == 0 { - s.WriteString("None — the project has no open to-dos.\n") + fmt.Fprintf(&s, "None — the project has %s.\n", empty) } for _, todo := range b.Todos { - fmt.Fprintf(&s, "- %s — `%s`\n", singleLine(todo.Title), todo.UUID) + mark := "" + if marked { + mark = statusIcon(todo.Status) + " " + } + fmt.Fprintf(&s, "- %s%s — `%s`\n", mark, singleLine(todo.Title), todo.UUID) } } diff --git a/internal/output/agent_test.go b/internal/output/agent_test.go index a8a41e1..1354e51 100644 --- a/internal/output/agent_test.go +++ b/internal/output/agent_test.go @@ -203,6 +203,51 @@ func TestPrintAgentBriefProjectWithNoTodos(t *testing.T) { } } +// A closed project's brief lists its contents whatever their status since +// issue #229, so "Open to-dos" would be a lie and a bare title would read as +// something still to do. The heading drops "Open" and each row is marked. +func TestPrintAgentBriefClosedProjectMarksTodoStatus(t *testing.T) { + for _, tc := range []struct { + name string + project *model.Task + }{ + {"completed", &model.Task{UUID: "proj-uuid", Title: "Launch v2", Type: model.TypeProject, Status: model.StatusCompleted}}, + {"cancelled", &model.Task{UUID: "proj-uuid", Title: "Launch v2", Type: model.TypeProject, Status: model.StatusCancelled}}, + {"trashed", &model.Task{UUID: "proj-uuid", Title: "Launch v2", Type: model.TypeProject, Status: model.StatusOpen, Trashed: true}}, + } { + t.Run(tc.name, func(t *testing.T) { + todos := []model.Task{ + {UUID: "todo-1", Title: "Cut RC build", Status: model.StatusCompleted}, + {UUID: "todo-2", Title: "Write notes", Status: model.StatusCancelled}, + {UUID: "todo-3", Title: "Left over", Status: model.StatusOpen}, + } + got := briefText(t, AgentBrief{Task: tc.project, Todos: todos}) + + for _, want := range []string{ + "## To-dos\n", + "- [x] Cut RC build — `todo-1`", + "- [~] Write notes — `todo-2`", + "- [ ] Left over — `todo-3`", + } { + if !strings.Contains(got, want) { + t.Errorf("closed project brief does not contain %q\n%s", want, got) + } + } + if strings.Contains(got, "## Open to-dos") { + t.Errorf("closed project brief still calls its contents open\n%s", got) + } + }) + } +} + +func TestPrintAgentBriefClosedProjectWithNoTodos(t *testing.T) { + project := &model.Task{UUID: "proj-uuid", Title: "Launch v2", Type: model.TypeProject, Status: model.StatusCompleted} + got := briefText(t, AgentBrief{Task: project}) + if !strings.Contains(got, "None — the project has no to-dos.") { + t.Errorf("closed project brief does not say it is empty\n%s", got) + } +} + // A title carrying a newline would otherwise break out of the heading or the // list item it is rendered into. func TestPrintAgentBriefFoldsMultilineTitles(t *testing.T) { diff --git a/internal/skill/SKILL.md b/internal/skill/SKILL.md index 1e92dee..61c1615 100644 --- a/internal/skill/SKILL.md +++ b/internal/skill/SKILL.md @@ -158,7 +158,12 @@ things list [view] [--project P] [--area A] [--tag T] [--on D | --from D --to D] # Tasks under a project heading belong to that project — they match # --project and the project's --area, and report projectTitle. # Trashing a project leaves its to-dos untrashed in the database; every - # view hides them anyway, except trash and logbook. + # view hides them anyway. A closed project is one logbook row and a trashed + # one is a single trash row — their to-dos are folded into the project row, + # not listed separately. To read them, name the project: --project on + # a closed or trashed project returns its contents whatever their status, + # and `things show --agent` marks each row [x]/[~]/[ ]. A to-do + # thrown away out of a trashed project is reachable nowhere, as in the app. # every named view except inbox lists projects as rows too, since Things # schedules a project the same way it schedules a to-do and shows the # project itself in those lists: scheduled in today/upcoming/anytime, @@ -176,9 +181,11 @@ things list [view] [--project P] [--area A] [--tag T] [--on D | --from D --to D] # unsupported on inbox/trash/logbook/someday/repeating. --on excludes --from/--to. # --include-completed is today-only: items ticked off in Today that Things # hasn't logged out yet. logbook holds every other closed item, including - # things closed today from Inbox/Anytime/Upcoming, so a closed item is in - # exactly one of the two lists — for a whole day's closes sweep both and - # filter logbook on stopDate. + # things closed today from Inbox/Anytime/Upcoming, so a closed item whose + # project is still open is in exactly one of the two lists — for a whole + # day's closes sweep both and filter logbook on stopDate. One closed inside + # a project that is itself closed or trashed is in neither: it is folded + # into the project row, per the note above. things show [--agent] # detail; --agent prints a Markdown brief (see below) things projects [-a|--area A] [--completed]