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.
- package/.tuiboard/config.example.yaml +7 -0
- package/CHANGELOG.md +38 -0
- package/README.md +71 -7
- package/package.json +1 -1
- package/src/app.tsx +20 -8
- 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 +193 -2
- package/src/store/index.ts +207 -1
- package/src/store/timeline.ts +19 -1
- package/src/ui/Modal.tsx +198 -11
- package/src/ui/TimelineView.tsx +75 -9
- package/src/ui/layout.ts +12 -0
- package/src/views/Dashboard.tsx +15 -12
package/src/store/calendar.ts
CHANGED
|
@@ -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
|
}
|
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,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
|
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,
|