tuiboard 0.5.5 → 0.6.1

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,456 @@
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(
147
+ cfg: GoogleCalendarConfig,
148
+ dateIso: string,
149
+ force = false,
150
+ ): Promise<CalEvent[]> {
151
+ if (!force) {
152
+ const cached = loadCache("google", dateIso);
153
+ if (cached) return cached;
154
+ }
155
+
156
+ const access = await googleAccessToken(cfg.token);
157
+ if (!access) return [];
158
+ const headers = { Authorization: `Bearer ${access}` };
159
+ const { min, max } = dayBoundsIso(dateIso);
160
+ const base = dayStartMs(dateIso);
161
+ const fallback = cfg.color || GOOGLE_FALLBACK_COLOR;
162
+
163
+ try {
164
+ // Which calendars + their colors.
165
+ const listRes = await fetch(
166
+ "https://www.googleapis.com/calendar/v3/users/me/calendarList?minAccessRole=reader",
167
+ { headers },
168
+ );
169
+ if (!listRes.ok) return [];
170
+ const list = (await listRes.json()) as {
171
+ items?: Array<{ id: string; backgroundColor?: string; selected?: boolean }>;
172
+ };
173
+ const cals = (list.items ?? []).map((c) => ({
174
+ id: c.id,
175
+ color: c.backgroundColor || fallback,
176
+ }));
177
+ if (cals.length === 0) cals.push({ id: "primary", color: fallback });
178
+
179
+ const events: CalEvent[] = [];
180
+ const seen = new Set<string>();
181
+ const results = await Promise.all(
182
+ cals.map(async (cal) => {
183
+ try {
184
+ const url =
185
+ `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(cal.id)}/events` +
186
+ `?timeMin=${encodeURIComponent(min)}&timeMax=${encodeURIComponent(max)}` +
187
+ `&singleEvents=true&orderBy=startTime`;
188
+ const r = await fetch(url, { headers });
189
+ if (!r.ok) return [] as Array<{ ev: CalEvent; uid: string }>;
190
+ const data = (await r.json()) as {
191
+ items?: Array<{
192
+ id?: string;
193
+ summary?: string;
194
+ start?: { dateTime?: string; date?: string };
195
+ end?: { dateTime?: string; date?: string };
196
+ }>;
197
+ };
198
+ const out: Array<{ ev: CalEvent; uid: string }> = [];
199
+ for (const it of data.items ?? []) {
200
+ const startRaw = it.start?.dateTime;
201
+ const endRaw = it.end?.dateTime;
202
+ if (!startRaw || !endRaw) continue; // skip all-day (date only)
203
+ const { startMin, endMin } = toMinutes(Date.parse(startRaw), Date.parse(endRaw), base);
204
+ out.push({
205
+ uid: it.id ?? `${cal.id}:${startRaw}`,
206
+ ev: {
207
+ title: it.summary ?? "(no title)",
208
+ startMin,
209
+ endMin,
210
+ color: cal.color,
211
+ source: "google",
212
+ },
213
+ });
214
+ }
215
+ return out;
216
+ } catch {
217
+ return [] as Array<{ ev: CalEvent; uid: string }>;
218
+ }
219
+ }),
220
+ );
221
+ for (const arr of results) {
222
+ for (const { ev, uid } of arr) {
223
+ if (seen.has(uid)) continue;
224
+ seen.add(uid);
225
+ events.push(ev);
226
+ }
227
+ }
228
+ events.sort((a, b) => a.startMin - b.startMin);
229
+ saveCache("google", dateIso, events);
230
+ return events;
231
+ } catch {
232
+ return [];
233
+ }
234
+ }
235
+
236
+ // ─── Microsoft 365 ────────────────────────────────────────────────────────────
237
+
238
+ interface AzureConfig {
239
+ client_id?: string;
240
+ authority?: string;
241
+ }
242
+
243
+ interface MsToken {
244
+ access_token?: string;
245
+ refresh_token?: string;
246
+ expiry?: string;
247
+ }
248
+
249
+ /**
250
+ * Refresh the Microsoft Graph access token if missing/expired. Returns a usable
251
+ * token or null. Reads the Azure app config (client_id + authority) and the
252
+ * token file written by `tuiboard calendar-setup microsoft`.
253
+ */
254
+ async function microsoftAccessToken(cfg: MicrosoftCalendarConfig): Promise<string | null> {
255
+ let azure: AzureConfig;
256
+ try {
257
+ azure = JSON.parse(readFileSync(cfg.config, "utf-8")) as AzureConfig;
258
+ } catch {
259
+ return null;
260
+ }
261
+ const clientId = azure.client_id;
262
+ if (!clientId || clientId === "YOUR_AZURE_APP_CLIENT_ID") return null;
263
+ const authority = azure.authority || "https://login.microsoftonline.com/common";
264
+
265
+ let tok: MsToken;
266
+ try {
267
+ tok = JSON.parse(readFileSync(cfg.tokenCache, "utf-8")) as MsToken;
268
+ } catch {
269
+ return null;
270
+ }
271
+ const notExpired = tok.expiry ? Date.parse(tok.expiry) - Date.now() > 60_000 : false;
272
+ if (tok.access_token && notExpired) return tok.access_token;
273
+ if (!tok.refresh_token) return null;
274
+
275
+ try {
276
+ const res = await fetch(`${authority}/oauth2/v2.0/token`, {
277
+ method: "POST",
278
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
279
+ body: new URLSearchParams({
280
+ client_id: clientId,
281
+ grant_type: "refresh_token",
282
+ refresh_token: tok.refresh_token,
283
+ scope: MS_GRAPH_SCOPE,
284
+ }),
285
+ });
286
+ if (!res.ok) return null;
287
+ const data = (await res.json()) as {
288
+ access_token?: string;
289
+ refresh_token?: string;
290
+ expires_in?: number;
291
+ };
292
+ if (!data.access_token) return null;
293
+ try {
294
+ tok.access_token = data.access_token;
295
+ if (data.refresh_token) tok.refresh_token = data.refresh_token;
296
+ if (data.expires_in) tok.expiry = new Date(Date.now() + data.expires_in * 1000).toISOString();
297
+ writeFileSync(cfg.tokenCache, JSON.stringify(tok), "utf-8");
298
+ } catch {
299
+ // token still usable in-memory even if write-back fails
300
+ }
301
+ return data.access_token;
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ /** Append a `Z` to a Graph dateTime that has no timezone designator. */
308
+ function ensureUtc(s: string): string {
309
+ if (s.endsWith("Z") || /[+-]\d\d:\d\d$/.test(s)) return s;
310
+ return `${s}Z`;
311
+ }
312
+
313
+ async function fetchMicrosoft(
314
+ cfg: MicrosoftCalendarConfig,
315
+ dateIso: string,
316
+ force = false,
317
+ ): Promise<CalEvent[]> {
318
+ if (!force) {
319
+ const cached = loadCache("microsoft", dateIso);
320
+ if (cached) return cached;
321
+ }
322
+
323
+ const access = await microsoftAccessToken(cfg);
324
+ if (!access) return [];
325
+ const base = dayStartMs(dateIso);
326
+ const { min, max } = dayBoundsIso(dateIso);
327
+ const color = cfg.color || MS_FALLBACK_COLOR;
328
+
329
+ try {
330
+ const url =
331
+ "https://graph.microsoft.com/v1.0/me/calendarView" +
332
+ `?startDateTime=${encodeURIComponent(min)}&endDateTime=${encodeURIComponent(max)}` +
333
+ `&%24orderby=${encodeURIComponent("start/dateTime")}&%24top=50`;
334
+ const r = await fetch(url, {
335
+ headers: {
336
+ Authorization: `Bearer ${access}`,
337
+ // Ask Graph to return times in UTC so our base-offset math is exact.
338
+ Prefer: 'outlook.timezone="UTC"',
339
+ },
340
+ });
341
+ if (!r.ok) return [];
342
+ const data = (await r.json()) as {
343
+ value?: Array<{
344
+ subject?: string;
345
+ isAllDay?: boolean;
346
+ start?: { dateTime?: string };
347
+ end?: { dateTime?: string };
348
+ }>;
349
+ };
350
+ const events: CalEvent[] = [];
351
+ for (const it of data.value ?? []) {
352
+ if (it.isAllDay) continue; // mirror Google: skip all-day events
353
+ const s = it.start?.dateTime;
354
+ const e = it.end?.dateTime;
355
+ if (!s || !e) continue;
356
+ const { startMin, endMin } = toMinutes(Date.parse(ensureUtc(s)), Date.parse(ensureUtc(e)), base);
357
+ events.push({
358
+ title: it.subject ?? "(no title)",
359
+ startMin,
360
+ endMin,
361
+ color,
362
+ source: "microsoft",
363
+ });
364
+ }
365
+ events.sort((a, b) => a.startMin - b.startMin);
366
+ saveCache("microsoft", dateIso, events);
367
+ return events;
368
+ } catch {
369
+ return [];
370
+ }
371
+ }
372
+
373
+ // ─── Public API ───────────────────────────────────────────────────────────────
374
+
375
+ /**
376
+ * Fetch all configured calendar events for the given ISO date (YYYY-MM-DD),
377
+ * mapped to minutes since local midnight. Silent on every failure.
378
+ */
379
+ export async function fetchCalendarEvents(
380
+ calendars: CalendarsConfig | undefined,
381
+ dateIso: string,
382
+ force = false,
383
+ ): Promise<CalEvent[]> {
384
+ if (!calendars) return [];
385
+ const out: CalEvent[] = [];
386
+ const tasks: Array<Promise<CalEvent[]>> = [];
387
+ if (calendars.google?.enabled && calendars.google.token) {
388
+ tasks.push(fetchGoogle(calendars.google, dateIso, force));
389
+ }
390
+ if (calendars.microsoft?.enabled && calendars.microsoft.config && calendars.microsoft.tokenCache) {
391
+ tasks.push(fetchMicrosoft(calendars.microsoft, dateIso, force));
392
+ }
393
+ for (const arr of await Promise.all(tasks)) out.push(...arr);
394
+ out.sort((a, b) => a.startMin - b.startMin);
395
+ return out;
396
+ }
397
+
398
+ // ─── Reactive store ─────────────────────────────────────────────────────────
399
+
400
+ export interface CalendarStore {
401
+ /** Events for the currently active date. */
402
+ events: () => CalEvent[];
403
+ /** Switch which date's events `events()` exposes; fetches it (cache-first). */
404
+ setActiveDate: (dateIso: string) => void;
405
+ /**
406
+ * Re-fetch the active date now (the 5-min interval calls this too). Pass
407
+ * `force` to bypass the 30-min disk cache — used by the manual `r` refresh
408
+ * so freshly-edited events show without waiting for the cache to expire.
409
+ */
410
+ refresh: (force?: boolean) => void;
411
+ dispose: () => Promise<void>;
412
+ }
413
+
414
+ /**
415
+ * Reactive store of one day's calendar events at a time — the "active date",
416
+ * driven by the Agenda's day-navigation. Fetches eagerly for the initial date,
417
+ * refreshes the active date every 5 minutes (cheap thanks to the 30-min disk
418
+ * cache), and re-fetches immediately when the active date changes. No-op when
419
+ * no calendars are configured.
420
+ */
421
+ export function createCalendarStore(
422
+ calendars: CalendarsConfig | undefined,
423
+ initialDate: () => string,
424
+ ): CalendarStore {
425
+ const [events, setEvents] = createSignal<CalEvent[]>([]);
426
+ let activeDate = initialDate();
427
+ let timer: ReturnType<typeof setInterval> | undefined;
428
+
429
+ function refresh(force = false): void {
430
+ if (!calendars) return;
431
+ const target = activeDate;
432
+ void fetchCalendarEvents(calendars, target, force)
433
+ .then((evs) => {
434
+ // Guard against out-of-order resolves when the user pages quickly:
435
+ // only apply if this is still the date the user is looking at.
436
+ if (target === activeDate) setEvents(evs);
437
+ })
438
+ .catch(() => {});
439
+ }
440
+
441
+ function setActiveDate(dateIso: string): void {
442
+ if (dateIso === activeDate) return;
443
+ activeDate = dateIso;
444
+ setEvents([]); // drop stale events immediately; the fetch repopulates
445
+ refresh();
446
+ }
447
+
448
+ refresh();
449
+ if (calendars) timer = setInterval(refresh, 5 * 60 * 1000);
450
+
451
+ async function dispose(): Promise<void> {
452
+ if (timer) clearInterval(timer);
453
+ }
454
+
455
+ return { events, setActiveDate, refresh, dispose };
456
+ }
@@ -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,48 @@ 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
+
822
+ /**
823
+ * Manual full refresh (the `r` key). Re-reads every board from disk, rescans
824
+ * Claude Code agents, and force-refetches the Agenda's calendar (bypassing
825
+ * the 30-min cache). Lets the user pull in external changes — a calendar
826
+ * event edited in the browser, a board touched elsewhere — without leaving
827
+ * tuiboard. Boards normally auto-reload via the file watcher; this also
828
+ * covers the agenda, whose feed is poll-based, not event-driven.
829
+ */
830
+ function refreshAll(): void {
831
+ setState("boards", loadAll(config));
832
+ setState("rev", (r) => r + 1);
833
+ agentsStore.refresh();
834
+ calendarStore.refresh(true);
835
+ flashBanner("info", "Refreshed boards · agents · agenda");
836
+ }
837
+
785
838
  // ─── Multi-select ────────────────────────────────────────────────────────
786
839
 
787
840
  function markKey(ref: TaskRef): string {
@@ -953,6 +1006,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
953
1006
  async function dispose(): Promise<void> {
954
1007
  await watcher.stop();
955
1008
  await agentsStore.dispose();
1009
+ await calendarStore.dispose();
956
1010
  }
957
1011
 
958
1012
  return {
@@ -960,6 +1014,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
960
1014
  config,
961
1015
  activeBoard,
962
1016
  agents: agentsStore,
1017
+ calendar: calendarStore,
963
1018
  // queries
964
1019
  getBoardByPath,
965
1020
  getTask,
@@ -985,6 +1040,10 @@ export function createTuiStore({ config }: CreateStoreOptions) {
985
1040
  exitGrab,
986
1041
  armTimeline,
987
1042
  setArmMode,
1043
+ agendaDate,
1044
+ shiftAgendaDay,
1045
+ resetAgendaDay,
1046
+ refreshAll,
988
1047
  setFilter,
989
1048
  applyBoardFilter,
990
1049
  setZoomed,
@@ -1035,6 +1094,12 @@ export function isoTomorrow(): string {
1035
1094
  return isoDate(d);
1036
1095
  }
1037
1096
 
1097
+ /** Add `n` days to an ISO date string (handles month/year/DST rollover). */
1098
+ export function isoAddDays(iso: string, n: number): string {
1099
+ const [y, m, d] = iso.split("-").map(Number);
1100
+ return isoDate(new Date(y!, (m ?? 1) - 1, (d ?? 1) + n));
1101
+ }
1102
+
1038
1103
  export function isoDate(d: Date): string {
1039
1104
  const yyyy = d.getFullYear();
1040
1105
  const mm = (d.getMonth() + 1).toString().padStart(2, "0");