tuiboard 0.13.3 → 0.14.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 +6 -0
- package/CHANGELOG.md +45 -0
- package/README.md +17 -1
- package/package.json +1 -1
- package/src/cli/summary.ts +33 -2
- package/src/config/loader.ts +12 -0
- package/src/input/handleKey.ts +62 -22
- package/src/input/keys.ts +22 -0
- package/src/store/index.ts +158 -13
- package/src/ui/Modal.tsx +148 -6
- package/src/ui/TaskRow.tsx +6 -44
- package/src/ui/TimelineView.tsx +8 -8
- package/src/ui/glyphs.ts +3 -0
- package/src/ui/markdown-lines.ts +110 -0
- package/src/ui/task-status.ts +100 -0
|
@@ -95,6 +95,12 @@ archive_column: Archive
|
|
|
95
95
|
# Nushell users typically want `;` instead of `&&`:
|
|
96
96
|
# copy_resume_command: 'cd "{cwd}"; {resume}'
|
|
97
97
|
|
|
98
|
+
# Optional: a markdown file to read from inside tuiboard with `i` — a morning
|
|
99
|
+
# digest, a handover note, whatever you (or an agent) write there. Shown as it
|
|
100
|
+
# is: never parsed, never written to. Relative paths resolve against this file,
|
|
101
|
+
# `~` works. tuiboard picks up changes while the dialog is open.
|
|
102
|
+
# status_file: ~/vault/Home.md
|
|
103
|
+
|
|
98
104
|
# Optional: overlay read-only calendar events on the Agenda (the 24h timeline).
|
|
99
105
|
# Connect a provider with `tuiboard calendar-setup google` / `... microsoft`,
|
|
100
106
|
# which opens the auth flow and prints the exact block to paste here. Bring your
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.1] - 2026-09-28
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Shift+T clears the time blocks it carries to today** (#79). A time block
|
|
14
|
+
is a slot on a particular day, so moving every overdue task to today used to
|
|
15
|
+
keep theirs: tasks landed in today's agenda at hours nobody chose, stacked on
|
|
16
|
+
whatever was already there. Now each moved task drops its block and waits in
|
|
17
|
+
today's planner to be placed again (two keys, with arm mode). The banner
|
|
18
|
+
says how many were cleared. `t` / `m` on a single task and the headless
|
|
19
|
+
`tuiboard task defer` are unchanged.
|
|
20
|
+
|
|
21
|
+
## [0.14.0] - 2026-09-21
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- **Status file viewer** (#67): point `status_file:` at a markdown file — a
|
|
25
|
+
morning digest, a handover note, whatever you or an agent write there — and
|
|
26
|
+
`i` shows it in a dialog, read-only. The markdown is rendered rather than
|
|
27
|
+
shown as source: headings and bold as weight, links and wikilinks by their
|
|
28
|
+
label, list and checkbox glyphs, frontmatter hidden. `j`/`k`, arrows and
|
|
29
|
+
PgUp/PgDn scroll it. It re-reads itself while open when the file changes,
|
|
30
|
+
and `tuiboard summary` reports its path and mtime (never its body) when
|
|
31
|
+
configured.
|
|
32
|
+
- **Overdue tasks show how late they are** (#67): a task overdue for days is
|
|
33
|
+
painted in a louder red than one that slipped yesterday. No day count
|
|
34
|
+
anywhere — just two bands.
|
|
35
|
+
- **Agent interface documentation** (#69): `docs/agent-interface.md` spells
|
|
36
|
+
out `tuiboard summary` and `tuiboard task` as a stable contract for agents
|
|
37
|
+
and scripts — the JSON shape, title matching, exit codes and the rules that
|
|
38
|
+
keep an agent from corrupting a board.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
- **Time blocking in arm mode takes fewer round trips** (#73). A task placed
|
|
42
|
+
on the agenda stays armed, so `+`/`-` and `j`/`k` size and move it straight
|
|
43
|
+
away. `Enter` keeps it and returns to where `c` was pressed (usually
|
|
44
|
+
Today/Tomorrow — in single-pane, without passing through the agents panel);
|
|
45
|
+
`Esc` puts the task back as it was and returns the same way.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
- **Keypad `+` grows an armed time block** (#71) instead of opening the "new
|
|
49
|
+
board" dialog.
|
|
50
|
+
- Arming a second task no longer rewrites the first task's reference in place
|
|
51
|
+
(#73), a latent bug surfaced by the new arm-mode tests.
|
|
52
|
+
|
|
10
53
|
## [0.13.3] - 2026-09-19
|
|
11
54
|
|
|
12
55
|
### Fixed
|
|
@@ -512,6 +555,8 @@ First public release on npm. This entry captures the full feature set at launch.
|
|
|
512
555
|
|
|
513
556
|
Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
|
|
514
557
|
|
|
558
|
+
[0.14.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.14.1
|
|
559
|
+
[0.14.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.14.0
|
|
515
560
|
[0.13.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.3
|
|
516
561
|
[0.13.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.2
|
|
517
562
|
[0.13.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.1
|
package/README.md
CHANGED
|
@@ -204,6 +204,12 @@ archive_column: Archive
|
|
|
204
204
|
# yourself in any tab/pane (no WezTerm needed). {cwd}/{sessionId}/{resume}
|
|
205
205
|
# substituted. Default: 'cd "{cwd}" && {resume}'. Nushell users:
|
|
206
206
|
# copy_resume_command: 'cd "{cwd}"; {resume}'
|
|
207
|
+
|
|
208
|
+
# Optional: a markdown file to read from inside tuiboard with `i` — a morning
|
|
209
|
+
# digest, a handover note, whatever you (or an agent) write there. Shown as it
|
|
210
|
+
# is: never parsed, never written to. `~` works, relative paths resolve against
|
|
211
|
+
# the config file. Unconfigured, `i` just explains how to set it.
|
|
212
|
+
# status_file: ~/vault/Home.md
|
|
207
213
|
```
|
|
208
214
|
|
|
209
215
|
## Zones
|
|
@@ -456,6 +462,10 @@ session (until the next terminal resize).
|
|
|
456
462
|
| `c` | Arm mode: click a task, then click a slot to schedule (works from any zone) |
|
|
457
463
|
| `j` / `k` | While armed: nudge the block ±15 min |
|
|
458
464
|
| `+` / `-` | While armed: resize the block's end ±15 min |
|
|
465
|
+
| `Enter` | While armed: keep the placement and go back to where `c` was pressed |
|
|
466
|
+
| `Esc` | While armed: undo the placement and go back to where `c` was pressed |
|
|
467
|
+
|
|
468
|
+
A placed task stays armed, so it can be sized and moved straight away.
|
|
459
469
|
|
|
460
470
|
### Agents (agents zone)
|
|
461
471
|
|
|
@@ -499,13 +509,19 @@ session (until the next terminal resize).
|
|
|
499
509
|
| Key | Action |
|
|
500
510
|
|---|---|
|
|
501
511
|
| `n` | New task in current column (quick-add syntax) |
|
|
502
|
-
| `Shift-T` | Reset ALL overdue tasks (any board) to today |
|
|
512
|
+
| `Shift-T` | Reset ALL overdue tasks (any board) to today — their time blocks are cleared, since a slot chosen for another day means nothing today |
|
|
503
513
|
| `Ctrl-Z` | Undo last mutation |
|
|
514
|
+
| `i` | Status file — the markdown file set as `status_file`, shown read-only |
|
|
504
515
|
| `?` | Help modal with the full reference |
|
|
505
516
|
| `q` · `Ctrl-C` | Quit |
|
|
506
517
|
|
|
507
518
|
## Headless commands
|
|
508
519
|
|
|
520
|
+
> Driving tuiboard from Claude Code, Codex or any other agent?
|
|
521
|
+
> [docs/agent-interface.md](docs/agent-interface.md) documents these two
|
|
522
|
+
> commands as an API: the JSON shape, the matching rules, the exit codes, and
|
|
523
|
+
> the handful of rules that keep an agent from corrupting a board.
|
|
524
|
+
|
|
509
525
|
Two subcommands run without the TUI, for status bars, widgets and scripts.
|
|
510
526
|
Both reuse the same config loader and parser as the dashboard, so they can
|
|
511
527
|
never disagree with it about what is on your board.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tuiboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live view of your coding-agent sessions (Claude Code, Codex, OpenCode, Pi). Use only the panels you want.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/summary.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* dashboard shows — the parser stays the single source of truth.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
15
|
+
import { readFileSync, statSync } from "node:fs";
|
|
16
16
|
|
|
17
17
|
import { isHiddenColumn, loadConfig } from "~/config/loader";
|
|
18
18
|
import { isTask, parseBoard } from "~/parser/markdown";
|
|
@@ -92,6 +92,22 @@ export interface Summary {
|
|
|
92
92
|
* widget and the dashboard can never disagree about what is due.
|
|
93
93
|
*/
|
|
94
94
|
planner: Record<PlannerSection, PlannerEntry[]>;
|
|
95
|
+
/**
|
|
96
|
+
* The configured status file, when there is one: mtime only, never the
|
|
97
|
+
* body. A bar widget wants to know *that* it changed — laying out a page of
|
|
98
|
+
* prose is the TUI's job. Absent when unconfigured, so every existing
|
|
99
|
+
* consumer sees the shape it already knows.
|
|
100
|
+
*/
|
|
101
|
+
statusFile?: { path: string; updatedAt: string | null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A file's mtime as ISO, or null when it isn't there. */
|
|
105
|
+
function statMtimeIso(path: string): string | null {
|
|
106
|
+
try {
|
|
107
|
+
return new Date(statSync(path).mtimeMs).toISOString();
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
95
111
|
}
|
|
96
112
|
|
|
97
113
|
/** Local calendar date as YYYY-MM-DD — never UTC, or "today" flips at the wrong hour. */
|
|
@@ -253,7 +269,22 @@ export function buildSummary(options: { next?: number; today?: string } = {}): S
|
|
|
253
269
|
});
|
|
254
270
|
}
|
|
255
271
|
|
|
256
|
-
|
|
272
|
+
const statusFile = config.statusFilePath
|
|
273
|
+
? {
|
|
274
|
+
path: config.statusFilePath,
|
|
275
|
+
// null, not absent: configured but not written yet is a state a widget
|
|
276
|
+
// may want to show, and it differs from "no status file at all".
|
|
277
|
+
updatedAt: statMtimeIso(config.statusFilePath),
|
|
278
|
+
}
|
|
279
|
+
: undefined;
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
generatedAt: new Date().toISOString(),
|
|
283
|
+
totals,
|
|
284
|
+
boards,
|
|
285
|
+
planner,
|
|
286
|
+
...(statusFile ? { statusFile } : {}),
|
|
287
|
+
};
|
|
257
288
|
}
|
|
258
289
|
|
|
259
290
|
export async function runSummary(argv: readonly string[]): Promise<number> {
|
package/src/config/loader.ts
CHANGED
|
@@ -59,6 +59,13 @@ export interface Config {
|
|
|
59
59
|
* Windows, `$SHELL` elsewhere). Ignored when `resumeCommand` is set.
|
|
60
60
|
*/
|
|
61
61
|
resumeShell: "auto" | Shell;
|
|
62
|
+
/**
|
|
63
|
+
* A markdown file to read from inside tuiboard with `i` (config
|
|
64
|
+
* `status_file`) — a morning digest, a handover note, whatever is written
|
|
65
|
+
* there. Read-only and never parsed: tuiboard shows it, nothing else.
|
|
66
|
+
* Unset means the key does nothing.
|
|
67
|
+
*/
|
|
68
|
+
statusFilePath?: string;
|
|
62
69
|
/**
|
|
63
70
|
* Agent status glyphs (config `status_indicators`): `symbols` (default —
|
|
64
71
|
* the same × ◐ ✓ ○ · herdr uses) or `dots` (● ○ ·).
|
|
@@ -223,6 +230,7 @@ interface RawConfig {
|
|
|
223
230
|
resume_terminal: string;
|
|
224
231
|
resume_shell: string;
|
|
225
232
|
status_indicators: string;
|
|
233
|
+
status_file: string;
|
|
226
234
|
copy_resume_command: string;
|
|
227
235
|
calendars: {
|
|
228
236
|
google?: {
|
|
@@ -374,6 +382,10 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
|
|
|
374
382
|
resumeShell: (SHELLS as readonly string[]).includes(raw.resume_shell ?? "")
|
|
375
383
|
? (raw.resume_shell as Shell)
|
|
376
384
|
: "auto",
|
|
385
|
+
statusFilePath:
|
|
386
|
+
typeof raw.status_file === "string" && raw.status_file.trim()
|
|
387
|
+
? expandPath(raw.status_file.trim(), root)
|
|
388
|
+
: undefined,
|
|
377
389
|
statusIndicators: raw.status_indicators === "dots" ? "dots" : "symbols",
|
|
378
390
|
copyResumeCommand:
|
|
379
391
|
typeof raw.copy_resume_command === "string" &&
|
package/src/input/handleKey.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
systemLaunchEnv,
|
|
25
25
|
} from "~/input/open-session";
|
|
26
26
|
import { planHerdrFocus, planHerdrResume } from "~/input/herdr-open";
|
|
27
|
+
import { isMinusKey, isPlusKey } from "~/input/keys";
|
|
27
28
|
import { HARNESS, type AgentSession } from "~/store/agents";
|
|
28
29
|
import { herdrBin, herdrPlace } from "~/store/herdr";
|
|
29
30
|
import { googleTokenCanWrite } from "~/store/calendar";
|
|
@@ -31,6 +32,7 @@ import { isTask } from "~/parser/markdown";
|
|
|
31
32
|
import {
|
|
32
33
|
isoToday,
|
|
33
34
|
isoTomorrow,
|
|
35
|
+
type ArmOrigin,
|
|
34
36
|
type ModalKind,
|
|
35
37
|
type TaskRef,
|
|
36
38
|
type TuiStore,
|
|
@@ -116,6 +118,17 @@ export function handleKey(
|
|
|
116
118
|
store.closeModal();
|
|
117
119
|
return;
|
|
118
120
|
}
|
|
121
|
+
// The status file closes on its own key too, like `o` closes the detail.
|
|
122
|
+
if (ui.modal.kind === "status-file") {
|
|
123
|
+
if (key.name === "i") { store.closeModal(); return; }
|
|
124
|
+
const step =
|
|
125
|
+
key.name === "j" || key.name === "down" ? 1 :
|
|
126
|
+
key.name === "k" || key.name === "up" ? -1 :
|
|
127
|
+
key.name === "pagedown" || key.name === "space" ? 10 :
|
|
128
|
+
key.name === "pageup" ? -10 : 0;
|
|
129
|
+
if (step) store.setStatusScroll(ui.statusScroll + step);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
119
132
|
// Help modal: j/k (or arrows) scroll its keyboard reference.
|
|
120
133
|
if (ui.modal.kind === "help") {
|
|
121
134
|
if (key.name === "j" || key.name === "down") { store.setHelpScroll(ui.helpScroll + 1); return; }
|
|
@@ -146,10 +159,11 @@ export function handleKey(
|
|
|
146
159
|
// disruptive first.
|
|
147
160
|
if (key.name === "escape") {
|
|
148
161
|
if (ui.armMode || ui.armedTimelineRef) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
store.
|
|
162
|
+
// Cancel: the armed task goes back to how it was, the cursor to where
|
|
163
|
+
// `c` was pressed (#73).
|
|
164
|
+
const hadTask = !!ui.armedTimelineRef;
|
|
165
|
+
restoreArmOrigin(store, store.leaveArmMode(false), plannerCount);
|
|
166
|
+
store.flashBanner("info", hadTask ? "Cancelled" : "Arm mode off");
|
|
153
167
|
return;
|
|
154
168
|
}
|
|
155
169
|
if (ui.selectedCalEvent) {
|
|
@@ -186,8 +200,9 @@ export function handleKey(
|
|
|
186
200
|
|
|
187
201
|
// Bulk: reset all overdue across all boards → today (Shift+T).
|
|
188
202
|
if (key.name === "t" && key.shift) {
|
|
189
|
-
const
|
|
190
|
-
|
|
203
|
+
const { tasks, blocks } = store.resetAllOverdueToToday();
|
|
204
|
+
const cleared = blocks > 0 ? ` (${blocks} time block${blocks > 1 ? "s" : ""} cleared)` : "";
|
|
205
|
+
store.flashBanner("info", tasks > 0 ? `Reset ${tasks} overdue → today${cleared}` : "No overdue tasks");
|
|
191
206
|
return;
|
|
192
207
|
}
|
|
193
208
|
|
|
@@ -212,9 +227,10 @@ export function handleKey(
|
|
|
212
227
|
return;
|
|
213
228
|
}
|
|
214
229
|
|
|
215
|
-
// New board — the `+` chip in the top bar, and its key.
|
|
216
|
-
//
|
|
217
|
-
|
|
230
|
+
// New board — the `+` chip in the top bar, and its key. Except on the
|
|
231
|
+
// agenda with a block armed: there `+` grows the block (handleTimelineZone),
|
|
232
|
+
// and this shortcut running first used to swallow it (#71).
|
|
233
|
+
if (isPlusKey(key) && !(ui.armedTimelineRef && ui.activeZone === "timeline")) {
|
|
218
234
|
store.openBoardNew();
|
|
219
235
|
return;
|
|
220
236
|
}
|
|
@@ -270,6 +286,14 @@ export function handleKey(
|
|
|
270
286
|
return;
|
|
271
287
|
}
|
|
272
288
|
|
|
289
|
+
// The status file: a markdown file read once in a while (a morning digest,
|
|
290
|
+
// a handover note), shown in a dialog. `i` for "info"; `u` and `w` were the
|
|
291
|
+
// other free letters, both easier to misread as undo / write.
|
|
292
|
+
if (key.name === "i") {
|
|
293
|
+
store.openModal({ kind: "status-file" });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
273
297
|
// Zoom toggle: focus the active panel (board column or planner panel)
|
|
274
298
|
// at full width.
|
|
275
299
|
if (key.name === "z") {
|
|
@@ -320,7 +344,7 @@ export function handleKey(
|
|
|
320
344
|
}
|
|
321
345
|
|
|
322
346
|
if (ui.activeZone === "timeline") {
|
|
323
|
-
handleTimelineZone(store, key, openLater);
|
|
347
|
+
handleTimelineZone(store, key, plannerCount, openLater);
|
|
324
348
|
return;
|
|
325
349
|
}
|
|
326
350
|
|
|
@@ -373,9 +397,22 @@ function handlePlannerZone(
|
|
|
373
397
|
}
|
|
374
398
|
}
|
|
375
399
|
|
|
400
|
+
/** Put the cursor back where `c` started arm mode. */
|
|
401
|
+
function restoreArmOrigin(store: TuiStore, origin: ArmOrigin | undefined, plannerCount = Infinity): boolean {
|
|
402
|
+
if (!origin) return false;
|
|
403
|
+
if (origin.boardIndex !== store.state.ui.activeBoardIndex) store.setActiveBoard(origin.boardIndex);
|
|
404
|
+
store.setActiveZone(origin.zone);
|
|
405
|
+
// Placing a task can reorder the planner (it joins the time-blocked
|
|
406
|
+
// bucket); the row stays, clamped to what's there.
|
|
407
|
+
const row = origin.zone === "planner" ? Math.min(origin.row, Math.max(0, plannerCount - 1)) : origin.row;
|
|
408
|
+
store.setCursor(origin.col, row);
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
376
412
|
function handleTimelineZone(
|
|
377
413
|
store: TuiStore,
|
|
378
414
|
key: KeyEvent,
|
|
415
|
+
plannerCount: number,
|
|
379
416
|
openLater: (m: ModalKind) => void,
|
|
380
417
|
): void {
|
|
381
418
|
const ui = store.state.ui;
|
|
@@ -427,6 +464,16 @@ function handleTimelineZone(
|
|
|
427
464
|
)
|
|
428
465
|
: undefined;
|
|
429
466
|
|
|
467
|
+
// Enter confirms whatever is armed — placed or not — and goes back to where
|
|
468
|
+
// `c` started; a task armed straight from its band still jumps to its card.
|
|
469
|
+
if (armedRef && (key.name === "enter" || key.name === "return")) {
|
|
470
|
+
const origin = store.leaveArmMode(true);
|
|
471
|
+
if (!restoreArmOrigin(store, origin, plannerCount)) jumpToKanban(store, armedRef);
|
|
472
|
+
const t = store.getTask(armedRef);
|
|
473
|
+
store.flashBanner("info", t?.timeBlock ? `✓ ${fmtHm(t.timeBlock.startMin)}-${fmtHm(t.timeBlock.endMin)}` : "Arm mode off");
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
430
477
|
if (armed) {
|
|
431
478
|
const NUDGE = 15; // minutes
|
|
432
479
|
if (key.name === "j" || key.name === "down") {
|
|
@@ -443,24 +490,18 @@ function handleTimelineZone(
|
|
|
443
490
|
store.flashBanner("info", `✋ ${fmtHm(newStart)}-${fmtHm(newEnd)}`);
|
|
444
491
|
return;
|
|
445
492
|
}
|
|
446
|
-
if (key
|
|
493
|
+
if (isPlusKey(key) || key.name === "=") {
|
|
447
494
|
const newEnd = Math.min(24 * 60 - 1, armed.endMin + NUDGE);
|
|
448
495
|
store.setTimeBlock(armed.ref, { startMin: armed.startMin, endMin: newEnd });
|
|
449
496
|
store.flashBanner("info", `↕ ${fmtHm(armed.startMin)}-${fmtHm(newEnd)}`);
|
|
450
497
|
return;
|
|
451
498
|
}
|
|
452
|
-
if (key
|
|
499
|
+
if (isMinusKey(key) || key.name === "_") {
|
|
453
500
|
const newEnd = Math.max(armed.startMin + 15, armed.endMin - NUDGE);
|
|
454
501
|
store.setTimeBlock(armed.ref, { startMin: armed.startMin, endMin: newEnd });
|
|
455
502
|
store.flashBanner("info", `↕ ${fmtHm(armed.startMin)}-${fmtHm(newEnd)}`);
|
|
456
503
|
return;
|
|
457
504
|
}
|
|
458
|
-
if (key.name === "enter" || key.name === "return") {
|
|
459
|
-
// Commit + jump to kanban + disarm.
|
|
460
|
-
store.armTimeline(undefined);
|
|
461
|
-
jumpToKanban(store, armed.ref);
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
505
|
// Fall through for other keys (Esc handled globally, task actions below).
|
|
465
506
|
}
|
|
466
507
|
|
|
@@ -784,13 +825,12 @@ function dispatchTaskAction(
|
|
|
784
825
|
// slot, repeat. `c` again or `Esc` exits. Works from any zone.
|
|
785
826
|
if (key.name === "c" && !key.shift) {
|
|
786
827
|
if (store.state.ui.armMode) {
|
|
787
|
-
|
|
788
|
-
store.
|
|
828
|
+
// `c` again keeps what was placed, like Enter.
|
|
829
|
+
restoreArmOrigin(store, store.leaveArmMode(true));
|
|
789
830
|
store.flashBanner("info", "Arm mode off");
|
|
790
831
|
return true;
|
|
791
832
|
}
|
|
792
|
-
store.
|
|
793
|
-
store.armTimeline(ref);
|
|
833
|
+
store.startArmMode(ref);
|
|
794
834
|
store.setZoneVisible("timeline", true);
|
|
795
835
|
store.setActiveZone("timeline");
|
|
796
836
|
const t = store.getTask(ref);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keys that more than one handler cares about, recognised in one place so the
|
|
3
|
+
* handlers can't disagree about what "+" is.
|
|
4
|
+
*
|
|
5
|
+
* A `+` arrives in several shapes: the main-row key (name "+", or "=" with
|
|
6
|
+
* shift on US layouts), the keypad key (name "kpplus" under the kitty
|
|
7
|
+
* keyboard protocol, a plain "+" sequence elsewhere).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface KeyLike {
|
|
11
|
+
name?: string;
|
|
12
|
+
sequence?: string;
|
|
13
|
+
shift?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isPlusKey(key: KeyLike): boolean {
|
|
17
|
+
return key.name === "+" || key.name === "kpplus" || key.sequence === "+" || (key.name === "=" && !!key.shift);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isMinusKey(key: KeyLike): boolean {
|
|
21
|
+
return key.name === "-" || key.name === "kpminus" || key.sequence === "-";
|
|
22
|
+
}
|
package/src/store/index.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* 4. Schedule a debounced write to disk (writer + watcher self-mark).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFileSync } from "node:fs";
|
|
21
|
+
import { readFileSync, statSync } from "node:fs";
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { createMemo } from "solid-js";
|
|
@@ -76,6 +76,14 @@ export interface TaskRef {
|
|
|
76
76
|
taskIndex: number;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
function sameRef(a: TaskRef, b: TaskRef): boolean {
|
|
80
|
+
return a.boardPath === b.boardPath && a.columnIndex === b.columnIndex && a.taskIndex === b.taskIndex;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sameBlock(a: TimeBlock | undefined, b: TimeBlock | undefined): boolean {
|
|
84
|
+
return a?.startMin === b?.startMin && a?.endMin === b?.endMin;
|
|
85
|
+
}
|
|
86
|
+
|
|
79
87
|
export interface LoadedBoard {
|
|
80
88
|
board: Board;
|
|
81
89
|
/** mtime in ms at the moment of the last successful read or write. */
|
|
@@ -98,6 +106,7 @@ export type ModalKind =
|
|
|
98
106
|
| { kind: "confirm-delete-event" }
|
|
99
107
|
| { kind: "search" }
|
|
100
108
|
| { kind: "board-new" }
|
|
109
|
+
| { kind: "status-file" }
|
|
101
110
|
| { kind: "help" };
|
|
102
111
|
|
|
103
112
|
/**
|
|
@@ -133,6 +142,24 @@ export interface BoardNew {
|
|
|
133
142
|
}
|
|
134
143
|
|
|
135
144
|
/** What the detail view needs to show a task's note, or to explain its absence. */
|
|
145
|
+
/** The configured status file, as the modal shows it. */
|
|
146
|
+
export interface StatusFileView {
|
|
147
|
+
/** Path as configured, for the dialog subtitle. */
|
|
148
|
+
path: string;
|
|
149
|
+
body?: string;
|
|
150
|
+
/** Last write, ISO — undefined when the file isn't there. */
|
|
151
|
+
updatedAt?: string;
|
|
152
|
+
/** The configured path doesn't exist. */
|
|
153
|
+
missing?: string;
|
|
154
|
+
/** It exists but couldn't be read. */
|
|
155
|
+
error?: string;
|
|
156
|
+
/** Shown truncated: bigger than STATUS_FILE_MAX_BYTES. */
|
|
157
|
+
truncated?: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A status file is prose, not a database: past this it's shown cut. */
|
|
161
|
+
export const STATUS_FILE_MAX_BYTES = 64 * 1024;
|
|
162
|
+
|
|
136
163
|
export interface TaskNoteView {
|
|
137
164
|
path?: string;
|
|
138
165
|
body?: string;
|
|
@@ -177,6 +204,14 @@ export interface SelectedCalEvent {
|
|
|
177
204
|
export type ActiveZone = "planner" | "board" | "timeline" | "agents";
|
|
178
205
|
|
|
179
206
|
/** Fixed cycling order for Shift+Tab navigation. */
|
|
207
|
+
/** Where `c` was pressed, so leaving arm mode can put the cursor back. */
|
|
208
|
+
export interface ArmOrigin {
|
|
209
|
+
zone: ActiveZone;
|
|
210
|
+
boardIndex: number;
|
|
211
|
+
col: number;
|
|
212
|
+
row: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
180
215
|
const ZONE_ORDER: readonly ActiveZone[] = ["planner", "board", "timeline", "agents"];
|
|
181
216
|
|
|
182
217
|
export interface UIState {
|
|
@@ -246,6 +281,8 @@ export interface UIState {
|
|
|
246
281
|
* `armedTimelineRef`, which is the single task currently armed.
|
|
247
282
|
*/
|
|
248
283
|
armMode: boolean;
|
|
284
|
+
/** Set when `c` starts arm mode; `Enter`/`Esc` return here (#73). */
|
|
285
|
+
armOrigin?: ArmOrigin;
|
|
249
286
|
/**
|
|
250
287
|
* Which day the Agenda (timeline) zone is showing, as a signed offset from
|
|
251
288
|
* today (0 = today, +1 = tomorrow, -1 = yesterday). Drives both the task
|
|
@@ -258,6 +295,10 @@ export interface UIState {
|
|
|
258
295
|
* the upper bound (it knows the block count) and scrolls that block into view.
|
|
259
296
|
*/
|
|
260
297
|
helpScroll: number;
|
|
298
|
+
/** Bumped when the status file changes on disk, so an open modal re-reads. */
|
|
299
|
+
statusFileRev: number;
|
|
300
|
+
/** Status-file modal scroll, in rows. The modal clamps the upper bound. */
|
|
301
|
+
statusScroll: number;
|
|
261
302
|
view: ViewMode;
|
|
262
303
|
/**
|
|
263
304
|
* Tasks marked for bulk ops (`Space`). Key format:
|
|
@@ -359,6 +400,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
359
400
|
armMode: false,
|
|
360
401
|
agendaOffset: 0,
|
|
361
402
|
helpScroll: 0,
|
|
403
|
+
statusFileRev: 0,
|
|
404
|
+
statusScroll: 0,
|
|
362
405
|
view: "kanban",
|
|
363
406
|
marked: {},
|
|
364
407
|
filter: "all",
|
|
@@ -372,9 +415,12 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
372
415
|
// Last content tuiboard itself wrote per board path — used by the watcher's
|
|
373
416
|
// self-write guard to ignore our own writes echoed back by the OS / sync.
|
|
374
417
|
const lastWrittenContent = new Map<string, string>();
|
|
375
|
-
const watcher: BoardWatcher = createBoardWatcher(
|
|
376
|
-
initialBoards.map((b) => b.board.filepath),
|
|
377
|
-
|
|
418
|
+
const watcher: BoardWatcher = createBoardWatcher([
|
|
419
|
+
...initialBoards.map((b) => b.board.filepath),
|
|
420
|
+
// Watched from the start, even when it doesn't exist yet: chokidar picks
|
|
421
|
+
// up a file created later as long as its directory exists (verified).
|
|
422
|
+
...(config.statusFilePath ? [config.statusFilePath] : []),
|
|
423
|
+
]);
|
|
378
424
|
|
|
379
425
|
// Agents store has its own lifecycle (chokidar watcher on each agent CLI's
|
|
380
426
|
// session dirs). Shared dispose() boundary below so SIGINT cleans both. When
|
|
@@ -389,6 +435,12 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
389
435
|
? createCalendarStore(config.calendars, isoToday)
|
|
390
436
|
: noopCalendarStore();
|
|
391
437
|
watcher.onChange((filepath) => {
|
|
438
|
+
// The status file is not a board: nothing to parse, just tell an open
|
|
439
|
+
// modal to read it again.
|
|
440
|
+
if (config.statusFilePath && filepath === config.statusFilePath) {
|
|
441
|
+
setState("ui", "statusFileRev", (n: number) => n + 1);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
392
444
|
// External edit. Re-read this board from disk.
|
|
393
445
|
try {
|
|
394
446
|
const content = readFileSync(filepath, "utf-8");
|
|
@@ -1308,14 +1360,58 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1308
1360
|
setState("ui", "grabbing", false);
|
|
1309
1361
|
}
|
|
1310
1362
|
|
|
1363
|
+
/**
|
|
1364
|
+
* The armed task as it was when it was armed, so `Esc` can put it back.
|
|
1365
|
+
* Not UI state: nothing renders it, and it only means something while the
|
|
1366
|
+
* same ref is still armed.
|
|
1367
|
+
*/
|
|
1368
|
+
let armSnapshot: { ref: TaskRef; scheduled?: string; timeBlock?: TimeBlock } | undefined;
|
|
1369
|
+
|
|
1311
1370
|
function armTimeline(ref: TaskRef | undefined): void {
|
|
1312
|
-
|
|
1371
|
+
// A copy: handed an object, setState merges it into the one already
|
|
1372
|
+
// stored, so arming a second task used to rewrite the first task's ref
|
|
1373
|
+
// object in place — wherever a caller still held it.
|
|
1374
|
+
const own = ref && { ...ref };
|
|
1375
|
+
setState("ui", "armedTimelineRef", own);
|
|
1376
|
+
const t = own && getTask(own);
|
|
1377
|
+
armSnapshot =
|
|
1378
|
+
own && t
|
|
1379
|
+
? { ref: own, scheduled: t.scheduled, timeBlock: t.timeBlock && { ...t.timeBlock } }
|
|
1380
|
+
: undefined;
|
|
1313
1381
|
}
|
|
1314
1382
|
|
|
1315
1383
|
function setArmMode(on: boolean): void {
|
|
1316
1384
|
setState("ui", "armMode", on);
|
|
1317
1385
|
}
|
|
1318
1386
|
|
|
1387
|
+
/** Start arm mode from the cursor, remembering where it started. */
|
|
1388
|
+
function startArmMode(ref: TaskRef): void {
|
|
1389
|
+
const ui = state.ui;
|
|
1390
|
+
setState("ui", "armOrigin", { zone: ui.activeZone, boardIndex: ui.activeBoardIndex, col: ui.col, row: ui.row });
|
|
1391
|
+
setArmMode(true);
|
|
1392
|
+
armTimeline(ref);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Leave arm mode. `confirm` keeps whatever was placed; otherwise the armed
|
|
1397
|
+
* task goes back to how it was when armed. Returns the origin to restore,
|
|
1398
|
+
* if arm mode was started with `c`.
|
|
1399
|
+
*/
|
|
1400
|
+
function leaveArmMode(confirm: boolean): ArmOrigin | undefined {
|
|
1401
|
+
const ref = state.ui.armedTimelineRef;
|
|
1402
|
+
const snap = armSnapshot;
|
|
1403
|
+
if (!confirm && ref && snap && sameRef(ref, snap.ref)) {
|
|
1404
|
+
const t = getTask(ref);
|
|
1405
|
+
if (t && !sameBlock(t.timeBlock, snap.timeBlock)) setTimeBlock(ref, snap.timeBlock);
|
|
1406
|
+
if (t && t.scheduled !== snap.scheduled) setScheduled(ref, snap.scheduled);
|
|
1407
|
+
}
|
|
1408
|
+
const origin = state.ui.armOrigin;
|
|
1409
|
+
armTimeline(undefined);
|
|
1410
|
+
setArmMode(false);
|
|
1411
|
+
setState("ui", "armOrigin", undefined);
|
|
1412
|
+
return origin;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1319
1415
|
/** ISO date the Agenda is currently showing (today + offset). Reactive. */
|
|
1320
1416
|
function agendaDate(): string {
|
|
1321
1417
|
return isoAddDays(isoToday(), state.ui.agendaOffset);
|
|
@@ -1370,6 +1466,37 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1370
1466
|
* from, or why it could not be read. `undefined` means the task has no note —
|
|
1371
1467
|
* which is most tasks, and must produce no message at all.
|
|
1372
1468
|
*/
|
|
1469
|
+
/**
|
|
1470
|
+
* The configured status file, read fresh. No parsing and no caching: it is
|
|
1471
|
+
* read when the modal opens and again whenever the watcher says it changed,
|
|
1472
|
+
* which is rare enough that a cache would only be a way to show stale text.
|
|
1473
|
+
*/
|
|
1474
|
+
function statusFile(): StatusFileView | undefined {
|
|
1475
|
+
const path = config.statusFilePath;
|
|
1476
|
+
if (!path) return undefined;
|
|
1477
|
+
let size: number;
|
|
1478
|
+
let mtimeMs: number;
|
|
1479
|
+
try {
|
|
1480
|
+
const st = statSync(path);
|
|
1481
|
+
size = st.size;
|
|
1482
|
+
mtimeMs = st.mtimeMs;
|
|
1483
|
+
} catch {
|
|
1484
|
+
return { path, missing: path };
|
|
1485
|
+
}
|
|
1486
|
+
try {
|
|
1487
|
+
const body = readNoteBody(path);
|
|
1488
|
+
const truncated = size > STATUS_FILE_MAX_BYTES;
|
|
1489
|
+
return {
|
|
1490
|
+
path,
|
|
1491
|
+
updatedAt: new Date(mtimeMs).toISOString(),
|
|
1492
|
+
body: truncated ? body.slice(0, STATUS_FILE_MAX_BYTES) : body,
|
|
1493
|
+
...(truncated ? { truncated: true } : {}),
|
|
1494
|
+
};
|
|
1495
|
+
} catch (e) {
|
|
1496
|
+
return { path, updatedAt: new Date(mtimeMs).toISOString(), error: (e as Error).message };
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1373
1500
|
function taskNote(ref: TaskRef): TaskNoteView | undefined {
|
|
1374
1501
|
const task = getTask(ref);
|
|
1375
1502
|
if (!task?.note) return undefined;
|
|
@@ -1514,9 +1641,15 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1514
1641
|
|
|
1515
1642
|
// ─── Bulk: reset all overdue across all boards to today ──────────────────
|
|
1516
1643
|
|
|
1517
|
-
|
|
1644
|
+
/**
|
|
1645
|
+
* Every overdue task → today, and its time block goes with the day it was
|
|
1646
|
+
* planned for: a slot chosen for last Tuesday means nothing on today's
|
|
1647
|
+
* agenda, and re-placing it is two keys with arm mode (#79).
|
|
1648
|
+
*/
|
|
1649
|
+
function resetAllOverdueToToday(): { tasks: number; blocks: number } {
|
|
1518
1650
|
const today = isoToday();
|
|
1519
|
-
let
|
|
1651
|
+
let tasks = 0;
|
|
1652
|
+
let blocks = 0;
|
|
1520
1653
|
for (const lb of state.boards) {
|
|
1521
1654
|
const board = lb.board;
|
|
1522
1655
|
for (let ci = 0; ci < board.columns.length; ci++) {
|
|
@@ -1525,22 +1658,25 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1525
1658
|
for (const child of col.children) {
|
|
1526
1659
|
if (!isTask(child)) continue;
|
|
1527
1660
|
if (!child.done && child.scheduled && child.scheduled < today) {
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1661
|
+
const ref = { boardPath: board.filepath, columnIndex: ci, taskIndex: ti };
|
|
1662
|
+
if (child.timeBlock) {
|
|
1663
|
+
setTimeBlock(ref, undefined);
|
|
1664
|
+
blocks++;
|
|
1665
|
+
}
|
|
1666
|
+
setScheduled(ref, today);
|
|
1667
|
+
tasks++;
|
|
1533
1668
|
}
|
|
1534
1669
|
ti++;
|
|
1535
1670
|
}
|
|
1536
1671
|
}
|
|
1537
1672
|
}
|
|
1538
|
-
return
|
|
1673
|
+
return { tasks, blocks };
|
|
1539
1674
|
}
|
|
1540
1675
|
|
|
1541
1676
|
function openModal(m: ModalKind): void {
|
|
1542
1677
|
// Help always opens scrolled to the top.
|
|
1543
1678
|
if (m.kind === "help") setState("ui", "helpScroll", 0);
|
|
1679
|
+
if (m.kind === "status-file") setState("ui", "statusScroll", 0);
|
|
1544
1680
|
setState("ui", "modal", m);
|
|
1545
1681
|
}
|
|
1546
1682
|
|
|
@@ -1603,6 +1739,11 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1603
1739
|
function setHelpScroll(n: number): void {
|
|
1604
1740
|
setState("ui", "helpScroll", Math.max(0, n));
|
|
1605
1741
|
}
|
|
1742
|
+
/** Scroll the status file (rows, lower-clamped at 0; StatusFileModal writes
|
|
1743
|
+
* back the upper bound once it knows how tall the file renders). */
|
|
1744
|
+
function setStatusScroll(n: number): void {
|
|
1745
|
+
setState("ui", "statusScroll", Math.max(0, n));
|
|
1746
|
+
}
|
|
1606
1747
|
|
|
1607
1748
|
/** Move the step-2 calendar selection (wraps). */
|
|
1608
1749
|
function setEventSel(n: number): void {
|
|
@@ -1753,6 +1894,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1753
1894
|
moveTaskWithinBoard,
|
|
1754
1895
|
// notes
|
|
1755
1896
|
taskNote,
|
|
1897
|
+
statusFile,
|
|
1756
1898
|
// boards
|
|
1757
1899
|
addBoard,
|
|
1758
1900
|
openBoardNew,
|
|
@@ -1779,6 +1921,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1779
1921
|
exitGrab,
|
|
1780
1922
|
armTimeline,
|
|
1781
1923
|
setArmMode,
|
|
1924
|
+
startArmMode,
|
|
1925
|
+
leaveArmMode,
|
|
1782
1926
|
agendaDate,
|
|
1783
1927
|
shiftAgendaDay,
|
|
1784
1928
|
resetAgendaDay,
|
|
@@ -1798,6 +1942,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1798
1942
|
openEventModal,
|
|
1799
1943
|
advanceEventToStep2,
|
|
1800
1944
|
setHelpScroll,
|
|
1945
|
+
setStatusScroll,
|
|
1801
1946
|
setEventSel,
|
|
1802
1947
|
confirmEventPicker,
|
|
1803
1948
|
selectCalEvent,
|
package/src/ui/Modal.tsx
CHANGED
|
@@ -22,9 +22,10 @@ import {
|
|
|
22
22
|
import { ATTR, T, cellWidth } from "~/ui/glyphs";
|
|
23
23
|
import { AGENDA_WIDTH } from "~/ui/layout";
|
|
24
24
|
import { formatHm } from "~/store/timeline";
|
|
25
|
-
import { HARNESS } from "~/store/agents";
|
|
25
|
+
import { HARNESS, formatAge } from "~/store/agents";
|
|
26
26
|
import { HARNESS_COLOR } from "~/ui/AgentRow";
|
|
27
27
|
import { herdrPlace } from "~/store/herdr";
|
|
28
|
+
import { markdownLines, type MdLine, type MdStyle } from "~/ui/markdown-lines";
|
|
28
29
|
import type { TuiStore } from "~/store/index";
|
|
29
30
|
import type { PriorityLevel, TimeBlock } from "~/types";
|
|
30
31
|
|
|
@@ -68,6 +69,7 @@ function ModalRouter(props: { store: TuiStore; modal: NonNullable<TuiStore["stat
|
|
|
68
69
|
case "confirm-delete-event": return <ConfirmDeleteEventModal store={props.store} />;
|
|
69
70
|
case "search": return <SearchModal store={props.store} />;
|
|
70
71
|
case "board-new": return <BoardNewModal store={props.store} />;
|
|
72
|
+
case "status-file": return <StatusFileModal store={props.store} />;
|
|
71
73
|
case "help": return <HelpModal store={props.store} />;
|
|
72
74
|
}
|
|
73
75
|
}
|
|
@@ -258,7 +260,7 @@ function DialogShell(props: DialogShellProps) {
|
|
|
258
260
|
>
|
|
259
261
|
{props.children}
|
|
260
262
|
<Show when={props.hint}>
|
|
261
|
-
<text>
|
|
263
|
+
<text style={{ flexShrink: 0 }}>
|
|
262
264
|
<span style={{ fg: T.textDim }}>{props.hint}</span>
|
|
263
265
|
</text>
|
|
264
266
|
</Show>
|
|
@@ -837,6 +839,145 @@ function DetailModal(props: { store: TuiStore; modal: Extract<NonNullable<TuiSto
|
|
|
837
839
|
);
|
|
838
840
|
}
|
|
839
841
|
|
|
842
|
+
/**
|
|
843
|
+
* The configured status file (`status_file`), shown and nothing else: no
|
|
844
|
+
* writing, and no parsing beyond the light markdown rendering of
|
|
845
|
+
* markdown-lines.ts — markers become colour and weight, link targets go away.
|
|
846
|
+
* Same shape as a task's note — a dialog with a scrollable body — because
|
|
847
|
+
* from tuiboard's side it is the same object.
|
|
848
|
+
*/
|
|
849
|
+
/** "updated now" / "updated 5m ago" — same vocabulary the agents rows use. */
|
|
850
|
+
function updatedLabel(iso: string): string {
|
|
851
|
+
const age = formatAge(Date.parse(iso), Date.now());
|
|
852
|
+
return age === "now" ? "updated just now" : `updated ${age} ago`;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const MD_SPAN: Record<MdStyle, { fg: string | undefined; attributes?: number }> = {
|
|
856
|
+
text: { fg: T.text },
|
|
857
|
+
bold: { fg: T.text, attributes: ATTR.bold },
|
|
858
|
+
italic: { fg: T.text, attributes: ATTR.italic },
|
|
859
|
+
code: { fg: T.tag },
|
|
860
|
+
link: { fg: T.accent },
|
|
861
|
+
wikilink: { fg: T.accent },
|
|
862
|
+
dim: { fg: T.textDim },
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
/** One markdown line as a word-wrapped row; blank lines keep their height. */
|
|
866
|
+
function MarkdownLine(props: { line: MdLine }) {
|
|
867
|
+
const l = () => props.line;
|
|
868
|
+
const heading = () => l().kind === "heading";
|
|
869
|
+
const spanStyle = (style: MdStyle) =>
|
|
870
|
+
heading()
|
|
871
|
+
? { fg: l().level! <= 2 ? T.accent : T.warm, attributes: ATTR.bold }
|
|
872
|
+
: l().kind === "quote" && style === "text"
|
|
873
|
+
? MD_SPAN.dim
|
|
874
|
+
: MD_SPAN[style];
|
|
875
|
+
return (
|
|
876
|
+
<Show when={l().kind !== "blank"} fallback={<box style={{ height: 1 }} />}>
|
|
877
|
+
<Show
|
|
878
|
+
when={l().kind !== "rule"}
|
|
879
|
+
fallback={
|
|
880
|
+
<text wrapMode="none">
|
|
881
|
+
<span style={{ fg: T.textDim }}>{"─".repeat(40)}</span>
|
|
882
|
+
</text>
|
|
883
|
+
}
|
|
884
|
+
>
|
|
885
|
+
<text wrapMode="word">
|
|
886
|
+
<Show when={l().prefix}>
|
|
887
|
+
<span style={{ fg: T.textDim }}>{l().prefix}</span>
|
|
888
|
+
</Show>
|
|
889
|
+
<For each={l().spans}>
|
|
890
|
+
{(s) => <span style={spanStyle(s.style)}>{s.text}</span>}
|
|
891
|
+
</For>
|
|
892
|
+
</text>
|
|
893
|
+
</Show>
|
|
894
|
+
</Show>
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** The bits of OpenTUI's <scrollbox> the status file needs. */
|
|
899
|
+
interface ScrollTopLike {
|
|
900
|
+
scrollTop: number;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function StatusFileModal(props: { store: TuiStore }) {
|
|
904
|
+
let scroller: ScrollTopLike | undefined;
|
|
905
|
+
// j/k drive `ui.statusScroll`; the scrollbox clamps it to what it can
|
|
906
|
+
// show, and the clamped value is written back so `k` answers at once after
|
|
907
|
+
// overshooting the end.
|
|
908
|
+
createEffect(() => {
|
|
909
|
+
const want = props.store.state.ui.statusScroll;
|
|
910
|
+
if (!scroller) return;
|
|
911
|
+
scroller.scrollTop = want;
|
|
912
|
+
if (scroller.scrollTop !== want) props.store.setStatusScroll(scroller.scrollTop);
|
|
913
|
+
});
|
|
914
|
+
const view = createMemo(() => {
|
|
915
|
+
props.store.state.ui.statusFileRev; // re-read when the file changes on disk
|
|
916
|
+
return props.store.statusFile();
|
|
917
|
+
});
|
|
918
|
+
return (
|
|
919
|
+
<Show
|
|
920
|
+
when={view()}
|
|
921
|
+
fallback={
|
|
922
|
+
<DialogShell title="Status file" hint="Esc to close" width={70}>
|
|
923
|
+
<text wrapMode="word">
|
|
924
|
+
<span style={{ fg: T.textDim }}>
|
|
925
|
+
No status file configured. Set `status_file:` in your config to the markdown
|
|
926
|
+
file you want to read here.
|
|
927
|
+
</span>
|
|
928
|
+
</text>
|
|
929
|
+
</DialogShell>
|
|
930
|
+
}
|
|
931
|
+
>
|
|
932
|
+
{(v: () => NonNullable<ReturnType<typeof view>>) => (
|
|
933
|
+
<DialogShell title="Status file" hint="j/k scroll · Esc/i to close" width={90}>
|
|
934
|
+
<text wrapMode="word">
|
|
935
|
+
<span style={{ fg: T.tag }}>{v().path}</span>
|
|
936
|
+
<Show when={v().updatedAt}>
|
|
937
|
+
<span style={{ fg: T.textDim }}>{" " + updatedLabel(v().updatedAt!)}</span>
|
|
938
|
+
</Show>
|
|
939
|
+
</text>
|
|
940
|
+
<Show when={v().missing}>
|
|
941
|
+
<box style={{ height: 1 }} />
|
|
942
|
+
<text wrapMode="word">
|
|
943
|
+
<span style={{ fg: T.overdue }}>{"File not found: " + v().missing}</span>
|
|
944
|
+
</text>
|
|
945
|
+
</Show>
|
|
946
|
+
<Show when={v().error}>
|
|
947
|
+
<box style={{ height: 1 }} />
|
|
948
|
+
<text wrapMode="word">
|
|
949
|
+
<span style={{ fg: T.overdue }}>{"File unreadable: " + v().error}</span>
|
|
950
|
+
</text>
|
|
951
|
+
</Show>
|
|
952
|
+
<Show when={v().body !== undefined}>
|
|
953
|
+
<box style={{ height: 1 }} />
|
|
954
|
+
<scrollbox
|
|
955
|
+
ref={(r: ScrollTopLike) => (scroller = r)}
|
|
956
|
+
style={{ flexGrow: 1, flexShrink: 1, flexBasis: 0, minHeight: 0 }}
|
|
957
|
+
>
|
|
958
|
+
<Show
|
|
959
|
+
when={v().body !== ""}
|
|
960
|
+
fallback={
|
|
961
|
+
<text wrapMode="word">
|
|
962
|
+
<span style={{ fg: T.textDim }}>(the status file is empty)</span>
|
|
963
|
+
</text>
|
|
964
|
+
}
|
|
965
|
+
>
|
|
966
|
+
<For each={markdownLines(v().body!)}>{(line) => <MarkdownLine line={line} />}</For>
|
|
967
|
+
</Show>
|
|
968
|
+
</scrollbox>
|
|
969
|
+
<Show when={v().truncated}>
|
|
970
|
+
<text wrapMode="word">
|
|
971
|
+
<span style={{ fg: T.textDim }}>… shown truncated (the file is large)</span>
|
|
972
|
+
</text>
|
|
973
|
+
</Show>
|
|
974
|
+
</Show>
|
|
975
|
+
</DialogShell>
|
|
976
|
+
)}
|
|
977
|
+
</Show>
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
840
981
|
// ─── Search ──────────────────────────────────────────────────────────────────
|
|
841
982
|
|
|
842
983
|
function SearchModal(props: { store: TuiStore }) {
|
|
@@ -1125,13 +1266,13 @@ const HELP_SECTIONS: HelpSection[] = [
|
|
|
1125
1266
|
["n / click slot", "New Google Calendar event (needs: calendar-setup google --write) append date+time: Lunch tomorrow 12-13 · Review 2026-06-10 15-16 · Holiday 25-12 allday"],
|
|
1126
1267
|
["click an event", "Select an editable Google event — then e edit · d delete · Esc"],
|
|
1127
1268
|
["c (any zone)", "Toggle ARM MODE — then click a task, click a slot, repeat"],
|
|
1128
|
-
["click empty row", "Place the armed task here (30-min block, or move if it has one)"],
|
|
1269
|
+
["click empty row", "Place the armed task here (30-min block, or move if it has one) — it stays armed"],
|
|
1129
1270
|
["click band", "Arm an existing block (or place the armed task at its start)"],
|
|
1130
1271
|
["shift+click row", "While armed (existing block): resize end to that row"],
|
|
1131
1272
|
["j / k", "While armed: nudge block ±15 min"],
|
|
1132
1273
|
["+ / -", "While armed: resize block end ±15 min"],
|
|
1133
|
-
["Enter", "While armed:
|
|
1134
|
-
["Esc", "
|
|
1274
|
+
["Enter", "While armed: keep it, exit arm mode, back to where c was pressed"],
|
|
1275
|
+
["Esc", "While armed: undo the placement, exit arm mode, back to where c was pressed"],
|
|
1135
1276
|
],
|
|
1136
1277
|
},
|
|
1137
1278
|
{
|
|
@@ -1166,13 +1307,14 @@ const HELP_SECTIONS: HelpSection[] = [
|
|
|
1166
1307
|
{
|
|
1167
1308
|
emoji: "📦",
|
|
1168
1309
|
title: "Bulk",
|
|
1169
|
-
rows: [["T", "Reset ALL overdue tasks (any board) to today"]],
|
|
1310
|
+
rows: [["T", "Reset ALL overdue tasks (any board) to today — clears their time blocks"]],
|
|
1170
1311
|
},
|
|
1171
1312
|
{
|
|
1172
1313
|
emoji: "🌐",
|
|
1173
1314
|
title: "Global",
|
|
1174
1315
|
rows: [
|
|
1175
1316
|
["Ctrl-Z", "Undo last mutation"],
|
|
1317
|
+
["i", "Status file (the markdown file set as `status_file`)"],
|
|
1176
1318
|
["?", "This help"],
|
|
1177
1319
|
["q · Ctrl-C", "Quit"],
|
|
1178
1320
|
],
|
package/src/ui/TaskRow.tsx
CHANGED
|
@@ -14,6 +14,12 @@ import { Show, createMemo } from "solid-js";
|
|
|
14
14
|
|
|
15
15
|
import { PRIORITY_COLOR, PRIORITY_GLYPH, T, cellWidth, fmtMin } from "~/ui/glyphs";
|
|
16
16
|
import { isoToday, isoTomorrow } from "~/store/index";
|
|
17
|
+
import {
|
|
18
|
+
statusOf,
|
|
19
|
+
suffixColorFor,
|
|
20
|
+
titleColorFor,
|
|
21
|
+
type TaskStatus,
|
|
22
|
+
} from "~/ui/task-status";
|
|
17
23
|
import type { Task } from "~/types";
|
|
18
24
|
|
|
19
25
|
interface TaskRowProps {
|
|
@@ -149,40 +155,6 @@ export function TaskRow(props: TaskRowProps) {
|
|
|
149
155
|
);
|
|
150
156
|
}
|
|
151
157
|
|
|
152
|
-
type TaskStatus =
|
|
153
|
-
| "done"
|
|
154
|
-
| "overdue"
|
|
155
|
-
| "today"
|
|
156
|
-
| "tomorrow"
|
|
157
|
-
| "future"
|
|
158
|
-
| "unscheduled";
|
|
159
|
-
|
|
160
|
-
function statusOf(t: Task): TaskStatus {
|
|
161
|
-
if (t.done) return "done";
|
|
162
|
-
const d = t.scheduled ?? t.due;
|
|
163
|
-
if (!d) return "unscheduled";
|
|
164
|
-
if (d < isoToday()) return "overdue";
|
|
165
|
-
if (d === isoToday()) return "today";
|
|
166
|
-
if (d === isoTomorrow()) return "tomorrow";
|
|
167
|
-
return "future";
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function titleColorFor(task: Task, status: TaskStatus): string | undefined {
|
|
171
|
-
// Precedence: done (green) > overdue (red) > priority (orange) > today
|
|
172
|
-
// (pale yellow) > tomorrow (grey) > default. The orange now *means*
|
|
173
|
-
// "priority flag" — only tasks with a priority get it; everything scheduled
|
|
174
|
-
// today is the calm pale yellow instead.
|
|
175
|
-
if (status === "done") return T.done;
|
|
176
|
-
if (status === "overdue") return T.overdue;
|
|
177
|
-
// Tomorrow is uniformly grey — even priority tasks — so everything set for
|
|
178
|
-
// tomorrow reads consistently as "later, de-emphasized".
|
|
179
|
-
if (status === "tomorrow") return T.textDim;
|
|
180
|
-
if (task.priority !== "none") return T.today;
|
|
181
|
-
if (status === "today") return T.todayPale;
|
|
182
|
-
// future / unscheduled: terminal default fg (looks right on any theme).
|
|
183
|
-
return T.text;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
158
|
/**
|
|
187
159
|
* Build the compact right-side suffix shown on a task row.
|
|
188
160
|
*
|
|
@@ -211,16 +183,6 @@ function buildSuffix(task: Task, hideDate?: boolean): string | undefined {
|
|
|
211
183
|
return parts.join(" ");
|
|
212
184
|
}
|
|
213
185
|
|
|
214
|
-
function suffixColorFor(task: Task, status: TaskStatus): string | undefined {
|
|
215
|
-
if (status === "done") return T.textDone;
|
|
216
|
-
if (status === "overdue") return T.overdue;
|
|
217
|
-
if (status === "today") return T.todayPale;
|
|
218
|
-
if (status === "tomorrow") return T.textDim;
|
|
219
|
-
if (status === "future") return T.scheduled;
|
|
220
|
-
return T.textDim;
|
|
221
|
-
void task;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
186
|
/**
|
|
225
187
|
* Truncate to `max` chars, preserving as much of the head as possible.
|
|
226
188
|
* When the string fits, return it untouched. Otherwise show the first
|
package/src/ui/TimelineView.tsx
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* Enter → bounce kanban cursor to the underlying task
|
|
21
21
|
* j/k while armed → nudge armed block ±15 min (move)
|
|
22
22
|
* +/- while armed → resize armed block end ±15 min
|
|
23
|
-
*
|
|
23
|
+
* Enter while armed → keep, leave arm mode, back to where `c` started
|
|
24
|
+
* Esc while armed → undo the placement, then the same way back
|
|
24
25
|
*
|
|
25
26
|
* Each timeline row is exactly 1 terminal line tall, so row index maps
|
|
26
27
|
* 1:1 to MINS_PER_ROW (15) minute offsets from DAY_START_HOUR.
|
|
@@ -258,7 +259,7 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
258
259
|
props.store.armTimeline(entry.ref);
|
|
259
260
|
props.store.flashBanner(
|
|
260
261
|
"info",
|
|
261
|
-
`Armed ⌚${formatHm(entry.startMin)}-${formatHm(entry.endMin)} · click
|
|
262
|
+
`Armed ⌚${formatHm(entry.startMin)}-${formatHm(entry.endMin)} · click a row to move, shift+click to resize · Enter done · Esc cancel`,
|
|
262
263
|
);
|
|
263
264
|
}
|
|
264
265
|
};
|
|
@@ -298,11 +299,10 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
298
299
|
props.store.setTimeBlock(ref, { startMin, endMin });
|
|
299
300
|
props.store.flashBanner(
|
|
300
301
|
"info",
|
|
301
|
-
`⌚
|
|
302
|
+
`⌚ ${formatHm(startMin)}-${formatHm(endMin)} · +/- length · j/k move · Enter done · Esc cancel`,
|
|
302
303
|
);
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
props.store.armTimeline(undefined);
|
|
304
|
+
// Stays armed (#73): the default length is rarely the right one, so
|
|
305
|
+
// +/- and j/k apply straight away, without re-clicking the band.
|
|
306
306
|
return;
|
|
307
307
|
}
|
|
308
308
|
|
|
@@ -361,7 +361,7 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
361
361
|
{"◉ ARM MODE "}
|
|
362
362
|
</span>
|
|
363
363
|
<span style={{ fg: T.textDim }}>
|
|
364
|
-
{"click a task → click a slot · Esc
|
|
364
|
+
{"click a task → click a slot · Enter done · Esc cancel"}
|
|
365
365
|
</span>
|
|
366
366
|
</text>
|
|
367
367
|
</Show>
|
|
@@ -376,7 +376,7 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
376
376
|
<span style={{ fg: T.textDim }}>
|
|
377
377
|
{armedIsUnscheduled()
|
|
378
378
|
? " click row to place · Esc to cancel"
|
|
379
|
-
: "
|
|
379
|
+
: " +/- length · j/k move · Enter done · Esc cancel"}
|
|
380
380
|
</span>
|
|
381
381
|
</text>
|
|
382
382
|
</Show>
|
package/src/ui/glyphs.ts
CHANGED
|
@@ -74,6 +74,9 @@ export const T = {
|
|
|
74
74
|
|
|
75
75
|
// Status-based row colors — kept clearly distinct in hue + brightness
|
|
76
76
|
overdue: "#e26a6a", // hue 0°, sat 65%, light 65% — clearly red
|
|
77
|
+
// Same hue, turned up: a task that has been late for days reads louder than
|
|
78
|
+
// one that slipped yesterday, without introducing a second meaning.
|
|
79
|
+
overdueHeavy: "#ff5555", // hue 0°, sat 100%, light 67%
|
|
77
80
|
today: "#e8a05c", // warm orange — now the PRIORITY accent (flagged tasks)
|
|
78
81
|
// Today/Tomorrow identity + today-scheduled task titles: a soft pale yellow.
|
|
79
82
|
todayPale: "#eaf6ad", // bright, calm — the Today/Tomorrow zone accent
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough markdown for reading a note in a dialog: the file is shown, not
|
|
3
|
+
* edited, so the markers that only matter to an editor go away (`**`, `#`,
|
|
4
|
+
* link targets, `[[ ]]`) and what they meant becomes colour and weight.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately line-based and forgiving — anything it doesn't recognise is
|
|
7
|
+
* printed as written, never dropped. Pure so the rules can be tested without
|
|
8
|
+
* a renderer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type MdStyle = "text" | "bold" | "italic" | "code" | "link" | "wikilink" | "dim";
|
|
12
|
+
|
|
13
|
+
export interface MdSpan {
|
|
14
|
+
text: string;
|
|
15
|
+
style: MdStyle;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type MdLineKind = "heading" | "text" | "quote" | "code" | "rule" | "blank";
|
|
19
|
+
|
|
20
|
+
export interface MdLine {
|
|
21
|
+
kind: MdLineKind;
|
|
22
|
+
/** Heading depth (1–6); only set on headings. */
|
|
23
|
+
level?: number;
|
|
24
|
+
/** Leading glyph + indent for list items and quotes, e.g. " • ". */
|
|
25
|
+
prefix?: string;
|
|
26
|
+
spans: MdSpan[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Order matters: code first (its content is literal), then links, then emphasis.
|
|
30
|
+
const INLINE =
|
|
31
|
+
/(`[^`]+`)|(\[\[[^\]]+\]\])|(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*\s][^*]*\*|(?<![\w])_[^_\s][^_]*_(?![\w]))/g;
|
|
32
|
+
|
|
33
|
+
export function parseInline(src: string): MdSpan[] {
|
|
34
|
+
const out: MdSpan[] = [];
|
|
35
|
+
let last = 0;
|
|
36
|
+
const push = (text: string, style: MdStyle) => {
|
|
37
|
+
if (!text) return;
|
|
38
|
+
const prev = out[out.length - 1];
|
|
39
|
+
if (prev && prev.style === style) prev.text += text;
|
|
40
|
+
else out.push({ text, style });
|
|
41
|
+
};
|
|
42
|
+
for (const m of src.matchAll(INLINE)) {
|
|
43
|
+
push(src.slice(last, m.index), "text");
|
|
44
|
+
const [tok, code, wiki, link, bold] = m;
|
|
45
|
+
if (code) push(tok.slice(1, -1), "code");
|
|
46
|
+
else if (wiki) {
|
|
47
|
+
const inner = tok.slice(2, -2);
|
|
48
|
+
const bar = inner.indexOf("|");
|
|
49
|
+
push(bar >= 0 ? inner.slice(bar + 1) : inner.replace(/#.*$/, ""), "wikilink");
|
|
50
|
+
} else if (link) push(tok.slice(1, tok.indexOf("](")), "link");
|
|
51
|
+
else if (bold) {
|
|
52
|
+
// Links inside bold keep their own colour; the rest is bold.
|
|
53
|
+
for (const s of parseInline(tok.slice(2, -2))) push(s.text, s.style === "text" ? "bold" : s.style);
|
|
54
|
+
} else push(tok.slice(1, -1), "italic");
|
|
55
|
+
last = m.index! + tok.length;
|
|
56
|
+
}
|
|
57
|
+
push(src.slice(last), "text");
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function markdownLines(body: string): MdLine[] {
|
|
62
|
+
const lines: MdLine[] = [];
|
|
63
|
+
let fence: string | undefined;
|
|
64
|
+
let inFrontmatter = false;
|
|
65
|
+
const src = body.replace(/\r\n?/g, "\n").split("\n");
|
|
66
|
+
|
|
67
|
+
src.forEach((raw, i) => {
|
|
68
|
+
// YAML frontmatter is metadata for the editor, not something to read.
|
|
69
|
+
if (i === 0 && raw.trim() === "---") return void (inFrontmatter = true);
|
|
70
|
+
if (inFrontmatter) {
|
|
71
|
+
if (raw.trim() === "---") inFrontmatter = false;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const fenceMatch = raw.match(/^\s*(```|~~~)/);
|
|
76
|
+
if (fenceMatch) {
|
|
77
|
+
fence = fence ? undefined : fenceMatch[1];
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (fence) return void lines.push({ kind: "code", spans: [{ text: raw, style: "code" }] });
|
|
81
|
+
|
|
82
|
+
if (raw.trim() === "") return void lines.push({ kind: "blank", spans: [] });
|
|
83
|
+
|
|
84
|
+
const heading = raw.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
|
|
85
|
+
if (heading) {
|
|
86
|
+
return void lines.push({ kind: "heading", level: heading[1]!.length, spans: parseInline(heading[2]!) });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(raw)) return void lines.push({ kind: "rule", spans: [] });
|
|
90
|
+
|
|
91
|
+
const quote = raw.match(/^\s*>\s?(.*)$/);
|
|
92
|
+
if (quote) return void lines.push({ kind: "quote", prefix: "│ ", spans: parseInline(quote[1]!) });
|
|
93
|
+
|
|
94
|
+
const item = raw.match(/^(\s*)([-*+]|\d+[.)])\s+(?:\[( |x|X)\]\s+)?(.*)$/);
|
|
95
|
+
if (item) {
|
|
96
|
+
const indent = " ".repeat(Math.floor(item[1]!.replace(/\t/g, " ").length / 2) * 2);
|
|
97
|
+
const box = item[3];
|
|
98
|
+
const glyph = box === undefined ? (/\d/.test(item[2]!) ? item[2]! : "•") : box === " " ? "○" : "✓";
|
|
99
|
+
return void lines.push({ kind: "text", prefix: `${indent}${glyph} `, spans: parseInline(item[4]!) });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
lines.push({ kind: "text", spans: parseInline(raw.trim()) });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Collapse runs of blank lines, and trim them at both ends.
|
|
106
|
+
const out = lines.filter((l, i, all) => l.kind !== "blank" || all[i - 1]?.kind !== "blank");
|
|
107
|
+
while (out[0]?.kind === "blank") out.shift();
|
|
108
|
+
while (out[out.length - 1]?.kind === "blank") out.pop();
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a task *is* right now (done, overdue, today…) and what color says so.
|
|
3
|
+
*
|
|
4
|
+
* Pulled out of TaskRow.tsx so the rules can be tested directly: they encode
|
|
5
|
+
* every precedence decision the board and planner rows rely on, and a
|
|
6
|
+
* component is an awkward place to assert them from.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { isoToday, isoTomorrow } from "~/store/index";
|
|
10
|
+
import { T } from "~/ui/glyphs";
|
|
11
|
+
import type { Task } from "~/types";
|
|
12
|
+
|
|
13
|
+
export type TaskStatus =
|
|
14
|
+
| "done"
|
|
15
|
+
| "overdue"
|
|
16
|
+
| "today"
|
|
17
|
+
| "tomorrow"
|
|
18
|
+
| "future"
|
|
19
|
+
| "unscheduled";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Calendar days from `from` to `to` (both `YYYY-MM-DD`). UTC arithmetic, so
|
|
23
|
+
* a DST change can't turn a day into 23 or 25 hours and round the wrong way.
|
|
24
|
+
*/
|
|
25
|
+
export function daysBetweenIso(from: string, to: string): number {
|
|
26
|
+
const ms = (iso: string) => {
|
|
27
|
+
const [y, m, d] = iso.split("-").map(Number);
|
|
28
|
+
return Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1);
|
|
29
|
+
};
|
|
30
|
+
return Math.round((ms(to) - ms(from)) / 86_400_000);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How late is late. Nazz resets most tasks to Today each morning, so being a
|
|
35
|
+
* day or two past is ordinary drift — a Friday task looked at again on Monday
|
|
36
|
+
* is already 3 days old without anyone ignoring it. Five days means the task
|
|
37
|
+
* has survived a weekend *and* working days of deliberate skipping, which is
|
|
38
|
+
* the thing worth seeing from across the board.
|
|
39
|
+
*
|
|
40
|
+
* Measured against the real boards when this landed (117 open tasks, 13
|
|
41
|
+
* overdue): 3 days would have painted 8 of the 13 — the whole weekend —
|
|
42
|
+
* while the spec's placeholder of 7 would have painted none.
|
|
43
|
+
*/
|
|
44
|
+
export const OVERDUE_HEAVY_AFTER_DAYS = 5;
|
|
45
|
+
|
|
46
|
+
/** Two bands, not a gradient: "a little" vs "a lot". */
|
|
47
|
+
export function overdueBand(scheduled: string, today: string): "light" | "heavy" {
|
|
48
|
+
return daysBetweenIso(scheduled, today) >= OVERDUE_HEAVY_AFTER_DAYS ? "heavy" : "light";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function statusOf(t: Task, today = isoToday(), tomorrow = isoTomorrow()): TaskStatus {
|
|
52
|
+
if (t.done) return "done";
|
|
53
|
+
const d = t.scheduled ?? t.due;
|
|
54
|
+
if (!d) return "unscheduled";
|
|
55
|
+
if (d < today) return "overdue";
|
|
56
|
+
if (d === today) return "today";
|
|
57
|
+
if (d === tomorrow) return "tomorrow";
|
|
58
|
+
return "future";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The overdue color for this task: louder once it's been late a while. */
|
|
62
|
+
function overdueColor(task: Task, today: string): string {
|
|
63
|
+
const d = task.scheduled ?? task.due;
|
|
64
|
+
return d && overdueBand(d, today) === "heavy" ? T.overdueHeavy : T.overdue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function titleColorFor(
|
|
68
|
+
task: Task,
|
|
69
|
+
status: TaskStatus,
|
|
70
|
+
today = isoToday(),
|
|
71
|
+
): string | undefined {
|
|
72
|
+
// Precedence: done (green) > overdue (red) > priority (orange) > today
|
|
73
|
+
// (pale yellow) > tomorrow (grey) > default. The orange now *means*
|
|
74
|
+
// "priority flag" — only tasks with a priority get it; everything scheduled
|
|
75
|
+
// today is the calm pale yellow instead.
|
|
76
|
+
if (status === "done") return T.done;
|
|
77
|
+
if (status === "overdue") return overdueColor(task, today);
|
|
78
|
+
// Tomorrow is uniformly grey — even priority tasks — so everything set for
|
|
79
|
+
// tomorrow reads consistently as "later, de-emphasized".
|
|
80
|
+
if (status === "tomorrow") return T.textDim;
|
|
81
|
+
if (task.priority !== "none") return T.today;
|
|
82
|
+
if (status === "today") return T.todayPale;
|
|
83
|
+
// future / unscheduled: terminal default fg (looks right on any theme).
|
|
84
|
+
return T.text;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function suffixColorFor(
|
|
88
|
+
task: Task,
|
|
89
|
+
status: TaskStatus,
|
|
90
|
+
today = isoToday(),
|
|
91
|
+
): string | undefined {
|
|
92
|
+
if (status === "done") return T.textDone;
|
|
93
|
+
// The date suffix carries the same band as the title, so a row doesn't read
|
|
94
|
+
// as two different degrees of late at once.
|
|
95
|
+
if (status === "overdue") return overdueColor(task, today);
|
|
96
|
+
if (status === "today") return T.todayPale;
|
|
97
|
+
if (status === "tomorrow") return T.textDim;
|
|
98
|
+
if (status === "future") return T.scheduled;
|
|
99
|
+
return T.textDim;
|
|
100
|
+
}
|