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/src/ui/Modal.tsx CHANGED
@@ -20,13 +20,14 @@ import {
20
20
  parseTimeBlockShortcut,
21
21
  } from "~/store/parsers";
22
22
  import { ATTR, T } from "~/ui/glyphs";
23
- import { TIMELINE_WIDTH } from "~/views/Dashboard";
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
 
27
28
  /** The modal panel matches the Agenda's width so it can drop into the Agenda's
28
- * slot (which the Dashboard vacates while a modal is open) with no reflow. */
29
- const MODAL_WIDTH = TIMELINE_WIDTH;
29
+ * slot (the Dashboard renders it there while a modal is open) with no reflow. */
30
+ const MODAL_WIDTH = AGENDA_WIDTH;
30
31
 
31
32
  export function ModalLayer(props: { store: TuiStore }) {
32
33
  const modal = createMemo(() => props.store.state.ui.modal);
@@ -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
  }
@@ -69,16 +73,14 @@ function DialogShell(props: DialogShellProps) {
69
73
  return (
70
74
  <box
71
75
  style={{
72
- // The modal panel: same width + marginLeft as the Agenda so it occupies
73
- // the Agenda's slot (the Dashboard hides the Agenda while a modal is
74
- // open) with no reflow. The title rides in the top border, exactly like
75
- // the board columns and the other zones.
76
+ // Byte-identical layout to the Agenda panel (TimelineView): same width,
77
+ // marginLeft, flexGrow, border and padding, so it occupies the Agenda's
78
+ // slot at the exact same size and the dashboard doesn't shift when a
79
+ // modal opens. The title rides in the top border like the columns/zones.
76
80
  flexDirection: "column",
77
81
  width: MODAL_WIDTH,
78
82
  minWidth: MODAL_WIDTH,
79
83
  flexGrow: 0,
80
- flexShrink: 0,
81
- alignSelf: "stretch",
82
84
  marginLeft: 1,
83
85
  backgroundColor: T.panelBgActive,
84
86
  border: true,
@@ -86,8 +88,6 @@ function DialogShell(props: DialogShellProps) {
86
88
  borderColor: T.borderActive,
87
89
  paddingLeft: 1,
88
90
  paddingRight: 1,
89
- paddingTop: 1,
90
- paddingBottom: 1,
91
91
  }}
92
92
  title={`┤ ${props.title} ├`}
93
93
  titleAlignment="left"
@@ -271,6 +271,191 @@ function TimeBlockModal(props: { store: TuiStore; modal: Extract<NonNullable<Tui
271
271
  );
272
272
  }
273
273
 
274
+ // ─── New calendar event ──────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * Two-step "new Google Calendar event" modal. Step 1: a title+time `<input>`
278
+ * (time prefilled from the clicked slot; append `HH:MM-HH:MM` to override).
279
+ * Step 2: a non-input calendar list navigated via handleKey (no input focused),
280
+ * preselected to the configured default. See `openEventModal` / `confirmEventPicker`.
281
+ */
282
+ function EventModal(props: { store: TuiStore }) {
283
+ const picker = () => props.store.state.ui.eventPicker;
284
+ const defaultId = () => props.store.config.calendars?.google?.defaultCalendar;
285
+ const [value, setValue] = createSignal("");
286
+ const [error, setError] = createSignal<string | undefined>();
287
+
288
+ function submit(text: string) {
289
+ const p = picker();
290
+ if (!p) return;
291
+ const trimmed = text.trim();
292
+ let title = trimmed;
293
+ let startMin = p.startMin;
294
+ let endMin = p.endMin;
295
+ // Peel a trailing time token: "Standup 9:00-9:30" / "Lunch 12-13".
296
+ const m = trimmed.match(/\s(\S+)$/);
297
+ const tok = m?.[1];
298
+ if (m && m.index !== undefined && tok) {
299
+ const tb = parseTimeBlockShortcut(tok);
300
+ if (tb && tb.endMin > tb.startMin) {
301
+ title = trimmed.slice(0, m.index).trim();
302
+ startMin = tb.startMin;
303
+ endMin = tb.endMin;
304
+ }
305
+ }
306
+ if (!title) {
307
+ setError("Title required");
308
+ return;
309
+ }
310
+ void props.store.advanceEventToStep2(title, startMin, endMin);
311
+ }
312
+
313
+ return (
314
+ <Show when={picker()}>
315
+ <Show
316
+ when={picker()!.step === 2}
317
+ fallback={
318
+ <DialogShell
319
+ title="New event"
320
+ hint={`${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)} · append HH:MM-HH:MM to change · Enter add · Esc cancel`}
321
+ >
322
+ <input
323
+ focused
324
+ value={value()}
325
+ onInput={(v: string) => {
326
+ setValue(v);
327
+ setError(undefined);
328
+ }}
329
+ onSubmit={((v: string) => submit(v)) as any}
330
+ />
331
+ <Show when={error()}>
332
+ <text>
333
+ <span style={{ fg: T.bannerError }}>{error()!}</span>
334
+ </text>
335
+ </Show>
336
+ </DialogShell>
337
+ }
338
+ >
339
+ <DialogShell
340
+ title={`Calendar · ${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)}`}
341
+ hint="j/k choose · Enter create · Esc cancel"
342
+ >
343
+ <For each={picker()!.cals}>
344
+ {(c, i) => {
345
+ const isSel = () => i() === picker()!.sel;
346
+ const isDefault = defaultId() ? c.id === defaultId() : c.primary;
347
+ return (
348
+ <box style={{ backgroundColor: isSel() ? T.cardBgCursor : undefined }}>
349
+ <text wrapMode="none" truncate>
350
+ <span style={{ fg: isSel() ? T.accent : T.textDim }}>
351
+ {isSel() ? "▶ " : " "}
352
+ </span>
353
+ <span style={{ fg: c.color }}>{"● "}</span>
354
+ <span style={{ fg: T.text }}>{c.summary}</span>
355
+ <Show when={isDefault}>
356
+ <span style={{ fg: T.textDim }}>{" (default)"}</span>
357
+ </Show>
358
+ </text>
359
+ </box>
360
+ );
361
+ }}
362
+ </For>
363
+ </DialogShell>
364
+ </Show>
365
+ </Show>
366
+ );
367
+ }
368
+
369
+ // ─── Edit existing calendar event ────────────────────────────────────────────
370
+
371
+ /**
372
+ * Edit the selected Google Calendar event's title + time (same calendar). A
373
+ * single `<input>` prefilled with "Title HH:MM-HH:MM"; Enter saves via PATCH.
374
+ * Reads `ui.selectedCalEvent` (set by clicking an editable event in the Agenda).
375
+ */
376
+ function EventEditModal(props: { store: TuiStore }) {
377
+ const sel = () => props.store.state.ui.selectedCalEvent;
378
+ const s0 = sel();
379
+ const [value, setValue] = createSignal(
380
+ s0 ? `${s0.title} ${formatHm(s0.startMin)}-${formatHm(s0.endMin)}` : "",
381
+ );
382
+ const [error, setError] = createSignal<string | undefined>();
383
+
384
+ function submit(text: string) {
385
+ const s = sel();
386
+ if (!s) {
387
+ props.store.closeModal();
388
+ return;
389
+ }
390
+ const trimmed = text.trim();
391
+ let title = trimmed;
392
+ let startMin = s.startMin;
393
+ let endMin = s.endMin;
394
+ // Peel a trailing time token: "Standup 9:00-9:30" / "Lunch 12-13".
395
+ const m = trimmed.match(/\s(\S+)$/);
396
+ const tok = m?.[1];
397
+ if (m && m.index !== undefined && tok) {
398
+ const tb = parseTimeBlockShortcut(tok);
399
+ if (tb && tb.endMin > tb.startMin) {
400
+ title = trimmed.slice(0, m.index).trim();
401
+ startMin = tb.startMin;
402
+ endMin = tb.endMin;
403
+ }
404
+ }
405
+ if (!title) {
406
+ setError("Title required");
407
+ return;
408
+ }
409
+ void props.store.confirmEventEdit(title, startMin, endMin);
410
+ }
411
+
412
+ return (
413
+ <Show when={sel()}>
414
+ <DialogShell
415
+ title="Edit event"
416
+ hint="append HH:MM-HH:MM to change the time · Enter save · Esc cancel"
417
+ >
418
+ <input
419
+ focused
420
+ value={value()}
421
+ onInput={(v: string) => {
422
+ setValue(v);
423
+ setError(undefined);
424
+ }}
425
+ onSubmit={((v: string) => submit(v)) as any}
426
+ />
427
+ <Show when={error()}>
428
+ <text>
429
+ <span style={{ fg: T.bannerError }}>{error()!}</span>
430
+ </text>
431
+ </Show>
432
+ </DialogShell>
433
+ </Show>
434
+ );
435
+ }
436
+
437
+ // ─── Confirm delete calendar event ───────────────────────────────────────────
438
+
439
+ function ConfirmDeleteEventModal(props: { store: TuiStore }) {
440
+ const sel = () => props.store.state.ui.selectedCalEvent;
441
+ return (
442
+ <DialogShell title="Delete event?" hint="⏎/y confirm · Esc/n cancel">
443
+ <text wrapMode="none" truncate>
444
+ <span style={{ fg: sel()?.color ?? T.text }}>{"📅 "}</span>
445
+ <span style={{ fg: T.text }}>{sel()?.title ?? "(missing)"}</span>
446
+ <Show when={sel()}>
447
+ <span style={{ fg: T.textDim }}>
448
+ {` ${formatHm(sel()!.startMin)}-${formatHm(sel()!.endMin)}`}
449
+ </span>
450
+ </Show>
451
+ </text>
452
+ <text>
453
+ <span style={{ fg: T.textDim }}>Deletes from Google Calendar — cannot be undone here.</span>
454
+ </text>
455
+ </DialogShell>
456
+ );
457
+ }
458
+
274
459
  // ─── Assign ──────────────────────────────────────────────────────────────────
275
460
 
276
461
  function AssignModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiStore["state"]["ui"]["modal"]>, { kind: "assign" }> }) {
@@ -603,6 +788,8 @@ function HelpModal(props: { store: TuiStore }) {
603
788
  <span style={{ fg: T.text }}>{" [ / ] Previous / next day (tasks + calendar events)\n"}</span>
604
789
  <span style={{ fg: T.text }}>{" \\ Jump back to today\n"}</span>
605
790
  <span style={{ fg: T.textDim }}>{"\nAgenda (timeline) scheduling\n"}</span>
791
+ <span style={{ fg: T.text }}>{" n / click slot New Google Calendar event (needs: calendar-setup google --write)\n"}</span>
792
+ <span style={{ fg: T.text }}>{" click an event Select an editable Google event — then e edit · d delete · Esc\n"}</span>
606
793
  <span style={{ fg: T.text }}>{" c (any zone) Toggle ARM MODE — then click a task, click a slot, repeat\n"}</span>
607
794
  <span style={{ fg: T.text }}>{" click empty row Place the armed task here (30-min block, or move if it has one)\n"}</span>
608
795
  <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
@@ -189,11 +197,34 @@ export function TimelineView(props: TimelineViewProps) {
189
197
  const onBlockClick = (entry: TimelineEntry, event: MouseEventLike) => {
190
198
  props.store.setActiveZone("timeline");
191
199
 
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).
200
+ // Calendar events can't be armed or time-block-moved. While a task is armed,
201
+ // a click places that task at this slot (unchanged). Otherwise: an editable
202
+ // Google event gets SELECTED for edit/delete (toggles off on re-click); a
203
+ // read-only event just reports that it can't be changed.
195
204
  if (entry.kind !== "task") {
196
- if (armedRef()) onEmptyRowClick(entry.startRow, event);
205
+ if (armedRef()) {
206
+ onEmptyRowClick(entry.startRow, event);
207
+ return;
208
+ }
209
+ if (entry.kind === "calendar") {
210
+ if (entry.editable && entry.calendarId && entry.eventId) {
211
+ const wasSelected = selectedCalKey() === `${entry.calendarId}:${entry.eventId}`;
212
+ props.store.selectCalEvent({
213
+ calendarId: entry.calendarId,
214
+ eventId: entry.eventId,
215
+ title: entry.title,
216
+ startMin: entry.startMin,
217
+ endMin: entry.endMin,
218
+ dateIso: viewedDate(),
219
+ color: entry.color,
220
+ });
221
+ if (!wasSelected) {
222
+ props.store.flashBanner("info", `Selected "${tailTruncate(entry.title, 28)}" · e edit · d delete · Esc`);
223
+ }
224
+ } else {
225
+ props.store.flashBanner("info", "Read-only event — not on a writable calendar");
226
+ }
227
+ }
197
228
  return;
198
229
  }
199
230
 
@@ -238,7 +269,17 @@ export function TimelineView(props: TimelineViewProps) {
238
269
  const onEmptyRowClick = (rowIndex: number, event: MouseEventLike) => {
239
270
  const armed = armedTask();
240
271
  const ref = armedRef();
241
- if (!armed || !ref) return;
272
+ if (!armed || !ref) {
273
+ // Nothing armed: an empty-slot click creates a Google Calendar event at
274
+ // that time (only when Google write is connected — otherwise a no-op).
275
+ const g = props.store.config.calendars?.google;
276
+ if (g && googleTokenCanWrite(g.token)) {
277
+ const startMin = Math.max(0, DAY_START_HOUR * 60 + rowIndex * MINS_PER_ROW);
278
+ const endMin = Math.min(24 * 60 - 1, startMin + DEFAULT_BLOCK_MIN);
279
+ props.store.openEventModal(viewedDate(), startMin, endMin);
280
+ }
281
+ return;
282
+ }
242
283
  const targetMin = DAY_START_HOUR * 60 + rowIndex * MINS_PER_ROW;
243
284
 
244
285
  // Unscheduled task → create a fresh block at the clicked row.
@@ -335,10 +376,21 @@ export function TimelineView(props: TimelineViewProps) {
335
376
  </span>
336
377
  </text>
337
378
  </Show>
379
+ {/* A selected calendar event shows its own action hint. */}
380
+ <Show when={selectedCal()}>
381
+ <text wrapMode="none">
382
+ <span style={{ fg: T.warm, attributes: ATTR.bold }}>
383
+ {"📅 "}{tailTruncate(selectedCal()!.title, 28)}{" "}
384
+ </span>
385
+ <span style={{ fg: T.textDim }}>
386
+ {" e edit · d delete · Esc deselect"}
387
+ </span>
388
+ </text>
389
+ </Show>
338
390
  {/* 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()}>
391
+ arming or with an event selected) so the [ ] day-switch is
392
+ discoverable. Off-today, the "\ today" reset is highlighted. */}
393
+ <Show when={!armMode() && !armedTask() && !selectedCal()}>
342
394
  <text wrapMode="none">
343
395
  <span style={{ fg: T.warm }}>{"◷ "}</span>
344
396
  <span style={{ fg: T.textDim }}>{"[ ] change day · "}</span>
@@ -376,6 +428,7 @@ export function TimelineView(props: TimelineViewProps) {
376
428
  rowIndex={i()}
377
429
  cursorEntry={isActive() ? cursorEntry() : undefined}
378
430
  armedEntry={armedEntry()}
431
+ selectedCalKey={selectedCalKey()}
379
432
  innerWidth={props.width ? props.width - 4 : undefined}
380
433
  onBlockClick={onBlockClick}
381
434
  onEmptyRowClick={onEmptyRowClick}
@@ -423,6 +476,8 @@ interface TimelineRowProps {
423
476
  cursorEntry: TimelineEntry | undefined;
424
477
  /** When set, the armed entry — used to tint its rows warm. */
425
478
  armedEntry: TimelineEntry | undefined;
479
+ /** `${calendarId}:${eventId}` of the selected calendar event, if any. */
480
+ selectedCalKey: string | undefined;
426
481
  /** Panel content width (border+padding already removed). Undefined = fullscreen. */
427
482
  innerWidth?: number;
428
483
  onBlockClick: (entry: TimelineEntry, event: MouseEventLike) => void;
@@ -455,6 +510,13 @@ function TimelineRow(props: TimelineRowProps) {
455
510
  const rightIsArmed = () =>
456
511
  !!props.armedEntry && right().entry === props.armedEntry;
457
512
 
513
+ const isSelectedCal = (e: TimelineEntry | undefined) =>
514
+ !!props.selectedCalKey &&
515
+ e?.kind === "calendar" &&
516
+ `${e.calendarId}:${e.eventId}` === props.selectedCalKey;
517
+ const leftIsSelCal = () => isSelectedCal(left().entry);
518
+ const rightIsSelCal = () => isSelectedCal(right().entry);
519
+
458
520
  const entryDone = (e: TimelineEntry | undefined) =>
459
521
  e?.kind === "task" && e.task.done;
460
522
  const leftIsDone = () => entryDone(left().entry);
@@ -490,6 +552,7 @@ function TimelineRow(props: TimelineRowProps) {
490
552
  backgroundColor: laneBg(
491
553
  leftIsCursor(),
492
554
  leftIsArmed(),
555
+ leftIsSelCal(),
493
556
  leftIsBlock(),
494
557
  leftIsDone(),
495
558
  ),
@@ -518,6 +581,7 @@ function TimelineRow(props: TimelineRowProps) {
518
581
  backgroundColor: laneBg(
519
582
  leftIsCursor(),
520
583
  leftIsArmed(),
584
+ leftIsSelCal(),
521
585
  leftIsBlock(),
522
586
  leftIsDone(),
523
587
  ),
@@ -540,6 +604,7 @@ function TimelineRow(props: TimelineRowProps) {
540
604
  backgroundColor: laneBg(
541
605
  rightIsCursor(),
542
606
  rightIsArmed(),
607
+ rightIsSelCal(),
543
608
  rightIsBlock(),
544
609
  rightIsDone(),
545
610
  ),
@@ -726,10 +791,11 @@ function isBlockKind(k: RowMapEntry["kind"]): boolean {
726
791
  function laneBg(
727
792
  isCursor: boolean,
728
793
  isArmed: boolean,
794
+ isSelectedCal: boolean,
729
795
  isBlock: boolean,
730
796
  isDone: boolean,
731
797
  ): string | undefined {
732
- if (isArmed) return T.warmDim;
798
+ if (isArmed || isSelectedCal) return T.warmDim;
733
799
  if (isCursor) return T.cardBgCursor;
734
800
  if (isBlock) return isDone ? T.cardBlockBgDone : T.cardBlockBg;
735
801
  return undefined;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared dashboard layout constants. Kept in a leaf module so both the
3
+ * Dashboard and the Modal can import them without an import cycle (the modal
4
+ * panel matches the Agenda's width so it can drop into the Agenda's slot).
5
+ */
6
+
7
+ /** Width (in cells) of the right-hand Agenda panel — and of the modal panel,
8
+ * which takes the Agenda's slot while a modal is open. */
9
+ export const AGENDA_WIDTH = 50;
10
+
11
+ /** Row height of the bottom Agents strip — enough for ~5 sessions. */
12
+ export const AGENTS_HEIGHT = 7;
@@ -25,19 +25,13 @@ import { AgentsBar } from "~/ui/AgentsBar";
25
25
  import { BoardView } from "~/ui/BoardView";
26
26
  import { TimelineView } from "~/ui/TimelineView";
27
27
  import { PlannerPanel } from "~/ui/PlannerPanel";
28
+ import { ModalLayer } from "~/ui/Modal";
29
+ import { AGENDA_WIDTH, AGENTS_HEIGHT } from "~/ui/layout";
28
30
  import { AgentsOnly } from "~/views/AgentsOnly";
29
31
  import { BoardOnly } from "~/views/BoardOnly";
30
32
  import { TimelineOnly } from "~/views/TimelineOnly";
31
33
  import type { TuiStore } from "~/store/index";
32
34
 
33
- /**
34
- * Width (in cells) for the right-column Agenda panel on a wide terminal. The
35
- * modal panel matches this so it can drop into the Agenda's slot (see
36
- * ModalLayer) without reflowing the rest of the dashboard.
37
- */
38
- export const TIMELINE_WIDTH = 50;
39
- /** Row height for the bottom Agents strip — enough for ~5 sessions. */
40
- const AGENTS_HEIGHT = 7;
41
35
 
42
36
  export function Dashboard(props: { store: TuiStore }) {
43
37
  const ui = () => props.store.state.ui;
@@ -100,10 +94,19 @@ function FourZoneLayout(props: { store: TuiStore }) {
100
94
  <AgentsBar store={props.store} height={AGENTS_HEIGHT} />
101
95
  </Show>
102
96
  </box>
103
- {/* Right column: Agenda (full height). Hidden while a modal is open so
104
- the modal panel takes its slot with no reflow. */}
105
- <Show when={visible().timeline && !ui().modal}>
106
- <TimelineView store={props.store} width={TIMELINE_WIDTH} />
97
+ {/* Right column: the Agenda or, while a modal is open, the modal panel
98
+ in the Agenda's exact slot (same parent, same width). Swapping them in
99
+ place keeps the whole layout and every height constant; nothing
100
+ shifts. The modal still appears here even if the Agenda is disabled. */}
101
+ <Show
102
+ when={ui().modal}
103
+ fallback={
104
+ <Show when={visible().timeline}>
105
+ <TimelineView store={props.store} width={AGENDA_WIDTH} />
106
+ </Show>
107
+ }
108
+ >
109
+ <ModalLayer store={props.store} />
107
110
  </Show>
108
111
  {/* ModalLayer rendered at App level so it can sit beside any view */}
109
112
  </box>