tuiboard 0.7.2 → 0.8.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.
@@ -68,10 +68,17 @@ archive_column: Archive
68
68
  # own credentials — nothing is hosted, nothing leaves your machine. All-day
69
69
  # events are skipped; each calendar keeps its own color. Paths support `~`.
70
70
  #
71
+ # To CREATE / EDIT / DELETE events (not just read), re-auth with:
72
+ # calendar-setup google --write. Then press `n` / click an empty slot to create;
73
+ # click an existing event to select it, `e` to edit, `d` to delete.
74
+ # `default_calendar` (a calendar id) sets the default target for new events;
75
+ # override per-event in the modal.
76
+ #
71
77
  # calendars:
72
78
  # google:
73
79
  # enabled: true
74
80
  # token: ~/.config/tuiboard/google_token.json
81
+ # # default_calendar: you@example.com # default target for new events
75
82
  # # color: "#e8a05c" # fallback when a calendar has no color
76
83
  # microsoft:
77
84
  # enabled: true
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ 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.0] - 2026-06-03
9
+
10
+ ### Added
11
+ - **Create, edit & delete Google Calendar events from the Agenda** (opt-in
12
+ write). Re-authorize with `tuiboard calendar-setup google --write`, then:
13
+ - **Create** — press `n` (or click an empty Agenda slot) to open a new-event
14
+ modal: type a title (append `HH:MM-HH:MM` to set the time), pick the target
15
+ calendar, Enter to create. A `default_calendar` config sets the default
16
+ target (override per-event in the modal).
17
+ - **Edit / delete** — click an existing event on a writable calendar to select
18
+ it, then `e` (or Enter) to edit its title/time, `d` to delete (with confirm),
19
+ `Esc` to deselect. Edits stay on the same calendar. Read-only events can't be
20
+ selected.
21
+
22
+ Every change appears in the Agenda and on Google Calendar immediately.
23
+ Read-only setups are unaffected — the write UI only appears when the token
24
+ carries the write scope, and only Google events on owner/writer calendars are
25
+ selectable. Microsoft event write is not supported yet.
26
+ - Expanded the README with a step-by-step Google Cloud OAuth client setup (the
27
+ bring-your-own-credentials flow), so first-time users have a clear path.
28
+
29
+ ## [0.7.3] - 2026-06-02
30
+
31
+ ### Fixed
32
+ - Opening a modal no longer shifts the whole dashboard up by a row. The Agenda's
33
+ tall scrollbox was inflating the main row one line past the terminal (flex
34
+ basis `auto` takes the content height); a modal in its place removed that
35
+ overflow, which read as a jump. The main row now grows purely from the
36
+ available space (`flex-basis: 0`), so the layout is steady whatever's on
37
+ screen. Modals also render in the Agenda's exact slot, so there's no
38
+ horizontal reflow either.
39
+
40
+ ### Changed
41
+ - Reclaimed a row: the board and agenda are one row taller, and the keyboard
42
+ cheat-sheet sits flush on the bottom line.
43
+
8
44
  ## [0.7.2] - 2026-06-02
9
45
 
10
46
  ### Changed
@@ -98,6 +134,8 @@ First public release on npm. This entry captures the full feature set at launch.
98
134
 
99
135
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
100
136
 
137
+ [0.8.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.0
138
+ [0.7.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.3
101
139
  [0.7.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.2
102
140
  [0.7.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.1
103
141
  [0.7.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.0
package/README.md CHANGED
@@ -193,13 +193,37 @@ tuiboard calendar-setup google # opens your browser (read-only scope)
193
193
  tuiboard calendar-setup microsoft # device-code flow, no redirect
194
194
  ```
195
195
 
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.
196
+ **Microsoft** needs an Azure app registration (Public client, `Calendars.Read`
197
+ delegated) whose client ID goes in `~/.config/tuiboard/azure_config.json`
198
+ running `calendar-setup microsoft` with no config writes a template that walks
199
+ you through it.
200
+
201
+ #### Google: one-time OAuth client setup
202
+
203
+ There's no hosted tuiboard app — **you create your own free OAuth client** in
204
+ your own Google Cloud project, so nothing is shared and your data never passes
205
+ through anyone else's servers. It takes about five minutes, once:
206
+
207
+ 1. Go to the [Google Cloud Console](https://console.cloud.google.com/) and
208
+ create a project (or pick an existing one) from the project dropdown.
209
+ 2. **APIs & Services → Library** → search **"Google Calendar API"** → **Enable**.
210
+ 3. **APIs & Services → OAuth consent screen**: if prompted, choose **External**,
211
+ give the app a name and your email, and save. You don't need to publish it or
212
+ submit for verification — as the project owner you're automatically a test
213
+ user of your own app, which is all tuiboard needs. (Add your Google address
214
+ under **Test users** if it asks.)
215
+ 4. **APIs & Services → Credentials → Create credentials → OAuth client ID**.
216
+ Application type: **Desktop app**. Create.
217
+ 5. **Download JSON** on the client you just made, and save it as
218
+ `~/.config/tuiboard/google_credentials.json`.
219
+ 6. Run `tuiboard calendar-setup google` (add `--write` to also create/edit/delete
220
+ — see below). Your browser opens; approve the access. You'll briefly see an
221
+ "unverified app" notice — that's expected for your own personal client; click
222
+ **Advanced → go to (your app)** to continue. The token is saved and the
223
+ command prints the YAML block to paste into your config.
224
+
225
+ The `calendar-setup` command prints these exact steps too if it doesn't find the
226
+ credentials file.
203
227
 
204
228
  After connecting, the command prints the exact YAML to paste into your config:
205
229
 
@@ -219,6 +243,46 @@ expired, or unconfigured calendar never breaks the board — it just shows no
219
243
  events. Set either provider's `enabled: false` (or drop the block) to turn it
220
244
  off; add a `color:` to override the fallback block color.
221
245
 
246
+ ### Creating, editing & deleting events (Google, opt-in)
247
+
248
+ Reading is the default. To also **create, edit, and delete** Google Calendar
249
+ events from the Agenda, re-authorize with the write scope:
250
+
251
+ ```bash
252
+ tuiboard calendar-setup google --write
253
+ ```
254
+
255
+ **Create** — in the Agenda zone, press **`n`** (or **click an empty time slot**)
256
+ to open the new-event modal: type a title (append `HH:MM-HH:MM` to change the
257
+ time), press Enter, pick the target calendar with `j`/`k`, Enter to create. Only
258
+ calendars you can write to (owner/writer) show in the picker.
259
+
260
+ **Edit / delete** — **click an existing event** in the Agenda to select it (only
261
+ events on writable calendars can be selected; read-only ones just say so). Then:
262
+
263
+ - **`e`** (or Enter) opens the edit modal, prefilled with the title and time —
264
+ change either (`Title HH:MM-HH:MM`) and Enter to save.
265
+ - **`d`** deletes it (with a confirm).
266
+ - **`Esc`** deselects.
267
+
268
+ Edits stay on the same calendar (moving an event between calendars isn't
269
+ supported). Every change appears in the Agenda right away and on Google Calendar.
270
+
271
+ Set the calendar new events default to with `default_calendar` (a calendar id;
272
+ unset → your primary). You can still override per-event in the modal:
273
+
274
+ ```yaml
275
+ calendars:
276
+ google:
277
+ enabled: true
278
+ token: ~/.config/tuiboard/google_token.json
279
+ default_calendar: you@example.com # optional; default target for new events
280
+ ```
281
+
282
+ The write scope is opt-in: without `--write`, tuiboard stays read-only and the
283
+ `n` shortcut / slot-click / event-selection do nothing. Microsoft event write
284
+ isn't supported yet (read-only).
285
+
222
286
  ## Markdown board format
223
287
 
224
288
  `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.2",
3
+ "version": "0.8.0",
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",
package/src/app.tsx CHANGED
@@ -115,23 +115,35 @@ function App() {
115
115
  width: "100%",
116
116
  height: "100%",
117
117
  backgroundColor: T.bg,
118
- padding: 1,
118
+ // No bottom padding: the keyboard cheat-sheet sits flush on the last
119
+ // row, which gives the board + agenda one extra row of height. Safe
120
+ // thanks to the flexBasis:0 main row — the content just fills the space.
121
+ paddingTop: 1,
122
+ paddingLeft: 1,
123
+ paddingRight: 1,
124
+ paddingBottom: 0,
119
125
  }}
120
126
  >
121
127
  <TopBar store={store} />
122
128
  <box style={{ height: 1 }} />
123
129
  {/*
124
- rootView + ModalLayer are flex-row siblings. The modal panel is exactly
125
- the Agenda's width, and the Dashboard hides the Agenda while a modal is
126
- open so the modal drops into the Agenda's slot with zero reflow of the
127
- left side (board / planner / agents). When no modal is open ModalLayer
128
- renders nothing and rootView gets the whole row.
130
+ The default Dashboard renders its modal inside the Agenda's slot, so the
131
+ layout stays put when a modal opens. The standalone `--view=` modes have
132
+ no Agenda slot, so they get the modal here as a right-hand panel.
133
+ (`view` is fixed at launch, never reactive.)
134
+
135
+ `flexBasis: 0` + `minHeight: 0` are load-bearing: without them the row's
136
+ flex-basis is `auto`, so the Agenda's tall scrollbox inflates the row to
137
+ its content height (1 row past the terminal). That overflow vanishes
138
+ when a modal (no scrollbox) takes the slot — which is exactly the 1-row
139
+ "shift" the whole bottom strip used to do. Basis 0 makes the row grow
140
+ purely from the available space, ignoring content, so it's stable.
129
141
  */}
130
- <box style={{ flexDirection: "row", flexGrow: 1 }}>
142
+ <box style={{ flexDirection: "row", flexGrow: 1, flexBasis: 0, minHeight: 0 }}>
131
143
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
132
144
  {rootViewFor(view, store)}
133
145
  </box>
134
- <ModalLayer store={store} />
146
+ {view ? <ModalLayer store={store} /> : null}
135
147
  </box>
136
148
  <BottomBar store={store} />
137
149
  </box>
@@ -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;