@getpipher/armory-todo 0.2.0 → 0.3.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/src/panel-data.ts CHANGED
@@ -4,27 +4,21 @@
4
4
 
5
5
  import type { SelectItem, SettingItem } from "@earendil-works/pi-tui";
6
6
  import type { Todo } from "./todo-store.ts";
7
+ import type { DoneItem } from "./archive.ts";
7
8
  import type { ArchiveSummary } from "./archive.ts";
8
9
  import type { TodoConfig } from "./config.ts";
9
10
 
10
- /** Format a todo as a SelectList item: "[id] (prio)⏵ (project) text…".
11
- * The project tag is placed BEFORE the text so it's always visible (not
12
- * clipped at the right edge). The text is truncated to a readable summary
13
- * (first ~80 chars) so long running-log todos don't blow out the row width.
14
- * The full text is still accessible via the "Edit text" action in the panel. */
11
+ /** Format a todo as a SelectList item: "[id] (prio)⏵ (project) • title".
12
+ * title is already ≤120 chars (enforced at write time), so no truncation is
13
+ * needed. The marker shows when notes is non-empty (signals "open the
14
+ * detail view / use `todo get` for context"). */
15
15
  export function todoToItem(t: Todo): SelectItem {
16
16
  const pin = t.status === "in_progress" ? " ⏵" : "";
17
17
  const proj = t.project ? ` (${t.project})` : "";
18
- const prefix = `[${t.id}] (${t.priority})${pin}${proj}`;
19
- const maxText = 80;
20
- let text = t.text;
21
- if (text.length > maxText) {
22
- const firstLine = text.split("\n")[0]!;
23
- text = firstLine.length > maxText ? firstLine.slice(0, maxText - 1) + "…" : firstLine + "…";
24
- }
18
+ const dot = t.notes.trim() ? " •" : "";
25
19
  return {
26
20
  value: t.id,
27
- label: `${prefix} ${text}`,
21
+ label: `[${t.id}] (${t.priority})${pin}${proj}${dot} ${t.title}`,
28
22
  };
29
23
  }
30
24
 
@@ -50,7 +44,7 @@ export function actionsForTodo(t: Todo): { label: string; action: string }[] {
50
44
  if (t.status === "done" || t.status === "cancelled") {
51
45
  actions.push({ label: "Restore (from archive)", action: "restore" });
52
46
  }
53
- actions.push({ label: "Edit text", action: "edit" });
47
+ actions.push({ label: "Edit title", action: "edit" });
54
48
  actions.push({ label: "Delete (cancel)", action: "delete" });
55
49
  return actions;
56
50
  }
@@ -67,4 +61,21 @@ export function configToSettingItems(cfg: TodoConfig): SettingItem[] {
67
61
  { id: "archiveMax", label: "Archive max", currentValue: String(cfg.health.archiveMax), values: ["100", "200", "500"], description: "Bloat flag when archive exceeds this." },
68
62
  { id: "archiveOldDays", label: "Archive old (days)", currentValue: String(cfg.health.archiveOldDays), values: ["90", "180", "365"], description: "Bloat flag when archive items older than this." },
69
63
  ];
70
- }
64
+ }
65
+
66
+ /** Format a done todo (live or archived) as a SelectList item with a
67
+ * location tag: "[live Nd]" or "[archived YYYY-MM-DD]". */
68
+ export function todoDoneItem(d: DoneItem): SelectItem {
69
+ const proj = d.project ? ` (${d.project})` : "";
70
+ const loc = d.location === "archive" && d.archivedAt
71
+ ? ` [archived ${d.archivedAt.slice(0, 10)}]`
72
+ : ` [live ${d.closedAt ? Math.floor((Date.now() - Date.parse(d.closedAt)) / 86400_000) : 0}d]`;
73
+ return { value: d.id, label: `[${d.id}] (done)${proj}${loc} ${d.title}` };
74
+ }
75
+
76
+ /** Actions for a done todo: View detail always; Restore only if archived. */
77
+ export function actionsForDoneTodo(d: DoneItem): { label: string; action: string }[] {
78
+ const acts: { label: string; action: string }[] = [{ label: "View detail", action: "view" }];
79
+ if (d.location === "archive") acts.push({ label: "Restore (from archive)", action: "restore" });
80
+ return acts;
81
+ }
package/src/panel.ts CHANGED
@@ -21,13 +21,13 @@ import {
21
21
  type Theme,
22
22
  } from "@earendil-works/pi-tui";
23
23
  import { listTodos, parkTodo, completeTodo, deleteTodo, updateTodo, type Todo, type Status } from "./todo-store.ts";
24
- import { restoreTodo, archiveSummary, listArchived } from "./archive.ts";
24
+ import { restoreTodo, archiveSummary, listArchived, listDoneUnified } from "./archive.ts";
25
25
  import { loadConfig, saveConfig, type TodoConfig } from "./config.ts";
26
26
  import { healthReport } from "./health.ts";
27
- import { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems } from "./panel-data.ts";
27
+ import { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems, todoDoneItem, actionsForDoneTodo } from "./panel-data.ts";
28
28
 
29
- export type Box = "active" | "parked" | "archive" | "config";
30
- const BOXES: Box[] = ["active", "parked", "archive", "config"];
29
+ export type Box = "active" | "parked" | "done" | "archive" | "config";
30
+ const BOXES: Box[] = ["active", "parked", "done", "archive", "config"];
31
31
 
32
32
  export interface TodoPanelOpts {
33
33
  theme: Theme;
@@ -47,6 +47,8 @@ export class TodoPanel extends Container {
47
47
  private editMode = false;
48
48
  private editInput: Input | null = null;
49
49
  private editId = "";
50
+ private detailMode = false;
51
+ private detailId = "";
50
52
  private settingsList: SettingsList | null = null;
51
53
  private config: TodoConfig;
52
54
  private healthFlags: string[] = [];
@@ -105,6 +107,20 @@ export class TodoPanel extends Container {
105
107
  } else if (this.actionMode && this.actionList) {
106
108
  this.addChild(new Text(this.theme.fg("accent", " Action:"), 0, 0));
107
109
  this.addChild(this.actionList);
110
+ } else if (this.detailMode) {
111
+ const all = listTodos({ status: "all", limit: 200 });
112
+ const t = all.find((x) => x.id === this.detailId);
113
+ if (!t) { this.detailMode = false; this.renderShell(); return; }
114
+ const proj = t.project || "no project";
115
+ const tags = t.tags.length ? t.tags.join(" ") : "(none)";
116
+ const notesText = t.notes || "(empty)";
117
+ this.addChild(new Text(this.theme.fg("accent", " " + t.title), 0, 0));
118
+ this.addChild(new Text(this.theme.fg("muted", " (" + t.priority + "/" + t.status + ") - " + proj + " - #" + tags), 0, 0));
119
+ this.addChild(new Spacer(1));
120
+ this.addChild(new Text(this.theme.fg("dim", " notes:"), 0, 0));
121
+ this.addChild(new Text(" " + notesText, 0, 0));
122
+ this.addChild(new Spacer(1));
123
+ this.addChild(new Text(this.theme.fg("dim", " notes: read-only - todo update <id> notes=... to edit"), 0, 0));
108
124
  } else if (this.currentBox === "config") {
109
125
  this.renderConfigBox();
110
126
  } else {
@@ -126,6 +142,9 @@ export class TodoPanel extends Container {
126
142
  } else if (this.currentBox === "parked") {
127
143
  const todos = listTodos({ status: "parked", text: filter || undefined, limit: 50 });
128
144
  this.setSelectItems(todos.map(todoToItem));
145
+ } else if (this.currentBox === "done") {
146
+ const items = listDoneUnified({ text: filter || undefined, limit: 50 });
147
+ this.setSelectItems(items.map(todoDoneItem));
129
148
  } else if (this.currentBox === "archive") {
130
149
  if (!filter) {
131
150
  const s = archiveSummary();
@@ -173,13 +192,20 @@ export class TodoPanel extends Container {
173
192
  }
174
193
 
175
194
  private openActionSubmenu(id: string): void {
176
- const all = listTodos({ status: "all", limit: 200 });
177
- const todo = all.find((t) => t.id === id);
178
- if (!todo) {
179
- this.onNotify("Todo not found in the live store (archive restore: use the archive box).", "info");
180
- return;
195
+ let acts: { label: string; action: string }[];
196
+ if (this.currentBox === "done") {
197
+ const d = listDoneUnified({}).find((x) => x.id === id);
198
+ if (!d) { this.onNotify("Done todo not found.", "info"); return; }
199
+ acts = actionsForDoneTodo(d);
200
+ } else {
201
+ const all = listTodos({ status: "all", limit: 200 });
202
+ const todo = all.find((t) => t.id === id);
203
+ if (!todo) {
204
+ this.onNotify("Todo not found in the live store (archive restore: use the archive box).", "info");
205
+ return;
206
+ }
207
+ acts = [{ label: "View detail", action: "view" }, ...actionsForTodo(todo)];
181
208
  }
182
- const acts = actionsForTodo(todo);
183
209
  const items: SelectItem[] = acts.map((a) => ({ value: a.action, label: a.label }));
184
210
  this.actionList = new SelectList(items, 8, {
185
211
  selectedPrefix: (s) => this.theme.fg("accent", s),
@@ -194,9 +220,23 @@ export class TodoPanel extends Container {
194
220
  this.renderShell();
195
221
  }
196
222
 
223
+ private viewDetail(id: string): void {
224
+ const all = listTodos({ status: "all", limit: 200 });
225
+ const t = all.find((x) => x.id === id);
226
+ if (!t) { this.onNotify("Todo not found.", "info"); return; }
227
+ this.detailId = id;
228
+ this.detailMode = true;
229
+ this.actionMode = false;
230
+ this.actionList = null;
231
+ this.editMode = false;
232
+ this.editInput = null;
233
+ this.renderShell();
234
+ }
235
+
197
236
  private async executeAction(id: string, action: string): Promise<void> {
198
237
  try {
199
238
  switch (action) {
239
+ case "view": this.viewDetail(id); return;
200
240
  case "complete": completeTodo(id); this.onNotify(`Completed ${id}`); break;
201
241
  case "park": parkTodo(id); this.onNotify(`Parked ${id}`); break;
202
242
  case "open": updateTodo(id, { status: "open" as Status }); this.onNotify(`Re-activated ${id}`); break;
@@ -207,9 +247,12 @@ export class TodoPanel extends Container {
207
247
  const t = all.find((x) => x.id === id);
208
248
  this.editId = id;
209
249
  this.editInput = new Input();
210
- this.editInput.setValue(t?.text ?? "");
250
+ this.editInput.setValue(t?.title ?? "");
211
251
  this.editInput.onSubmit = (value) => {
212
- if (value.trim()) { updateTodo(id, { text: value.trim() }); this.onNotify(`Edited ${id}`); }
252
+ if (value.trim()) {
253
+ try { updateTodo(id, { title: value.trim() }); this.onNotify(`Edited ${id}`); }
254
+ catch (err) { this.onNotify((err as Error).message, "error"); }
255
+ }
213
256
  this.exitEditMode();
214
257
  };
215
258
  this.editInput.onEscape = () => this.exitEditMode();
@@ -221,7 +264,7 @@ export class TodoPanel extends Container {
221
264
  }
222
265
  }
223
266
  } catch (err) {
224
- this.onNotify(`Error: ${(err as Error).message}`, "error");
267
+ this.onNotify((err as Error).message, "error");
225
268
  }
226
269
  this.actionMode = false;
227
270
  this.actionList = null;
@@ -308,6 +351,17 @@ export class TodoPanel extends Container {
308
351
  this.invalidate();
309
352
  return;
310
353
  }
354
+ if (this.detailMode) {
355
+ if (matchesKey(data, "escape") || matchesKey(data, "esc") || matchesKey(data, "enter") || matchesKey(data, "return")) {
356
+ this.detailMode = false;
357
+ this.detailId = "";
358
+ this.refreshList();
359
+ this.renderShell();
360
+ return;
361
+ }
362
+ this.invalidate();
363
+ return;
364
+ }
311
365
  if (this.actionMode && this.actionList) {
312
366
  if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
313
367
  this.actionMode = false;
package/src/todo-store.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  } from "node:fs";
19
19
  import { dirname } from "node:path";
20
20
  import { getLivePath, getTodoDir, getLegacyPath } from "./paths.ts";
21
- import { migrateIfNeeded } from "./migrate.ts";
21
+ import { migrateIfNeeded, migrateV2ToV3 } from "./migrate.ts";
22
22
 
23
23
  export type Priority = "low" | "med" | "high" | "critical";
24
24
  export type Status = "open" | "in_progress" | "parked" | "done" | "cancelled";
@@ -31,7 +31,8 @@ const STATUS_SET = new Set(STATUSES);
31
31
 
32
32
  export interface Todo {
33
33
  id: string;
34
- text: string;
34
+ title: string; // ≤120 chars, non-empty, trimmed
35
+ notes: string; // any length, may be ""
35
36
  project: string;
36
37
  tags: string[];
37
38
  priority: Priority;
@@ -43,13 +44,14 @@ export interface Todo {
43
44
  }
44
45
 
45
46
  export interface Store {
46
- version: 2;
47
+ version: 3;
47
48
  updatedAt: string;
48
49
  todos: Todo[];
49
50
  }
50
51
 
51
52
  export interface AddInput {
52
- text: string;
53
+ title: string;
54
+ notes?: string;
53
55
  project?: string;
54
56
  tags?: string[];
55
57
  priority?: Priority;
@@ -57,7 +59,8 @@ export interface AddInput {
57
59
  }
58
60
 
59
61
  export interface UpdateInput {
60
- text?: string;
62
+ title?: string;
63
+ notes?: string;
61
64
  project?: string;
62
65
  tags?: string[];
63
66
  priority?: Priority;
@@ -86,8 +89,19 @@ function genId(): string {
86
89
  return "td-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
87
90
  }
88
91
 
92
+ const TITLE_MAX = 120; // must match the constant in migrate.ts
93
+
94
+ function normalizeTitle(raw: string): string {
95
+ const t = raw.trim();
96
+ if (!t) throw new TodoError("title is required");
97
+ if (t.length > TITLE_MAX) {
98
+ throw new TodoError(`title must be ≤${TITLE_MAX} chars (got ${t.length}); move detail into notes`);
99
+ }
100
+ return t;
101
+ }
102
+
89
103
  function emptyStore(): Store {
90
- return { version: 2, updatedAt: now(), todos: [] };
104
+ return { version: 3, updatedAt: now(), todos: [] };
91
105
  }
92
106
 
93
107
  export function getStorePath(): string {
@@ -105,14 +119,18 @@ export function loadStore(): Store {
105
119
  if (!existsSync(path)) return emptyStore();
106
120
  try {
107
121
  const raw = readFileSync(path, "utf8");
108
- const parsed = JSON.parse(raw) as Store;
122
+ let parsed = JSON.parse(raw) as Store;
109
123
  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.todos)) {
110
124
  throw new Error("invalid store shape");
111
125
  }
112
- if (parsed.version !== 2) {
113
- // v1v2: accept it (the migration moved the file), just bump the version in memory.
114
- // The data shape is otherwise identical; parked status is new but old todos won't have it.
115
- parsed.version = 2;
126
+ if (parsed.version === 2) {
127
+ // v2v3: curated map + fallback, persist once so migration runs a single time.
128
+ const migrated = migrateV2ToV3(parsed as any) as unknown as Store;
129
+ saveStore(migrated);
130
+ return migrated;
131
+ }
132
+ if (parsed.version !== 3) {
133
+ throw new Error("invalid store shape");
116
134
  }
117
135
  return parsed;
118
136
  } catch {
@@ -160,13 +178,14 @@ function findOrFail(store: Store, id: string): Todo {
160
178
  }
161
179
 
162
180
  export function addTodo(input: AddInput): Todo {
163
- const text = (input.text ?? "").trim();
164
- if (!text) throw new TodoError("text is required");
181
+ const title = normalizeTitle(input.title);
165
182
  if (input.priority) assertPriority(input.priority);
183
+ const notes = (input.notes ?? "").trim();
166
184
  const store = loadStore();
167
185
  const todo: Todo = {
168
186
  id: genId(),
169
- text,
187
+ title,
188
+ notes,
170
189
  project: (input.project ?? "").trim(),
171
190
  tags: (input.tags ?? []).map((t) => t.trim()).filter(Boolean),
172
191
  priority: input.priority ?? "med",
@@ -195,7 +214,7 @@ export function listTodos(filter: ListFilter = {}): Todo[] {
195
214
  if (filter.tag) out = out.filter((t) => t.tags.includes(filter.tag as string));
196
215
  if (filter.text) {
197
216
  const q = filter.text.toLowerCase();
198
- out = out.filter((t) => t.text.toLowerCase().includes(q));
217
+ out = out.filter((t) => t.title.toLowerCase().includes(q) || t.notes.toLowerCase().includes(q));
199
218
  }
200
219
  if (filter.since) out = out.filter((t) => t.createdAt >= (filter.since as string));
201
220
  if (filter.before) out = out.filter((t) => t.createdAt < (filter.before as string));
@@ -218,11 +237,8 @@ export function listTodos(filter: ListFilter = {}): Todo[] {
218
237
  export function updateTodo(id: string, patch: UpdateInput): Todo {
219
238
  const store = loadStore();
220
239
  const todo = findOrFail(store, id);
221
- if (patch.text !== undefined) {
222
- const text = patch.text.trim();
223
- if (!text) throw new TodoError("text must not be empty");
224
- todo.text = text;
225
- }
240
+ if (patch.title !== undefined) todo.title = normalizeTitle(patch.title);
241
+ if (patch.notes !== undefined) todo.notes = patch.notes.trim();
226
242
  if (patch.project !== undefined) todo.project = patch.project.trim();
227
243
  if (patch.tags !== undefined) todo.tags = patch.tags.map((t) => t.trim()).filter(Boolean);
228
244
  if (patch.priority !== undefined) {
@@ -242,6 +258,11 @@ export function updateTodo(id: string, patch: UpdateInput): Todo {
242
258
  return todo;
243
259
  }
244
260
 
261
+ export function getTodo(id: string): Todo {
262
+ const store = loadStore();
263
+ return findOrFail(store, id);
264
+ }
265
+
245
266
  export function completeTodo(id: string): Todo {
246
267
  return updateTodo(id, { status: "done" });
247
268
  }
@@ -272,7 +293,8 @@ export function renderOpenBlock(max = 15): string {
272
293
  const lines = shown.map((t) => {
273
294
  const tag = t.project ? ` (${t.project})` : "";
274
295
  const pin = t.status === "in_progress" ? " ⏵" : "";
275
- return `- [${t.id}] (${t.priority})${pin} ${t.text}${tag}`;
296
+ const dot = t.notes.trim() ? " •" : "";
297
+ return `- [${t.id}] (${t.priority})${pin}${dot} ${t.title}${tag}`;
276
298
  });
277
299
  const overflow = todos.length > max ? `\n- … +${todos.length - max} more (use \`todo list\`)` : "";
278
300
  return `## Open TODOs (${todos.length})\n${lines.join("\n")}${overflow}\n`;