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/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>
|
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
|
|
@@ -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
|
|
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
|
|
193
|
-
//
|
|
194
|
-
//
|
|
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())
|
|
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)
|
|
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
|
|
340
|
-
"\ today" reset is highlighted
|
|
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
|
-
{/*
|
|
357
|
-
|
|
358
|
-
|
|
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;
|