tuiboard 0.8.0 → 0.8.2

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.
@@ -65,8 +65,9 @@ archive_column: Archive
65
65
  # Optional: overlay read-only calendar events on the Agenda (the 24h timeline).
66
66
  # Connect a provider with `tuiboard calendar-setup google` / `... microsoft`,
67
67
  # which opens the auth flow and prints the exact block to paste here. Bring your
68
- # own credentials — nothing is hosted, nothing leaves your machine. All-day
69
- # events are skipped; each calendar keeps its own color. Paths support `~`.
68
+ # own credentials — nothing is hosted, nothing leaves your machine. Timed events
69
+ # render on the 24h grid; all-day events show as chips at the top. Each calendar
70
+ # keeps its own color. Paths support `~`.
70
71
  #
71
72
  # To CREATE / EDIT / DELETE events (not just read), re-auth with:
72
73
  # calendar-setup google --write. Then press `n` / click an empty slot to create;
package/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ All notable changes to **tuiboard** are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.2] - 2026-06-04
9
+
10
+ ### Changed
11
+ - **Consistent date shortcuts everywhere.** `m` now means "tomorrow" in every
12
+ date input (the schedule modal, the new-event/edit modals, and quick-add),
13
+ matching the board's `m` = tomorrow key — so `t`/`m` = today/tomorrow whether
14
+ you press them on a card or type them into a field. `tm`/`tom`/`tomorrow`/
15
+ `domani` still work as aliases. Hints and the help screen updated to lead with
16
+ `m`. (Audit of all shortcut surfaces found this was the only divergence; the
17
+ rest — `t`, `-`/empty to clear, weekdays, ±N — were already aligned.)
18
+
19
+ ## [0.8.1] - 2026-06-04
20
+
21
+ ### Added
22
+ - **Set an event's date from the Agenda modal.** When creating or editing a
23
+ Google Calendar event you can now append a date token to the title — `t` /
24
+ `tm` / `+3` / `lun` / `2026-06-10`, the same shortcuts as task scheduling — so
25
+ you're no longer limited to the day the Agenda is showing. Natural order is
26
+ `Title [date] HH:MM-HH:MM` (e.g. `Lunch tomorrow 12-13`); without a date token
27
+ it still defaults to the viewed day. On edit, a date token moves the event to
28
+ another day.
29
+ - **All-day events now show in the Agenda.** Previously skipped, all-day events
30
+ (Google and Microsoft) render as a chip strip at the top of the Agenda — like
31
+ Google Calendar's all-day band — instead of being dropped.
32
+ - **Create all-day events from the Agenda.** Add `allday` (or `all-day`) anywhere
33
+ in the new-event title — e.g. `Holiday 2026-12-25 allday` — to create a
34
+ date-only Google event instead of a timed one. It appears in the top chip
35
+ strip. (Editing all-day events isn't supported; create only.)
36
+
8
37
  ## [0.8.0] - 2026-06-03
9
38
 
10
39
  ### Added
@@ -134,6 +163,8 @@ First public release on npm. This entry captures the full feature set at launch.
134
163
 
135
164
  Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
136
165
 
166
+ [0.8.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.2
167
+ [0.8.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.1
137
168
  [0.8.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.8.0
138
169
  [0.7.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.3
139
170
  [0.7.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.7.2
package/README.md CHANGED
@@ -179,12 +179,14 @@ at all — handy if you don't use Claude Code and don't want tuiboard reading
179
179
 
180
180
  ## Calendars (Agenda overlay)
181
181
 
182
- The **Agenda** zone (the 24h timeline) can overlay read-only events from Google
183
- Calendar and Microsoft 365 alongside your time-blocked tasks — rendered as
184
- colored `📅` blocks you can't edit, so the day's real shape is visible at a
185
- glance. All-day events are skipped; each calendar keeps its own color. Events
186
- are cached 30 min on disk and refreshed every 5 min. **Bring your own
187
- credentials** — there's nothing to sign up for and nothing leaves your machine.
182
+ The **Agenda** zone (the 24h timeline) can overlay events from Google Calendar
183
+ and Microsoft 365 alongside your time-blocked tasks — timed events render as
184
+ colored `📅` blocks on the grid, and all-day events ride in a chip strip at the
185
+ top (like Google Calendar's all-day band). The day's real shape is visible at a
186
+ glance. Each calendar keeps its own color. Events are cached 30 min on disk and
187
+ refreshed every 5 min. **Bring your own credentials** — there's nothing to sign
188
+ up for and nothing leaves your machine. (Reading is the default; opt into
189
+ creating/editing events below.)
188
190
 
189
191
  Connect a calendar with the built-in setup command:
190
192
 
@@ -253,15 +255,30 @@ tuiboard calendar-setup google --write
253
255
  ```
254
256
 
255
257
  **Create** — in the Agenda zone, press **`n`** (or **click an empty time slot**)
256
- to open the new-event modal: type a title (append `HH:MM-HH:MM` to change the
257
- time), press Enter, pick the target calendar with `j`/`k`, Enter to create. Only
258
- calendars you can write to (owner/writer) show in the picker.
258
+ to open the new-event modal: type a title, press Enter, pick the target calendar
259
+ with `j`/`k`, Enter to create. Only calendars you can write to (owner/writer)
260
+ show in the picker. Append tokens to the title to set the **time** and **date**:
261
+
262
+ ```
263
+ Standup 9:00-9:30 # today (or the viewed day), 09:00–09:30
264
+ Lunch m 12-13 # tomorrow (m), 12:00–13:00
265
+ Review 2026-06-10 15-16 # that date, 15:00–16:00
266
+ Call +3 16:00-16:30 # in 3 days · lun = next Monday also works
267
+ Holiday 2026-12-25 allday # an all-day event (no time)
268
+ ```
269
+
270
+ The date defaults to whichever day the Agenda is showing; an explicit date token
271
+ (`t` / `m` / `+N` / weekday / `YYYY-MM-DD` — the same `t`/`m` = today/tomorrow as
272
+ the board keys) overrides it. The time is taken from
273
+ the clicked slot, or `HH:MM-HH:MM`. Add **`allday`** (or `all-day`) anywhere in
274
+ the title to create an all-day event instead — it lands in the top chip strip.
259
275
 
260
276
  **Edit / delete** — **click an existing event** in the Agenda to select it (only
261
277
  events on writable calendars can be selected; read-only ones just say so). Then:
262
278
 
263
279
  - **`e`** (or Enter) opens the edit modal, prefilled with the title and time —
264
- change either (`Title HH:MM-HH:MM`) and Enter to save.
280
+ change the title, time, and/or date (same `Title [date] HH:MM-HH:MM` syntax)
281
+ and Enter to save. A date token moves the event to another day.
265
282
  - **`d`** deletes it (with a confirm).
266
283
  - **`Esc`** deselects.
267
284
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuiboard",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live Claude Code agent view. Use only the panels you want.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,10 @@ export interface CalEvent {
37
37
  * on an owner/writer calendar, with a write-scoped token. Microsoft events
38
38
  * and read-only-calendar events are never editable. */
39
39
  editable?: boolean;
40
+ /** All-day event (date-only, no time). Rendered as a chip at the top of the
41
+ * Agenda rather than on the 24h grid; startMin/endMin are unused. Display
42
+ * only — not editable from tuiboard. */
43
+ allDay?: boolean;
40
44
  }
41
45
 
42
46
  const GOOGLE_FALLBACK_COLOR = "#e8a05c";
@@ -231,29 +235,45 @@ function localRfc3339(dateIso: string, min: number): string {
231
235
  );
232
236
  }
233
237
 
238
+ /** The day after `dateIso` (YYYY-MM-DD). Google all-day events use an EXCLUSIVE
239
+ * end date, so a single-day all-day event ends on the following day. */
240
+ function nextDayIso(dateIso: string): string {
241
+ const d = new Date(dayStartMs(dateIso) + 24 * 60 * 60000);
242
+ const pad = (n: number) => String(n).padStart(2, "0");
243
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
244
+ }
245
+
234
246
  /**
235
247
  * Create a Google Calendar event on `calendarId`. Unlike the read path, this
236
248
  * surfaces failures (returns {ok:false,error}) so the UI can flash a banner.
249
+ * Pass `allDay` for a date-only event (start/end as dates, end exclusive).
237
250
  */
238
251
  export async function createGoogleEvent(
239
252
  cfg: GoogleCalendarConfig,
240
- args: { calendarId: string; title: string; dateIso: string; startMin: number; endMin: number },
253
+ args: { calendarId: string; title: string; dateIso: string; startMin: number; endMin: number; allDay?: boolean },
241
254
  ): Promise<{ ok: true } | { ok: false; error: string }> {
242
255
  const access = await googleAccessToken(cfg.token);
243
256
  if (!access) {
244
257
  return { ok: false, error: "not authorized — run: tuiboard calendar-setup google --write" };
245
258
  }
246
259
  try {
260
+ const body = args.allDay
261
+ ? {
262
+ summary: args.title,
263
+ start: { date: args.dateIso },
264
+ end: { date: nextDayIso(args.dateIso) },
265
+ }
266
+ : {
267
+ summary: args.title,
268
+ start: { dateTime: localRfc3339(args.dateIso, args.startMin) },
269
+ end: { dateTime: localRfc3339(args.dateIso, args.endMin) },
270
+ };
247
271
  const res = await fetch(
248
272
  `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(args.calendarId)}/events`,
249
273
  {
250
274
  method: "POST",
251
275
  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
- }),
276
+ body: JSON.stringify(body),
257
277
  },
258
278
  );
259
279
  if (!res.ok) {
@@ -387,7 +407,24 @@ async function fetchGoogle(
387
407
  for (const it of data.items ?? []) {
388
408
  const startRaw = it.start?.dateTime;
389
409
  const endRaw = it.end?.dateTime;
390
- if (!startRaw || !endRaw) continue; // skip all-day (date only)
410
+ if (!startRaw || !endRaw) {
411
+ // All-day / date-only event: the per-day query already scoped it
412
+ // to a day it covers, so just surface it as a chip (display only).
413
+ const dayKey = it.start?.date;
414
+ if (!dayKey) continue;
415
+ out.push({
416
+ uid: it.id ?? `${cal.id}:allday:${dayKey}`,
417
+ ev: {
418
+ title: it.summary ?? "(no title)",
419
+ startMin: 0,
420
+ endMin: 0,
421
+ color: cal.color,
422
+ source: "google",
423
+ allDay: true,
424
+ },
425
+ });
426
+ continue;
427
+ }
391
428
  const { startMin, endMin } = toMinutes(Date.parse(startRaw), Date.parse(endRaw), base);
392
429
  out.push({
393
430
  uid: it.id ?? `${cal.id}:${startRaw}`,
@@ -540,7 +577,18 @@ async function fetchMicrosoft(
540
577
  };
541
578
  const events: CalEvent[] = [];
542
579
  for (const it of data.value ?? []) {
543
- if (it.isAllDay) continue; // mirror Google: skip all-day events
580
+ if (it.isAllDay) {
581
+ // All-day event → chip at the top of the Agenda (display only).
582
+ events.push({
583
+ title: it.subject ?? "(no title)",
584
+ startMin: 0,
585
+ endMin: 0,
586
+ color,
587
+ source: "microsoft",
588
+ allDay: true,
589
+ });
590
+ continue;
591
+ }
544
592
  const s = it.start?.dateTime;
545
593
  const e = it.end?.dateTime;
546
594
  if (!s || !e) continue;
@@ -95,6 +95,8 @@ export interface EventPicker {
95
95
  dateIso: string;
96
96
  startMin: number;
97
97
  endMin: number;
98
+ /** Create as a date-only all-day event (start/end times ignored). */
99
+ allDay?: boolean;
98
100
  cals: WritableCalendar[];
99
101
  }
100
102
 
@@ -1137,7 +1139,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1137
1139
  /** Step 1 → step 2: stash the parsed title + time, load calendars if needed,
1138
1140
  * preselect the default, and either show the picker or (single calendar)
1139
1141
  * create immediately. */
1140
- async function advanceEventToStep2(title: string, startMin: number, endMin: number): Promise<void> {
1142
+ async function advanceEventToStep2(title: string, startMin: number, endMin: number, dateIso?: string, allDay?: boolean): Promise<void> {
1141
1143
  const g = config.calendars?.google;
1142
1144
  if (!g || !state.ui.eventPicker) { closeModal(); return; }
1143
1145
  let cals = state.ui.eventPicker.cals;
@@ -1154,6 +1156,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1154
1156
  p.title = title;
1155
1157
  p.startMin = startMin;
1156
1158
  p.endMin = endMin;
1159
+ if (dateIso) p.dateIso = dateIso; // explicit date token overrides the viewed day
1160
+ p.allDay = !!allDay;
1157
1161
  p.cals = cals;
1158
1162
  p.sel = idx < 0 ? 0 : idx;
1159
1163
  p.step = 2;
@@ -1176,6 +1180,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1176
1180
  if (!p || !g) { closeModal(); return; }
1177
1181
  const calendarId = p.cals[p.sel]?.id ?? g.defaultCalendar ?? "primary";
1178
1182
  const title = p.title.trim() || "(busy)";
1183
+ const allDay = !!p.allDay;
1179
1184
  closeModal();
1180
1185
  const r = await createGoogleEvent(g, {
1181
1186
  calendarId,
@@ -1183,10 +1188,11 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1183
1188
  dateIso: p.dateIso,
1184
1189
  startMin: p.startMin,
1185
1190
  endMin: p.endMin,
1191
+ allDay,
1186
1192
  });
1187
1193
  if (r.ok) {
1188
1194
  calendarStore.refresh(true);
1189
- flashBanner("info", `📅 Event created: ${title}`);
1195
+ flashBanner("info", allDay ? `📅 All-day event created: ${title}` : `📅 Event created: ${title}`);
1190
1196
  } else {
1191
1197
  flashBanner("error", `Create failed: ${r.error}`);
1192
1198
  }
@@ -1218,8 +1224,9 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1218
1224
  setTimeout(() => openModal({ kind: "event-edit" }), 0);
1219
1225
  }
1220
1226
 
1221
- /** Save the edited title/time to the selected event, refresh, close. */
1222
- async function confirmEventEdit(title: string, startMin: number, endMin: number): Promise<void> {
1227
+ /** Save the edited title/time/date to the selected event, refresh, close.
1228
+ * `dateIso` (optional) moves the event to another day; defaults to its day. */
1229
+ async function confirmEventEdit(title: string, startMin: number, endMin: number, dateIso?: string): Promise<void> {
1223
1230
  const sel = state.ui.selectedCalEvent;
1224
1231
  const g = config.calendars?.google;
1225
1232
  if (!sel || !g) { closeModal(); return; }
@@ -1228,7 +1235,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1228
1235
  calendarId: sel.calendarId,
1229
1236
  eventId: sel.eventId,
1230
1237
  title: title.trim() || sel.title,
1231
- dateIso: sel.dateIso,
1238
+ dateIso: dateIso ?? sel.dateIso,
1232
1239
  startMin,
1233
1240
  endMin,
1234
1241
  });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import { isoToday, isoTomorrow } from "./index";
4
+ import { parseDateShortcut, parseQuickAdd, parseTimeBlockShortcut } from "./parsers";
5
+
6
+ describe("parseDateShortcut", () => {
7
+ it("maps t / today / oggi to today", () => {
8
+ expect(parseDateShortcut("t")).toBe(isoToday());
9
+ expect(parseDateShortcut("today")).toBe(isoToday());
10
+ expect(parseDateShortcut("oggi")).toBe(isoToday());
11
+ });
12
+
13
+ it("maps m to tomorrow (consistent with the board's m key)", () => {
14
+ expect(parseDateShortcut("m")).toBe(isoTomorrow());
15
+ });
16
+
17
+ it("keeps tm / tom / tomorrow / domani as tomorrow aliases", () => {
18
+ expect(parseDateShortcut("tm")).toBe(isoTomorrow());
19
+ expect(parseDateShortcut("tom")).toBe(isoTomorrow());
20
+ expect(parseDateShortcut("tomorrow")).toBe(isoTomorrow());
21
+ expect(parseDateShortcut("domani")).toBe(isoTomorrow());
22
+ });
23
+
24
+ it("is case-insensitive", () => {
25
+ expect(parseDateShortcut("M")).toBe(isoTomorrow());
26
+ expect(parseDateShortcut("T")).toBe(isoToday());
27
+ });
28
+
29
+ it("clears on empty / dash, fails on garbage", () => {
30
+ expect(parseDateShortcut("")).toBeUndefined();
31
+ expect(parseDateShortcut("-")).toBeUndefined();
32
+ expect(parseDateShortcut("zzz")).toBeNull();
33
+ });
34
+
35
+ it("parses ISO dates literally", () => {
36
+ expect(parseDateShortcut("2026-06-10")).toBe("2026-06-10");
37
+ });
38
+ });
39
+
40
+ describe("parseQuickAdd date tokens", () => {
41
+ it("treats a standalone m as tomorrow and strips it from the title", () => {
42
+ const r = parseQuickAdd("Pay invoice m");
43
+ expect(r.scheduled).toBe(isoTomorrow());
44
+ expect(r.title).toBe("Pay invoice");
45
+ });
46
+
47
+ it("treats a standalone t as today", () => {
48
+ const r = parseQuickAdd("Standup t");
49
+ expect(r.scheduled).toBe(isoToday());
50
+ expect(r.title).toBe("Standup");
51
+ });
52
+ });
53
+
54
+ describe("parseTimeBlockShortcut", () => {
55
+ it("parses loose H-H ranges and HH:MM-HH:MM", () => {
56
+ expect(parseTimeBlockShortcut("9-11")).toEqual({ startMin: 540, endMin: 660 });
57
+ expect(parseTimeBlockShortcut("09:30-10:45")).toEqual({ startMin: 570, endMin: 645 });
58
+ });
59
+
60
+ it("clears on empty / dash", () => {
61
+ expect(parseTimeBlockShortcut("")).toBeUndefined();
62
+ expect(parseTimeBlockShortcut("-")).toBeUndefined();
63
+ });
64
+ });
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Date input shortcuts:
5
5
  * t → today
6
- * tm | tom → tomorrow
6
+ * m | tm | tom → tomorrow (m matches the board's `m` = tomorrow key)
7
7
  * -N → N days ago
8
8
  * +N → N days ahead
9
9
  * lun/mar/.../dom → next weekday (Italian short)
@@ -47,7 +47,8 @@ export function parseDateShortcut(input: string): string | undefined | null {
47
47
  if (s === "-") return undefined;
48
48
 
49
49
  if (s === "t" || s === "today" || s === "oggi") return isoToday();
50
- if (s === "tm" || s === "tom" || s === "tomorrow" || s === "domani") return isoTomorrow();
50
+ // `m` mirrors the board's `m` = tomorrow key; `tm`/`tom`/… kept as aliases.
51
+ if (s === "m" || s === "tm" || s === "tom" || s === "tomorrow" || s === "domani") return isoTomorrow();
51
52
 
52
53
  // Relative ±N
53
54
  const rel = s.match(/^([+-])(\d+)$/);
@@ -167,7 +168,7 @@ export interface QuickAddResult {
167
168
  * Parse a free-form quick-add string. Recognized tokens:
168
169
  * @name → assignee
169
170
  * #tag → tag
170
- * t, tm, +N → scheduled date shortcut
171
+ * t, m, +N → scheduled date shortcut (m = tomorrow, matches the board key)
171
172
  * YYYY-MM-DD → scheduled date literal
172
173
  * HH:MM-HH:MM → time block (also sets scheduled to today if missing)
173
174
  * 9-11 → 09:00-11:00 time block
@@ -220,10 +221,10 @@ export function parseQuickAdd(input: string): QuickAddResult {
220
221
  }
221
222
  continue;
222
223
  }
223
- // Date: t, tm, +N, YYYY-MM-DD
224
+ // Date: t, m/tm, +N, YYYY-MM-DD
224
225
  const lower = tok.toLowerCase();
225
226
  if (
226
- lower === "t" || lower === "tm" || lower === "tom" ||
227
+ lower === "t" || lower === "m" || lower === "tm" || lower === "tom" ||
227
228
  lower === "today" || lower === "tomorrow" ||
228
229
  lower === "oggi" || lower === "domani" ||
229
230
  /^[+-]\d+$/.test(lower) ||
package/src/ui/Modal.tsx CHANGED
@@ -139,7 +139,7 @@ function AddModal(props: { store: TuiStore; columnIndex: number }) {
139
139
  return (
140
140
  <DialogShell
141
141
  title="New task"
142
- hint="Quick syntax: @assignee #tag t/tm/+N HH:MM-HH:MM 🔺 · Enter to add, Esc to cancel"
142
+ hint="Quick syntax: @assignee #tag t/m/+N HH:MM-HH:MM 🔺 · Enter to add, Esc to cancel"
143
143
  width={70}
144
144
  >
145
145
  <input
@@ -195,7 +195,7 @@ function ScheduleModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiS
195
195
  function submit(text: string) {
196
196
  const d = parseDateShortcut(text);
197
197
  if (d === null) {
198
- setError(`Cannot parse "${text}". Try: t · tm · +3 · lun · 2026-06-15`);
198
+ setError(`Cannot parse "${text}". Try: t · m · +3 · lun · 2026-06-15`);
199
199
  return;
200
200
  }
201
201
  const n = props.store.applyToMarkedOr(props.modal.ref, (r) =>
@@ -208,7 +208,7 @@ function ScheduleModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiS
208
208
  return (
209
209
  <DialogShell
210
210
  title={markedCount > 1 ? `Schedule · ${markedCount} tasks` : "Schedule"}
211
- hint="t = today · tm = tomorrow · +3 = in 3 days · lun = next Monday · 2026-06-15 · empty/-clear · Esc to cancel"
211
+ hint="t = today · m = tomorrow · +3 = in 3 days · lun = next Monday · 2026-06-15 · empty/-clear · Esc to cancel"
212
212
  width={70}
213
213
  >
214
214
  <input
@@ -273,9 +273,76 @@ function TimeBlockModal(props: { store: TuiStore; modal: Extract<NonNullable<Tui
273
273
 
274
274
  // ─── New calendar event ──────────────────────────────────────────────────────
275
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
+
276
342
  /**
277
343
  * 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).
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).
279
346
  * Step 2: a non-input calendar list navigated via handleKey (no input focused),
280
347
  * preselected to the configured default. See `openEventModal` / `confirmEventPicker`.
281
348
  */
@@ -288,26 +355,12 @@ function EventModal(props: { store: TuiStore }) {
288
355
  function submit(text: string) {
289
356
  const p = picker();
290
357
  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
- }
358
+ const { title, startMin, endMin, dateIso, allDay } = peelEventInput(text, p.startMin, p.endMin);
306
359
  if (!title) {
307
360
  setError("Title required");
308
361
  return;
309
362
  }
310
- void props.store.advanceEventToStep2(title, startMin, endMin);
363
+ void props.store.advanceEventToStep2(title, startMin, endMin, dateIso, allDay);
311
364
  }
312
365
 
313
366
  return (
@@ -317,7 +370,7 @@ function EventModal(props: { store: TuiStore }) {
317
370
  fallback={
318
371
  <DialogShell
319
372
  title="New event"
320
- hint={`${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)} · append HH:MM-HH:MM to change · Enter add · Esc cancel`}
373
+ hint={`${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)} · add a time, a date (m · +3 · 2026-06-10), or "allday" · Enter add · Esc`}
321
374
  >
322
375
  <input
323
376
  focused
@@ -337,7 +390,7 @@ function EventModal(props: { store: TuiStore }) {
337
390
  }
338
391
  >
339
392
  <DialogShell
340
- title={`Calendar · ${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)}`}
393
+ title={`Calendar · ${shortDate(picker()!.dateIso)} ${picker()!.allDay ? "all day" : `${formatHm(picker()!.startMin)}-${formatHm(picker()!.endMin)}`}`}
341
394
  hint="j/k choose · Enter create · Esc cancel"
342
395
  >
343
396
  <For each={picker()!.cals}>
@@ -387,33 +440,19 @@ function EventEditModal(props: { store: TuiStore }) {
387
440
  props.store.closeModal();
388
441
  return;
389
442
  }
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
- }
443
+ const { title, startMin, endMin, dateIso } = peelEventInput(text, s.startMin, s.endMin);
405
444
  if (!title) {
406
445
  setError("Title required");
407
446
  return;
408
447
  }
409
- void props.store.confirmEventEdit(title, startMin, endMin);
448
+ void props.store.confirmEventEdit(title, startMin, endMin, dateIso);
410
449
  }
411
450
 
412
451
  return (
413
452
  <Show when={sel()}>
414
453
  <DialogShell
415
- title="Edit event"
416
- hint="append HH:MM-HH:MM to change the time · Enter save · Esc cancel"
454
+ title={`Edit event · ${shortDate(sel()!.dateIso)}`}
455
+ hint="change title, HH:MM-HH:MM, and/or a date (m · +3 · lun · 2026-06-10) · Enter save · Esc"
417
456
  >
418
457
  <input
419
458
  focused
@@ -774,7 +813,7 @@ function HelpModal(props: { store: TuiStore }) {
774
813
  <span style={{ fg: T.text }}>{" Enter Toggle done\n"}</span>
775
814
  <span style={{ fg: T.text }}>{" o Open detail view\n"}</span>
776
815
  <span style={{ fg: T.text }}>{" e Edit task text\n"}</span>
777
- <span style={{ fg: T.text }}>{" s Schedule date modal (t/tm/+N/lun/YYYY-MM-DD)\n"}</span>
816
+ <span style={{ fg: T.text }}>{" s Schedule date modal (t/m/+N/lun/YYYY-MM-DD — same t/m as the board)\n"}</span>
778
817
  <span style={{ fg: T.text }}>{" t Set scheduled = today\n"}</span>
779
818
  <span style={{ fg: T.text }}>{" m Set scheduled = tomorrow\n"}</span>
780
819
  <span style={{ fg: T.text }}>{" . Schedule now — time block at next 15-min slot (30min)\n"}</span>
@@ -789,6 +828,7 @@ function HelpModal(props: { store: TuiStore }) {
789
828
  <span style={{ fg: T.text }}>{" \\ Jump back to today\n"}</span>
790
829
  <span style={{ fg: T.textDim }}>{"\nAgenda (timeline) scheduling\n"}</span>
791
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>
792
832
  <span style={{ fg: T.text }}>{" click an event Select an editable Google event — then e edit · d delete · Esc\n"}</span>
793
833
  <span style={{ fg: T.text }}>{" c (any zone) Toggle ARM MODE — then click a task, click a slot, repeat\n"}</span>
794
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>
@@ -115,9 +115,13 @@ export function TimelineView(props: TimelineViewProps) {
115
115
  });
116
116
 
117
117
  // Read-only calendar events (Google / Microsoft), merged into the grid for
118
- // 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).
119
120
  const calEntries = createMemo(() =>
120
- 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),
121
125
  );
122
126
 
123
127
  // Recompute the row map every minute so the "now" marker stays current.
@@ -405,9 +409,32 @@ export function TimelineView(props: TimelineViewProps) {
405
409
  </text>
406
410
  </Show>
407
411
 
408
- {/* The 24h grid now owns the whole panel no sticky section above
409
- it. Tasks are armed for scheduling from the board / planner panel
410
- 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. */}
411
438
  <scrollbox
412
439
  ref={(r: ScrollBoxLike) => (scrollBoxRef = r)}
413
440
  style={{