tuiboard 0.7.3 → 0.8.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.
@@ -65,13 +65,21 @@ archive_column: Archive
65
65
  # Optional: overlay read-only calendar events on the Agenda (the 24h timeline).
66
66
  # Connect a provider with `tuiboard calendar-setup google` / `... microsoft`,
67
67
  # which opens the auth flow and prints the exact block to paste here. Bring your
68
- # own credentials — nothing is hosted, nothing leaves your machine. All-day
69
- # events are skipped; each calendar keeps its own color. Paths support `~`.
68
+ # own credentials — nothing is hosted, nothing leaves your machine. Timed events
69
+ # render on the 24h grid; all-day events show as chips at the top. Each calendar
70
+ # keeps its own color. Paths support `~`.
71
+ #
72
+ # To CREATE / EDIT / DELETE events (not just read), re-auth with:
73
+ # calendar-setup google --write. Then press `n` / click an empty slot to create;
74
+ # click an existing event to select it, `e` to edit, `d` to delete.
75
+ # `default_calendar` (a calendar id) sets the default target for new events;
76
+ # override per-event in the modal.
70
77
  #
71
78
  # calendars:
72
79
  # google:
73
80
  # enabled: true
74
81
  # token: ~/.config/tuiboard/google_token.json
82
+ # # default_calendar: you@example.com # default target for new events
75
83
  # # color: "#e8a05c" # fallback when a calendar has no color
76
84
  # microsoft:
77
85
  # enabled: true
package/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ All notable changes to **tuiboard** are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.1] - 2026-06-04
9
+
10
+ ### Added
11
+ - **Set an event's date from the Agenda modal.** When creating or editing a
12
+ Google Calendar event you can now append a date token to the title — `t` /
13
+ `tm` / `+3` / `lun` / `2026-06-10`, the same shortcuts as task scheduling — so
14
+ you're no longer limited to the day the Agenda is showing. Natural order is
15
+ `Title [date] HH:MM-HH:MM` (e.g. `Lunch tomorrow 12-13`); without a date token
16
+ it still defaults to the viewed day. On edit, a date token moves the event to
17
+ another day.
18
+ - **All-day events now show in the Agenda.** Previously skipped, all-day events
19
+ (Google and Microsoft) render as a chip strip at the top of the Agenda — like
20
+ Google Calendar's all-day band — instead of being dropped.
21
+ - **Create all-day events from the Agenda.** Add `allday` (or `all-day`) anywhere
22
+ in the new-event title — e.g. `Holiday 2026-12-25 allday` — to create a
23
+ date-only Google event instead of a timed one. It appears in the top chip
24
+ strip. (Editing all-day events isn't supported; create only.)
25
+
26
+ ## [0.8.0] - 2026-06-03
27
+
28
+ ### Added
29
+ - **Create, edit & delete Google Calendar events from the Agenda** (opt-in
30
+ write). Re-authorize with `tuiboard calendar-setup google --write`, then:
31
+ - **Create** — press `n` (or click an empty Agenda slot) to open a new-event
32
+ modal: type a title (append `HH:MM-HH:MM` to set the time), pick the target
33
+ calendar, Enter to create. A `default_calendar` config sets the default
34
+ target (override per-event in the modal).
35
+ - **Edit / delete** — click an existing event on a writable calendar to select
36
+ it, then `e` (or Enter) to edit its title/time, `d` to delete (with confirm),
37
+ `Esc` to deselect. Edits stay on the same calendar. Read-only events can't be
38
+ selected.
39
+
40
+ Every change appears in the Agenda and on Google Calendar immediately.
41
+ Read-only setups are unaffected — the write UI only appears when the token
42
+ carries the write scope, and only Google events on owner/writer calendars are
43
+ selectable. Microsoft event write is not supported yet.
44
+ - Expanded the README with a step-by-step Google Cloud OAuth client setup (the
45
+ bring-your-own-credentials flow), so first-time users have a clear path.
46
+
8
47
  ## [0.7.3] - 2026-06-02
9
48
 
10
49
  ### Fixed
@@ -113,6 +152,8 @@ First public release on npm. This entry captures the full feature set at launch.
113
152
 
114
153
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
115
154
 
155
+ [0.8.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.1
156
+ [0.8.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.0
116
157
  [0.7.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.3
117
158
  [0.7.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.2
118
159
  [0.7.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.1
package/README.md CHANGED
@@ -179,12 +179,14 @@ at all — handy if you don't use Claude Code and don't want tuiboard reading
179
179
 
180
180
  ## Calendars (Agenda overlay)
181
181
 
182
- The **Agenda** zone (the 24h timeline) can overlay read-only events from Google
183
- Calendar and Microsoft 365 alongside your time-blocked tasks — rendered as
184
- colored `📅` blocks you can't edit, so the day's real shape is visible at a
185
- glance. All-day events are skipped; each calendar keeps its own color. Events
186
- are cached 30 min on disk and refreshed every 5 min. **Bring your own
187
- credentials** — there's nothing to sign up for and nothing leaves your machine.
182
+ The **Agenda** zone (the 24h timeline) can overlay events from Google Calendar
183
+ and Microsoft 365 alongside your time-blocked tasks — timed events render as
184
+ colored `📅` blocks on the grid, and all-day events ride in a chip strip at the
185
+ top (like Google Calendar's all-day band). The day's real shape is visible at a
186
+ glance. Each calendar keeps its own color. Events are cached 30 min on disk and
187
+ refreshed every 5 min. **Bring your own credentials** — there's nothing to sign
188
+ up for and nothing leaves your machine. (Reading is the default; opt into
189
+ creating/editing events below.)
188
190
 
189
191
  Connect a calendar with the built-in setup command:
190
192
 
@@ -193,13 +195,37 @@ tuiboard calendar-setup google # opens your browser (read-only scope)
193
195
  tuiboard calendar-setup microsoft # device-code flow, no redirect
194
196
  ```
195
197
 
196
- **Google** needs a one-time OAuth client (free): Google Cloud Console → enable
197
- *Google Calendar API* → create an *OAuth client ID* (type **Desktop app**)
198
- download the JSON to `~/.config/tuiboard/google_credentials.json`, then run the
199
- command above. **Microsoft** needs an Azure app registration (Public client,
200
- `Calendars.Read` delegated) whose client ID goes in
201
- `~/.config/tuiboard/azure_config.json` running `calendar-setup microsoft` with
202
- no config writes a template that walks you through it.
198
+ **Microsoft** needs an Azure app registration (Public client, `Calendars.Read`
199
+ delegated) whose client ID goes in `~/.config/tuiboard/azure_config.json`
200
+ running `calendar-setup microsoft` with no config writes a template that walks
201
+ you through it.
202
+
203
+ #### Google: one-time OAuth client setup
204
+
205
+ There's no hosted tuiboard app — **you create your own free OAuth client** in
206
+ your own Google Cloud project, so nothing is shared and your data never passes
207
+ through anyone else's servers. It takes about five minutes, once:
208
+
209
+ 1. Go to the [Google Cloud Console](https://console.cloud.google.com/) and
210
+ create a project (or pick an existing one) from the project dropdown.
211
+ 2. **APIs & Services → Library** → search **"Google Calendar API"** → **Enable**.
212
+ 3. **APIs & Services → OAuth consent screen**: if prompted, choose **External**,
213
+ give the app a name and your email, and save. You don't need to publish it or
214
+ submit for verification — as the project owner you're automatically a test
215
+ user of your own app, which is all tuiboard needs. (Add your Google address
216
+ under **Test users** if it asks.)
217
+ 4. **APIs & Services → Credentials → Create credentials → OAuth client ID**.
218
+ Application type: **Desktop app**. Create.
219
+ 5. **Download JSON** on the client you just made, and save it as
220
+ `~/.config/tuiboard/google_credentials.json`.
221
+ 6. Run `tuiboard calendar-setup google` (add `--write` to also create/edit/delete
222
+ — see below). Your browser opens; approve the access. You'll briefly see an
223
+ "unverified app" notice — that's expected for your own personal client; click
224
+ **Advanced → go to (your app)** to continue. The token is saved and the
225
+ command prints the YAML block to paste into your config.
226
+
227
+ The `calendar-setup` command prints these exact steps too if it doesn't find the
228
+ credentials file.
203
229
 
204
230
  After connecting, the command prints the exact YAML to paste into your config:
205
231
 
@@ -219,6 +245,60 @@ expired, or unconfigured calendar never breaks the board — it just shows no
219
245
  events. Set either provider's `enabled: false` (or drop the block) to turn it
220
246
  off; add a `color:` to override the fallback block color.
221
247
 
248
+ ### Creating, editing & deleting events (Google, opt-in)
249
+
250
+ Reading is the default. To also **create, edit, and delete** Google Calendar
251
+ events from the Agenda, re-authorize with the write scope:
252
+
253
+ ```bash
254
+ tuiboard calendar-setup google --write
255
+ ```
256
+
257
+ **Create** — in the Agenda zone, press **`n`** (or **click an empty time slot**)
258
+ to open the new-event modal: type a title, press Enter, pick the target calendar
259
+ with `j`/`k`, Enter to create. Only calendars you can write to (owner/writer)
260
+ show in the picker. Append tokens to the title to set the **time** and **date**:
261
+
262
+ ```
263
+ Standup 9:00-9:30 # today (or the viewed day), 09:00–09:30
264
+ Lunch tomorrow 12-13 # tomorrow, 12:00–13:00
265
+ Review 2026-06-10 15-16 # that date, 15:00–16:00
266
+ Call +3 16:00-16:30 # in 3 days · lun = next Monday also works
267
+ Holiday 2026-12-25 allday # an all-day event (no time)
268
+ ```
269
+
270
+ The date defaults to whichever day the Agenda is showing; an explicit date token
271
+ (`t` / `tm` / `+N` / weekday / `YYYY-MM-DD`) overrides it. The time is taken from
272
+ the clicked slot, or `HH:MM-HH:MM`. Add **`allday`** (or `all-day`) anywhere in
273
+ the title to create an all-day event instead — it lands in the top chip strip.
274
+
275
+ **Edit / delete** — **click an existing event** in the Agenda to select it (only
276
+ events on writable calendars can be selected; read-only ones just say so). Then:
277
+
278
+ - **`e`** (or Enter) opens the edit modal, prefilled with the title and time —
279
+ change the title, time, and/or date (same `Title [date] HH:MM-HH:MM` syntax)
280
+ and Enter to save. A date token moves the event to another day.
281
+ - **`d`** deletes it (with a confirm).
282
+ - **`Esc`** deselects.
283
+
284
+ Edits stay on the same calendar (moving an event between calendars isn't
285
+ supported). Every change appears in the Agenda right away and on Google Calendar.
286
+
287
+ Set the calendar new events default to with `default_calendar` (a calendar id;
288
+ unset → your primary). You can still override per-event in the modal:
289
+
290
+ ```yaml
291
+ calendars:
292
+ google:
293
+ enabled: true
294
+ token: ~/.config/tuiboard/google_token.json
295
+ default_calendar: you@example.com # optional; default target for new events
296
+ ```
297
+
298
+ The write scope is opt-in: without `--write`, tuiboard stays read-only and the
299
+ `n` shortcut / slot-click / event-selection do nothing. Microsoft event write
300
+ isn't supported yet (read-only).
301
+
222
302
  ## Markdown board format
223
303
 
224
304
  `tuiboard` reads and writes **plain CommonMark** with the Obsidian
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
4
4
  "description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live Claude Code agent view. Use only the panels you want.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,9 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
21
21
  import { loadConfig } from "~/config/loader";
22
22
 
23
23
  const TUIBOARD_DIR = join(homedir(), ".config", "tuiboard");
24
- const GOOGLE_SCOPE = "https://www.googleapis.com/auth/calendar.readonly";
24
+ const GOOGLE_SCOPE_RO = "https://www.googleapis.com/auth/calendar.readonly";
25
+ /** Added with `--write`: lets tuiboard CREATE events (and still read them). */
26
+ const GOOGLE_SCOPE_EVENTS = "https://www.googleapis.com/auth/calendar.events";
25
27
  const MS_SCOPE = "Calendars.Read offline_access openid profile";
26
28
 
27
29
  function expandPath(p: string, root: string): string {
@@ -32,8 +34,10 @@ function expandPath(p: string, root: string): string {
32
34
  function openBrowser(url: string): void {
33
35
  try {
34
36
  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
+ // NOT `cmd /c start`: cmd treats the `&` in an OAuth URL as a command
38
+ // separator and truncates it (→ Google "invalid_request"). rundll32 gets
39
+ // the URL as a single literal argv, no shell parsing.
40
+ spawn("rundll32", ["url.dll,FileProtocolHandler", url], { detached: true, stdio: "ignore" }).unref();
37
41
  } else if (process.platform === "darwin") {
38
42
  spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
39
43
  } else {
@@ -58,8 +62,10 @@ interface GoogleClientSecrets {
58
62
  token_uri?: string;
59
63
  }
60
64
 
61
- async function setupGoogle(credsPath: string, tokenPath: string): Promise<number> {
65
+ async function setupGoogle(credsPath: string, tokenPath: string, write: boolean): Promise<number> {
66
+ const scopes = write ? [GOOGLE_SCOPE_RO, GOOGLE_SCOPE_EVENTS] : [GOOGLE_SCOPE_RO];
62
67
  console.log("\n── Google Calendar setup ──────────────────────────────────");
68
+ console.log(write ? "Mode: read + create events" : "Mode: read-only (add --write to create events)");
63
69
  if (!existsSync(credsPath)) {
64
70
  console.log(`OAuth client file not found:\n ${credsPath}\n
65
71
  Create it:
@@ -88,7 +94,10 @@ Create it:
88
94
  console.log("client_id / client_secret missing from the OAuth client file.");
89
95
  return 1;
90
96
  }
91
- const authUri = secrets.auth_uri || "https://accounts.google.com/o/oauth2/auth";
97
+ // Force the current v2 authorization endpoint. Desktop client_secret files
98
+ // still ship the legacy `/o/oauth2/auth` in `auth_uri`, which returns
99
+ // "Error 400: invalid_request (GeneralOAuthFlow)" for loopback + multi-scope.
100
+ const authUri = "https://accounts.google.com/o/oauth2/v2/auth";
92
101
  const tokenUri = secrets.token_uri || "https://oauth2.googleapis.com/token";
93
102
 
94
103
  // Loopback server captures the ?code= redirect.
@@ -96,15 +105,22 @@ Create it:
96
105
  const result = new Promise<{ code?: string; error?: string }>((r) => (resolveResult = r));
97
106
  const server = Bun.serve({
98
107
  port: 0,
108
+ // Bind the IPv4 loopback explicitly. On Windows `localhost` resolves to
109
+ // IPv6 `::1` first; if the server is IPv4-only the redirect is refused.
110
+ // We also use 127.0.0.1 in the redirect URI so the two always match.
111
+ hostname: "127.0.0.1",
99
112
  fetch(req) {
100
113
  const u = new URL(req.url);
101
114
  const code = u.searchParams.get("code") ?? undefined;
102
115
  const error = u.searchParams.get("error") ?? undefined;
103
116
  if (code || error) {
104
- resolveResult({ code, error });
105
117
  const msg = error
106
118
  ? `Authorization failed: ${error}`
107
119
  : "tuiboard is now connected to Google Calendar. You can close this tab.";
120
+ // Resolve AFTER the response is handed back so the success page has a
121
+ // moment to flush; resolving synchronously lets the caller stop the
122
+ // server before the bytes reach the browser (→ ERR_CONNECTION_REFUSED).
123
+ setTimeout(() => resolveResult({ code, error }), 300);
108
124
  return new Response(`<!doctype html><meta charset=utf-8><body style="font:16px system-ui;padding:3rem">${msg}</body>`, {
109
125
  headers: { "content-type": "text/html" },
110
126
  });
@@ -112,24 +128,24 @@ Create it:
112
128
  return new Response("waiting for Google…", { headers: { "content-type": "text/plain" } });
113
129
  },
114
130
  });
115
- const redirectUri = `http://localhost:${server.port}`;
131
+ const redirectUri = `http://127.0.0.1:${server.port}`;
116
132
  const authUrl =
117
133
  `${authUri}?` +
118
134
  new URLSearchParams({
119
135
  client_id: clientId,
120
136
  redirect_uri: redirectUri,
121
137
  response_type: "code",
122
- scope: GOOGLE_SCOPE,
138
+ scope: scopes.join(" "),
123
139
  access_type: "offline",
124
140
  prompt: "consent",
125
141
  }).toString();
126
142
 
127
- console.log("Opening your browser to authorize Google Calendar (read-only)…");
143
+ console.log(`Opening your browser to authorize Google Calendar (${write ? "read + write" : "read-only"})…`);
128
144
  console.log(`If it doesn't open, visit:\n ${authUrl}\n`);
129
145
  openBrowser(authUrl);
130
146
 
131
147
  const { code, error } = await result;
132
- server.stop(true);
148
+ server.stop(); // graceful: lets the success page finish sending
133
149
  if (error || !code) {
134
150
  console.log(`\nSetup cancelled${error ? `: ${error}` : ""}.`);
135
151
  return 1;
@@ -165,7 +181,7 @@ Create it:
165
181
  token_uri: tokenUri,
166
182
  client_id: clientId,
167
183
  client_secret: clientSecret,
168
- scopes: [GOOGLE_SCOPE],
184
+ scopes,
169
185
  expiry: data.expires_in ? new Date(Date.now() + data.expires_in * 1000).toISOString() : undefined,
170
186
  });
171
187
  console.log(`\n✓ Google Calendar connected. Token saved:\n ${tokenPath}\n`);
@@ -279,8 +295,9 @@ function usage(): void {
279
295
  console.log(`tuiboard calendar-setup — connect a calendar to the Agenda overlay
280
296
 
281
297
  Usage:
282
- tuiboard calendar-setup google Browser OAuth for Google Calendar
283
- tuiboard calendar-setup microsoft Device-code flow for Microsoft 365
298
+ tuiboard calendar-setup google Browser OAuth, read-only
299
+ tuiboard calendar-setup google --write Browser OAuth, read + create events
300
+ tuiboard calendar-setup microsoft Device-code flow for Microsoft 365
284
301
 
285
302
  Tokens are written to the paths in your tuiboard config (calendars.google.token
286
303
  / calendars.microsoft.tokenCache), or ~/.config/tuiboard/ if no calendars block
@@ -289,7 +306,8 @@ prints the exact YAML.`);
289
306
  }
290
307
 
291
308
  export async function runCalendarSetup(argv: string[]): Promise<number> {
292
- const provider = (argv[0] ?? "").toLowerCase();
309
+ const write = argv.includes("--write");
310
+ const provider = (argv.find((a) => !a.startsWith("--")) ?? "").toLowerCase();
293
311
  const cfg = loadConfig();
294
312
  const root = cfg.root;
295
313
 
@@ -299,7 +317,7 @@ export async function runCalendarSetup(argv: string[]): Promise<number> {
299
317
  ? expandPath(g.credentials, root)
300
318
  : join(TUIBOARD_DIR, "google_credentials.json");
301
319
  const tokenPath = g?.token ? expandPath(g.token, root) : join(TUIBOARD_DIR, "google_token.json");
302
- const rc = await setupGoogle(credsPath, tokenPath);
320
+ const rc = await setupGoogle(credsPath, tokenPath, write);
303
321
  if (rc === 0 && !g) {
304
322
  console.log(`Add this to your tuiboard config (${cfg.loaded ? "config found" : "create ~/.config/tuiboard/config.yaml"}):
305
323
 
@@ -83,6 +83,9 @@ export interface GoogleCalendarConfig {
83
83
  credentials?: string;
84
84
  /** Fallback color when a calendar has none of its own. */
85
85
  color?: string;
86
+ /** Calendar id new events are created on by default (override in the modal).
87
+ * Unset → the account's primary calendar. Requires `--write` setup. */
88
+ defaultCalendar?: string;
86
89
  }
87
90
 
88
91
  export interface MicrosoftCalendarConfig {
@@ -152,6 +155,7 @@ interface RawConfig {
152
155
  token?: string;
153
156
  credentials?: string;
154
157
  color?: string;
158
+ default_calendar?: string;
155
159
  };
156
160
  microsoft?: {
157
161
  enabled?: boolean;
@@ -191,6 +195,7 @@ function normalizeCalendars(
191
195
  token: expandPath(g.token, root),
192
196
  credentials: g.credentials ? expandPath(g.credentials, root) : undefined,
193
197
  color: g.color,
198
+ defaultCalendar: g.default_calendar,
194
199
  };
195
200
  }
196
201
  const m = raw.microsoft;
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { isHiddenColumn } from "~/config/loader";
17
+ import { googleTokenCanWrite } from "~/store/calendar";
17
18
  import { isTask } from "~/parser/markdown";
18
19
  import {
19
20
  isoToday,
@@ -59,6 +60,14 @@ export function handleKey(
59
60
  }
60
61
  return;
61
62
  }
63
+ if (ui.modal.kind === "confirm-delete-event") {
64
+ if (key.name === "y" || key.name === "enter" || key.name === "return") {
65
+ void store.confirmDeleteEvent();
66
+ } else if (key.name === "n") {
67
+ store.closeModal();
68
+ }
69
+ return;
70
+ }
62
71
  if ((ui.modal.kind === "help" ||
63
72
  ui.modal.kind === "detail" ||
64
73
  ui.modal.kind === "agent-detail") &&
@@ -66,6 +75,17 @@ export function handleKey(
66
75
  store.closeModal();
67
76
  return;
68
77
  }
78
+ // New-event modal: step 1 typing goes to the <input>; step 2 (no input
79
+ // focused) is the calendar picker, driven here.
80
+ if (ui.modal.kind === "event") {
81
+ const p = ui.eventPicker;
82
+ if (p && p.step === 2) {
83
+ if (key.name === "j" || key.name === "down") { store.setEventSel(p.sel + 1); return; }
84
+ if (key.name === "k" || key.name === "up") { store.setEventSel(p.sel - 1); return; }
85
+ if (key.name === "enter" || key.name === "return") { void store.confirmEventPicker(); return; }
86
+ }
87
+ return;
88
+ }
69
89
  return;
70
90
  }
71
91
 
@@ -85,6 +105,11 @@ export function handleKey(
85
105
  store.flashBanner("info", wasMode ? "Arm mode off" : "Disarmed");
86
106
  return;
87
107
  }
108
+ if (ui.selectedCalEvent) {
109
+ store.clearCalSelection();
110
+ store.flashBanner("info", "Event deselected");
111
+ return;
112
+ }
88
113
  if (ui.grabbing) {
89
114
  store.exitGrab();
90
115
  store.flashBanner("info", "Grab released");
@@ -294,6 +319,34 @@ function handleTimelineZone(
294
319
  );
295
320
  const target = entries[ui.row];
296
321
 
322
+ // A selected (clicked) calendar event takes over e/d/Enter for edit/delete.
323
+ // Any other key drops the selection and is then handled normally below.
324
+ const selCal = ui.selectedCalEvent;
325
+ if (selCal) {
326
+ if (key.name === "e" || key.name === "enter" || key.name === "return") {
327
+ store.openEventEditModal();
328
+ return;
329
+ }
330
+ if (key.name === "d") {
331
+ openLater({ kind: "confirm-delete-event" });
332
+ return;
333
+ }
334
+ store.clearCalSelection();
335
+ }
336
+
337
+ // `n` (Agenda zone) = create a Google Calendar event at the now-rounded slot.
338
+ // Mirrors `n` = new task in the board. Gated on Google write being connected.
339
+ if ((key.name === "n" || key.name === "N") && !key.ctrl) {
340
+ const g = store.config.calendars?.google;
341
+ if (g && googleTokenCanWrite(g.token)) {
342
+ const { startMin, endMin } = nextNowBlock();
343
+ store.openEventModal(store.agendaDate(), startMin, endMin);
344
+ } else {
345
+ store.flashBanner("warn", "Connect Google write first: tuiboard calendar-setup google --write");
346
+ }
347
+ return;
348
+ }
349
+
297
350
  // Armed-block adjustments take priority over navigation. While a block
298
351
  // is armed, j/k nudge its start time and +/- nudge its end.
299
352
  const armedRef = ui.armedTimelineRef;