tuiboard 0.5.4 → 0.6.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,439 @@
1
+ /**
2
+ * Read-only calendar feeds for the Agenda (timeline) zone.
3
+ *
4
+ * Dependency-light: raw `fetch` + direct OAuth token refresh, no SDKs. Reads
5
+ * the token files written by `tuiboard calendar-setup` (`google_token.json`
6
+ * for Google; `azure_config.json` + `ms_token.json` for Microsoft).
7
+ *
8
+ * Every fetch fails silently (returns []): a missing/expired/unconfigured
9
+ * calendar must never break the board.
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ import { createSignal } from "solid-js";
17
+
18
+ import type {
19
+ CalendarsConfig,
20
+ GoogleCalendarConfig,
21
+ MicrosoftCalendarConfig,
22
+ } from "~/config/loader";
23
+
24
+ /** A calendar event mapped onto the target day's minute grid. */
25
+ export interface CalEvent {
26
+ title: string;
27
+ /** Minutes since local midnight of the target day (may be <0 if it started earlier). */
28
+ startMin: number;
29
+ endMin: number;
30
+ color: string;
31
+ source: "google" | "microsoft";
32
+ }
33
+
34
+ const GOOGLE_FALLBACK_COLOR = "#e8a05c";
35
+ const MS_FALLBACK_COLOR = "#b39ddb";
36
+ const MS_GRAPH_SCOPE = "Calendars.Read offline_access";
37
+ const CACHE_DIR = join(homedir(), ".config", "tuiboard", "cal_cache");
38
+ const CACHE_TTL_MS = 30 * 60 * 1000;
39
+
40
+ // ─── Cache ────────────────────────────────────────────────────────────────────
41
+
42
+ function cachePath(source: string, dateIso: string): string {
43
+ return join(CACHE_DIR, `${source}_${dateIso}.json`);
44
+ }
45
+
46
+ function loadCache(source: string, dateIso: string): CalEvent[] | undefined {
47
+ try {
48
+ const p = cachePath(source, dateIso);
49
+ if (!existsSync(p)) return undefined;
50
+ if (Date.now() - statSync(p).mtimeMs > CACHE_TTL_MS) return undefined;
51
+ return JSON.parse(readFileSync(p, "utf-8")) as CalEvent[];
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ function saveCache(source: string, dateIso: string, events: CalEvent[]): void {
58
+ try {
59
+ mkdirSync(CACHE_DIR, { recursive: true });
60
+ writeFileSync(cachePath(source, dateIso), JSON.stringify(events), "utf-8");
61
+ } catch {
62
+ // best-effort
63
+ }
64
+ }
65
+
66
+ // ─── Day window helpers ─────────────────────────────────────────────────────
67
+
68
+ /** Local-midnight epoch ms for "YYYY-MM-DD". */
69
+ function dayStartMs(dateIso: string): number {
70
+ const [y, m, d] = dateIso.split("-").map(Number);
71
+ return new Date(y!, (m ?? 1) - 1, d ?? 1, 0, 0, 0, 0).getTime();
72
+ }
73
+
74
+ function dayBoundsIso(dateIso: string): { min: string; max: string } {
75
+ const [y, m, d] = dateIso.split("-").map(Number);
76
+ const min = new Date(y!, (m ?? 1) - 1, d ?? 1, 0, 0, 0, 0);
77
+ const max = new Date(y!, (m ?? 1) - 1, d ?? 1, 23, 59, 59, 999);
78
+ return { min: min.toISOString(), max: max.toISOString() };
79
+ }
80
+
81
+ /** Map an absolute start/end (epoch ms) to minutes since the target day's midnight. */
82
+ function toMinutes(startMs: number, endMs: number, base: number): { startMin: number; endMin: number } {
83
+ return {
84
+ startMin: Math.round((startMs - base) / 60000),
85
+ endMin: Math.round((endMs - base) / 60000),
86
+ };
87
+ }
88
+
89
+ // ─── Google ─────────────────────────────────────────────────────────────────
90
+
91
+ interface GoogleToken {
92
+ token?: string;
93
+ access_token?: string;
94
+ refresh_token?: string;
95
+ token_uri?: string;
96
+ client_id?: string;
97
+ client_secret?: string;
98
+ expiry?: string;
99
+ }
100
+
101
+ /** Refresh the Google access token if missing/expired. Returns a usable token or null. */
102
+ async function googleAccessToken(tokenPath: string): Promise<string | null> {
103
+ let tok: GoogleToken;
104
+ try {
105
+ tok = JSON.parse(readFileSync(tokenPath, "utf-8")) as GoogleToken;
106
+ } catch {
107
+ return null;
108
+ }
109
+ const current = tok.token ?? tok.access_token;
110
+ const notExpired = tok.expiry ? Date.parse(tok.expiry) - Date.now() > 60_000 : false;
111
+ if (current && notExpired) return current;
112
+
113
+ if (!tok.refresh_token || !tok.client_id || !tok.client_secret) return null;
114
+ const tokenUri = tok.token_uri || "https://oauth2.googleapis.com/token";
115
+ try {
116
+ const res = await fetch(tokenUri, {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
119
+ body: new URLSearchParams({
120
+ client_id: tok.client_id,
121
+ client_secret: tok.client_secret,
122
+ refresh_token: tok.refresh_token,
123
+ grant_type: "refresh_token",
124
+ }),
125
+ });
126
+ if (!res.ok) return null;
127
+ const data = (await res.json()) as { access_token?: string; expires_in?: number };
128
+ if (!data.access_token) return null;
129
+ // Persist the refreshed token so we don't refresh on every fetch.
130
+ try {
131
+ tok.token = data.access_token;
132
+ tok.access_token = data.access_token;
133
+ if (data.expires_in) {
134
+ tok.expiry = new Date(Date.now() + data.expires_in * 1000).toISOString();
135
+ }
136
+ writeFileSync(tokenPath, JSON.stringify(tok), "utf-8");
137
+ } catch {
138
+ // token still usable in-memory even if write-back fails
139
+ }
140
+ return data.access_token;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ async function fetchGoogle(cfg: GoogleCalendarConfig, dateIso: string): Promise<CalEvent[]> {
147
+ const cached = loadCache("google", dateIso);
148
+ if (cached) return cached;
149
+
150
+ const access = await googleAccessToken(cfg.token);
151
+ if (!access) return [];
152
+ const headers = { Authorization: `Bearer ${access}` };
153
+ const { min, max } = dayBoundsIso(dateIso);
154
+ const base = dayStartMs(dateIso);
155
+ const fallback = cfg.color || GOOGLE_FALLBACK_COLOR;
156
+
157
+ try {
158
+ // Which calendars + their colors.
159
+ const listRes = await fetch(
160
+ "https://www.googleapis.com/calendar/v3/users/me/calendarList?minAccessRole=reader",
161
+ { headers },
162
+ );
163
+ if (!listRes.ok) return [];
164
+ const list = (await listRes.json()) as {
165
+ items?: Array<{ id: string; backgroundColor?: string; selected?: boolean }>;
166
+ };
167
+ const cals = (list.items ?? []).map((c) => ({
168
+ id: c.id,
169
+ color: c.backgroundColor || fallback,
170
+ }));
171
+ if (cals.length === 0) cals.push({ id: "primary", color: fallback });
172
+
173
+ const events: CalEvent[] = [];
174
+ const seen = new Set<string>();
175
+ const results = await Promise.all(
176
+ cals.map(async (cal) => {
177
+ try {
178
+ const url =
179
+ `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(cal.id)}/events` +
180
+ `?timeMin=${encodeURIComponent(min)}&timeMax=${encodeURIComponent(max)}` +
181
+ `&singleEvents=true&orderBy=startTime`;
182
+ const r = await fetch(url, { headers });
183
+ if (!r.ok) return [] as Array<{ ev: CalEvent; uid: string }>;
184
+ const data = (await r.json()) as {
185
+ items?: Array<{
186
+ id?: string;
187
+ summary?: string;
188
+ start?: { dateTime?: string; date?: string };
189
+ end?: { dateTime?: string; date?: string };
190
+ }>;
191
+ };
192
+ const out: Array<{ ev: CalEvent; uid: string }> = [];
193
+ for (const it of data.items ?? []) {
194
+ const startRaw = it.start?.dateTime;
195
+ const endRaw = it.end?.dateTime;
196
+ if (!startRaw || !endRaw) continue; // skip all-day (date only)
197
+ const { startMin, endMin } = toMinutes(Date.parse(startRaw), Date.parse(endRaw), base);
198
+ out.push({
199
+ uid: it.id ?? `${cal.id}:${startRaw}`,
200
+ ev: {
201
+ title: it.summary ?? "(no title)",
202
+ startMin,
203
+ endMin,
204
+ color: cal.color,
205
+ source: "google",
206
+ },
207
+ });
208
+ }
209
+ return out;
210
+ } catch {
211
+ return [] as Array<{ ev: CalEvent; uid: string }>;
212
+ }
213
+ }),
214
+ );
215
+ for (const arr of results) {
216
+ for (const { ev, uid } of arr) {
217
+ if (seen.has(uid)) continue;
218
+ seen.add(uid);
219
+ events.push(ev);
220
+ }
221
+ }
222
+ events.sort((a, b) => a.startMin - b.startMin);
223
+ saveCache("google", dateIso, events);
224
+ return events;
225
+ } catch {
226
+ return [];
227
+ }
228
+ }
229
+
230
+ // ─── Microsoft 365 ────────────────────────────────────────────────────────────
231
+
232
+ interface AzureConfig {
233
+ client_id?: string;
234
+ authority?: string;
235
+ }
236
+
237
+ interface MsToken {
238
+ access_token?: string;
239
+ refresh_token?: string;
240
+ expiry?: string;
241
+ }
242
+
243
+ /**
244
+ * Refresh the Microsoft Graph access token if missing/expired. Returns a usable
245
+ * token or null. Reads the Azure app config (client_id + authority) and the
246
+ * token file written by `tuiboard calendar-setup microsoft`.
247
+ */
248
+ async function microsoftAccessToken(cfg: MicrosoftCalendarConfig): Promise<string | null> {
249
+ let azure: AzureConfig;
250
+ try {
251
+ azure = JSON.parse(readFileSync(cfg.config, "utf-8")) as AzureConfig;
252
+ } catch {
253
+ return null;
254
+ }
255
+ const clientId = azure.client_id;
256
+ if (!clientId || clientId === "YOUR_AZURE_APP_CLIENT_ID") return null;
257
+ const authority = azure.authority || "https://login.microsoftonline.com/common";
258
+
259
+ let tok: MsToken;
260
+ try {
261
+ tok = JSON.parse(readFileSync(cfg.tokenCache, "utf-8")) as MsToken;
262
+ } catch {
263
+ return null;
264
+ }
265
+ const notExpired = tok.expiry ? Date.parse(tok.expiry) - Date.now() > 60_000 : false;
266
+ if (tok.access_token && notExpired) return tok.access_token;
267
+ if (!tok.refresh_token) return null;
268
+
269
+ try {
270
+ const res = await fetch(`${authority}/oauth2/v2.0/token`, {
271
+ method: "POST",
272
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
273
+ body: new URLSearchParams({
274
+ client_id: clientId,
275
+ grant_type: "refresh_token",
276
+ refresh_token: tok.refresh_token,
277
+ scope: MS_GRAPH_SCOPE,
278
+ }),
279
+ });
280
+ if (!res.ok) return null;
281
+ const data = (await res.json()) as {
282
+ access_token?: string;
283
+ refresh_token?: string;
284
+ expires_in?: number;
285
+ };
286
+ if (!data.access_token) return null;
287
+ try {
288
+ tok.access_token = data.access_token;
289
+ if (data.refresh_token) tok.refresh_token = data.refresh_token;
290
+ if (data.expires_in) tok.expiry = new Date(Date.now() + data.expires_in * 1000).toISOString();
291
+ writeFileSync(cfg.tokenCache, JSON.stringify(tok), "utf-8");
292
+ } catch {
293
+ // token still usable in-memory even if write-back fails
294
+ }
295
+ return data.access_token;
296
+ } catch {
297
+ return null;
298
+ }
299
+ }
300
+
301
+ /** Append a `Z` to a Graph dateTime that has no timezone designator. */
302
+ function ensureUtc(s: string): string {
303
+ if (s.endsWith("Z") || /[+-]\d\d:\d\d$/.test(s)) return s;
304
+ return `${s}Z`;
305
+ }
306
+
307
+ async function fetchMicrosoft(cfg: MicrosoftCalendarConfig, dateIso: string): Promise<CalEvent[]> {
308
+ const cached = loadCache("microsoft", dateIso);
309
+ if (cached) return cached;
310
+
311
+ const access = await microsoftAccessToken(cfg);
312
+ if (!access) return [];
313
+ const base = dayStartMs(dateIso);
314
+ const { min, max } = dayBoundsIso(dateIso);
315
+ const color = cfg.color || MS_FALLBACK_COLOR;
316
+
317
+ try {
318
+ const url =
319
+ "https://graph.microsoft.com/v1.0/me/calendarView" +
320
+ `?startDateTime=${encodeURIComponent(min)}&endDateTime=${encodeURIComponent(max)}` +
321
+ `&%24orderby=${encodeURIComponent("start/dateTime")}&%24top=50`;
322
+ const r = await fetch(url, {
323
+ headers: {
324
+ Authorization: `Bearer ${access}`,
325
+ // Ask Graph to return times in UTC so our base-offset math is exact.
326
+ Prefer: 'outlook.timezone="UTC"',
327
+ },
328
+ });
329
+ if (!r.ok) return [];
330
+ const data = (await r.json()) as {
331
+ value?: Array<{
332
+ subject?: string;
333
+ isAllDay?: boolean;
334
+ start?: { dateTime?: string };
335
+ end?: { dateTime?: string };
336
+ }>;
337
+ };
338
+ const events: CalEvent[] = [];
339
+ for (const it of data.value ?? []) {
340
+ if (it.isAllDay) continue; // mirror Google: skip all-day events
341
+ const s = it.start?.dateTime;
342
+ const e = it.end?.dateTime;
343
+ if (!s || !e) continue;
344
+ const { startMin, endMin } = toMinutes(Date.parse(ensureUtc(s)), Date.parse(ensureUtc(e)), base);
345
+ events.push({
346
+ title: it.subject ?? "(no title)",
347
+ startMin,
348
+ endMin,
349
+ color,
350
+ source: "microsoft",
351
+ });
352
+ }
353
+ events.sort((a, b) => a.startMin - b.startMin);
354
+ saveCache("microsoft", dateIso, events);
355
+ return events;
356
+ } catch {
357
+ return [];
358
+ }
359
+ }
360
+
361
+ // ─── Public API ───────────────────────────────────────────────────────────────
362
+
363
+ /**
364
+ * Fetch all configured calendar events for the given ISO date (YYYY-MM-DD),
365
+ * mapped to minutes since local midnight. Silent on every failure.
366
+ */
367
+ export async function fetchCalendarEvents(
368
+ calendars: CalendarsConfig | undefined,
369
+ dateIso: string,
370
+ ): Promise<CalEvent[]> {
371
+ if (!calendars) return [];
372
+ const out: CalEvent[] = [];
373
+ const tasks: Array<Promise<CalEvent[]>> = [];
374
+ if (calendars.google?.enabled && calendars.google.token) {
375
+ tasks.push(fetchGoogle(calendars.google, dateIso));
376
+ }
377
+ if (calendars.microsoft?.enabled && calendars.microsoft.config && calendars.microsoft.tokenCache) {
378
+ tasks.push(fetchMicrosoft(calendars.microsoft, dateIso));
379
+ }
380
+ for (const arr of await Promise.all(tasks)) out.push(...arr);
381
+ out.sort((a, b) => a.startMin - b.startMin);
382
+ return out;
383
+ }
384
+
385
+ // ─── Reactive store ─────────────────────────────────────────────────────────
386
+
387
+ export interface CalendarStore {
388
+ /** Events for the currently active date. */
389
+ events: () => CalEvent[];
390
+ /** Switch which date's events `events()` exposes; fetches it (cache-first). */
391
+ setActiveDate: (dateIso: string) => void;
392
+ /** Re-fetch the active date now (the 5-min interval calls this too). */
393
+ refresh: () => void;
394
+ dispose: () => Promise<void>;
395
+ }
396
+
397
+ /**
398
+ * Reactive store of one day's calendar events at a time — the "active date",
399
+ * driven by the Agenda's day-navigation. Fetches eagerly for the initial date,
400
+ * refreshes the active date every 5 minutes (cheap thanks to the 30-min disk
401
+ * cache), and re-fetches immediately when the active date changes. No-op when
402
+ * no calendars are configured.
403
+ */
404
+ export function createCalendarStore(
405
+ calendars: CalendarsConfig | undefined,
406
+ initialDate: () => string,
407
+ ): CalendarStore {
408
+ const [events, setEvents] = createSignal<CalEvent[]>([]);
409
+ let activeDate = initialDate();
410
+ let timer: ReturnType<typeof setInterval> | undefined;
411
+
412
+ function refresh(): void {
413
+ if (!calendars) return;
414
+ const target = activeDate;
415
+ void fetchCalendarEvents(calendars, target)
416
+ .then((evs) => {
417
+ // Guard against out-of-order resolves when the user pages quickly:
418
+ // only apply if this is still the date the user is looking at.
419
+ if (target === activeDate) setEvents(evs);
420
+ })
421
+ .catch(() => {});
422
+ }
423
+
424
+ function setActiveDate(dateIso: string): void {
425
+ if (dateIso === activeDate) return;
426
+ activeDate = dateIso;
427
+ setEvents([]); // drop stale events immediately; the fetch repopulates
428
+ refresh();
429
+ }
430
+
431
+ refresh();
432
+ if (calendars) timer = setInterval(refresh, 5 * 60 * 1000);
433
+
434
+ async function dispose(): Promise<void> {
435
+ if (timer) clearInterval(timer);
436
+ }
437
+
438
+ return { events, setActiveDate, refresh, dispose };
439
+ }
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
 
3
3
  import type { Config } from "~/config/loader";
4
- import { createTuiStore } from "./index";
4
+ import { createTuiStore, isoAddDays, isoToday } from "./index";
5
5
 
6
6
  describe("test runner smoke", () => {
7
7
  it("can run a trivial assertion", () => {
@@ -108,3 +108,51 @@ describe("UI cycleActiveZone", () => {
108
108
  expect(store.state.ui.activeZone).toBe("board");
109
109
  });
110
110
  });
111
+
112
+ describe("isoAddDays", () => {
113
+ it("adds and subtracts days", () => {
114
+ expect(isoAddDays("2026-05-30", 1)).toBe("2026-05-31");
115
+ expect(isoAddDays("2026-05-30", -1)).toBe("2026-05-29");
116
+ expect(isoAddDays("2026-05-30", 0)).toBe("2026-05-30");
117
+ });
118
+
119
+ it("rolls over month and year boundaries", () => {
120
+ expect(isoAddDays("2026-05-31", 1)).toBe("2026-06-01");
121
+ expect(isoAddDays("2026-12-31", 1)).toBe("2027-01-01");
122
+ expect(isoAddDays("2026-03-01", -1)).toBe("2026-02-28");
123
+ });
124
+ });
125
+
126
+ describe("Agenda day navigation", () => {
127
+ it("defaults to today (offset 0)", () => {
128
+ const store = createTuiStore({ config: emptyConfig() });
129
+ expect(store.state.ui.agendaOffset).toBe(0);
130
+ expect(store.agendaDate()).toBe(isoToday());
131
+ });
132
+
133
+ it("shifts the viewed day and reflects it in agendaDate()", () => {
134
+ const store = createTuiStore({ config: emptyConfig() });
135
+ store.shiftAgendaDay(1);
136
+ expect(store.state.ui.agendaOffset).toBe(1);
137
+ expect(store.agendaDate()).toBe(isoAddDays(isoToday(), 1));
138
+ store.shiftAgendaDay(-3);
139
+ expect(store.state.ui.agendaOffset).toBe(-2);
140
+ });
141
+
142
+ it("resets to today and clamps to ±365", () => {
143
+ const store = createTuiStore({ config: emptyConfig() });
144
+ store.shiftAgendaDay(1000);
145
+ expect(store.state.ui.agendaOffset).toBe(365);
146
+ store.resetAgendaDay();
147
+ expect(store.state.ui.agendaOffset).toBe(0);
148
+ store.shiftAgendaDay(-1000);
149
+ expect(store.state.ui.agendaOffset).toBe(-365);
150
+ });
151
+
152
+ it("resets the timeline cursor when changing day", () => {
153
+ const store = createTuiStore({ config: emptyConfig() });
154
+ store.setCursor(0, 5);
155
+ store.shiftAgendaDay(1);
156
+ expect(store.state.ui.row).toBe(0);
157
+ });
158
+ });
@@ -28,6 +28,7 @@ import {
28
28
  type BoardWatcher,
29
29
  } from "~/io/watcher";
30
30
  import { createAgentsStore, type AgentsStore } from "./agents";
31
+ import { createCalendarStore, type CalendarStore } from "./calendar";
31
32
  import { ConflictError, statMtime, writeBoardFile } from "~/io/writer";
32
33
  import { isTask, parseBoard } from "~/parser/markdown";
33
34
  import { serializeBoard } from "~/parser/serialize";
@@ -114,6 +115,12 @@ export interface UIState {
114
115
  * `armedTimelineRef`, which is the single task currently armed.
115
116
  */
116
117
  armMode: boolean;
118
+ /**
119
+ * Which day the Agenda (timeline) zone is showing, as a signed offset from
120
+ * today (0 = today, +1 = tomorrow, -1 = yesterday). Drives both the task
121
+ * entries and the calendar overlay. Changed with `[` / `]`; `\` resets to 0.
122
+ */
123
+ agendaOffset: number;
117
124
  view: ViewMode;
118
125
  /**
119
126
  * Tasks marked for bulk ops (`Space`). Key format:
@@ -173,6 +180,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
173
180
  zoomed: false,
174
181
  grabbing: false,
175
182
  armMode: false,
183
+ agendaOffset: 0,
176
184
  view: "kanban",
177
185
  marked: {},
178
186
  filter: "all",
@@ -192,6 +200,9 @@ export function createTuiStore({ config }: CreateStoreOptions) {
192
200
  // Agents store has its own lifecycle (chokidar watcher on ~/.claude).
193
201
  // Shared dispose() boundary below so SIGINT cleans both.
194
202
  const agentsStore: AgentsStore = createAgentsStore();
203
+ // Calendar feeds (read-only) merged into the Agenda zone. No-op when no
204
+ // `calendars:` block is configured.
205
+ const calendarStore: CalendarStore = createCalendarStore(config.calendars, isoToday);
195
206
  watcher.onChange((filepath) => {
196
207
  // External edit. Re-read this board from disk.
197
208
  try {
@@ -782,6 +793,32 @@ export function createTuiStore({ config }: CreateStoreOptions) {
782
793
  setState("ui", "armMode", on);
783
794
  }
784
795
 
796
+ /** ISO date the Agenda is currently showing (today + offset). Reactive. */
797
+ function agendaDate(): string {
798
+ return isoAddDays(isoToday(), state.ui.agendaOffset);
799
+ }
800
+
801
+ /**
802
+ * Move the Agenda's viewed day. `delta` shifts relative to the current day;
803
+ * pass `0`-reset behavior via `resetAgendaDay`. Clamped to ±365 days so the
804
+ * calendar fetch can't run away. Resets the timeline cursor and disarms,
805
+ * since the prior day's armed block no longer renders.
806
+ */
807
+ function shiftAgendaDay(delta: number): void {
808
+ const next = Math.max(-365, Math.min(365, state.ui.agendaOffset + delta));
809
+ if (next === state.ui.agendaOffset) return;
810
+ setState("ui", "agendaOffset", next);
811
+ setState("ui", "row", 0);
812
+ setState("ui", "armedTimelineRef", undefined);
813
+ }
814
+
815
+ function resetAgendaDay(): void {
816
+ if (state.ui.agendaOffset === 0) return;
817
+ setState("ui", "agendaOffset", 0);
818
+ setState("ui", "row", 0);
819
+ setState("ui", "armedTimelineRef", undefined);
820
+ }
821
+
785
822
  // ─── Multi-select ────────────────────────────────────────────────────────
786
823
 
787
824
  function markKey(ref: TaskRef): string {
@@ -953,6 +990,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
953
990
  async function dispose(): Promise<void> {
954
991
  await watcher.stop();
955
992
  await agentsStore.dispose();
993
+ await calendarStore.dispose();
956
994
  }
957
995
 
958
996
  return {
@@ -960,6 +998,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
960
998
  config,
961
999
  activeBoard,
962
1000
  agents: agentsStore,
1001
+ calendar: calendarStore,
963
1002
  // queries
964
1003
  getBoardByPath,
965
1004
  getTask,
@@ -985,6 +1024,9 @@ export function createTuiStore({ config }: CreateStoreOptions) {
985
1024
  exitGrab,
986
1025
  armTimeline,
987
1026
  setArmMode,
1027
+ agendaDate,
1028
+ shiftAgendaDay,
1029
+ resetAgendaDay,
988
1030
  setFilter,
989
1031
  applyBoardFilter,
990
1032
  setZoomed,
@@ -1035,6 +1077,12 @@ export function isoTomorrow(): string {
1035
1077
  return isoDate(d);
1036
1078
  }
1037
1079
 
1080
+ /** Add `n` days to an ISO date string (handles month/year/DST rollover). */
1081
+ export function isoAddDays(iso: string, n: number): string {
1082
+ const [y, m, d] = iso.split("-").map(Number);
1083
+ return isoDate(new Date(y!, (m ?? 1) - 1, (d ?? 1) + n));
1084
+ }
1085
+
1038
1086
  export function isoDate(d: Date): string {
1039
1087
  const yyyy = d.getFullYear();
1040
1088
  const mm = (d.getMonth() + 1).toString().padStart(2, "0");