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