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/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 {
|
|
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 (
|
|
29
|
-
const MODAL_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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// the
|
|
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>
|
package/src/ui/TimelineView.tsx
CHANGED
|
@@ -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
|
|
193
|
-
//
|
|
194
|
-
//
|
|
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())
|
|
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)
|
|
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
|
|
340
|
-
"\ today" reset is highlighted
|
|
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;
|
package/src/ui/layout.ts
ADDED
|
@@ -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;
|
package/src/views/Dashboard.tsx
CHANGED
|
@@ -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
|
|
104
|
-
the
|
|
105
|
-
|
|
106
|
-
|
|
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>
|