@stevecastaneda/paseo-progress 0.3.3 → 0.3.4

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.
@@ -1,386 +0,0 @@
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
- // The events from the last `run.start` on; earlier runs are ignored.
59
- export function latestRun(events) {
60
- const start = events.findLastIndex(({ event }) => event.type === "run.start");
61
- return events.slice(Math.max(start, 0));
62
- }
63
- // Folds the events of the latest run into what the panel shows at `now`, how
64
- // long that stays true while the events don't change, and the ids new items get.
65
- // Everything before the last `run.start` belongs to earlier runs and is ignored.
66
- export function reduceProgress(parsed, now) {
67
- const time = clock(now);
68
- const events = latestRun(parsed.events);
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
- source: event.source,
100
- agentId: event.by,
101
- });
102
- break;
103
- case "ticket.update": {
104
- const ticket = tickets.get(event.id);
105
- if (!ticket) {
106
- issues.push({ line, reason: `unknown ticket ${event.id}` });
107
- break;
108
- }
109
- if (event.title !== undefined)
110
- ticket.title = event.title;
111
- if (event.estimateMin !== undefined)
112
- ticket.estimateMin = event.estimateMin;
113
- const change = { at: event.ts };
114
- if (event.status !== undefined && event.status !== ticket.status)
115
- change.status = event.status;
116
- if (event.stage !== undefined && event.stage !== (ticket.stage ?? ""))
117
- change.stage = event.stage;
118
- if (event.note)
119
- change.note = event.note;
120
- if (Object.keys(change).length > 1)
121
- ticket.history.push(change);
122
- if (event.status !== undefined && event.status !== ticket.status) {
123
- ticket.status = event.status;
124
- ticket.statusSince = event.ts;
125
- if (event.status === "working")
126
- ticket.workingSince = event.ts;
127
- }
128
- if (event.stage !== undefined && event.stage !== (ticket.stage ?? "")) {
129
- ticket.stage = event.stage || undefined;
130
- ticket.stageSince = event.stage ? event.ts : undefined;
131
- }
132
- if (event.note !== undefined)
133
- ticket.note = event.note || undefined;
134
- if (event.waitsFor !== undefined)
135
- ticket.waitsFor = event.waitsFor || undefined;
136
- if (event.source !== undefined)
137
- ticket.source = event.source || undefined;
138
- if (event.by)
139
- ticket.agentId = event.by;
140
- break;
141
- }
142
- case "ticket.remove":
143
- if (!tickets.delete(event.id))
144
- issues.push({ line, reason: `unknown ticket ${event.id}` });
145
- break;
146
- case "question.ask":
147
- questions.set(event.id, {
148
- id: event.id,
149
- title: event.title,
150
- question: event.question,
151
- options: event.options ?? [],
152
- recommended: event.recommend ?? event.default ?? "",
153
- background: event.background,
154
- files: event.files ?? [],
155
- raisedBy: event.raisedBy,
156
- ticketId: event.ticket,
157
- askedAt: event.ts,
158
- });
159
- break;
160
- case "question.answer": {
161
- const question = questions.get(event.id);
162
- if (!question)
163
- issues.push({ line, reason: `unknown question ${event.id}` });
164
- else
165
- question.answer = { choice: event.choice, words: event.words, changedCourse: event.changedCourse, at: event.ts };
166
- break;
167
- }
168
- case "question.update": {
169
- const question = questions.get(event.id);
170
- if (!question) {
171
- issues.push({ line, reason: `unknown question ${event.id}` });
172
- break;
173
- }
174
- if (event.question !== undefined)
175
- question.question = event.question;
176
- if (event.background !== undefined)
177
- question.background = event.background;
178
- if (event.files !== undefined)
179
- question.files = [...question.files, ...event.files];
180
- break;
181
- }
182
- case "question.remove":
183
- if (!questions.delete(event.id))
184
- issues.push({ line, reason: `unknown question ${event.id}` });
185
- break;
186
- case "deliverable.add":
187
- deliverables.set(event.id, {
188
- id: event.id,
189
- title: event.title,
190
- path: event.path,
191
- url: event.url,
192
- ticketId: event.ticket,
193
- kind: event.kind,
194
- addedAt: event.ts,
195
- });
196
- break;
197
- case "deliverable.remove":
198
- if (!deliverables.delete(event.id))
199
- issues.push({ line, reason: `unknown deliverable ${event.id}` });
200
- break;
201
- case "activity.add":
202
- activity.set(event.id, { id: event.id, text: event.text, ticketId: event.ticket, at: event.ts });
203
- break;
204
- case "activity.update": {
205
- const entry = activity.get(event.id);
206
- if (!entry)
207
- issues.push({ line, reason: `unknown activity ${event.id}` });
208
- else
209
- Object.assign(entry, { text: event.text, editedAt: event.ts });
210
- break;
211
- }
212
- case "activity.remove":
213
- if (!activity.delete(event.id))
214
- issues.push({ line, reason: `unknown activity ${event.id}` });
215
- break;
216
- case "ticker.set":
217
- ticker = { text: event.text, since: event.ts, ...(event.by ? { by: event.by } : {}) };
218
- break;
219
- case "ticker.clear":
220
- ticker = null;
221
- break;
222
- case "stuck.set":
223
- manualStuck.set(event.id, { id: event.id, reason: event.reason, ticket: event.ticket, since: event.ts });
224
- break;
225
- case "stuck.clear":
226
- if (!manualStuck.delete(event.id))
227
- issues.push({ line, reason: `unknown stuck item ${event.id}` });
228
- break;
229
- }
230
- }
231
- const list = [...tickets.values()];
232
- for (const ticket of list) {
233
- const other = ticket.waitsFor ? tickets.get(ticket.waitsFor) : undefined;
234
- ticket.waitingFor = other && !isSettled(other) && !isSettled(ticket)
235
- ? { id: other.id, title: other.title }
236
- : undefined;
237
- }
238
- const itemLabel = run?.itemLabel ?? "Ticket";
239
- const stuck = stuckItems(list, [...activity.values()], [...manualStuck.values()], time.reached);
240
- const allQuestions = [...questions.values()];
241
- const open = allQuestions.filter((question) => !question.answer);
242
- // A finished run has nothing left to stall, so it never goes stale.
243
- const finished = Boolean(run?.finished) || (list.length > 0 && list.every(isSettled));
244
- const answered = allQuestions
245
- .filter((question) => question.answer)
246
- .sort((a, b) => Date.parse(b.answer.at) - Date.parse(a.answer.at));
247
- const dashboard = {
248
- run,
249
- updatedAt,
250
- stale: !finished && updatedAt !== null && time.reached(Date.parse(updatedAt) + STALE_AFTER_MIN * 60_000),
251
- headline: headline(list, itemLabel, open.length, stuck.length),
252
- stuck,
253
- questions: { open, answered },
254
- activity: [...activity.values()].reverse(),
255
- ticker,
256
- deliverables: [...deliverables.values()].reverse().map((deliverable) => {
257
- const ticket = deliverable.ticketId ? tickets.get(deliverable.ticketId) : undefined;
258
- return ticket ? { ...deliverable, ticketLabel: shortTitle(ticket.title) } : deliverable;
259
- }),
260
- progress: progress(list, run?.startedAt),
261
- tickets: list,
262
- issues: issues.sort((a, b) => a.line - b.line),
263
- };
264
- return { dashboard, validUntil: time.validUntil, nextIds: nextIds(parsed.events.map(({ event }) => event), events.map(({ event }) => event)) };
265
- }
266
- function nextIds(all, inRun) {
267
- const used = (events, type) => events.flatMap((event) => (event.type === type && "id" in event ? [event.id] : []));
268
- return {
269
- ticket: nextId("T", used(inRun, "ticket.add")),
270
- question: nextId("Q", used(all, "question.ask"), 1),
271
- deliverable: nextId("D", used(inRun, "deliverable.add"), 1),
272
- activity: nextId("A", used(inRun, "activity.add"), 1),
273
- stuck: nextId("S", used(inRun, "stuck.set"), 1),
274
- };
275
- }
276
- function progress(tickets, startedAt) {
277
- const counted = tickets.filter((ticket) => ticket.status !== "skipped");
278
- const totalMin = counted.reduce((sum, ticket) => sum + ticket.estimateMin, 0);
279
- const doneMin = counted.filter((ticket) => ticket.status === "done").reduce((sum, ticket) => sum + ticket.estimateMin, 0);
280
- // All done: the last ticket to settle, done or skipped, ended the work.
281
- const allDone = counted.length > 0 && counted.every((ticket) => ticket.status === "done") && tickets.every(isSettled);
282
- const endedAt = Math.max(...tickets.map((ticket) => Date.parse(ticket.statusSince)));
283
- return {
284
- percent: totalMin ? Math.round((doneMin / totalMin) * 100) : 0,
285
- doneMin,
286
- totalMin,
287
- tookMin: allDone && startedAt ? Math.max(0, (endedAt - Date.parse(startedAt)) / 60_000) : undefined,
288
- segments: counted.map(({ id, estimateMin, status, waitingFor }) => ({ id, estimateMin, status, waiting: Boolean(waitingFor) })),
289
- };
290
- }
291
- function lastTicketUpdate(ticket, activity) {
292
- return Math.max(...ticket.history.map((change) => Date.parse(change.at)), ...activity.filter((entry) => entry.ticketId === ticket.id).map((entry) => Date.parse(entry.at)));
293
- }
294
- function stuckItems(tickets, activity, manual, reached) {
295
- const items = [];
296
- for (const ticket of tickets) {
297
- // Blocked only on another ticket in the run is waiting, not stuck.
298
- if (ticket.status === "blocked" && !ticket.waitingFor) {
299
- items.push({ kind: "blocked", key: `blocked:${ticket.id}`, ticketId: ticket.id, title: ticket.title, since: ticket.statusSince, note: ticket.note });
300
- }
301
- else if (ticket.status === "working") {
302
- // Any sign of work resets the clock: a status, stage or note change, or activity tagged to the ticket.
303
- const lastUpdate = lastTicketUpdate(ticket, activity);
304
- // Overdue once more than the estimate has passed, so a millisecond after it.
305
- if (reached(lastUpdate + ticket.estimateMin * 60_000 + 1)) {
306
- items.push({
307
- kind: "overdue",
308
- key: `overdue:${ticket.id}`,
309
- ticketId: ticket.id,
310
- title: ticket.title,
311
- since: new Date(lastUpdate).toISOString(),
312
- estimateMin: ticket.estimateMin,
313
- });
314
- }
315
- }
316
- }
317
- for (const item of manual) {
318
- const ticket = item.ticket ? tickets.find((candidate) => candidate.id === item.ticket) : undefined;
319
- items.push({
320
- kind: "manual",
321
- key: `manual:${item.id}`,
322
- id: item.id,
323
- ticketId: item.ticket,
324
- title: ticket?.title ?? item.reason,
325
- reason: item.reason,
326
- since: item.since,
327
- });
328
- }
329
- return items;
330
- }
331
- // "Ticket 03: Frozen links survive deletes" → "Ticket 03".
332
- export function shortTitle(title) {
333
- const index = title.indexOf(":");
334
- return index > 0 && index <= 24 ? title.slice(0, index) : title;
335
- }
336
- // The finished tickets (done or skipped) the panel folds into one row at the
337
- // top, in plan order, so the work still ahead sits near the top. A ticket
338
- // finished out of order folds too. A single one isn't worth a fold row. Once
339
- // every ticket is finished nothing is ahead, and the whole list is the record
340
- // of the run, so nothing folds.
341
- export function finishedToFold(tickets) {
342
- const finished = tickets.filter(isFinished);
343
- return finished.length >= 2 && finished.length < tickets.length ? finished : [];
344
- }
345
- export function isFinished(ticket) {
346
- return ticket.status === "done" || ticket.status === "skipped";
347
- }
348
- function plural(count, noun) {
349
- return `${count} ${count === 1 ? noun : `${noun}s`}`;
350
- }
351
- function headline(tickets, itemLabel, waiting, stuck) {
352
- const counted = tickets.filter((ticket) => ticket.status !== "skipped");
353
- const done = counted.filter((ticket) => ticket.status === "done").length;
354
- const parts = [{ text: `${done} of ${plural(counted.length, itemLabel.toLowerCase())} done`, tone: "normal" }];
355
- if (waiting)
356
- parts.push({ text: `${plural(waiting, "question")} waiting for you`, tone: "normal" });
357
- if (stuck)
358
- parts.push({ text: `${stuck} stuck`, tone: "danger" });
359
- return parts;
360
- }
361
- export function headlineText(dashboard) {
362
- return dashboard.headline.map((part) => part.text).join(", ");
363
- }
364
- function nextId(prefix, used, width = 2) {
365
- let max = 0;
366
- for (const id of used) {
367
- const match = new RegExp(`^${prefix}(\\d+)$`).exec(id);
368
- if (match)
369
- max = Math.max(max, Number(match[1]));
370
- }
371
- return `${prefix}${String(max + 1).padStart(width, "0")}`;
372
- }
373
- export const EMPTY_DASHBOARD = {
374
- run: null,
375
- updatedAt: null,
376
- stale: false,
377
- headline: [],
378
- stuck: [],
379
- questions: { open: [], answered: [] },
380
- deliverables: [],
381
- activity: [],
382
- ticker: null,
383
- progress: { percent: 0, doneMin: 0, totalMin: 0, segments: [] },
384
- tickets: [],
385
- issues: [],
386
- };
@@ -1,127 +0,0 @@
1
- import { z } from "zod";
2
- // One line of `progress.jsonl`. The command-line tool writes these; the
3
- // dashboard reducer reads them. `v` lets later versions change a shape without
4
- // misreading old files. Which folder holds it is set up per repo.
5
- export const PROGRESS_FILE_NAME = "progress.jsonl";
6
- // Present once someone has opened the Progress panel for this worktree.
7
- export const PANEL_OPENED_NAME = "progress-panel-opened";
8
- // Where 0.1.x kept each worktree's files. store.ts moves them out.
9
- export const LEGACY_FOLDER = ".scratch";
10
- export const EVENT_VERSION = 1;
11
- export const TICKET_STATUSES = ["not_started", "working", "blocked", "done", "skipped"];
12
- export const ticketStatusSchema = z.enum(TICKET_STATUSES);
13
- const text = z.string().trim().min(1);
14
- const minutes = z.number().int().positive().max(100_000);
15
- // `by` is the Paseo agent that wrote the event, absent when run outside Paseo.
16
- const base = { v: z.literal(EVENT_VERSION), ts: z.iso.datetime(), by: text.optional() };
17
- export const questionOptionSchema = z.object({
18
- letter: z.string().regex(/^[A-Z]$/),
19
- label: text,
20
- consequence: text.optional(),
21
- });
22
- const httpUrl = z.url().refine((value) => /^https?:\/\//i.test(value), "Use an HTTP or HTTPS URL");
23
- // Something attached to a question: a local path or a web link.
24
- export const fileLinkSchema = z
25
- .object({ path: text.optional(), url: httpUrl.optional(), label: text.optional() })
26
- .refine((file) => Boolean(file.path) !== Boolean(file.url), "Give a path or a URL");
27
- export const DELIVERABLE_KINDS = ["file", "folder", "report", "screenshot", "link"];
28
- export const eventSchema = z.discriminatedUnion("type", [
29
- z.object({
30
- ...base,
31
- type: z.literal("run.start"),
32
- title: text,
33
- subtitle: text.optional(),
34
- itemLabel: text.optional(),
35
- }),
36
- // Closes the run. Nothing more is recorded on it; new work needs `run.start`.
37
- z.object({ ...base, type: z.literal("run.finish"), outcome: text }),
38
- z.object({
39
- ...base,
40
- type: z.literal("ticket.add"),
41
- id: text,
42
- title: text,
43
- estimateMin: minutes,
44
- status: ticketStatusSchema.optional(),
45
- // Another ticket in the run this one can't start until it's done.
46
- waitsFor: text.optional(),
47
- // Where the ticket is written up: a spec file or an issue link.
48
- source: fileLinkSchema.optional(),
49
- }),
50
- z.object({
51
- ...base,
52
- type: z.literal("ticket.update"),
53
- id: text,
54
- title: text.optional(),
55
- estimateMin: minutes.optional(),
56
- status: ticketStatusSchema.optional(),
57
- // The step inside the ticket, like "Build" or "Fixes". Empty clears it.
58
- stage: z.string().trim().optional(),
59
- note: z.string().trim().optional(),
60
- // Empty clears it.
61
- waitsFor: z.string().trim().optional(),
62
- // Empty clears it.
63
- source: z.union([fileLinkSchema, z.literal("")]).optional(),
64
- }),
65
- z.object({ ...base, type: z.literal("ticket.remove"), id: text }),
66
- // A blocker that is not a ticket status, like a failing external service.
67
- z.object({ ...base, type: z.literal("stuck.set"), id: text, reason: text, ticket: text.optional() }),
68
- z.object({ ...base, type: z.literal("stuck.clear"), id: text }),
69
- // The agent asks, recommends an option, and builds none of them before the
70
- // answer: it works only on what the question doesn't affect. Older questions
71
- // carry `default` (the option the agent carried on with) and `waits` instead.
72
- z.object({
73
- ...base,
74
- type: z.literal("question.ask"),
75
- id: text,
76
- title: text,
77
- question: text,
78
- options: z.array(questionOptionSchema).max(26).optional(),
79
- // The agent's pick: an option letter, or a word such as "Looks right" when there are no options.
80
- recommend: text.optional(),
81
- default: text.optional(),
82
- waits: z.boolean().optional(),
83
- background: text.optional(),
84
- files: z.array(fileLinkSchema).optional(),
85
- raisedBy: text.optional(),
86
- // The ticket this question came up on.
87
- ticket: text.optional(),
88
- }),
89
- z.object({
90
- ...base,
91
- type: z.literal("question.answer"),
92
- id: text,
93
- choice: text,
94
- // The user's own words, quoted under the answer.
95
- words: text.optional(),
96
- // Older answers: true when the agent had to undo work built on its default.
97
- changedCourse: z.boolean().optional(),
98
- }),
99
- // Adds detail to an open question; its reference and default stay.
100
- z.object({
101
- ...base,
102
- type: z.literal("question.update"),
103
- id: text,
104
- question: text.optional(),
105
- background: text.optional(),
106
- files: z.array(fileLinkSchema).optional(),
107
- }),
108
- z.object({ ...base, type: z.literal("question.remove"), id: text }),
109
- // `path` is relative to the worktree root, or absolute when outside it.
110
- z.object({
111
- ...base,
112
- type: z.literal("deliverable.add"),
113
- id: text,
114
- title: text,
115
- path: text.optional(),
116
- url: httpUrl.optional(),
117
- ticket: text.optional(),
118
- kind: z.enum(DELIVERABLE_KINDS).optional(),
119
- }).refine((event) => Boolean(event.path) !== Boolean(event.url), "A deliverable has a path or a URL"),
120
- z.object({ ...base, type: z.literal("deliverable.remove"), id: text }),
121
- z.object({ ...base, type: z.literal("activity.add"), id: text, text, ticket: text.optional() }),
122
- z.object({ ...base, type: z.literal("activity.update"), id: text, text }),
123
- z.object({ ...base, type: z.literal("activity.remove"), id: text }),
124
- // One line at the top saying what the agent is doing right now.
125
- z.object({ ...base, type: z.literal("ticker.set"), text }),
126
- z.object({ ...base, type: z.literal("ticker.clear") }),
127
- ]);
@@ -1,50 +0,0 @@
1
- // Display helpers shared by the panel and the `show` command. Times are shown
2
- // in the viewer's local time zone.
3
- const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
4
- export function formatMinutes(totalMin) {
5
- const whole = Math.max(0, Math.floor(totalMin));
6
- if (whole < 1)
7
- return "<1 min";
8
- const hours = Math.floor(whole / 60);
9
- const minutes = whole % 60;
10
- if (!hours)
11
- return `${minutes} min`;
12
- return minutes ? `${hours} h ${minutes} min` : `${hours} h`;
13
- }
14
- // A ticket's estimate, the way people say one: "45 min" under an hour, then
15
- // hours to one decimal: "1 h", "1.5 h", "2.1 h".
16
- export function formatEstimate(totalMin) {
17
- if (totalMin < 60)
18
- return formatMinutes(totalMin);
19
- return `${Math.round(totalMin / 6) / 10} h`;
20
- }
21
- // Estimated work done out of the total, in the units people estimate in:
22
- // "5 of 10 min", "1 h 30 min of 2 h". Under an hour on both sides, the unit is
23
- // said once.
24
- // Once everything is done, how long it really took instead: "1 h 20 min".
25
- export function formatWorkDone(doneMin, totalMin, tookMin) {
26
- if (tookMin !== undefined)
27
- return formatMinutes(tookMin);
28
- if (totalMin < 60 && doneMin >= 1)
29
- return `${Math.floor(doneMin)} of ${formatMinutes(totalMin)}`;
30
- return `${doneMin < 1 ? "0 min" : formatMinutes(doneMin)} of ${formatMinutes(totalMin)}`;
31
- }
32
- export function formatClock(iso) {
33
- const date = new Date(iso);
34
- const hours = date.getHours();
35
- const minutes = String(date.getMinutes()).padStart(2, "0");
36
- return `${MONTHS[date.getMonth()]} ${date.getDate()}, ${hours % 12 || 12}:${minutes} ${hours < 12 ? "AM" : "PM"}`;
37
- }
38
- // Just the time, "2:08 AM", when it falls on the same day as `nowMs`; the full
39
- // clock time otherwise.
40
- export function formatTimeOfDay(iso, nowMs) {
41
- const full = formatClock(iso);
42
- return new Date(iso).toDateString() === new Date(nowMs).toDateString() ? full.slice(full.indexOf(", ") + 2) : full;
43
- }
44
- export function formatAgo(iso, nowMs) {
45
- const minutes = (nowMs - Date.parse(iso)) / 60_000;
46
- return minutes < 1 ? "just now" : `${formatMinutes(minutes)} ago`;
47
- }
48
- export function minutesSince(iso, nowMs) {
49
- return Math.max(0, (nowMs - Date.parse(iso)) / 60_000);
50
- }
@@ -1,74 +0,0 @@
1
- import { headlineText, shortTitle } from "./dashboard.js";
2
- import { formatAgo, formatClock, formatEstimate, formatMinutes, formatWorkDone, minutesSince } from "./format.js";
3
- const STATUS = { not_started: "Not started", working: "Working", blocked: "Blocked", done: "Done", skipped: "Skipped" };
4
- // The dashboard as plain text, so an agent can check what the user sees.
5
- export function dashboardText(dashboard, nowMs) {
6
- if (!dashboard.run && !dashboard.tickets.length)
7
- return "No run yet. Start one with: paseo-progress start \"<title>\"";
8
- const lines = [];
9
- const when = (iso) => `${formatClock(iso)} (${formatAgo(iso, nowMs)})`;
10
- lines.push(dashboard.run?.title ?? "Progress");
11
- if (dashboard.run?.subtitle)
12
- lines.push(dashboard.run.subtitle);
13
- lines.push(headlineText(dashboard));
14
- if (dashboard.run?.finished) {
15
- lines.push(`Finished ${when(dashboard.run.finished.at)}: ${dashboard.run.finished.outcome}`);
16
- lines.push(`This run is closed. Start new work with: paseo-progress start "<title>"`);
17
- }
18
- if (dashboard.ticker)
19
- lines.push(`Now: ${dashboard.ticker.text}`);
20
- if (dashboard.updatedAt)
21
- lines.push(`Last updated ${when(dashboard.updatedAt)}`);
22
- if (dashboard.stale && dashboard.updatedAt)
23
- lines.push(`POSSIBLY STALE: no update for ${formatMinutes(minutesSince(dashboard.updatedAt, nowMs))}.`);
24
- if (dashboard.progress.totalMin) {
25
- lines.push(`${dashboard.progress.percent}% of estimated work done, ${dashboard.progress.tookMin !== undefined ? "took " : ""}${formatWorkDone(dashboard.progress.doneMin, dashboard.progress.totalMin, dashboard.progress.tookMin)}`);
26
- }
27
- if (dashboard.issues.length) {
28
- lines.push("", `Skipped lines: ${dashboard.issues.map((issue) => `line ${issue.line} (${issue.reason})`).join(", ")}`);
29
- }
30
- if (dashboard.stuck.length) {
31
- lines.push("", "Stuck");
32
- for (const item of dashboard.stuck) {
33
- const detail = item.kind === "overdue" ? `no update for longer than its ${formatMinutes(item.estimateMin)} estimate`
34
- : item.kind === "blocked" ? `blocked${item.note ? `: ${item.note}` : ""}`
35
- : `${item.id}: ${item.reason}`;
36
- lines.push(` ${item.title} (${detail}), since ${when(item.since)}`);
37
- }
38
- }
39
- lines.push("", `${dashboard.run?.itemLabel ?? "Ticket"}s`);
40
- for (const ticket of dashboard.tickets) {
41
- const stage = ticket.status === "working" && ticket.stage ? `, ${ticket.stage} stage` : "";
42
- const status = ticket.waitingFor ? `Waiting for ${ticket.waitingFor.id}` : STATUS[ticket.status];
43
- lines.push(` ${ticket.id} ${status}${stage} ${formatEstimate(ticket.estimateMin)} ${ticket.title}`);
44
- if (ticket.source)
45
- lines.push(` Source: ${ticket.source.path ?? ticket.source.url}${ticket.source.label ? ` (${ticket.source.label})` : ""}`);
46
- }
47
- if (dashboard.questions.open.length) {
48
- lines.push("", "Questions waiting");
49
- for (const question of dashboard.questions.open) {
50
- lines.push(` ${question.id} ${question.ticketId ? `[${question.ticketId}] ` : ""}(${question.title}): ${question.question} You recommended ${question.recommended}`);
51
- for (const option of question.options)
52
- lines.push(` ${option.letter}) ${option.label}${option.consequence ? `: ${option.consequence}` : ""}`);
53
- }
54
- }
55
- if (dashboard.questions.answered.length) {
56
- lines.push("", "Answered");
57
- for (const question of dashboard.questions.answered) {
58
- lines.push(` ${question.id} (${question.title}): ${question.answer.choice}${question.answer.changedCourse ? " (changed course)" : ""}`);
59
- }
60
- }
61
- if (dashboard.deliverables.length) {
62
- lines.push("", "Latest deliverables");
63
- for (const deliverable of dashboard.deliverables.slice(0, 5)) {
64
- const ticket = deliverable.ticketId ? `, ${deliverable.ticketLabel ?? shortTitle(deliverable.ticketId)}` : "";
65
- lines.push(` ${deliverable.id} ${deliverable.title}: ${deliverable.url ?? deliverable.path}${ticket}`);
66
- }
67
- }
68
- if (dashboard.activity.length) {
69
- lines.push("", "Activity");
70
- for (const entry of dashboard.activity.slice(0, 5))
71
- lines.push(` ${entry.id} ${entry.ticketId ? `[${entry.ticketId}] ` : ""}${entry.text} (${formatAgo(entry.at, nowMs)})`);
72
- }
73
- return lines.join("\n");
74
- }