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.
- package/.tuiboard/config.example.yaml +3 -2
- package/CHANGELOG.md +31 -0
- package/README.md +27 -10
- package/package.json +1 -1
- package/src/store/calendar.ts +56 -8
- package/src/store/index.ts +12 -5
- package/src/store/parsers.test.ts +64 -0
- package/src/store/parsers.ts +6 -5
- package/src/ui/Modal.tsx +81 -41
- package/src/ui/TimelineView.tsx +32 -5
|
@@ -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.
|
|
69
|
-
#
|
|
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
|
|
183
|
-
|
|
184
|
-
colored `📅` blocks
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
credentials** — there's nothing to sign
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
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.
|
|
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",
|
package/src/store/calendar.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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;
|
package/src/store/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/store/parsers.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Date input shortcuts:
|
|
5
5
|
* t → today
|
|
6
|
-
* tm | tom
|
|
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
|
-
|
|
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,
|
|
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/
|
|
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 ·
|
|
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 ·
|
|
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
|
|
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)} ·
|
|
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
|
|
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=
|
|
416
|
-
hint="
|
|
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/
|
|
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>
|
package/src/ui/TimelineView.tsx
CHANGED
|
@@ -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
|
|
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
|
-
{/*
|
|
409
|
-
|
|
410
|
-
|
|
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={{
|