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/src/ui/Modal.tsx CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  } from "~/store/parsers";
22
22
  import { ATTR, T } from "~/ui/glyphs";
23
23
  import { AGENDA_WIDTH } from "~/ui/layout";
24
+ import { formatHm } from "~/store/timeline";
24
25
  import type { TuiStore } from "~/store/index";
25
26
  import type { PriorityLevel, TimeBlock } from "~/types";
26
27
 
@@ -50,6 +51,9 @@ function ModalRouter(props: { store: TuiStore; modal: NonNullable<TuiStore["stat
50
51
  case "confirm-delete": return <ConfirmDeleteModal store={props.store} modal={m} />;
51
52
  case "detail": return <DetailModal store={props.store} modal={m} />;
52
53
  case "agent-detail": return <AgentDetailModal store={props.store} modal={m} />;
54
+ case "event": return <EventModal store={props.store} />;
55
+ case "event-edit": return <EventEditModal store={props.store} />;
56
+ case "confirm-delete-event": return <ConfirmDeleteEventModal store={props.store} />;
53
57
  case "search": return <SearchModal store={props.store} />;
54
58
  case "help": return <HelpModal store={props.store} />;
55
59
  }
@@ -267,6 +271,230 @@ function TimeBlockModal(props: { store: TuiStore; modal: Extract<NonNullable<Tui
267
271
  );
268
272
  }
269
273
 
274
+ // ─── New calendar event ──────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * Parse an event input line into title + time + (optional) date. Tokens are
278
+ * peeled off the END, time first then date, so the natural order is
279
+ * "Title [date] [time]":
280
+ * "Standup 9:00-9:30" → title, 09:00-09:30, (default date)
281
+ * "Lunch tomorrow 12-13" → title, 12:00-13:00, tomorrow
282
+ * "Review 2026-06-10 15-16" → title, 15:00-16:00, that date
283
+ * A trailing token is only consumed if it parses AND something is left for the
284
+ * title, so a one-word title like "tomorrow" stays a title. `dateIso` is set
285
+ * only when an explicit date token was found (caller falls back to its default).
286
+ */
287
+ function peelEventInput(
288
+ text: string,
289
+ defStart: number,
290
+ defEnd: number,
291
+ ): { title: string; startMin: number; endMin: number; dateIso?: string; allDay: boolean } {
292
+ let title = text.trim();
293
+ let startMin = defStart;
294
+ let endMin = defEnd;
295
+ let dateIso: string | undefined;
296
+
297
+ // 0) an "allday" / "all-day" keyword anywhere → date-only event (time ignored)
298
+ let allDay = false;
299
+ if (/(^|\s)(all-?day)(\s|$)/i.test(title)) {
300
+ allDay = true;
301
+ title = title.replace(/(^|\s)all-?day(\s|$)/i, " ").replace(/\s+/g, " ").trim();
302
+ }
303
+
304
+ // 1) trailing time token
305
+ const mt = title.match(/\s(\S+)$/);
306
+ const ttok = mt?.[1];
307
+ if (mt && mt.index !== undefined && ttok) {
308
+ const tb = parseTimeBlockShortcut(ttok);
309
+ if (tb && tb.endMin > tb.startMin) {
310
+ const rest = title.slice(0, mt.index).trim();
311
+ if (rest) {
312
+ title = rest;
313
+ startMin = tb.startMin;
314
+ endMin = tb.endMin;
315
+ }
316
+ }
317
+ }
318
+ // 2) trailing date token (on what's left)
319
+ const md = title.match(/\s(\S+)$/);
320
+ const dtok = md?.[1];
321
+ if (md && md.index !== undefined && dtok) {
322
+ const d = parseDateShortcut(dtok);
323
+ if (typeof d === "string") {
324
+ const rest = title.slice(0, md.index).trim();
325
+ if (rest) {
326
+ title = rest;
327
+ dateIso = d;
328
+ }
329
+ }
330
+ }
331
+ return { title, startMin, endMin, dateIso, allDay };
332
+ }
333
+
334
+ /** Short "Mon 10 Jun" style label for an ISO date, for modal titles. */
335
+ function shortDate(iso: string): string {
336
+ const [y, m, d] = iso.split("-").map((n) => parseInt(n, 10));
337
+ if (!y || !m || !d) return iso;
338
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
339
+ return `${d} ${months[m - 1]}`;
340
+ }
341
+
342
+ /**
343
+ * Two-step "new Google Calendar event" modal. Step 1: a title+time `<input>`
344
+ * (time prefilled from the clicked slot; append `HH:MM-HH:MM` to override, and
345
+ * a date token like `tomorrow` / `2026-06-10` to change the day).
346
+ * Step 2: a non-input calendar list navigated via handleKey (no input focused),
347
+ * preselected to the configured default. See `openEventModal` / `confirmEventPicker`.
348
+ */
349
+ function EventModal(props: { store: TuiStore }) {
350
+ const picker = () => props.store.state.ui.eventPicker;
351
+ const defaultId = () => props.store.config.calendars?.google?.defaultCalendar;
352
+ const [value, setValue] = createSignal("");
353
+ const [error, setError] = createSignal<string | undefined>();
354
+
355
+ function submit(text: string) {
356
+ const p = picker();
357
+ if (!p) return;
358
+ const { title, startMin, endMin, dateIso, allDay } = peelEventInput(text, p.startMin, p.endMin);
359
+ if (!title) {
360
+ setError("Title required");
361
+ return;
362
+ }
363
+ void props.store.advanceEventToStep2(title, startMin, endMin, dateIso, allDay);
364
+ }
365
+
366
+ return (
367
+ <Show when={picker()}>
368
+ <Show
369
+ when={picker()!.step === 2}
370
+ fallback={
371
+ <DialogShell
372
+ title="New event"
373
+ hint={`${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)} · add a time, a date (tm · +3 · 2026-06-10), or "allday" · Enter add · Esc`}
374
+ >
375
+ <input
376
+ focused
377
+ value={value()}
378
+ onInput={(v: string) => {
379
+ setValue(v);
380
+ setError(undefined);
381
+ }}
382
+ onSubmit={((v: string) => submit(v)) as any}
383
+ />
384
+ <Show when={error()}>
385
+ <text>
386
+ <span style={{ fg: T.bannerError }}>{error()!}</span>
387
+ </text>
388
+ </Show>
389
+ </DialogShell>
390
+ }
391
+ >
392
+ <DialogShell
393
+ title={`Calendar · ${shortDate(picker()!.dateIso)} ${picker()!.allDay ? "all day" : `${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)}`}`}
394
+ hint="j/k choose · Enter create · Esc cancel"
395
+ >
396
+ <For each={picker()!.cals}>
397
+ {(c, i) => {
398
+ const isSel = () => i() === picker()!.sel;
399
+ const isDefault = defaultId() ? c.id === defaultId() : c.primary;
400
+ return (
401
+ <box style={{ backgroundColor: isSel() ? T.cardBgCursor : undefined }}>
402
+ <text wrapMode="none" truncate>
403
+ <span style={{ fg: isSel() ? T.accent : T.textDim }}>
404
+ {isSel() ? "▶ " : " "}
405
+ </span>
406
+ <span style={{ fg: c.color }}>{"● "}</span>
407
+ <span style={{ fg: T.text }}>{c.summary}</span>
408
+ <Show when={isDefault}>
409
+ <span style={{ fg: T.textDim }}>{" (default)"}</span>
410
+ </Show>
411
+ </text>
412
+ </box>
413
+ );
414
+ }}
415
+ </For>
416
+ </DialogShell>
417
+ </Show>
418
+ </Show>
419
+ );
420
+ }
421
+
422
+ // ─── Edit existing calendar event ────────────────────────────────────────────
423
+
424
+ /**
425
+ * Edit the selected Google Calendar event's title + time (same calendar). A
426
+ * single `<input>` prefilled with "Title HH:MM-HH:MM"; Enter saves via PATCH.
427
+ * Reads `ui.selectedCalEvent` (set by clicking an editable event in the Agenda).
428
+ */
429
+ function EventEditModal(props: { store: TuiStore }) {
430
+ const sel = () => props.store.state.ui.selectedCalEvent;
431
+ const s0 = sel();
432
+ const [value, setValue] = createSignal(
433
+ s0 ? `${s0.title} ${formatHm(s0.startMin)}-${formatHm(s0.endMin)}` : "",
434
+ );
435
+ const [error, setError] = createSignal<string | undefined>();
436
+
437
+ function submit(text: string) {
438
+ const s = sel();
439
+ if (!s) {
440
+ props.store.closeModal();
441
+ return;
442
+ }
443
+ const { title, startMin, endMin, dateIso } = peelEventInput(text, s.startMin, s.endMin);
444
+ if (!title) {
445
+ setError("Title required");
446
+ return;
447
+ }
448
+ void props.store.confirmEventEdit(title, startMin, endMin, dateIso);
449
+ }
450
+
451
+ return (
452
+ <Show when={sel()}>
453
+ <DialogShell
454
+ title={`Edit event · ${shortDate(sel()!.dateIso)}`}
455
+ hint="change title, HH:MM-HH:MM, and/or a date (tm · +3 · lun · 2026-06-10) · Enter save · Esc"
456
+ >
457
+ <input
458
+ focused
459
+ value={value()}
460
+ onInput={(v: string) => {
461
+ setValue(v);
462
+ setError(undefined);
463
+ }}
464
+ onSubmit={((v: string) => submit(v)) as any}
465
+ />
466
+ <Show when={error()}>
467
+ <text>
468
+ <span style={{ fg: T.bannerError }}>{error()!}</span>
469
+ </text>
470
+ </Show>
471
+ </DialogShell>
472
+ </Show>
473
+ );
474
+ }
475
+
476
+ // ─── Confirm delete calendar event ───────────────────────────────────────────
477
+
478
+ function ConfirmDeleteEventModal(props: { store: TuiStore }) {
479
+ const sel = () => props.store.state.ui.selectedCalEvent;
480
+ return (
481
+ <DialogShell title="Delete event?" hint="⏎/y confirm · Esc/n cancel">
482
+ <text wrapMode="none" truncate>
483
+ <span style={{ fg: sel()?.color ?? T.text }}>{"📅 "}</span>
484
+ <span style={{ fg: T.text }}>{sel()?.title ?? "(missing)"}</span>
485
+ <Show when={sel()}>
486
+ <span style={{ fg: T.textDim }}>
487
+ {` ${formatHm(sel()!.startMin)}-${formatHm(sel()!.endMin)}`}
488
+ </span>
489
+ </Show>
490
+ </text>
491
+ <text>
492
+ <span style={{ fg: T.textDim }}>Deletes from Google Calendar — cannot be undone here.</span>
493
+ </text>
494
+ </DialogShell>
495
+ );
496
+ }
497
+
270
498
  // ─── Assign ──────────────────────────────────────────────────────────────────
271
499
 
272
500
  function AssignModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "assign" }> }) {
@@ -599,6 +827,9 @@ function HelpModal(props: { store: TuiStore }) {
599
827
  <span style={{ fg: T.text }}>{" [ / ] Previous / next day (tasks + calendar events)\n"}</span>
600
828
  <span style={{ fg: T.text }}>{" \\ Jump back to today\n"}</span>
601
829
  <span style={{ fg: T.textDim }}>{"\nAgenda (timeline) scheduling\n"}</span>
830
+ <span style={{ fg: T.text }}>{" n / click slot New Google Calendar event (needs: calendar-setup google --write)\n"}</span>
831
+ <span style={{ fg: T.text }}>{" append date+time: Lunch tomorrow 12-13 · Review 2026-06-10 15-16 · Holiday 25-12 allday\n"}</span>
832
+ <span style={{ fg: T.text }}>{" click an event Select an editable Google event — then e edit · d delete · Esc\n"}</span>
602
833
  <span style={{ fg: T.text }}>{" c (any zone) Toggle ARM MODE — then click a task, click a slot, repeat\n"}</span>
603
834
  <span style={{ fg: T.text }}>{" click empty row Place the armed task here (30-min block, or move if it has one)\n"}</span>
604
835
  <span style={{ fg: T.text }}>{" click band Arm an existing block (or place the armed task at its start)\n"}</span>
@@ -36,6 +36,7 @@ import {
36
36
  onMount,
37
37
  } from "solid-js";
38
38
 
39
+ import { googleTokenCanWrite } from "~/store/calendar";
39
40
  import type { TaskRef } from "~/store/index";
40
41
  import {
41
42
  DAY_START_HOUR,
@@ -92,6 +93,13 @@ export function TimelineView(props: TimelineViewProps) {
92
93
  const viewedDate = () => props.store.agendaDate();
93
94
  const isToday = () => props.store.state.ui.agendaOffset === 0;
94
95
 
96
+ // The Google event currently selected (clicked) for edit/delete, if any.
97
+ const selectedCal = () => props.store.state.ui.selectedCalEvent;
98
+ const selectedCalKey = () => {
99
+ const s = selectedCal();
100
+ return s ? `${s.calendarId}:${s.eventId}` : undefined;
101
+ };
102
+
95
103
  // Task entries drive the cursor + arm/keyboard interactions.
96
104
  const entries = createMemo(() => {
97
105
  props.store.state.rev; // recompute on any board mutation
@@ -107,9 +115,13 @@ export function TimelineView(props: TimelineViewProps) {
107
115
  });
108
116
 
109
117
  // Read-only calendar events (Google / Microsoft), merged into the grid for
110
- // display only — not cursor-navigable, not editable.
118
+ // display only — not cursor-navigable. Timed events go on the 24h grid;
119
+ // all-day events are pulled out into a chip strip at the top (no time slot).
111
120
  const calEntries = createMemo(() =>
112
- buildCalendarEntries(props.store.calendar.events()),
121
+ buildCalendarEntries(props.store.calendar.events().filter((e) => !e.allDay)),
122
+ );
123
+ const allDayEvents = createMemo(() =>
124
+ props.store.calendar.events().filter((e) => e.allDay),
113
125
  );
114
126
 
115
127
  // Recompute the row map every minute so the "now" marker stays current.
@@ -189,11 +201,34 @@ export function TimelineView(props: TimelineViewProps) {
189
201
  const onBlockClick = (entry: TimelineEntry, event: MouseEventLike) => {
190
202
  props.store.setActiveZone("timeline");
191
203
 
192
- // Calendar events are read-only: they can't be armed or moved. A click on
193
- // one while a task is armed just places the armed task at that slot;
194
- // otherwise it's a no-op (beyond focusing the zone).
204
+ // Calendar events can't be armed or time-block-moved. While a task is armed,
205
+ // a click places that task at this slot (unchanged). Otherwise: an editable
206
+ // Google event gets SELECTED for edit/delete (toggles off on re-click); a
207
+ // read-only event just reports that it can't be changed.
195
208
  if (entry.kind !== "task") {
196
- if (armedRef()) onEmptyRowClick(entry.startRow, event);
209
+ if (armedRef()) {
210
+ onEmptyRowClick(entry.startRow, event);
211
+ return;
212
+ }
213
+ if (entry.kind === "calendar") {
214
+ if (entry.editable && entry.calendarId && entry.eventId) {
215
+ const wasSelected = selectedCalKey() === `${entry.calendarId}:${entry.eventId}`;
216
+ props.store.selectCalEvent({
217
+ calendarId: entry.calendarId,
218
+ eventId: entry.eventId,
219
+ title: entry.title,
220
+ startMin: entry.startMin,
221
+ endMin: entry.endMin,
222
+ dateIso: viewedDate(),
223
+ color: entry.color,
224
+ });
225
+ if (!wasSelected) {
226
+ props.store.flashBanner("info", `Selected "${tailTruncate(entry.title, 28)}" · e edit · d delete · Esc`);
227
+ }
228
+ } else {
229
+ props.store.flashBanner("info", "Read-only event — not on a writable calendar");
230
+ }
231
+ }
197
232
  return;
198
233
  }
199
234
 
@@ -238,7 +273,17 @@ export function TimelineView(props: TimelineViewProps) {
238
273
  const onEmptyRowClick = (rowIndex: number, event: MouseEventLike) => {
239
274
  const armed = armedTask();
240
275
  const ref = armedRef();
241
- if (!armed || !ref) return;
276
+ if (!armed || !ref) {
277
+ // Nothing armed: an empty-slot click creates a Google Calendar event at
278
+ // that time (only when Google write is connected — otherwise a no-op).
279
+ const g = props.store.config.calendars?.google;
280
+ if (g && googleTokenCanWrite(g.token)) {
281
+ const startMin = Math.max(0, DAY_START_HOUR * 60 + rowIndex * MINS_PER_ROW);
282
+ const endMin = Math.min(24 * 60 - 1, startMin + DEFAULT_BLOCK_MIN);
283
+ props.store.openEventModal(viewedDate(), startMin, endMin);
284
+ }
285
+ return;
286
+ }
242
287
  const targetMin = DAY_START_HOUR * 60 + rowIndex * MINS_PER_ROW;
243
288
 
244
289
  // Unscheduled task → create a fresh block at the clicked row.
@@ -335,10 +380,21 @@ export function TimelineView(props: TimelineViewProps) {
335
380
  </span>
336
381
  </text>
337
382
  </Show>
383
+ {/* A selected calendar event shows its own action hint. */}
384
+ <Show when={selectedCal()}>
385
+ <text wrapMode="none">
386
+ <span style={{ fg: T.warm, attributes: ATTR.bold }}>
387
+ {"📅 "}{tailTruncate(selectedCal()!.title, 28)}{" "}
388
+ </span>
389
+ <span style={{ fg: T.textDim }}>
390
+ {" e edit · d delete · Esc deselect"}
391
+ </span>
392
+ </text>
393
+ </Show>
338
394
  {/* Day-navigation hint — always visible in the resting state (not while
339
- arming) so the [ ] day-switch is discoverable. Off-today, the
340
- "\ today" reset is highlighted to pull the eye back. */}
341
- <Show when={!armMode() && !armedTask()}>
395
+ arming or with an event selected) so the [ ] day-switch is
396
+ discoverable. Off-today, the "\ today" reset is highlighted. */}
397
+ <Show when={!armMode() && !armedTask() && !selectedCal()}>
342
398
  <text wrapMode="none">
343
399
  <span style={{ fg: T.warm }}>{"◷ "}</span>
344
400
  <span style={{ fg: T.textDim }}>{"[ ] change day · "}</span>
@@ -353,9 +409,32 @@ export function TimelineView(props: TimelineViewProps) {
353
409
  </text>
354
410
  </Show>
355
411
 
356
- {/* The 24h grid now owns the whole panel no sticky section above
357
- it. Tasks are armed for scheduling from the board / planner panel
358
- via the `C` shortcut, then placed by clicking a slot here. */}
412
+ {/* All-day events ride in a chip strip above the 24h grid (like Google
413
+ Calendar's top band) they have no time slot to sit in. Display only. */}
414
+ <Show when={allDayEvents().length > 0}>
415
+ <box style={{ flexDirection: "row", height: 1 }}>
416
+ <text wrapMode="none" style={{ flexShrink: 0 }}>
417
+ <span style={{ fg: T.textDim }}>{"▦ "}</span>
418
+ </text>
419
+ <For each={allDayEvents().slice(0, 8)}>
420
+ {(e) => (
421
+ <text wrapMode="none" truncate style={{ flexShrink: 1, marginRight: 1 }}>
422
+ <span style={{ fg: e.color }}>{"●"}</span>
423
+ <span style={{ fg: T.text }}>{" " + tailTruncate(e.title, 18)}</span>
424
+ </text>
425
+ )}
426
+ </For>
427
+ <Show when={allDayEvents().length > 8}>
428
+ <text wrapMode="none" style={{ flexShrink: 0 }}>
429
+ <span style={{ fg: T.textDim }}>{`+${allDayEvents().length - 8}`}</span>
430
+ </text>
431
+ </Show>
432
+ </box>
433
+ </Show>
434
+
435
+ {/* The 24h grid owns the rest of the panel. Tasks are armed for scheduling
436
+ from the board / planner panel via the `C` shortcut, then placed by
437
+ clicking a slot here. */}
359
438
  <scrollbox
360
439
  ref={(r: ScrollBoxLike) => (scrollBoxRef = r)}
361
440
  style={{
@@ -376,6 +455,7 @@ export function TimelineView(props: TimelineViewProps) {
376
455
  rowIndex={i()}
377
456
  cursorEntry={isActive() ? cursorEntry() : undefined}
378
457
  armedEntry={armedEntry()}
458
+ selectedCalKey={selectedCalKey()}
379
459
  innerWidth={props.width ? props.width - 4 : undefined}
380
460
  onBlockClick={onBlockClick}
381
461
  onEmptyRowClick={onEmptyRowClick}
@@ -423,6 +503,8 @@ interface TimelineRowProps {
423
503
  cursorEntry: TimelineEntry | undefined;
424
504
  /** When set, the armed entry — used to tint its rows warm. */
425
505
  armedEntry: TimelineEntry | undefined;
506
+ /** `${calendarId}:${eventId}` of the selected calendar event, if any. */
507
+ selectedCalKey: string | undefined;
426
508
  /** Panel content width (border+padding already removed). Undefined = fullscreen. */
427
509
  innerWidth?: number;
428
510
  onBlockClick: (entry: TimelineEntry, event: MouseEventLike) => void;
@@ -455,6 +537,13 @@ function TimelineRow(props: TimelineRowProps) {
455
537
  const rightIsArmed = () =>
456
538
  !!props.armedEntry && right().entry === props.armedEntry;
457
539
 
540
+ const isSelectedCal = (e: TimelineEntry | undefined) =>
541
+ !!props.selectedCalKey &&
542
+ e?.kind === "calendar" &&
543
+ `${e.calendarId}:${e.eventId}` === props.selectedCalKey;
544
+ const leftIsSelCal = () => isSelectedCal(left().entry);
545
+ const rightIsSelCal = () => isSelectedCal(right().entry);
546
+
458
547
  const entryDone = (e: TimelineEntry | undefined) =>
459
548
  e?.kind === "task" && e.task.done;
460
549
  const leftIsDone = () => entryDone(left().entry);
@@ -490,6 +579,7 @@ function TimelineRow(props: TimelineRowProps) {
490
579
  backgroundColor: laneBg(
491
580
  leftIsCursor(),
492
581
  leftIsArmed(),
582
+ leftIsSelCal(),
493
583
  leftIsBlock(),
494
584
  leftIsDone(),
495
585
  ),
@@ -518,6 +608,7 @@ function TimelineRow(props: TimelineRowProps) {
518
608
  backgroundColor: laneBg(
519
609
  leftIsCursor(),
520
610
  leftIsArmed(),
611
+ leftIsSelCal(),
521
612
  leftIsBlock(),
522
613
  leftIsDone(),
523
614
  ),
@@ -540,6 +631,7 @@ function TimelineRow(props: TimelineRowProps) {
540
631
  backgroundColor: laneBg(
541
632
  rightIsCursor(),
542
633
  rightIsArmed(),
634
+ rightIsSelCal(),
543
635
  rightIsBlock(),
544
636
  rightIsDone(),
545
637
  ),
@@ -726,10 +818,11 @@ function isBlockKind(k: RowMapEntry["kind"]): boolean {
726
818
  function laneBg(
727
819
  isCursor: boolean,
728
820
  isArmed: boolean,
821
+ isSelectedCal: boolean,
729
822
  isBlock: boolean,
730
823
  isDone: boolean,
731
824
  ): string | undefined {
732
- if (isArmed) return T.warmDim;
825
+ if (isArmed || isSelectedCal) return T.warmDim;
733
826
  if (isCursor) return T.cardBgCursor;
734
827
  if (isBlock) return isDone ? T.cardBlockBgDone : T.cardBlockBg;
735
828
  return undefined;