@miroshnik/elves 0.0.0 → 2026.1018.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/AGENTS.md +310 -0
- package/README.md +344 -0
- package/bin/ai-dev.ts +7 -0
- package/bin/elves.ts +2847 -0
- package/bin/shims/timeout +113 -0
- package/claude/CLAUDE.md +71 -0
- package/dist/github/scripts/github.js +3428 -0
- package/dist/spec/scripts/architecture.js +628 -0
- package/dist/spec/scripts/eslint-config.js +54 -0
- package/dist/spec/scripts/harness-qzb932c8.js +959 -0
- package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
- package/dist/spec/scripts/harness.js +53 -0
- package/dist/spec/scripts/knip-hints.cjs +34 -0
- package/docs/ci-concurrency.md +82 -0
- package/docs/cloud-sessions.md +203 -0
- package/docs/judgment.md +81 -0
- package/docs/parallel-checkouts.md +220 -0
- package/docs/pr-checks.md +117 -0
- package/docs/testing.md +304 -0
- package/package.json +70 -1
- package/skills/ci-wait/SKILL.md +180 -0
- package/skills/ci-wait/scripts/wait-ci.sh +377 -0
- package/skills/dashboard/SKILL.md +212 -0
- package/skills/dashboard/scripts/dashboard.ts +912 -0
- package/skills/dashboard/scripts/sessions.ts +772 -0
- package/skills/est/SKILL.md +221 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4854 -0
- package/skills/github/SKILL.md +206 -0
- package/skills/github/locales/en.json +449 -0
- package/skills/github/locales/ru.json +449 -0
- package/skills/github/reference.md +1073 -0
- package/skills/github/scripts/github.ts +4390 -0
- package/skills/github/scripts/locale.ts +495 -0
- package/skills/github/scripts/package.json +3 -0
- package/skills/slot/SKILL.md +145 -0
- package/skills/slot/scripts/package.json +3 -0
- package/skills/slot/scripts/slot.ts +279 -0
- package/skills/spec/SKILL.md +247 -0
- package/skills/spec/canon.md +219 -0
- package/skills/spec/checks.md +709 -0
- package/skills/spec/ci.md +378 -0
- package/skills/spec/migration.md +125 -0
- package/skills/spec/reference.md +889 -0
- package/skills/spec/scripts/architecture.ts +839 -0
- package/skills/spec/scripts/c4.ts +184 -0
- package/skills/spec/scripts/eslint-config.ts +82 -0
- package/skills/spec/scripts/harness.ts +1483 -0
- package/skills/spec/scripts/knip-hints.cjs +34 -0
- package/skills/spec/scripts/knip-hints.d.cts +17 -0
- package/skills/spec/scripts/package-run.ts +22 -0
- package/skills/spec/scripts/package.json +3 -0
- package/skills/spec/scripts/spec-break.ts +232 -0
- package/skills/spec/scripts/spec-claims.ts +198 -0
- package/skills/spec/scripts/spec-diff.ts +1111 -0
- package/skills/spec/scripts/spec-doc.ts +744 -0
- package/skills/spec/scripts/spec-exceptions.ts +233 -0
- package/skills/spec/scripts/spec-publish.ts +214 -0
- package/skills/spec/scripts/spec-run.ts +235 -0
- package/skills/spec/scripts/spec-shard.ts +267 -0
- package/skills/spec/scripts/speclib.ts +826 -0
- package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
- package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
- package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
- package/skills/task-env/SKILL.md +231 -0
- package/skills/task-env/proxy/compose.yaml +51 -0
- package/skills/task-env/reference.md +254 -0
- package/skills/task-env/scripts/task-env.ts +1017 -0
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* dashboard — a task page in two tabs: "Sessions" (the main one) — "In progress" tasks, their sessions and what they
|
|
4
|
+
* wait for; "Estimate and actual" — a chart of estimate, actual, tokens and cost by close time.
|
|
5
|
+
*
|
|
6
|
+
* dashboard [--repo o/r | --all-repos] [--since 90d] [--port N] [--no-open]
|
|
7
|
+
*
|
|
8
|
+
* Serves the page itself (Bun.serve, 127.0.0.1 only), prints the address and opens it in the browser. The data — the
|
|
9
|
+
* GitHub project rows (fields and the "Estimate" and "Actual" comment markers) via the est script next to it, session
|
|
10
|
+
* pins and transcripts (sessions.ts), the routes of the task-env proxy — task addresses; writes nothing to GitHub. The
|
|
11
|
+
* page is built on the server: SVG as a string, no dependencies; in the browser — only the hover tooltip.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { parseArgs } from "node:util";
|
|
16
|
+
|
|
17
|
+
import locale from "../../github/scripts/locale.ts";
|
|
18
|
+
import { calib, EstError, fmtH, fmtLocal, loadRegistry, markerWaits, median, parseSince, Repo, resolveRepo, sharesTxt, usdByModel, usdShares, waitsBrief, waitsShift } from "../../est/scripts/est.ts";
|
|
19
|
+
import type { Registry, Row, Waits, WaitsShift } from "../../est/scripts/est.ts";
|
|
20
|
+
import { proxyHosts, proxyPort } from "../../task-env/scripts/task-env.ts";
|
|
21
|
+
import { findTranscripts, liveQuery, readPins, RECENT, REFRESH, refreshScript, repoTranscripts, SESSIONS_CSS, sessionsBody, sessionsView } from "./sessions.ts";
|
|
22
|
+
import type { Live, SessionsInput, SessionsView } from "./sessions.ts";
|
|
23
|
+
|
|
24
|
+
const DAY = 86400;
|
|
25
|
+
/** Trend and summary window: the last 10 closed tasks; a median of fewer than three is noise, there is none. */
|
|
26
|
+
const WINDOW = 10;
|
|
27
|
+
const MIN_TREND = 3;
|
|
28
|
+
/** GitHub is re-read when the page opens, but no more than once a minute; "Refresh" — at once. */
|
|
29
|
+
const TTL = 60;
|
|
30
|
+
|
|
31
|
+
// ----------------------------------------------------------------------------
|
|
32
|
+
// Data
|
|
33
|
+
// ----------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** A task on the chart. Estimate and forecast — only from the est marker: estimates without it are in other units. */
|
|
36
|
+
export interface Point {
|
|
37
|
+
repo: string;
|
|
38
|
+
number: number;
|
|
39
|
+
title: string;
|
|
40
|
+
url: string;
|
|
41
|
+
/** Close time, epoch seconds. */
|
|
42
|
+
t: number;
|
|
43
|
+
type: string;
|
|
44
|
+
cov: string;
|
|
45
|
+
est: number | null;
|
|
46
|
+
fact: number;
|
|
47
|
+
tokEst: number | null;
|
|
48
|
+
/** Actual tokens, millions. */
|
|
49
|
+
tok: number | null;
|
|
50
|
+
usdEst: number | null;
|
|
51
|
+
usd: number | null;
|
|
52
|
+
/** Actual $ by model; null — the actual was computed before the per-model split. */
|
|
53
|
+
usdModels: Record<string, number> | null;
|
|
54
|
+
/** The actual's split by waits, minutes; null — the actual was computed before the split. */
|
|
55
|
+
waits: Waits | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
59
|
+
// the dashboard is shared by all repositories: labels are in English, parsing takes names in any language of the
|
|
60
|
+
// github skill's locales dictionary
|
|
61
|
+
const EPIC_TYPES = locale.every((n) => n.issueTypes.epic).map((t) => t.toLowerCase());
|
|
62
|
+
const isEpic = (r: Row) => r.labels.includes("epic") || EPIC_TYPES.includes((r.issueType ?? "").toLowerCase());
|
|
63
|
+
|
|
64
|
+
/** Closed tasks of the period without epics: an epic is the sum of its subtasks, they're already in the list. */
|
|
65
|
+
function taskRows(rows: Row[], since: number | null): Row[] {
|
|
66
|
+
return rows.filter((r) => r.state === "CLOSED" && r.closedAt !== null && !isEpic(r) && (since === null || r.closedAt >= since));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Chart points — closed tasks with an actual, by close time. */
|
|
70
|
+
export function points(repo: string, rows: Row[], since: number | null = null): Point[] {
|
|
71
|
+
const out: Point[] = [];
|
|
72
|
+
for (const r of taskRows(rows, since)) {
|
|
73
|
+
if (r.fact === null) continue;
|
|
74
|
+
const em = r.est_marker;
|
|
75
|
+
const fm = r.fact_marker ?? {};
|
|
76
|
+
const tok = num(fm.tok?.total);
|
|
77
|
+
out.push({
|
|
78
|
+
repo, number: r.number, title: r.title, url: `https://github.com/${repo}/issues/${r.number}`, t: r.closedAt!,
|
|
79
|
+
type: fm.type || em?.type || "", cov: fm.cov || "—",
|
|
80
|
+
est: em ? num(em.h) || r.est || null : null, fact: r.fact,
|
|
81
|
+
tokEst: num(em?.tok), tok: tok === null ? null : tok / 1e6,
|
|
82
|
+
usdEst: num(em?.usd), usd: num(fm.usd), usdModels: fm.models ? usdByModel([fm]) : null, waits: markerWaits(fm),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return out.sort((a, b) => a.t - b.t || a.number - b.number);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Rolling median of the last `window` values; while there are fewer than `min` values — no trend. */
|
|
89
|
+
export function trend(vals: number[], window = WINDOW, min = MIN_TREND): (number | null)[] {
|
|
90
|
+
return vals.map((_, i) => {
|
|
91
|
+
const w = vals.slice(Math.max(0, i - window + 1), i + 1);
|
|
92
|
+
return w.length < min ? null : median(w);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Median of the latest tasks and of the previous ones: no `prev` while there's nothing to compare with. */
|
|
97
|
+
export interface Shift {
|
|
98
|
+
now: number | null;
|
|
99
|
+
prev: number | null;
|
|
100
|
+
n: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function shift(vals: number[]): Shift {
|
|
104
|
+
const last = vals.slice(-WINDOW);
|
|
105
|
+
const before = vals.slice(-2 * WINDOW, -WINDOW);
|
|
106
|
+
return { now: last.length ? median(last) : null, prev: before.length >= MIN_TREND ? median(before) : null, n: last.length };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Minutes a task waited for CI, the merge's checks and the deploy, and local checks — the waits worth shortening. */
|
|
110
|
+
const waited = (w: Waits) => w.ci + w.merged + w.checks;
|
|
111
|
+
|
|
112
|
+
/** The waits' share of the actual, 0…1; an actual of zero minutes — none. */
|
|
113
|
+
export function waitShare(w: Waits): number | null {
|
|
114
|
+
const all = waited(w) + w.rest;
|
|
115
|
+
return all > 0 ? waited(w) / all : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** $ shares by model of the latest tasks and of the previous ones: a pricing shift next to the cost trend. */
|
|
119
|
+
export interface ModelShift {
|
|
120
|
+
now: [string, number][];
|
|
121
|
+
prev: [string, number][];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function modelShares(pts: Point[]): [string, number][] {
|
|
125
|
+
const sum: Record<string, number> = {};
|
|
126
|
+
for (const p of pts) for (const [m, v] of Object.entries(p.usdModels ?? {})) sum[m] = (sum[m] ?? 0) + v;
|
|
127
|
+
return usdShares(sum);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface View {
|
|
131
|
+
points: Point[];
|
|
132
|
+
/** Estimate accuracy — k and the share within tolerance, as in `est history`. */
|
|
133
|
+
calib: ReturnType<typeof calib>;
|
|
134
|
+
h: Shift;
|
|
135
|
+
tok: Shift;
|
|
136
|
+
usd: Shift;
|
|
137
|
+
models: ModelShift;
|
|
138
|
+
/** Waits for CI, after the merge and for checks per task, minutes: their sum and the median of each kind. */
|
|
139
|
+
waits: Shift;
|
|
140
|
+
waitKinds: WaitsShift;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Everything the page shows: points, estimate accuracy and medians of the latest tasks — from the repositories'
|
|
145
|
+
* project rows.
|
|
146
|
+
*/
|
|
147
|
+
export function view(rowsByRepo: Record<string, Row[]>, since: number | null): View {
|
|
148
|
+
const pts = Object.entries(rowsByRepo).flatMap(([repo, rows]) => points(repo, rows, since)).sort((a, b) => a.t - b.t);
|
|
149
|
+
const vals = (f: (p: Point) => number | null) => pts.map(f).filter((v): v is number => v !== null);
|
|
150
|
+
return {
|
|
151
|
+
points: pts,
|
|
152
|
+
calib: calib(Object.values(rowsByRepo).flatMap((rows) => taskRows(rows, since))),
|
|
153
|
+
h: shift(vals((p) => p.fact)),
|
|
154
|
+
tok: shift(vals((p) => p.tok)),
|
|
155
|
+
usd: shift(vals((p) => p.usd)),
|
|
156
|
+
models: { now: modelShares(pts.slice(-WINDOW)), prev: pts.length - WINDOW >= MIN_TREND ? modelShares(pts.slice(-2 * WINDOW, -WINDOW)) : [] },
|
|
157
|
+
waits: shift(vals((p) => (p.waits ? waited(p.waits) : null))),
|
|
158
|
+
waitKinds: waitsShift(pts.map((p) => p.waits).filter((w): w is Waits => w !== null), WINDOW, MIN_TREND),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ----------------------------------------------------------------------------
|
|
163
|
+
// Chart
|
|
164
|
+
// ----------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
|
|
167
|
+
const r1 = (x: number) => Math.round(x * 10) / 10;
|
|
168
|
+
const two = (n: number) => String(n).padStart(2, "0");
|
|
169
|
+
const fmtUsd = (v: number) => `$${v.toFixed(2)}`;
|
|
170
|
+
const fmtTok = (v: number) => fmtH(v, 2);
|
|
171
|
+
|
|
172
|
+
interface Panel {
|
|
173
|
+
key: "h" | "ratio" | "tok" | "usd";
|
|
174
|
+
title: string;
|
|
175
|
+
fact: (p: Point) => number | null;
|
|
176
|
+
est: (p: Point) => number | null;
|
|
177
|
+
/** A scale step label and the value label at the trend's end. */
|
|
178
|
+
tick: (v: number) => string;
|
|
179
|
+
fmt: (v: number) => string;
|
|
180
|
+
/** The reference line and the tolerance band — on the ratio panel. */
|
|
181
|
+
ref?: { at: number; band: [number, number] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const none = () => null;
|
|
185
|
+
const times = (v: number) => `×${fmtH(v)}`;
|
|
186
|
+
// scale steps are round numbers of any magnitude (0.001, 20): fmtH would round small ones to zero
|
|
187
|
+
const tick = (v: number) => String(v);
|
|
188
|
+
// Panels, not several scales on one: hours, ratio, tokens and dollars are different units
|
|
189
|
+
// (tests/capabilities/dashboard).
|
|
190
|
+
const PANELS: Panel[] = [
|
|
191
|
+
{ key: "h", title: "Hours per task", fact: (p) => p.fact, est: (p) => p.est, tick, fmt: (v) => fmtH(v) },
|
|
192
|
+
{ key: "ratio", title: "Actual to estimate — tolerance band ×0.5…×2", fact: (p) => (p.est ? p.fact / p.est : null), est: none, tick: (v) => `×${v}`, fmt: times, ref: { at: 1, band: [0.5, 2] } },
|
|
193
|
+
{ key: "tok", title: "Tokens per task, M", fact: (p) => p.tok, est: (p) => p.tokEst, tick, fmt: fmtTok },
|
|
194
|
+
{ key: "usd", title: "Cost per task, $", fact: (p) => p.usd, est: (p) => p.usdEst, tick, fmt: (v) => v.toFixed(v < 10 ? 2 : 0) },
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Parts of the actual's split, bottom-up: the waits stand on the zero line — comparable across tasks, the rest is on
|
|
199
|
+
* top; the slot queue is a part of the checks, drawn apart from them.
|
|
200
|
+
*/
|
|
201
|
+
const PARTS: { key: keyof Waits; label: string; min: (w: Waits) => number }[] = [
|
|
202
|
+
{ key: "ci", label: "CI on the PR", min: (w) => w.ci },
|
|
203
|
+
{ key: "merged", label: "after the merge", min: (w) => w.merged },
|
|
204
|
+
{ key: "checks", label: "checks", min: (w) => Math.max(0, w.checks - w.slot) },
|
|
205
|
+
{ key: "slot", label: "slot queue", min: (w) => w.slot },
|
|
206
|
+
{ key: "rest", label: "rest", min: (w) => w.rest },
|
|
207
|
+
];
|
|
208
|
+
const SPLIT_TITLE = "Actual split per task, min";
|
|
209
|
+
const SHARE_LABEL = "waits share, % — right scale";
|
|
210
|
+
const NO_SPLIT = "no split — the actual was recorded before it: a gap on the panel, not 0";
|
|
211
|
+
|
|
212
|
+
// Geometry in viewBox units: on the left — scale labels, on the right — the trend end label and the split panel's
|
|
213
|
+
// share scale, at the bottom — the shared time axis.
|
|
214
|
+
const W = 1080;
|
|
215
|
+
const LEFT = 56;
|
|
216
|
+
const RIGHT = 64;
|
|
217
|
+
const HEAD = 28;
|
|
218
|
+
const PLOT = 150;
|
|
219
|
+
const GAP = 18;
|
|
220
|
+
const AXIS = 30;
|
|
221
|
+
const PANEL_H = HEAD + PLOT + GAP;
|
|
222
|
+
/** The dot panels and the split panel under them. */
|
|
223
|
+
const ROWS = PANELS.length + 1;
|
|
224
|
+
const HEIGHT = ROWS * PANEL_H + AXIS;
|
|
225
|
+
|
|
226
|
+
/** Log scale steps covering [lo, hi]: the 1–2–5 series, and if there are more than seven steps — decades only. */
|
|
227
|
+
function logTicks(lo: number, hi: number): number[] {
|
|
228
|
+
const cover = (mantissas: number[]) => {
|
|
229
|
+
const all: number[] = [];
|
|
230
|
+
for (let k = Math.floor(Math.log10(lo)) - 1; k <= Math.ceil(Math.log10(hi)) + 1; k++) for (const m of mantissas) all.push(Number((m * 10 ** k).toPrecision(12)));
|
|
231
|
+
const from = all.filter((t) => t <= lo).pop()!;
|
|
232
|
+
const to = all.find((t) => t >= hi && t > from)!;
|
|
233
|
+
return all.filter((t) => t >= from && t <= to);
|
|
234
|
+
};
|
|
235
|
+
const fine = cover([1, 2, 5]);
|
|
236
|
+
return fine.length > 7 ? cover([1]) : fine;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Linear scale steps from zero covering [0, max]: a round step of the 1–2–5 series, five steps or fewer. */
|
|
240
|
+
function linTicks(max: number): number[] {
|
|
241
|
+
const raw = max / 5;
|
|
242
|
+
const mag = 10 ** Math.floor(Math.log10(raw));
|
|
243
|
+
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw)!;
|
|
244
|
+
return Array.from({ length: Math.ceil(max / step - 1e-9) + 1 }, (_, k) => Number((k * step).toPrecision(12)));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Time axis marks — local midnights with a step that keeps them at eight or fewer. */
|
|
248
|
+
function timeTicks(t0: number, t1: number): number[] {
|
|
249
|
+
const step = [1, 2, 7, 14, 30, 60, 90, 180].find((s) => (t1 - t0) / DAY / s <= 8) ?? 365;
|
|
250
|
+
const d = new Date(t0 * 1000);
|
|
251
|
+
const out: number[] = [];
|
|
252
|
+
for (let i = 1; ; i += step) {
|
|
253
|
+
const t = new Date(d.getFullYear(), d.getMonth(), d.getDate() + i).getTime() / 1000;
|
|
254
|
+
if (t > t1) break;
|
|
255
|
+
out.push(t);
|
|
256
|
+
}
|
|
257
|
+
return out.length ? out : [(t0 + t1) / 2];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
interface Layout {
|
|
261
|
+
x: (t: number) => number;
|
|
262
|
+
ticks: number[];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function layout(pts: Point[]): Layout {
|
|
266
|
+
const min = pts[0]!.t;
|
|
267
|
+
const max = pts[pts.length - 1]!.t;
|
|
268
|
+
const pad = Math.max((max - min) * 0.03, DAY / 4);
|
|
269
|
+
const [t0, t1] = [min - pad, max + pad];
|
|
270
|
+
return { x: (t) => LEFT + ((t - t0) / (t1 - t0)) * (W - LEFT - RIGHT), ticks: timeTicks(t0, t1) };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A task's hover target on a panel: a point's height or a bar's span [top, bottom]; null — none. */
|
|
274
|
+
type Target = number | [number, number] | null;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A panel: the grid, "estimate — actual" segments, estimate rings, the trend line and actual points; `ys` — the actual
|
|
278
|
+
* point height for each task.
|
|
279
|
+
*/
|
|
280
|
+
function panelSvg(p: Panel, i: number, pts: Point[], lay: Layout): { svg: string; ys: Target[] } {
|
|
281
|
+
const top = i * PANEL_H + HEAD;
|
|
282
|
+
const pos = (v: number | null): v is number => v !== null && v > 0;
|
|
283
|
+
const values = pts.flatMap((pt) => [p.fact(pt), p.est(pt)]).filter(pos);
|
|
284
|
+
if (values.length && p.ref) values.push(...p.ref.band);
|
|
285
|
+
const out = [`<g class="panel" data-panel="${p.key}">`, `<text class="panel-title" x="${LEFT}" y="${top - 10}">${esc(p.title)}</text>`];
|
|
286
|
+
if (!values.length) {
|
|
287
|
+
out.push(`<text class="empty" x="${LEFT}" y="${top + PLOT / 2}">no data</text>`, "</g>");
|
|
288
|
+
return { svg: out.join(""), ys: pts.map(() => null) };
|
|
289
|
+
}
|
|
290
|
+
const ticks = logTicks(Math.min(...values), Math.max(...values));
|
|
291
|
+
const [lo, hi] = [Math.log10(ticks[0]!), Math.log10(ticks[ticks.length - 1]!)];
|
|
292
|
+
const y = (v: number) => r1(top + PLOT - ((Math.log10(v) - lo) / (hi - lo)) * PLOT);
|
|
293
|
+
if (p.ref) out.push(`<rect class="band" x="${LEFT}" y="${y(p.ref.band[1])}" width="${W - LEFT - RIGHT}" height="${r1(y(p.ref.band[0]) - y(p.ref.band[1]))}"/>`);
|
|
294
|
+
for (const t of lay.ticks) out.push(`<line class="grid" x1="${r1(lay.x(t))}" x2="${r1(lay.x(t))}" y1="${top}" y2="${top + PLOT}"/>`);
|
|
295
|
+
for (const t of ticks) out.push(`<line class="grid" x1="${LEFT}" x2="${W - RIGHT}" y1="${y(t)}" y2="${y(t)}"/>`, `<text class="y-tick" x="${LEFT - 8}" y="${y(t) + 4}">${p.tick(t)}</text>`);
|
|
296
|
+
if (p.ref) out.push(`<line class="ref" x1="${LEFT}" x2="${W - RIGHT}" y1="${y(p.ref.at)}" y2="${y(p.ref.at)}"/>`);
|
|
297
|
+
const facts = pts.map((pt) => ({ x: r1(lay.x(pt.t)), fact: p.fact(pt), est: p.est(pt) }));
|
|
298
|
+
for (const f of facts) {
|
|
299
|
+
if (!pos(f.fact) || !pos(f.est)) continue;
|
|
300
|
+
out.push(`<line class="link" x1="${f.x}" x2="${f.x}" y1="${y(f.est)}" y2="${y(f.fact)}"/>`, `<circle class="est" cx="${f.x}" cy="${y(f.est)}" r="3.5"/>`);
|
|
301
|
+
}
|
|
302
|
+
const drawn = facts.filter((f) => pos(f.fact));
|
|
303
|
+
const line = trend(drawn.map((f) => f.fact as number)).map((v, k) => (v === null ? null : { x: drawn[k]!.x, y: y(v), v })).filter((v) => v !== null);
|
|
304
|
+
const end = line[line.length - 1];
|
|
305
|
+
if (end) out.push(`<path class="trend" d="${line.map((q, k) => `${k ? "L" : "M"}${q.x} ${q.y}`).join("")}"/>`, `<text class="end-label" x="${end.x + 10}" y="${end.y + 4}">${p.fmt(end.v)}</text>`);
|
|
306
|
+
const ys = facts.map((f) => (pos(f.fact) ? y(f.fact) : null));
|
|
307
|
+
facts.forEach((f, k) => ys[k] !== null && out.push(`<circle class="fact" data-i="${k}" cx="${f.x}" cy="${ys[k]}" r="4"/>`));
|
|
308
|
+
out.push("</g>");
|
|
309
|
+
return { svg: out.join(""), ys };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The split panel's legend on its title line: a swatch per part and the share line. */
|
|
313
|
+
function splitLegend(x: number, y: number): string {
|
|
314
|
+
const out: string[] = [];
|
|
315
|
+
// label widths are estimated: 12px text, ~6.5 units a character
|
|
316
|
+
for (const part of PARTS) {
|
|
317
|
+
out.push(`<rect class="seg ${part.key}" x="${r1(x)}" y="${y - 9}" width="10" height="10" rx="2"/>`, `<text class="legend" x="${r1(x + 14)}" y="${y}">${esc(part.label)}</text>`);
|
|
318
|
+
x += 14 + part.label.length * 6.5 + 16;
|
|
319
|
+
}
|
|
320
|
+
out.push(`<path class="trend" d="M${r1(x)} ${y - 4}h16"/>`, `<text class="legend" x="${r1(x + 20)}" y="${y}">${esc(SHARE_LABEL)}</text>`);
|
|
321
|
+
return out.join("");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The split panel: a bar per task from the parts of its actual, minutes on a linear scale — stacked parts on a log one
|
|
326
|
+
* would lie; the line — the rolling median of the waits' share, % on the right scale. A task without the split — a gap,
|
|
327
|
+
* its hover target is the panel's whole height: the tooltip says why it's empty.
|
|
328
|
+
*/
|
|
329
|
+
function splitSvg(i: number, pts: Point[], lay: Layout): { svg: string; ys: Target[] } {
|
|
330
|
+
const top = i * PANEL_H + HEAD;
|
|
331
|
+
const base = top + PLOT;
|
|
332
|
+
const total = (w: Waits) => PARTS.reduce((s, part) => s + part.min(w), 0);
|
|
333
|
+
const max = Math.max(0, ...pts.map((p) => (p.waits ? total(p.waits) : 0)));
|
|
334
|
+
const out = [`<g class="panel" data-panel="waits">`, `<text class="panel-title" x="${LEFT}" y="${top - 10}">${esc(SPLIT_TITLE)}</text>`];
|
|
335
|
+
if (!(max > 0)) {
|
|
336
|
+
out.push(`<text class="empty" x="${LEFT}" y="${top + PLOT / 2}">no tasks with the split</text>`, "</g>");
|
|
337
|
+
return { svg: out.join(""), ys: pts.map(() => null) };
|
|
338
|
+
}
|
|
339
|
+
out.push(splitLegend(LEFT + SPLIT_TITLE.length * 7.5 + 24, top - 10));
|
|
340
|
+
const ticks = linTicks(max);
|
|
341
|
+
const y = (v: number) => r1(base - (v / ticks[ticks.length - 1]!) * PLOT);
|
|
342
|
+
const yShare = (s: number) => r1(base - s * PLOT);
|
|
343
|
+
for (const t of lay.ticks) out.push(`<line class="grid" x1="${r1(lay.x(t))}" x2="${r1(lay.x(t))}" y1="${top}" y2="${base}"/>`);
|
|
344
|
+
for (const t of ticks) out.push(`<line class="grid" x1="${LEFT}" x2="${W - RIGHT}" y1="${y(t)}" y2="${y(t)}"/>`, `<text class="y-tick" x="${LEFT - 8}" y="${y(t) + 4}">${tick(t)}</text>`);
|
|
345
|
+
for (const s of [0, 0.5, 1]) out.push(`<text class="y-tick-r" x="${W - 4}" y="${yShare(s) + 4}">${s * 100} %</text>`);
|
|
346
|
+
const xs = pts.map((p) => r1(lay.x(p.t)));
|
|
347
|
+
// a bar is as wide as its nearest neighbor allows: tasks closed close together get thin bars, not overlapping ones
|
|
348
|
+
const width = (k: number) => Math.min(10, Math.max(1.5, 0.7 * Math.min(k ? xs[k]! - xs[k - 1]! : Infinity, k < xs.length - 1 ? xs[k + 1]! - xs[k]! : Infinity)));
|
|
349
|
+
const order = pts.map((_, k) => k).filter((k) => pts[k]!.waits).sort((a, b) => total(pts[b]!.waits!) - total(pts[a]!.waits!));
|
|
350
|
+
// taller bars first: a shorter one closed at the same time stays in front of it
|
|
351
|
+
for (const k of order) {
|
|
352
|
+
const w = pts[k]!.waits!;
|
|
353
|
+
const bw = width(k);
|
|
354
|
+
let below = 0;
|
|
355
|
+
const segs: string[] = [];
|
|
356
|
+
for (const part of PARTS) {
|
|
357
|
+
const m = part.min(w);
|
|
358
|
+
if (m <= 0) continue;
|
|
359
|
+
// a 1-unit gap in the surface color separates a part from the one below it
|
|
360
|
+
const h = r1(y(below) - y(below + m) - (segs.length ? 1 : 0));
|
|
361
|
+
if (h > 0) segs.push(`<rect class="seg ${part.key}" x="${r1(xs[k]! - bw / 2)}" y="${y(below + m)}" width="${r1(bw)}" height="${h}"/>`);
|
|
362
|
+
below += m;
|
|
363
|
+
}
|
|
364
|
+
out.push(`<g class="bar" data-i="${k}">${segs.join("")}</g>`);
|
|
365
|
+
}
|
|
366
|
+
const shares = pts.flatMap((p, k) => {
|
|
367
|
+
const s = p.waits ? waitShare(p.waits) : null;
|
|
368
|
+
return s === null ? [] : [{ x: xs[k]!, s }];
|
|
369
|
+
});
|
|
370
|
+
const line = trend(shares.map((q) => q.s)).flatMap((v, k) => (v === null ? [] : [{ x: shares[k]!.x, y: yShare(v), v }]));
|
|
371
|
+
const end = line[line.length - 1];
|
|
372
|
+
if (end) {
|
|
373
|
+
const d = line.map((q, k) => `${k ? "L" : "M"}${q.x} ${q.y}`).join("");
|
|
374
|
+
out.push(`<path class="halo" d="${d}"/>`, `<path class="trend" d="${d}"/>`, `<text class="end-label" x="${end.x + 10}" y="${end.y + 4}">${Math.round(end.v * 100)} %</text>`);
|
|
375
|
+
}
|
|
376
|
+
out.push("</g>");
|
|
377
|
+
return { svg: out.join(""), ys: pts.map((p) => (p.waits ? [y(total(p.waits)), base] : [top, base])) };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
interface Chart {
|
|
381
|
+
svg: string;
|
|
382
|
+
/** Points for the tooltip: the abscissa and the hover targets per panel in viewBox units. */
|
|
383
|
+
hover: { x: number; y: Target[] }[];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function chart(pts: Point[]): Chart {
|
|
387
|
+
if (!pts.length) return { svg: "", hover: [] };
|
|
388
|
+
const lay = layout(pts);
|
|
389
|
+
const panels = [...PANELS.map((p, i) => panelSvg(p, i, pts, lay)), splitSvg(PANELS.length, pts, lay)];
|
|
390
|
+
const axisY = ROWS * PANEL_H - GAP;
|
|
391
|
+
const axis = lay.ticks.map((t) => {
|
|
392
|
+
const d = new Date(t * 1000);
|
|
393
|
+
return `<text class="x-tick" x="${r1(lay.x(t))}" y="${axisY + 20}">${two(d.getDate())}.${two(d.getMonth() + 1)}</text>`;
|
|
394
|
+
});
|
|
395
|
+
const svg = [
|
|
396
|
+
`<svg class="chart" viewBox="0 0 ${W} ${HEIGHT}" role="img" aria-label="Hours, tokens, cost and the split of the actual of tasks by close date; the values are in the table below">`,
|
|
397
|
+
...panels.map((p) => p.svg),
|
|
398
|
+
`<g class="x-axis">${axis.join("")}</g>`,
|
|
399
|
+
`<line class="crosshair" x1="0" x2="0" y1="${HEAD}" y2="${axisY}" visibility="hidden"/>`,
|
|
400
|
+
"</svg>",
|
|
401
|
+
].join("");
|
|
402
|
+
return { svg, hover: pts.map((pt, k) => ({ x: r1(lay.x(pt.t)), y: panels.map((p) => p.ys[k] ?? null) })) };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The chart: panels with a shared time axis — hours, actual to estimate, tokens, cost, the actual's split; no tasks — an
|
|
407
|
+
* empty string.
|
|
408
|
+
*/
|
|
409
|
+
export function chartSvg(pts: Point[]): string {
|
|
410
|
+
return chart(pts).svg;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ----------------------------------------------------------------------------
|
|
414
|
+
// Page
|
|
415
|
+
// ----------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
export interface Model {
|
|
418
|
+
repos: string[];
|
|
419
|
+
/** The page period: `30d`, `90d`…; empty or `all` — all time. */
|
|
420
|
+
since: string;
|
|
421
|
+
view: View;
|
|
422
|
+
/** When GitHub was read, epoch seconds. */
|
|
423
|
+
at: number;
|
|
424
|
+
errors: string[];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const PRESETS: [string, string][] = [["30d", "30 days"], ["90d", "90 days"], ["all", "all time"]];
|
|
428
|
+
|
|
429
|
+
// Palette — the tested "blue / orange" pair and utility tones for the light and dark themes.
|
|
430
|
+
const CSS = `
|
|
431
|
+
:root{color-scheme:light;--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink2:#52514e;--muted:#898781;--grid:#e1e0d9;--axis:#c3c2b7;--border:rgba(11,11,11,.10);--fact:#2a78d6;--trend:#eb6834;--good:#006300;--bad:#b42c2c;--warn:#f2b53a;--w-ci:#2a78d6;--w-merged:#1baf7a;--w-checks:#4a3aa7;--w-slot:#eda100;--w-rest:#d3d2ca}
|
|
432
|
+
@media (prefers-color-scheme:dark){:root{color-scheme:dark;--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--axis:#383835;--border:rgba(255,255,255,.10);--fact:#3987e5;--trend:#d95926;--good:#0ca30c;--bad:#e66767;--warn:#e0a52c;--w-ci:#3987e5;--w-merged:#199e70;--w-checks:#9085e9;--w-slot:#c98500;--w-rest:#4a4946}}
|
|
433
|
+
*{box-sizing:border-box}
|
|
434
|
+
body{margin:0;background:var(--page);color:var(--ink);font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
435
|
+
main{max-width:1180px;margin:0 auto;padding:24px 16px 48px}
|
|
436
|
+
h1{font-size:20px;margin:0 0 4px}
|
|
437
|
+
.tabs{display:flex;gap:4px;margin:0 0 16px;border-bottom:1px solid var(--border)}
|
|
438
|
+
.tabs a{padding:6px 12px;margin-bottom:-1px;text-decoration:none;color:var(--ink2);border-bottom:2px solid transparent}
|
|
439
|
+
.tabs a[aria-current=page]{color:var(--ink);border-bottom-color:var(--ink);font-weight:600}
|
|
440
|
+
a{color:inherit}
|
|
441
|
+
.sub,.filters,figcaption,.note{color:var(--ink2)}
|
|
442
|
+
.filters{margin:16px 0;display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
|
443
|
+
.filters a{padding:3px 10px;border-radius:999px;border:1px solid var(--border);text-decoration:none}
|
|
444
|
+
.filters a.cur{background:var(--ink);color:var(--surface);border-color:var(--ink)}
|
|
445
|
+
.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:16px;margin:0 0 16px}
|
|
446
|
+
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:0 0 16px}
|
|
447
|
+
.tile{margin:0}
|
|
448
|
+
.tile .label{color:var(--ink2)}
|
|
449
|
+
.tile .value{font-size:28px;font-weight:600;line-height:1.2;margin:2px 0}
|
|
450
|
+
.tile .delta,.tile .hint{font-size:13px;color:var(--ink2)}
|
|
451
|
+
.delta.good{color:var(--good)}.delta.bad{color:var(--bad)}
|
|
452
|
+
.errors{border-color:var(--bad)}
|
|
453
|
+
figure{position:relative}
|
|
454
|
+
figcaption{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:0 0 8px;font-size:13px}
|
|
455
|
+
.key{display:inline-flex;align-items:center;gap:6px}
|
|
456
|
+
.key svg{width:22px;height:12px}
|
|
457
|
+
svg.chart{display:block;width:100%;height:auto;touch-action:pan-y}
|
|
458
|
+
.chart text{font-size:12px;fill:var(--muted);font-variant-numeric:tabular-nums}
|
|
459
|
+
.chart .panel-title{font-size:13px;font-weight:600;fill:var(--ink)}
|
|
460
|
+
.chart .y-tick,.chart .y-tick-r{text-anchor:end}.chart .x-tick{text-anchor:middle}
|
|
461
|
+
.chart .legend{fill:var(--ink2)}
|
|
462
|
+
.chart .end-label{fill:var(--ink);font-weight:600}
|
|
463
|
+
.grid{stroke:var(--grid);stroke-width:1}
|
|
464
|
+
.band{fill:var(--ink);opacity:.05}
|
|
465
|
+
.ref{stroke:var(--muted);stroke-width:1}
|
|
466
|
+
.link{stroke:var(--axis);stroke-width:1}
|
|
467
|
+
.est{fill:var(--surface);stroke:var(--fact);stroke-width:1.5}
|
|
468
|
+
.fact{fill:var(--fact);stroke:var(--surface);stroke-width:2}
|
|
469
|
+
.fact.hl{r:6}
|
|
470
|
+
.trend{fill:none;stroke:var(--trend);stroke-width:2;stroke-linejoin:round;stroke-linecap:round}
|
|
471
|
+
.halo{fill:none;stroke:var(--surface);stroke-width:5;stroke-linejoin:round;stroke-linecap:round}
|
|
472
|
+
.seg.ci{fill:var(--w-ci)}.seg.merged{fill:var(--w-merged)}.seg.checks{fill:var(--w-checks)}.seg.slot{fill:var(--w-slot)}.seg.rest{fill:var(--w-rest)}
|
|
473
|
+
.bar.hl rect{stroke:var(--ink);stroke-width:1}
|
|
474
|
+
.crosshair{stroke:var(--axis);stroke-width:1}
|
|
475
|
+
#tip{position:absolute;pointer-events:none;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:8px 10px;box-shadow:0 4px 16px rgba(0,0,0,.12);font-size:13px;max-width:320px;z-index:1}
|
|
476
|
+
#tip .head{font-weight:600}#tip .when{color:var(--ink2);margin-bottom:4px}
|
|
477
|
+
#tip .row{display:flex;justify-content:space-between;gap:16px}#tip .row span{color:var(--ink2)}#tip .row b{font-weight:600}
|
|
478
|
+
.scroll{overflow-x:auto}
|
|
479
|
+
table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
|
|
480
|
+
th,td{text-align:right;padding:5px 8px;border-bottom:1px solid var(--grid);white-space:nowrap}
|
|
481
|
+
th{color:var(--ink2);font-weight:500;font-size:13px;white-space:normal;vertical-align:bottom}
|
|
482
|
+
th:nth-child(-n+3),td:nth-child(-n+3){text-align:left}
|
|
483
|
+
td:nth-child(2){white-space:normal;min-width:220px}
|
|
484
|
+
tr.hl td{background:var(--page)}
|
|
485
|
+
${SESSIONS_CSS}`;
|
|
486
|
+
|
|
487
|
+
// Tooltip: the task nearest to the cursor (horizontally — four times stricter than vertically), the crosshair, its marks
|
|
488
|
+
// and its row in the table.
|
|
489
|
+
const SCRIPT = `
|
|
490
|
+
(() => {
|
|
491
|
+
const svg = document.querySelector("svg.chart"); if (!svg) return;
|
|
492
|
+
const data = JSON.parse(document.getElementById("pts").textContent);
|
|
493
|
+
const tip = document.getElementById("tip"), cross = svg.querySelector(".crosshair"), fig = svg.parentElement;
|
|
494
|
+
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
|
|
495
|
+
let cur = -1;
|
|
496
|
+
const mark = (i, on) => {
|
|
497
|
+
svg.querySelectorAll('[data-i="' + i + '"]').forEach((c) => c.classList.toggle("hl", on));
|
|
498
|
+
const row = document.querySelector('tr[data-task="' + CSS.escape(data.pts[i].task) + '"]'); if (row) row.classList.toggle("hl", on);
|
|
499
|
+
};
|
|
500
|
+
const hide = () => { if (cur >= 0) mark(cur, false); cur = -1; tip.hidden = true; cross.setAttribute("visibility", "hidden"); };
|
|
501
|
+
svg.addEventListener("pointermove", (ev) => {
|
|
502
|
+
const box = svg.getBoundingClientRect(), k = data.w / box.width;
|
|
503
|
+
const x = (ev.clientX - box.left) * k, y = (ev.clientY - box.top) * k;
|
|
504
|
+
let best = -1, dist = Infinity;
|
|
505
|
+
// a point — by its height, a bar — by its span: anywhere inside it is zero vertical distance
|
|
506
|
+
const dy = (py) => (Array.isArray(py) ? (y < py[0] ? py[0] - y : y > py[1] ? y - py[1] : 0) : py - y);
|
|
507
|
+
data.pts.forEach((p, i) => p.y.forEach((py) => { if (py === null) return; const d = (p.x - x) ** 2 + dy(py) ** 2 / 16; if (d < dist) { dist = d; best = i; } }));
|
|
508
|
+
if (best < 0) return hide();
|
|
509
|
+
if (best !== cur) {
|
|
510
|
+
if (cur >= 0) mark(cur, false);
|
|
511
|
+
cur = best; mark(cur, true);
|
|
512
|
+
const p = data.pts[cur];
|
|
513
|
+
tip.replaceChildren(el("div", "head", p.head), el("div", "when", p.when), ...p.rows.map(([label, value]) => { const r = el("div", "row"); r.append(el("span", "", label), el("b", "", value)); return r; }));
|
|
514
|
+
cross.setAttribute("x1", p.x); cross.setAttribute("x2", p.x); cross.setAttribute("visibility", "visible");
|
|
515
|
+
}
|
|
516
|
+
tip.hidden = false;
|
|
517
|
+
const f = fig.getBoundingClientRect(), left = ev.clientX - f.left + 14;
|
|
518
|
+
tip.style.left = Math.max(0, left + tip.offsetWidth > f.width ? left - tip.offsetWidth - 28 : left) + "px";
|
|
519
|
+
const top = ev.clientY - f.top + 14;
|
|
520
|
+
tip.style.top = (ev.clientY + 14 + tip.offsetHeight > innerHeight ? top - tip.offsetHeight - 28 : top) + "px";
|
|
521
|
+
});
|
|
522
|
+
svg.addEventListener("pointerleave", hide);
|
|
523
|
+
})();
|
|
524
|
+
`;
|
|
525
|
+
|
|
526
|
+
const taskId = (p: Point) => `${p.repo}#${p.number}`;
|
|
527
|
+
const dash = (v: number | null, f: (v: number) => string) => (v === null ? "—" : f(v));
|
|
528
|
+
const ratio = (p: Point) => (p.est ? `×${(p.fact / p.est).toFixed(2)}` : "—");
|
|
529
|
+
const pair = (est: number | null, fact: number | null, f: (v: number) => string) => (est === null ? dash(fact, f) : `${f(est)} → ${dash(fact, f)}`);
|
|
530
|
+
|
|
531
|
+
function tile(label: string, value: string, delta: string, hint: string): string {
|
|
532
|
+
return `<figure class="tile card"><div class="label">${esc(label)}</div><div class="value">${esc(value)}</div>${delta}<div class="hint">${esc(hint)}</div></figure>`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* A metric tile: the median of the latest tasks and its change vs the previous ones; less is better, the sign
|
|
537
|
+
* duplicates the color.
|
|
538
|
+
*/
|
|
539
|
+
function shiftTile(label: string, s: Shift, f: (v: number) => string, extra = ""): string {
|
|
540
|
+
if (s.now === null) return tile(label, "—", "", "no data");
|
|
541
|
+
let delta = `<div class="delta">nothing to compare with yet</div>`;
|
|
542
|
+
if (s.prev) {
|
|
543
|
+
const pct = Math.round((s.now / s.prev - 1) * 100);
|
|
544
|
+
delta = pct === 0 ? `<div class="delta">no change vs the previous ${WINDOW}</div>` : `<div class="delta ${pct < 0 ? "good" : "bad"}">${pct < 0 ? "▼" : "▲"} ${pct < 0 ? "−" : "+"}${Math.abs(pct)} % vs the previous ${WINDOW} (${esc(f(s.prev))})</div>`;
|
|
545
|
+
}
|
|
546
|
+
return tile(label, f(s.now), delta + extra, `median of the last ${s.n}`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The waits tile: the median of a task's waits for CI, after the merge and for checks, and the median of each kind — the
|
|
551
|
+
* latest tasks and the previous ones; tasks before the split aren't in it.
|
|
552
|
+
*/
|
|
553
|
+
function waitsTile(v: View): string {
|
|
554
|
+
const k = v.waitKinds;
|
|
555
|
+
let extra = k.now ? `<div class="delta">${esc(waitsBrief(k.now))}</div>` : "";
|
|
556
|
+
if (k.prev) extra += `<div class="delta">previous ${WINDOW}: ${esc(waitsBrief(k.prev))}</div>`;
|
|
557
|
+
return shiftTile("Waits per task: CI, merge, checks", v.waits, (x) => `${Math.round(x)} min`, extra);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The pricing tile: the $ share of the leading model of the latest tasks, the others and the previous ones — as lines;
|
|
562
|
+
* a model change moves the $ trend.
|
|
563
|
+
*/
|
|
564
|
+
function modelsTile(m: ModelShift): string {
|
|
565
|
+
const label = "Cost by model";
|
|
566
|
+
const hint = `share of $ over the last ${WINDOW}: the $ trend mixes the flow with the model's rate — compare the flow by tokens and output`;
|
|
567
|
+
if (!m.now.length) return tile(label, "—", "", hint);
|
|
568
|
+
const [top, share] = m.now[0]!;
|
|
569
|
+
let delta = `<div class="delta">${esc(top)}${m.now.length > 1 ? `; ${esc(sharesTxt(m.now.slice(1)))}` : ""}</div>`;
|
|
570
|
+
if (m.prev.length) delta += `<div class="delta">previous ${WINDOW}: ${esc(sharesTxt(m.prev))}</div>`;
|
|
571
|
+
return tile(label, `${Math.round(share * 100)} %`, delta, hint);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function tiles(v: View): string {
|
|
575
|
+
const c = v.calib;
|
|
576
|
+
const accuracy = c.n
|
|
577
|
+
? tile("Estimate accuracy", `×${c.k}`, `<div class="delta">within ×0.5…×2 — ${Math.trunc(c.share! * 100)} % of tasks</div>`, `actual to estimate, median of the last ${c.n} pairs`)
|
|
578
|
+
: tile("Estimate accuracy", "—", "", "no tasks with an est estimate and an actual");
|
|
579
|
+
return `<section class="tiles">${accuracy}${shiftTile("Hours per task", v.h, (x) => `${fmtH(x)} h`)}${shiftTile("Tokens per task", v.tok, (x) => `${fmtH(x, 1)}M`)}${shiftTile("Cost per task", v.usd, fmtUsd)}${modelsTile(v.models)}${waitsTile(v)}</section>`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const LEGEND = [
|
|
583
|
+
`<span class="key"><svg viewBox="0 0 22 12"><circle class="est" cx="11" cy="6" r="3.5"/></svg>estimate</span>`,
|
|
584
|
+
`<span class="key"><svg viewBox="0 0 22 12"><circle class="fact" cx="11" cy="6" r="4"/></svg>actual</span>`,
|
|
585
|
+
`<span class="key"><svg viewBox="0 0 22 12"><path class="trend" d="M1 6L21 6"/></svg>median of the last ${WINDOW} tasks</span>`,
|
|
586
|
+
`<span>log scales, the split — linear: an equal step is an equal ratio</span>`,
|
|
587
|
+
].join("");
|
|
588
|
+
|
|
589
|
+
function table(pts: Point[], many: boolean): string {
|
|
590
|
+
const head = ["Closed", "Task", "Type", "Estimate, h", "Actual, h", "Actual to estimate", "Tokens, M: forecast", "Tokens, M: actual", "Cost: forecast", "Cost: actual", "Wait for CI, min", "After the merge, min", "Checks, min (slot)", "Rest, min", "Coverage"];
|
|
591
|
+
const min = (v: number) => String(Math.round(v));
|
|
592
|
+
const body = [...pts].reverse().map((p) => {
|
|
593
|
+
const cells = [
|
|
594
|
+
fmtLocal(p.t).slice(0, 10),
|
|
595
|
+
`<a href="${esc(p.url)}">${esc(`${many ? p.repo : ""}#${p.number}`)}</a> ${esc(p.title)}`,
|
|
596
|
+
esc(p.type), dash(p.est, fmtH), fmtH(p.fact), ratio(p), dash(p.tokEst, fmtTok), dash(p.tok, fmtTok), dash(p.usdEst, fmtUsd), dash(p.usd, fmtUsd),
|
|
597
|
+
p.waits ? min(p.waits.ci) : "—", p.waits ? min(p.waits.merged) : "—", p.waits ? `${min(p.waits.checks)} (${min(p.waits.slot)})` : "—", p.waits ? min(p.waits.rest) : "—", esc(p.cov),
|
|
598
|
+
];
|
|
599
|
+
return `<tr data-task="${esc(taskId(p))}">${cells.map((c) => `<td>${c}</td>`).join("")}</tr>`;
|
|
600
|
+
});
|
|
601
|
+
return `<section class="card scroll"><table><thead><tr>${head.map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${body.join("")}</tbody></table></section>`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
type Tab = "sessions" | "est";
|
|
605
|
+
// "Sessions" is the main tab: it shows what the in-progress tasks wait for; the chart is the second tab
|
|
606
|
+
const TABS: [Tab, string, string][] = [["sessions", "/", "Sessions"], ["est", "/est", "Estimate and actual"]];
|
|
607
|
+
const EST_TITLE = "Tasks: estimate, actual and cost";
|
|
608
|
+
const SESSIONS_TITLE = "Sessions: tasks in progress";
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The page frame: tabs, the title, the line under it (HTML), GitHub read errors — as a banner, the tab's content;
|
|
612
|
+
* `tail` — after `<main>`.
|
|
613
|
+
*/
|
|
614
|
+
function shell(o: { tab: Tab; title: string; repos: string[]; sub: string; errors: string[]; body: string; tail?: string }): string {
|
|
615
|
+
const tabs = TABS.map(([k, href, label]) => `<a href="${href}"${k === o.tab ? ' aria-current="page"' : ""}>${label}</a>`).join("");
|
|
616
|
+
return [
|
|
617
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">`,
|
|
618
|
+
`<title>${esc(o.title)} — ${esc(o.repos.join(", "))}</title><style>${CSS}</style></head><body><main>`,
|
|
619
|
+
`<nav class="tabs" aria-label="Tabs">${tabs}</nav>`,
|
|
620
|
+
`<h1>${esc(o.title)}</h1>`,
|
|
621
|
+
`<div class="sub">${o.sub}</div>`,
|
|
622
|
+
o.errors.length ? `<div class="card errors">${o.errors.map((e) => `<div>${esc(e)}</div>`).join("")}</div>` : "",
|
|
623
|
+
o.body,
|
|
624
|
+
// the script is outside <main>: self-refresh replaces <main> and mustn't replace itself
|
|
625
|
+
`</main>${o.tail ?? ""}</body></html>`,
|
|
626
|
+
].join("");
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** The tooltip's split rows: the parts and the waits' share; no split — why the split panel is empty there. */
|
|
630
|
+
function waitRows(p: Point): [string, string][] {
|
|
631
|
+
if (!p.waits) return [["Waits", NO_SPLIT]];
|
|
632
|
+
const s = waitShare(p.waits);
|
|
633
|
+
return [["Waits, min", waitsBrief(p.waits)], ...(s === null ? [] : [["Waits share", `${Math.round(s * 100)} %`] as [string, string]])];
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** The "Estimate and actual" tab: summary, chart, table. */
|
|
637
|
+
export function page(m: Model): string {
|
|
638
|
+
const pts = m.view.points;
|
|
639
|
+
const many = m.repos.length > 1;
|
|
640
|
+
const since = m.since || "all";
|
|
641
|
+
const presets = PRESETS.some(([k]) => k === since) ? PRESETS : ([[since, since], ...PRESETS] as [string, string][]);
|
|
642
|
+
const c = chart(pts);
|
|
643
|
+
const hover = {
|
|
644
|
+
w: W,
|
|
645
|
+
pts: pts.map((p, i) => ({
|
|
646
|
+
...c.hover[i]!, task: taskId(p), head: `${many ? p.repo : ""}#${p.number} ${p.title}`, when: [fmtLocal(p.t), p.type, p.cov === "full" ? "" : `coverage ${p.cov}`].filter(Boolean).join(" · "),
|
|
647
|
+
rows: [["Hours: estimate → actual", `${pair(p.est, p.fact, fmtH)}${p.est ? ` (${ratio(p)})` : ""}`], ["Tokens, M", pair(p.tokEst, p.tok, fmtTok)], ["Cost", pair(p.usdEst, p.usd, fmtUsd)], ...waitRows(p)],
|
|
648
|
+
})),
|
|
649
|
+
};
|
|
650
|
+
const body = pts.length
|
|
651
|
+
? `${tiles(m.view)}<figure class="card"><figcaption>${LEGEND}</figcaption>${c.svg}<div id="tip" hidden></div></figure>${table(pts, many)}` +
|
|
652
|
+
`<script id="pts" type="application/json">${JSON.stringify(hover).replace(/</g, "\\u003c")}</script><script>${SCRIPT}</script>`
|
|
653
|
+
: `<p class="card">No closed tasks with an actual${since === "all" ? "" : " in this period"} — <code>est fact --write</code> records the actual when a task is closed.</p>`;
|
|
654
|
+
return shell({
|
|
655
|
+
tab: "est", title: EST_TITLE, repos: m.repos, errors: m.errors,
|
|
656
|
+
sub: `${esc(m.repos.join(", "))} · closed tasks with an actual by close date · GitHub read ${esc(fmtLocal(m.at))} · <a href="/est?since=${esc(since)}&refresh=1">Refresh</a>`,
|
|
657
|
+
body: `<nav class="filters"><span>Period:</span>${presets.map(([k, label]) => `<a${k === since ? ' class="cur"' : ""} href="/est?since=${esc(k)}">${esc(label)}</a>`).join("")}</nav>${body}`,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
interface SessionsModel {
|
|
662
|
+
repos: string[];
|
|
663
|
+
view: SessionsView;
|
|
664
|
+
/** When GitHub was read, epoch seconds. */
|
|
665
|
+
at: number;
|
|
666
|
+
/** When the pins and transcripts were read: session record ages count from it. */
|
|
667
|
+
now: number;
|
|
668
|
+
errors: string[];
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** The "Sessions" tab: "Waiting for the owner" and a line per "In progress" task; refreshes itself. */
|
|
672
|
+
function sessionsPage(m: SessionsModel): string {
|
|
673
|
+
return shell({
|
|
674
|
+
tab: "sessions", title: SESSIONS_TITLE, repos: m.repos, errors: m.errors,
|
|
675
|
+
sub: `${esc(m.repos.join(", "))} · tasks in progress, their sessions and what they wait for · refreshes every ${REFRESH} s · GitHub read ${esc(fmtLocal(m.at))} · <a href="/?refresh=1">Refresh</a>`,
|
|
676
|
+
body: sessionsBody(m.view, m.now, m.repos.length > 1),
|
|
677
|
+
tail: refreshScript(REFRESH),
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* What to show on "Sessions": in addition to GitHub — the pins of "In progress" tasks, their sessions' transcripts,
|
|
683
|
+
* the repositories' recent sessions and the hosts of the proxy's routes.
|
|
684
|
+
*/
|
|
685
|
+
function sessionsInput(live: Record<string, Live>, registry: Registry, now: number, hosts: string[]): SessionsInput {
|
|
686
|
+
const pins = readPins();
|
|
687
|
+
const ids = Object.entries(live).flatMap(([repo, l]) => l.tasks.flatMap((t) => pins[`${repo}#${t.number}`] ?? []));
|
|
688
|
+
const others = Object.fromEntries(Object.keys(live).map((repo) => [repo, repoTranscripts(registry[repo]?.paths ?? [], now - RECENT)]));
|
|
689
|
+
return { pins, transcripts: findTranscripts(ids), live, others, hosts, port: proxyPort() };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Hosts of the task-env proxy's routes — read on every open, like the pins: a local request, and a route leaves with
|
|
694
|
+
* its container. The proxy isn't running — no addresses and no error; another failure — a line in the tab's banner.
|
|
695
|
+
*/
|
|
696
|
+
async function readHosts(): Promise<{ hosts: string[]; error: string | null }> {
|
|
697
|
+
try {
|
|
698
|
+
return { hosts: (await proxyHosts()) ?? [], error: null };
|
|
699
|
+
} catch (e) {
|
|
700
|
+
return { hosts: [], error: (e as Error).message };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ----------------------------------------------------------------------------
|
|
705
|
+
// Server and CLI
|
|
706
|
+
// ----------------------------------------------------------------------------
|
|
707
|
+
|
|
708
|
+
/** `--help`, `-h` (tests/standards/cli-help): the synopsis, what it does and examples. */
|
|
709
|
+
const USAGE = `dashboard [--repo o/r | --all-repos] [--since 90d] [--port N] [--no-open]
|
|
710
|
+
|
|
711
|
+
A task page: «Sessions» (tasks in progress and what they wait for) and «Estimate and actual» (a chart by close time).
|
|
712
|
+
|
|
713
|
+
--repo o/r repository (by default — from the directory's git remote origin)
|
|
714
|
+
--all-repos all repositories in the personal est registry
|
|
715
|
+
--since 90d default period of the «Estimate and actual» tab (days d, weeks w, months m); without it — all time
|
|
716
|
+
--port N port (by default — a free one)
|
|
717
|
+
--no-open don't open the browser (the same — BROWSER=none; BROWSER=<command> — what to open it with)
|
|
718
|
+
|
|
719
|
+
Examples:
|
|
720
|
+
dashboard
|
|
721
|
+
dashboard --all-repos --since 30d`;
|
|
722
|
+
|
|
723
|
+
// GitHub Projects are unreachable from a Claude Code cloud session, and its localhost isn't visible to the user's
|
|
724
|
+
// browser.
|
|
725
|
+
const CLOUD_ERR = "Claude Code cloud session: GitHub projects aren't reachable from here, and a page on its localhost can't be opened — the dashboard runs only in a local session (docs/cloud-sessions.md in Elves)";
|
|
726
|
+
const isCloud = () => process.env.CLAUDE_CODE_REMOTE === "true";
|
|
727
|
+
// An agent's sandbox (Claude Code with allowLocalBinding off) denies listen, and Bun reports that as EADDRINUSE on any
|
|
728
|
+
// port: picking another port won't help, running outside the sandbox will.
|
|
729
|
+
const LISTEN_ERR = "can't listen on 127.0.0.1: opening a local port is denied here, not busy (an agent's sandbox?) — run the dashboard outside the sandbox; in Claude Code sandbox.network.allowLocalBinding: true allows it inside";
|
|
730
|
+
|
|
731
|
+
/** Whether a free port on 127.0.0.1 can be taken: `Bun.serve` reports a denied listen the same way as a busy port. */
|
|
732
|
+
function canListen(): boolean {
|
|
733
|
+
try {
|
|
734
|
+
Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response(null) }).stop(true);
|
|
735
|
+
return true;
|
|
736
|
+
} catch {
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const nowTs = () => Date.now() / 1000;
|
|
741
|
+
|
|
742
|
+
interface Options {
|
|
743
|
+
repos: string[];
|
|
744
|
+
registry: Registry;
|
|
745
|
+
since: string;
|
|
746
|
+
port: number;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** The page period → its start, epoch seconds; empty and `all` — all time. */
|
|
750
|
+
function sinceTs(s: string): number | null {
|
|
751
|
+
return s && s !== "all" ? nowTs() - parseSince(s) : null;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function serve(o: Options) {
|
|
755
|
+
let cache: { at: number; rows: Record<string, Row[]>; errors: string[] } | null = null;
|
|
756
|
+
/** Project rows: cached for TTL; a repository with an error — a line on the page, all with errors — an exception. */
|
|
757
|
+
const load = () => {
|
|
758
|
+
if (cache && nowTs() - cache.at < TTL) return cache;
|
|
759
|
+
const rows: Record<string, Row[]> = {};
|
|
760
|
+
const errors: string[] = [];
|
|
761
|
+
for (const full of o.repos) {
|
|
762
|
+
try {
|
|
763
|
+
rows[full] = new Repo(full, o.registry).projectRows();
|
|
764
|
+
} catch (e) {
|
|
765
|
+
if (!(e instanceof EstError)) throw e;
|
|
766
|
+
errors.push(`${full}: ${e.message}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (!Object.keys(rows).length) throw new EstError(errors.join("; "));
|
|
770
|
+
return (cache = { at: nowTs(), rows, errors });
|
|
771
|
+
};
|
|
772
|
+
let live: { at: number; live: Record<string, Live>; errors: string[] } | null = null;
|
|
773
|
+
let pending: Promise<void> | null = null;
|
|
774
|
+
/**
|
|
775
|
+
* GitHub for "Sessions" — a request per repository, in parallel and without blocking the server; a repository's
|
|
776
|
+
* error — a line on the page. Doesn't throw: it also runs in the background.
|
|
777
|
+
*/
|
|
778
|
+
const refreshLive = () =>
|
|
779
|
+
(pending ??= (async () => {
|
|
780
|
+
const got = await Promise.all(
|
|
781
|
+
o.repos.map(async (full): Promise<[string, Live | null, string | null]> => {
|
|
782
|
+
try {
|
|
783
|
+
return [full, await liveQuery(full, new Repo(full, o.registry).projectMeta().id), null];
|
|
784
|
+
} catch (e) {
|
|
785
|
+
return [full, null, `${full}: ${e instanceof EstError ? e.message : String((e as Error)?.stack ?? e)}`];
|
|
786
|
+
}
|
|
787
|
+
}),
|
|
788
|
+
);
|
|
789
|
+
live = { at: nowTs(), live: Object.fromEntries(got.filter(([, l]) => l).map(([full, l]) => [full, l!])), errors: got.flatMap(([, , err]) => (err ? [err] : [])) };
|
|
790
|
+
})().finally(() => (pending = null)));
|
|
791
|
+
const html = (body: string, status = 200) => new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
792
|
+
const failure = (msg: string, status: number, tab: Tab) =>
|
|
793
|
+
html(`<!doctype html><html lang="en"><meta charset="utf-8"><title>Dashboard: error</title><style>${CSS}</style><main><h1>${tab === "est" ? EST_TITLE : SESSIONS_TITLE}</h1><p class="card errors">${esc(msg)}</p><p><a href="${tab === "est" ? "/est" : "/"}?refresh=1">Refresh</a></p></main></html>`, status);
|
|
794
|
+
/** The "Estimate and actual" tab: the period — from the address, otherwise from --since. */
|
|
795
|
+
const estTab = (url: URL) => {
|
|
796
|
+
const since = url.searchParams.get("since") ?? o.since;
|
|
797
|
+
let from: number | null;
|
|
798
|
+
try {
|
|
799
|
+
from = sinceTs(since);
|
|
800
|
+
} catch (e) {
|
|
801
|
+
if (e instanceof EstError) return failure(e.message, 400, "est");
|
|
802
|
+
throw e;
|
|
803
|
+
}
|
|
804
|
+
const data = load();
|
|
805
|
+
return html(page({ repos: o.repos, since, view: view(data.rows, from), at: data.at, errors: data.errors }));
|
|
806
|
+
};
|
|
807
|
+
/**
|
|
808
|
+
* The "Sessions" tab: pins and transcripts — afresh on every open, GitHub — from the cache; if it's stale, the page
|
|
809
|
+
* comes from it, and a fresh one is gathered in the background for the next refresh: the tab refreshes itself and
|
|
810
|
+
* doesn't wait for GitHub.
|
|
811
|
+
*/
|
|
812
|
+
const sessionsTab = async () => {
|
|
813
|
+
if (!live) await refreshLive();
|
|
814
|
+
else if (nowTs() - live.at >= TTL) void refreshLive();
|
|
815
|
+
const l = live!;
|
|
816
|
+
const routes = await readHosts();
|
|
817
|
+
const now = nowTs();
|
|
818
|
+
const v = sessionsView(sessionsInput(l.live, o.registry, now, routes.hosts), now);
|
|
819
|
+
return html(sessionsPage({ repos: o.repos, view: v, at: l.at, now, errors: routes.error ? [...l.errors, routes.error] : l.errors }));
|
|
820
|
+
};
|
|
821
|
+
const server = Bun.serve({
|
|
822
|
+
hostname: "127.0.0.1",
|
|
823
|
+
port: o.port,
|
|
824
|
+
async fetch(req) {
|
|
825
|
+
const url = new URL(req.url);
|
|
826
|
+
// the page names tasks of private repositories: a foreign Host is a site that pointed its name at 127.0.0.1
|
|
827
|
+
// (DNS rebinding)
|
|
828
|
+
if (!["127.0.0.1", "localhost"].includes(url.hostname)) return new Response("foreign Host", { status: 403 });
|
|
829
|
+
const tab = TABS.find(([, href]) => href === url.pathname)?.[0];
|
|
830
|
+
if (!tab) return new Response("no such page", { status: 404 });
|
|
831
|
+
if (url.searchParams.has("refresh")) {
|
|
832
|
+
cache = live = null;
|
|
833
|
+
// an address without refresh: reloading the tab doesn't re-read GitHub
|
|
834
|
+
const since = url.searchParams.get("since");
|
|
835
|
+
return new Response(null, { status: 303, headers: { location: tab === "est" && since ? `/est?since=${encodeURIComponent(since)}` : url.pathname } });
|
|
836
|
+
}
|
|
837
|
+
try {
|
|
838
|
+
return tab === "est" ? estTab(url) : await sessionsTab();
|
|
839
|
+
} catch (e) {
|
|
840
|
+
if (e instanceof EstError) return failure(e.message, 502, tab);
|
|
841
|
+
return failure(String((e as Error)?.stack ?? e), 500, tab);
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
return { server, refreshLive };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Opens the address in the browser: the command from BROWSER, otherwise the system one; returns a line for output. */
|
|
849
|
+
function openBrowser(url: string, open: boolean): string {
|
|
850
|
+
if (!open) return "not opening (--no-open)";
|
|
851
|
+
const browser = process.env.BROWSER;
|
|
852
|
+
if (browser === "none") return "not opening (BROWSER=none)";
|
|
853
|
+
const cmd = browser ? [browser, url] : process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
|
|
854
|
+
const p = spawn(cmd[0]!, cmd.slice(1), { stdio: "ignore", detached: true });
|
|
855
|
+
p.on("error", (e) => console.error(`browser didn't open (${e.message}) — open the address yourself`));
|
|
856
|
+
p.unref();
|
|
857
|
+
return `opening ${url}`;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function main(argv: string[]): number | null {
|
|
861
|
+
try {
|
|
862
|
+
const { values } = parseArgs({
|
|
863
|
+
args: argv,
|
|
864
|
+
options: { repo: { type: "string" }, "all-repos": { type: "boolean", default: false }, since: { type: "string", default: "" }, port: { type: "string", default: "0" }, "no-open": { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false } },
|
|
865
|
+
});
|
|
866
|
+
if (values.help) {
|
|
867
|
+
console.log(USAGE);
|
|
868
|
+
return 0;
|
|
869
|
+
}
|
|
870
|
+
if (isCloud()) throw new EstError(CLOUD_ERR);
|
|
871
|
+
if (values.repo && values["all-repos"]) throw new EstError("--repo and --all-repos are mutually exclusive");
|
|
872
|
+
sinceTs(values.since);
|
|
873
|
+
const port = Number(values.port);
|
|
874
|
+
if (!/^\d+$/.test(values.port) || port > 65535) throw new EstError(`--port: expected a port 0…65535, got «${values.port}»`);
|
|
875
|
+
const registry = loadRegistry();
|
|
876
|
+
const repos = values["all-repos"] ? Object.keys(registry) : [resolveRepo(values.repo)];
|
|
877
|
+
if (!repos.length) throw new EstError("the est repository registry is empty — pass --repo owner/repo");
|
|
878
|
+
let started: ReturnType<typeof serve>;
|
|
879
|
+
try {
|
|
880
|
+
started = serve({ repos, registry, since: values.since, port });
|
|
881
|
+
} catch (e) {
|
|
882
|
+
if ((e as { code?: unknown } | null)?.code !== "EADDRINUSE") throw e;
|
|
883
|
+
// port 0 can't be busy; a port is busy only if a free one can still be taken
|
|
884
|
+
if (port === 0 || !canListen()) throw new EstError(LISTEN_ERR);
|
|
885
|
+
throw new EstError(`port ${port} is busy — pass another --port or drop it: a free one will be taken`);
|
|
886
|
+
}
|
|
887
|
+
const { server, refreshLive } = started;
|
|
888
|
+
const url = `http://127.0.0.1:${server.port}/`;
|
|
889
|
+
console.log(`dashboard: ${url} — ${repos.join(", ")}; stop with Ctrl+C`);
|
|
890
|
+
// the main tab opens without waiting for GitHub: it's read while the browser opens; the project rows for
|
|
891
|
+
// "Estimate and actual" — when that tab opens: across all repositories it takes tens of seconds, "Sessions"
|
|
892
|
+
// doesn't wait for them
|
|
893
|
+
void refreshLive();
|
|
894
|
+
console.log(`browser: ${openBrowser(url, !values["no-open"])}`);
|
|
895
|
+
return null; // the server lives until stopped
|
|
896
|
+
} catch (e) {
|
|
897
|
+
if (e instanceof EstError) {
|
|
898
|
+
console.error(`error: ${e.message}`);
|
|
899
|
+
return 1;
|
|
900
|
+
}
|
|
901
|
+
if (e && typeof e === "object" && String((e as { code?: unknown }).code).startsWith("ERR_PARSE_ARGS")) {
|
|
902
|
+
console.error(`${(e as Error).message}\n\n${USAGE}`);
|
|
903
|
+
return 2;
|
|
904
|
+
}
|
|
905
|
+
throw e;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
if (import.meta.main) {
|
|
910
|
+
const code = main(process.argv.slice(2));
|
|
911
|
+
if (code !== null) process.exitCode = code;
|
|
912
|
+
}
|