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.
- package/.tuiboard/config.example.yaml +10 -2
- package/CHANGELOG.md +41 -0
- package/README.md +93 -13
- package/package.json +1 -1
- package/src/calendar/setup.ts +33 -15
- package/src/config/loader.ts +5 -0
- package/src/input/handleKey.ts +53 -0
- package/src/store/calendar.ts +243 -4
- package/src/store/index.ts +214 -1
- package/src/store/timeline.ts +19 -1
- package/src/ui/Modal.tsx +231 -0
- package/src/ui/TimelineView.tsx +107 -14
package/src/store/calendar.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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;
|
package/src/store/index.ts
CHANGED
|
@@ -28,7 +28,16 @@ import {
|
|
|
28
28
|
type BoardWatcher,
|
|
29
29
|
} from "~/io/watcher";
|
|
30
30
|
import { createAgentsStore, type AgentsStore } from "./agents";
|
|
31
|
-
import {
|
|
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
|
package/src/store/timeline.ts
CHANGED
|
@@ -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<{
|
|
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,
|