things-api 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +31 -11
  2. package/dist/audit/log.js +56 -2
  3. package/dist/audit/log.js.map +1 -1
  4. package/dist/audit/schema.d.ts +26 -8
  5. package/dist/audit/schema.js +42 -1
  6. package/dist/audit/schema.js.map +1 -1
  7. package/dist/cli/commands/area.d.ts +34 -2
  8. package/dist/cli/commands/area.js +140 -28
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -1
  11. package/dist/cli/commands/doctor.js +44 -5
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/mcp.js +23 -4
  14. package/dist/cli/commands/mcp.js.map +1 -1
  15. package/dist/cli/commands/project.d.ts +23 -1
  16. package/dist/cli/commands/project.js +82 -15
  17. package/dist/cli/commands/project.js.map +1 -1
  18. package/dist/cli/commands/reads.d.ts +6 -125
  19. package/dist/cli/commands/reads.js +649 -657
  20. package/dist/cli/commands/reads.js.map +1 -1
  21. package/dist/cli/commands/repeat-flags.d.ts +20 -0
  22. package/dist/cli/commands/repeat-flags.js +76 -0
  23. package/dist/cli/commands/repeat-flags.js.map +1 -0
  24. package/dist/cli/commands/setup.js +2 -3
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/show.d.ts +1 -1
  27. package/dist/cli/commands/show.js +169 -18
  28. package/dist/cli/commands/show.js.map +1 -1
  29. package/dist/cli/commands/snapshot.js +1 -1
  30. package/dist/cli/commands/snapshot.js.map +1 -1
  31. package/dist/cli/commands/todo.d.ts +1 -1
  32. package/dist/cli/commands/todo.js +21 -9
  33. package/dist/cli/commands/todo.js.map +1 -1
  34. package/dist/cli/commands/writes.js +378 -148
  35. package/dist/cli/commands/writes.js.map +1 -1
  36. package/dist/cli/did-you-mean.d.ts +29 -0
  37. package/dist/cli/did-you-mean.js +53 -0
  38. package/dist/cli/did-you-mean.js.map +1 -0
  39. package/dist/cli/excess-args.d.ts +15 -0
  40. package/dist/cli/excess-args.js +51 -0
  41. package/dist/cli/excess-args.js.map +1 -0
  42. package/dist/cli/glyphs.d.ts +67 -7
  43. package/dist/cli/glyphs.js +202 -19
  44. package/dist/cli/glyphs.js.map +1 -1
  45. package/dist/cli/help.d.ts +54 -0
  46. package/dist/cli/help.js +355 -0
  47. package/dist/cli/help.js.map +1 -0
  48. package/dist/cli/main.js +36 -21
  49. package/dist/cli/main.js.map +1 -1
  50. package/dist/cli/period.d.ts +39 -0
  51. package/dist/cli/period.js +141 -0
  52. package/dist/cli/period.js.map +1 -0
  53. package/dist/cli/read-driver.d.ts +83 -0
  54. package/dist/cli/read-driver.js +215 -0
  55. package/dist/cli/read-driver.js.map +1 -0
  56. package/dist/cli/render.d.ts +246 -0
  57. package/dist/cli/render.js +806 -0
  58. package/dist/cli/render.js.map +1 -0
  59. package/dist/cli/resolve-invocation.d.ts +116 -0
  60. package/dist/cli/resolve-invocation.js +261 -0
  61. package/dist/cli/resolve-invocation.js.map +1 -0
  62. package/dist/cli/shell-quote.d.ts +9 -0
  63. package/dist/cli/shell-quote.js +12 -0
  64. package/dist/cli/shell-quote.js.map +1 -0
  65. package/dist/cli/tag-filters.d.ts +59 -0
  66. package/dist/cli/tag-filters.js +57 -0
  67. package/dist/cli/tag-filters.js.map +1 -0
  68. package/dist/cli/verb-hint.d.ts +25 -0
  69. package/dist/cli/verb-hint.js +138 -0
  70. package/dist/cli/verb-hint.js.map +1 -0
  71. package/dist/cli/width.d.ts +135 -0
  72. package/dist/cli/width.js +313 -0
  73. package/dist/cli/width.js.map +1 -0
  74. package/dist/client.d.ts +146 -24
  75. package/dist/client.js +106 -16
  76. package/dist/client.js.map +1 -1
  77. package/dist/config.d.ts +11 -0
  78. package/dist/config.js +3 -0
  79. package/dist/config.js.map +1 -1
  80. package/dist/contracts.d.ts +136 -1
  81. package/dist/contracts.js +44 -1
  82. package/dist/contracts.js.map +1 -1
  83. package/dist/db/fingerprint.d.ts +12 -0
  84. package/dist/db/fingerprint.js +15 -1
  85. package/dist/db/fingerprint.js.map +1 -1
  86. package/dist/db/locate.js +1 -1
  87. package/dist/db/locate.js.map +1 -1
  88. package/dist/diagnose.d.ts +63 -0
  89. package/dist/diagnose.js +38 -1
  90. package/dist/diagnose.js.map +1 -1
  91. package/dist/index.d.ts +30 -4
  92. package/dist/index.js +35 -2
  93. package/dist/index.js.map +1 -1
  94. package/dist/mcp/server.d.ts +9 -1
  95. package/dist/mcp/server.js +850 -128
  96. package/dist/mcp/server.js.map +1 -1
  97. package/dist/model/entities.d.ts +40 -7
  98. package/dist/model/entities.js.map +1 -1
  99. package/dist/model/mappers.d.ts +7 -0
  100. package/dist/model/mappers.js +20 -3
  101. package/dist/model/mappers.js.map +1 -1
  102. package/dist/model/occurrences.d.ts +6 -1
  103. package/dist/model/occurrences.js +9 -5
  104. package/dist/model/occurrences.js.map +1 -1
  105. package/dist/model/recurrence.d.ts +17 -8
  106. package/dist/model/recurrence.js.map +1 -1
  107. package/dist/model/serialize.d.ts +28 -0
  108. package/dist/model/serialize.js +80 -0
  109. package/dist/model/serialize.js.map +1 -0
  110. package/dist/model/when-sugar.d.ts +47 -0
  111. package/dist/model/when-sugar.js +45 -0
  112. package/dist/model/when-sugar.js.map +1 -0
  113. package/dist/read/area-view.d.ts +8 -1
  114. package/dist/read/area-view.js +49 -13
  115. package/dist/read/area-view.js.map +1 -1
  116. package/dist/read/caps.d.ts +14 -0
  117. package/dist/read/caps.js +21 -0
  118. package/dist/read/caps.js.map +1 -0
  119. package/dist/read/filter-contract.d.ts +117 -0
  120. package/dist/read/filter-contract.js +78 -0
  121. package/dist/read/filter-contract.js.map +1 -0
  122. package/dist/read/log-boundary.d.ts +9 -4
  123. package/dist/read/log-boundary.js.map +1 -1
  124. package/dist/read/predicates.d.ts +61 -0
  125. package/dist/read/predicates.js +78 -0
  126. package/dist/read/predicates.js.map +1 -0
  127. package/dist/read/project-view.d.ts +12 -1
  128. package/dist/read/project-view.js +39 -8
  129. package/dist/read/project-view.js.map +1 -1
  130. package/dist/read/queries.d.ts +156 -8
  131. package/dist/read/queries.js +261 -38
  132. package/dist/read/queries.js.map +1 -1
  133. package/dist/read/search-rank.d.ts +36 -0
  134. package/dist/read/search-rank.js +31 -0
  135. package/dist/read/search-rank.js.map +1 -0
  136. package/dist/read/sections.d.ts +51 -0
  137. package/dist/read/sections.js +37 -0
  138. package/dist/read/sections.js.map +1 -0
  139. package/dist/read/show-target.d.ts +13 -4
  140. package/dist/read/show-target.js +11 -2
  141. package/dist/read/show-target.js.map +1 -1
  142. package/dist/read/sidebar-order.d.ts +19 -0
  143. package/dist/read/sidebar-order.js +93 -0
  144. package/dist/read/sidebar-order.js.map +1 -0
  145. package/dist/read/tags.d.ts +27 -3
  146. package/dist/read/tags.js +83 -12
  147. package/dist/read/tags.js.map +1 -1
  148. package/dist/read/truncation.d.ts +71 -0
  149. package/dist/read/truncation.js +217 -0
  150. package/dist/read/truncation.js.map +1 -0
  151. package/dist/read/views.d.ts +177 -19
  152. package/dist/read/views.js +390 -213
  153. package/dist/read/views.js.map +1 -1
  154. package/dist/surface-copy.d.ts +42 -0
  155. package/dist/surface-copy.js +55 -0
  156. package/dist/surface-copy.js.map +1 -1
  157. package/dist/sync-health.d.ts +78 -0
  158. package/dist/sync-health.js +312 -0
  159. package/dist/sync-health.js.map +1 -0
  160. package/dist/write/accessibility-probe.d.ts +12 -0
  161. package/dist/write/accessibility-probe.js +63 -0
  162. package/dist/write/accessibility-probe.js.map +1 -0
  163. package/dist/write/automation-probe.d.ts +7 -0
  164. package/dist/write/automation-probe.js +8 -1
  165. package/dist/write/automation-probe.js.map +1 -1
  166. package/dist/write/batch.js +4 -2
  167. package/dist/write/batch.js.map +1 -1
  168. package/dist/write/capabilities.d.ts +8 -0
  169. package/dist/write/capabilities.js +13 -5
  170. package/dist/write/capabilities.js.map +1 -1
  171. package/dist/write/commands.d.ts +2 -0
  172. package/dist/write/commands.js +562 -48
  173. package/dist/write/commands.js.map +1 -1
  174. package/dist/write/edit-checklist.js +3 -2
  175. package/dist/write/edit-checklist.js.map +1 -1
  176. package/dist/write/guards.d.ts +2 -7
  177. package/dist/write/guards.js +64 -6
  178. package/dist/write/guards.js.map +1 -1
  179. package/dist/write/heading.js +2 -0
  180. package/dist/write/heading.js.map +1 -1
  181. package/dist/write/lock.d.ts +40 -2
  182. package/dist/write/lock.js +91 -14
  183. package/dist/write/lock.js.map +1 -1
  184. package/dist/write/make-repeating-project.d.ts +4 -0
  185. package/dist/write/make-repeating-project.js +253 -0
  186. package/dist/write/make-repeating-project.js.map +1 -0
  187. package/dist/write/operations.d.ts +145 -1
  188. package/dist/write/operations.js +48 -0
  189. package/dist/write/operations.js.map +1 -1
  190. package/dist/write/pipeline.d.ts +19 -0
  191. package/dist/write/pipeline.js +109 -26
  192. package/dist/write/pipeline.js.map +1 -1
  193. package/dist/write/pre-state.d.ts +68 -3
  194. package/dist/write/pre-state.js +102 -3
  195. package/dist/write/pre-state.js.map +1 -1
  196. package/dist/write/reopen.js +3 -2
  197. package/dist/write/reopen.js.map +1 -1
  198. package/dist/write/reorder.js +27 -17
  199. package/dist/write/reorder.js.map +1 -1
  200. package/dist/write/repeat-rule.d.ts +22 -0
  201. package/dist/write/repeat-rule.js +258 -0
  202. package/dist/write/repeat-rule.js.map +1 -0
  203. package/dist/write/reversibility.js +49 -0
  204. package/dist/write/reversibility.js.map +1 -1
  205. package/dist/write/tag-refs.d.ts +47 -0
  206. package/dist/write/tag-refs.js +126 -0
  207. package/dist/write/tag-refs.js.map +1 -0
  208. package/dist/write/undo.d.ts +74 -5
  209. package/dist/write/undo.js +494 -80
  210. package/dist/write/undo.js.map +1 -1
  211. package/dist/write/vectors/registry.d.ts +17 -1
  212. package/dist/write/vectors/registry.js +18 -2
  213. package/dist/write/vectors/registry.js.map +1 -1
  214. package/dist/write/vectors/types.d.ts +119 -3
  215. package/dist/write/vectors/ui-certification.d.ts +48 -0
  216. package/dist/write/vectors/ui-certification.js +46 -0
  217. package/dist/write/vectors/ui-certification.js.map +1 -0
  218. package/dist/write/vectors/ui-drag.d.ts +188 -0
  219. package/dist/write/vectors/ui-drag.js +1095 -0
  220. package/dist/write/vectors/ui-drag.js.map +1 -0
  221. package/dist/write/vectors/ui-recipes.d.ts +70 -0
  222. package/dist/write/vectors/ui-recipes.js +558 -0
  223. package/dist/write/vectors/ui-recipes.js.map +1 -0
  224. package/dist/write/vectors/ui.d.ts +137 -0
  225. package/dist/write/vectors/ui.js +656 -0
  226. package/dist/write/vectors/ui.js.map +1 -0
  227. package/dist/write/verify/delta.d.ts +31 -3
  228. package/dist/write/verify/delta.js +22 -6
  229. package/dist/write/verify/delta.js.map +1 -1
  230. package/dist/write/verify/poller.js +1 -0
  231. package/dist/write/verify/poller.js.map +1 -1
  232. package/package.json +1 -1
  233. package/dist/cli/exit-codes.d.ts +0 -26
  234. package/dist/cli/exit-codes.js +0 -26
  235. package/dist/cli/exit-codes.js.map +0 -1
  236. package/dist/cli/output.d.ts +0 -42
  237. package/dist/cli/output.js +0 -16
  238. package/dist/cli/output.js.map +0 -1
@@ -3,17 +3,84 @@ import { logBoundary, markLogged } from "./log-boundary.js";
3
3
  import { mapProject, mapTodo } from "../model/mappers.js";
4
4
  import { projectOccurrences } from "../model/occurrences.js";
5
5
  import { decodeRecurrenceRule } from "../model/recurrence.js";
6
- import { fetchTagsForTasks, fetchTaskRows, makeHeadingProjectResolver, makeRefResolver, NOT_TEMPLATE, resolveAreaUuid, resolveProjectUuid, resolveTagUuid, tagScopeBinds, tagScopeSql, tagWithDescendants, } from "./queries.js";
7
- function tagFilter(db, filter) {
8
- if (filter?.tag === undefined)
6
+ import { directTagScopeSql, directUntaggedScopeSql, fetchTagsForTasks, fetchTaskRows, makeHeadingProjectResolver, makeRefResolver, NOT_TEMPLATE, resolveAreaUuid, resolveProjectUuid, resolveTagUuid, tagScopeBinds, tagScopeSql, tagWithDescendants, untaggedScopeSql, } from "./queries.js";
7
+ import { ANYTIME_SELF, CONTAINER_UNTRASHED, EFF_PROJECT, LIVE, OPEN, OPEN_OR_UNSWEPT, OVERDUE, PROJECT_ANYTIME_ACTIVE, } from "./predicates.js";
8
+ import { groupBySidebar } from "./sidebar-order.js";
9
+ import { areasView } from "./tags.js";
10
+ import { compareSearchMatches } from "./search-rank.js";
11
+ /**
12
+ * Escape a user term for a SQL LIKE pattern so `%` and `_` match LITERALLY,
13
+ * not as wildcards (search "my_task" must not match "myXtask"; "50%" must not
14
+ * match "50"-anything). Backslash is the escape character — escape it first,
15
+ * then the two wildcard metacharacters. Every LIKE fed an escaped term must
16
+ * append `ESCAPE '\'` (see {@link LIKE_ESCAPE}).
17
+ */
18
+ function escapeLike(term) {
19
+ return term.replace(/[\\%_]/g, (c) => `\\${c}`);
20
+ }
21
+ /** SQL clause suffix pairing an escaped LIKE pattern with its escape char. */
22
+ const LIKE_ESCAPE = " ESCAPE '\\'";
23
+ /**
24
+ * Compose the tag scope for a filter into an AND-chained SQL fragment plus its
25
+ * binds. Every clause AND-combines: the `untagged` negation and each `--tag`
26
+ * ref (each ref independently resolved and descendant-expanded per `exactTag`).
27
+ * The mutually-exclusive combination (`untagged` with a tag-presence flag) is
28
+ * refused at the CLI/MCP surfaces; here it would simply AND to an empty result.
29
+ * Splices in before {@link overdueFilter} so the tag binds precede the overdue
30
+ * bind.
31
+ *
32
+ * `options.container` selects the CONTAINER semantics (`project show` /
33
+ * `area show` / the `projects` list): the view's own inheritance hop is dropped,
34
+ * so `--tag` matches a DIRECT assignment (still descendant-expanded) and
35
+ * `--untagged` means "no direct tag". Every child inherits its container's tags,
36
+ * so an inheritance-inclusive filter there is vacuous — direct-on-the-row is the
37
+ * useful, GUI-faithful behavior. FLAT views omit the option and keep the
38
+ * inheritance-inclusive relation.
39
+ */
40
+ export function tagFilter(db, filter, options) {
41
+ const container = options?.container === true;
42
+ const clauses = [];
43
+ const binds = [];
44
+ if (filter?.untagged === true)
45
+ clauses.push(container ? directUntaggedScopeSql() : untaggedScopeSql());
46
+ const exact = filter?.exactTag === true;
47
+ const tagRefs = [...(filter?.tags ?? []), ...(filter?.tag !== undefined ? [filter.tag] : [])];
48
+ const expand = (ref) => {
49
+ const target = resolveTagUuid(db, ref);
50
+ return exact ? [target] : tagWithDescendants(db, target);
51
+ };
52
+ for (const ref of tagRefs) {
53
+ const uuids = expand(ref);
54
+ if (container) {
55
+ clauses.push(directTagScopeSql(uuids.length));
56
+ binds.push(...uuids);
57
+ }
58
+ else {
59
+ clauses.push(tagScopeSql(uuids.length));
60
+ binds.push(...tagScopeBinds(uuids));
61
+ }
62
+ }
63
+ if (clauses.length === 0)
9
64
  return { sql: "", binds: [] };
10
- const target = resolveTagUuid(db, filter.tag);
11
- const uuids = filter.exactTag === true ? [target] : tagWithDescendants(db, target);
12
- return { sql: ` AND ${tagScopeSql(uuids.length)}`, binds: tagScopeBinds(uuids) };
65
+ return { sql: ` AND ${clauses.join(" AND ")}`, binds };
13
66
  }
14
- const LIVE = `t.type IN (0, 1) AND t.trashed = 0 AND ${NOT_TEMPLATE}`;
15
- const OPEN = `${LIVE} AND t.status = 0`;
16
- function materialize(db, rows) {
67
+ /**
68
+ * The `--overdue` content scope as a spliceable SQL fragment (empty when off).
69
+ * Appended AFTER {@link tagFilter} in every host view so it intersects with the
70
+ * tag scope, and its single packed-today bind trails the tag binds in the same
71
+ * order. `packedToday` is encodePackedDate(localToday(now)) — the caller's
72
+ * injected clock — never a hardcoded date.
73
+ */
74
+ function overdueFilter(filter, packedToday) {
75
+ if (filter?.overdue !== true)
76
+ return { sql: "", binds: [] };
77
+ return { sql: ` AND ${OVERDUE}`, binds: [packedToday] };
78
+ }
79
+ // `boundary` is REQUIRED (no wall-clock default): every caller threads the
80
+ // view's injected `now` via logBoundary(db, now) so the `logged` flag it stamps
81
+ // honors the same clock as the SQL membership filter (a pinned test clock, a
82
+ // lab run) — nothing here silently reaches for real time.
83
+ function materialize(db, rows, boundary) {
17
84
  const refs = makeRefResolver(db);
18
85
  const headingProject = makeHeadingProjectResolver(db);
19
86
  const tags = fetchTagsForTasks(db, rows.map((r) => r.uuid));
@@ -28,12 +95,18 @@ function materialize(db, rows) {
28
95
  }
29
96
  return todo;
30
97
  });
31
- return markLogged(items, logBoundary(db));
98
+ // The caller may pass the boundary it already used for the SQL membership
99
+ // filter so the `logged` flag and the row's presence agree (a checked-unswept
100
+ // row must materialize with logged=false).
101
+ return markLogged(items, boundary);
32
102
  }
103
+ /** Badge = OPEN members only (the GUI badge counts remaining work). */
104
+ const isOpen = (i) => i.status === "open";
33
105
  export function todayView(db, now, filter) {
34
106
  const todayIso = localToday(now);
35
107
  const packedToday = encodePackedDate(todayIso);
36
108
  const tf = tagFilter(db, filter);
109
+ const of = overdueFilter(filter, packedToday);
37
110
  // Membership + comparator per the UI-oracle research (lab, 2026-07-04;
38
111
  // docs/lab/today-order-research.md, two reproducing runs + exact live
39
112
  // reconciliation: 405 − 12 suppressed = 393 = the UI's own count):
@@ -45,180 +118,165 @@ export function todayView(db, now, filter) {
45
118
  // (the date the item ENTERED Today: its startDate, or its deadline for
46
119
  // deadline-driven members), then todayIndex ASC, then uuid (observed
47
120
  // stable tiebreak).
48
- const rows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED} AND (
121
+ // Membership is OPEN_OR_UNSWEPT (not OPEN): a checked row the log-move sweep
122
+ // has not yet passed keeps its comparator slot in place (GUI-parity ruling
123
+ // 2026-07-14). The boundary is threaded into both the SQL filter and
124
+ // materialize so the row's presence and its `logged` flag agree.
125
+ const boundary = logBoundary(db, now);
126
+ const rows = fetchTaskRows(db, `${OPEN_OR_UNSWEPT} AND ${CONTAINER_UNTRASHED} AND (
49
127
  (t.startDate IS NOT NULL AND t.startDate <= ? AND t.start IN (1, 2))
50
128
  OR (t.deadline IS NOT NULL AND t.deadline <= ? AND t.startDate IS NULL
51
129
  AND (t.deadlineSuppressionDate IS NULL OR t.deadlineSuppressionDate < t.deadline))
52
- )${tf.sql}
130
+ )${tf.sql}${of.sql}
53
131
  ORDER BY t.startBucket ASC,
54
132
  COALESCE(t.todayIndexReferenceDate, t.startDate, t.deadline) DESC,
55
- t.todayIndex ASC, t.uuid ASC`, [packedToday, packedToday, ...tf.binds]);
56
- const items = materialize(db, rows);
133
+ t.todayIndex ASC, t.uuid ASC`, [boundary.getTime() / 1000, packedToday, packedToday, ...tf.binds, ...of.binds]);
134
+ const items = materialize(db, rows, boundary);
57
135
  // Evening membership expires daily: raw startBucket=1 counts only while
58
136
  // startDate is exactly today; stale evening items belong to Today proper.
59
137
  const isEvening = (i) => i.todaySection === "evening" && i.startDate === todayIso;
60
- const dueOrOverdue = items.filter((i) => i.deadline !== null && i.deadline <= todayIso).length;
138
+ const evening = items.filter(isEvening);
139
+ const dueIn = (list) => list.filter((i) => i.deadline !== null && i.deadline <= todayIso).length;
140
+ // Badge = OPEN members only (the GUI badge counts remaining work). A
141
+ // checked-but-unswept row is present in the list but never moves the badge —
142
+ // this preserves the exact live reconciliation computed under OPEN membership.
143
+ // The evening filter mirrors the tag filter's badge treatment: the badge
144
+ // reflects exactly the members the view now returns (here, evening only).
145
+ if (filter?.eveningOnly === true) {
146
+ const openEvening = evening.filter(isOpen);
147
+ const eveningDue = dueIn(openEvening);
148
+ return {
149
+ today: [],
150
+ evening,
151
+ badge: { dueOrOverdue: eveningDue, other: openEvening.length - eveningDue },
152
+ };
153
+ }
154
+ const openItems = items.filter(isOpen);
155
+ const dueOrOverdue = dueIn(openItems);
61
156
  return {
62
157
  today: items.filter((i) => !isEvening(i)),
63
- evening: items.filter(isEvening),
64
- badge: { dueOrOverdue, other: items.length - dueOrOverdue },
158
+ evening,
159
+ badge: { dueOrOverdue, other: openItems.length - dueOrOverdue },
65
160
  };
66
161
  }
67
- export function inboxView(db, filter) {
162
+ export function inboxView(db, now, filter) {
68
163
  const tf = tagFilter(db, filter);
69
- const rows = fetchTaskRows(db, `${OPEN} AND t.start = 0${tf.sql} ORDER BY t."index" ASC`, tf.binds);
70
- return materialize(db, rows);
164
+ // `now` is threaded only for the `--overdue` boundary (the inbox order and
165
+ // the since/until window key on epoch creationDate, not on today).
166
+ const of = overdueFilter(filter, encodePackedDate(localToday(now)));
167
+ const where = [OPEN, "t.start = 0"];
168
+ const binds = [];
169
+ // creationDate is an epoch REAL (Unix seconds) — mirror the changes/logbook
170
+ // comparison (getTime()/1000 against the raw column), never encodePackedDate.
171
+ if (filter?.since !== undefined) {
172
+ where.push("t.creationDate >= ?");
173
+ binds.push(filter.since.getTime() / 1000);
174
+ }
175
+ if (filter?.until !== undefined) {
176
+ where.push("t.creationDate <= ?");
177
+ binds.push(filter.until.getTime() / 1000);
178
+ }
179
+ const rows = fetchTaskRows(db, `${where.join(" AND ")}${tf.sql}${of.sql} ORDER BY t."index" ASC`, [...binds, ...tf.binds, ...of.binds]);
180
+ return materialize(db, rows, logBoundary(db, now));
71
181
  }
72
- /** An item's own anytime membership: unscheduled-active, or dated <= today. */
73
- const ANYTIME_SELF = (col) => `((${col}.start = 1 AND (${col}.startDate IS NULL OR ${col}.startDate <= ?))
74
- OR (${col}.start = 2 AND ${col}.startDate IS NOT NULL AND ${col}.startDate <= ?))`;
75
- /**
76
- * The item's effective project: its own link, or its heading's project for
77
- * headed children (heading rows carry the project link).
78
- */
79
- const EFF_PROJECT = `COALESCE(t.project, (SELECT h.project FROM TMTask h WHERE h.uuid = t.heading))`;
80
- /**
81
- * Container cascade (live-verified against the UI, 2026-07-09): a to-do
82
- * inside a project that is NOT itself anytime-visible (someday or
83
- * future-scheduled, logged, or trashed) is absent from Anytime regardless of
84
- * the to-do's own start state — the project row alone represents it.
85
- * Projects and container-less to-dos pass through. Two binds (packedToday ×2).
86
- */
87
- const PROJECT_ANYTIME_ACTIVE = `(${EFF_PROJECT} IS NULL OR EXISTS (
88
- SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT}
89
- AND p.trashed = 0 AND p.status = 0 AND ${ANYTIME_SELF("p")}))`;
90
- /**
91
- * DERIVED-trash exclusion: project deletion is SHALLOW (A24B — only the
92
- * project row flips trashed=1; children keep trashed=0 and their links, so
93
- * their Trash membership is derived through the container chain). Every live
94
- * view must therefore check the chain, not just the row's own flag: the
95
- * heading (if any) and the effective project (direct or via heading) must
96
- * both be untrashed. Areas cannot be trashed (they delete permanently), so
97
- * the chain is at most heading → project. Trash-adjacent surfaces stay
98
- * exempt on purpose: `things trash` lists directly-flagged rows, and a
99
- * trashed project's OWN view shows its would-be-recovered children.
100
- */
101
- const CONTAINER_UNTRASHED = `(t.heading IS NULL OR EXISTS (
102
- SELECT 1 FROM TMTask hh WHERE hh.uuid = t.heading AND hh.trashed = 0))
103
- AND (${EFF_PROJECT} IS NULL OR EXISTS (
104
- SELECT 1 FROM TMTask cc WHERE cc.uuid = ${EFF_PROJECT} AND cc.trashed = 0))`;
105
182
  export function anytimeView(db, now, filter) {
106
183
  const packedToday = encodePackedDate(localToday(now));
107
184
  const tf = tagFilter(db, filter);
185
+ const of = overdueFilter(filter, packedToday);
108
186
  // Mirrors UI membership: every active item, including Today members
109
187
  // (starred in the UI) and pending-promotion rows (start=2, past-dated) —
110
- // MINUS children of non-active containers (the project cascade).
111
- const rows = fetchTaskRows(db, `${OPEN} AND ${ANYTIME_SELF("t")}
112
- AND ${PROJECT_ANYTIME_ACTIVE}${tf.sql} ORDER BY t."index" ASC`, [packedToday, packedToday, packedToday, packedToday, ...tf.binds]);
113
- return groupBySidebar(db, materialize(db, rows));
188
+ // MINUS children of non-active containers (the project cascade). Membership
189
+ // is OPEN_OR_UNSWEPT (GUI-parity ruling 2026-07-14): a checked row the sweep
190
+ // has not passed keeps its index slot. PROJECT_ANYTIME_ACTIVE still requires
191
+ // the parent project be open, so a closed-but-unswept project's children stay
192
+ // cascade-excluded — the checked project row represents them. The boundary is
193
+ // threaded into the SQL filter and materialize so presence and `logged` agree.
194
+ const boundary = logBoundary(db, now);
195
+ const rows = fetchTaskRows(db, `${OPEN_OR_UNSWEPT} AND ${ANYTIME_SELF("t")}
196
+ AND ${PROJECT_ANYTIME_ACTIVE}${tf.sql}${of.sql} ORDER BY t."index" ASC`, [
197
+ boundary.getTime() / 1000,
198
+ packedToday,
199
+ packedToday,
200
+ packedToday,
201
+ packedToday,
202
+ ...tf.binds,
203
+ ...of.binds,
204
+ ]);
205
+ return groupBySidebar(db, materialize(db, rows, boundary));
114
206
  }
115
207
  /**
116
- * Arranges view members into the UI's flat sidebar-mirroring order: the
117
- * area-less block first (direct to-dos, then each top-level project followed
118
- * by its members), then each area by its sidebar index (direct to-dos, then
119
- * its projects). Project and area order here mirrors the sidebar exactly —
120
- * both read the same "index" columns.
208
+ * The UI's star marker in Anytime: the item is also a Today member. Mirrors
209
+ * {@link todayView}'s two membership arms so an unscheduled item pulled into
210
+ * Today by a due deadline stars exactly like a scheduled one:
211
+ * - SCHEDULED a When-date that has arrived (startDate <= today); or
212
+ * - DEADLINE-DRIVEN no When-date, but a due/overdue deadline (deadline <=
213
+ * today) pulls it into Today (the todayView arm that lets a bare deadline
214
+ * surface an item). Only reached for anytime-visible rows (start=1,
215
+ * startDate NULL), so it never over-stars someday/inbox rows.
216
+ * KNOWN LIMITATION: todayView's deadline arm also drops a DISMISSED-nag
217
+ * deadline (deadlineSuppressionDate >= deadline); that column is not surfaced
218
+ * on the entity, so a suppressed unscheduled overdue row is over-starred here.
121
219
  */
122
- function groupBySidebar(db, items) {
123
- if (items.length === 0)
124
- return [];
125
- const inList = (n) => Array.from({ length: n }, () => "?").join(", ");
126
- // Headed children: resolve heading -> project.
127
- const headingUuids = [
128
- ...new Set(items.flatMap((i) => (i.type === "to-do" && i.heading !== null ? [i.heading.uuid] : []))),
129
- ];
130
- const headingProject = new Map();
131
- if (headingUuids.length > 0) {
132
- for (const row of db
133
- .prepare(`SELECT uuid, project FROM TMTask WHERE uuid IN (${inList(headingUuids.length)})`)
134
- .all(...headingUuids)) {
135
- headingProject.set(row.uuid, row.project);
136
- }
137
- }
138
- const effProject = (i) => i.type !== "to-do"
139
- ? null
140
- : (i.project?.uuid ??
141
- (i.heading !== null ? (headingProject.get(i.heading.uuid) ?? null) : null));
142
- // Sidebar rank + title for areas; index + area for every referenced project
143
- // (a tag-filtered list can contain a child whose project row didn't match).
144
- const areaRows = db
145
- .prepare(`SELECT uuid, title, "index" FROM TMArea ORDER BY "index" ASC, uuid ASC`)
146
- .all();
147
- const areaRank = new Map(areaRows.map((a, rank) => [a.uuid, rank]));
148
- const areaTitle = new Map(areaRows.map((a) => [a.uuid, a.title ?? ""]));
149
- const projectUuids = [
150
- ...new Set([
151
- ...items.flatMap((i) => (i.type === "project" ? [i.uuid] : [])),
152
- ...items.flatMap((i) => {
153
- const p = effProject(i);
154
- return p === null ? [] : [p];
155
- }),
156
- ]),
157
- ];
158
- const projectMeta = new Map();
159
- if (projectUuids.length > 0) {
160
- for (const row of db
161
- .prepare(`SELECT uuid, "index", area FROM TMTask WHERE uuid IN (${inList(projectUuids.length)})`)
162
- .all(...projectUuids)) {
163
- projectMeta.set(row.uuid, { index: row.index ?? 0, area: row.area });
164
- }
165
- }
166
- // items arrive in SQL "index" order, so array position IS the per-container
167
- // rank (the internal index is no longer exposed on the entity).
168
- const sortKey = (i, pos) => {
169
- const project = i.type === "project" ? i.uuid : effProject(i);
170
- const meta = project === null ? undefined : projectMeta.get(project);
171
- const area = i.area?.uuid ?? meta?.area ?? null;
172
- return {
173
- areaRank: area === null ? -1 : (areaRank.get(area) ?? areaRows.length),
174
- area: area ?? "",
175
- inProject: project === null ? 0 : 1,
176
- projectIndex: meta?.index ?? 0,
177
- project: project ?? "",
178
- headerFirst: i.type === "project" ? 0 : 1,
179
- pos,
180
- uuid: i.uuid,
181
- };
182
- };
183
- const keyed = items.map((item, pos) => ({ item, k: sortKey(item, pos) }));
184
- keyed.sort((a, b) => a.k.areaRank - b.k.areaRank ||
185
- a.k.area.localeCompare(b.k.area) ||
186
- a.k.inProject - b.k.inProject ||
187
- a.k.projectIndex - b.k.projectIndex ||
188
- a.k.project.localeCompare(b.k.project) ||
189
- a.k.headerFirst - b.k.headerFirst ||
190
- a.k.pos - b.k.pos ||
191
- a.k.uuid.localeCompare(b.k.uuid));
192
- const sections = [];
193
- for (const { item, k } of keyed) {
194
- const areaUuid = k.area === "" ? null : k.area;
195
- const last = sections.at(-1);
196
- if (last === undefined || (last.area?.uuid ?? null) !== areaUuid) {
197
- sections.push({
198
- area: areaUuid === null ? null : { uuid: areaUuid, title: areaTitle.get(areaUuid) ?? "" },
199
- items: [],
200
- });
201
- }
202
- sections.at(-1)?.items.push(item);
203
- }
204
- return sections;
205
- }
206
- /** The UI's star marker in Anytime: the item is also a Today member. */
207
220
  export function isTodayMember(item, now) {
208
- return item.startDate !== null && item.startDate <= localToday(now);
221
+ const today = localToday(now);
222
+ if (item.startDate !== null)
223
+ return item.startDate <= today;
224
+ return item.deadline !== null && item.deadline <= today;
209
225
  }
226
+ /**
227
+ * The merged date-ordered stream keys on COALESCE(startDate, deadline) — a
228
+ * scheduled row groups under its when-date, a forecast row under its deadline.
229
+ */
230
+ const groupKey = (i) => i.startDate ?? i.deadline ?? "";
210
231
  export function upcomingView(db, now, filter) {
211
232
  const packedToday = encodePackedDate(localToday(now));
233
+ const boundary = logBoundary(db, now);
212
234
  const until = filter?.until;
213
- const untilSql = until === undefined ? "" : " AND t.startDate <= ?";
235
+ const since = filter?.since;
214
236
  const untilBinds = until === undefined ? [] : [encodePackedDate(until)];
237
+ const sinceBinds = since === undefined ? [] : [encodePackedDate(since)];
215
238
  const tf = tagFilter(db, filter);
239
+ // Cohort 1 — SCHEDULED: start=2 with a future startDate. The bounds clip the
240
+ // APPEARANCE date, which for these rows is the startDate (when-date).
241
+ const schedUntilSql = until === undefined ? "" : " AND t.startDate <= ?";
242
+ const schedSinceSql = since === undefined ? "" : " AND t.startDate >= ?";
216
243
  const rows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED}
217
- AND t.start = 2 AND t.startDate IS NOT NULL AND t.startDate > ?${untilSql}${tf.sql}
218
- ORDER BY t.startDate ASC, t."index" ASC`, [packedToday, ...untilBinds, ...tf.binds]);
219
- const items = materialize(db, rows);
244
+ AND t.start = 2 AND t.startDate IS NOT NULL AND t.startDate > ?${schedUntilSql}${schedSinceSql}${tf.sql}
245
+ ORDER BY t.startDate ASC, t."index" ASC`, [packedToday, ...untilBinds, ...sinceBinds, ...tf.binds]);
246
+ // Cohort 2 — DEADLINE-FORECAST (UPC1, GUI-verified): anytime/someday to-dos
247
+ // and someday projects carrying a FUTURE deadline and no when-date. Inbox
248
+ // (start=0) is excluded; a dismissed-nag deadline (deadlineSuppressionDate ==
249
+ // deadline) is dropped while a re-armed one (supp < deadline) survives — the
250
+ // todayView suppression clause one step earlier (deadline > today, not <=).
251
+ // These rows keep startDate NULL (JSON honesty) and APPEAR under their
252
+ // deadline, so the bounds clip on deadline, exactly as cohort 1 clips on
253
+ // startDate.
254
+ const fcUntilSql = until === undefined ? "" : " AND t.deadline <= ?";
255
+ const fcSinceSql = since === undefined ? "" : " AND t.deadline >= ?";
256
+ const forecastRows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED}
257
+ AND t.startDate IS NULL AND t.start IN (1, 2)
258
+ AND t.deadline IS NOT NULL AND t.deadline > ?
259
+ AND (t.deadlineSuppressionDate IS NULL OR t.deadlineSuppressionDate < t.deadline)${fcUntilSql}${fcSinceSql}${tf.sql}
260
+ ORDER BY t.deadline ASC, t."index" ASC`, [packedToday, ...untilBinds, ...sinceBinds, ...tf.binds]);
261
+ const scheduled = materialize(db, rows, boundary);
262
+ const forecast = materialize(db, forecastRows, boundary);
263
+ // The merged date-ordered stream keys on COALESCE(startDate, deadline) — a
264
+ // scheduled row groups under its when-date, a forecast row under its deadline
265
+ // — then the UI's within-day drag order (todayIndex ASC; live-verified
266
+ // 2026-07-11 against the GUI — plain `index` disagrees), then a stable
267
+ // seed-order/uuid tiebreak.
268
+ const sortDated = (list, indexRows) => {
269
+ const todayIndexOf = new Map(indexRows.map((r) => [r.uuid, r.todayIndex ?? 0]));
270
+ return list
271
+ .map((item, pos) => ({ item, pos }))
272
+ .toSorted((a, b) => groupKey(a.item).localeCompare(groupKey(b.item)) ||
273
+ (todayIndexOf.get(a.item.uuid) ?? 0) - (todayIndexOf.get(b.item.uuid) ?? 0) ||
274
+ a.pos - b.pos ||
275
+ a.item.uuid.localeCompare(b.item.uuid))
276
+ .map((x) => x.item);
277
+ };
220
278
  if (filter?.repeats === false)
221
- return items;
279
+ return sortDated([...scheduled, ...forecast], [...rows, ...forecastRows]);
222
280
  // UI parity: repeating templates surface at their app-materialized next
223
281
  // occurrence (rt1_nextInstanceStartDate). Fixed rules only — after-
224
282
  // completion templates carry no next date until the prior instance
@@ -227,16 +285,19 @@ export function upcomingView(db, now, filter) {
227
285
  const templateRows = fetchTaskRows(db, `t.type IN (0, 1) AND t.trashed = 0 AND t.status = 0 AND ${CONTAINER_UNTRASHED}
228
286
  AND (t.rt1_recurrenceRule IS NOT NULL OR t.repeater IS NOT NULL)
229
287
  AND t.rt1_instanceCreationPaused = 0
230
- AND t.rt1_nextInstanceStartDate IS NOT NULL AND t.rt1_nextInstanceStartDate > ?${until === undefined ? "" : " AND t.rt1_nextInstanceStartDate <= ?"}${tf.sql}
231
- ORDER BY t.rt1_nextInstanceStartDate ASC, t."index" ASC`, [packedToday, ...untilBinds, ...tf.binds]);
288
+ AND t.rt1_nextInstanceStartDate IS NOT NULL AND t.rt1_nextInstanceStartDate > ?${until === undefined ? "" : " AND t.rt1_nextInstanceStartDate <= ?"}${since === undefined ? "" : " AND t.rt1_nextInstanceStartDate >= ?"}${tf.sql}
289
+ ORDER BY t.rt1_nextInstanceStartDate ASC, t."index" ASC`, [packedToday, ...untilBinds, ...sinceBinds, ...tf.binds]);
232
290
  const horizon = Math.max(1, Math.min(10, Math.trunc(filter?.horizon ?? 1)));
233
291
  const rawByUuid = new Map(templateRows.map((r) => [r.uuid, r.rt1_recurrenceRule]));
234
- const occurrences = materialize(db, templateRows).flatMap((template) => {
292
+ const occurrences = materialize(db, templateRows, boundary).flatMap((template) => {
235
293
  const startDate = template.repeating.nextOccurrence ?? null;
236
294
  if (startDate === null)
237
295
  return [];
238
- // Only the rule-derived deadline is real: the template row's own deadline
239
- // column carries app-internal sentinels (4001-01-01 observed live).
296
+ // Whether occurrences deadline is the TEMPLATE's property, not the rule's:
297
+ // a deadline-less template (repeating.deadlined false) spawns instances
298
+ // with NO deadline even for fixed ts=0 rules — its rt1_recurrenceRule is
299
+ // byte-identical to a deadlined ts=0 rule (oddities §8a, UI1 2026-07-12).
300
+ const deadlined = template.repeating.deadlined === true;
240
301
  let rule = null;
241
302
  const raw = rawByUuid.get(template.uuid);
242
303
  if (raw !== null && raw !== undefined) {
@@ -248,40 +309,30 @@ export function upcomingView(db, now, filter) {
248
309
  }
249
310
  }
250
311
  if (rule === null || horizon === 1 || rule.type !== "fixed") {
251
- // Corpus-validated 2026-07-11 (1,900+ live instances): FIXED rules
252
- // always deadline their occurrences at the event date start − ts,
253
- // INCLUDING ts=0 (deadline = the start date itself; birthday-style
254
- // repeats). After-completion rules deadline only when ts < 0.
255
- const deadline = rule !== null && (rule.type === "fixed" || rule.startOffsetDays < 0)
256
- ? addDaysIso(startDate, -rule.startOffsetDays)
257
- : null;
312
+ // Deadlined templates deadline the occurrence at the event date
313
+ // (start ts, incl. ts=0); deadline-less ones carry no deadline.
314
+ const deadline = rule !== null && deadlined ? addDaysIso(startDate, -rule.startOffsetDays) : null;
258
315
  return [{ ...template, startDate, deadline }];
259
316
  }
260
317
  // horizon > 1: later occurrences PROJECTED from the decoded rule,
261
318
  // anchored on the app's own materialized next instance. The until bound
262
319
  // clips projections the same way it clips stored rows.
263
- return projectOccurrences(rule, startDate, {
320
+ return (projectOccurrences(rule, startDate, {
264
321
  count: horizon,
265
322
  ...(until !== undefined && { until }),
266
- })
323
+ }, deadlined)
267
324
  .filter((o) => until === undefined || o.startDate <= until)
325
+ .filter((o) => since === undefined || o.startDate >= since)
326
+ // oxlint-disable-next-line no-map-spread -- building fresh occurrence objects, not mutating
268
327
  .map((o) => ({
269
328
  ...template,
270
329
  startDate: o.startDate,
271
330
  deadline: o.deadline,
272
- }));
331
+ })));
273
332
  });
274
- // Within a day the UI's drag order is todayIndex ASC (live-verified
275
- // 2026-07-11 against the GUI: plain `index` disagrees, todayIndex matches
276
- // exactly), so the sortable Upcoming order survives the CLI.
277
- const todayIndexOf = new Map([...rows, ...templateRows].map((r) => [r.uuid, r.todayIndex ?? 0]));
278
- const dated = [...items, ...occurrences]
279
- .map((item, pos) => ({ item, pos }))
280
- .toSorted((a, b) => (a.item.startDate ?? "").localeCompare(b.item.startDate ?? "") ||
281
- (todayIndexOf.get(a.item.uuid) ?? 0) - (todayIndexOf.get(b.item.uuid) ?? 0) ||
282
- a.pos - b.pos ||
283
- a.item.uuid.localeCompare(b.item.uuid))
284
- .map((x) => x.item);
333
+ // The scheduled + forecast + occurrence rows share one date-ordered stream
334
+ // (COALESCE(startDate, deadline), then the UI's within-day drag order).
335
+ const dated = sortDated([...scheduled, ...forecast, ...occurrences], [...rows, ...forecastRows, ...templateRows]);
285
336
  // The UI's trailing "Repeating To-Dos" section: templates with NO set
286
337
  // next occurrence (after-completion rules between instances, rules past
287
338
  // their end date) plus paused ones — startDate stays null, the decoded
@@ -291,7 +342,7 @@ export function upcomingView(db, now, filter) {
291
342
  AND (t.rt1_nextInstanceStartDate IS NULL OR t.rt1_nextInstanceStartDate <= ?
292
343
  OR t.rt1_instanceCreationPaused = 1)${tf.sql}
293
344
  ORDER BY t.todayIndex ASC, t."index" ASC`, [packedToday, ...tf.binds]);
294
- const resting = materialize(db, restingRows).map((item) => {
345
+ const resting = materialize(db, restingRows, boundary).map((item) => {
295
346
  const raw = restingRows.find((r) => r.uuid === item.uuid)?.rt1_recurrenceRule;
296
347
  if (raw !== null && raw !== undefined) {
297
348
  try {
@@ -305,9 +356,12 @@ export function upcomingView(db, now, filter) {
305
356
  });
306
357
  return [...dated, ...resting];
307
358
  }
359
+ /** A someday to-do nested under a project (directly or via a heading). */
360
+ const isChild = (i) => i.type === "to-do" && (i.project !== null || i.headingProject !== null);
308
361
  export function somedayView(db, now, filter) {
309
362
  const tf = tagFilter(db, filter);
310
363
  const packedToday = encodePackedDate(localToday(now));
364
+ const of = overdueFilter(filter, packedToday);
311
365
  // Default membership excludes ALL project children (the UI shows only
312
366
  // container-less someday to-dos, area members, and someday project rows);
313
367
  // the toggle adds someday children of anytime-ACTIVE projects only.
@@ -317,11 +371,24 @@ export function somedayView(db, now, filter) {
317
371
  AND p.trashed = 0 AND p.status = 0 AND ${ANYTIME_SELF("p")})`
318
372
  : "0";
319
373
  const rows = fetchTaskRows(db, `${OPEN} AND t.start = 2 AND t.startDate IS NULL
320
- AND (${EFF_PROJECT} IS NULL OR ${childArm})${tf.sql} ORDER BY t."index" ASC`, [...(withActiveChildren ? [packedToday, packedToday] : []), ...tf.binds]);
321
- return groupBySidebar(db, materialize(db, rows));
374
+ AND (${EFF_PROJECT} IS NULL OR ${childArm})${tf.sql}${of.sql} ORDER BY t."index" ASC`, [...(withActiveChildren ? [packedToday, packedToday] : []), ...tf.binds, ...of.binds]);
375
+ const sections = groupBySidebar(db, materialize(db, rows, logBoundary(db, now)));
376
+ // GUI order within a Someday group (live side-by-side, 2026-07-12):
377
+ // PROJECT rows first (their sidebar order preserved), then direct to-dos in
378
+ // drag order. Someday children of active projects (the activeProjectItems
379
+ // toggle) trail the group, still clustered by their project — surfaces
380
+ // present them as a separate "From active projects" section.
381
+ return sections.map((s) => ({
382
+ area: s.area,
383
+ items: [
384
+ ...s.items.filter((i) => i.type === "project"),
385
+ ...s.items.filter((i) => i.type === "to-do" && !isChild(i)),
386
+ ...s.items.filter(isChild),
387
+ ],
388
+ }));
322
389
  }
323
- export function logbookView(db, options) {
324
- const limit = options?.limit ?? 100;
390
+ export function logbookView(db, now, options) {
391
+ const cap = options?.limit === null ? null : (options?.limit ?? 100);
325
392
  const tf = tagFilter(db, options);
326
393
  const where = [];
327
394
  const binds = [];
@@ -344,21 +411,38 @@ export function logbookView(db, options) {
344
411
  }
345
412
  const extra = where.length > 0 ? ` AND ${where.join(" AND ")}` : "";
346
413
  // Completion ≠ logged: closed items past the log-move boundary still sit
347
- // checked in their original lists, exactly like the GUI's Logbook.
348
- const rows = fetchTaskRows(db, `${LIVE} AND t.status IN (2, 3) AND t.stopDate <= ?${extra}${tf.sql} ORDER BY t.stopDate DESC LIMIT ?`, [logBoundary(db).getTime() / 1000, ...binds, ...tf.binds, limit]);
349
- return materialize(db, rows);
414
+ // checked in their original lists, exactly like the GUI's Logbook. The
415
+ // membership boundary rides the injected clock (threaded into both the SQL
416
+ // filter and materialize so presence and the `logged` flag agree).
417
+ const boundary = logBoundary(db, now);
418
+ const rows = fetchTaskRows(db, `${LIVE} AND t.status IN (2, 3) AND t.stopDate <= ?${extra}${tf.sql} ORDER BY t.stopDate DESC${cap === null ? "" : " LIMIT ?"}`, [boundary.getTime() / 1000, ...binds, ...tf.binds, ...(cap === null ? [] : [cap])]);
419
+ return materialize(db, rows, boundary);
350
420
  }
351
- export function trashView(db, options) {
352
- const limit = options?.limit ?? 200;
421
+ export function trashView(db, now, options) {
422
+ const cap = options?.limit === null ? null : (options?.limit ?? 200);
353
423
  const rows = fetchTaskRows(db, `t.type IN (0, 1) AND t.trashed = 1 AND ${NOT_TEMPLATE}
354
- ORDER BY t.userModificationDate DESC LIMIT ?`, [limit]);
355
- return materialize(db, rows);
424
+ ORDER BY t.userModificationDate DESC${cap === null ? "" : " LIMIT ?"}`, cap === null ? [] : [cap]);
425
+ return materialize(db, rows, logBoundary(db, now));
356
426
  }
357
427
  export function projectsView(db, options) {
358
428
  // areaUuid accepts a uuid OR a unique (case-insensitive) title; ambiguous
359
429
  // or unknown references throw like every other ref resolver.
360
430
  const area = options?.areaUuid === undefined ? null : resolveAreaUuid(db, options.areaUuid);
361
431
  const packedToday = encodePackedDate(localToday(options?.now));
432
+ // Tag scope (§9a): the projects LIST is a FLAT view (like `anytime` restricted
433
+ // to project rows), NOT a single-container view — projects sit in different
434
+ // areas with heterogeneous inheritance, there is no universally-inherited tag
435
+ // set, and inherited tags are opaque here (area headers don't show what they
436
+ // confer), so filtering by an inherited tag is useful. `--tag`/`--untagged`
437
+ // are INHERITANCE-INCLUSIVE (a project inherits its area's tags). Its binds sit
438
+ // in the WHERE, after the overdue bind and before the active-first binds.
439
+ const tf = tagFilter(db, options);
440
+ // OWN-DEADLINE UNIFORM: `--overdue` keeps only projects whose OWN deadline is
441
+ // overdue (open, strictly before today) — projects carry a `deadline` column
442
+ // exactly like to-dos, so the shared OVERDUE predicate applies to project
443
+ // rows verbatim. A content scope: it narrows the list, never lifts a limit.
444
+ const overdueSql = options?.overdue === true ? ` AND ${OVERDUE}` : "";
445
+ const overdueBinds = options?.overdue === true ? [packedToday] : [];
362
446
  // Sidebar default: LATER (someday + future-scheduled) projects are hidden —
363
447
  // active means ANYTIME_SELF (a scheduled project whose date has arrived
364
448
  // counts active, exactly the Anytime membership test). With later=true they
@@ -374,16 +458,18 @@ export function projectsView(db, options) {
374
458
  // above the areas — then each area by ITS sidebar rank (TMArea."index"),
375
459
  // projects within an area by their drag order.
376
460
  const where = area
377
- ? `${OPEN} AND t.type = 1 AND t.area = ?${laterSql}
461
+ ? `${OPEN} AND t.type = 1 AND t.area = ?${laterSql}${overdueSql}${tf.sql}
378
462
  ORDER BY ${activeFirst}t."index" ASC`
379
- : `${OPEN} AND t.type = 1${laterSql} ORDER BY (t.area IS NOT NULL) ASC,
463
+ : `${OPEN} AND t.type = 1${laterSql}${overdueSql}${tf.sql} ORDER BY (t.area IS NOT NULL) ASC,
380
464
  (SELECT a."index" FROM TMArea a WHERE a.uuid = t.area) ASC, ${activeFirst}t."index" ASC`;
381
465
  const rows = fetchTaskRows(db, where, [
382
466
  ...(area ? [area] : []),
383
467
  ...laterBinds,
468
+ ...overdueBinds,
469
+ ...tf.binds,
384
470
  ...activeFirstBinds,
385
471
  ]);
386
- const items = materialize(db, rows);
472
+ const items = materialize(db, rows, logBoundary(db, options?.now));
387
473
  if (options?.later !== true)
388
474
  return items;
389
475
  // Each group's later sub-block reads like Upcoming: SCHEDULED projects
@@ -437,22 +523,72 @@ export function projectsView(db, options) {
437
523
  * carry no modification dates, and checklist-item edits do not bump the
438
524
  * parent task — those changes are invisible here.
439
525
  */
440
- export function changesView(db, options) {
441
- const limit = options.limit ?? 200;
526
+ export function changesView(db, now, options) {
527
+ const cap = options.limit === null ? null : (options.limit ?? 200);
442
528
  const sinceEpoch = options.since.getTime() / 1000;
443
529
  const rows = fetchTaskRows(db, `t.type IN (0, 1) AND t.userModificationDate > ?
444
- ORDER BY t.userModificationDate DESC LIMIT ?`, [sinceEpoch, limit]);
445
- return materialize(db, rows).map((item, i) => ({
530
+ ORDER BY t.userModificationDate DESC${cap === null ? "" : " LIMIT ?"}`, [sinceEpoch, ...(cap === null ? [] : [cap])]);
531
+ // oxlint-disable-next-line no-map-spread -- building fresh change objects, not mutating
532
+ return materialize(db, rows, logBoundary(db, now)).map((item, i) => ({
446
533
  ...item,
447
534
  changeKind: (rows[i]?.creationDate ?? 0) > sinceEpoch ? "created" : "modified",
448
535
  }));
449
536
  }
450
- export function searchView(db, query, options) {
451
- const limit = options?.limit ?? 50;
452
- const needle = `%${query}%`;
453
- const where = [`${NOT_TEMPLATE} AND (t.title LIKE ? OR t.notes LIKE ?)`];
454
- const binds = [needle, needle];
455
- where.push(options?.type === "to-do"
537
+ /**
538
+ * The did-you-mean fallback search: a case-insensitive SUBSTRING match on
539
+ * TITLES ONLY (never notes, headings, or checklist items), across areas,
540
+ * projects, and to-dos open + untrashed only. Ordered per the house
541
+ * doctrine: containers (areas, then projects) first, then to-dos; within a
542
+ * group active rows precede someday rows, then most-recently-modified. `type`
543
+ * scopes to a single class (the typed-namespace paths). Results are capped
544
+ * (default 10); `total` reports the pre-cap match count so the caller can
545
+ * append a "… n more" tail.
546
+ */
547
+ const somedayRank = (i) => (i.start === "someday" ? 1 : 0);
548
+ /** Lite-search order within a task group: active before someday, then most-recently-modified. */
549
+ function byStatusThenRecent(a, b) {
550
+ return somedayRank(a) - somedayRank(b) || b.modified.getTime() - a.modified.getTime();
551
+ }
552
+ export function liteTitleSearch(db, query, options, now) {
553
+ const cap = options?.limit ?? 10;
554
+ const type = options?.type;
555
+ const needle = query.toLowerCase();
556
+ const areas = type === undefined || type === "area"
557
+ ? areasView(db)
558
+ .filter((a) => a.title.toLowerCase().includes(needle))
559
+ .toSorted((a, b) => a.title.localeCompare(b.title))
560
+ : [];
561
+ let tasks = [];
562
+ if (type === undefined || type === "to-do" || type === "project") {
563
+ const typeSql = type === "to-do" ? "t.type = 0" : type === "project" ? "t.type = 1" : "t.type IN (0, 1)";
564
+ const rows = fetchTaskRows(db, `${OPEN} AND ${CONTAINER_UNTRASHED} AND ${typeSql} AND t.title LIKE ?${LIKE_ESCAPE}`, [`%${escapeLike(query)}%`]);
565
+ tasks = materialize(db, rows, logBoundary(db, now));
566
+ }
567
+ const projects = tasks.filter((t) => t.type === "project").toSorted(byStatusThenRecent);
568
+ const todos = tasks.filter((t) => t.type === "to-do").toSorted(byStatusThenRecent);
569
+ const ordered = [
570
+ ...areas.map((area) => ({ kind: "area", area })),
571
+ ...projects.map((task) => ({ kind: "task", task })),
572
+ ...todos.map((task) => ({ kind: "task", task })),
573
+ ];
574
+ return { candidates: ordered.slice(0, cap), total: ordered.length };
575
+ }
576
+ export function searchView(db, query, options, now) {
577
+ const cap = options?.limit === null ? null : (options?.limit ?? 50);
578
+ const needle = `%${escapeLike(query)}%`;
579
+ // `--overdue` narrows to OPEN, past-deadline matches. Its open-only predicate
580
+ // is contradictory with the status-widening flags (logged/trashed/all); the
581
+ // CLI/MCP surfaces reject that combination, so here it simply intersects.
582
+ const of = overdueFilter(options, encodePackedDate(localToday(now)));
583
+ // Threaded into every materialize so the `logged` flag (--logged widens to
584
+ // closed rows) honors the injected clock, not wall time.
585
+ const boundary = logBoundary(db, now);
586
+ // The scope predicates (everything except the match needle), reused verbatim
587
+ // for the needle query AND the heading-credited-project query so both honor
588
+ // the same type/status/scope constraints.
589
+ const scope = [NOT_TEMPLATE];
590
+ const scopeBinds = [];
591
+ scope.push(options?.type === "to-do"
456
592
  ? "t.type = 0"
457
593
  : options?.type === "project"
458
594
  ? "t.type = 1"
@@ -462,24 +598,65 @@ export function searchView(db, query, options) {
462
598
  // container chain (derived trash, A24B), not just the row's own flag.
463
599
  if (options?.all !== true) {
464
600
  const statuses = options?.logged === true ? "(0, 2, 3)" : "(0)";
465
- where.push(`t.status IN ${statuses}`);
601
+ scope.push(`t.status IN ${statuses}`);
466
602
  if (options?.trashed !== true)
467
- where.push(`t.trashed = 0 AND ${CONTAINER_UNTRASHED}`);
603
+ scope.push(`t.trashed = 0 AND ${CONTAINER_UNTRASHED}`);
468
604
  }
469
605
  if (options?.project !== undefined) {
470
606
  const uuid = resolveProjectUuid(db, options.project);
471
607
  // Children incl. headed ones (heading rows carry the project link).
472
- where.push("(t.project = ? OR t.heading IN (SELECT uuid FROM TMTask WHERE type = 2 AND project = ?))");
473
- binds.push(uuid, uuid);
608
+ scope.push("(t.project = ? OR t.heading IN (SELECT uuid FROM TMTask WHERE type = 2 AND project = ?))");
609
+ scopeBinds.push(uuid, uuid);
474
610
  }
475
611
  if (options?.area !== undefined) {
476
612
  const uuid = resolveAreaUuid(db, options.area);
477
- where.push("t.area = ?");
478
- binds.push(uuid);
613
+ scope.push("t.area = ?");
614
+ scopeBinds.push(uuid);
479
615
  }
480
616
  const tf = tagFilter(db, options);
481
- const rows = fetchTaskRows(db, `${where.join(" AND ")}${tf.sql}
482
- ORDER BY t.userModificationDate DESC LIMIT ?`, [...binds, ...tf.binds, limit]);
483
- return materialize(db, rows);
617
+ // Needle matches: title OR notes. No SQL LIMIT — ranking runs before the cap.
618
+ const rows = fetchTaskRows(db, `${scope.join(" AND ")} AND (t.title LIKE ?${LIKE_ESCAPE} OR t.notes LIKE ?${LIKE_ESCAPE})${tf.sql}${of.sql}`, [...scopeBinds, needle, needle, ...tf.binds, ...of.binds]);
619
+ const needleLower = query.toLowerCase();
620
+ const matches = new Map();
621
+ for (const item of materialize(db, rows, boundary)) {
622
+ // Field credit: title beats notes when both carry the substring.
623
+ const field = item.title.toLowerCase().includes(needleLower) ? "title" : "notes";
624
+ matches.set(item.uuid, { item, field });
625
+ }
626
+ // Heading titles are treated as if they lived in the parent PROJECT's notes:
627
+ // a heading-title match surfaces the parent project (never a bare heading
628
+ // row), credited at the heading-via-project field rank. A to-do-only search
629
+ // has no project rows, so headings do not apply there.
630
+ if (options?.type !== "to-do") {
631
+ const headingRows = db
632
+ .prepare(`SELECT project AS projectUuid, title FROM TMTask
633
+ WHERE type = 2 AND project IS NOT NULL AND trashed = 0 AND title LIKE ?${LIKE_ESCAPE}`)
634
+ .all(needle);
635
+ const headingTitleFor = new Map();
636
+ for (const h of headingRows) {
637
+ if (!headingTitleFor.has(h.projectUuid))
638
+ headingTitleFor.set(h.projectUuid, h.title ?? "");
639
+ }
640
+ // Surface only the projects NOT already matched by their own title/notes
641
+ // (a higher-ranked field already represents them), and only those passing
642
+ // the view's scope.
643
+ const wanted = [...headingTitleFor.keys()].filter((uuid) => !matches.has(uuid));
644
+ if (wanted.length > 0) {
645
+ const placeholders = wanted.map(() => "?").join(", ");
646
+ const projectRows = fetchTaskRows(db, `${scope.join(" AND ")} AND t.type = 1 AND t.uuid IN (${placeholders})${tf.sql}${of.sql}`, [...scopeBinds, ...wanted, ...tf.binds, ...of.binds]);
647
+ for (const item of materialize(db, projectRows, boundary)) {
648
+ const matchedVia = {
649
+ kind: "heading",
650
+ title: headingTitleFor.get(item.uuid) ?? "",
651
+ };
652
+ // Annotate the freshly materialized entity in place (owned here).
653
+ item.matchedVia = matchedVia;
654
+ matches.set(item.uuid, { item, field: "heading", matchedVia });
655
+ }
656
+ }
657
+ }
658
+ const ranked = [...matches.values()].toSorted(compareSearchMatches);
659
+ const sliced = cap === null ? ranked : ranked.slice(0, cap);
660
+ return sliced.map((m) => m.item);
484
661
  }
485
662
  //# sourceMappingURL=views.js.map