@tashks/core 0.1.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.
@@ -0,0 +1,673 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import * as Cause from "effect/Cause";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Exit from "effect/Exit";
8
+ import * as Option from "effect/Option";
9
+ import { applyPerspectiveToTasks, byCreatedAsc, byDueAsc, byEnergyAsc, byUpdatedDescThenTitle, hasEnergy, hasProject, hasTag, isBlocked, isStalerThan, isDeferred, isDueBefore, isDueThisWeek, isUnblocked, loadPerspectiveConfig, resolveRelativeDate, wasCompletedBetween, wasCompletedOn, } from "./query.js";
10
+ const makeTask = (overrides) => ({
11
+ id: overrides.id,
12
+ title: overrides.title,
13
+ status: overrides.status ?? "active",
14
+ area: overrides.area ?? "personal",
15
+ project: overrides.project ?? null,
16
+ tags: overrides.tags ?? [],
17
+ created: overrides.created ?? "2026-02-25",
18
+ updated: overrides.updated ?? "2026-02-25",
19
+ urgency: overrides.urgency ?? "medium",
20
+ energy: overrides.energy ?? "medium",
21
+ due: overrides.due ?? null,
22
+ context: overrides.context ?? "",
23
+ subtasks: overrides.subtasks ?? [],
24
+ blocked_by: overrides.blocked_by ?? [],
25
+ estimated_minutes: overrides.estimated_minutes ?? null,
26
+ actual_minutes: overrides.actual_minutes ?? null,
27
+ completed_at: overrides.completed_at ?? null,
28
+ last_surfaced: overrides.last_surfaced ?? null,
29
+ defer_until: overrides.defer_until ?? null,
30
+ nudge_count: overrides.nudge_count ?? 0,
31
+ recurrence: overrides.recurrence ?? null,
32
+ recurrence_trigger: overrides.recurrence_trigger ?? "clock",
33
+ recurrence_strategy: overrides.recurrence_strategy ?? "replace",
34
+ recurrence_last_generated: overrides.recurrence_last_generated ?? null,
35
+ });
36
+ const writePerspectiveConfig = async (dataDir, source) => {
37
+ await mkdir(dataDir, { recursive: true });
38
+ await writeFile(join(dataDir, "perspectives.yaml"), source, "utf8");
39
+ };
40
+ describe("query dependency predicates", () => {
41
+ it("isBlocked returns true when any blocker exists and is not done", () => {
42
+ const blocker = makeTask({
43
+ id: "setup-router",
44
+ title: "Set up router",
45
+ status: "active",
46
+ });
47
+ const task = makeTask({
48
+ id: "wire-rack",
49
+ title: "Wire rack",
50
+ blocked_by: ["setup-router"],
51
+ });
52
+ expect(isBlocked(task, [task, blocker])).toBe(true);
53
+ expect(isUnblocked(task, [task, blocker])).toBe(false);
54
+ });
55
+ it("isBlocked returns false when every blocker is done", () => {
56
+ const blocker = makeTask({
57
+ id: "setup-router",
58
+ title: "Set up router",
59
+ status: "done",
60
+ });
61
+ const task = makeTask({
62
+ id: "wire-rack",
63
+ title: "Wire rack",
64
+ blocked_by: ["setup-router"],
65
+ });
66
+ expect(isBlocked(task, [task, blocker])).toBe(false);
67
+ expect(isUnblocked(task, [task, blocker])).toBe(true);
68
+ });
69
+ it("isBlocked returns false for unknown blocker ids", () => {
70
+ const task = makeTask({
71
+ id: "wire-rack",
72
+ title: "Wire rack",
73
+ blocked_by: ["missing-task"],
74
+ });
75
+ expect(isBlocked(task, [task])).toBe(false);
76
+ expect(isUnblocked(task, [task])).toBe(true);
77
+ });
78
+ });
79
+ describe("query date predicates", () => {
80
+ it("isDueBefore matches tasks with due dates on or before the cutoff", () => {
81
+ const cutoff = "2026-03-01";
82
+ const dueBefore = makeTask({
83
+ id: "renew-domain",
84
+ title: "Renew domain",
85
+ due: "2026-02-28",
86
+ });
87
+ const dueOnCutoff = makeTask({
88
+ id: "pay-rent",
89
+ title: "Pay rent",
90
+ due: "2026-03-01",
91
+ });
92
+ const dueAfter = makeTask({
93
+ id: "replace-battery",
94
+ title: "Replace battery",
95
+ due: "2026-03-02",
96
+ });
97
+ const noDue = makeTask({
98
+ id: "cleanup-notes",
99
+ title: "Cleanup notes",
100
+ due: null,
101
+ });
102
+ expect(isDueBefore(cutoff)(dueBefore)).toBe(true);
103
+ expect(isDueBefore(cutoff)(dueOnCutoff)).toBe(true);
104
+ expect(isDueBefore(cutoff)(dueAfter)).toBe(false);
105
+ expect(isDueBefore(cutoff)(noDue)).toBe(false);
106
+ });
107
+ it("isDueThisWeek matches due dates from today through the next 6 days", () => {
108
+ const today = "2026-02-25";
109
+ const dueToday = makeTask({
110
+ id: "inbox-zero",
111
+ title: "Inbox zero",
112
+ due: "2026-02-25",
113
+ });
114
+ const dueInWeek = makeTask({
115
+ id: "book-flights",
116
+ title: "Book flights",
117
+ due: "2026-03-03",
118
+ });
119
+ const duePast = makeTask({
120
+ id: "file-receipts",
121
+ title: "File receipts",
122
+ due: "2026-02-24",
123
+ });
124
+ const dueBeyondWindow = makeTask({
125
+ id: "plan-trip",
126
+ title: "Plan trip",
127
+ due: "2026-03-04",
128
+ });
129
+ const noDue = makeTask({
130
+ id: "tidy-docs",
131
+ title: "Tidy docs",
132
+ due: null,
133
+ });
134
+ expect(isDueThisWeek(today)(dueToday)).toBe(true);
135
+ expect(isDueThisWeek(today)(dueInWeek)).toBe(true);
136
+ expect(isDueThisWeek(today)(duePast)).toBe(false);
137
+ expect(isDueThisWeek(today)(dueBeyondWindow)).toBe(false);
138
+ expect(isDueThisWeek(today)(noDue)).toBe(false);
139
+ });
140
+ it("isDeferred matches tasks hidden until a future defer date", () => {
141
+ const today = "2026-02-25";
142
+ const deferred = makeTask({
143
+ id: "audit-logs",
144
+ title: "Audit logs",
145
+ defer_until: "2026-02-26",
146
+ });
147
+ const availableToday = makeTask({
148
+ id: "patch-host",
149
+ title: "Patch host",
150
+ defer_until: "2026-02-25",
151
+ });
152
+ const overdueDefer = makeTask({
153
+ id: "rotate-keys",
154
+ title: "Rotate keys",
155
+ defer_until: "2026-02-24",
156
+ });
157
+ const noDefer = makeTask({
158
+ id: "clean-up",
159
+ title: "Clean up",
160
+ defer_until: null,
161
+ });
162
+ expect(isDeferred(today)(deferred)).toBe(true);
163
+ expect(isDeferred(today)(availableToday)).toBe(false);
164
+ expect(isDeferred(today)(overdueDefer)).toBe(false);
165
+ expect(isDeferred(today)(noDefer)).toBe(false);
166
+ });
167
+ });
168
+ describe("query metadata predicates", () => {
169
+ it("filters by energy level", () => {
170
+ const lowEnergy = makeTask({
171
+ id: "read-spec",
172
+ title: "Read spec",
173
+ energy: "low",
174
+ });
175
+ const highEnergy = makeTask({
176
+ id: "refactor-repo",
177
+ title: "Refactor repo",
178
+ energy: "high",
179
+ });
180
+ expect(hasEnergy("low")(lowEnergy)).toBe(true);
181
+ expect(hasEnergy("low")(highEnergy)).toBe(false);
182
+ });
183
+ it("filters by tag", () => {
184
+ const tagged = makeTask({
185
+ id: "buy-cables",
186
+ title: "Buy cables",
187
+ tags: ["errands", "hardware"],
188
+ });
189
+ const untagged = makeTask({
190
+ id: "write-docs",
191
+ title: "Write docs",
192
+ tags: ["writing"],
193
+ });
194
+ expect(hasTag("hardware")(tagged)).toBe(true);
195
+ expect(hasTag("hardware")(untagged)).toBe(false);
196
+ });
197
+ it("filters by project", () => {
198
+ const withProject = makeTask({
199
+ id: "rack-layout",
200
+ title: "Plan rack layout",
201
+ project: "homelab",
202
+ });
203
+ const withOtherProject = makeTask({
204
+ id: "site-redesign",
205
+ title: "Site redesign",
206
+ project: "blog-refresh",
207
+ });
208
+ const withoutProject = makeTask({
209
+ id: "desk-reset",
210
+ title: "Desk reset",
211
+ project: null,
212
+ });
213
+ expect(hasProject("homelab")(withProject)).toBe(true);
214
+ expect(hasProject("homelab")(withOtherProject)).toBe(false);
215
+ expect(hasProject("homelab")(withoutProject)).toBe(false);
216
+ });
217
+ });
218
+ describe("query staleness predicates", () => {
219
+ it("treats tasks older than threshold days as stale", () => {
220
+ const stale = makeTask({
221
+ id: "stale-task",
222
+ title: "Stale task",
223
+ updated: "2026-02-10",
224
+ });
225
+ const boundary = makeTask({
226
+ id: "boundary-task",
227
+ title: "Boundary task",
228
+ updated: "2026-02-11",
229
+ });
230
+ expect(isStalerThan(14, "2026-02-25")(stale)).toBe(true);
231
+ expect(isStalerThan(14, "2026-02-25")(boundary)).toBe(false);
232
+ });
233
+ it("returns false when dates are invalid", () => {
234
+ const invalidUpdated = makeTask({
235
+ id: "invalid-updated",
236
+ title: "Invalid updated",
237
+ updated: "not-a-date",
238
+ });
239
+ expect(isStalerThan(3, "2026-02-25")(invalidUpdated)).toBe(false);
240
+ expect(isStalerThan(3, "not-a-date")(invalidUpdated)).toBe(false);
241
+ });
242
+ });
243
+ describe("query completion predicates", () => {
244
+ it("matches tasks completed on a specific date", () => {
245
+ const completed = makeTask({
246
+ id: "publish-post",
247
+ title: "Publish post",
248
+ completed_at: "2026-02-25T10:15:00Z",
249
+ });
250
+ const differentDate = makeTask({
251
+ id: "ship-patch",
252
+ title: "Ship patch",
253
+ completed_at: "2026-02-24T23:59:59Z",
254
+ });
255
+ const incomplete = makeTask({
256
+ id: "open-task",
257
+ title: "Open task",
258
+ completed_at: null,
259
+ });
260
+ const invalidCompleted = makeTask({
261
+ id: "invalid-completed",
262
+ title: "Invalid completed",
263
+ completed_at: "nope",
264
+ });
265
+ expect(wasCompletedOn("2026-02-25")(completed)).toBe(true);
266
+ expect(wasCompletedOn("2026-02-25")(differentDate)).toBe(false);
267
+ expect(wasCompletedOn("2026-02-25")(incomplete)).toBe(false);
268
+ expect(wasCompletedOn("2026-02-25")(invalidCompleted)).toBe(false);
269
+ });
270
+ it("matches completion dates within inclusive boundaries", () => {
271
+ const startBoundary = makeTask({
272
+ id: "start-boundary",
273
+ title: "Start boundary",
274
+ completed_at: "2026-02-20T00:00:00Z",
275
+ });
276
+ const middle = makeTask({
277
+ id: "middle-boundary",
278
+ title: "Middle boundary",
279
+ completed_at: "2026-02-22T09:30:00Z",
280
+ });
281
+ const endBoundary = makeTask({
282
+ id: "end-boundary",
283
+ title: "End boundary",
284
+ completed_at: "2026-02-25T23:59:59Z",
285
+ });
286
+ const outside = makeTask({
287
+ id: "outside-range",
288
+ title: "Outside range",
289
+ completed_at: "2026-02-26T00:00:00Z",
290
+ });
291
+ const invalidCompleted = makeTask({
292
+ id: "invalid-range-date",
293
+ title: "Invalid range date",
294
+ completed_at: "invalid",
295
+ });
296
+ const withinWindow = wasCompletedBetween("2026-02-20", "2026-02-25");
297
+ expect(withinWindow(startBoundary)).toBe(true);
298
+ expect(withinWindow(middle)).toBe(true);
299
+ expect(withinWindow(endBoundary)).toBe(true);
300
+ expect(withinWindow(outside)).toBe(false);
301
+ expect(withinWindow(invalidCompleted)).toBe(false);
302
+ });
303
+ });
304
+ describe("query sort helpers", () => {
305
+ it("sorts by due date ascending with null due dates last", () => {
306
+ const earlyDue = makeTask({
307
+ id: "due-early",
308
+ title: "Due early",
309
+ due: "2026-02-26",
310
+ });
311
+ const lateDue = makeTask({
312
+ id: "due-late",
313
+ title: "Due late",
314
+ due: "2026-03-03",
315
+ });
316
+ const noDue = makeTask({
317
+ id: "no-due",
318
+ title: "No due",
319
+ due: null,
320
+ });
321
+ const sorted = [noDue, lateDue, earlyDue].sort(byDueAsc);
322
+ expect(sorted.map((task) => task.id)).toEqual([
323
+ "due-early",
324
+ "due-late",
325
+ "no-due",
326
+ ]);
327
+ });
328
+ it("sorts by energy low to high", () => {
329
+ const high = makeTask({
330
+ id: "high-energy",
331
+ title: "High energy",
332
+ energy: "high",
333
+ });
334
+ const low = makeTask({
335
+ id: "low-energy",
336
+ title: "Low energy",
337
+ energy: "low",
338
+ });
339
+ const medium = makeTask({
340
+ id: "medium-energy",
341
+ title: "Medium energy",
342
+ energy: "medium",
343
+ });
344
+ const sorted = [high, medium, low].sort(byEnergyAsc);
345
+ expect(sorted.map((task) => task.id)).toEqual([
346
+ "low-energy",
347
+ "medium-energy",
348
+ "high-energy",
349
+ ]);
350
+ });
351
+ it("sorts by created date ascending", () => {
352
+ const older = makeTask({
353
+ id: "older-created",
354
+ title: "Older created",
355
+ created: "2026-02-20",
356
+ });
357
+ const newer = makeTask({
358
+ id: "newer-created",
359
+ title: "Newer created",
360
+ created: "2026-02-22",
361
+ });
362
+ const oldest = makeTask({
363
+ id: "oldest-created",
364
+ title: "Oldest created",
365
+ created: "2026-02-19",
366
+ });
367
+ const sorted = [newer, oldest, older].sort(byCreatedAsc);
368
+ expect(sorted.map((task) => task.id)).toEqual([
369
+ "oldest-created",
370
+ "older-created",
371
+ "newer-created",
372
+ ]);
373
+ });
374
+ it("sorts by updated descending then title ascending", () => {
375
+ const newest = makeTask({
376
+ id: "newest",
377
+ title: "Newest item",
378
+ updated: "2026-02-26",
379
+ });
380
+ const alphaAtSameDate = makeTask({
381
+ id: "alpha",
382
+ title: "Alpha",
383
+ updated: "2026-02-25",
384
+ });
385
+ const betaAtSameDate = makeTask({
386
+ id: "beta",
387
+ title: "Beta",
388
+ updated: "2026-02-25",
389
+ });
390
+ const sorted = [betaAtSameDate, newest, alphaAtSameDate].sort(byUpdatedDescThenTitle);
391
+ expect(sorted.map((task) => task.id)).toEqual(["newest", "alpha", "beta"]);
392
+ });
393
+ });
394
+ describe("relative date resolution", () => {
395
+ it("resolves today and +Nd expressions", () => {
396
+ expect(resolveRelativeDate("today", "2026-02-25")).toBe("2026-02-25");
397
+ expect(resolveRelativeDate("+7d", "2026-02-25")).toBe("2026-03-04");
398
+ });
399
+ it("passes through absolute ISO dates", () => {
400
+ expect(resolveRelativeDate("2026-03-01", "2026-02-25")).toBe("2026-03-01");
401
+ });
402
+ it("returns null for unsupported expressions", () => {
403
+ expect(resolveRelativeDate("tomorrow", "2026-02-25")).toBeNull();
404
+ expect(resolveRelativeDate("+2w", "2026-02-25")).toBeNull();
405
+ });
406
+ });
407
+ describe("perspective config loader", () => {
408
+ it("loads perspective config from perspectives.yaml", async () => {
409
+ const dataDir = await mkdtemp(join(tmpdir(), "tasks-perspectives-"));
410
+ try {
411
+ await writePerspectiveConfig(dataDir, [
412
+ "quick-wins:",
413
+ " filters:",
414
+ " status: active",
415
+ " energy: low",
416
+ " unblocked_only: true",
417
+ " sort: updated_desc",
418
+ "due-this-week:",
419
+ " filters:",
420
+ " status: active",
421
+ " due_before: '+7d'",
422
+ " sort: due_asc",
423
+ "done-today:",
424
+ " filters:",
425
+ " status: done",
426
+ " completed_on: today",
427
+ ].join("\n"));
428
+ const config = await Effect.runPromise(loadPerspectiveConfig(dataDir, "2026-02-25"));
429
+ expect(config).toEqual({
430
+ "done-today": {
431
+ filters: {
432
+ completed_on: "2026-02-25",
433
+ status: "done",
434
+ },
435
+ },
436
+ "due-this-week": {
437
+ filters: {
438
+ due_before: "2026-03-04",
439
+ status: "active",
440
+ },
441
+ sort: "due_asc",
442
+ },
443
+ "quick-wins": {
444
+ filters: {
445
+ energy: "low",
446
+ status: "active",
447
+ unblocked_only: true,
448
+ },
449
+ sort: "updated_desc",
450
+ },
451
+ });
452
+ }
453
+ finally {
454
+ await rm(dataDir, { recursive: true, force: true });
455
+ }
456
+ });
457
+ it("returns an empty config when perspectives.yaml does not exist", async () => {
458
+ const dataDir = await mkdtemp(join(tmpdir(), "tasks-perspectives-empty-"));
459
+ try {
460
+ const config = await Effect.runPromise(loadPerspectiveConfig(dataDir));
461
+ expect(config).toEqual({});
462
+ }
463
+ finally {
464
+ await rm(dataDir, { recursive: true, force: true });
465
+ }
466
+ });
467
+ it("fails when perspective config includes invalid relative dates", async () => {
468
+ const dataDir = await mkdtemp(join(tmpdir(), "tasks-perspectives-relative-invalid-"));
469
+ try {
470
+ await writePerspectiveConfig(dataDir, [
471
+ "due-this-week:",
472
+ " filters:",
473
+ " status: active",
474
+ " due_before: tomorrow",
475
+ ].join("\n"));
476
+ const result = await Effect.runPromiseExit(loadPerspectiveConfig(dataDir, "2026-02-25"));
477
+ expect(Exit.isFailure(result)).toBe(true);
478
+ if (Exit.isFailure(result)) {
479
+ const failure = Option.getOrNull(Cause.failureOption(result.cause));
480
+ expect(failure).toBe(`Perspective config loader failed: Invalid perspective config in ${join(dataDir, "perspectives.yaml")}`);
481
+ }
482
+ }
483
+ finally {
484
+ await rm(dataDir, { recursive: true, force: true });
485
+ }
486
+ });
487
+ it("fails when perspective config does not match the schema", async () => {
488
+ const dataDir = await mkdtemp(join(tmpdir(), "tasks-perspectives-invalid-"));
489
+ try {
490
+ await writePerspectiveConfig(dataDir, ["broken-perspective:", " filters:", " status: maybe"].join("\n"));
491
+ const result = await Effect.runPromiseExit(loadPerspectiveConfig(dataDir));
492
+ expect(Exit.isFailure(result)).toBe(true);
493
+ if (Exit.isFailure(result)) {
494
+ const failure = Option.getOrNull(Cause.failureOption(result.cause));
495
+ expect(failure).toBe(`Perspective config loader failed: Invalid perspective config in ${join(dataDir, "perspectives.yaml")}`);
496
+ }
497
+ }
498
+ finally {
499
+ await rm(dataDir, { recursive: true, force: true });
500
+ }
501
+ });
502
+ });
503
+ describe("perspective application", () => {
504
+ it("applies all configured filters and due_asc sort", () => {
505
+ const tasks = [
506
+ makeTask({
507
+ id: "eligible-later",
508
+ title: "Eligible later",
509
+ status: "active",
510
+ project: "homelab",
511
+ tags: ["ops", "weekly"],
512
+ energy: "low",
513
+ due: "2026-02-27",
514
+ updated: "2026-02-21",
515
+ }),
516
+ makeTask({
517
+ id: "eligible-earlier",
518
+ title: "Eligible earlier",
519
+ status: "active",
520
+ project: "homelab",
521
+ tags: ["ops"],
522
+ energy: "low",
523
+ due: "2026-02-26",
524
+ updated: "2026-02-20",
525
+ }),
526
+ makeTask({
527
+ id: "blocked-task",
528
+ title: "Blocked task",
529
+ status: "active",
530
+ project: "homelab",
531
+ tags: ["ops"],
532
+ energy: "low",
533
+ due: "2026-02-25",
534
+ blocked_by: ["active-blocker"],
535
+ }),
536
+ makeTask({
537
+ id: "active-blocker",
538
+ title: "Active blocker",
539
+ status: "active",
540
+ project: "homelab",
541
+ tags: ["ops"],
542
+ energy: "high",
543
+ due: null,
544
+ }),
545
+ makeTask({
546
+ id: "wrong-energy",
547
+ title: "Wrong energy",
548
+ status: "active",
549
+ project: "homelab",
550
+ tags: ["ops"],
551
+ energy: "medium",
552
+ due: "2026-02-26",
553
+ }),
554
+ makeTask({
555
+ id: "wrong-project",
556
+ title: "Wrong project",
557
+ status: "active",
558
+ project: "chores",
559
+ tags: ["ops"],
560
+ energy: "low",
561
+ due: "2026-02-26",
562
+ }),
563
+ ];
564
+ const filtered = applyPerspectiveToTasks(tasks, {
565
+ filters: {
566
+ status: "active",
567
+ project: "homelab",
568
+ tags: ["ops"],
569
+ energy: "low",
570
+ due_before: "2026-02-27",
571
+ unblocked_only: true,
572
+ },
573
+ sort: "due_asc",
574
+ }, "2026-02-25");
575
+ expect(filtered.map((task) => task.id)).toEqual([
576
+ "eligible-earlier",
577
+ "eligible-later",
578
+ ]);
579
+ });
580
+ it("sorts by completed_at descending when using done-day perspectives", () => {
581
+ const tasks = [
582
+ makeTask({
583
+ id: "done-morning",
584
+ title: "Done morning",
585
+ status: "done",
586
+ completed_at: "2026-02-25T09:30:00Z",
587
+ }),
588
+ makeTask({
589
+ id: "done-afternoon",
590
+ title: "Done afternoon",
591
+ status: "done",
592
+ completed_at: "2026-02-25T16:45:00Z",
593
+ }),
594
+ makeTask({
595
+ id: "done-other-day",
596
+ title: "Done other day",
597
+ status: "done",
598
+ completed_at: "2026-02-24T12:00:00Z",
599
+ }),
600
+ makeTask({
601
+ id: "active-task",
602
+ title: "Active task",
603
+ status: "active",
604
+ completed_at: null,
605
+ }),
606
+ ];
607
+ const filtered = applyPerspectiveToTasks(tasks, {
608
+ filters: {
609
+ status: "done",
610
+ completed_on: "2026-02-25",
611
+ },
612
+ sort: "completed_at_desc",
613
+ }, "2026-02-25");
614
+ expect(filtered.map((task) => task.id)).toEqual([
615
+ "done-afternoon",
616
+ "done-morning",
617
+ ]);
618
+ });
619
+ it("applies loaded perspective config to tasks after relative date resolution", async () => {
620
+ const dataDir = await mkdtemp(join(tmpdir(), "tasks-perspectives-apply-loaded-"));
621
+ try {
622
+ await writePerspectiveConfig(dataDir, [
623
+ "due-this-week:",
624
+ " filters:",
625
+ " status: active",
626
+ " due_before: '+7d'",
627
+ " unblocked_only: true",
628
+ " sort: due_asc",
629
+ ].join("\n"));
630
+ const config = await Effect.runPromise(loadPerspectiveConfig(dataDir, "2026-02-25"));
631
+ const tasks = [
632
+ makeTask({
633
+ id: "due-inside-window",
634
+ title: "Due inside window",
635
+ status: "active",
636
+ due: "2026-03-01",
637
+ }),
638
+ makeTask({
639
+ id: "due-tomorrow",
640
+ title: "Due tomorrow",
641
+ status: "active",
642
+ due: "2026-02-26",
643
+ }),
644
+ makeTask({
645
+ id: "due-outside-window",
646
+ title: "Due outside window",
647
+ status: "active",
648
+ due: "2026-03-12",
649
+ }),
650
+ makeTask({
651
+ id: "blocked-weekly-task",
652
+ title: "Blocked weekly task",
653
+ status: "active",
654
+ due: "2026-02-27",
655
+ blocked_by: ["blocker"],
656
+ }),
657
+ makeTask({
658
+ id: "blocker",
659
+ title: "Blocker",
660
+ status: "active",
661
+ }),
662
+ ];
663
+ const filtered = applyPerspectiveToTasks(tasks, config["due-this-week"], "2026-02-25");
664
+ expect(filtered.map((task) => task.id)).toEqual([
665
+ "due-tomorrow",
666
+ "due-inside-window",
667
+ ]);
668
+ }
669
+ finally {
670
+ await rm(dataDir, { recursive: true, force: true });
671
+ }
672
+ });
673
+ });