Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down
7 changes: 7 additions & 0 deletions cmd/things/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions cmd/things/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions docs/content/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/content/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion internal/db/repeating_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"},
}
Expand Down
28 changes: 27 additions & 1 deletion internal/db/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
158 changes: 158 additions & 0 deletions internal/db/tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
1 change: 1 addition & 0 deletions internal/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading