project-mgr 0.3.2 → 0.4.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/CHANGELOG.md +14 -0
- package/README.md +22 -0
- package/README_CN.md +54 -1
- package/dist/cli.js +36 -1
- package/dist/db.d.ts +17 -0
- package/dist/db.js +238 -6
- package/dist/domain.d.ts +3 -1
- package/dist/domain.js +6 -0
- package/dist/server.js +76 -1
- package/dist/web/locales/en.js +16 -0
- package/dist/web/locales/zh.d.ts +16 -0
- package/dist/web/locales/zh.js +16 -0
- package/dist/web/script.js +89 -5
- package/dist/web/ui.js +12 -0
- package/package.json +1 -1
- package/skills/project-mgr/SKILL.md +20 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-10-28
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Add independent Todos with optional project/source links, per-item completion, cancellation, reopening, and CLI commands.
|
|
8
|
+
- Create Todos atomically from new next steps and explicitly convert legacy steps without rewriting history.
|
|
9
|
+
- Add dashboard activity completion controls and project-filtered Todo views.
|
|
10
|
+
- Preserve Todo state in version-3 exports and enforce same-origin local JSON writes.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Guide agents to ask before recording multiple sub-projects, either under one project name or with snapshot tags.
|
|
15
|
+
- Sync the Chinese README with CLI help, activity completion, and Todo documentation.
|
|
16
|
+
|
|
3
17
|
## 0.3.2 - 2026-10-16
|
|
4
18
|
|
|
5
19
|
- Add general and per-command CLI help, no-argument help, and package version aliases without opening the database.
|
package/README.md
CHANGED
|
@@ -333,3 +333,25 @@ Activity cards display Completed with a follow-up link and retain original detai
|
|
|
333
333
|
Export now emits a version-2 bundle `{ "version": 2, "entries": [...], "completions": [...] }`. JSONL emits one complete bundle per line. Entries retain UUIDs and recording times; events retain UUIDs and per-target predecessor links, including revocations. Import accepts legacy entry objects/arrays/JSONL and version-2 bundles, checks identity conflicts, and applies each file atomically. Re-importing the same bundle is idempotent. Event order is significant: import events in exported order; divergent histories fail instead of silently merging. Older clients cannot import version-2 bundles. Migration adds UUIDs to existing snapshots without changing their content.
|
|
334
334
|
|
|
335
335
|
Independently migrated copies of legacy data receive different UUIDs. Cross-import may fail on an existing `externalRef`; no implicit identity adoption occurs. Restore a bundle into a fresh database for a faithful migration. Older writers are rejected if they omit UUIDs. With an existing `externalRef`, `add` retains its existing snapshot and applies explicitly supplied `--completes` links to that snapshot; identical active links are no-ops. Use `complete` to make after-the-fact reconciliation explicit.
|
|
336
|
+
|
|
337
|
+
### Todos and dashboard actions
|
|
338
|
+
|
|
339
|
+
New `add --next` values create independent Todos linked to the new activity.
|
|
340
|
+
Historical next steps stay as text until explicitly converted in the dashboard.
|
|
341
|
+
The Todos tab supports standalone creation, optional project assignment, source
|
|
342
|
+
links, completion, cancellation, and reopening. Project cards filter this list.
|
|
343
|
+
Activity cards provide **Mark completed**, recording a follow-up with the current
|
|
344
|
+
project status; this neither completes the project nor its Todos.
|
|
345
|
+
|
|
346
|
+
```sh
|
|
347
|
+
pmgr todo-add --title "Review results" --project demo --json
|
|
348
|
+
pmgr todo-add --title "Unassigned idea" --json
|
|
349
|
+
pmgr todos --project demo --json
|
|
350
|
+
pmgr todo-update --uid <todo-uuid> --status completed --json
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Exports with Todos use version 3 and preserve their identities and state;
|
|
354
|
+
version-2 and legacy imports remain supported without creating new Todos from
|
|
355
|
+
old next-step text. Conflicting Todo snapshots are rejected atomically on import.
|
|
356
|
+
Dashboard writes are restricted to same-origin localhost requests with JSON bodies;
|
|
357
|
+
remote-host dashboards remain read-only.
|
package/README_CN.md
CHANGED
|
@@ -127,6 +127,18 @@ pnpm start history --project test-project
|
|
|
127
127
|
|
|
128
128
|
自动化测试会使用内存数据库或系统临时目录中的 SQLite 文件,不会读写默认数据库。`PMGR_DB_PATH=:memory:` 也可用于单条命令,但每个 CLI 命令都是独立进程,命令结束后数据就会消失,不适合连续手工测试。
|
|
129
129
|
|
|
130
|
+
## CLI 帮助与版本
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
pmgr # 显示通用帮助
|
|
134
|
+
pmgr -h # 也可使用 --help 或 help
|
|
135
|
+
pmgr add --help # 命令用法与可用选项
|
|
136
|
+
pmgr help history # 等价于 history -h
|
|
137
|
+
pmgr -v # 也可使用 -V、--version 或 version
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
帮助与版本命令会成功退出,且不会打开或创建数据库。版本输出来自已安装包的元数据。也支持兼容形式 `pmgr status <command> --help`。
|
|
141
|
+
|
|
130
142
|
## 快速开始
|
|
131
143
|
|
|
132
144
|
追加一条项目状态:
|
|
@@ -186,7 +198,7 @@ pnpm start at \
|
|
|
186
198
|
- `--external-ref`:全局幂等键。重复写入同一项目时返回原记录;若被其他项目占用则报错。
|
|
187
199
|
- `--json`:输出稳定 JSON。
|
|
188
200
|
|
|
189
|
-
|
|
201
|
+
### 项目元数据与模糊查询
|
|
190
202
|
|
|
191
203
|
可通过 `set-project` 设置或更新项目的名称、简介与备注,或使用 `--clear-description` / `--clear-notes` 清空:
|
|
192
204
|
|
|
@@ -298,3 +310,44 @@ pnpm build
|
|
|
298
310
|
```
|
|
299
311
|
|
|
300
312
|
Statements、branches、functions、lines 的覆盖率门槛均为 100%。详细设计见[架构文档](docs/architecture.md)。
|
|
313
|
+
|
|
314
|
+
## 补全更早的动态
|
|
315
|
+
|
|
316
|
+
项目状态和动态是否完成是两回事。关联条目前,先查看 `pmgr history --project demo --json`。`status` 是原始快照;`effectiveStatus` 计入显式的后续完成;`isCurrent` 标识该项目的最新快照。缺少 `completedBy` 并不表示这条动态尚未完成。
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
# 一次更新可完成多条更早的记录(本地数字 ID 或可移植 UUID)。
|
|
320
|
+
pmgr add --project demo --status active --summary "部署已验收" \
|
|
321
|
+
--completes 12 --completes 15 --source agent --json
|
|
322
|
+
# 关联一条已有的后续记录,或不删除历史地纠正错误关联。
|
|
323
|
+
pmgr complete --entry 12 --by 18 --source agent --json
|
|
324
|
+
pmgr revoke-completion --entry 12 --by 18 --reason "验收仍未完成" --source agent --json
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
只关联那些全部未完成工作(包括阻塞和下一步)都已解决的条目。同一项目内的关联要求后续记录的发生时间严格更晚;发生时间相同的关联会被拒绝。每个目标只能有一个有效的完成来源。重复同一关联是空操作;要替换时需先撤销。`add --completes` 会原子写入快照、元数据和关联。已有历史不会自动对账。
|
|
328
|
+
|
|
329
|
+
动态卡片会显示已完成,并链到后续动态;原始详情保留在可展开的「原始记录」中。「当前状态」和「已有后续更新」只表示快照新旧,与是否完成相互独立。`/api/entries/<id-or-uid>` 和 `/?entry=<uid>` 即使不在初始动态页,也能打开一条记录。日期筛选选择的是快照;完成标注反映的是当前结果。`at` 仍然返回不含完成信息的历史项目快照;按历史时点查询完成状态暂不支持。
|
|
330
|
+
|
|
331
|
+
导出现在会输出 version 2 包 `{ "version": 2, "entries": [...], "completions": [...] }`。JSONL 每行输出一个完整包。条目保留 UUID 和记录时间;事件保留 UUID,以及每个目标的前驱链接(含撤销)。导入同时接受旧版条目对象、数组、JSONL 和 version 2 包,会检查身份冲突,并按文件原子应用。重复导入同一包是幂等的。事件顺序有意义:须按导出顺序导入;历史不一致时导入失败,不会静默合并。旧客户端无法导入 version 2 包。迁移会给已有快照补上 UUID,不改变其内容。
|
|
332
|
+
|
|
333
|
+
各自独立迁移的旧数据副本会得到不同的 UUID。跨库导入可能因已存在的 `externalRef` 失败,不会隐式沿用对方身份。要忠实迁移,请把包恢复到一个全新数据库。省略 UUID 的旧写入会被拒绝。若 `externalRef` 已存在,`add` 会保留原快照,并把显式传入的 `--completes` 关联应用到该快照;相同且仍然有效的关联是空操作。事后补关联请使用 `complete`,让对账显式发生。
|
|
334
|
+
|
|
335
|
+
### 待办与仪表板操作
|
|
336
|
+
|
|
337
|
+
新的 `add --next` 会创建独立待办,并关联到这条新动态。
|
|
338
|
+
历史中的下一步仍只是文本,直到在仪表板里显式转为待办。
|
|
339
|
+
待办页支持单独创建、可选关联项目、查看来源动态、标为已完成、取消和重新打开。项目卡片可以筛选这份列表。
|
|
340
|
+
动态卡片提供 **标为已完成**,会用当前项目状态记一条后续动态;这既不会把项目标成完成,也不会完成它的待办。
|
|
341
|
+
|
|
342
|
+
```sh
|
|
343
|
+
pmgr todo-add --title "复查结果" --project demo --json
|
|
344
|
+
pmgr todo-add --title "未归属的想法" --json
|
|
345
|
+
pmgr todos --project demo --json
|
|
346
|
+
pmgr todo-update --uid <todo-uuid> --status completed --json
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
带待办的导出使用 version 3,并保留待办的身份与状态;
|
|
350
|
+
version 2 和旧版导入仍然可用,且不会把旧的下一步文本自动变成新待办。
|
|
351
|
+
冲突的待办快照会在导入时整批拒绝。
|
|
352
|
+
仪表板写入仅接受同源的 localhost JSON 请求;
|
|
353
|
+
绑定到远程主机时,仪表板保持只读。
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { basename } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
|
-
import { addStatus, at, current, history, importLedger, exportLedger, changeCompletion, listProjects, openLedger, parseTime, setProjectMetadata, startServer, ValidationError, } from "./index.js";
|
|
5
|
+
import { addStatus, createTodo, listTodos, updateTodo, at, current, history, importLedger, exportLedger, changeCompletion, listProjects, openLedger, parseTime, setProjectMetadata, startServer, ValidationError, } from "./index.js";
|
|
6
6
|
const booleanFlags = new Set([
|
|
7
7
|
"json",
|
|
8
8
|
"clear-description",
|
|
@@ -67,6 +67,9 @@ const allowedFlags = new Map([
|
|
|
67
67
|
"json",
|
|
68
68
|
]),
|
|
69
69
|
],
|
|
70
|
+
["todo-add", new Set(["title", "project", "entry", "json"])],
|
|
71
|
+
["todos", new Set(["project", "json"])],
|
|
72
|
+
["todo-update", new Set(["uid", "status", "project", "json"])],
|
|
70
73
|
["complete", new Set(["entry", "by", "reason", "source", "json"])],
|
|
71
74
|
["revoke-completion", new Set(["entry", "by", "reason", "source", "json"])],
|
|
72
75
|
["import", new Set(["file", "json"])],
|
|
@@ -94,6 +97,15 @@ const allowedFlags = new Map([
|
|
|
94
97
|
["serve", new Set(["port", "host", "open", "no-open", "json"])],
|
|
95
98
|
]);
|
|
96
99
|
const commandHelp = {
|
|
100
|
+
"todo-add": [
|
|
101
|
+
"Create an independent or linked Todo",
|
|
102
|
+
"--title <text> [--project <name>] [--entry <uid>] [--json]",
|
|
103
|
+
],
|
|
104
|
+
todos: ["List Todos", "[--project <name>] [--json]"],
|
|
105
|
+
"todo-update": [
|
|
106
|
+
"Change Todo status or assign a project",
|
|
107
|
+
"--uid <uuid> [--status open|completed|cancelled] [--project <name>] [--json]",
|
|
108
|
+
],
|
|
97
109
|
add: [
|
|
98
110
|
"Append a status snapshot",
|
|
99
111
|
"--project <name> --summary <text> [options]",
|
|
@@ -289,6 +301,29 @@ export function run(argv, write = process.stdout.write.bind(process.stdout)) {
|
|
|
289
301
|
const json = flags.json === true;
|
|
290
302
|
try {
|
|
291
303
|
switch (action) {
|
|
304
|
+
case "todo-add":
|
|
305
|
+
output(createTodo(db, {
|
|
306
|
+
title: stringFlag(flags, "title", true),
|
|
307
|
+
project: stringFlag(flags, "project"),
|
|
308
|
+
entryUid: stringFlag(flags, "entry"),
|
|
309
|
+
}), json, write);
|
|
310
|
+
break;
|
|
311
|
+
case "todos": {
|
|
312
|
+
const todos = listTodos(db, stringFlag(flags, "project"));
|
|
313
|
+
if (json)
|
|
314
|
+
output(todos, true, write);
|
|
315
|
+
else
|
|
316
|
+
write(todos
|
|
317
|
+
.map((t) => `${t.uid}\t${t.status}\t${t.project ?? "-"}\t${t.title}`)
|
|
318
|
+
.join("\n") + "\n");
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "todo-update":
|
|
322
|
+
output(updateTodo(db, stringFlag(flags, "uid", true), {
|
|
323
|
+
status: stringFlag(flags, "status"),
|
|
324
|
+
project: stringFlag(flags, "project"),
|
|
325
|
+
}), json, write);
|
|
326
|
+
break;
|
|
292
327
|
case "add": {
|
|
293
328
|
const project = stringFlag(flags, "project", true);
|
|
294
329
|
const description = stringFlag(flags, "description");
|
package/dist/db.d.ts
CHANGED
|
@@ -36,11 +36,13 @@ export type CompletionEvent = {
|
|
|
36
36
|
export declare function getEntry(db: Ledger, ref: string): StatusEntry | undefined;
|
|
37
37
|
export declare function changeCompletion(db: Ledger, targetRef: string, byRef: string, action?: CompletionEvent["action"], reason?: string, source?: Source): CompletionEvent;
|
|
38
38
|
export declare function exportLedger(db: Ledger, project?: string): {
|
|
39
|
+
todos?: Todo[] | undefined;
|
|
39
40
|
version: number;
|
|
40
41
|
entries: {
|
|
41
42
|
recordedAt: string;
|
|
42
43
|
progress?: string | undefined;
|
|
43
44
|
externalRef?: string | undefined;
|
|
45
|
+
nextStepsSourceUid?: string | undefined;
|
|
44
46
|
uid: string;
|
|
45
47
|
project: string;
|
|
46
48
|
status: import("./domain.js").ProjectStatus;
|
|
@@ -55,3 +57,18 @@ export declare function exportLedger(db: Ledger, project?: string): {
|
|
|
55
57
|
completions: CompletionEvent[];
|
|
56
58
|
};
|
|
57
59
|
export declare function importLedger(db: Ledger, values: unknown[]): InsertResult[];
|
|
60
|
+
export type Todo = {
|
|
61
|
+
uid: string;
|
|
62
|
+
title: string;
|
|
63
|
+
project: string | null;
|
|
64
|
+
entryUid: string | null;
|
|
65
|
+
stepIndex: number | null;
|
|
66
|
+
status: "open" | "completed" | "cancelled";
|
|
67
|
+
createdAt: string;
|
|
68
|
+
completedAt: string | null;
|
|
69
|
+
};
|
|
70
|
+
export declare function listTodos(db: Ledger, project?: string): Todo[];
|
|
71
|
+
export declare function createTodo(db: Ledger, value: unknown): Todo;
|
|
72
|
+
export declare function convertNextStep(db: Ledger, ref: string, index: number): Todo;
|
|
73
|
+
export declare function updateTodo(db: Ledger, uid: string, value: unknown): Todo;
|
|
74
|
+
export declare function completeActivity(db: Ledger, ref: string): StatusEntry;
|
package/dist/db.js
CHANGED
|
@@ -75,6 +75,19 @@ export function openLedger(path = databasePath()) {
|
|
|
75
75
|
BEGIN SELECT RAISE(ABORT, 'entry uid required; upgrade the writer'); END;`);
|
|
76
76
|
db.prepare("INSERT INTO schema_migrations(version) VALUES (3)").run();
|
|
77
77
|
}
|
|
78
|
+
if (!db.prepare("SELECT 1 FROM schema_migrations WHERE version = 4").get()) {
|
|
79
|
+
db.exec(`ALTER TABLE status_entries ADD COLUMN next_steps_source_uid TEXT REFERENCES status_entries(uid);
|
|
80
|
+
CREATE TABLE todos (
|
|
81
|
+
uid TEXT PRIMARY KEY, title TEXT NOT NULL,
|
|
82
|
+
project TEXT REFERENCES projects(slug),
|
|
83
|
+
entry_uid TEXT REFERENCES status_entries(uid),
|
|
84
|
+
step_index INTEGER, status TEXT NOT NULL CHECK(status IN ('open','completed','cancelled')),
|
|
85
|
+
created_at TEXT NOT NULL, completed_at TEXT,
|
|
86
|
+
UNIQUE(entry_uid, step_index)
|
|
87
|
+
);
|
|
88
|
+
CREATE INDEX idx_todos_project ON todos(project);
|
|
89
|
+
INSERT INTO schema_migrations(version) VALUES (4);`);
|
|
90
|
+
}
|
|
78
91
|
}).immediate();
|
|
79
92
|
return db;
|
|
80
93
|
}
|
|
@@ -88,6 +101,9 @@ function entryFromRow(row) {
|
|
|
88
101
|
...(row.progress ? { progress: row.progress } : {}),
|
|
89
102
|
blockers: JSON.parse(row.blockers_json),
|
|
90
103
|
nextSteps: JSON.parse(row.next_steps_json),
|
|
104
|
+
...(row.next_steps_source_uid
|
|
105
|
+
? { nextStepsSourceUid: row.next_steps_source_uid }
|
|
106
|
+
: {}),
|
|
91
107
|
decisions: JSON.parse(row.decisions_json),
|
|
92
108
|
tags: JSON.parse(row.tags_json),
|
|
93
109
|
occurredAt: row.occurred_at,
|
|
@@ -204,6 +220,8 @@ export function addStatus(db, value, defaultSource = "human", completes = []) {
|
|
|
204
220
|
return db
|
|
205
221
|
.transaction(() => {
|
|
206
222
|
const result = addValidatedStatus(db, input);
|
|
223
|
+
if (result.inserted && !result.entry.nextStepsSourceUid)
|
|
224
|
+
result.entry.nextSteps.forEach((_, index) => convertNextStep(db, result.entry.uid, index));
|
|
207
225
|
for (const target of completes)
|
|
208
226
|
changeCompletion(db, target, result.entry.uid, "complete", undefined, input.source);
|
|
209
227
|
return result;
|
|
@@ -235,15 +253,24 @@ function addValidatedStatus(db, input, recordedAt) {
|
|
|
235
253
|
return { entry: existing, inserted: false };
|
|
236
254
|
}
|
|
237
255
|
}
|
|
256
|
+
if (input.nextStepsSourceUid) {
|
|
257
|
+
const origin = getEntry(db, input.nextStepsSourceUid);
|
|
258
|
+
if (!origin ||
|
|
259
|
+
origin.nextStepsSourceUid !== undefined ||
|
|
260
|
+
origin.project !== input.project ||
|
|
261
|
+
origin.occurredAt >= input.occurredAt ||
|
|
262
|
+
!isDeepStrictEqual(origin.nextSteps, input.nextSteps))
|
|
263
|
+
throw new ValidationError("invalid next step provenance");
|
|
264
|
+
}
|
|
238
265
|
const now = recordedAt ?? new Date().toISOString();
|
|
239
266
|
upsertProjectMetadata(db, { project: input.project });
|
|
240
267
|
const project = db
|
|
241
268
|
.prepare("SELECT id FROM projects WHERE slug = ?")
|
|
242
269
|
.get(input.project);
|
|
243
270
|
const result = db
|
|
244
|
-
.prepare(`INSERT INTO status_entries(project_id, status, summary, progress, blockers_json, next_steps_json, decisions_json, tags_json, occurred_at, recorded_at, source, external_ref, uid)
|
|
245
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
246
|
-
.run(project.id, input.status, input.summary, input.progress ?? null, JSON.stringify(input.blockers), JSON.stringify(input.nextSteps), JSON.stringify(input.decisions), JSON.stringify(input.tags), input.occurredAt, now, input.source, input.externalRef ?? null, input.uid ?? randomUUID());
|
|
271
|
+
.prepare(`INSERT INTO status_entries(project_id, status, summary, progress, blockers_json, next_steps_json, decisions_json, tags_json, occurred_at, recorded_at, source, external_ref, uid, next_steps_source_uid)
|
|
272
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
273
|
+
.run(project.id, input.status, input.summary, input.progress ?? null, JSON.stringify(input.blockers), JSON.stringify(input.nextSteps), JSON.stringify(input.decisions), JSON.stringify(input.tags), input.occurredAt, now, input.source, input.externalRef ?? null, input.uid ?? randomUUID(), input.nextStepsSourceUid ?? null);
|
|
247
274
|
return {
|
|
248
275
|
entry: one(db, `${select} WHERE e.id = ?`, result.lastInsertRowid),
|
|
249
276
|
inserted: true,
|
|
@@ -423,7 +450,17 @@ export function exportLedger(db, project) {
|
|
|
423
450
|
const rows = db
|
|
424
451
|
.prepare(`SELECT c.* FROM completion_events c JOIN status_entries e ON e.uid = c.target_uid JOIN projects p ON p.id = e.project_id ${project ? "WHERE p.slug = ?" : ""} ORDER BY c.id`)
|
|
425
452
|
.all(...(project ? [project] : []));
|
|
426
|
-
|
|
453
|
+
const todos = listTodos(db, project);
|
|
454
|
+
return {
|
|
455
|
+
version: todos.length || entries.some((entry) => entry.nextStepsSourceUid)
|
|
456
|
+
? 3
|
|
457
|
+
: 2,
|
|
458
|
+
entries,
|
|
459
|
+
completions: rows.map(completionFromRow),
|
|
460
|
+
...(todos.length || entries.some((entry) => entry.nextStepsSourceUid)
|
|
461
|
+
? { todos }
|
|
462
|
+
: {}),
|
|
463
|
+
};
|
|
427
464
|
})();
|
|
428
465
|
}
|
|
429
466
|
function record(value, keys) {
|
|
@@ -475,8 +512,15 @@ export function importLedger(db, values) {
|
|
|
475
512
|
const results = [];
|
|
476
513
|
for (const value of values) {
|
|
477
514
|
if (value && typeof value === "object" && "version" in value) {
|
|
478
|
-
const bundle = record(value, [
|
|
479
|
-
|
|
515
|
+
const bundle = record(value, [
|
|
516
|
+
"version",
|
|
517
|
+
"entries",
|
|
518
|
+
"completions",
|
|
519
|
+
"todos",
|
|
520
|
+
]);
|
|
521
|
+
if ((bundle.version !== 2 && bundle.version !== 3) ||
|
|
522
|
+
(bundle.version === 3 && !Array.isArray(bundle.todos)) ||
|
|
523
|
+
(bundle.version === 2 && bundle.todos !== undefined) ||
|
|
480
524
|
!Array.isArray(bundle.entries) ||
|
|
481
525
|
!Array.isArray(bundle.completions))
|
|
482
526
|
throw new ValidationError("invalid ledger bundle");
|
|
@@ -489,6 +533,9 @@ export function importLedger(db, values) {
|
|
|
489
533
|
}
|
|
490
534
|
for (const event of bundle.completions)
|
|
491
535
|
insertCompletion(db, validateCompletionEvent(event));
|
|
536
|
+
if (bundle.version === 3)
|
|
537
|
+
for (const todo of bundle.todos)
|
|
538
|
+
importTodo(db, todo);
|
|
492
539
|
}
|
|
493
540
|
else
|
|
494
541
|
results.push(addValidatedStatus(db, validateStatusEntry(value, "import")));
|
|
@@ -497,3 +544,188 @@ export function importLedger(db, values) {
|
|
|
497
544
|
})
|
|
498
545
|
.immediate();
|
|
499
546
|
}
|
|
547
|
+
const todoSelect = `SELECT uid, title, project, entry_uid AS entryUid,
|
|
548
|
+
step_index AS stepIndex, status, created_at AS createdAt, completed_at AS completedAt FROM todos`;
|
|
549
|
+
export function listTodos(db, project) {
|
|
550
|
+
return db
|
|
551
|
+
.prepare(todoSelect +
|
|
552
|
+
(project ? " WHERE project = ?" : "") +
|
|
553
|
+
" ORDER BY created_at, uid")
|
|
554
|
+
.all(...(project ? [project] : []));
|
|
555
|
+
}
|
|
556
|
+
function insertTodo(db, todo) {
|
|
557
|
+
db.prepare(`INSERT INTO todos(uid,title,project,entry_uid,step_index,status,created_at,completed_at)
|
|
558
|
+
VALUES (?,?,?,?,?,?,?,?)`).run(todo.uid, todo.title, todo.project, todo.entryUid, todo.stepIndex, todo.status, todo.createdAt, todo.completedAt);
|
|
559
|
+
return todo;
|
|
560
|
+
}
|
|
561
|
+
export function createTodo(db, value) {
|
|
562
|
+
const input = record(value, ["title", "project", "entryUid"]);
|
|
563
|
+
if (typeof input.title !== "string" ||
|
|
564
|
+
!input.title.trim() ||
|
|
565
|
+
input.title.trim().length > 2000)
|
|
566
|
+
throw new ValidationError("title must contain 1 to 2000 characters");
|
|
567
|
+
let project = null;
|
|
568
|
+
let entryUid = null;
|
|
569
|
+
if (input.project != null) {
|
|
570
|
+
project = validateProjectMetadataInput({ project: input.project }).project;
|
|
571
|
+
if (!db.prepare("SELECT 1 FROM projects WHERE slug = ?").get(project))
|
|
572
|
+
throw new ValidationError("project not found");
|
|
573
|
+
}
|
|
574
|
+
if (input.entryUid != null) {
|
|
575
|
+
const entry = getEntry(db, validateUid(input.entryUid));
|
|
576
|
+
if (!entry)
|
|
577
|
+
throw new ValidationError("entry not found");
|
|
578
|
+
if (project && project !== entry.project)
|
|
579
|
+
throw new ValidationError("source project mismatch");
|
|
580
|
+
project = entry.project;
|
|
581
|
+
entryUid = entry.uid;
|
|
582
|
+
}
|
|
583
|
+
return insertTodo(db, {
|
|
584
|
+
uid: randomUUID(),
|
|
585
|
+
title: input.title.trim(),
|
|
586
|
+
project,
|
|
587
|
+
entryUid,
|
|
588
|
+
stepIndex: null,
|
|
589
|
+
status: "open",
|
|
590
|
+
createdAt: new Date().toISOString(),
|
|
591
|
+
completedAt: null,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
export function convertNextStep(db, ref, index) {
|
|
595
|
+
return db
|
|
596
|
+
.transaction(() => {
|
|
597
|
+
const entry = getEntry(db, ref);
|
|
598
|
+
if (!entry ||
|
|
599
|
+
!Number.isInteger(index) ||
|
|
600
|
+
index < 0 ||
|
|
601
|
+
index >= entry.nextSteps.length)
|
|
602
|
+
throw new ValidationError("next step not found");
|
|
603
|
+
if (entry.nextStepsSourceUid)
|
|
604
|
+
return convertNextStep(db, entry.nextStepsSourceUid, index);
|
|
605
|
+
const existing = db
|
|
606
|
+
.prepare(todoSelect + " WHERE entry_uid = ? AND step_index = ?")
|
|
607
|
+
.get(entry.uid, index);
|
|
608
|
+
if (existing)
|
|
609
|
+
return existing;
|
|
610
|
+
return insertTodo(db, {
|
|
611
|
+
uid: randomUUID(),
|
|
612
|
+
title: entry.nextSteps[index],
|
|
613
|
+
project: entry.project,
|
|
614
|
+
entryUid: entry.uid,
|
|
615
|
+
stepIndex: index,
|
|
616
|
+
status: "open",
|
|
617
|
+
createdAt: new Date().toISOString(),
|
|
618
|
+
completedAt: null,
|
|
619
|
+
});
|
|
620
|
+
})
|
|
621
|
+
.immediate();
|
|
622
|
+
}
|
|
623
|
+
export function updateTodo(db, uid, value) {
|
|
624
|
+
validateUid(uid);
|
|
625
|
+
const input = record(value, ["status", "project"]);
|
|
626
|
+
return db
|
|
627
|
+
.transaction(() => {
|
|
628
|
+
const todo = db.prepare(todoSelect + " WHERE uid = ?").get(uid);
|
|
629
|
+
if (!todo)
|
|
630
|
+
throw new ValidationError("todo not found");
|
|
631
|
+
if (input.status !== undefined) {
|
|
632
|
+
if (!["open", "completed", "cancelled"].includes(input.status))
|
|
633
|
+
throw new ValidationError("invalid todo status");
|
|
634
|
+
if (todo.status !== input.status) {
|
|
635
|
+
todo.status = input.status;
|
|
636
|
+
todo.completedAt =
|
|
637
|
+
todo.status === "completed" ? new Date().toISOString() : null;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (input.project !== undefined) {
|
|
641
|
+
if (todo.entryUid)
|
|
642
|
+
throw new ValidationError("source-linked todo project cannot change");
|
|
643
|
+
todo.project =
|
|
644
|
+
input.project === null
|
|
645
|
+
? null
|
|
646
|
+
: validateProjectMetadataInput({ project: input.project }).project;
|
|
647
|
+
if (todo.project &&
|
|
648
|
+
!db.prepare("SELECT 1 FROM projects WHERE slug = ?").get(todo.project))
|
|
649
|
+
throw new ValidationError("project not found");
|
|
650
|
+
}
|
|
651
|
+
db.prepare("UPDATE todos SET status=?, completed_at=?, project=? WHERE uid=?").run(todo.status, todo.completedAt, todo.project, uid);
|
|
652
|
+
return todo;
|
|
653
|
+
})
|
|
654
|
+
.immediate();
|
|
655
|
+
}
|
|
656
|
+
function importTodo(db, value) {
|
|
657
|
+
const input = record(value, [
|
|
658
|
+
"uid",
|
|
659
|
+
"title",
|
|
660
|
+
"project",
|
|
661
|
+
"entryUid",
|
|
662
|
+
"stepIndex",
|
|
663
|
+
"status",
|
|
664
|
+
"createdAt",
|
|
665
|
+
"completedAt",
|
|
666
|
+
]);
|
|
667
|
+
validateUid(input.uid);
|
|
668
|
+
if (typeof input.title !== "string" ||
|
|
669
|
+
!input.title.trim() ||
|
|
670
|
+
input.title.length > 2000)
|
|
671
|
+
throw new ValidationError("invalid todo title");
|
|
672
|
+
if (!["open", "completed", "cancelled"].includes(input.status))
|
|
673
|
+
throw new ValidationError("invalid todo status");
|
|
674
|
+
parseTime(input.createdAt, "createdAt");
|
|
675
|
+
if (input.status === "completed")
|
|
676
|
+
parseTime(input.completedAt, "completedAt");
|
|
677
|
+
else if (input.completedAt !== null)
|
|
678
|
+
throw new ValidationError("invalid completion timestamp");
|
|
679
|
+
if (input.project !== null) {
|
|
680
|
+
validateProjectMetadataInput({ project: input.project });
|
|
681
|
+
upsertProjectMetadata(db, { project: input.project });
|
|
682
|
+
}
|
|
683
|
+
if (input.entryUid !== null) {
|
|
684
|
+
const entry = getEntry(db, validateUid(input.entryUid));
|
|
685
|
+
if (!entry || entry.project !== input.project)
|
|
686
|
+
throw new ValidationError("invalid todo source");
|
|
687
|
+
if (input.stepIndex !== null &&
|
|
688
|
+
(!Number.isInteger(input.stepIndex) ||
|
|
689
|
+
Number(input.stepIndex) < 0 ||
|
|
690
|
+
entry.nextSteps[Number(input.stepIndex)] !== input.title))
|
|
691
|
+
throw new ValidationError("invalid next step index");
|
|
692
|
+
}
|
|
693
|
+
else if (input.stepIndex !== null)
|
|
694
|
+
throw new ValidationError("next step requires a source");
|
|
695
|
+
const todo = input;
|
|
696
|
+
const existing = db.prepare(todoSelect + " WHERE uid = ?").get(todo.uid);
|
|
697
|
+
if (existing) {
|
|
698
|
+
if (!isDeepStrictEqual(existing, todo))
|
|
699
|
+
throw new ValidationError("conflicting todo uid");
|
|
700
|
+
}
|
|
701
|
+
else
|
|
702
|
+
insertTodo(db, todo);
|
|
703
|
+
}
|
|
704
|
+
export function completeActivity(db, ref) {
|
|
705
|
+
return db
|
|
706
|
+
.transaction(() => {
|
|
707
|
+
const entry = getEntry(db, ref);
|
|
708
|
+
if (!entry)
|
|
709
|
+
throw new ValidationError("entry not found");
|
|
710
|
+
if (entry.effectiveStatus === "completed")
|
|
711
|
+
return entry;
|
|
712
|
+
const latest = current(db, entry.project)[0];
|
|
713
|
+
const occurredAt = new Date(Math.max(Date.now(), Date.parse(latest.occurredAt) + 1)).toISOString();
|
|
714
|
+
// Preserve current context without generating another set of next-step Todos.
|
|
715
|
+
const followUp = addValidatedStatus(db, validateStatusEntry({
|
|
716
|
+
project: entry.project,
|
|
717
|
+
status: latest.status,
|
|
718
|
+
summary: `Completed activity #${entry.id}`,
|
|
719
|
+
progress: latest.progress,
|
|
720
|
+
blockers: latest.blockers,
|
|
721
|
+
nextSteps: latest.nextSteps,
|
|
722
|
+
nextStepsSourceUid: latest.nextStepsSourceUid ?? latest.uid,
|
|
723
|
+
decisions: latest.decisions,
|
|
724
|
+
tags: latest.tags,
|
|
725
|
+
occurredAt,
|
|
726
|
+
}));
|
|
727
|
+
changeCompletion(db, entry.uid, followUp.entry.uid);
|
|
728
|
+
return getEntry(db, entry.uid);
|
|
729
|
+
})
|
|
730
|
+
.immediate();
|
|
731
|
+
}
|
package/dist/domain.d.ts
CHANGED
|
@@ -26,13 +26,15 @@ export type StatusEntryInput = {
|
|
|
26
26
|
progress?: string;
|
|
27
27
|
blockers?: string[];
|
|
28
28
|
nextSteps?: string[];
|
|
29
|
+
nextStepsSourceUid?: string;
|
|
29
30
|
decisions?: string[];
|
|
30
31
|
tags?: string[];
|
|
31
32
|
occurredAt?: string;
|
|
32
33
|
source?: Source;
|
|
33
34
|
externalRef?: string;
|
|
34
35
|
};
|
|
35
|
-
export type StatusEntry = Required<Omit<StatusEntryInput, "progress" | "externalRef">> & {
|
|
36
|
+
export type StatusEntry = Required<Omit<StatusEntryInput, "progress" | "externalRef" | "nextStepsSourceUid">> & {
|
|
37
|
+
nextStepsSourceUid?: string;
|
|
36
38
|
id: number;
|
|
37
39
|
progress?: string;
|
|
38
40
|
externalRef?: string;
|
package/dist/domain.js
CHANGED
|
@@ -16,6 +16,7 @@ const keys = new Set([
|
|
|
16
16
|
"progress",
|
|
17
17
|
"blockers",
|
|
18
18
|
"nextSteps",
|
|
19
|
+
"nextStepsSourceUid",
|
|
19
20
|
"decisions",
|
|
20
21
|
"tags",
|
|
21
22
|
"occurredAt",
|
|
@@ -128,6 +129,9 @@ export function validateStatusEntry(value, defaultSource = "human") {
|
|
|
128
129
|
progress: text(input.progress, "progress"),
|
|
129
130
|
blockers: textList(input.blockers, "blockers"),
|
|
130
131
|
nextSteps: textList(input.nextSteps, "nextSteps"),
|
|
132
|
+
...(input.nextStepsSourceUid === undefined
|
|
133
|
+
? {}
|
|
134
|
+
: { nextStepsSourceUid: validateUid(input.nextStepsSourceUid) }),
|
|
131
135
|
decisions: textList(input.decisions, "decisions"),
|
|
132
136
|
tags: textList(input.tags, "tags"),
|
|
133
137
|
occurredAt: input.occurredAt === undefined
|
|
@@ -150,6 +154,8 @@ export function toPortableStatusEntry(entry) {
|
|
|
150
154
|
occurredAt: entry.occurredAt,
|
|
151
155
|
source: entry.source,
|
|
152
156
|
};
|
|
157
|
+
if (entry.nextStepsSourceUid !== undefined)
|
|
158
|
+
result.nextStepsSourceUid = entry.nextStepsSourceUid;
|
|
153
159
|
if (entry.progress !== undefined)
|
|
154
160
|
result.progress = entry.progress;
|
|
155
161
|
if (entry.externalRef !== undefined)
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
2
|
import { createServer, } from "node:http";
|
|
3
3
|
import { URL } from "node:url";
|
|
4
|
-
import { countEntries, getEntry, history, listProjects, openLedger, } from "./db.js";
|
|
4
|
+
import { countEntries, createTodo, listTodos, updateTodo, convertNextStep, completeActivity, getEntry, history, listProjects, openLedger, } from "./db.js";
|
|
5
|
+
import { ValidationError } from "./domain.js";
|
|
5
6
|
import { getWebUIHTML } from "./web/ui.js";
|
|
6
7
|
export function openBrowser(url) {
|
|
7
8
|
const platform = process.platform;
|
|
@@ -34,6 +35,11 @@ function sendJson(res, statusCode, data) {
|
|
|
34
35
|
res.end(JSON.stringify(data));
|
|
35
36
|
}
|
|
36
37
|
export function handleRequest(req, res, db) {
|
|
38
|
+
if (req.method === "POST" &&
|
|
39
|
+
/^\/api\/(todos(?:\/[^/]+)?|entries\/[^/]+\/(complete|todos))$/.test((req.url || "").split("?")[0])) {
|
|
40
|
+
void handleMutation(req, res, db);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
37
43
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
38
44
|
sendJson(res, 405, { error: "Method not allowed" });
|
|
39
45
|
return;
|
|
@@ -61,6 +67,10 @@ export function handleRequest(req, res, db) {
|
|
|
61
67
|
res.end(getWebUIHTML());
|
|
62
68
|
return;
|
|
63
69
|
}
|
|
70
|
+
if (pathname === "/api/todos") {
|
|
71
|
+
sendJson(res, 200, listTodos(db, query.project));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
64
74
|
if (pathname === "/api/projects") {
|
|
65
75
|
const projects = listProjects(db, { query: query.query });
|
|
66
76
|
sendJson(res, 200, projects);
|
|
@@ -162,3 +172,68 @@ export function startServer(options = {}) {
|
|
|
162
172
|
});
|
|
163
173
|
});
|
|
164
174
|
}
|
|
175
|
+
async function handleMutation(req, res, db) {
|
|
176
|
+
try {
|
|
177
|
+
const host = req.headers.host;
|
|
178
|
+
if (!host ||
|
|
179
|
+
!/^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(host) ||
|
|
180
|
+
(req.headers.origin !== undefined &&
|
|
181
|
+
req.headers.origin !== `http://${host}`) ||
|
|
182
|
+
(req.headers["sec-fetch-site"] !== undefined &&
|
|
183
|
+
!["same-origin", "none"].includes(String(req.headers["sec-fetch-site"])))) {
|
|
184
|
+
sendJson(res, 403, {
|
|
185
|
+
error: "Only same-origin local writes are allowed",
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (req.headers["content-type"] !== "application/json") {
|
|
190
|
+
sendJson(res, 415, { error: "JSON required" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const chunks = [];
|
|
194
|
+
let size = 0;
|
|
195
|
+
for await (const chunk of req) {
|
|
196
|
+
const bytes = Buffer.from(chunk);
|
|
197
|
+
size += bytes.length;
|
|
198
|
+
if (size > 32768) {
|
|
199
|
+
sendJson(res, 413, { error: "Request too large" });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
chunks.push(bytes);
|
|
203
|
+
}
|
|
204
|
+
const input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
205
|
+
const parts = new URL(req.url, `http://${host}`).pathname
|
|
206
|
+
.split("/")
|
|
207
|
+
.map(decodeURIComponent);
|
|
208
|
+
let result;
|
|
209
|
+
if (parts[2] === "todos")
|
|
210
|
+
result = parts[3]
|
|
211
|
+
? updateTodo(db, parts[3], input)
|
|
212
|
+
: createTodo(db, input);
|
|
213
|
+
else {
|
|
214
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
215
|
+
throw new ValidationError("expected an object");
|
|
216
|
+
const data = input;
|
|
217
|
+
if (parts[4] === "complete") {
|
|
218
|
+
if (Object.keys(data).length)
|
|
219
|
+
throw new ValidationError("unexpected fields");
|
|
220
|
+
result = completeActivity(db, parts[3]);
|
|
221
|
+
}
|
|
222
|
+
else if (Object.keys(data).length === 1 && "index" in data)
|
|
223
|
+
result = convertNextStep(db, parts[3], data.index);
|
|
224
|
+
else {
|
|
225
|
+
if (Object.keys(data).some((key) => key !== "title"))
|
|
226
|
+
throw new ValidationError("unexpected fields");
|
|
227
|
+
result = createTodo(db, { title: data.title, entryUid: parts[3] });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
sendJson(res, 200, result);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
sendJson(res, error instanceof ValidationError ||
|
|
234
|
+
error instanceof SyntaxError ||
|
|
235
|
+
error instanceof URIError
|
|
236
|
+
? 400
|
|
237
|
+
: 500, { error: error instanceof Error ? error.message : "Request failed" });
|
|
238
|
+
}
|
|
239
|
+
}
|
package/dist/web/locales/en.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
export const en = {
|
|
2
|
+
savedRefreshError: "Saved, but the list could not refresh. Refresh before retrying.",
|
|
3
|
+
todos: "Todos",
|
|
4
|
+
todoTitle: "Todo title",
|
|
5
|
+
addTodo: "Add Todo",
|
|
6
|
+
complete: "Mark completed",
|
|
7
|
+
convertTodo: "Convert to Todo",
|
|
8
|
+
noTodos: "No Todos",
|
|
9
|
+
noProject: "No project",
|
|
10
|
+
sourceEntry: "Source activity",
|
|
11
|
+
reopen: "Reopen",
|
|
12
|
+
cancelTodo: "Cancel Todo",
|
|
13
|
+
todo_open: "Open",
|
|
14
|
+
todo_completed: "Completed",
|
|
15
|
+
todo_cancelled: "Cancelled",
|
|
16
|
+
viewTodos: "View Todos",
|
|
17
|
+
chooseProject: "Assign project",
|
|
2
18
|
htmlLang: "en",
|
|
3
19
|
pageTitle: "Project Manager - Projects & Activity Feed",
|
|
4
20
|
appTitle: "Project Manager",
|
package/dist/web/locales/zh.d.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
export declare const zh: {
|
|
2
|
+
readonly savedRefreshError: "已保存,但列表刷新失败。请先刷新页面,不要重复创建。";
|
|
3
|
+
readonly todos: "待办";
|
|
4
|
+
readonly todoTitle: "待办内容";
|
|
5
|
+
readonly addTodo: "新增待办";
|
|
6
|
+
readonly complete: "标为已完成";
|
|
7
|
+
readonly convertTodo: "转为待办";
|
|
8
|
+
readonly noTodos: "暂无待办";
|
|
9
|
+
readonly noProject: "不关联项目";
|
|
10
|
+
readonly sourceEntry: "来源动态";
|
|
11
|
+
readonly reopen: "重新打开";
|
|
12
|
+
readonly cancelTodo: "取消待办";
|
|
13
|
+
readonly todo_open: "待办";
|
|
14
|
+
readonly todo_completed: "已完成";
|
|
15
|
+
readonly todo_cancelled: "已取消";
|
|
16
|
+
readonly viewTodos: "查看待办";
|
|
17
|
+
readonly chooseProject: "关联项目";
|
|
2
18
|
readonly htmlLang: "zh-CN";
|
|
3
19
|
readonly pageTitle: "项目管理 - 项目与动态概览";
|
|
4
20
|
readonly appTitle: "项目管理";
|
package/dist/web/locales/zh.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
export const zh = {
|
|
2
|
+
savedRefreshError: "已保存,但列表刷新失败。请先刷新页面,不要重复创建。",
|
|
3
|
+
todos: "待办",
|
|
4
|
+
todoTitle: "待办内容",
|
|
5
|
+
addTodo: "新增待办",
|
|
6
|
+
complete: "标为已完成",
|
|
7
|
+
convertTodo: "转为待办",
|
|
8
|
+
noTodos: "暂无待办",
|
|
9
|
+
noProject: "不关联项目",
|
|
10
|
+
sourceEntry: "来源动态",
|
|
11
|
+
reopen: "重新打开",
|
|
12
|
+
cancelTodo: "取消待办",
|
|
13
|
+
todo_open: "待办",
|
|
14
|
+
todo_completed: "已完成",
|
|
15
|
+
todo_cancelled: "已取消",
|
|
16
|
+
viewTodos: "查看待办",
|
|
17
|
+
chooseProject: "关联项目",
|
|
2
18
|
htmlLang: "zh-CN",
|
|
3
19
|
pageTitle: "项目管理 - 项目与动态概览",
|
|
4
20
|
appTitle: "项目管理",
|
package/dist/web/script.js
CHANGED
|
@@ -2,11 +2,13 @@ import { getClientI18nScript } from "./i18n.js";
|
|
|
2
2
|
export function getWebUIScript(initialLang) {
|
|
3
3
|
return `${getClientI18nScript(initialLang)}
|
|
4
4
|
|
|
5
|
+
let todosData = [];
|
|
5
6
|
let projectsData = [];
|
|
6
7
|
let historyData = [];
|
|
7
8
|
let focusedEntry = null;
|
|
8
9
|
let activeProjectFilter = null;
|
|
9
10
|
let activeGroupMode = 'grouped';
|
|
11
|
+
let initialNavigation = true;
|
|
10
12
|
|
|
11
13
|
function getTodayDateString() {
|
|
12
14
|
const now = new Date();
|
|
@@ -32,13 +34,15 @@ export function getWebUIScript(initialLang) {
|
|
|
32
34
|
|
|
33
35
|
async function fetchData() {
|
|
34
36
|
try {
|
|
35
|
-
const [projRes, histRes, statsRes] = await Promise.all([
|
|
37
|
+
const [projRes, histRes, statsRes, todosRes] = await Promise.all([
|
|
36
38
|
fetch('/api/projects'),
|
|
37
39
|
fetch('/api/history?limit=1000'),
|
|
38
|
-
fetch('/api/stats')
|
|
40
|
+
fetch('/api/stats'),
|
|
41
|
+
fetch('/api/todos')
|
|
39
42
|
]);
|
|
40
43
|
|
|
41
|
-
if (projRes.ok)
|
|
44
|
+
if (![projRes, histRes, statsRes, todosRes].every(response => response.ok)) throw new Error(t('entryLoadError'));
|
|
45
|
+
projectsData = await projRes.json();
|
|
42
46
|
if (histRes.ok) historyData = await histRes.json();
|
|
43
47
|
if (statsRes.ok) {
|
|
44
48
|
const stats = await statsRes.json();
|
|
@@ -47,24 +51,32 @@ export function getWebUIScript(initialLang) {
|
|
|
47
51
|
document.getElementById('statUpdates').textContent = stats.totalEntries;
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
if (!todosRes.ok) throw new Error('Todos unavailable');
|
|
55
|
+
todosData = await todosRes.json();
|
|
50
56
|
render();
|
|
51
57
|
if (typeof location !== 'undefined') {
|
|
52
58
|
const ref = new URLSearchParams(location.search).get('entry');
|
|
53
59
|
if (ref) {
|
|
54
|
-
switchTab('activity');
|
|
60
|
+
if (initialNavigation) switchTab('activity');
|
|
55
61
|
const response = await fetch('/api/entries/' + encodeURIComponent(ref));
|
|
56
62
|
if (!response.ok) throw new Error('Entry unavailable');
|
|
57
63
|
focusedEntry = await response.json();
|
|
58
64
|
render();
|
|
59
65
|
}
|
|
60
66
|
}
|
|
67
|
+
initialNavigation = false;
|
|
68
|
+
return true;
|
|
61
69
|
} catch (err) {
|
|
70
|
+
document.getElementById('actionError').textContent = t('entryLoadError');
|
|
62
71
|
console.error('Failed to load data', err);
|
|
63
72
|
document.getElementById('timelineContainer').textContent = t('entryLoadError');
|
|
73
|
+
return false;
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
function switchTab(tab) {
|
|
78
|
+
document.getElementById('tabTodos').classList.toggle('active', tab === 'todos');
|
|
79
|
+
document.getElementById('viewTodos').classList.toggle('active', tab === 'todos');
|
|
68
80
|
document.getElementById('tabProjects').classList.toggle('active', tab === 'projects');
|
|
69
81
|
document.getElementById('tabActivity').classList.toggle('active', tab === 'activity');
|
|
70
82
|
document.getElementById('viewProjects').classList.toggle('active', tab === 'projects');
|
|
@@ -176,7 +188,7 @@ export function getWebUIScript(initialLang) {
|
|
|
176
188
|
<div class="section-box">
|
|
177
189
|
<div class="section-title next-steps">\${escapeHtml(t('nextStepsTitle'))}</div>
|
|
178
190
|
<ul class="bullet-list">
|
|
179
|
-
\${item.nextSteps.map(n => \`<li>\${escapeHtml(n)}</li>\`).join('')}
|
|
191
|
+
\${item.nextSteps.map((n, index) => todosData.some(todo => todo.entryUid === (item.nextStepsSourceUid || item.uid) && todo.stepIndex === index) ? '' : \`<li>\${escapeHtml(n)} <button class="btn btn-sm" data-action="convert-todo" data-entry="\${escapeHtml(item.uid)}" data-index="\${index}">\${escapeHtml(t('convertTodo'))}</button></li>\`).join('')}
|
|
180
192
|
</ul>
|
|
181
193
|
</div>
|
|
182
194
|
\` : ''}
|
|
@@ -196,6 +208,11 @@ export function getWebUIScript(initialLang) {
|
|
|
196
208
|
</div>
|
|
197
209
|
\` : ''}
|
|
198
210
|
\${item.completedBy ? '</details>' : ''}
|
|
211
|
+
<div class="section-box">
|
|
212
|
+
<button class="btn" data-action="add-todo" data-entry="\${escapeHtml(item.uid)}">\${escapeHtml(t('addTodo'))}</button>
|
|
213
|
+
\${status !== 'completed' ? \`<button class="btn" data-action="complete-entry" data-entry="\${escapeHtml(item.uid)}">\${escapeHtml(t('complete'))}</button>\` : ''}
|
|
214
|
+
\${todosData.filter(todo => todo.entryUid === item.uid || todo.entryUid === item.nextStepsSourceUid).map(renderTodo).join('')}
|
|
215
|
+
</div>
|
|
199
216
|
</div>
|
|
200
217
|
\`;
|
|
201
218
|
}
|
|
@@ -214,6 +231,7 @@ export function getWebUIScript(initialLang) {
|
|
|
214
231
|
const query = document.getElementById('searchInput').value.trim().toLowerCase();
|
|
215
232
|
const selectedDate = document.getElementById('activityDateInput').value;
|
|
216
233
|
|
|
234
|
+
renderTodos(query);
|
|
217
235
|
// Render Active Filter Chip
|
|
218
236
|
const filterBar = document.getElementById('filterBarContainer');
|
|
219
237
|
const chip = document.getElementById('activeFilterChip');
|
|
@@ -271,6 +289,7 @@ export function getWebUIScript(initialLang) {
|
|
|
271
289
|
</div>
|
|
272
290
|
<div class="card-footer">
|
|
273
291
|
<span>\${escapeHtml(t('createdLabel'))} \${formatTime(p.createdAt)}</span>
|
|
292
|
+
<button class="btn" data-action="project-todos" data-project="\${escapeHtml(p.project)}">\${escapeHtml(t('viewTodos'))}</button>
|
|
274
293
|
<a class="link-btn" data-action="filter-project" data-project="\${escapeHtml(p.project)}">\${escapeHtml(t('viewActivity'))}</a>
|
|
275
294
|
</div>
|
|
276
295
|
</div>
|
|
@@ -352,5 +371,70 @@ export function getWebUIScript(initialLang) {
|
|
|
352
371
|
.replace(/'/g, ''');
|
|
353
372
|
}
|
|
354
373
|
|
|
374
|
+
|
|
375
|
+
function projectOptions(selected) {
|
|
376
|
+
return '<option value="">' + escapeHtml(t('noProject')) + '</option>' + projectsData.map(p =>
|
|
377
|
+
'<option value="' + escapeHtml(p.project) + '"' + (selected === p.project ? ' selected' : '') + '>' + escapeHtml(p.displayName) + '</option>').join('');
|
|
378
|
+
}
|
|
379
|
+
function renderTodo(todo) {
|
|
380
|
+
return '<div class="section-box"><strong>' + escapeHtml(todo.title) + '</strong> · ' + escapeHtml(t('todo_' + todo.status)) +
|
|
381
|
+
(todo.project ? ' · ' + escapeHtml(todo.project) : '') +
|
|
382
|
+
(todo.entryUid ? ' <a class="link-btn" href="?entry=' + encodeURIComponent(todo.entryUid) + '">' + escapeHtml(t('sourceEntry')) + '</a>' :
|
|
383
|
+
' <label>' + escapeHtml(t('chooseProject')) + ' <select data-todo-project="' + escapeHtml(todo.uid) + '">' + projectOptions(todo.project) + '</select></label>') +
|
|
384
|
+
' <button class="btn" data-action="todo-status" data-uid="' + escapeHtml(todo.uid) + '" data-status="' + (todo.status === 'open' ? 'completed' : 'open') + '">' + escapeHtml(t(todo.status === 'open' ? 'complete' : 'reopen')) + '</button>' +
|
|
385
|
+
(todo.status === 'open' ? ' <button class="btn" data-action="todo-status" data-uid="' + escapeHtml(todo.uid) + '" data-status="cancelled">' + escapeHtml(t('cancelTodo')) + '</button>' : '') + '</div>';
|
|
386
|
+
}
|
|
387
|
+
function renderTodos(query) {
|
|
388
|
+
const select = document.getElementById('todoProject');
|
|
389
|
+
select.innerHTML = projectOptions(select.value);
|
|
390
|
+
const todos = todosData.filter(todo => (!activeProjectFilter || todo.project === activeProjectFilter) &&
|
|
391
|
+
(!query || (todo.title + ' ' + (todo.project || '')).toLowerCase().includes(query)));
|
|
392
|
+
document.getElementById('todosContainer').innerHTML = todos.length ? todos.map(renderTodo).join('') : '<p>' + escapeHtml(t('noTodos')) + '</p>';
|
|
393
|
+
}
|
|
394
|
+
async function mutate(path, data) {
|
|
395
|
+
const response = await fetch(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data)});
|
|
396
|
+
const result = await response.json();
|
|
397
|
+
if (!response.ok) throw new Error(result.error || 'Request failed');
|
|
398
|
+
if (!await fetchData()) { const error = new Error(t('savedRefreshError')); error.saved = true; throw error; }
|
|
399
|
+
}
|
|
400
|
+
async function runAction(element, action) {
|
|
401
|
+
if (element.disabled) return;
|
|
402
|
+
element.disabled = true;
|
|
403
|
+
document.getElementById('actionError').textContent = '';
|
|
404
|
+
try { await action(); }
|
|
405
|
+
catch (error) { document.getElementById('actionError').textContent = error.message; }
|
|
406
|
+
finally { element.disabled = false; }
|
|
407
|
+
}
|
|
408
|
+
document.getElementById('todoForm').addEventListener('submit', function(event) {
|
|
409
|
+
event.preventDefault();
|
|
410
|
+
const button = this.querySelector('button');
|
|
411
|
+
runAction(button, async () => {
|
|
412
|
+
try {
|
|
413
|
+
await mutate('/api/todos', {title: document.getElementById('todoTitle').value, project: document.getElementById('todoProject').value || null});
|
|
414
|
+
document.getElementById('todoTitle').value = '';
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error.saved) document.getElementById('todoTitle').value = '';
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
document.addEventListener('change', function(event) {
|
|
422
|
+
const select = event.target.closest('[data-todo-project]');
|
|
423
|
+
if (select) runAction(select, () => mutate('/api/todos/' + select.dataset.todoProject, {project: select.value || null}));
|
|
424
|
+
});
|
|
425
|
+
document.addEventListener('click', function(event) {
|
|
426
|
+
const button = event.target.closest('[data-action]');
|
|
427
|
+
if (!button) return;
|
|
428
|
+
const data = button.dataset;
|
|
429
|
+
if (data.action === 'project-todos') { activeProjectFilter = data.project; switchTab('todos'); render(); document.getElementById('todoProject').value = data.project; return; }
|
|
430
|
+
if (data.action === 'complete-entry') runAction(button, () => mutate('/api/entries/' + data.entry + '/complete', {}));
|
|
431
|
+
if (data.action === 'convert-todo') runAction(button, () => mutate('/api/entries/' + data.entry + '/todos', {index: Number(data.index)}));
|
|
432
|
+
if (data.action === 'todo-status') runAction(button, () => mutate('/api/todos/' + data.uid, {status: data.status}));
|
|
433
|
+
if (data.action === 'add-todo') {
|
|
434
|
+
const title = window.prompt(t('todoTitle'));
|
|
435
|
+
if (title && title.trim()) runAction(button, () => mutate('/api/entries/' + data.entry + '/todos', {title}));
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
355
439
|
fetchData();`;
|
|
356
440
|
}
|
package/dist/web/ui.js
CHANGED
|
@@ -45,6 +45,7 @@ ${WEB_UI_STYLES}
|
|
|
45
45
|
<div class="nav-tabs">
|
|
46
46
|
<button class="tab-btn active" id="tabProjects" data-i18n="projects" onclick="switchTab('projects')">${dict.projects}</button>
|
|
47
47
|
<button class="tab-btn" id="tabActivity" data-i18n="activityFeed" onclick="switchTab('activity')">${dict.activityFeed}</button>
|
|
48
|
+
<button class="tab-btn" id="tabTodos" data-i18n="todos" onclick="switchTab('todos')">${dict.todos}</button>
|
|
48
49
|
</div>
|
|
49
50
|
<button class="btn" id="btnRefresh" data-i18n="refresh" onclick="refreshData()">${dict.refresh}</button>
|
|
50
51
|
</div>
|
|
@@ -54,6 +55,17 @@ ${WEB_UI_STYLES}
|
|
|
54
55
|
<span id="labelFilterByProject" data-i18n="filteringByProject">${dict.filteringByProject}</span> <span id="activeFilterChip" class="filter-chip"></span>
|
|
55
56
|
</div>
|
|
56
57
|
|
|
58
|
+
<div id="actionError" role="alert"></div>
|
|
59
|
+
<div id="viewTodos" class="view-panel">
|
|
60
|
+
<form id="todoForm" class="section-box">
|
|
61
|
+
<label for="todoTitle" data-i18n="todoTitle">${dict.todoTitle}</label>
|
|
62
|
+
<input id="todoTitle" class="search-input" required maxlength="2000" />
|
|
63
|
+
<label for="todoProject" data-i18n="projects">${dict.projects}</label>
|
|
64
|
+
<select id="todoProject" class="date-input"></select>
|
|
65
|
+
<button class="btn" type="submit" data-i18n="addTodo">${dict.addTodo}</button>
|
|
66
|
+
</form>
|
|
67
|
+
<div id="todosContainer"></div>
|
|
68
|
+
</div>
|
|
57
69
|
<!-- Projects View -->
|
|
58
70
|
<div id="viewProjects" class="view-panel active">
|
|
59
71
|
<div id="projectsContainer" class="projects-grid"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: project-mgr
|
|
3
|
-
description: Manage project status with the local-first project-mgr CLI. Use when an AI agent needs to add an append-only project update, set project descriptions/notes, list or fuzzy-search projects, start the local Web Dashboard, import status records, query current or historical project state, or export a status ledger.
|
|
3
|
+
description: Manage project status with the local-first project-mgr CLI. Use when an AI agent needs to add an append-only project update, set project descriptions/notes, list or fuzzy-search projects, start the local Web Dashboard, import status records, query current or historical project state, or export a status ledger. When one effort contains multiple sub-projects, ask whether they share one project name or should be distinguished with tags.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# project-mgr
|
|
@@ -23,6 +23,7 @@ Completion links and version-2 exports require project-mgr 0.3.0 or later. This
|
|
|
23
23
|
- Set `--source agent` for agent-authored updates. Use `--external-ref` only when a stable idempotency key is available.
|
|
24
24
|
- Explicitly pass `--status` on every `add`, and include `status` in every agent-authored import entry. The CLI defaults to `completed`; do not rely on that default. Choose the status from the evidence and inspect the current snapshot when unclear. Completing one activity does not necessarily complete the project; use `active` when project work continues. If the status remains uncertain, clarify before writing.
|
|
25
25
|
- If the project is not yet in the ledger database, initialize its metadata with `set-project`. Backfill only milestones supported by verifiable Git commits, dated documents, or historical records, and include the evidence reference in `--progress`. Use historical `--at` timestamps only when supported by that evidence; a commit timestamp proves a commit, not a deployment. There is no required number of entries. Never invent milestones or dates; when evidence is unavailable, skip backfill and append only the current known status.
|
|
26
|
+
- If one effort contains multiple sub-projects and the user has not already chosen how to identify them, ask before writing. See "Multiple sub-projects".
|
|
26
27
|
|
|
27
28
|
## Metadata vs. status entries
|
|
28
29
|
|
|
@@ -31,7 +32,20 @@ Completion links and version-2 exports require project-mgr 0.3.0 or later. This
|
|
|
31
32
|
- **Do NOT** put transient progress, session logs, or one-off status messages into `--notes`.
|
|
32
33
|
- Avoid passing `--notes` during routine `add` commands to prevent accidentally overwriting long-term project notes.
|
|
33
34
|
- **Project description (`--description`)**: A high-level, stable summary of the project's purpose.
|
|
34
|
-
- **Status snapshots (`add`)**: Use `--summary` (concise milestone headline), `--progress` (detailed work accomplished), `--next` (immediate next steps), `--blocker` (current obstacles), and `--
|
|
35
|
+
- **Status snapshots (`add`)**: Use `--summary` (concise milestone headline), `--progress` (detailed work accomplished), `--next` (immediate next steps), `--blocker` (current obstacles), `--decision`, and repeatable `--tag` for transactional, append-only updates.
|
|
36
|
+
|
|
37
|
+
## Multiple sub-projects
|
|
38
|
+
|
|
39
|
+
One repository or effort may contain several sub-projects, such as separate packages, apps, services, or contracts. `--project` is the ledger identity: current status, history, and completion links all belong to that slug. `--tag` is a repeatable label on a single status snapshot. Tags do not create a project, a separate current status, or a separate completion history. `projects` / `list --query` can match tags on the latest snapshot.
|
|
40
|
+
|
|
41
|
+
When several sub-projects are in scope and the user has not already chosen how to identify them, ask before the first write. Do not decide silently. Do not create a separate project slug per sub-project unless the user explicitly asks for separate ledger projects.
|
|
42
|
+
|
|
43
|
+
Offer these two choices:
|
|
44
|
+
|
|
45
|
+
1. **Share one project name.** Append every sub-project to the same `--project`. Use this when the user wants one current status and one history for the whole effort. Do not add sub-project tags unless the user also asks for them.
|
|
46
|
+
2. **Distinguish sub-projects with tags.** Keep that same `--project`, and pass repeatable `--tag` values naming the sub-project this snapshot covers, for example `--project acme --tag web`. Reuse the same tag on later updates for that sub-project. A tag describes that snapshot only; older snapshots keep the tags they were written with. Search with `pmgr list --query web --json`. Completion links still apply to the whole project, so link an earlier entry only when this update resolves all of that entry's commitments.
|
|
47
|
+
|
|
48
|
+
Follow a choice the user already recorded. Do not store the choice in `--notes` unless the user asks; notes are long-term project context, and `set-project --notes` replaces them.
|
|
35
49
|
|
|
36
50
|
## Operations
|
|
37
51
|
|
|
@@ -53,6 +67,10 @@ pmgr serve --port 3000 --no-open --json
|
|
|
53
67
|
pmgr add --project my-project --status active --summary "Implemented the CLI" \
|
|
54
68
|
--progress "Tests pass" --next "Publish the package" --source agent --json
|
|
55
69
|
|
|
70
|
+
# Same project, with a tag naming the sub-project this snapshot covers.
|
|
71
|
+
pmgr add --project acme --status active --summary "Shipped the web dashboard" \
|
|
72
|
+
--tag web --source agent --json
|
|
73
|
+
|
|
56
74
|
# Query current state, history, or state at a time.
|
|
57
75
|
pmgr current --project my-project --json
|
|
58
76
|
pmgr history --project my-project --limit 20 --json
|