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.
- package/.tuiboard/config.example.yaml +17 -0
- package/README.md +62 -1
- package/bin/tuiboard.ts +7 -0
- package/package.json +1 -1
- package/src/calendar/setup.ts +337 -0
- package/src/config/loader.ts +81 -0
- package/src/input/handleKey.ts +42 -2
- package/src/parser/markdown.ts +1 -1
- package/src/store/calendar.ts +456 -0
- package/src/store/index.test.ts +49 -1
- package/src/store/index.ts +65 -0
- package/src/store/timeline.test.ts +64 -13
- package/src/store/timeline.ts +94 -21
- package/src/ui/AgentRow.tsx +5 -3
- package/src/ui/Chrome.tsx +1 -1
- package/src/ui/Modal.tsx +5 -1
- package/src/ui/TimelineView.tsx +81 -18
|
@@ -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
|
+
}
|
package/src/store/index.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/store/index.ts
CHANGED
|
@@ -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");
|