tuiboard 0.5.5 → 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.
@@ -47,3 +47,20 @@ archive_column: Archive
47
47
  # - C:/Users/you/.config/tuiboard/code-resume.nu
48
48
  # - "{cwd}"
49
49
  # - "{sessionId}"
50
+
51
+ # Optional: overlay read-only calendar events on the Agenda (the 24h timeline).
52
+ # Connect a provider with `tuiboard calendar-setup google` / `... microsoft`,
53
+ # which opens the auth flow and prints the exact block to paste here. Bring your
54
+ # own credentials — nothing is hosted, nothing leaves your machine. All-day
55
+ # events are skipped; each calendar keeps its own color. Paths support `~`.
56
+ #
57
+ # calendars:
58
+ # google:
59
+ # enabled: true
60
+ # token: ~/.config/tuiboard/google_token.json
61
+ # # color: "#e8a05c" # fallback when a calendar has no color
62
+ # microsoft:
63
+ # enabled: true
64
+ # config: ~/.config/tuiboard/azure_config.json
65
+ # token_cache: ~/.config/tuiboard/ms_token.json
66
+ # # color: "#b39ddb"
package/README.md CHANGED
@@ -98,7 +98,12 @@ https://github.com/NazzarenoGiannelli/tuiboard). Set it up for me from scratch:
98
98
  home path) with a `boards:` list pointing at those files by ABSOLUTE path,
99
99
  plus `assignees: [...]`, `done_column: Done`, `archive_column: Archive`.
100
100
  4. Do NOT configure the Agent view — tuiboard reads `~/.claude` automatically.
101
- 5. Show me the final config, then tell me to run `tuiboard`, and how to add a
101
+ 5. Ask me whether I want to overlay my Google Calendar or Microsoft 365 events
102
+ on the Agenda. If yes, tell me to run `tuiboard calendar-setup google` (or
103
+ `microsoft`) — it interviews me, opens the browser, and prints the exact
104
+ `calendars:` YAML block to add. Don't try to do the OAuth yourself. If no,
105
+ skip it (it's optional and can be added later).
106
+ 6. Show me the final config, then tell me to run `tuiboard`, and how to add a
102
107
  board later (create a new `.md` and append it to the `boards:` list).
103
108
 
104
109
  Confirm the directory and file names with me before writing any files.
@@ -137,6 +142,48 @@ archive_column: Archive
137
142
  # resume_command: ["nu", "C:/Users/you/.config/tuiboard/code-resume.nu", "{cwd}", "{sessionId}"]
138
143
  ```
139
144
 
145
+ ## Calendars (Agenda overlay)
146
+
147
+ The **Agenda** zone (the 24h timeline) can overlay read-only events from Google
148
+ Calendar and Microsoft 365 alongside your time-blocked tasks — rendered as
149
+ colored `📅` blocks you can't edit, so the day's real shape is visible at a
150
+ glance. All-day events are skipped; each calendar keeps its own color. Events
151
+ are cached 30 min on disk and refreshed every 5 min. **Bring your own
152
+ credentials** — there's nothing to sign up for and nothing leaves your machine.
153
+
154
+ Connect a calendar with the built-in setup command:
155
+
156
+ ```bash
157
+ tuiboard calendar-setup google # opens your browser (read-only scope)
158
+ tuiboard calendar-setup microsoft # device-code flow, no redirect
159
+ ```
160
+
161
+ **Google** needs a one-time OAuth client (free): Google Cloud Console → enable
162
+ *Google Calendar API* → create an *OAuth client ID* (type **Desktop app**) →
163
+ download the JSON to `~/.config/tuiboard/google_credentials.json`, then run the
164
+ command above. **Microsoft** needs an Azure app registration (Public client,
165
+ `Calendars.Read` delegated) whose client ID goes in
166
+ `~/.config/tuiboard/azure_config.json` — running `calendar-setup microsoft` with
167
+ no config writes a template that walks you through it.
168
+
169
+ After connecting, the command prints the exact YAML to paste into your config:
170
+
171
+ ```yaml
172
+ calendars:
173
+ google:
174
+ enabled: true
175
+ token: ~/.config/tuiboard/google_token.json
176
+ microsoft:
177
+ enabled: true
178
+ config: ~/.config/tuiboard/azure_config.json
179
+ token_cache: ~/.config/tuiboard/ms_token.json
180
+ ```
181
+
182
+ Paths support `~` and resolve against the config dir if relative. A missing,
183
+ expired, or unconfigured calendar never breaks the board — it just shows no
184
+ events. Set either provider's `enabled: false` (or drop the block) to turn it
185
+ off; add a `color:` to override the fallback block color.
186
+
140
187
  ## Markdown board format
141
188
 
142
189
  `tuiboard` reads and writes **plain CommonMark** with the Obsidian
@@ -223,6 +270,16 @@ session (until the next terminal resize).
223
270
  | `F1` / `F2` / `F3` | Toggle visibility of Virtual / Timeline / Agents zones |
224
271
  | `z` | Zoom active zone to full screen |
225
272
 
273
+ ### Agenda (timeline zone)
274
+
275
+ | Key | Action |
276
+ |---|---|
277
+ | `[` / `]` | Previous / next day — shows that day's tasks **and** calendar events |
278
+ | `\` | Jump back to today |
279
+ | `c` | Arm mode: click a task, then click a slot to schedule (works from any zone) |
280
+ | `j` / `k` | While armed: nudge the block ±15 min |
281
+ | `+` / `-` | While armed: resize the block's end ±15 min |
282
+
226
283
  ### Task actions (work in board, virtual, AND timeline zones)
227
284
 
228
285
  | Key | Action |
@@ -261,6 +318,9 @@ session (until the next terminal resize).
261
318
 
262
319
  ## Status
263
320
 
321
+ - **v0.6** — adds the Agenda calendar overlay (Google + Microsoft 365,
322
+ read-only, BYO credentials) and day-navigation (`[` / `]` / `\`) so you can
323
+ page tasks and events across days.
264
324
  - **v0.5** — daily-driver ready. Kanban + virtual + timeline + agents
265
325
  all functional, multi-select, undo, atomic file roundtrip, mouse click,
266
326
  responsive layout. Tested on Windows with WezTerm; Linux/macOS should
package/bin/tuiboard.ts CHANGED
@@ -17,6 +17,13 @@ import { fileURLToPath } from "node:url";
17
17
 
18
18
  const here = dirname(fileURLToPath(import.meta.url));
19
19
  const appPath = join(here, "..", "src", "app.tsx");
20
+
21
+ // Subcommands that run as a plain CLI (no TUI, no preload needed). Handle them
22
+ // here before re-exec'ing bun for the dashboard.
23
+ if (process.argv[2] === "calendar-setup") {
24
+ const { runCalendarSetup } = await import("../src/calendar/setup.ts");
25
+ process.exit(await runCalendarSetup(process.argv.slice(3)));
26
+ }
20
27
  const preload = fileURLToPath(import.meta.resolve("@opentui/solid/preload"));
21
28
 
22
29
  const result = spawnSync(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "Terminal dashboard for markdown task boards. Kanban + Today/Tomorrow + 24h timeline + Claude Code agent view, all in one TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,337 @@
1
+ /**
2
+ * One-time OAuth setup for the Agenda calendar overlay.
3
+ *
4
+ * tuiboard calendar-setup google # browser OAuth (loopback redirect)
5
+ * tuiboard calendar-setup microsoft # device-code flow (no redirect)
6
+ * tuiboard calendar-setup # show usage
7
+ *
8
+ * Dependency-light: raw `fetch` + the platform browser, no SDKs. Tokens are
9
+ * written to the paths your tuiboard config points at (`calendars.google.token`
10
+ * / `calendars.microsoft.tokenCache`); if there's no `calendars:` block yet we
11
+ * fall back to `~/.config/tuiboard/` and tell you what to add.
12
+ *
13
+ * Runs as a plain CLI (no TUI) — see bin/tuiboard.ts.
14
+ */
15
+
16
+ import { spawn } from "node:child_process";
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, isAbsolute, join, resolve } from "node:path";
20
+
21
+ import { loadConfig } from "~/config/loader";
22
+
23
+ const TUIBOARD_DIR = join(homedir(), ".config", "tuiboard");
24
+ const GOOGLE_SCOPE = "https://www.googleapis.com/auth/calendar.readonly";
25
+ const MS_SCOPE = "Calendars.Read offline_access openid profile";
26
+
27
+ function expandPath(p: string, root: string): string {
28
+ if (p.startsWith("~")) return join(homedir(), p.slice(1).replace(/^[/\\]/, ""));
29
+ return isAbsolute(p) ? p : resolve(root, p);
30
+ }
31
+
32
+ function openBrowser(url: string): void {
33
+ try {
34
+ if (process.platform === "win32") {
35
+ // `start` is a cmd builtin; the empty "" is the window title arg.
36
+ spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
37
+ } else if (process.platform === "darwin") {
38
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
39
+ } else {
40
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
41
+ }
42
+ } catch {
43
+ // fall back to the printed URL
44
+ }
45
+ }
46
+
47
+ function writeJson(path: string, data: unknown): void {
48
+ mkdirSync(dirname(path), { recursive: true });
49
+ writeFileSync(path, JSON.stringify(data, null, 2), "utf-8");
50
+ }
51
+
52
+ // ─── Google ─────────────────────────────────────────────────────────────────
53
+
54
+ interface GoogleClientSecrets {
55
+ client_id?: string;
56
+ client_secret?: string;
57
+ auth_uri?: string;
58
+ token_uri?: string;
59
+ }
60
+
61
+ async function setupGoogle(credsPath: string, tokenPath: string): Promise<number> {
62
+ console.log("\n── Google Calendar setup ──────────────────────────────────");
63
+ if (!existsSync(credsPath)) {
64
+ console.log(`OAuth client file not found:\n ${credsPath}\n
65
+ Create it:
66
+ 1. Google Cloud Console → APIs & Services → enable "Google Calendar API"
67
+ 2. Credentials → Create credentials → OAuth client ID
68
+ Application type: Desktop app
69
+ 3. Download the JSON and save it as the path above
70
+ 4. Re-run: tuiboard calendar-setup google\n`);
71
+ return 1;
72
+ }
73
+
74
+ let secrets: GoogleClientSecrets;
75
+ try {
76
+ const raw = JSON.parse(readFileSync(credsPath, "utf-8")) as {
77
+ installed?: GoogleClientSecrets;
78
+ web?: GoogleClientSecrets;
79
+ } & GoogleClientSecrets;
80
+ secrets = raw.installed ?? raw.web ?? raw;
81
+ } catch {
82
+ console.log(`Could not parse ${credsPath} as JSON.`);
83
+ return 1;
84
+ }
85
+ const clientId = secrets.client_id;
86
+ const clientSecret = secrets.client_secret;
87
+ if (!clientId || !clientSecret) {
88
+ console.log("client_id / client_secret missing from the OAuth client file.");
89
+ return 1;
90
+ }
91
+ const authUri = secrets.auth_uri || "https://accounts.google.com/o/oauth2/auth";
92
+ const tokenUri = secrets.token_uri || "https://oauth2.googleapis.com/token";
93
+
94
+ // Loopback server captures the ?code= redirect.
95
+ let resolveResult!: (v: { code?: string; error?: string }) => void;
96
+ const result = new Promise<{ code?: string; error?: string }>((r) => (resolveResult = r));
97
+ const server = Bun.serve({
98
+ port: 0,
99
+ fetch(req) {
100
+ const u = new URL(req.url);
101
+ const code = u.searchParams.get("code") ?? undefined;
102
+ const error = u.searchParams.get("error") ?? undefined;
103
+ if (code || error) {
104
+ resolveResult({ code, error });
105
+ const msg = error
106
+ ? `Authorization failed: ${error}`
107
+ : "tuiboard is now connected to Google Calendar. You can close this tab.";
108
+ return new Response(`<!doctype html><meta charset=utf-8><body style="font:16px system-ui;padding:3rem">${msg}</body>`, {
109
+ headers: { "content-type": "text/html" },
110
+ });
111
+ }
112
+ return new Response("waiting for Google…", { headers: { "content-type": "text/plain" } });
113
+ },
114
+ });
115
+ const redirectUri = `http://localhost:${server.port}`;
116
+ const authUrl =
117
+ `${authUri}?` +
118
+ new URLSearchParams({
119
+ client_id: clientId,
120
+ redirect_uri: redirectUri,
121
+ response_type: "code",
122
+ scope: GOOGLE_SCOPE,
123
+ access_type: "offline",
124
+ prompt: "consent",
125
+ }).toString();
126
+
127
+ console.log("Opening your browser to authorize Google Calendar (read-only)…");
128
+ console.log(`If it doesn't open, visit:\n ${authUrl}\n`);
129
+ openBrowser(authUrl);
130
+
131
+ const { code, error } = await result;
132
+ server.stop(true);
133
+ if (error || !code) {
134
+ console.log(`\nSetup cancelled${error ? `: ${error}` : ""}.`);
135
+ return 1;
136
+ }
137
+
138
+ const res = await fetch(tokenUri, {
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
141
+ body: new URLSearchParams({
142
+ code,
143
+ client_id: clientId,
144
+ client_secret: clientSecret,
145
+ redirect_uri: redirectUri,
146
+ grant_type: "authorization_code",
147
+ }),
148
+ });
149
+ if (!res.ok) {
150
+ console.log(`Token exchange failed (${res.status}): ${await res.text()}`);
151
+ return 1;
152
+ }
153
+ const data = (await res.json()) as {
154
+ access_token?: string;
155
+ refresh_token?: string;
156
+ expires_in?: number;
157
+ };
158
+ if (!data.access_token || !data.refresh_token) {
159
+ console.log("Google did not return a refresh token. Revoke prior access and retry.");
160
+ return 1;
161
+ }
162
+ writeJson(tokenPath, {
163
+ token: data.access_token,
164
+ refresh_token: data.refresh_token,
165
+ token_uri: tokenUri,
166
+ client_id: clientId,
167
+ client_secret: clientSecret,
168
+ scopes: [GOOGLE_SCOPE],
169
+ expiry: data.expires_in ? new Date(Date.now() + data.expires_in * 1000).toISOString() : undefined,
170
+ });
171
+ console.log(`\n✓ Google Calendar connected. Token saved:\n ${tokenPath}\n`);
172
+ return 0;
173
+ }
174
+
175
+ // ─── Microsoft 365 ────────────────────────────────────────────────────────────
176
+
177
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
178
+
179
+ async function setupMicrosoft(configPath: string, tokenPath: string): Promise<number> {
180
+ console.log("\n── Microsoft 365 setup ────────────────────────────────────");
181
+ if (!existsSync(configPath)) {
182
+ writeJson(configPath, {
183
+ client_id: "YOUR_AZURE_APP_CLIENT_ID",
184
+ authority: "https://login.microsoftonline.com/common",
185
+ });
186
+ console.log(`Azure config template created:\n ${configPath}\n
187
+ Complete it:
188
+ 1. Azure Portal → App registrations → New registration
189
+ Supported accounts: "any org directory and personal Microsoft accounts"
190
+ Redirect URI: Public client/native → http://localhost
191
+ 2. Authentication → enable "Allow public client flows"
192
+ 3. API permissions → Microsoft Graph → Delegated → Calendars.Read
193
+ 4. Put the Application (client) ID into the file above
194
+ 5. Re-run: tuiboard calendar-setup microsoft\n`);
195
+ return 1;
196
+ }
197
+
198
+ let cfg: { client_id?: string; authority?: string };
199
+ try {
200
+ cfg = JSON.parse(readFileSync(configPath, "utf-8")) as typeof cfg;
201
+ } catch {
202
+ console.log(`Could not parse ${configPath} as JSON.`);
203
+ return 1;
204
+ }
205
+ const clientId = cfg.client_id;
206
+ if (!clientId || clientId === "YOUR_AZURE_APP_CLIENT_ID") {
207
+ console.log(`Set your Azure client_id in ${configPath} first.`);
208
+ return 1;
209
+ }
210
+ const authority = cfg.authority || "https://login.microsoftonline.com/common";
211
+
212
+ const dcRes = await fetch(`${authority}/oauth2/v2.0/devicecode`, {
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
215
+ body: new URLSearchParams({ client_id: clientId, scope: MS_SCOPE }),
216
+ });
217
+ if (!dcRes.ok) {
218
+ console.log(`Failed to start device flow (${dcRes.status}): ${await dcRes.text()}`);
219
+ return 1;
220
+ }
221
+ const dc = (await dcRes.json()) as {
222
+ device_code: string;
223
+ user_code: string;
224
+ verification_uri: string;
225
+ message?: string;
226
+ interval?: number;
227
+ expires_in?: number;
228
+ };
229
+ console.log(`\n ${dc.message ?? `Go to ${dc.verification_uri} and enter code ${dc.user_code}`}\n`);
230
+ openBrowser(dc.verification_uri);
231
+
232
+ const tokenEndpoint = `${authority}/oauth2/v2.0/token`;
233
+ let interval = (dc.interval ?? 5) * 1000;
234
+ const deadline = Date.now() + (dc.expires_in ?? 900) * 1000;
235
+ while (Date.now() < deadline) {
236
+ await sleep(interval);
237
+ const res = await fetch(tokenEndpoint, {
238
+ method: "POST",
239
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
240
+ body: new URLSearchParams({
241
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
242
+ client_id: clientId,
243
+ device_code: dc.device_code,
244
+ }),
245
+ });
246
+ const data = (await res.json()) as {
247
+ access_token?: string;
248
+ refresh_token?: string;
249
+ expires_in?: number;
250
+ error?: string;
251
+ error_description?: string;
252
+ };
253
+ if (data.access_token) {
254
+ writeJson(tokenPath, {
255
+ access_token: data.access_token,
256
+ refresh_token: data.refresh_token,
257
+ expiry: data.expires_in
258
+ ? new Date(Date.now() + data.expires_in * 1000).toISOString()
259
+ : undefined,
260
+ });
261
+ console.log(`\n✓ Microsoft 365 connected. Token saved:\n ${tokenPath}\n`);
262
+ return 0;
263
+ }
264
+ if (data.error === "authorization_pending") continue;
265
+ if (data.error === "slow_down") {
266
+ interval += 5000;
267
+ continue;
268
+ }
269
+ console.log(`\nAuthentication failed: ${data.error_description ?? data.error ?? "unknown error"}`);
270
+ return 1;
271
+ }
272
+ console.log("\nDevice code expired before authorization. Re-run to try again.");
273
+ return 1;
274
+ }
275
+
276
+ // ─── Entry ────────────────────────────────────────────────────────────────────
277
+
278
+ function usage(): void {
279
+ console.log(`tuiboard calendar-setup — connect a calendar to the Agenda overlay
280
+
281
+ Usage:
282
+ tuiboard calendar-setup google Browser OAuth for Google Calendar
283
+ tuiboard calendar-setup microsoft Device-code flow for Microsoft 365
284
+
285
+ Tokens are written to the paths in your tuiboard config (calendars.google.token
286
+ / calendars.microsoft.tokenCache), or ~/.config/tuiboard/ if no calendars block
287
+ exists yet. After connecting, add the matching block to your config — the setup
288
+ prints the exact YAML.`);
289
+ }
290
+
291
+ export async function runCalendarSetup(argv: string[]): Promise<number> {
292
+ const provider = (argv[0] ?? "").toLowerCase();
293
+ const cfg = loadConfig();
294
+ const root = cfg.root;
295
+
296
+ if (provider === "google") {
297
+ const g = cfg.calendars?.google;
298
+ const credsPath = g?.credentials
299
+ ? expandPath(g.credentials, root)
300
+ : join(TUIBOARD_DIR, "google_credentials.json");
301
+ const tokenPath = g?.token ? expandPath(g.token, root) : join(TUIBOARD_DIR, "google_token.json");
302
+ const rc = await setupGoogle(credsPath, tokenPath);
303
+ if (rc === 0 && !g) {
304
+ console.log(`Add this to your tuiboard config (${cfg.loaded ? "config found" : "create ~/.config/tuiboard/config.yaml"}):
305
+
306
+ calendars:
307
+ google:
308
+ enabled: true
309
+ token: ${tokenPath}
310
+ `);
311
+ }
312
+ return rc;
313
+ }
314
+
315
+ if (provider === "microsoft" || provider === "ms" || provider === "ms365") {
316
+ const m = cfg.calendars?.microsoft;
317
+ const configPath = m?.config ? expandPath(m.config, root) : join(TUIBOARD_DIR, "azure_config.json");
318
+ const tokenPath = m?.tokenCache
319
+ ? expandPath(m.tokenCache, root)
320
+ : join(TUIBOARD_DIR, "ms_token.json");
321
+ const rc = await setupMicrosoft(configPath, tokenPath);
322
+ if (rc === 0 && !m) {
323
+ console.log(`Add this to your tuiboard config:
324
+
325
+ calendars:
326
+ microsoft:
327
+ enabled: true
328
+ config: ${configPath}
329
+ token_cache: ${tokenPath}
330
+ `);
331
+ }
332
+ return rc;
333
+ }
334
+
335
+ usage();
336
+ return provider ? 1 : 0;
337
+ }
@@ -43,6 +43,36 @@ export interface Config {
43
43
  * When unset, tuiboard falls back to opening a tab + `claude --resume <id>`.
44
44
  */
45
45
  resumeCommand?: string[];
46
+ /**
47
+ * Optional read-only calendar feeds merged into the Agenda (timeline) zone.
48
+ * Paths support `~` and are resolved against the config dir if relative.
49
+ * Tokens are produced by `tuiboard calendar-setup`.
50
+ */
51
+ calendars?: CalendarsConfig;
52
+ }
53
+
54
+ export interface GoogleCalendarConfig {
55
+ enabled?: boolean;
56
+ /** Path to the google_token.json authorized-user file. */
57
+ token: string;
58
+ /** Path to the OAuth client google_credentials.json (used by setup only). */
59
+ credentials?: string;
60
+ /** Fallback color when a calendar has none of its own. */
61
+ color?: string;
62
+ }
63
+
64
+ export interface MicrosoftCalendarConfig {
65
+ enabled?: boolean;
66
+ /** Path to azure_config.json ({ client_id, authority }). */
67
+ config: string;
68
+ /** Path to the MSAL serialized ms_token_cache.json. */
69
+ tokenCache: string;
70
+ color?: string;
71
+ }
72
+
73
+ export interface CalendarsConfig {
74
+ google?: GoogleCalendarConfig;
75
+ microsoft?: MicrosoftCalendarConfig;
46
76
  }
47
77
 
48
78
  export const DEFAULT_CONFIG: Omit<Config, "root" | "loaded" | "boards"> = {
@@ -91,6 +121,56 @@ interface RawConfig {
91
121
  done_column: string;
92
122
  archive_column: string;
93
123
  resume_command: string[];
124
+ calendars: {
125
+ google?: {
126
+ enabled?: boolean;
127
+ token?: string;
128
+ credentials?: string;
129
+ color?: string;
130
+ };
131
+ microsoft?: {
132
+ enabled?: boolean;
133
+ config?: string;
134
+ token_cache?: string;
135
+ color?: string;
136
+ };
137
+ };
138
+ }
139
+
140
+ /** Expand `~` to the home dir; resolve relative paths against the config dir. */
141
+ function expandPath(p: string, root: string): string {
142
+ if (p === "~") return homedir();
143
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
144
+ return join(homedir(), p.slice(2));
145
+ }
146
+ return isAbsolute(p) ? p : resolve(root, p);
147
+ }
148
+
149
+ function normalizeCalendars(
150
+ raw: RawConfig["calendars"] | undefined,
151
+ root: string,
152
+ ): CalendarsConfig | undefined {
153
+ if (!raw || typeof raw !== "object") return undefined;
154
+ const out: CalendarsConfig = {};
155
+ const g = raw.google;
156
+ if (g && g.token) {
157
+ out.google = {
158
+ enabled: g.enabled !== false,
159
+ token: expandPath(g.token, root),
160
+ credentials: g.credentials ? expandPath(g.credentials, root) : undefined,
161
+ color: g.color,
162
+ };
163
+ }
164
+ const m = raw.microsoft;
165
+ if (m && m.config && m.token_cache) {
166
+ out.microsoft = {
167
+ enabled: m.enabled !== false,
168
+ config: expandPath(m.config, root),
169
+ tokenCache: expandPath(m.token_cache, root),
170
+ color: m.color,
171
+ };
172
+ }
173
+ return out.google || out.microsoft ? out : undefined;
94
174
  }
95
175
 
96
176
  interface FoundConfig {
@@ -158,6 +238,7 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
158
238
  Array.isArray(raw.resume_command) && raw.resume_command.length > 0
159
239
  ? raw.resume_command.map(String)
160
240
  : undefined,
241
+ calendars: normalizeCalendars(raw.calendars, root),
161
242
  };
162
243
  }
163
244
 
@@ -23,7 +23,7 @@ import {
23
23
  type TuiStore,
24
24
  } from "~/store/index";
25
25
  import type { Board, PriorityLevel } from "~/types";
26
- import { buildTimelineEntries } from "~/store/timeline";
26
+ import { buildTimelineEntries, formatAgendaDay } from "~/store/timeline";
27
27
  import { buildVirtualItems } from "~/store/virtual-panel";
28
28
  import { jumpToKanban } from "~/ui/TimelineView";
29
29
 
@@ -247,9 +247,29 @@ function handleTimelineZone(
247
247
  openLater: (m: ModalKind) => void,
248
248
  ): void {
249
249
  const ui = store.state.ui;
250
+
251
+ // Day navigation: page the Agenda to other days (tasks + calendar events).
252
+ // `[` previous, `]` next, `\` back to today. Handled first so they work
253
+ // regardless of arm/cursor state.
254
+ if (key.name === "[" || key.sequence === "[") {
255
+ store.shiftAgendaDay(-1);
256
+ store.flashBanner("info", `◀ ${formatAgendaDay(store.state.ui.agendaOffset, store.agendaDate())}`);
257
+ return;
258
+ }
259
+ if (key.name === "]" || key.sequence === "]") {
260
+ store.shiftAgendaDay(1);
261
+ store.flashBanner("info", `▶ ${formatAgendaDay(store.state.ui.agendaOffset, store.agendaDate())}`);
262
+ return;
263
+ }
264
+ if (key.name === "\\" || key.sequence === "\\") {
265
+ store.resetAgendaDay();
266
+ store.flashBanner("info", "Agenda → Today");
267
+ return;
268
+ }
269
+
250
270
  const entries = buildTimelineEntries(
251
271
  store.state.boards.map((b) => b.board),
252
- isoToday(),
272
+ store.agendaDate(),
253
273
  );
254
274
  const target = entries[ui.row];
255
275
 
@@ -326,7 +326,7 @@ function extractBoardName(frontmatter: string, filepath: string): string {
326
326
  const m = frontmatter.match(/^name:\s*(.+?)\s*$/m);
327
327
  if (m) return m[1]!;
328
328
  const file = basename(filepath, extname(filepath));
329
- // "Tasks - R3PLICA" → "R3PLICA"; otherwise use as-is.
329
+ // "Tasks - Work" → "Work"; otherwise use as-is.
330
330
  const dash = file.indexOf(" - ");
331
331
  return dash >= 0 ? file.slice(dash + 3) : file;
332
332
  }