things-api 0.10.0 → 0.12.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 (239) hide show
  1. package/README.md +50 -13
  2. package/dist/audit/schema.d.ts +7 -0
  3. package/dist/audit/schema.js.map +1 -1
  4. package/dist/cli/clock.d.ts +8 -0
  5. package/dist/cli/clock.js +24 -0
  6. package/dist/cli/clock.js.map +1 -0
  7. package/dist/cli/commands/area.d.ts +18 -11
  8. package/dist/cli/commands/area.js +61 -74
  9. package/dist/cli/commands/area.js.map +1 -1
  10. package/dist/cli/commands/doctor.d.ts +1 -2
  11. package/dist/cli/commands/doctor.js +11 -1
  12. package/dist/cli/commands/doctor.js.map +1 -1
  13. package/dist/cli/commands/install-skill.d.ts +60 -0
  14. package/dist/cli/commands/install-skill.js +256 -0
  15. package/dist/cli/commands/install-skill.js.map +1 -0
  16. package/dist/cli/commands/mcp.js +9 -0
  17. package/dist/cli/commands/mcp.js.map +1 -1
  18. package/dist/cli/commands/project.d.ts +1 -0
  19. package/dist/cli/commands/project.js +27 -34
  20. package/dist/cli/commands/project.js.map +1 -1
  21. package/dist/cli/commands/reads.d.ts +15 -2
  22. package/dist/cli/commands/reads.js +144 -44
  23. package/dist/cli/commands/reads.js.map +1 -1
  24. package/dist/cli/commands/setup.js +7 -2
  25. package/dist/cli/commands/setup.js.map +1 -1
  26. package/dist/cli/commands/show.js +38 -14
  27. package/dist/cli/commands/show.js.map +1 -1
  28. package/dist/cli/commands/todo.js +15 -9
  29. package/dist/cli/commands/todo.js.map +1 -1
  30. package/dist/cli/commands/writes.js +797 -174
  31. package/dist/cli/commands/writes.js.map +1 -1
  32. package/dist/cli/did-you-mean.d.ts +14 -3
  33. package/dist/cli/did-you-mean.js +26 -3
  34. package/dist/cli/did-you-mean.js.map +1 -1
  35. package/dist/cli/glyphs.d.ts +32 -2
  36. package/dist/cli/glyphs.js +83 -12
  37. package/dist/cli/glyphs.js.map +1 -1
  38. package/dist/cli/help.js +121 -16
  39. package/dist/cli/help.js.map +1 -1
  40. package/dist/cli/main.js +33 -2
  41. package/dist/cli/main.js.map +1 -1
  42. package/dist/cli/move-hint.d.ts +46 -0
  43. package/dist/cli/move-hint.js +196 -0
  44. package/dist/cli/move-hint.js.map +1 -0
  45. package/dist/cli/period.d.ts +2 -8
  46. package/dist/cli/period.js +44 -30
  47. package/dist/cli/period.js.map +1 -1
  48. package/dist/cli/read-driver.d.ts +43 -5
  49. package/dist/cli/read-driver.js +91 -15
  50. package/dist/cli/read-driver.js.map +1 -1
  51. package/dist/cli/render.d.ts +19 -8
  52. package/dist/cli/render.js +70 -27
  53. package/dist/cli/render.js.map +1 -1
  54. package/dist/cli/resolve-invocation.d.ts +2 -2
  55. package/dist/cli/resolve-invocation.js +3 -3
  56. package/dist/cli/resolve-invocation.js.map +1 -1
  57. package/dist/cli/skill-check.d.ts +27 -0
  58. package/dist/cli/skill-check.js +99 -0
  59. package/dist/cli/skill-check.js.map +1 -0
  60. package/dist/cli/skill.d.ts +70 -0
  61. package/dist/cli/skill.js +152 -0
  62. package/dist/cli/skill.js.map +1 -0
  63. package/dist/cli/tag-filters.d.ts +6 -2
  64. package/dist/cli/tag-filters.js +8 -6
  65. package/dist/cli/tag-filters.js.map +1 -1
  66. package/dist/cli/verb-hint.js +28 -3
  67. package/dist/cli/verb-hint.js.map +1 -1
  68. package/dist/cli/version.d.ts +4 -0
  69. package/dist/cli/version.js +18 -0
  70. package/dist/cli/version.js.map +1 -0
  71. package/dist/client.d.ts +171 -27
  72. package/dist/client.js +279 -40
  73. package/dist/client.js.map +1 -1
  74. package/dist/config.d.ts +66 -2
  75. package/dist/config.js +120 -12
  76. package/dist/config.js.map +1 -1
  77. package/dist/contracts.d.ts +186 -28
  78. package/dist/contracts.js +22 -1
  79. package/dist/contracts.js.map +1 -1
  80. package/dist/diagnose.d.ts +34 -2
  81. package/dist/diagnose.js +36 -1
  82. package/dist/diagnose.js.map +1 -1
  83. package/dist/index.d.ts +25 -9
  84. package/dist/index.js +22 -3
  85. package/dist/index.js.map +1 -1
  86. package/dist/mcp/server.d.ts +9 -0
  87. package/dist/mcp/server.js +1157 -684
  88. package/dist/mcp/server.js.map +1 -1
  89. package/dist/model/clock.d.ts +61 -0
  90. package/dist/model/clock.js +71 -0
  91. package/dist/model/clock.js.map +1 -0
  92. package/dist/model/dates.d.ts +26 -3
  93. package/dist/model/dates.js +84 -3
  94. package/dist/model/dates.js.map +1 -1
  95. package/dist/model/entities.d.ts +60 -3
  96. package/dist/model/entities.js.map +1 -1
  97. package/dist/model/mappers.d.ts +4 -2
  98. package/dist/model/mappers.js +66 -9
  99. package/dist/model/mappers.js.map +1 -1
  100. package/dist/model/recurrence.d.ts +8 -1
  101. package/dist/model/recurrence.js.map +1 -1
  102. package/dist/read/area-filter.d.ts +76 -0
  103. package/dist/read/area-filter.js +59 -0
  104. package/dist/read/area-filter.js.map +1 -0
  105. package/dist/read/area-view.d.ts +12 -12
  106. package/dist/read/area-view.js +58 -34
  107. package/dist/read/area-view.js.map +1 -1
  108. package/dist/read/detail.d.ts +1 -1
  109. package/dist/read/detail.js +26 -12
  110. package/dist/read/detail.js.map +1 -1
  111. package/dist/read/log-boundary.d.ts +1 -1
  112. package/dist/read/log-boundary.js +13 -3
  113. package/dist/read/log-boundary.js.map +1 -1
  114. package/dist/read/predicates.d.ts +18 -0
  115. package/dist/read/predicates.js +19 -0
  116. package/dist/read/predicates.js.map +1 -1
  117. package/dist/read/project-view.d.ts +32 -16
  118. package/dist/read/project-view.js +80 -45
  119. package/dist/read/project-view.js.map +1 -1
  120. package/dist/read/pseudo-area.d.ts +29 -0
  121. package/dist/read/pseudo-area.js +27 -0
  122. package/dist/read/pseudo-area.js.map +1 -0
  123. package/dist/read/queries.d.ts +108 -19
  124. package/dist/read/queries.js +212 -35
  125. package/dist/read/queries.js.map +1 -1
  126. package/dist/read/scope.d.ts +126 -0
  127. package/dist/read/scope.js +162 -0
  128. package/dist/read/scope.js.map +1 -0
  129. package/dist/read/search-rank.d.ts +2 -5
  130. package/dist/read/shape.d.ts +162 -0
  131. package/dist/read/shape.js +686 -0
  132. package/dist/read/shape.js.map +1 -0
  133. package/dist/read/show-target.d.ts +9 -1
  134. package/dist/read/show-target.js +45 -4
  135. package/dist/read/show-target.js.map +1 -1
  136. package/dist/read/snapshot.d.ts +1 -1
  137. package/dist/read/snapshot.js +9 -2
  138. package/dist/read/snapshot.js.map +1 -1
  139. package/dist/read/stage.d.ts +199 -0
  140. package/dist/read/stage.js +125 -0
  141. package/dist/read/stage.js.map +1 -0
  142. package/dist/read/truncation.d.ts +8 -6
  143. package/dist/read/truncation.js +35 -10
  144. package/dist/read/truncation.js.map +1 -1
  145. package/dist/read/views.d.ts +61 -20
  146. package/dist/read/views.js +240 -82
  147. package/dist/read/views.js.map +1 -1
  148. package/dist/surface-copy.d.ts +9 -0
  149. package/dist/surface-copy.js +9 -0
  150. package/dist/surface-copy.js.map +1 -1
  151. package/dist/write/accessibility-probe.d.ts +1 -1
  152. package/dist/write/accessibility-probe.js +8 -0
  153. package/dist/write/accessibility-probe.js.map +1 -1
  154. package/dist/write/automation-probe.d.ts +1 -1
  155. package/dist/write/automation-probe.js +8 -0
  156. package/dist/write/automation-probe.js.map +1 -1
  157. package/dist/write/batch.d.ts +49 -10
  158. package/dist/write/batch.js +423 -70
  159. package/dist/write/batch.js.map +1 -1
  160. package/dist/write/commands.d.ts +1 -1
  161. package/dist/write/commands.js +316 -66
  162. package/dist/write/commands.js.map +1 -1
  163. package/dist/write/guards.d.ts +1 -1
  164. package/dist/write/guards.js +94 -10
  165. package/dist/write/guards.js.map +1 -1
  166. package/dist/write/heading.d.ts +10 -1
  167. package/dist/write/heading.js +35 -5
  168. package/dist/write/heading.js.map +1 -1
  169. package/dist/write/make-repeating-project.d.ts +2 -2
  170. package/dist/write/make-repeating-project.js +9 -9
  171. package/dist/write/make-repeating-project.js.map +1 -1
  172. package/dist/write/move.d.ts +130 -0
  173. package/dist/write/move.js +1533 -0
  174. package/dist/write/move.js.map +1 -0
  175. package/dist/write/operations.d.ts +105 -19
  176. package/dist/write/operations.js +56 -7
  177. package/dist/write/operations.js.map +1 -1
  178. package/dist/write/opid.d.ts +31 -0
  179. package/dist/write/opid.js +30 -0
  180. package/dist/write/opid.js.map +1 -0
  181. package/dist/write/pipeline.d.ts +122 -3
  182. package/dist/write/pipeline.js +309 -52
  183. package/dist/write/pipeline.js.map +1 -1
  184. package/dist/write/planner.js +8 -1
  185. package/dist/write/planner.js.map +1 -1
  186. package/dist/write/pre-state.d.ts +197 -6
  187. package/dist/write/pre-state.js +427 -20
  188. package/dist/write/pre-state.js.map +1 -1
  189. package/dist/write/recurrence-rule-blob.d.ts +65 -0
  190. package/dist/write/recurrence-rule-blob.js +150 -0
  191. package/dist/write/recurrence-rule-blob.js.map +1 -0
  192. package/dist/write/reorder.d.ts +23 -3
  193. package/dist/write/reorder.js +1871 -85
  194. package/dist/write/reorder.js.map +1 -1
  195. package/dist/write/reversibility.js +19 -7
  196. package/dist/write/reversibility.js.map +1 -1
  197. package/dist/write/scope-guard.d.ts +31 -0
  198. package/dist/write/scope-guard.js +162 -0
  199. package/dist/write/scope-guard.js.map +1 -0
  200. package/dist/write/undo.js +120 -13
  201. package/dist/write/undo.js.map +1 -1
  202. package/dist/write/vectors/applescript.js +16 -7
  203. package/dist/write/vectors/applescript.js.map +1 -1
  204. package/dist/write/vectors/registry.d.ts +5 -7
  205. package/dist/write/vectors/registry.js +49 -2
  206. package/dist/write/vectors/registry.js.map +1 -1
  207. package/dist/write/vectors/shortcuts.js +1 -1
  208. package/dist/write/vectors/shortcuts.js.map +1 -1
  209. package/dist/write/vectors/simulator.d.ts +44 -0
  210. package/dist/write/vectors/simulator.js +1192 -0
  211. package/dist/write/vectors/simulator.js.map +1 -0
  212. package/dist/write/vectors/types.d.ts +46 -0
  213. package/dist/write/vectors/ui-certification.d.ts +10 -4
  214. package/dist/write/vectors/ui-certification.js +43 -10
  215. package/dist/write/vectors/ui-certification.js.map +1 -1
  216. package/dist/write/vectors/ui-drag.d.ts +21 -1
  217. package/dist/write/vectors/ui-drag.js +138 -27
  218. package/dist/write/vectors/ui-drag.js.map +1 -1
  219. package/dist/write/vectors/ui-recipes.d.ts +34 -1
  220. package/dist/write/vectors/ui-recipes.js +217 -6
  221. package/dist/write/vectors/ui-recipes.js.map +1 -1
  222. package/dist/write/vectors/ui.d.ts +40 -1
  223. package/dist/write/vectors/ui.js +196 -39
  224. package/dist/write/vectors/ui.js.map +1 -1
  225. package/dist/write/verify/delta.d.ts +127 -2
  226. package/dist/write/verify/delta.js +255 -16
  227. package/dist/write/verify/delta.js.map +1 -1
  228. package/dist/write/verify/poller.d.ts +7 -1
  229. package/dist/write/verify/poller.js +20 -1
  230. package/dist/write/verify/poller.js.map +1 -1
  231. package/package.json +24 -3
  232. package/schema/envelope.schema.json +383 -0
  233. package/skills/things-cli/SKILL.md +70 -0
  234. package/skills/things-cli/references/banner.md +35 -0
  235. package/skills/things-cli/references/contracts.md +62 -0
  236. package/skills/things-cli/references/errors.md +49 -0
  237. package/skills/things-cli/references/gui.md +11 -0
  238. package/skills/things-cli/references/model.md +60 -0
  239. package/skills/things-cli/references/ordering.md +71 -0
@@ -4,7 +4,9 @@ import { mapProject, mapTodo } from "../model/mappers.js";
4
4
  import { projectOccurrences } from "../model/occurrences.js";
5
5
  import { decodeRecurrenceRule } from "../model/recurrence.js";
6
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";
7
+ import { ANYTIME_SELF, CONTAINER_UNTRASHED, DEADLINE_PULLED, EFF_PROJECT, LIVE, OPEN, OPEN_OR_UNSWEPT, OVERDUE, PROJECT_ANYTIME_ACTIVE, } from "./predicates.js";
8
+ import { isLooseRef } from "./pseudo-area.js";
9
+ import { CANDIDATE_CAP } from "./shape.js";
8
10
  import { groupBySidebar } from "./sidebar-order.js";
9
11
  import { areasView } from "./tags.js";
10
12
  import { compareSearchMatches } from "./search-rank.js";
@@ -80,14 +82,17 @@ function overdueFilter(filter, packedToday) {
80
82
  // view's injected `now` via logBoundary(db, now) so the `logged` flag it stamps
81
83
  // honors the same clock as the SQL membership filter (a pinned test clock, a
82
84
  // lab run) — nothing here silently reaches for real time.
83
- function materialize(db, rows, boundary) {
85
+ // `packedToday` (the view's injected clock, encodePackedDate(localToday(now)))
86
+ // is threaded so the mapper can gate `todaySection` to Today members only —
87
+ // the same clock the SQL membership filter uses.
88
+ function materialize(db, rows, boundary, packedToday) {
84
89
  const refs = makeRefResolver(db);
85
90
  const headingProject = makeHeadingProjectResolver(db);
86
91
  const tags = fetchTagsForTasks(db, rows.map((r) => r.uuid));
87
92
  const items = rows.map((row) => {
88
93
  if (row.type === 1)
89
- return mapProject(row, refs, tags.get(row.uuid) ?? []);
90
- const todo = mapTodo(row, refs, tags.get(row.uuid) ?? []);
94
+ return mapProject(row, refs, tags.get(row.uuid) ?? [], packedToday);
95
+ const todo = mapTodo(row, refs, tags.get(row.uuid) ?? [], packedToday);
91
96
  if (todo.heading !== null) {
92
97
  const p = headingProject(todo.heading.uuid);
93
98
  if (p !== null)
@@ -102,8 +107,8 @@ function materialize(db, rows, boundary) {
102
107
  }
103
108
  /** Badge = OPEN members only (the GUI badge counts remaining work). */
104
109
  const isOpen = (i) => i.status === "open";
105
- export function todayView(db, now, filter) {
106
- const todayIso = localToday(now);
110
+ export function todayView(db, now, filter, zone) {
111
+ const todayIso = localToday(now, zone);
107
112
  const packedToday = encodePackedDate(todayIso);
108
113
  const tf = tagFilter(db, filter);
109
114
  const of = overdueFilter(filter, packedToday);
@@ -122,16 +127,15 @@ export function todayView(db, now, filter) {
122
127
  // has not yet passed keeps its comparator slot in place (GUI-parity ruling
123
128
  // 2026-07-14). The boundary is threaded into both the SQL filter and
124
129
  // materialize so the row's presence and its `logged` flag agree.
125
- const boundary = logBoundary(db, now);
130
+ const boundary = logBoundary(db, now, zone);
126
131
  const rows = fetchTaskRows(db, `${OPEN_OR_UNSWEPT} AND ${CONTAINER_UNTRASHED} AND (
127
132
  (t.startDate IS NOT NULL AND t.startDate <= ? AND t.start IN (1, 2))
128
- OR (t.deadline IS NOT NULL AND t.deadline <= ? AND t.startDate IS NULL
129
- AND (t.deadlineSuppressionDate IS NULL OR t.deadlineSuppressionDate < t.deadline))
133
+ OR ${DEADLINE_PULLED}
130
134
  )${tf.sql}${of.sql}
131
135
  ORDER BY t.startBucket ASC,
132
136
  COALESCE(t.todayIndexReferenceDate, t.startDate, t.deadline) DESC,
133
137
  t.todayIndex ASC, t.uuid ASC`, [boundary.getTime() / 1000, packedToday, packedToday, ...tf.binds, ...of.binds]);
134
- const items = materialize(db, rows, boundary);
138
+ const items = materialize(db, rows, boundary, packedToday);
135
139
  // Evening membership expires daily: raw startBucket=1 counts only while
136
140
  // startDate is exactly today; stale evening items belong to Today proper.
137
141
  const isEvening = (i) => i.todaySection === "evening" && i.startDate === todayIso;
@@ -159,11 +163,13 @@ export function todayView(db, now, filter) {
159
163
  badge: { dueOrOverdue, other: openItems.length - dueOrOverdue },
160
164
  };
161
165
  }
162
- export function inboxView(db, now, filter) {
166
+ export function inboxView(db, now, filter, zone) {
163
167
  const tf = tagFilter(db, filter);
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)));
168
+ // `now` is threaded for the `--overdue` boundary AND for the deadline-pull
169
+ // exclusion below (the inbox order and the since/until window key on epoch
170
+ // creationDate, not on today).
171
+ const packedToday = encodePackedDate(localToday(now, zone));
172
+ const of = overdueFilter(filter, packedToday);
167
173
  const where = [OPEN, "t.start = 0"];
168
174
  const binds = [];
169
175
  // creationDate is an epoch REAL (Unix seconds) — mirror the changes/logbook
@@ -176,11 +182,19 @@ export function inboxView(db, now, filter) {
176
182
  where.push("t.creationDate <= ?");
177
183
  binds.push(filter.until.getTime() / 1000);
178
184
  }
185
+ // R13 (BANNER1 Q1b, law L-A): a due/overdue un-dismissed deadline pulls an
186
+ // undated Inbox row into Today AND removes it from the GUI Inbox list — even
187
+ // while its raw `start` still reads 0. EXCLUDE the pulled rows so the flat
188
+ // inbox view stays GUI-faithful; reuse the Today deadline arm verbatim so the
189
+ // exclusion can never drift from the pull todayView claims. A SUPPRESSED
190
+ // deadline (guard inside DEADLINE_PULLED) is not a pull, so it stays in Inbox.
191
+ where.push(`NOT ${DEADLINE_PULLED}`);
192
+ binds.push(packedToday);
179
193
  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));
194
+ return materialize(db, rows, logBoundary(db, now, zone), encodePackedDate(localToday(now, zone)));
181
195
  }
182
- export function anytimeView(db, now, filter) {
183
- const packedToday = encodePackedDate(localToday(now));
196
+ export function anytimeView(db, now, filter, zone) {
197
+ const packedToday = encodePackedDate(localToday(now, zone));
184
198
  const tf = tagFilter(db, filter);
185
199
  const of = overdueFilter(filter, packedToday);
186
200
  // Mirrors UI membership: every active item, including Today members
@@ -191,18 +205,27 @@ export function anytimeView(db, now, filter) {
191
205
  // the parent project be open, so a closed-but-unswept project's children stay
192
206
  // cascade-excluded — the checked project row represents them. The boundary is
193
207
  // 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")}
208
+ //
209
+ // R13 (BANNER1b, law L-A): Anytime is a SUPERSET of Today's to-dos — a
210
+ // due-deadline pull re-files an undated Inbox/Someday row into Anytime (out of
211
+ // its origin bucket) even while its raw `start` still reads 0/2. So the
212
+ // membership arm is ANYTIME_SELF *OR* the Today deadline-pull arm (reused
213
+ // verbatim from todayView, never re-derived). The container cascade still
214
+ // applies (a pulled child of a non-active project is represented by that
215
+ // project row).
216
+ const boundary = logBoundary(db, now, zone);
217
+ const rows = fetchTaskRows(db, `${OPEN_OR_UNSWEPT} AND (${ANYTIME_SELF("t")} OR ${DEADLINE_PULLED})
196
218
  AND ${PROJECT_ANYTIME_ACTIVE}${tf.sql}${of.sql} ORDER BY t."index" ASC`, [
197
219
  boundary.getTime() / 1000,
198
- packedToday,
199
- packedToday,
200
- packedToday,
201
- packedToday,
220
+ packedToday, // ANYTIME_SELF arm 1
221
+ packedToday, // ANYTIME_SELF arm 2
222
+ packedToday, // DEADLINE_PULLED
223
+ packedToday, // PROJECT_ANYTIME_ACTIVE arm 1
224
+ packedToday, // PROJECT_ANYTIME_ACTIVE arm 2
202
225
  ...tf.binds,
203
226
  ...of.binds,
204
227
  ]);
205
- return groupBySidebar(db, materialize(db, rows, boundary));
228
+ return groupBySidebar(db, materialize(db, rows, boundary, packedToday));
206
229
  }
207
230
  /**
208
231
  * The UI's star marker in Anytime: the item is also a Today member. Mirrors
@@ -217,8 +240,8 @@ export function anytimeView(db, now, filter) {
217
240
  * deadline (deadlineSuppressionDate >= deadline); that column is not surfaced
218
241
  * on the entity, so a suppressed unscheduled overdue row is over-starred here.
219
242
  */
220
- export function isTodayMember(item, now) {
221
- const today = localToday(now);
243
+ export function isTodayMember(item, now, zone) {
244
+ const today = localToday(now, zone);
222
245
  if (item.startDate !== null)
223
246
  return item.startDate <= today;
224
247
  return item.deadline !== null && item.deadline <= today;
@@ -228,9 +251,9 @@ export function isTodayMember(item, now) {
228
251
  * scheduled row groups under its when-date, a forecast row under its deadline.
229
252
  */
230
253
  const groupKey = (i) => i.startDate ?? i.deadline ?? "";
231
- export function upcomingView(db, now, filter) {
232
- const packedToday = encodePackedDate(localToday(now));
233
- const boundary = logBoundary(db, now);
254
+ export function upcomingView(db, now, filter, zone) {
255
+ const packedToday = encodePackedDate(localToday(now, zone));
256
+ const boundary = logBoundary(db, now, zone);
234
257
  const until = filter?.until;
235
258
  const since = filter?.since;
236
259
  const untilBinds = until === undefined ? [] : [encodePackedDate(until)];
@@ -258,8 +281,8 @@ export function upcomingView(db, now, filter) {
258
281
  AND t.deadline IS NOT NULL AND t.deadline > ?
259
282
  AND (t.deadlineSuppressionDate IS NULL OR t.deadlineSuppressionDate < t.deadline)${fcUntilSql}${fcSinceSql}${tf.sql}
260
283
  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);
284
+ const scheduled = materialize(db, rows, boundary, packedToday);
285
+ const forecast = materialize(db, forecastRows, boundary, packedToday);
263
286
  // The merged date-ordered stream keys on COALESCE(startDate, deadline) — a
264
287
  // scheduled row groups under its when-date, a forecast row under its deadline
265
288
  // — then the UI's within-day drag order (todayIndex ASC; live-verified
@@ -289,7 +312,7 @@ export function upcomingView(db, now, filter) {
289
312
  ORDER BY t.rt1_nextInstanceStartDate ASC, t."index" ASC`, [packedToday, ...untilBinds, ...sinceBinds, ...tf.binds]);
290
313
  const horizon = Math.max(1, Math.min(10, Math.trunc(filter?.horizon ?? 1)));
291
314
  const rawByUuid = new Map(templateRows.map((r) => [r.uuid, r.rt1_recurrenceRule]));
292
- const occurrences = materialize(db, templateRows, boundary).flatMap((template) => {
315
+ const occurrences = materialize(db, templateRows, boundary, packedToday).flatMap((template) => {
293
316
  const startDate = template.repeating.nextOccurrence ?? null;
294
317
  if (startDate === null)
295
318
  return [];
@@ -342,7 +365,7 @@ export function upcomingView(db, now, filter) {
342
365
  AND (t.rt1_nextInstanceStartDate IS NULL OR t.rt1_nextInstanceStartDate <= ?
343
366
  OR t.rt1_instanceCreationPaused = 1)${tf.sql}
344
367
  ORDER BY t.todayIndex ASC, t."index" ASC`, [packedToday, ...tf.binds]);
345
- const resting = materialize(db, restingRows, boundary).map((item) => {
368
+ const resting = materialize(db, restingRows, boundary, packedToday).map((item) => {
346
369
  const raw = restingRows.find((r) => r.uuid === item.uuid)?.rt1_recurrenceRule;
347
370
  if (raw !== null && raw !== undefined) {
348
371
  try {
@@ -358,9 +381,9 @@ export function upcomingView(db, now, filter) {
358
381
  }
359
382
  /** A someday to-do nested under a project (directly or via a heading). */
360
383
  const isChild = (i) => i.type === "to-do" && (i.project !== null || i.headingProject !== null);
361
- export function somedayView(db, now, filter) {
384
+ export function somedayView(db, now, filter, zone) {
362
385
  const tf = tagFilter(db, filter);
363
- const packedToday = encodePackedDate(localToday(now));
386
+ const packedToday = encodePackedDate(localToday(now, zone));
364
387
  const of = overdueFilter(filter, packedToday);
365
388
  // Default membership excludes ALL project children (the UI shows only
366
389
  // container-less someday to-dos, area members, and someday project rows);
@@ -370,9 +393,20 @@ export function somedayView(db, now, filter) {
370
393
  ? `EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT}
371
394
  AND p.trashed = 0 AND p.status = 0 AND ${ANYTIME_SELF("p")})`
372
395
  : "0";
396
+ // R13 (BANNER1 Q1b, law L-A): a due/overdue un-dismissed deadline pulls an
397
+ // undated Someday row into Today AND removes it from the GUI Someday list —
398
+ // even while its raw `start` still reads 2. EXCLUDE the pulled rows so the flat
399
+ // someday view stays GUI-faithful; reuse the Today deadline arm verbatim so the
400
+ // exclusion can never drift from the pull todayView claims. A SUPPRESSED
401
+ // deadline (guard inside DEADLINE_PULLED) is not a pull, so it stays in Someday.
373
402
  const rows = fetchTaskRows(db, `${OPEN} AND t.start = 2 AND t.startDate IS NULL
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)));
403
+ AND (${EFF_PROJECT} IS NULL OR ${childArm}) AND NOT ${DEADLINE_PULLED}${tf.sql}${of.sql} ORDER BY t."index" ASC`, [
404
+ ...(withActiveChildren ? [packedToday, packedToday] : []),
405
+ packedToday, // DEADLINE_PULLED exclusion
406
+ ...tf.binds,
407
+ ...of.binds,
408
+ ]);
409
+ const sections = groupBySidebar(db, materialize(db, rows, logBoundary(db, now, zone), encodePackedDate(localToday(now, zone))));
376
410
  // GUI order within a Someday group (live side-by-side, 2026-07-12):
377
411
  // PROJECT rows first (their sidebar order preserved), then direct to-dos in
378
412
  // drag order. Someday children of active projects (the activeProjectItems
@@ -387,7 +421,7 @@ export function somedayView(db, now, filter) {
387
421
  ],
388
422
  }));
389
423
  }
390
- export function logbookView(db, now, options) {
424
+ export function logbookView(db, now, options, zone) {
391
425
  const cap = options?.limit === null ? null : (options?.limit ?? 100);
392
426
  const tf = tagFilter(db, options);
393
427
  const where = [];
@@ -397,9 +431,17 @@ export function logbookView(db, now, options) {
397
431
  binds.push(resolveProjectUuid(db, options.project));
398
432
  }
399
433
  if (options?.area !== undefined) {
400
- const areaUuid = resolveAreaUuid(db, options.area);
401
- where.push(`(t.area = ? OR EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT} AND p.area = ?))`);
402
- binds.push(areaUuid, areaUuid);
434
+ if (isLooseRef(options.area)) {
435
+ // `--area loose`: the area-less logged rows effective area is null (own
436
+ // area null AND no area-carrying containing project). The inversion of the
437
+ // real-area subtree keep-rule below.
438
+ where.push(`t.area IS NULL AND (${EFF_PROJECT} IS NULL OR NOT EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT} AND p.area IS NOT NULL))`);
439
+ }
440
+ else {
441
+ const areaUuid = resolveAreaUuid(db, options.area);
442
+ where.push(`(t.area = ? OR EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT} AND p.area = ?))`);
443
+ binds.push(areaUuid, areaUuid);
444
+ }
403
445
  }
404
446
  if (options?.since !== undefined) {
405
447
  where.push("t.stopDate >= ?");
@@ -414,21 +456,40 @@ export function logbookView(db, now, options) {
414
456
  // checked in their original lists, exactly like the GUI's Logbook. The
415
457
  // membership boundary rides the injected clock (threaded into both the SQL
416
458
  // filter and materialize so presence and the `logged` flag agree).
417
- const boundary = logBoundary(db, now);
459
+ const boundary = logBoundary(db, now, zone);
418
460
  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);
461
+ return materialize(db, rows, boundary, encodePackedDate(localToday(now, zone)));
420
462
  }
421
- export function trashView(db, now, options) {
463
+ /**
464
+ * A cheap COUNT of one area's logged rows — the SAME subtree-inclusive scope
465
+ * (own area OR containing project's area) and log-move boundary logbookView
466
+ * applies, without materializing a single row. Feeds the `area show` TTY card's
467
+ * live logbook-footer count; real areas only (the loose pseudo-area accumulates
468
+ * no `logbook --area` archive).
469
+ */
470
+ export function areaLoggedCount(db, areaUuid, now, zone) {
471
+ const boundary = logBoundary(db, now, zone);
472
+ const row = db
473
+ .prepare(`SELECT COUNT(*) AS n FROM TMTask t
474
+ WHERE ${LIVE} AND t.status IN (2, 3) AND t.stopDate <= ?
475
+ AND (t.area = ? OR EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT} AND p.area = ?))`)
476
+ .get(boundary.getTime() / 1000, areaUuid, areaUuid);
477
+ return row.n;
478
+ }
479
+ export function trashView(db, now, options, zone) {
422
480
  const cap = options?.limit === null ? null : (options?.limit ?? 200);
423
481
  const rows = fetchTaskRows(db, `t.type IN (0, 1) AND t.trashed = 1 AND ${NOT_TEMPLATE}
424
482
  ORDER BY t.userModificationDate DESC${cap === null ? "" : " LIMIT ?"}`, cap === null ? [] : [cap]);
425
- return materialize(db, rows, logBoundary(db, now));
483
+ return materialize(db, rows, logBoundary(db, now, zone), encodePackedDate(localToday(now, zone)));
426
484
  }
427
485
  export function projectsView(db, options) {
428
486
  // areaUuid accepts a uuid OR a unique (case-insensitive) title; ambiguous
429
- // or unknown references throw like every other ref resolver.
430
- const area = options?.areaUuid === undefined ? null : resolveAreaUuid(db, options.areaUuid);
431
- const packedToday = encodePackedDate(localToday(options?.now));
487
+ // or unknown references throw like every other ref resolver. The reserved
488
+ // `loose` ref scopes to the NULL area (area-less projects) — the sidebar's
489
+ // leading loose block — with no uuid to resolve.
490
+ const loose = options?.areaUuid !== undefined && isLooseRef(options.areaUuid);
491
+ const area = options?.areaUuid === undefined || loose ? null : resolveAreaUuid(db, options.areaUuid);
492
+ const packedToday = encodePackedDate(localToday(options?.now, options?.zone));
432
493
  // Tag scope (§9a): the projects LIST is a FLAT view (like `anytime` restricted
433
494
  // to project rows), NOT a single-container view — projects sit in different
434
495
  // areas with heterogeneous inheritance, there is no universally-inherited tag
@@ -457,8 +518,11 @@ export function projectsView(db, options) {
457
518
  // (area-less) projects first in their own drag order — the GUI lists them
458
519
  // above the areas — then each area by ITS sidebar rank (TMArea."index"),
459
520
  // projects within an area by their drag order.
460
- const where = area
461
- ? `${OPEN} AND t.type = 1 AND t.area = ?${laterSql}${overdueSql}${tf.sql}
521
+ // A single-group flat listing (loose OR a real area) shares one ORDER shape;
522
+ // only the unscoped sidebar catalogue interleaves areas by their rank.
523
+ const areaClause = loose ? " AND t.area IS NULL" : area ? " AND t.area = ?" : "";
524
+ const where = loose || area
525
+ ? `${OPEN} AND t.type = 1${areaClause}${laterSql}${overdueSql}${tf.sql}
462
526
  ORDER BY ${activeFirst}t."index" ASC`
463
527
  : `${OPEN} AND t.type = 1${laterSql}${overdueSql}${tf.sql} ORDER BY (t.area IS NOT NULL) ASC,
464
528
  (SELECT a."index" FROM TMArea a WHERE a.uuid = t.area) ASC, ${activeFirst}t."index" ASC`;
@@ -469,7 +533,7 @@ export function projectsView(db, options) {
469
533
  ...tf.binds,
470
534
  ...activeFirstBinds,
471
535
  ]);
472
- const items = materialize(db, rows, logBoundary(db, options?.now));
536
+ const items = materialize(db, rows, logBoundary(db, options?.now, options?.zone), packedToday);
473
537
  if (options?.later !== true)
474
538
  return items;
475
539
  // Each group's later sub-block reads like Upcoming: SCHEDULED projects
@@ -477,7 +541,7 @@ export function projectsView(db, options) {
477
541
  // hidden on entities but present on the raw rows) — then someday in drag
478
542
  // order. The SQL already made group runs contiguous with actives leading,
479
543
  // so only the later runs are re-sorted (stable within someday).
480
- const todayIso = localToday(options?.now);
544
+ const todayIso = localToday(options?.now, options?.zone);
481
545
  const ti = new Map(rows.map((r) => [r.uuid, r.todayIndex ?? 0]));
482
546
  const isLater = (p) => p.startDate !== null ? p.startDate > todayIso : p.start === "someday";
483
547
  const out = [];
@@ -523,13 +587,14 @@ export function projectsView(db, options) {
523
587
  * carry no modification dates, and checklist-item edits do not bump the
524
588
  * parent task — those changes are invisible here.
525
589
  */
526
- export function changesView(db, now, options) {
590
+ export function changesView(db, now, options, zone) {
527
591
  const cap = options.limit === null ? null : (options.limit ?? 200);
528
592
  const sinceEpoch = options.since.getTime() / 1000;
529
593
  const rows = fetchTaskRows(db, `t.type IN (0, 1) AND t.userModificationDate > ?
530
594
  ORDER BY t.userModificationDate DESC${cap === null ? "" : " LIMIT ?"}`, [sinceEpoch, ...(cap === null ? [] : [cap])]);
595
+ const changed = materialize(db, rows, logBoundary(db, now, zone), encodePackedDate(localToday(now, zone)));
531
596
  // oxlint-disable-next-line no-map-spread -- building fresh change objects, not mutating
532
- return materialize(db, rows, logBoundary(db, now)).map((item, i) => ({
597
+ return changed.map((item, i) => ({
533
598
  ...item,
534
599
  changeKind: (rows[i]?.creationDate ?? 0) > sinceEpoch ? "created" : "modified",
535
600
  }));
@@ -541,16 +606,16 @@ export function changesView(db, now, options) {
541
606
  * doctrine: containers (areas, then projects) first, then to-dos; within a
542
607
  * group active rows precede someday rows, then most-recently-modified. `type`
543
608
  * 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.
609
+ * (default {@link CANDIDATE_CAP}); `total` reports the pre-cap match count so the
610
+ * caller can append a "… n more" tail.
546
611
  */
547
612
  const somedayRank = (i) => (i.start === "someday" ? 1 : 0);
548
613
  /** Lite-search order within a task group: active before someday, then most-recently-modified. */
549
614
  function byStatusThenRecent(a, b) {
550
615
  return somedayRank(a) - somedayRank(b) || b.modified.getTime() - a.modified.getTime();
551
616
  }
552
- export function liteTitleSearch(db, query, options, now) {
553
- const cap = options?.limit ?? 10;
617
+ export function liteTitleSearch(db, query, options, now, zone) {
618
+ const cap = options?.limit ?? CANDIDATE_CAP;
554
619
  const type = options?.type;
555
620
  const needle = query.toLowerCase();
556
621
  const areas = type === undefined || type === "area"
@@ -562,7 +627,7 @@ export function liteTitleSearch(db, query, options, now) {
562
627
  if (type === undefined || type === "to-do" || type === "project") {
563
628
  const typeSql = type === "to-do" ? "t.type = 0" : type === "project" ? "t.type = 1" : "t.type IN (0, 1)";
564
629
  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));
630
+ tasks = materialize(db, rows, logBoundary(db, now, zone), encodePackedDate(localToday(now, zone)));
566
631
  }
567
632
  const projects = tasks.filter((t) => t.type === "project").toSorted(byStatusThenRecent);
568
633
  const todos = tasks.filter((t) => t.type === "to-do").toSorted(byStatusThenRecent);
@@ -571,18 +636,56 @@ export function liteTitleSearch(db, query, options, now) {
571
636
  ...projects.map((task) => ({ kind: "task", task })),
572
637
  ...todos.map((task) => ({ kind: "task", task })),
573
638
  ];
574
- return { candidates: ordered.slice(0, cap), total: ordered.length };
639
+ // Zero LIVE matches: probe whether the name matches DEAD rows (trashed, or
640
+ // swept to the logbook) so the caller gets an honest hint instead of a bare
641
+ // miss — the dead rows themselves never become candidates (areas can be
642
+ // neither trashed nor logged, so only tasks are counted).
643
+ let deadMatches;
644
+ if (ordered.length === 0 && type !== "area") {
645
+ const typeSql = type === "to-do" ? "t.type = 0" : type === "project" ? "t.type = 1" : "t.type IN (0, 1)";
646
+ const boundary = logBoundary(db, now, zone).getTime() / 1000;
647
+ const row = db
648
+ .prepare(`SELECT
649
+ SUM(CASE WHEN t.trashed = 1 THEN 1 ELSE 0 END) AS trashed,
650
+ SUM(CASE WHEN t.trashed = 0 AND t.status IN (2, 3) AND t.stopDate IS NOT NULL AND t.stopDate <= ? THEN 1 ELSE 0 END) AS logbook
651
+ FROM TMTask t
652
+ WHERE ${NOT_TEMPLATE} AND ${typeSql} AND t.title LIKE ?${LIKE_ESCAPE}`)
653
+ .get(boundary, `%${escapeLike(query)}%`);
654
+ const trashed = row.trashed ?? 0;
655
+ const logbook = row.logbook ?? 0;
656
+ if (trashed > 0 || logbook > 0)
657
+ deadMatches = { trashed, logbook };
658
+ }
659
+ return {
660
+ candidates: ordered.slice(0, cap),
661
+ total: ordered.length,
662
+ ...(deadMatches !== undefined && { deadMatches }),
663
+ };
664
+ }
665
+ /** A one-line notes snippet (~80 chars) centered on the first occurrence of the needle. */
666
+ const SNIPPET_MAX = 80;
667
+ function notesSnippet(notes, needleLower) {
668
+ const flat = notes.replace(/\s+/g, " ").trim();
669
+ if (flat.length <= SNIPPET_MAX)
670
+ return flat;
671
+ const hit = Math.max(0, flat.toLowerCase().indexOf(needleLower));
672
+ const half = Math.max(0, Math.floor((SNIPPET_MAX - needleLower.length) / 2));
673
+ let end = Math.min(flat.length, Math.max(hit - half, 0) + SNIPPET_MAX);
674
+ const start = Math.max(0, end - SNIPPET_MAX);
675
+ const core = flat.slice(start, end);
676
+ return `${start > 0 ? "…" : ""}${core}${end < flat.length ? "…" : ""}`;
575
677
  }
576
- export function searchView(db, query, options, now) {
678
+ export function searchView(db, query, options, now, zone) {
577
679
  const cap = options?.limit === null ? null : (options?.limit ?? 50);
578
680
  const needle = `%${escapeLike(query)}%`;
579
681
  // `--overdue` narrows to OPEN, past-deadline matches. Its open-only predicate
580
682
  // is contradictory with the status-widening flags (logged/trashed/all); the
581
683
  // CLI/MCP surfaces reject that combination, so here it simply intersects.
582
- const of = overdueFilter(options, encodePackedDate(localToday(now)));
684
+ const packedToday = encodePackedDate(localToday(now, zone));
685
+ const of = overdueFilter(options, packedToday);
583
686
  // Threaded into every materialize so the `logged` flag (--logged widens to
584
687
  // closed rows) honors the injected clock, not wall time.
585
- const boundary = logBoundary(db, now);
688
+ const boundary = logBoundary(db, now, zone);
586
689
  // The scope predicates (everything except the match needle), reused verbatim
587
690
  // for the needle query AND the heading-credited-project query so both honor
588
691
  // the same type/status/scope constraints.
@@ -609,24 +712,49 @@ export function searchView(db, query, options, now) {
609
712
  scopeBinds.push(uuid, uuid);
610
713
  }
611
714
  if (options?.area !== undefined) {
612
- const uuid = resolveAreaUuid(db, options.area);
613
- scope.push("t.area = ?");
614
- scopeBinds.push(uuid);
715
+ if (isLooseRef(options.area)) {
716
+ // `--area loose`: the area-less hits — effective area is null (own area
717
+ // null AND no area-carrying containing project). The inversion of the
718
+ // real-area subtree keep-rule below; inbox captures (area-less) are
719
+ // legitimately included.
720
+ scope.push(`t.area IS NULL AND (${EFF_PROJECT} IS NULL OR NOT EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT} AND p.area IS NOT NULL))`);
721
+ }
722
+ else {
723
+ // Real-area filter is SUBTREE-INCLUSIVE (own area OR containing project's
724
+ // area) — the same effective-area shape logbookView uses, so every --area
725
+ // view scopes to the whole subtree, not just an area's direct members.
726
+ const uuid = resolveAreaUuid(db, options.area);
727
+ scope.push(`(t.area = ? OR EXISTS (SELECT 1 FROM TMTask p WHERE p.uuid = ${EFF_PROJECT} AND p.area = ?))`);
728
+ scopeBinds.push(uuid, uuid);
729
+ }
615
730
  }
616
731
  const tf = tagFilter(db, options);
617
732
  // Needle matches: title OR notes. No SQL LIMIT — ranking runs before the cap.
618
733
  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
734
  const needleLower = query.toLowerCase();
620
735
  const matches = new Map();
621
- for (const item of materialize(db, rows, boundary)) {
622
- // Field credit: title beats notes when both carry the substring.
736
+ /** Attach a match annotation to a materialized hit (title matches carry none). */
737
+ const annotate = (item, ann) => {
738
+ if (ann !== undefined)
739
+ item.match = ann;
740
+ };
741
+ for (const item of materialize(db, rows, boundary, packedToday)) {
742
+ // Field credit: title beats notes when both carry the substring. A notes-only
743
+ // hit is annotated with a bounded snippet centered on the match; a title hit
744
+ // carries no annotation (presence-keyed — matched where you'd expect).
623
745
  const field = item.title.toLowerCase().includes(needleLower) ? "title" : "notes";
746
+ if (field === "notes") {
747
+ annotate(item, { field: "notes", text: notesSnippet(item.notes ?? "", needleLower) });
748
+ }
624
749
  matches.set(item.uuid, { item, field });
625
750
  }
626
751
  // Heading titles are treated as if they lived in the parent PROJECT's notes:
627
752
  // 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.
753
+ // row), credited at the heading field rank. A to-do-only search has no project
754
+ // rows, so headings do not apply there. Heading OUTRANKS notes for the
755
+ // annotation (precedence title > heading > notes): a project matched by its
756
+ // own notes AND by a child heading shows the heading annotation, though it
757
+ // keeps its notes rank; a title-matched project keeps NO annotation.
630
758
  if (options?.type !== "to-do") {
631
759
  const headingRows = db
632
760
  .prepare(`SELECT project AS projectUuid, title FROM TMTask
@@ -637,21 +765,51 @@ export function searchView(db, query, options, now) {
637
765
  if (!headingTitleFor.has(h.projectUuid))
638
766
  headingTitleFor.set(h.projectUuid, h.title ?? "");
639
767
  }
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));
768
+ // Projects ALREADY matched: re-credit a notes annotation to the heading
769
+ // (title stays annotation-free). Projects NOT yet matched: materialize and
770
+ // add them at the heading rank. Both only where the view's scope allows.
771
+ const wanted = [];
772
+ for (const [uuid, headingTitle] of headingTitleFor) {
773
+ const existing = matches.get(uuid);
774
+ if (existing === undefined) {
775
+ wanted.push(uuid);
776
+ }
777
+ else if (existing.item.type === "project" && existing.field === "notes") {
778
+ annotate(existing.item, { field: "heading", text: headingTitle });
779
+ }
780
+ }
644
781
  if (wanted.length > 0) {
645
782
  const placeholders = wanted.map(() => "?").join(", ");
646
783
  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 });
784
+ for (const item of materialize(db, projectRows, boundary, packedToday)) {
785
+ annotate(item, { field: "heading", text: headingTitleFor.get(item.uuid) ?? "" });
786
+ matches.set(item.uuid, { item, field: "heading" });
787
+ }
788
+ }
789
+ }
790
+ // Checklist-item titles are treated as properties of the parent TO-DO (like
791
+ // notes): a checklist-body match surfaces the parent to-do (deduped — one to-do
792
+ // no matter how many of its rows match), annotated with the FIRST matching
793
+ // row's title. Checklist is the lowest-precedence field, so it only adds to-dos
794
+ // not already matched by their own title/notes (those keep the higher credit).
795
+ // Checklist-item uuids appear on NO surface — the join reads titles only.
796
+ if (options?.type !== "project") {
797
+ const checklistRows = db
798
+ .prepare(`SELECT task AS todoUuid, title FROM TMChecklistItem
799
+ WHERE task IS NOT NULL AND title LIKE ?${LIKE_ESCAPE} ORDER BY "index" ASC`)
800
+ .all(needle);
801
+ const checklistTitleFor = new Map();
802
+ for (const c of checklistRows) {
803
+ if (!checklistTitleFor.has(c.todoUuid))
804
+ checklistTitleFor.set(c.todoUuid, c.title ?? "");
805
+ }
806
+ const wanted = [...checklistTitleFor.keys()].filter((uuid) => !matches.has(uuid));
807
+ if (wanted.length > 0) {
808
+ const placeholders = wanted.map(() => "?").join(", ");
809
+ const todoRows = fetchTaskRows(db, `${scope.join(" AND ")} AND t.type = 0 AND t.uuid IN (${placeholders})${tf.sql}${of.sql}`, [...scopeBinds, ...wanted, ...tf.binds, ...of.binds]);
810
+ for (const item of materialize(db, todoRows, boundary, packedToday)) {
811
+ annotate(item, { field: "checklist", text: checklistTitleFor.get(item.uuid) ?? "" });
812
+ matches.set(item.uuid, { item, field: "checklist" });
655
813
  }
656
814
  }
657
815
  }