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.
@@ -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,