tuiboard 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tuiboard/config.example.yaml +6 -0
- package/CHANGELOG.md +45 -0
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/cli/summary.ts +33 -2
- package/src/cli/task.ts +19 -6
- package/src/config/loader.ts +12 -0
- package/src/input/handleKey.ts +59 -20
- package/src/input/keys.ts +22 -0
- package/src/store/index.ts +142 -5
- package/src/ui/Modal.tsx +147 -5
- 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.0] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Status file viewer** (#67): point `status_file:` at a markdown file — a
|
|
14
|
+
morning digest, a handover note, whatever you or an agent write there — and
|
|
15
|
+
`i` shows it in a dialog, read-only. The markdown is rendered rather than
|
|
16
|
+
shown as source: headings and bold as weight, links and wikilinks by their
|
|
17
|
+
label, list and checkbox glyphs, frontmatter hidden. `j`/`k`, arrows and
|
|
18
|
+
PgUp/PgDn scroll it. It re-reads itself while open when the file changes,
|
|
19
|
+
and `tuiboard summary` reports its path and mtime (never its body) when
|
|
20
|
+
configured.
|
|
21
|
+
- **Overdue tasks show how late they are** (#67): a task overdue for days is
|
|
22
|
+
painted in a louder red than one that slipped yesterday. No day count
|
|
23
|
+
anywhere — just two bands.
|
|
24
|
+
- **Agent interface documentation** (#69): `docs/agent-interface.md` spells
|
|
25
|
+
out `tuiboard summary` and `tuiboard task` as a stable contract for agents
|
|
26
|
+
and scripts — the JSON shape, title matching, exit codes and the rules that
|
|
27
|
+
keep an agent from corrupting a board.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- **Time blocking in arm mode takes fewer round trips** (#73). A task placed
|
|
31
|
+
on the agenda stays armed, so `+`/`-` and `j`/`k` size and move it straight
|
|
32
|
+
away. `Enter` keeps it and returns to where `c` was pressed (usually
|
|
33
|
+
Today/Tomorrow — in single-pane, without passing through the agents panel);
|
|
34
|
+
`Esc` puts the task back as it was and returns the same way.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- **Keypad `+` grows an armed time block** (#71) instead of opening the "new
|
|
38
|
+
board" dialog.
|
|
39
|
+
- Arming a second task no longer rewrites the first task's reference in place
|
|
40
|
+
(#73), a latent bug surfaced by the new arm-mode tests.
|
|
41
|
+
|
|
42
|
+
## [0.13.3] - 2026-09-19
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
- **`tuiboard task --board` refuses ambiguous boards** (#63) instead of taking
|
|
46
|
+
the first configured board whose path ends with the argument. Two boards can
|
|
47
|
+
easily end the same way (`Work/Personal.md`, `Home/Personal.md`), and this is
|
|
48
|
+
the headless path used by cron jobs and the bar widget, so the wrong board
|
|
49
|
+
could be mutated silently. It now lists the candidates and writes nothing,
|
|
50
|
+
the way an ambiguous `--match` already did. Thanks to the commenter on
|
|
51
|
+
Reddit who spotted it.
|
|
52
|
+
|
|
10
53
|
## [0.13.2] - 2026-09-17
|
|
11
54
|
|
|
12
55
|
### Fixed
|
|
@@ -501,6 +544,8 @@ First public release on npm. This entry captures the full feature set at launch.
|
|
|
501
544
|
|
|
502
545
|
Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
|
|
503
546
|
|
|
547
|
+
[0.14.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.14.0
|
|
548
|
+
[0.13.3]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.3
|
|
504
549
|
[0.13.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.2
|
|
505
550
|
[0.13.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.1
|
|
506
551
|
[0.13.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.0
|
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
|
|
|
@@ -501,11 +511,17 @@ session (until the next terminal resize).
|
|
|
501
511
|
| `n` | New task in current column (quick-add syntax) |
|
|
502
512
|
| `Shift-T` | Reset ALL overdue tasks (any board) to 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.0",
|
|
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/cli/task.ts
CHANGED
|
@@ -71,18 +71,31 @@ function isoShift(days: number): string {
|
|
|
71
71
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a board by configured name, then by path suffix — and refuse to
|
|
76
|
+
* guess when more than one matches, the way findTask does for task titles.
|
|
77
|
+
* Two boards can easily end the same way (`Work/Personal.md`,
|
|
78
|
+
* `Home/Personal.md`), and this is the headless path: a cron job or the bar
|
|
79
|
+
* widget mutating the wrong board would go unnoticed.
|
|
80
|
+
*/
|
|
75
81
|
function resolveBoard(needle: string) {
|
|
76
82
|
const cfg = loadConfig();
|
|
77
83
|
const lower = needle.toLowerCase();
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
const byName = cfg.boards.filter((b) => (b.name ?? "").toLowerCase() === lower);
|
|
85
|
+
const hits = byName.length > 0
|
|
86
|
+
? byName
|
|
87
|
+
: cfg.boards.filter((b) => b.path.toLowerCase().endsWith(lower));
|
|
88
|
+
if (hits.length === 0) {
|
|
82
89
|
const names = cfg.boards.map((b) => b.name ?? b.path).join(", ");
|
|
83
90
|
throw new Error(`board "${needle}" not found. Configured: ${names || "(none)"}`);
|
|
84
91
|
}
|
|
85
|
-
|
|
92
|
+
if (hits.length > 1) {
|
|
93
|
+
const candidates = hits.map((b) => (b.name ? `${b.name} (${b.path})` : b.path)).join(", ");
|
|
94
|
+
throw new Error(
|
|
95
|
+
`board "${needle}" matches ${hits.length} boards: ${candidates}; be more specific`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return hits[0]!;
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
function findColumn(board: Board, name: string): Column {
|
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) {
|
|
@@ -212,9 +226,10 @@ export function handleKey(
|
|
|
212
226
|
return;
|
|
213
227
|
}
|
|
214
228
|
|
|
215
|
-
// New board — the `+` chip in the top bar, and its key.
|
|
216
|
-
//
|
|
217
|
-
|
|
229
|
+
// New board — the `+` chip in the top bar, and its key. Except on the
|
|
230
|
+
// agenda with a block armed: there `+` grows the block (handleTimelineZone),
|
|
231
|
+
// and this shortcut running first used to swallow it (#71).
|
|
232
|
+
if (isPlusKey(key) && !(ui.armedTimelineRef && ui.activeZone === "timeline")) {
|
|
218
233
|
store.openBoardNew();
|
|
219
234
|
return;
|
|
220
235
|
}
|
|
@@ -270,6 +285,14 @@ export function handleKey(
|
|
|
270
285
|
return;
|
|
271
286
|
}
|
|
272
287
|
|
|
288
|
+
// The status file: a markdown file read once in a while (a morning digest,
|
|
289
|
+
// a handover note), shown in a dialog. `i` for "info"; `u` and `w` were the
|
|
290
|
+
// other free letters, both easier to misread as undo / write.
|
|
291
|
+
if (key.name === "i") {
|
|
292
|
+
store.openModal({ kind: "status-file" });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
273
296
|
// Zoom toggle: focus the active panel (board column or planner panel)
|
|
274
297
|
// at full width.
|
|
275
298
|
if (key.name === "z") {
|
|
@@ -320,7 +343,7 @@ export function handleKey(
|
|
|
320
343
|
}
|
|
321
344
|
|
|
322
345
|
if (ui.activeZone === "timeline") {
|
|
323
|
-
handleTimelineZone(store, key, openLater);
|
|
346
|
+
handleTimelineZone(store, key, plannerCount, openLater);
|
|
324
347
|
return;
|
|
325
348
|
}
|
|
326
349
|
|
|
@@ -373,9 +396,22 @@ function handlePlannerZone(
|
|
|
373
396
|
}
|
|
374
397
|
}
|
|
375
398
|
|
|
399
|
+
/** Put the cursor back where `c` started arm mode. */
|
|
400
|
+
function restoreArmOrigin(store: TuiStore, origin: ArmOrigin | undefined, plannerCount = Infinity): boolean {
|
|
401
|
+
if (!origin) return false;
|
|
402
|
+
if (origin.boardIndex !== store.state.ui.activeBoardIndex) store.setActiveBoard(origin.boardIndex);
|
|
403
|
+
store.setActiveZone(origin.zone);
|
|
404
|
+
// Placing a task can reorder the planner (it joins the time-blocked
|
|
405
|
+
// bucket); the row stays, clamped to what's there.
|
|
406
|
+
const row = origin.zone === "planner" ? Math.min(origin.row, Math.max(0, plannerCount - 1)) : origin.row;
|
|
407
|
+
store.setCursor(origin.col, row);
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
376
411
|
function handleTimelineZone(
|
|
377
412
|
store: TuiStore,
|
|
378
413
|
key: KeyEvent,
|
|
414
|
+
plannerCount: number,
|
|
379
415
|
openLater: (m: ModalKind) => void,
|
|
380
416
|
): void {
|
|
381
417
|
const ui = store.state.ui;
|
|
@@ -427,6 +463,16 @@ function handleTimelineZone(
|
|
|
427
463
|
)
|
|
428
464
|
: undefined;
|
|
429
465
|
|
|
466
|
+
// Enter confirms whatever is armed — placed or not — and goes back to where
|
|
467
|
+
// `c` started; a task armed straight from its band still jumps to its card.
|
|
468
|
+
if (armedRef && (key.name === "enter" || key.name === "return")) {
|
|
469
|
+
const origin = store.leaveArmMode(true);
|
|
470
|
+
if (!restoreArmOrigin(store, origin, plannerCount)) jumpToKanban(store, armedRef);
|
|
471
|
+
const t = store.getTask(armedRef);
|
|
472
|
+
store.flashBanner("info", t?.timeBlock ? `✓ ${fmtHm(t.timeBlock.startMin)}-${fmtHm(t.timeBlock.endMin)}` : "Arm mode off");
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
430
476
|
if (armed) {
|
|
431
477
|
const NUDGE = 15; // minutes
|
|
432
478
|
if (key.name === "j" || key.name === "down") {
|
|
@@ -443,24 +489,18 @@ function handleTimelineZone(
|
|
|
443
489
|
store.flashBanner("info", `✋ ${fmtHm(newStart)}-${fmtHm(newEnd)}`);
|
|
444
490
|
return;
|
|
445
491
|
}
|
|
446
|
-
if (key
|
|
492
|
+
if (isPlusKey(key) || key.name === "=") {
|
|
447
493
|
const newEnd = Math.min(24 * 60 - 1, armed.endMin + NUDGE);
|
|
448
494
|
store.setTimeBlock(armed.ref, { startMin: armed.startMin, endMin: newEnd });
|
|
449
495
|
store.flashBanner("info", `↕ ${fmtHm(armed.startMin)}-${fmtHm(newEnd)}`);
|
|
450
496
|
return;
|
|
451
497
|
}
|
|
452
|
-
if (key
|
|
498
|
+
if (isMinusKey(key) || key.name === "_") {
|
|
453
499
|
const newEnd = Math.max(armed.startMin + 15, armed.endMin - NUDGE);
|
|
454
500
|
store.setTimeBlock(armed.ref, { startMin: armed.startMin, endMin: newEnd });
|
|
455
501
|
store.flashBanner("info", `↕ ${fmtHm(armed.startMin)}-${fmtHm(newEnd)}`);
|
|
456
502
|
return;
|
|
457
503
|
}
|
|
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
504
|
// Fall through for other keys (Esc handled globally, task actions below).
|
|
465
505
|
}
|
|
466
506
|
|
|
@@ -784,13 +824,12 @@ function dispatchTaskAction(
|
|
|
784
824
|
// slot, repeat. `c` again or `Esc` exits. Works from any zone.
|
|
785
825
|
if (key.name === "c" && !key.shift) {
|
|
786
826
|
if (store.state.ui.armMode) {
|
|
787
|
-
|
|
788
|
-
store.
|
|
827
|
+
// `c` again keeps what was placed, like Enter.
|
|
828
|
+
restoreArmOrigin(store, store.leaveArmMode(true));
|
|
789
829
|
store.flashBanner("info", "Arm mode off");
|
|
790
830
|
return true;
|
|
791
831
|
}
|
|
792
|
-
store.
|
|
793
|
-
store.armTimeline(ref);
|
|
832
|
+
store.startArmMode(ref);
|
|
794
833
|
store.setZoneVisible("timeline", true);
|
|
795
834
|
store.setActiveZone("timeline");
|
|
796
835
|
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;
|
|
@@ -1541,6 +1668,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1541
1668
|
function openModal(m: ModalKind): void {
|
|
1542
1669
|
// Help always opens scrolled to the top.
|
|
1543
1670
|
if (m.kind === "help") setState("ui", "helpScroll", 0);
|
|
1671
|
+
if (m.kind === "status-file") setState("ui", "statusScroll", 0);
|
|
1544
1672
|
setState("ui", "modal", m);
|
|
1545
1673
|
}
|
|
1546
1674
|
|
|
@@ -1603,6 +1731,11 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1603
1731
|
function setHelpScroll(n: number): void {
|
|
1604
1732
|
setState("ui", "helpScroll", Math.max(0, n));
|
|
1605
1733
|
}
|
|
1734
|
+
/** Scroll the status file (rows, lower-clamped at 0; StatusFileModal writes
|
|
1735
|
+
* back the upper bound once it knows how tall the file renders). */
|
|
1736
|
+
function setStatusScroll(n: number): void {
|
|
1737
|
+
setState("ui", "statusScroll", Math.max(0, n));
|
|
1738
|
+
}
|
|
1606
1739
|
|
|
1607
1740
|
/** Move the step-2 calendar selection (wraps). */
|
|
1608
1741
|
function setEventSel(n: number): void {
|
|
@@ -1753,6 +1886,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1753
1886
|
moveTaskWithinBoard,
|
|
1754
1887
|
// notes
|
|
1755
1888
|
taskNote,
|
|
1889
|
+
statusFile,
|
|
1756
1890
|
// boards
|
|
1757
1891
|
addBoard,
|
|
1758
1892
|
openBoardNew,
|
|
@@ -1779,6 +1913,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1779
1913
|
exitGrab,
|
|
1780
1914
|
armTimeline,
|
|
1781
1915
|
setArmMode,
|
|
1916
|
+
startArmMode,
|
|
1917
|
+
leaveArmMode,
|
|
1782
1918
|
agendaDate,
|
|
1783
1919
|
shiftAgendaDay,
|
|
1784
1920
|
resetAgendaDay,
|
|
@@ -1798,6 +1934,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1798
1934
|
openEventModal,
|
|
1799
1935
|
advanceEventToStep2,
|
|
1800
1936
|
setHelpScroll,
|
|
1937
|
+
setStatusScroll,
|
|
1801
1938
|
setEventSel,
|
|
1802
1939
|
confirmEventPicker,
|
|
1803
1940
|
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
|
{
|
|
@@ -1173,6 +1314,7 @@ const HELP_SECTIONS: HelpSection[] = [
|
|
|
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
|
+
}
|