tuiboard 0.7.3 → 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,27 @@ 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
+
8
29
  ## [0.7.3] - 2026-06-02
9
30
 
10
31
  ### Fixed
@@ -113,6 +134,7 @@ First public release on npm. This entry captures the full feature set at launch.
113
134
 
114
135
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
115
136
 
137
+ [0.8.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.0
116
138
  [0.7.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.3
117
139
  [0.7.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.2
118
140
  [0.7.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.1
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.3",
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",
@@ -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;
@@ -29,6 +29,14 @@ export interface CalEvent {
29
29
  endMin: number;
30
30
  color: string;
31
31
  source: "google" | "microsoft";
32
+ /** Google calendar id this event lives on (set for Google events only). */
33
+ calendarId?: string;
34
+ /** Google event id (set for Google events only). Needed to edit/delete. */
35
+ eventId?: string;
36
+ /** True when this event can be edited/deleted from tuiboard: a Google event
37
+ * on an owner/writer calendar, with a write-scoped token. Microsoft events
38
+ * and read-only-calendar events are never editable. */
39
+ editable?: boolean;
32
40
  }
33
41
 
34
42
  const GOOGLE_FALLBACK_COLOR = "#e8a05c";
@@ -143,6 +151,182 @@ async function googleAccessToken(tokenPath: string): Promise<string | null> {
143
151
  }
144
152
  }
145
153
 
154
+ // ─── Google write (calendar list + event creation) ──────────────────────────
155
+
156
+ /** A calendar the connected account can write to (accessRole owner/writer). */
157
+ export interface WritableCalendar {
158
+ id: string;
159
+ summary: string;
160
+ accessRole: string;
161
+ color: string;
162
+ primary: boolean;
163
+ }
164
+
165
+ /**
166
+ * List the calendars the connected Google account can WRITE to (owner/writer),
167
+ * with display name + color. Used by the new-event calendar picker. Returns []
168
+ * on any failure. (Read path `fetchGoogle` keeps its own, reader-scoped query.)
169
+ */
170
+ export async function listGoogleCalendars(
171
+ cfg: GoogleCalendarConfig,
172
+ ): Promise<WritableCalendar[]> {
173
+ const access = await googleAccessToken(cfg.token);
174
+ if (!access) return [];
175
+ try {
176
+ const res = await fetch(
177
+ "https://www.googleapis.com/calendar/v3/users/me/calendarList",
178
+ { headers: { Authorization: `Bearer ${access}` } },
179
+ );
180
+ if (!res.ok) return [];
181
+ const list = (await res.json()) as {
182
+ items?: Array<{
183
+ id: string;
184
+ summary?: string;
185
+ accessRole?: string;
186
+ backgroundColor?: string;
187
+ primary?: boolean;
188
+ }>;
189
+ };
190
+ return (list.items ?? [])
191
+ .filter((c) => c.accessRole === "owner" || c.accessRole === "writer")
192
+ .map((c) => ({
193
+ id: c.id,
194
+ summary: c.summary ?? c.id,
195
+ accessRole: c.accessRole ?? "reader",
196
+ color: c.backgroundColor || cfg.color || GOOGLE_FALLBACK_COLOR,
197
+ primary: c.primary === true,
198
+ }));
199
+ } catch {
200
+ return [];
201
+ }
202
+ }
203
+
204
+ /** True if the persisted Google token carries an event-write scope. Gates the
205
+ * whole event-creation UI so read-only users never see it. */
206
+ export function googleTokenCanWrite(tokenPath: string): boolean {
207
+ try {
208
+ const tok = JSON.parse(readFileSync(tokenPath, "utf-8")) as { scopes?: string[] };
209
+ return (
210
+ Array.isArray(tok.scopes) &&
211
+ tok.scopes.some((s) => s.includes("calendar.events") || s.endsWith("/auth/calendar"))
212
+ );
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
218
+ /** RFC3339 timestamp with the LOCAL UTC offset for `min` minutes past local
219
+ * midnight of `dateIso` — e.g. "2026-06-03T15:00:00+02:00". Per-instant offset,
220
+ * so DST is handled; Google then stores the wall-clock time exactly as typed. */
221
+ function localRfc3339(dateIso: string, min: number): string {
222
+ const d = new Date(dayStartMs(dateIso) + min * 60000);
223
+ const pad = (n: number) => String(n).padStart(2, "0");
224
+ const offMin = -d.getTimezoneOffset(); // minutes east of UTC
225
+ const sign = offMin >= 0 ? "+" : "-";
226
+ const off = Math.abs(offMin);
227
+ return (
228
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
229
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:00` +
230
+ `${sign}${pad(Math.floor(off / 60))}:${pad(off % 60)}`
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Create a Google Calendar event on `calendarId`. Unlike the read path, this
236
+ * surfaces failures (returns {ok:false,error}) so the UI can flash a banner.
237
+ */
238
+ export async function createGoogleEvent(
239
+ cfg: GoogleCalendarConfig,
240
+ args: { calendarId: string; title: string; dateIso: string; startMin: number; endMin: number },
241
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
242
+ const access = await googleAccessToken(cfg.token);
243
+ if (!access) {
244
+ return { ok: false, error: "not authorized — run: tuiboard calendar-setup google --write" };
245
+ }
246
+ try {
247
+ const res = await fetch(
248
+ `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(args.calendarId)}/events`,
249
+ {
250
+ method: "POST",
251
+ headers: { Authorization: `Bearer ${access}`, "Content-Type": "application/json" },
252
+ body: JSON.stringify({
253
+ summary: args.title,
254
+ start: { dateTime: localRfc3339(args.dateIso, args.startMin) },
255
+ end: { dateTime: localRfc3339(args.dateIso, args.endMin) },
256
+ }),
257
+ },
258
+ );
259
+ if (!res.ok) {
260
+ const body = await res.text().catch(() => "");
261
+ return { ok: false, error: `${res.status} ${body.slice(0, 140)}` };
262
+ }
263
+ return { ok: true };
264
+ } catch (e) {
265
+ return { ok: false, error: String(e) };
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Edit an existing Google Calendar event's title + time (same calendar — moving
271
+ * an event between calendars is intentionally not supported). PATCH so untouched
272
+ * fields (attendees, description, recurrence, …) are preserved.
273
+ */
274
+ export async function updateGoogleEvent(
275
+ cfg: GoogleCalendarConfig,
276
+ args: { calendarId: string; eventId: string; title: string; dateIso: string; startMin: number; endMin: number },
277
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
278
+ const access = await googleAccessToken(cfg.token);
279
+ if (!access) {
280
+ return { ok: false, error: "not authorized — run: tuiboard calendar-setup google --write" };
281
+ }
282
+ try {
283
+ const res = await fetch(
284
+ `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(args.calendarId)}/events/${encodeURIComponent(args.eventId)}`,
285
+ {
286
+ method: "PATCH",
287
+ headers: { Authorization: `Bearer ${access}`, "Content-Type": "application/json" },
288
+ body: JSON.stringify({
289
+ summary: args.title,
290
+ start: { dateTime: localRfc3339(args.dateIso, args.startMin) },
291
+ end: { dateTime: localRfc3339(args.dateIso, args.endMin) },
292
+ }),
293
+ },
294
+ );
295
+ if (!res.ok) {
296
+ const body = await res.text().catch(() => "");
297
+ return { ok: false, error: `${res.status} ${body.slice(0, 140)}` };
298
+ }
299
+ return { ok: true };
300
+ } catch (e) {
301
+ return { ok: false, error: String(e) };
302
+ }
303
+ }
304
+
305
+ /** Delete a Google Calendar event. DELETE returns 204 (no body) on success. */
306
+ export async function deleteGoogleEvent(
307
+ cfg: GoogleCalendarConfig,
308
+ args: { calendarId: string; eventId: string },
309
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
310
+ const access = await googleAccessToken(cfg.token);
311
+ if (!access) {
312
+ return { ok: false, error: "not authorized — run: tuiboard calendar-setup google --write" };
313
+ }
314
+ try {
315
+ const res = await fetch(
316
+ `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(args.calendarId)}/events/${encodeURIComponent(args.eventId)}`,
317
+ { method: "DELETE", headers: { Authorization: `Bearer ${access}` } },
318
+ );
319
+ // 410 Gone = already deleted; treat as success (the goal state is reached).
320
+ if (!res.ok && res.status !== 410) {
321
+ const body = await res.text().catch(() => "");
322
+ return { ok: false, error: `${res.status} ${body.slice(0, 140)}` };
323
+ }
324
+ return { ok: true };
325
+ } catch (e) {
326
+ return { ok: false, error: String(e) };
327
+ }
328
+ }
329
+
146
330
  async function fetchGoogle(
147
331
  cfg: GoogleCalendarConfig,
148
332
  dateIso: string,
@@ -168,13 +352,17 @@ async function fetchGoogle(
168
352
  );
169
353
  if (!listRes.ok) return [];
170
354
  const list = (await listRes.json()) as {
171
- items?: Array<{ id: string; backgroundColor?: string; selected?: boolean }>;
355
+ items?: Array<{ id: string; backgroundColor?: string; selected?: boolean; accessRole?: string }>;
172
356
  };
357
+ // Events are editable only with a write-scoped token AND on a calendar the
358
+ // account owns/can-write. Computed once here, stamped on each event below.
359
+ const canWrite = googleTokenCanWrite(cfg.token);
173
360
  const cals = (list.items ?? []).map((c) => ({
174
361
  id: c.id,
175
362
  color: c.backgroundColor || fallback,
363
+ writable: canWrite && (c.accessRole === "owner" || c.accessRole === "writer"),
176
364
  }));
177
- if (cals.length === 0) cals.push({ id: "primary", color: fallback });
365
+ if (cals.length === 0) cals.push({ id: "primary", color: fallback, writable: false });
178
366
 
179
367
  const events: CalEvent[] = [];
180
368
  const seen = new Set<string>();
@@ -209,6 +397,9 @@ async function fetchGoogle(
209
397
  endMin,
210
398
  color: cal.color,
211
399
  source: "google",
400
+ calendarId: cal.id,
401
+ eventId: it.id,
402
+ editable: cal.writable && !!it.id,
212
403
  },
213
404
  });
214
405
  }
@@ -28,7 +28,16 @@ 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
+ import {
32
+ createCalendarStore,
33
+ createGoogleEvent,
34
+ deleteGoogleEvent,
35
+ googleTokenCanWrite,
36
+ listGoogleCalendars,
37
+ updateGoogleEvent,
38
+ type CalendarStore,
39
+ type WritableCalendar,
40
+ } from "./calendar";
32
41
  import { ConflictError, statMtime, writeBoardFile } from "~/io/writer";
33
42
  import { isTask, parseBoard } from "~/parser/markdown";
34
43
  import { serializeBoard } from "~/parser/serialize";
@@ -67,9 +76,44 @@ export type ModalKind =
67
76
  | { kind: "confirm-delete"; ref: TaskRef }
68
77
  | { kind: "detail"; ref: TaskRef }
69
78
  | { kind: "agent-detail"; sessionId: string }
79
+ | { kind: "event"; dateIso: string; startMin: number; endMin: number }
80
+ | { kind: "event-edit" }
81
+ | { kind: "confirm-delete-event" }
70
82
  | { kind: "search" }
71
83
  | { kind: "help" };
72
84
 
85
+ /**
86
+ * Transient state for the two-step "new calendar event" modal. Step 1 is the
87
+ * title+time `<input>`; step 2 is the calendar picker, navigated via handleKey
88
+ * (no input focused). Lives in UI state so the key handler can drive it.
89
+ */
90
+ export interface EventPicker {
91
+ step: 1 | 2;
92
+ /** Selection index into `cals` (step 2). */
93
+ sel: number;
94
+ title: string;
95
+ dateIso: string;
96
+ startMin: number;
97
+ endMin: number;
98
+ cals: WritableCalendar[];
99
+ }
100
+
101
+ /**
102
+ * The Google Calendar event currently selected in the Agenda (by clicking an
103
+ * editable event band). Parallel to `armedTimelineRef` but for read/write
104
+ * calendar events rather than tasks. While set, `e` edits and `d` deletes it.
105
+ */
106
+ export interface SelectedCalEvent {
107
+ calendarId: string;
108
+ eventId: string;
109
+ title: string;
110
+ startMin: number;
111
+ endMin: number;
112
+ /** The Agenda day the event was selected on — its date for the API call. */
113
+ dateIso: string;
114
+ color: string;
115
+ }
116
+
73
117
  /** Which dashboard zone owns the keyboard cursor. */
74
118
  export type ActiveZone = "planner" | "board" | "timeline" | "agents";
75
119
 
@@ -118,6 +162,13 @@ export interface UIState {
118
162
  * cancels.
119
163
  */
120
164
  armedTimelineRef?: TaskRef;
165
+ /**
166
+ * The Google Calendar event currently selected in the Agenda (clicked). While
167
+ * set, the Agenda zone's `e` edits it and `d` deletes it; any navigation key
168
+ * or `Esc` clears it. Only ever set for editable events (owner/writer + write
169
+ * token), so the presence of this implies "actionable".
170
+ */
171
+ selectedCalEvent?: SelectedCalEvent;
121
172
  /**
122
173
  * Persistent calendar "arm mode" (toggled with `c`). While on, clicking any
123
174
  * task in the board / planner panel arms it for the timeline, so you can
@@ -146,6 +197,8 @@ export interface UIState {
146
197
  banner?: { kind: "info" | "warn" | "error"; text: string; ts: number };
147
198
  /** Open modal, if any. Keyboard handler routes input to the modal when set. */
148
199
  modal?: ModalKind;
200
+ /** Two-step new-event modal state (set only while `modal.kind === "event"`). */
201
+ eventPicker?: EventPicker;
149
202
  }
150
203
 
151
204
  export interface UndoEntry {
@@ -1058,6 +1111,150 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1058
1111
 
1059
1112
  function closeModal(): void {
1060
1113
  setState("ui", "modal", undefined);
1114
+ setState("ui", "eventPicker", undefined);
1115
+ }
1116
+
1117
+ // ─── New calendar event (two-step modal) ─────────────────────────────────
1118
+
1119
+ /** Open the "new event" modal for a time slot. Guarded on Google write being
1120
+ * connected; prefetches the writable calendars while the user types. */
1121
+ function openEventModal(dateIso: string, startMin: number, endMin: number): void {
1122
+ const g = config.calendars?.google;
1123
+ if (!g || !googleTokenCanWrite(g.token)) {
1124
+ flashBanner("warn", "Connect Google write first: tuiboard calendar-setup google --write");
1125
+ return;
1126
+ }
1127
+ setState("ui", "eventPicker", { step: 1, sel: 0, title: "", dateIso, startMin, endMin, cals: [] });
1128
+ // Defer the modal open so the OpenTUI <input> mounts after this key event.
1129
+ setTimeout(() => openModal({ kind: "event", dateIso, startMin, endMin }), 0);
1130
+ void listGoogleCalendars(g).then((cals) => {
1131
+ setState("ui", "eventPicker", produce((p: EventPicker | undefined) => {
1132
+ if (p && p.cals.length === 0) p.cals = cals;
1133
+ }));
1134
+ });
1135
+ }
1136
+
1137
+ /** Step 1 → step 2: stash the parsed title + time, load calendars if needed,
1138
+ * preselect the default, and either show the picker or (single calendar)
1139
+ * create immediately. */
1140
+ async function advanceEventToStep2(title: string, startMin: number, endMin: number): Promise<void> {
1141
+ const g = config.calendars?.google;
1142
+ if (!g || !state.ui.eventPicker) { closeModal(); return; }
1143
+ let cals = state.ui.eventPicker.cals;
1144
+ if (cals.length === 0) cals = await listGoogleCalendars(g);
1145
+ if (cals.length === 0) {
1146
+ flashBanner("warn", "No writable Google calendars");
1147
+ closeModal();
1148
+ return;
1149
+ }
1150
+ const def = g.defaultCalendar;
1151
+ const idx = cals.findIndex((c) => (def ? c.id === def : c.primary));
1152
+ setState("ui", "eventPicker", produce((p: EventPicker | undefined) => {
1153
+ if (!p) return;
1154
+ p.title = title;
1155
+ p.startMin = startMin;
1156
+ p.endMin = endMin;
1157
+ p.cals = cals;
1158
+ p.sel = idx < 0 ? 0 : idx;
1159
+ p.step = 2;
1160
+ }));
1161
+ if (cals.length === 1) void confirmEventPicker();
1162
+ }
1163
+
1164
+ /** Move the step-2 calendar selection (wraps). */
1165
+ function setEventSel(n: number): void {
1166
+ setState("ui", "eventPicker", produce((p: EventPicker | undefined) => {
1167
+ if (!p || p.cals.length === 0) return;
1168
+ p.sel = ((n % p.cals.length) + p.cals.length) % p.cals.length;
1169
+ }));
1170
+ }
1171
+
1172
+ /** Create the event on the selected calendar, refresh the agenda, close. */
1173
+ async function confirmEventPicker(): Promise<void> {
1174
+ const p = state.ui.eventPicker;
1175
+ const g = config.calendars?.google;
1176
+ if (!p || !g) { closeModal(); return; }
1177
+ const calendarId = p.cals[p.sel]?.id ?? g.defaultCalendar ?? "primary";
1178
+ const title = p.title.trim() || "(busy)";
1179
+ closeModal();
1180
+ const r = await createGoogleEvent(g, {
1181
+ calendarId,
1182
+ title,
1183
+ dateIso: p.dateIso,
1184
+ startMin: p.startMin,
1185
+ endMin: p.endMin,
1186
+ });
1187
+ if (r.ok) {
1188
+ calendarStore.refresh(true);
1189
+ flashBanner("info", `📅 Event created: ${title}`);
1190
+ } else {
1191
+ flashBanner("error", `Create failed: ${r.error}`);
1192
+ }
1193
+ }
1194
+
1195
+ // ─── Edit / delete an existing calendar event ────────────────────────────
1196
+
1197
+ /** Select an editable Google event (clicked in the Agenda). Toggles off if
1198
+ * the same event is clicked again. Clears any armed task so the two
1199
+ * selection models don't fight. */
1200
+ function selectCalEvent(sel: SelectedCalEvent): void {
1201
+ const cur = state.ui.selectedCalEvent;
1202
+ if (cur && cur.calendarId === sel.calendarId && cur.eventId === sel.eventId) {
1203
+ setState("ui", "selectedCalEvent", undefined);
1204
+ return;
1205
+ }
1206
+ setState("ui", "armedTimelineRef", undefined);
1207
+ setState("ui", "selectedCalEvent", sel);
1208
+ }
1209
+
1210
+ function clearCalSelection(): void {
1211
+ setState("ui", "selectedCalEvent", undefined);
1212
+ }
1213
+
1214
+ /** Open the edit modal for the selected event (deferred so the <input>
1215
+ * mounts after this key event). No-op if nothing is selected. */
1216
+ function openEventEditModal(): void {
1217
+ if (!state.ui.selectedCalEvent) return;
1218
+ setTimeout(() => openModal({ kind: "event-edit" }), 0);
1219
+ }
1220
+
1221
+ /** Save the edited title/time to the selected event, refresh, close. */
1222
+ async function confirmEventEdit(title: string, startMin: number, endMin: number): Promise<void> {
1223
+ const sel = state.ui.selectedCalEvent;
1224
+ const g = config.calendars?.google;
1225
+ if (!sel || !g) { closeModal(); return; }
1226
+ closeModal();
1227
+ const r = await updateGoogleEvent(g, {
1228
+ calendarId: sel.calendarId,
1229
+ eventId: sel.eventId,
1230
+ title: title.trim() || sel.title,
1231
+ dateIso: sel.dateIso,
1232
+ startMin,
1233
+ endMin,
1234
+ });
1235
+ clearCalSelection();
1236
+ if (r.ok) {
1237
+ calendarStore.refresh(true);
1238
+ flashBanner("info", `✏ Event updated: ${title.trim() || sel.title}`);
1239
+ } else {
1240
+ flashBanner("error", `Update failed: ${r.error}`);
1241
+ }
1242
+ }
1243
+
1244
+ /** Delete the selected event, refresh, close. */
1245
+ async function confirmDeleteEvent(): Promise<void> {
1246
+ const sel = state.ui.selectedCalEvent;
1247
+ const g = config.calendars?.google;
1248
+ if (!sel || !g) { closeModal(); return; }
1249
+ closeModal();
1250
+ const r = await deleteGoogleEvent(g, { calendarId: sel.calendarId, eventId: sel.eventId });
1251
+ clearCalSelection();
1252
+ if (r.ok) {
1253
+ calendarStore.refresh(true);
1254
+ flashBanner("info", `🗑 Event deleted: ${sel.title}`);
1255
+ } else {
1256
+ flashBanner("error", `Delete failed: ${r.error}`);
1257
+ }
1061
1258
  }
1062
1259
 
1063
1260
  // ─── Private mutation helper ─────────────────────────────────────────────
@@ -1133,6 +1330,15 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1133
1330
  resetAllOverdueToToday,
1134
1331
  openModal,
1135
1332
  closeModal,
1333
+ openEventModal,
1334
+ advanceEventToStep2,
1335
+ setEventSel,
1336
+ confirmEventPicker,
1337
+ selectCalEvent,
1338
+ clearCalSelection,
1339
+ openEventEditModal,
1340
+ confirmEventEdit,
1341
+ confirmDeleteEvent,
1136
1342
  flashBanner,
1137
1343
  clearBanner,
1138
1344
  // undo
@@ -50,6 +50,12 @@ export interface CalTimelineEntry extends BaseEntry {
50
50
  title: string;
51
51
  color: string;
52
52
  source: "google" | "microsoft";
53
+ /** Google calendar id (Google events only) — needed to edit/delete. */
54
+ calendarId?: string;
55
+ /** Google event id (Google events only) — needed to edit/delete. */
56
+ eventId?: string;
57
+ /** True when this event can be edited/deleted from tuiboard. */
58
+ editable?: boolean;
53
59
  }
54
60
 
55
61
  export type TimelineEntry = TaskTimelineEntry | CalTimelineEntry;
@@ -168,7 +174,16 @@ export function buildTimelineEntries(
168
174
  * the target day) into grid entries, clipped to the rendered window.
169
175
  */
170
176
  export function buildCalendarEntries(
171
- events: Array<{ title: string; startMin: number; endMin: number; color: string; source: "google" | "microsoft" }>,
177
+ events: Array<{
178
+ title: string;
179
+ startMin: number;
180
+ endMin: number;
181
+ color: string;
182
+ source: "google" | "microsoft";
183
+ calendarId?: string;
184
+ eventId?: string;
185
+ editable?: boolean;
186
+ }>,
172
187
  ): CalTimelineEntry[] {
173
188
  const out: CalTimelineEntry[] = [];
174
189
  for (const e of events) {
@@ -179,6 +194,9 @@ export function buildCalendarEntries(
179
194
  title: e.title,
180
195
  color: e.color,
181
196
  source: e.source,
197
+ calendarId: e.calendarId,
198
+ eventId: e.eventId,
199
+ editable: e.editable,
182
200
  startMin: e.startMin,
183
201
  endMin: e.endMin,
184
202
  startRow: rows.startRow,
package/src/ui/Modal.tsx CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  } from "~/store/parsers";
22
22
  import { ATTR, T } from "~/ui/glyphs";
23
23
  import { AGENDA_WIDTH } from "~/ui/layout";
24
+ import { formatHm } from "~/store/timeline";
24
25
  import type { TuiStore } from "~/store/index";
25
26
  import type { PriorityLevel, TimeBlock } from "~/types";
26
27
 
@@ -50,6 +51,9 @@ function ModalRouter(props: { store: TuiStore; modal: NonNullable<TuiStore["stat
50
51
  case "confirm-delete": return <ConfirmDeleteModal store={props.store} modal={m} />;
51
52
  case "detail": return <DetailModal store={props.store} modal={m} />;
52
53
  case "agent-detail": return <AgentDetailModal store={props.store} modal={m} />;
54
+ case "event": return <EventModal store={props.store} />;
55
+ case "event-edit": return <EventEditModal store={props.store} />;
56
+ case "confirm-delete-event": return <ConfirmDeleteEventModal store={props.store} />;
53
57
  case "search": return <SearchModal store={props.store} />;
54
58
  case "help": return <HelpModal store={props.store} />;
55
59
  }
@@ -267,6 +271,191 @@ function TimeBlockModal(props: { store: TuiStore; modal: Extract<NonNullable<Tui
267
271
  );
268
272
  }
269
273
 
274
+ // ─── New calendar event ──────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * Two-step "new Google Calendar event" modal. Step 1: a title+time `<input>`
278
+ * (time prefilled from the clicked slot; append `HH:MM-HH:MM` to override).
279
+ * Step 2: a non-input calendar list navigated via handleKey (no input focused),
280
+ * preselected to the configured default. See `openEventModal` / `confirmEventPicker`.
281
+ */
282
+ function EventModal(props: { store: TuiStore }) {
283
+ const picker = () => props.store.state.ui.eventPicker;
284
+ const defaultId = () => props.store.config.calendars?.google?.defaultCalendar;
285
+ const [value, setValue] = createSignal("");
286
+ const [error, setError] = createSignal<string | undefined>();
287
+
288
+ function submit(text: string) {
289
+ const p = picker();
290
+ if (!p) return;
291
+ const trimmed = text.trim();
292
+ let title = trimmed;
293
+ let startMin = p.startMin;
294
+ let endMin = p.endMin;
295
+ // Peel a trailing time token: "Standup 9:00-9:30" / "Lunch 12-13".
296
+ const m = trimmed.match(/\s(\S+)$/);
297
+ const tok = m?.[1];
298
+ if (m && m.index !== undefined && tok) {
299
+ const tb = parseTimeBlockShortcut(tok);
300
+ if (tb && tb.endMin > tb.startMin) {
301
+ title = trimmed.slice(0, m.index).trim();
302
+ startMin = tb.startMin;
303
+ endMin = tb.endMin;
304
+ }
305
+ }
306
+ if (!title) {
307
+ setError("Title required");
308
+ return;
309
+ }
310
+ void props.store.advanceEventToStep2(title, startMin, endMin);
311
+ }
312
+
313
+ return (
314
+ <Show when={picker()}>
315
+ <Show
316
+ when={picker()!.step === 2}
317
+ fallback={
318
+ <DialogShell
319
+ title="New event"
320
+ hint={`${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)} · append HH:MM-HH:MM to change · Enter add · Esc cancel`}
321
+ >
322
+ <input
323
+ focused
324
+ value={value()}
325
+ onInput={(v: string) => {
326
+ setValue(v);
327
+ setError(undefined);
328
+ }}
329
+ onSubmit={((v: string) => submit(v)) as any}
330
+ />
331
+ <Show when={error()}>
332
+ <text>
333
+ <span style={{ fg: T.bannerError }}>{error()!}</span>
334
+ </text>
335
+ </Show>
336
+ </DialogShell>
337
+ }
338
+ >
339
+ <DialogShell
340
+ title={`Calendar · ${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)}`}
341
+ hint="j/k choose · Enter create · Esc cancel"
342
+ >
343
+ <For each={picker()!.cals}>
344
+ {(c, i) => {
345
+ const isSel = () => i() === picker()!.sel;
346
+ const isDefault = defaultId() ? c.id === defaultId() : c.primary;
347
+ return (
348
+ <box style={{ backgroundColor: isSel() ? T.cardBgCursor : undefined }}>
349
+ <text wrapMode="none" truncate>
350
+ <span style={{ fg: isSel() ? T.accent : T.textDim }}>
351
+ {isSel() ? "▶ " : " "}
352
+ </span>
353
+ <span style={{ fg: c.color }}>{"● "}</span>
354
+ <span style={{ fg: T.text }}>{c.summary}</span>
355
+ <Show when={isDefault}>
356
+ <span style={{ fg: T.textDim }}>{" (default)"}</span>
357
+ </Show>
358
+ </text>
359
+ </box>
360
+ );
361
+ }}
362
+ </For>
363
+ </DialogShell>
364
+ </Show>
365
+ </Show>
366
+ );
367
+ }
368
+
369
+ // ─── Edit existing calendar event ────────────────────────────────────────────
370
+
371
+ /**
372
+ * Edit the selected Google Calendar event's title + time (same calendar). A
373
+ * single `<input>` prefilled with "Title HH:MM-HH:MM"; Enter saves via PATCH.
374
+ * Reads `ui.selectedCalEvent` (set by clicking an editable event in the Agenda).
375
+ */
376
+ function EventEditModal(props: { store: TuiStore }) {
377
+ const sel = () => props.store.state.ui.selectedCalEvent;
378
+ const s0 = sel();
379
+ const [value, setValue] = createSignal(
380
+ s0 ? `${s0.title} ${formatHm(s0.startMin)}-${formatHm(s0.endMin)}` : "",
381
+ );
382
+ const [error, setError] = createSignal<string | undefined>();
383
+
384
+ function submit(text: string) {
385
+ const s = sel();
386
+ if (!s) {
387
+ props.store.closeModal();
388
+ return;
389
+ }
390
+ const trimmed = text.trim();
391
+ let title = trimmed;
392
+ let startMin = s.startMin;
393
+ let endMin = s.endMin;
394
+ // Peel a trailing time token: "Standup 9:00-9:30" / "Lunch 12-13".
395
+ const m = trimmed.match(/\s(\S+)$/);
396
+ const tok = m?.[1];
397
+ if (m && m.index !== undefined && tok) {
398
+ const tb = parseTimeBlockShortcut(tok);
399
+ if (tb && tb.endMin > tb.startMin) {
400
+ title = trimmed.slice(0, m.index).trim();
401
+ startMin = tb.startMin;
402
+ endMin = tb.endMin;
403
+ }
404
+ }
405
+ if (!title) {
406
+ setError("Title required");
407
+ return;
408
+ }
409
+ void props.store.confirmEventEdit(title, startMin, endMin);
410
+ }
411
+
412
+ return (
413
+ <Show when={sel()}>
414
+ <DialogShell
415
+ title="Edit event"
416
+ hint="append HH:MM-HH:MM to change the time · Enter save · Esc cancel"
417
+ >
418
+ <input
419
+ focused
420
+ value={value()}
421
+ onInput={(v: string) => {
422
+ setValue(v);
423
+ setError(undefined);
424
+ }}
425
+ onSubmit={((v: string) => submit(v)) as any}
426
+ />
427
+ <Show when={error()}>
428
+ <text>
429
+ <span style={{ fg: T.bannerError }}>{error()!}</span>
430
+ </text>
431
+ </Show>
432
+ </DialogShell>
433
+ </Show>
434
+ );
435
+ }
436
+
437
+ // ─── Confirm delete calendar event ───────────────────────────────────────────
438
+
439
+ function ConfirmDeleteEventModal(props: { store: TuiStore }) {
440
+ const sel = () => props.store.state.ui.selectedCalEvent;
441
+ return (
442
+ <DialogShell title="Delete event?" hint="⏎/y confirm · Esc/n cancel">
443
+ <text wrapMode="none" truncate>
444
+ <span style={{ fg: sel()?.color ?? T.text }}>{"📅 "}</span>
445
+ <span style={{ fg: T.text }}>{sel()?.title ?? "(missing)"}</span>
446
+ <Show when={sel()}>
447
+ <span style={{ fg: T.textDim }}>
448
+ {` ${formatHm(sel()!.startMin)}-${formatHm(sel()!.endMin)}`}
449
+ </span>
450
+ </Show>
451
+ </text>
452
+ <text>
453
+ <span style={{ fg: T.textDim }}>Deletes from Google Calendar — cannot be undone here.</span>
454
+ </text>
455
+ </DialogShell>
456
+ );
457
+ }
458
+
270
459
  // ─── Assign ──────────────────────────────────────────────────────────────────
271
460
 
272
461
  function AssignModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "assign" }> }) {
@@ -599,6 +788,8 @@ function HelpModal(props: { store: TuiStore }) {
599
788
  <span style={{ fg: T.text }}>{" [ / ] Previous / next day (tasks + calendar events)\n"}</span>
600
789
  <span style={{ fg: T.text }}>{" \\ Jump back to today\n"}</span>
601
790
  <span style={{ fg: T.textDim }}>{"\nAgenda (timeline) scheduling\n"}</span>
791
+ <span style={{ fg: T.text }}>{" n / click slot New Google Calendar event (needs: calendar-setup google --write)\n"}</span>
792
+ <span style={{ fg: T.text }}>{" click an event Select an editable Google event — then e edit · d delete · Esc\n"}</span>
602
793
  <span style={{ fg: T.text }}>{" c (any zone) Toggle ARM MODE — then click a task, click a slot, repeat\n"}</span>
603
794
  <span style={{ fg: T.text }}>{" click empty row Place the armed task here (30-min block, or move if it has one)\n"}</span>
604
795
  <span style={{ fg: T.text }}>{" click band Arm an existing block (or place the armed task at its start)\n"}</span>
@@ -36,6 +36,7 @@ import {
36
36
  onMount,
37
37
  } from "solid-js";
38
38
 
39
+ import { googleTokenCanWrite } from "~/store/calendar";
39
40
  import type { TaskRef } from "~/store/index";
40
41
  import {
41
42
  DAY_START_HOUR,
@@ -92,6 +93,13 @@ export function TimelineView(props: TimelineViewProps) {
92
93
  const viewedDate = () => props.store.agendaDate();
93
94
  const isToday = () => props.store.state.ui.agendaOffset === 0;
94
95
 
96
+ // The Google event currently selected (clicked) for edit/delete, if any.
97
+ const selectedCal = () => props.store.state.ui.selectedCalEvent;
98
+ const selectedCalKey = () => {
99
+ const s = selectedCal();
100
+ return s ? `${s.calendarId}:${s.eventId}` : undefined;
101
+ };
102
+
95
103
  // Task entries drive the cursor + arm/keyboard interactions.
96
104
  const entries = createMemo(() => {
97
105
  props.store.state.rev; // recompute on any board mutation
@@ -189,11 +197,34 @@ export function TimelineView(props: TimelineViewProps) {
189
197
  const onBlockClick = (entry: TimelineEntry, event: MouseEventLike) => {
190
198
  props.store.setActiveZone("timeline");
191
199
 
192
- // Calendar events are read-only: they can't be armed or moved. A click on
193
- // one while a task is armed just places the armed task at that slot;
194
- // otherwise it's a no-op (beyond focusing the zone).
200
+ // Calendar events can't be armed or time-block-moved. While a task is armed,
201
+ // a click places that task at this slot (unchanged). Otherwise: an editable
202
+ // Google event gets SELECTED for edit/delete (toggles off on re-click); a
203
+ // read-only event just reports that it can't be changed.
195
204
  if (entry.kind !== "task") {
196
- if (armedRef()) onEmptyRowClick(entry.startRow, event);
205
+ if (armedRef()) {
206
+ onEmptyRowClick(entry.startRow, event);
207
+ return;
208
+ }
209
+ if (entry.kind === "calendar") {
210
+ if (entry.editable && entry.calendarId && entry.eventId) {
211
+ const wasSelected = selectedCalKey() === `${entry.calendarId}:${entry.eventId}`;
212
+ props.store.selectCalEvent({
213
+ calendarId: entry.calendarId,
214
+ eventId: entry.eventId,
215
+ title: entry.title,
216
+ startMin: entry.startMin,
217
+ endMin: entry.endMin,
218
+ dateIso: viewedDate(),
219
+ color: entry.color,
220
+ });
221
+ if (!wasSelected) {
222
+ props.store.flashBanner("info", `Selected "${tailTruncate(entry.title, 28)}" · e edit · d delete · Esc`);
223
+ }
224
+ } else {
225
+ props.store.flashBanner("info", "Read-only event — not on a writable calendar");
226
+ }
227
+ }
197
228
  return;
198
229
  }
199
230
 
@@ -238,7 +269,17 @@ export function TimelineView(props: TimelineViewProps) {
238
269
  const onEmptyRowClick = (rowIndex: number, event: MouseEventLike) => {
239
270
  const armed = armedTask();
240
271
  const ref = armedRef();
241
- if (!armed || !ref) return;
272
+ if (!armed || !ref) {
273
+ // Nothing armed: an empty-slot click creates a Google Calendar event at
274
+ // that time (only when Google write is connected — otherwise a no-op).
275
+ const g = props.store.config.calendars?.google;
276
+ if (g && googleTokenCanWrite(g.token)) {
277
+ const startMin = Math.max(0, DAY_START_HOUR * 60 + rowIndex * MINS_PER_ROW);
278
+ const endMin = Math.min(24 * 60 - 1, startMin + DEFAULT_BLOCK_MIN);
279
+ props.store.openEventModal(viewedDate(), startMin, endMin);
280
+ }
281
+ return;
282
+ }
242
283
  const targetMin = DAY_START_HOUR * 60 + rowIndex * MINS_PER_ROW;
243
284
 
244
285
  // Unscheduled task → create a fresh block at the clicked row.
@@ -335,10 +376,21 @@ export function TimelineView(props: TimelineViewProps) {
335
376
  </span>
336
377
  </text>
337
378
  </Show>
379
+ {/* A selected calendar event shows its own action hint. */}
380
+ <Show when={selectedCal()}>
381
+ <text wrapMode="none">
382
+ <span style={{ fg: T.warm, attributes: ATTR.bold }}>
383
+ {"📅 "}{tailTruncate(selectedCal()!.title, 28)}{" "}
384
+ </span>
385
+ <span style={{ fg: T.textDim }}>
386
+ {" e edit · d delete · Esc deselect"}
387
+ </span>
388
+ </text>
389
+ </Show>
338
390
  {/* Day-navigation hint — always visible in the resting state (not while
339
- arming) so the [ ] day-switch is discoverable. Off-today, the
340
- "\ today" reset is highlighted to pull the eye back. */}
341
- <Show when={!armMode() && !armedTask()}>
391
+ arming or with an event selected) so the [ ] day-switch is
392
+ discoverable. Off-today, the "\ today" reset is highlighted. */}
393
+ <Show when={!armMode() && !armedTask() && !selectedCal()}>
342
394
  <text wrapMode="none">
343
395
  <span style={{ fg: T.warm }}>{"◷ "}</span>
344
396
  <span style={{ fg: T.textDim }}>{"[ ] change day · "}</span>
@@ -376,6 +428,7 @@ export function TimelineView(props: TimelineViewProps) {
376
428
  rowIndex={i()}
377
429
  cursorEntry={isActive() ? cursorEntry() : undefined}
378
430
  armedEntry={armedEntry()}
431
+ selectedCalKey={selectedCalKey()}
379
432
  innerWidth={props.width ? props.width - 4 : undefined}
380
433
  onBlockClick={onBlockClick}
381
434
  onEmptyRowClick={onEmptyRowClick}
@@ -423,6 +476,8 @@ interface TimelineRowProps {
423
476
  cursorEntry: TimelineEntry | undefined;
424
477
  /** When set, the armed entry — used to tint its rows warm. */
425
478
  armedEntry: TimelineEntry | undefined;
479
+ /** `${calendarId}:${eventId}` of the selected calendar event, if any. */
480
+ selectedCalKey: string | undefined;
426
481
  /** Panel content width (border+padding already removed). Undefined = fullscreen. */
427
482
  innerWidth?: number;
428
483
  onBlockClick: (entry: TimelineEntry, event: MouseEventLike) => void;
@@ -455,6 +510,13 @@ function TimelineRow(props: TimelineRowProps) {
455
510
  const rightIsArmed = () =>
456
511
  !!props.armedEntry && right().entry === props.armedEntry;
457
512
 
513
+ const isSelectedCal = (e: TimelineEntry | undefined) =>
514
+ !!props.selectedCalKey &&
515
+ e?.kind === "calendar" &&
516
+ `${e.calendarId}:${e.eventId}` === props.selectedCalKey;
517
+ const leftIsSelCal = () => isSelectedCal(left().entry);
518
+ const rightIsSelCal = () => isSelectedCal(right().entry);
519
+
458
520
  const entryDone = (e: TimelineEntry | undefined) =>
459
521
  e?.kind === "task" && e.task.done;
460
522
  const leftIsDone = () => entryDone(left().entry);
@@ -490,6 +552,7 @@ function TimelineRow(props: TimelineRowProps) {
490
552
  backgroundColor: laneBg(
491
553
  leftIsCursor(),
492
554
  leftIsArmed(),
555
+ leftIsSelCal(),
493
556
  leftIsBlock(),
494
557
  leftIsDone(),
495
558
  ),
@@ -518,6 +581,7 @@ function TimelineRow(props: TimelineRowProps) {
518
581
  backgroundColor: laneBg(
519
582
  leftIsCursor(),
520
583
  leftIsArmed(),
584
+ leftIsSelCal(),
521
585
  leftIsBlock(),
522
586
  leftIsDone(),
523
587
  ),
@@ -540,6 +604,7 @@ function TimelineRow(props: TimelineRowProps) {
540
604
  backgroundColor: laneBg(
541
605
  rightIsCursor(),
542
606
  rightIsArmed(),
607
+ rightIsSelCal(),
543
608
  rightIsBlock(),
544
609
  rightIsDone(),
545
610
  ),
@@ -726,10 +791,11 @@ function isBlockKind(k: RowMapEntry["kind"]): boolean {
726
791
  function laneBg(
727
792
  isCursor: boolean,
728
793
  isArmed: boolean,
794
+ isSelectedCal: boolean,
729
795
  isBlock: boolean,
730
796
  isDone: boolean,
731
797
  ): string | undefined {
732
- if (isArmed) return T.warmDim;
798
+ if (isArmed || isSelectedCal) return T.warmDim;
733
799
  if (isCursor) return T.cardBgCursor;
734
800
  if (isBlock) return isDone ? T.cardBlockBgDone : T.cardBlockBg;
735
801
  return undefined;