@stevecastaneda/paseo-progress 0.1.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/LICENSE +21 -0
- package/README.md +109 -0
- package/client/attachments.tsx +95 -0
- package/client/dashboard-query.ts +32 -0
- package/client/deliverables.tsx +67 -0
- package/client/dialog-stack.tsx +104 -0
- package/client/directory.ts +90 -0
- package/client/launcher.tsx +111 -0
- package/client/motion.tsx +173 -0
- package/client/panel-opened.ts +13 -0
- package/client/panel.tsx +480 -0
- package/client/pill-popover.tsx +122 -0
- package/client/pill-state.ts +34 -0
- package/client/pills.tsx +235 -0
- package/client/preview.tsx +228 -0
- package/client/questions.tsx +248 -0
- package/client/row.tsx +34 -0
- package/client/section-title.tsx +24 -0
- package/client/show-more.tsx +37 -0
- package/client/spinner.tsx +35 -0
- package/client/status-badge.tsx +30 -0
- package/client/surfaces.ts +45 -0
- package/client/ticket-dialog.tsx +203 -0
- package/client/when.tsx +15 -0
- package/dist/server/cli.js +528 -0
- package/dist/server/launcher.js +90 -0
- package/dist/server/lock.js +34 -0
- package/dist/server/paths.js +6 -0
- package/dist/server/progress-file.js +123 -0
- package/dist/shared/attachments.js +86 -0
- package/dist/shared/dashboard.js +369 -0
- package/dist/shared/events.js +117 -0
- package/dist/shared/format.js +37 -0
- package/dist/shared/show.js +72 -0
- package/images/1-panel.png +0 -0
- package/images/2-ticket.png +0 -0
- package/images/3-question.png +0 -0
- package/images/4-pill.png +0 -0
- package/index.client.tsx +33 -0
- package/index.server.ts +19 -0
- package/npm-shrinkwrap.json +4041 -0
- package/package.json +62 -0
- package/paseo-plugin.json +7 -0
- package/server/cli.ts +541 -0
- package/server/dashboard.ts +46 -0
- package/server/launcher.ts +97 -0
- package/server/lock.ts +32 -0
- package/server/open.ts +107 -0
- package/server/paths.ts +7 -0
- package/server/progress-file.ts +141 -0
- package/server/skill.ts +85 -0
- package/shared/attachments.ts +100 -0
- package/shared/dashboard.ts +491 -0
- package/shared/events.ts +131 -0
- package/shared/format.ts +39 -0
- package/shared/rpc.ts +109 -0
- package/shared/show.ts +68 -0
- package/shared/ticket-story.ts +93 -0
- package/skills/paseo-progress/SKILL.md +79 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Everything that reads or writes a worktree's progress files: the event log
|
|
2
|
+
// the command appends to, the panel-opened marker, and the .gitignore that keeps
|
|
3
|
+
// both out of git. The command and the panel's RPC handlers go through here, so
|
|
4
|
+
// they always agree on where the files are and how they're read.
|
|
5
|
+
import { access, appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
|
+
import { EMPTY_DASHBOARD, parseProgress, reduceProgress } from "../shared/dashboard.js";
|
|
8
|
+
import { EVENT_VERSION, eventSchema, PANEL_OPENED_FILE, PROGRESS_FILE } from "../shared/events.js";
|
|
9
|
+
import { withLock } from "./lock.js";
|
|
10
|
+
const SCRATCH_DIR = dirname(PROGRESS_FILE);
|
|
11
|
+
const LOCK_FILE = `${PROGRESS_FILE}.lock`;
|
|
12
|
+
// Names only this plugin's files, so they stay out of git without the user
|
|
13
|
+
// editing their own .gitignore. Other files in .scratch/ are left to the repo.
|
|
14
|
+
export const SCRATCH_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
|
|
15
|
+
${basename(PROGRESS_FILE)}
|
|
16
|
+
${basename(LOCK_FILE)}/
|
|
17
|
+
${basename(PANEL_OPENED_FILE)}
|
|
18
|
+
.gitignore
|
|
19
|
+
`;
|
|
20
|
+
// A draft the event schema rejects. The message names each bad field.
|
|
21
|
+
export class InvalidEvent extends Error {
|
|
22
|
+
}
|
|
23
|
+
// The parsed file per worktree, kept until its size or change time moves. The
|
|
24
|
+
// file only grows, and the panel and pills read it every few seconds.
|
|
25
|
+
const parsedFiles = new Map();
|
|
26
|
+
// Replaced file snapshots and their derived data can be collected together.
|
|
27
|
+
const dashboards = new WeakMap();
|
|
28
|
+
// The dashboard at `now`, for the panel, the pills and `show`. Repeated reads
|
|
29
|
+
// reuse the last one until the file changes or a stale or overdue time passes.
|
|
30
|
+
// Stale and overdue checks use the daemon host's clock, the same clock the
|
|
31
|
+
// command stamps events with.
|
|
32
|
+
export async function readProgress(root, now = new Date()) {
|
|
33
|
+
const parsed = await readParsed(root);
|
|
34
|
+
const panelOpened = await exists(join(root, PANEL_OPENED_FILE));
|
|
35
|
+
if (parsed === null)
|
|
36
|
+
return { configured: false, dashboard: EMPTY_DASHBOARD, panelOpened };
|
|
37
|
+
let cached = dashboards.get(parsed);
|
|
38
|
+
// A backwards clock jump must also re-evaluate stale/overdue state.
|
|
39
|
+
if (!cached || now.getTime() < cached.from || now.getTime() >= cached.until) {
|
|
40
|
+
const { dashboard, validUntil } = reduceProgress(parsed, now);
|
|
41
|
+
cached = { dashboard, from: now.getTime(), until: validUntil };
|
|
42
|
+
dashboards.set(parsed, cached);
|
|
43
|
+
}
|
|
44
|
+
return { configured: true, dashboard: cached.dashboard, panelOpened };
|
|
45
|
+
}
|
|
46
|
+
// Appends the one event `write` builds from the file as it is now. Commands
|
|
47
|
+
// that run at once take turns, so each sees the others' events and no two hand
|
|
48
|
+
// out the same id. `write` may throw to append nothing.
|
|
49
|
+
export async function appendProgress(root, now, write) {
|
|
50
|
+
await prepareScratch(root);
|
|
51
|
+
return withLock(join(root, LOCK_FILE), async () => {
|
|
52
|
+
const text = (await readText(root)) ?? "";
|
|
53
|
+
const { dashboard, nextIds } = reduceProgress(parseProgress(text), now());
|
|
54
|
+
const { event, result } = write({ dashboard, nextIds });
|
|
55
|
+
const checked = eventSchema.safeParse({ v: EVENT_VERSION, ts: now().toISOString(), ...withoutUndefined(event) });
|
|
56
|
+
if (!checked.success) {
|
|
57
|
+
throw new InvalidEvent(checked.error.issues.map((issue) => `${issue.path.join(".") || "value"}: ${issue.message}`).join("; "));
|
|
58
|
+
}
|
|
59
|
+
// A line cut short (a killed command, a hand edit) would swallow this event, so start a new line.
|
|
60
|
+
const lead = text && !text.endsWith("\n") ? "\n" : "";
|
|
61
|
+
await appendFile(join(root, PROGRESS_FILE), `${lead}${JSON.stringify(checked.data)}\n`);
|
|
62
|
+
return result;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export async function markPanelOpened(root) {
|
|
66
|
+
await prepareScratch(root);
|
|
67
|
+
await writeFile(join(root, PANEL_OPENED_FILE), "");
|
|
68
|
+
}
|
|
69
|
+
// Creates .scratch/ in the worktree and, the first time, its .gitignore. An
|
|
70
|
+
// existing .gitignore is the user's, so it is never touched.
|
|
71
|
+
async function prepareScratch(root) {
|
|
72
|
+
const directory = join(root, SCRATCH_DIR);
|
|
73
|
+
await mkdir(directory, { recursive: true });
|
|
74
|
+
await writeFile(join(directory, ".gitignore"), SCRATCH_GITIGNORE, { flag: "wx" }).catch((error) => {
|
|
75
|
+
if (error.code !== "EEXIST")
|
|
76
|
+
throw error;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function readText(root) {
|
|
80
|
+
try {
|
|
81
|
+
return await readFile(join(root, PROGRESS_FILE), "utf8");
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code === "ENOENT")
|
|
85
|
+
return null;
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function readParsed(root) {
|
|
90
|
+
const info = await stat(join(root, PROGRESS_FILE)).catch((error) => {
|
|
91
|
+
if (error.code === "ENOENT")
|
|
92
|
+
return null;
|
|
93
|
+
throw error;
|
|
94
|
+
});
|
|
95
|
+
if (!info) {
|
|
96
|
+
parsedFiles.delete(root);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const cached = parsedFiles.get(root);
|
|
100
|
+
if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs && cached.ctimeMs === info.ctimeMs)
|
|
101
|
+
return cached.parsed;
|
|
102
|
+
// Stat before reading: if the file grows in between, the next stat won't match and it's read again.
|
|
103
|
+
const text = await readText(root);
|
|
104
|
+
if (text === null) {
|
|
105
|
+
parsedFiles.delete(root);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const parsed = parseProgress(text);
|
|
109
|
+
parsedFiles.set(root, { size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, parsed });
|
|
110
|
+
return parsed;
|
|
111
|
+
}
|
|
112
|
+
async function exists(path) {
|
|
113
|
+
try {
|
|
114
|
+
await access(path);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function withoutUndefined(value) {
|
|
122
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
123
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export function deliverableAttachment(deliverable) {
|
|
2
|
+
return { ref: deliverable.id, title: deliverable.title, path: deliverable.path, url: deliverable.url, kind: deliverable.kind };
|
|
3
|
+
}
|
|
4
|
+
export function questionAttachments(question) {
|
|
5
|
+
return question.files.map((file, index) => ({
|
|
6
|
+
ref: `${question.id}.${index + 1}`,
|
|
7
|
+
title: file.label ?? (file.path ?? file.url ?? "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "",
|
|
8
|
+
path: file.path,
|
|
9
|
+
url: file.url,
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
// The attachment a reference names, or null when it names nothing.
|
|
13
|
+
export function findAttachment(dashboard, ref) {
|
|
14
|
+
const question = ref.match(/^(Q\d+)\./);
|
|
15
|
+
if (question) {
|
|
16
|
+
const found = [...dashboard.questions.open, ...dashboard.questions.answered].find((candidate) => candidate.id === question[1]);
|
|
17
|
+
return (found && questionAttachments(found).find((attachment) => attachment.ref === ref)) ?? null;
|
|
18
|
+
}
|
|
19
|
+
const deliverable = dashboard.deliverables.find((candidate) => candidate.id === ref);
|
|
20
|
+
return deliverable ? deliverableAttachment(deliverable) : null;
|
|
21
|
+
}
|
|
22
|
+
const IMAGE_TYPES = {
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".jpg": "image/jpeg",
|
|
25
|
+
".jpeg": "image/jpeg",
|
|
26
|
+
".gif": "image/gif",
|
|
27
|
+
".webp": "image/webp",
|
|
28
|
+
".svg": "image/svg+xml",
|
|
29
|
+
};
|
|
30
|
+
// Plain text shown as-is in the preview dialog, and opened in the browser on
|
|
31
|
+
// the host. HTML is left to the browser either way.
|
|
32
|
+
const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".mdx", ".txt", ".log", ".json", ".jsonl", ".yaml", ".yml", ".csv"]);
|
|
33
|
+
function extension(path) {
|
|
34
|
+
return path.toLowerCase().match(/\.[a-z0-9]+$/)?.[0] ?? null;
|
|
35
|
+
}
|
|
36
|
+
// The image type of a path by its extension, or null when it isn't an image.
|
|
37
|
+
export function imageMimeType(path) {
|
|
38
|
+
return IMAGE_TYPES[extension(path) ?? ""] ?? null;
|
|
39
|
+
}
|
|
40
|
+
export function previewKind(path) {
|
|
41
|
+
if (imageMimeType(path))
|
|
42
|
+
return "image";
|
|
43
|
+
return TEXT_EXTENSIONS.has(extension(path) ?? "") ? "text" : null;
|
|
44
|
+
}
|
|
45
|
+
// Whether the preview dialog can show it. Only a browser draws SVG: Paseo's
|
|
46
|
+
// desktop app can, the phone apps' image view can't, so there an SVG opens on
|
|
47
|
+
// the host like any other file.
|
|
48
|
+
export function canPreview(attachment, where) {
|
|
49
|
+
if (!attachment.path)
|
|
50
|
+
return false;
|
|
51
|
+
const kind = previewKind(attachment.path);
|
|
52
|
+
if (kind === "image" && extension(attachment.path) === ".svg")
|
|
53
|
+
return where.browser;
|
|
54
|
+
return kind !== null;
|
|
55
|
+
}
|
|
56
|
+
const KIND_ICON = {
|
|
57
|
+
file: "File",
|
|
58
|
+
folder: "Folder",
|
|
59
|
+
report: "FileChartColumn",
|
|
60
|
+
screenshot: "Image",
|
|
61
|
+
link: "Globe",
|
|
62
|
+
};
|
|
63
|
+
// One icon per attachment, the same on every screen. What the agent called a
|
|
64
|
+
// deliverable wins; otherwise the file itself decides.
|
|
65
|
+
export function attachmentIcon(attachment) {
|
|
66
|
+
if (attachment.kind && attachment.kind !== "file")
|
|
67
|
+
return KIND_ICON[attachment.kind];
|
|
68
|
+
if (attachment.url)
|
|
69
|
+
return "Globe";
|
|
70
|
+
if (!attachment.path)
|
|
71
|
+
return "File";
|
|
72
|
+
const kind = previewKind(attachment.path);
|
|
73
|
+
return kind === "image" ? "Image" : kind === "text" ? "FileText" : /[\/]$/.test(attachment.path) ? "Folder" : "File";
|
|
74
|
+
}
|
|
75
|
+
// The short name people recognize: a file or folder's own name, or a link's site.
|
|
76
|
+
export function shortName(attachment) {
|
|
77
|
+
if (attachment.url) {
|
|
78
|
+
try {
|
|
79
|
+
return new URL(attachment.url).host.replace(/^www\./, "");
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return attachment.url;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return (attachment.path ?? "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "";
|
|
86
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { EVENT_VERSION, eventSchema } from "./events.js";
|
|
2
|
+
// Reads the JSONL text. A last line without a newline may be half written by
|
|
3
|
+
// a command that is still running, so it is skipped without an issue.
|
|
4
|
+
export function parseProgress(text) {
|
|
5
|
+
const events = [];
|
|
6
|
+
const issues = [];
|
|
7
|
+
// Whether or not the text ends in a newline, the last split piece is either
|
|
8
|
+
// empty or unfinished.
|
|
9
|
+
text.split("\n").slice(0, -1).forEach((raw, index) => {
|
|
10
|
+
const line = index + 1;
|
|
11
|
+
if (!raw.trim())
|
|
12
|
+
return;
|
|
13
|
+
let json;
|
|
14
|
+
try {
|
|
15
|
+
json = JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
issues.push({ line, reason: "not valid JSON" });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const parsed = eventSchema.safeParse(json);
|
|
22
|
+
if (parsed.success)
|
|
23
|
+
events.push({ line, event: parsed.data });
|
|
24
|
+
else
|
|
25
|
+
issues.push({ line, reason: describeInvalid(json) });
|
|
26
|
+
});
|
|
27
|
+
return { events, issues };
|
|
28
|
+
}
|
|
29
|
+
function describeInvalid(json) {
|
|
30
|
+
const version = typeof json === "object" && json !== null && "v" in json ? json.v : undefined;
|
|
31
|
+
if (typeof version === "number" && version !== EVENT_VERSION)
|
|
32
|
+
return `written by a newer version of the plugin (v${version})`;
|
|
33
|
+
const type = typeof json === "object" && json !== null && "type" in json ? String(json.type) : null;
|
|
34
|
+
return type ? `invalid ${type} event` : "not a progress event";
|
|
35
|
+
}
|
|
36
|
+
export const STALE_AFTER_MIN = 15;
|
|
37
|
+
// Every time-dependent rule asks `reached(at)` instead of comparing with `now`
|
|
38
|
+
// itself. Each answer also records the moment it would flip, so `validUntil`
|
|
39
|
+
// is the first millisecond at which the same events can produce a different
|
|
40
|
+
// dashboard, and a new rule can't forget to tell the cache.
|
|
41
|
+
function clock(now) {
|
|
42
|
+
let validUntil = Infinity;
|
|
43
|
+
return {
|
|
44
|
+
reached(at) {
|
|
45
|
+
if (now.getTime() >= at)
|
|
46
|
+
return true;
|
|
47
|
+
validUntil = Math.min(validUntil, at);
|
|
48
|
+
return false;
|
|
49
|
+
},
|
|
50
|
+
get validUntil() {
|
|
51
|
+
return validUntil;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function isSettled(ticket) {
|
|
56
|
+
return ticket.status === "done" || ticket.status === "skipped";
|
|
57
|
+
}
|
|
58
|
+
// Folds the events of the latest run into what the panel shows at `now`, how
|
|
59
|
+
// long that stays true while the events don't change, and the ids new items get.
|
|
60
|
+
// Everything before the last `run.start` belongs to earlier runs and is ignored.
|
|
61
|
+
export function reduceProgress(parsed, now) {
|
|
62
|
+
const time = clock(now);
|
|
63
|
+
let start = 0;
|
|
64
|
+
parsed.events.forEach(({ event }, index) => {
|
|
65
|
+
if (event.type === "run.start")
|
|
66
|
+
start = index;
|
|
67
|
+
});
|
|
68
|
+
const events = parsed.events.slice(start);
|
|
69
|
+
const issues = [...parsed.issues];
|
|
70
|
+
let run = null;
|
|
71
|
+
const tickets = new Map();
|
|
72
|
+
const questions = new Map();
|
|
73
|
+
const deliverables = new Map();
|
|
74
|
+
const activity = new Map();
|
|
75
|
+
let ticker = null;
|
|
76
|
+
const manualStuck = new Map();
|
|
77
|
+
let updatedAt = null;
|
|
78
|
+
for (const { line, event } of events) {
|
|
79
|
+
updatedAt = event.ts;
|
|
80
|
+
switch (event.type) {
|
|
81
|
+
case "run.start":
|
|
82
|
+
run = { title: event.title, subtitle: event.subtitle, itemLabel: event.itemLabel ?? "Ticket", startedAt: event.ts };
|
|
83
|
+
break;
|
|
84
|
+
case "run.finish":
|
|
85
|
+
if (run)
|
|
86
|
+
run.finished = { at: event.ts, outcome: event.outcome };
|
|
87
|
+
ticker = null;
|
|
88
|
+
break;
|
|
89
|
+
case "ticket.add":
|
|
90
|
+
tickets.set(event.id, {
|
|
91
|
+
id: event.id,
|
|
92
|
+
title: event.title,
|
|
93
|
+
estimateMin: event.estimateMin,
|
|
94
|
+
status: event.status ?? "not_started",
|
|
95
|
+
statusSince: event.ts,
|
|
96
|
+
workingSince: event.status === "working" ? event.ts : undefined,
|
|
97
|
+
history: [{ at: event.ts, status: event.status ?? "not_started" }],
|
|
98
|
+
waitsFor: event.waitsFor,
|
|
99
|
+
});
|
|
100
|
+
break;
|
|
101
|
+
case "ticket.update": {
|
|
102
|
+
const ticket = tickets.get(event.id);
|
|
103
|
+
if (!ticket) {
|
|
104
|
+
issues.push({ line, reason: `unknown ticket ${event.id}` });
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
if (event.title !== undefined)
|
|
108
|
+
ticket.title = event.title;
|
|
109
|
+
if (event.estimateMin !== undefined)
|
|
110
|
+
ticket.estimateMin = event.estimateMin;
|
|
111
|
+
const change = { at: event.ts };
|
|
112
|
+
if (event.status !== undefined && event.status !== ticket.status)
|
|
113
|
+
change.status = event.status;
|
|
114
|
+
if (event.stage !== undefined && event.stage !== (ticket.stage ?? ""))
|
|
115
|
+
change.stage = event.stage;
|
|
116
|
+
if (event.note)
|
|
117
|
+
change.note = event.note;
|
|
118
|
+
if (Object.keys(change).length > 1)
|
|
119
|
+
ticket.history.push(change);
|
|
120
|
+
if (event.status !== undefined && event.status !== ticket.status) {
|
|
121
|
+
ticket.status = event.status;
|
|
122
|
+
ticket.statusSince = event.ts;
|
|
123
|
+
if (event.status === "working")
|
|
124
|
+
ticket.workingSince = event.ts;
|
|
125
|
+
}
|
|
126
|
+
if (event.stage !== undefined && event.stage !== (ticket.stage ?? "")) {
|
|
127
|
+
ticket.stage = event.stage || undefined;
|
|
128
|
+
ticket.stageSince = event.stage ? event.ts : undefined;
|
|
129
|
+
}
|
|
130
|
+
if (event.note !== undefined)
|
|
131
|
+
ticket.note = event.note || undefined;
|
|
132
|
+
if (event.waitsFor !== undefined)
|
|
133
|
+
ticket.waitsFor = event.waitsFor || undefined;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "ticket.remove":
|
|
137
|
+
if (!tickets.delete(event.id))
|
|
138
|
+
issues.push({ line, reason: `unknown ticket ${event.id}` });
|
|
139
|
+
break;
|
|
140
|
+
case "question.ask":
|
|
141
|
+
questions.set(event.id, {
|
|
142
|
+
id: event.id,
|
|
143
|
+
title: event.title,
|
|
144
|
+
question: event.question,
|
|
145
|
+
options: event.options ?? [],
|
|
146
|
+
default: event.default,
|
|
147
|
+
waits: event.waits ?? false,
|
|
148
|
+
background: event.background,
|
|
149
|
+
files: event.files ?? [],
|
|
150
|
+
raisedBy: event.raisedBy,
|
|
151
|
+
ticketId: event.ticket,
|
|
152
|
+
askedAt: event.ts,
|
|
153
|
+
});
|
|
154
|
+
break;
|
|
155
|
+
case "question.answer": {
|
|
156
|
+
const question = questions.get(event.id);
|
|
157
|
+
if (!question)
|
|
158
|
+
issues.push({ line, reason: `unknown question ${event.id}` });
|
|
159
|
+
else
|
|
160
|
+
question.answer = { choice: event.choice, words: event.words, changedCourse: event.changedCourse, at: event.ts };
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case "question.update": {
|
|
164
|
+
const question = questions.get(event.id);
|
|
165
|
+
if (!question) {
|
|
166
|
+
issues.push({ line, reason: `unknown question ${event.id}` });
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
if (event.question !== undefined)
|
|
170
|
+
question.question = event.question;
|
|
171
|
+
if (event.background !== undefined)
|
|
172
|
+
question.background = event.background;
|
|
173
|
+
if (event.files !== undefined)
|
|
174
|
+
question.files = [...question.files, ...event.files];
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case "question.remove":
|
|
178
|
+
if (!questions.delete(event.id))
|
|
179
|
+
issues.push({ line, reason: `unknown question ${event.id}` });
|
|
180
|
+
break;
|
|
181
|
+
case "deliverable.add":
|
|
182
|
+
deliverables.set(event.id, {
|
|
183
|
+
id: event.id,
|
|
184
|
+
title: event.title,
|
|
185
|
+
path: event.path,
|
|
186
|
+
url: event.url,
|
|
187
|
+
ticketId: event.ticket,
|
|
188
|
+
kind: event.kind,
|
|
189
|
+
addedAt: event.ts,
|
|
190
|
+
});
|
|
191
|
+
break;
|
|
192
|
+
case "deliverable.remove":
|
|
193
|
+
if (!deliverables.delete(event.id))
|
|
194
|
+
issues.push({ line, reason: `unknown deliverable ${event.id}` });
|
|
195
|
+
break;
|
|
196
|
+
case "activity.add":
|
|
197
|
+
activity.set(event.id, { id: event.id, text: event.text, ticketId: event.ticket, at: event.ts });
|
|
198
|
+
break;
|
|
199
|
+
case "activity.update": {
|
|
200
|
+
const entry = activity.get(event.id);
|
|
201
|
+
if (!entry)
|
|
202
|
+
issues.push({ line, reason: `unknown activity ${event.id}` });
|
|
203
|
+
else
|
|
204
|
+
Object.assign(entry, { text: event.text, editedAt: event.ts });
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case "activity.remove":
|
|
208
|
+
if (!activity.delete(event.id))
|
|
209
|
+
issues.push({ line, reason: `unknown activity ${event.id}` });
|
|
210
|
+
break;
|
|
211
|
+
case "ticker.set":
|
|
212
|
+
ticker = { text: event.text, since: event.ts };
|
|
213
|
+
break;
|
|
214
|
+
case "ticker.clear":
|
|
215
|
+
ticker = null;
|
|
216
|
+
break;
|
|
217
|
+
case "stuck.set":
|
|
218
|
+
manualStuck.set(event.id, { id: event.id, reason: event.reason, ticket: event.ticket, since: event.ts });
|
|
219
|
+
break;
|
|
220
|
+
case "stuck.clear":
|
|
221
|
+
if (!manualStuck.delete(event.id))
|
|
222
|
+
issues.push({ line, reason: `unknown stuck item ${event.id}` });
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const list = [...tickets.values()];
|
|
227
|
+
for (const ticket of list) {
|
|
228
|
+
const other = ticket.waitsFor ? tickets.get(ticket.waitsFor) : undefined;
|
|
229
|
+
ticket.waitingFor = other && !isSettled(other) && !isSettled(ticket)
|
|
230
|
+
? { id: other.id, title: other.title }
|
|
231
|
+
: undefined;
|
|
232
|
+
}
|
|
233
|
+
const itemLabel = run?.itemLabel ?? "Ticket";
|
|
234
|
+
const stuck = stuckItems(list, [...activity.values()], [...manualStuck.values()], time.reached);
|
|
235
|
+
const allQuestions = [...questions.values()];
|
|
236
|
+
const open = allQuestions.filter((question) => !question.answer);
|
|
237
|
+
// A finished run has nothing left to stall, so it never goes stale.
|
|
238
|
+
const finished = Boolean(run?.finished) || (list.length > 0 && list.every(isSettled));
|
|
239
|
+
const answered = allQuestions
|
|
240
|
+
.filter((question) => question.answer)
|
|
241
|
+
.sort((a, b) => Date.parse(b.answer.at) - Date.parse(a.answer.at));
|
|
242
|
+
const dashboard = {
|
|
243
|
+
run,
|
|
244
|
+
updatedAt,
|
|
245
|
+
stale: !finished && updatedAt !== null && time.reached(Date.parse(updatedAt) + STALE_AFTER_MIN * 60_000),
|
|
246
|
+
headline: headline(list, itemLabel, open.length, stuck.length),
|
|
247
|
+
stuck,
|
|
248
|
+
questions: { open, answered },
|
|
249
|
+
activity: [...activity.values()].reverse(),
|
|
250
|
+
ticker,
|
|
251
|
+
deliverables: [...deliverables.values()].reverse().map((deliverable) => {
|
|
252
|
+
const ticket = deliverable.ticketId ? tickets.get(deliverable.ticketId) : undefined;
|
|
253
|
+
return ticket ? { ...deliverable, ticketLabel: shortTitle(ticket.title) } : deliverable;
|
|
254
|
+
}),
|
|
255
|
+
progress: progress(list, run?.startedAt),
|
|
256
|
+
tickets: list,
|
|
257
|
+
issues: issues.sort((a, b) => a.line - b.line),
|
|
258
|
+
};
|
|
259
|
+
return { dashboard, validUntil: time.validUntil, nextIds: nextIds(parsed.events.map(({ event }) => event), events.map(({ event }) => event)) };
|
|
260
|
+
}
|
|
261
|
+
function nextIds(all, inRun) {
|
|
262
|
+
const used = (events, type) => events.flatMap((event) => (event.type === type && "id" in event ? [event.id] : []));
|
|
263
|
+
return {
|
|
264
|
+
ticket: nextId("T", used(inRun, "ticket.add")),
|
|
265
|
+
question: nextId("Q", used(all, "question.ask"), 1),
|
|
266
|
+
deliverable: nextId("D", used(inRun, "deliverable.add"), 1),
|
|
267
|
+
activity: nextId("A", used(inRun, "activity.add"), 1),
|
|
268
|
+
stuck: nextId("S", used(inRun, "stuck.set"), 1),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function progress(tickets, startedAt) {
|
|
272
|
+
const counted = tickets.filter((ticket) => ticket.status !== "skipped");
|
|
273
|
+
const totalMin = counted.reduce((sum, ticket) => sum + ticket.estimateMin, 0);
|
|
274
|
+
const doneMin = counted.filter((ticket) => ticket.status === "done").reduce((sum, ticket) => sum + ticket.estimateMin, 0);
|
|
275
|
+
// All done: the last ticket to settle, done or skipped, ended the work.
|
|
276
|
+
const allDone = counted.length > 0 && counted.every((ticket) => ticket.status === "done") && tickets.every(isSettled);
|
|
277
|
+
const endedAt = Math.max(...tickets.map((ticket) => Date.parse(ticket.statusSince)));
|
|
278
|
+
return {
|
|
279
|
+
percent: totalMin ? Math.round((doneMin / totalMin) * 100) : 0,
|
|
280
|
+
doneMin,
|
|
281
|
+
totalMin,
|
|
282
|
+
tookMin: allDone && startedAt ? Math.max(0, (endedAt - Date.parse(startedAt)) / 60_000) : undefined,
|
|
283
|
+
segments: counted.map(({ id, estimateMin, status, waitingFor }) => ({ id, estimateMin, status, waiting: Boolean(waitingFor) })),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function lastTicketUpdate(ticket, activity) {
|
|
287
|
+
return Math.max(...ticket.history.map((change) => Date.parse(change.at)), ...activity.filter((entry) => entry.ticketId === ticket.id).map((entry) => Date.parse(entry.at)));
|
|
288
|
+
}
|
|
289
|
+
function stuckItems(tickets, activity, manual, reached) {
|
|
290
|
+
const items = [];
|
|
291
|
+
for (const ticket of tickets) {
|
|
292
|
+
// Blocked only on another ticket in the run is waiting, not stuck.
|
|
293
|
+
if (ticket.status === "blocked" && !ticket.waitingFor) {
|
|
294
|
+
items.push({ kind: "blocked", key: `blocked:${ticket.id}`, ticketId: ticket.id, title: ticket.title, since: ticket.statusSince, note: ticket.note });
|
|
295
|
+
}
|
|
296
|
+
else if (ticket.status === "working") {
|
|
297
|
+
// Any sign of work resets the clock: a status, stage or note change, or activity tagged to the ticket.
|
|
298
|
+
const lastUpdate = lastTicketUpdate(ticket, activity);
|
|
299
|
+
// Overdue once more than the estimate has passed, so a millisecond after it.
|
|
300
|
+
if (reached(lastUpdate + ticket.estimateMin * 60_000 + 1)) {
|
|
301
|
+
items.push({
|
|
302
|
+
kind: "overdue",
|
|
303
|
+
key: `overdue:${ticket.id}`,
|
|
304
|
+
ticketId: ticket.id,
|
|
305
|
+
title: ticket.title,
|
|
306
|
+
since: new Date(lastUpdate).toISOString(),
|
|
307
|
+
estimateMin: ticket.estimateMin,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const item of manual) {
|
|
313
|
+
const ticket = item.ticket ? tickets.find((candidate) => candidate.id === item.ticket) : undefined;
|
|
314
|
+
items.push({
|
|
315
|
+
kind: "manual",
|
|
316
|
+
key: `manual:${item.id}`,
|
|
317
|
+
id: item.id,
|
|
318
|
+
ticketId: item.ticket,
|
|
319
|
+
title: ticket?.title ?? item.reason,
|
|
320
|
+
reason: item.reason,
|
|
321
|
+
since: item.since,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return items;
|
|
325
|
+
}
|
|
326
|
+
// "Ticket 03: Frozen links survive deletes" → "Ticket 03".
|
|
327
|
+
export function shortTitle(title) {
|
|
328
|
+
const index = title.indexOf(":");
|
|
329
|
+
return index > 0 && index <= 24 ? title.slice(0, index) : title;
|
|
330
|
+
}
|
|
331
|
+
function plural(count, noun) {
|
|
332
|
+
return `${count} ${count === 1 ? noun : `${noun}s`}`;
|
|
333
|
+
}
|
|
334
|
+
function headline(tickets, itemLabel, waiting, stuck) {
|
|
335
|
+
const counted = tickets.filter((ticket) => ticket.status !== "skipped");
|
|
336
|
+
const done = counted.filter((ticket) => ticket.status === "done").length;
|
|
337
|
+
const parts = [{ text: `${done} of ${plural(counted.length, itemLabel.toLowerCase())} done`, tone: "normal" }];
|
|
338
|
+
if (waiting)
|
|
339
|
+
parts.push({ text: `${plural(waiting, "question")} waiting for you`, tone: "normal" });
|
|
340
|
+
if (stuck)
|
|
341
|
+
parts.push({ text: `${stuck} stuck`, tone: "danger" });
|
|
342
|
+
return parts;
|
|
343
|
+
}
|
|
344
|
+
export function headlineText(dashboard) {
|
|
345
|
+
return dashboard.headline.map((part) => part.text).join(", ");
|
|
346
|
+
}
|
|
347
|
+
function nextId(prefix, used, width = 2) {
|
|
348
|
+
let max = 0;
|
|
349
|
+
for (const id of used) {
|
|
350
|
+
const match = new RegExp(`^${prefix}(\\d+)$`).exec(id);
|
|
351
|
+
if (match)
|
|
352
|
+
max = Math.max(max, Number(match[1]));
|
|
353
|
+
}
|
|
354
|
+
return `${prefix}${String(max + 1).padStart(width, "0")}`;
|
|
355
|
+
}
|
|
356
|
+
export const EMPTY_DASHBOARD = {
|
|
357
|
+
run: null,
|
|
358
|
+
updatedAt: null,
|
|
359
|
+
stale: false,
|
|
360
|
+
headline: [],
|
|
361
|
+
stuck: [],
|
|
362
|
+
questions: { open: [], answered: [] },
|
|
363
|
+
deliverables: [],
|
|
364
|
+
activity: [],
|
|
365
|
+
ticker: null,
|
|
366
|
+
progress: { percent: 0, doneMin: 0, totalMin: 0, segments: [] },
|
|
367
|
+
tickets: [],
|
|
368
|
+
issues: [],
|
|
369
|
+
};
|