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.
@@ -29,6 +29,18 @@ 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;
40
+ /** All-day event (date-only, no time). Rendered as a chip at the top of the
41
+ * Agenda rather than on the 24h grid; startMin/endMin are unused. Display
42
+ * only — not editable from tuiboard. */
43
+ allDay?: boolean;
32
44
  }
33
45
 
34
46
  const GOOGLE_FALLBACK_COLOR = "#e8a05c";
@@ -143,6 +155,198 @@ async function googleAccessToken(tokenPath: string): Promise<string | null> {
143
155
  }
144
156
  }
145
157
 
158
+ // ─── Google write (calendar list + event creation) ──────────────────────────
159
+
160
+ /** A calendar the connected account can write to (accessRole owner/writer). */
161
+ export interface WritableCalendar {
162
+ id: string;
163
+ summary: string;
164
+ accessRole: string;
165
+ color: string;
166
+ primary: boolean;
167
+ }
168
+
169
+ /**
170
+ * List the calendars the connected Google account can WRITE to (owner/writer),
171
+ * with display name + color. Used by the new-event calendar picker. Returns []
172
+ * on any failure. (Read path `fetchGoogle` keeps its own, reader-scoped query.)
173
+ */
174
+ export async function listGoogleCalendars(
175
+ cfg: GoogleCalendarConfig,
176
+ ): Promise<WritableCalendar[]> {
177
+ const access = await googleAccessToken(cfg.token);
178
+ if (!access) return [];
179
+ try {
180
+ const res = await fetch(
181
+ "https://www.googleapis.com/calendar/v3/users/me/calendarList",
182
+ { headers: { Authorization: `Bearer ${access}` } },
183
+ );
184
+ if (!res.ok) return [];
185
+ const list = (await res.json()) as {
186
+ items?: Array<{
187
+ id: string;
188
+ summary?: string;
189
+ accessRole?: string;
190
+ backgroundColor?: string;
191
+ primary?: boolean;
192
+ }>;
193
+ };
194
+ return (list.items ?? [])
195
+ .filter((c) => c.accessRole === "owner" || c.accessRole === "writer")
196
+ .map((c) => ({
197
+ id: c.id,
198
+ summary: c.summary ?? c.id,
199
+ accessRole: c.accessRole ?? "reader",
200
+ color: c.backgroundColor || cfg.color || GOOGLE_FALLBACK_COLOR,
201
+ primary: c.primary === true,
202
+ }));
203
+ } catch {
204
+ return [];
205
+ }
206
+ }
207
+
208
+ /** True if the persisted Google token carries an event-write scope. Gates the
209
+ * whole event-creation UI so read-only users never see it. */
210
+ export function googleTokenCanWrite(tokenPath: string): boolean {
211
+ try {
212
+ const tok = JSON.parse(readFileSync(tokenPath, "utf-8")) as { scopes?: string[] };
213
+ return (
214
+ Array.isArray(tok.scopes) &&
215
+ tok.scopes.some((s) => s.includes("calendar.events") || s.endsWith("/auth/calendar"))
216
+ );
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+
222
+ /** RFC3339 timestamp with the LOCAL UTC offset for `min` minutes past local
223
+ * midnight of `dateIso` — e.g. "2026-06-03T15:00:00+02:00". Per-instant offset,
224
+ * so DST is handled; Google then stores the wall-clock time exactly as typed. */
225
+ function localRfc3339(dateIso: string, min: number): string {
226
+ const d = new Date(dayStartMs(dateIso) + min * 60000);
227
+ const pad = (n: number) => String(n).padStart(2, "0");
228
+ const offMin = -d.getTimezoneOffset(); // minutes east of UTC
229
+ const sign = offMin >= 0 ? "+" : "-";
230
+ const off = Math.abs(offMin);
231
+ return (
232
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
233
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:00` +
234
+ `${sign}${pad(Math.floor(off / 60))}:${pad(off % 60)}`
235
+ );
236
+ }
237
+
238
+ /** The day after `dateIso` (YYYY-MM-DD). Google all-day events use an EXCLUSIVE
239
+ * end date, so a single-day all-day event ends on the following day. */
240
+ function nextDayIso(dateIso: string): string {
241
+ const d = new Date(dayStartMs(dateIso) + 24 * 60 * 60000);
242
+ const pad = (n: number) => String(n).padStart(2, "0");
243
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
244
+ }
245
+
246
+ /**
247
+ * Create a Google Calendar event on `calendarId`. Unlike the read path, this
248
+ * surfaces failures (returns {ok:false,error}) so the UI can flash a banner.
249
+ * Pass `allDay` for a date-only event (start/end as dates, end exclusive).
250
+ */
251
+ export async function createGoogleEvent(
252
+ cfg: GoogleCalendarConfig,
253
+ args: { calendarId: string; title: string; dateIso: string; startMin: number; endMin: number; allDay?: boolean },
254
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
255
+ const access = await googleAccessToken(cfg.token);
256
+ if (!access) {
257
+ return { ok: false, error: "not authorized — run: tuiboard calendar-setup google --write" };
258
+ }
259
+ try {
260
+ const body = args.allDay
261
+ ? {
262
+ summary: args.title,
263
+ start: { date: args.dateIso },
264
+ end: { date: nextDayIso(args.dateIso) },
265
+ }
266
+ : {
267
+ summary: args.title,
268
+ start: { dateTime: localRfc3339(args.dateIso, args.startMin) },
269
+ end: { dateTime: localRfc3339(args.dateIso, args.endMin) },
270
+ };
271
+ const res = await fetch(
272
+ `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(args.calendarId)}/events`,
273
+ {
274
+ method: "POST",
275
+ headers: { Authorization: `Bearer ${access}`, "Content-Type": "application/json" },
276
+ body: JSON.stringify(body),
277
+ },
278
+ );
279
+ if (!res.ok) {
280
+ const body = await res.text().catch(() => "");
281
+ return { ok: false, error: `${res.status} ${body.slice(0, 140)}` };
282
+ }
283
+ return { ok: true };
284
+ } catch (e) {
285
+ return { ok: false, error: String(e) };
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Edit an existing Google Calendar event's title + time (same calendar — moving
291
+ * an event between calendars is intentionally not supported). PATCH so untouched
292
+ * fields (attendees, description, recurrence, …) are preserved.
293
+ */
294
+ export async function updateGoogleEvent(
295
+ cfg: GoogleCalendarConfig,
296
+ args: { calendarId: string; eventId: string; title: string; dateIso: string; startMin: number; endMin: number },
297
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
298
+ const access = await googleAccessToken(cfg.token);
299
+ if (!access) {
300
+ return { ok: false, error: "not authorized — run: tuiboard calendar-setup google --write" };
301
+ }
302
+ try {
303
+ const res = await fetch(
304
+ `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(args.calendarId)}/events/${encodeURIComponent(args.eventId)}`,
305
+ {
306
+ method: "PATCH",
307
+ headers: { Authorization: `Bearer ${access}`, "Content-Type": "application/json" },
308
+ body: JSON.stringify({
309
+ summary: args.title,
310
+ start: { dateTime: localRfc3339(args.dateIso, args.startMin) },
311
+ end: { dateTime: localRfc3339(args.dateIso, args.endMin) },
312
+ }),
313
+ },
314
+ );
315
+ if (!res.ok) {
316
+ const body = await res.text().catch(() => "");
317
+ return { ok: false, error: `${res.status} ${body.slice(0, 140)}` };
318
+ }
319
+ return { ok: true };
320
+ } catch (e) {
321
+ return { ok: false, error: String(e) };
322
+ }
323
+ }
324
+
325
+ /** Delete a Google Calendar event. DELETE returns 204 (no body) on success. */
326
+ export async function deleteGoogleEvent(
327
+ cfg: GoogleCalendarConfig,
328
+ args: { calendarId: string; eventId: string },
329
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
330
+ const access = await googleAccessToken(cfg.token);
331
+ if (!access) {
332
+ return { ok: false, error: "not authorized — run: tuiboard calendar-setup google --write" };
333
+ }
334
+ try {
335
+ const res = await fetch(
336
+ `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(args.calendarId)}/events/${encodeURIComponent(args.eventId)}`,
337
+ { method: "DELETE", headers: { Authorization: `Bearer ${access}` } },
338
+ );
339
+ // 410 Gone = already deleted; treat as success (the goal state is reached).
340
+ if (!res.ok && res.status !== 410) {
341
+ const body = await res.text().catch(() => "");
342
+ return { ok: false, error: `${res.status} ${body.slice(0, 140)}` };
343
+ }
344
+ return { ok: true };
345
+ } catch (e) {
346
+ return { ok: false, error: String(e) };
347
+ }
348
+ }
349
+
146
350
  async function fetchGoogle(
147
351
  cfg: GoogleCalendarConfig,
148
352
  dateIso: string,
@@ -168,13 +372,17 @@ async function fetchGoogle(
168
372
  );
169
373
  if (!listRes.ok) return [];
170
374
  const list = (await listRes.json()) as {
171
- items?: Array<{ id: string; backgroundColor?: string; selected?: boolean }>;
375
+ items?: Array<{ id: string; backgroundColor?: string; selected?: boolean; accessRole?: string }>;
172
376
  };
377
+ // Events are editable only with a write-scoped token AND on a calendar the
378
+ // account owns/can-write. Computed once here, stamped on each event below.
379
+ const canWrite = googleTokenCanWrite(cfg.token);
173
380
  const cals = (list.items ?? []).map((c) => ({
174
381
  id: c.id,
175
382
  color: c.backgroundColor || fallback,
383
+ writable: canWrite && (c.accessRole === "owner" || c.accessRole === "writer"),
176
384
  }));
177
- if (cals.length === 0) cals.push({ id: "primary", color: fallback });
385
+ if (cals.length === 0) cals.push({ id: "primary", color: fallback, writable: false });
178
386
 
179
387
  const events: CalEvent[] = [];
180
388
  const seen = new Set<string>();
@@ -199,7 +407,24 @@ async function fetchGoogle(
199
407
  for (const it of data.items ?? []) {
200
408
  const startRaw = it.start?.dateTime;
201
409
  const endRaw = it.end?.dateTime;
202
- if (!startRaw || !endRaw) continue; // skip all-day (date only)
410
+ if (!startRaw || !endRaw) {
411
+ // All-day / date-only event: the per-day query already scoped it
412
+ // to a day it covers, so just surface it as a chip (display only).
413
+ const dayKey = it.start?.date;
414
+ if (!dayKey) continue;
415
+ out.push({
416
+ uid: it.id ?? `${cal.id}:allday:${dayKey}`,
417
+ ev: {
418
+ title: it.summary ?? "(no title)",
419
+ startMin: 0,
420
+ endMin: 0,
421
+ color: cal.color,
422
+ source: "google",
423
+ allDay: true,
424
+ },
425
+ });
426
+ continue;
427
+ }
203
428
  const { startMin, endMin } = toMinutes(Date.parse(startRaw), Date.parse(endRaw), base);
204
429
  out.push({
205
430
  uid: it.id ?? `${cal.id}:${startRaw}`,
@@ -209,6 +434,9 @@ async function fetchGoogle(
209
434
  endMin,
210
435
  color: cal.color,
211
436
  source: "google",
437
+ calendarId: cal.id,
438
+ eventId: it.id,
439
+ editable: cal.writable && !!it.id,
212
440
  },
213
441
  });
214
442
  }
@@ -349,7 +577,18 @@ async function fetchMicrosoft(
349
577
  };
350
578
  const events: CalEvent[] = [];
351
579
  for (const it of data.value ?? []) {
352
- if (it.isAllDay) continue; // mirror Google: skip all-day events
580
+ if (it.isAllDay) {
581
+ // All-day event → chip at the top of the Agenda (display only).
582
+ events.push({
583
+ title: it.subject ?? "(no title)",
584
+ startMin: 0,
585
+ endMin: 0,
586
+ color,
587
+ source: "microsoft",
588
+ allDay: true,
589
+ });
590
+ continue;
591
+ }
353
592
  const s = it.start?.dateTime;
354
593
  const e = it.end?.dateTime;
355
594
  if (!s || !e) continue;
@@ -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,46 @@ 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
+ /** Create as a date-only all-day event (start/end times ignored). */
99
+ allDay?: boolean;
100
+ cals: WritableCalendar[];
101
+ }
102
+
103
+ /**
104
+ * The Google Calendar event currently selected in the Agenda (by clicking an
105
+ * editable event band). Parallel to `armedTimelineRef` but for read/write
106
+ * calendar events rather than tasks. While set, `e` edits and `d` deletes it.
107
+ */
108
+ export interface SelectedCalEvent {
109
+ calendarId: string;
110
+ eventId: string;
111
+ title: string;
112
+ startMin: number;
113
+ endMin: number;
114
+ /** The Agenda day the event was selected on — its date for the API call. */
115
+ dateIso: string;
116
+ color: string;
117
+ }
118
+
73
119
  /** Which dashboard zone owns the keyboard cursor. */
74
120
  export type ActiveZone = "planner" | "board" | "timeline" | "agents";
75
121
 
@@ -118,6 +164,13 @@ export interface UIState {
118
164
  * cancels.
119
165
  */
120
166
  armedTimelineRef?: TaskRef;
167
+ /**
168
+ * The Google Calendar event currently selected in the Agenda (clicked). While
169
+ * set, the Agenda zone's `e` edits it and `d` deletes it; any navigation key
170
+ * or `Esc` clears it. Only ever set for editable events (owner/writer + write
171
+ * token), so the presence of this implies "actionable".
172
+ */
173
+ selectedCalEvent?: SelectedCalEvent;
121
174
  /**
122
175
  * Persistent calendar "arm mode" (toggled with `c`). While on, clicking any
123
176
  * task in the board / planner panel arms it for the timeline, so you can
@@ -146,6 +199,8 @@ export interface UIState {
146
199
  banner?: { kind: "info" | "warn" | "error"; text: string; ts: number };
147
200
  /** Open modal, if any. Keyboard handler routes input to the modal when set. */
148
201
  modal?: ModalKind;
202
+ /** Two-step new-event modal state (set only while `modal.kind === "event"`). */
203
+ eventPicker?: EventPicker;
149
204
  }
150
205
 
151
206
  export interface UndoEntry {
@@ -1058,6 +1113,155 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1058
1113
 
1059
1114
  function closeModal(): void {
1060
1115
  setState("ui", "modal", undefined);
1116
+ setState("ui", "eventPicker", undefined);
1117
+ }
1118
+
1119
+ // ─── New calendar event (two-step modal) ─────────────────────────────────
1120
+
1121
+ /** Open the "new event" modal for a time slot. Guarded on Google write being
1122
+ * connected; prefetches the writable calendars while the user types. */
1123
+ function openEventModal(dateIso: string, startMin: number, endMin: number): void {
1124
+ const g = config.calendars?.google;
1125
+ if (!g || !googleTokenCanWrite(g.token)) {
1126
+ flashBanner("warn", "Connect Google write first: tuiboard calendar-setup google --write");
1127
+ return;
1128
+ }
1129
+ setState("ui", "eventPicker", { step: 1, sel: 0, title: "", dateIso, startMin, endMin, cals: [] });
1130
+ // Defer the modal open so the OpenTUI <input> mounts after this key event.
1131
+ setTimeout(() => openModal({ kind: "event", dateIso, startMin, endMin }), 0);
1132
+ void listGoogleCalendars(g).then((cals) => {
1133
+ setState("ui", "eventPicker", produce((p: EventPicker | undefined) => {
1134
+ if (p && p.cals.length === 0) p.cals = cals;
1135
+ }));
1136
+ });
1137
+ }
1138
+
1139
+ /** Step 1 → step 2: stash the parsed title + time, load calendars if needed,
1140
+ * preselect the default, and either show the picker or (single calendar)
1141
+ * create immediately. */
1142
+ async function advanceEventToStep2(title: string, startMin: number, endMin: number, dateIso?: string, allDay?: boolean): Promise<void> {
1143
+ const g = config.calendars?.google;
1144
+ if (!g || !state.ui.eventPicker) { closeModal(); return; }
1145
+ let cals = state.ui.eventPicker.cals;
1146
+ if (cals.length === 0) cals = await listGoogleCalendars(g);
1147
+ if (cals.length === 0) {
1148
+ flashBanner("warn", "No writable Google calendars");
1149
+ closeModal();
1150
+ return;
1151
+ }
1152
+ const def = g.defaultCalendar;
1153
+ const idx = cals.findIndex((c) => (def ? c.id === def : c.primary));
1154
+ setState("ui", "eventPicker", produce((p: EventPicker | undefined) => {
1155
+ if (!p) return;
1156
+ p.title = title;
1157
+ p.startMin = startMin;
1158
+ p.endMin = endMin;
1159
+ if (dateIso) p.dateIso = dateIso; // explicit date token overrides the viewed day
1160
+ p.allDay = !!allDay;
1161
+ p.cals = cals;
1162
+ p.sel = idx < 0 ? 0 : idx;
1163
+ p.step = 2;
1164
+ }));
1165
+ if (cals.length === 1) void confirmEventPicker();
1166
+ }
1167
+
1168
+ /** Move the step-2 calendar selection (wraps). */
1169
+ function setEventSel(n: number): void {
1170
+ setState("ui", "eventPicker", produce((p: EventPicker | undefined) => {
1171
+ if (!p || p.cals.length === 0) return;
1172
+ p.sel = ((n % p.cals.length) + p.cals.length) % p.cals.length;
1173
+ }));
1174
+ }
1175
+
1176
+ /** Create the event on the selected calendar, refresh the agenda, close. */
1177
+ async function confirmEventPicker(): Promise<void> {
1178
+ const p = state.ui.eventPicker;
1179
+ const g = config.calendars?.google;
1180
+ if (!p || !g) { closeModal(); return; }
1181
+ const calendarId = p.cals[p.sel]?.id ?? g.defaultCalendar ?? "primary";
1182
+ const title = p.title.trim() || "(busy)";
1183
+ const allDay = !!p.allDay;
1184
+ closeModal();
1185
+ const r = await createGoogleEvent(g, {
1186
+ calendarId,
1187
+ title,
1188
+ dateIso: p.dateIso,
1189
+ startMin: p.startMin,
1190
+ endMin: p.endMin,
1191
+ allDay,
1192
+ });
1193
+ if (r.ok) {
1194
+ calendarStore.refresh(true);
1195
+ flashBanner("info", allDay ? `📅 All-day event created: ${title}` : `📅 Event created: ${title}`);
1196
+ } else {
1197
+ flashBanner("error", `Create failed: ${r.error}`);
1198
+ }
1199
+ }
1200
+
1201
+ // ─── Edit / delete an existing calendar event ────────────────────────────
1202
+
1203
+ /** Select an editable Google event (clicked in the Agenda). Toggles off if
1204
+ * the same event is clicked again. Clears any armed task so the two
1205
+ * selection models don't fight. */
1206
+ function selectCalEvent(sel: SelectedCalEvent): void {
1207
+ const cur = state.ui.selectedCalEvent;
1208
+ if (cur && cur.calendarId === sel.calendarId && cur.eventId === sel.eventId) {
1209
+ setState("ui", "selectedCalEvent", undefined);
1210
+ return;
1211
+ }
1212
+ setState("ui", "armedTimelineRef", undefined);
1213
+ setState("ui", "selectedCalEvent", sel);
1214
+ }
1215
+
1216
+ function clearCalSelection(): void {
1217
+ setState("ui", "selectedCalEvent", undefined);
1218
+ }
1219
+
1220
+ /** Open the edit modal for the selected event (deferred so the <input>
1221
+ * mounts after this key event). No-op if nothing is selected. */
1222
+ function openEventEditModal(): void {
1223
+ if (!state.ui.selectedCalEvent) return;
1224
+ setTimeout(() => openModal({ kind: "event-edit" }), 0);
1225
+ }
1226
+
1227
+ /** Save the edited title/time/date to the selected event, refresh, close.
1228
+ * `dateIso` (optional) moves the event to another day; defaults to its day. */
1229
+ async function confirmEventEdit(title: string, startMin: number, endMin: number, dateIso?: string): Promise<void> {
1230
+ const sel = state.ui.selectedCalEvent;
1231
+ const g = config.calendars?.google;
1232
+ if (!sel || !g) { closeModal(); return; }
1233
+ closeModal();
1234
+ const r = await updateGoogleEvent(g, {
1235
+ calendarId: sel.calendarId,
1236
+ eventId: sel.eventId,
1237
+ title: title.trim() || sel.title,
1238
+ dateIso: dateIso ?? sel.dateIso,
1239
+ startMin,
1240
+ endMin,
1241
+ });
1242
+ clearCalSelection();
1243
+ if (r.ok) {
1244
+ calendarStore.refresh(true);
1245
+ flashBanner("info", `✏ Event updated: ${title.trim() || sel.title}`);
1246
+ } else {
1247
+ flashBanner("error", `Update failed: ${r.error}`);
1248
+ }
1249
+ }
1250
+
1251
+ /** Delete the selected event, refresh, close. */
1252
+ async function confirmDeleteEvent(): Promise<void> {
1253
+ const sel = state.ui.selectedCalEvent;
1254
+ const g = config.calendars?.google;
1255
+ if (!sel || !g) { closeModal(); return; }
1256
+ closeModal();
1257
+ const r = await deleteGoogleEvent(g, { calendarId: sel.calendarId, eventId: sel.eventId });
1258
+ clearCalSelection();
1259
+ if (r.ok) {
1260
+ calendarStore.refresh(true);
1261
+ flashBanner("info", `🗑 Event deleted: ${sel.title}`);
1262
+ } else {
1263
+ flashBanner("error", `Delete failed: ${r.error}`);
1264
+ }
1061
1265
  }
1062
1266
 
1063
1267
  // ─── Private mutation helper ─────────────────────────────────────────────
@@ -1133,6 +1337,15 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1133
1337
  resetAllOverdueToToday,
1134
1338
  openModal,
1135
1339
  closeModal,
1340
+ openEventModal,
1341
+ advanceEventToStep2,
1342
+ setEventSel,
1343
+ confirmEventPicker,
1344
+ selectCalEvent,
1345
+ clearCalSelection,
1346
+ openEventEditModal,
1347
+ confirmEventEdit,
1348
+ confirmDeleteEvent,
1136
1349
  flashBanner,
1137
1350
  clearBanner,
1138
1351
  // 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,