@worca/app 1.2.0 → 1.3.0-rc.2
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/README.md +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
// ui/public/team-metrics-view.mjs
|
|
2
|
+
// Team metrics page renderers (team-metrics-design.md §4.10; mockup boards 1–3). Pure DOM:
|
|
3
|
+
// every function takes an aggregate() result (src/shared/team-metrics/aggregate.mjs) and
|
|
4
|
+
// returns detached elements. app.js owns fetch, mounting and delegated events.
|
|
5
|
+
import { niceScale, roundedTopBar } from './stats-view.mjs';
|
|
6
|
+
import { safeHttpUrl } from '../../src/shared/team-metrics/aggregate.mjs';
|
|
7
|
+
|
|
8
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
9
|
+
const MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
10
|
+
const MONTH_FULL = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
11
|
+
/** Series palette (mockup legend order): blue-ink, amber-ink, violet-ink, green-ink, red-ink, ink-3. */
|
|
12
|
+
export const SERIES_COLORS = ['var(--blue-ink)', 'var(--amber-ink)', 'var(--violet-ink)', 'var(--green-ink)', 'var(--red-ink)', 'var(--ink-3)'];
|
|
13
|
+
const RESULT_STACKS = [
|
|
14
|
+
{ key: 'done', label: 'Done', color: 'var(--green-ink)' },
|
|
15
|
+
{ key: 'failed', label: 'Failed', color: 'var(--red-ink)' },
|
|
16
|
+
{ key: 'stopped', label: 'Stopped', color: 'var(--ink-3)' },
|
|
17
|
+
];
|
|
18
|
+
const PREV_WORD = { 'this-month': 'month', 'last-month': 'month', quarter: 'quarter', year: 'year', custom: 'period' };
|
|
19
|
+
|
|
20
|
+
export const TM_FMT = {
|
|
21
|
+
usd: (n) => `$${(Number(n) || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
|
|
22
|
+
duration: (ms) => {
|
|
23
|
+
if (ms == null) return '—';
|
|
24
|
+
const s = Math.round(ms / 1000);
|
|
25
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
|
|
26
|
+
return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
|
|
27
|
+
},
|
|
28
|
+
pct: (v) => (v == null ? '—' : `${Math.round(v * 100)}%`),
|
|
29
|
+
day: (iso) => { const d = new Date(iso); return `${MO[d.getUTCMonth()]} ${d.getUTCDate()}`; },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function h(doc, tag, cls, text) {
|
|
33
|
+
const n = doc.createElement(tag);
|
|
34
|
+
if (cls) n.className = cls;
|
|
35
|
+
if (text != null) n.textContent = text;
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
function s(doc, tag, attrs = {}) {
|
|
39
|
+
const n = doc.createElementNS(SVG_NS, tag);
|
|
40
|
+
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v));
|
|
41
|
+
return n;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function renderScopeOptions(select, scopes, selectedId, { doc = globalThis.document } = {}) {
|
|
45
|
+
select.replaceChildren();
|
|
46
|
+
const groups = [['Projects', scopes.projects || []], ['Workspaces', scopes.workspaces || []]];
|
|
47
|
+
let any = false;
|
|
48
|
+
for (const [label, items] of groups) {
|
|
49
|
+
if (!items.length) continue;
|
|
50
|
+
const g = h(doc, 'optgroup'); g.label = label;
|
|
51
|
+
for (const it of items) {
|
|
52
|
+
const o = h(doc, 'option', null, it.label);
|
|
53
|
+
o.value = it.id;
|
|
54
|
+
if (it.recordedIn) o.title = `recorded in ${it.recordedIn}`;
|
|
55
|
+
g.append(o);
|
|
56
|
+
}
|
|
57
|
+
select.append(g);
|
|
58
|
+
any = true;
|
|
59
|
+
}
|
|
60
|
+
if (!any) {
|
|
61
|
+
const o = h(doc, 'option', null, 'Nothing is recording yet'); o.value = '';
|
|
62
|
+
select.append(o);
|
|
63
|
+
}
|
|
64
|
+
select.disabled = !any;
|
|
65
|
+
select.value = any ? selectedId : '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ago(iso, now) {
|
|
69
|
+
const ms = now - Date.parse(iso);
|
|
70
|
+
if (!Number.isFinite(ms)) return null;
|
|
71
|
+
const m = Math.round(ms / 60_000);
|
|
72
|
+
if (m < 1) return 'just now';
|
|
73
|
+
if (m < 60) return `${m} min ago`;
|
|
74
|
+
const hrs = Math.round(m / 60);
|
|
75
|
+
return hrs < 48 ? `${hrs} h ago` : `${Math.round(hrs / 24)} d ago`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Topbar right slot: "Synced 2 min ago | 3 runs pending push | Refresh | Push now". */
|
|
79
|
+
/**
|
|
80
|
+
* `busy` (or refresh.pending from a deferred read): a fetch from origin is running after the
|
|
81
|
+
* response — the dot becomes a small spinner, the text leads with "Checking origin…" and Refresh
|
|
82
|
+
* is held, until the fetch's `changed` event reloads the page (docs/team-metrics.md "Loading").
|
|
83
|
+
*/
|
|
84
|
+
export function renderSyncChip({ sync = [], stats = {}, refresh = null, fetchError = null }, { doc = globalThis.document, now = Date.now(), busy = false } = {}) {
|
|
85
|
+
const checking = !!busy || !!refresh?.pending;
|
|
86
|
+
const chip = h(doc, 'div', `sync-chip-inner${checking ? ' is-busy' : ''}`);
|
|
87
|
+
const pending = sync.reduce((a, x) => a + (x.pending || 0), 0);
|
|
88
|
+
const lastError = sync.map((x) => x.lastError).find(Boolean) || null;
|
|
89
|
+
const pushHintText = sync.map((x) => x.hint).find(Boolean) || null;
|
|
90
|
+
// Newest of BOTH stamps: `lastSyncAt || fetchedAt` showed the last push even when a newer fetch
|
|
91
|
+
// had just run, so the chip could claim "Synced 2 h ago" right after a successful refresh.
|
|
92
|
+
const synced = sync.flatMap((x) => [x.lastSyncAt, x.fetchedAt]).filter(Boolean).sort().pop() || null;
|
|
93
|
+
const tone = lastError || fetchError ? 'red' : pending ? 'amber' : 'green';
|
|
94
|
+
if (checking) { const sp = h(doc, 'span', 'tm-busy-spin'); sp.setAttribute('aria-hidden', 'true'); chip.append(sp); }
|
|
95
|
+
else chip.append(h(doc, 'span', `dot ${tone}`));
|
|
96
|
+
const txt = h(doc, 'span', 'sync-text');
|
|
97
|
+
if (checking) chip.setAttribute('aria-busy', 'true');
|
|
98
|
+
if (checking) txt.append(h(doc, 'span', 'tm-checking', 'Checking origin…'), synced ? ' · ' : '');
|
|
99
|
+
if (checking && !synced) { /* the first fetch: nothing older to date */ }
|
|
100
|
+
else txt.append(synced ? (checking ? 'synced ' : 'Synced ') : 'Not synced yet');
|
|
101
|
+
if (synced) txt.append(h(doc, 'b', null, ago(synced, now)));
|
|
102
|
+
if (pending) { txt.append(' | '); txt.append(h(doc, 'b', null, String(pending)), ` run${pending === 1 ? '' : 's'} pending push`); }
|
|
103
|
+
if (stats.unknownV) txt.append(` | ${stats.unknownV} records need a newer Worca`);
|
|
104
|
+
// Only when the throttle actually skipped the fetch: a throttled refresh that still did its
|
|
105
|
+
// regular 60 s fetch serves fresh data, and "refresh again in N s" would be a lie.
|
|
106
|
+
if (refresh?.limited && !refresh.fetched) txt.append(` | refresh again in ${Math.ceil(refresh.retryInMs / 1000)} s`);
|
|
107
|
+
chip.append(txt);
|
|
108
|
+
const refreshBtn = h(doc, 'button', `btn-ghost btn-mini tm-refresh${checking ? ' busy' : ''}`, 'Refresh'); refreshBtn.type = 'button';
|
|
109
|
+
if (checking) refreshBtn.disabled = true;
|
|
110
|
+
chip.append(refreshBtn);
|
|
111
|
+
// "Push now" exists only while there is something to push: with an empty outbox
|
|
112
|
+
// the flush is a no-op, and a permanently visible (disabled) button read as an
|
|
113
|
+
// action that was somehow unavailable rather than one that was not needed.
|
|
114
|
+
if (pending > 0) {
|
|
115
|
+
const pushBtn = h(doc, 'button', 'btn-ghost btn-mini tm-push-now', 'Push now'); pushBtn.type = 'button';
|
|
116
|
+
chip.append(pushBtn);
|
|
117
|
+
}
|
|
118
|
+
const err = lastError || fetchError;
|
|
119
|
+
if (err) {
|
|
120
|
+
// §4.7: the stderr verbatim. <pre> + white-space:pre-wrap, so a multi-line remote: block is
|
|
121
|
+
// readable instead of collapsed to its first line.
|
|
122
|
+
const e = h(doc, 'pre', 'hint mono tm-sync-error', String(err).trim());
|
|
123
|
+
e.title = String(err);
|
|
124
|
+
chip.append(e);
|
|
125
|
+
}
|
|
126
|
+
// §4.7 also asks for the "exempt worca-metrics" hint next to the stderr, as on the Projects cell.
|
|
127
|
+
if (pushHintText) chip.append(h(doc, 'small', 'hint tm-sync-hint', pushHintText));
|
|
128
|
+
return chip;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function deltaChip(doc, text, title) {
|
|
132
|
+
if (text == null) return null;
|
|
133
|
+
const c = h(doc, 'span', 'stat-delta', text);
|
|
134
|
+
if (title) c.title = title;
|
|
135
|
+
return c;
|
|
136
|
+
}
|
|
137
|
+
const signedPct = (v) => (v == null ? null : `${v >= 0 ? '+' : '−'}${Math.abs(Math.round(v * 100))}%`);
|
|
138
|
+
|
|
139
|
+
/** The six mockup tile glyphs, path data verbatim from boards 1–2 (`svg.stat-ico`). */
|
|
140
|
+
const TILE_ICONS = Object.freeze({
|
|
141
|
+
spend: ['M12 3v18M17 7.5c0-1.9-2.2-3-5-3s-5 1.1-5 3 2.2 2.6 5 3 5 1.1 5 3-2.2 3-5 3-5-1.1-5-3'],
|
|
142
|
+
runs: ['M5 20v-7M12 20V4M19 20v-11'],
|
|
143
|
+
costPerRun: ['circle:12,12,8', 'M12 8v8M9.5 10.5h5'],
|
|
144
|
+
duration: ['circle:12,12,8', 'M12 8v4l3 2'],
|
|
145
|
+
autonomy: ['M4 12h4l2-5 4 10 2-5h4'],
|
|
146
|
+
cycles: ['M3 12a9 9 0 1 0 3-6.7', 'M3 4v4h4'],
|
|
147
|
+
});
|
|
148
|
+
function tileIcon(doc, key) {
|
|
149
|
+
const svg = s(doc, 'svg', { class: 'stat-ico', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': 1.9, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' });
|
|
150
|
+
for (const d of TILE_ICONS[key] || []) {
|
|
151
|
+
if (d.startsWith('circle:')) { const [cx, cy, r] = d.slice(7).split(','); svg.append(s(doc, 'circle', { cx, cy, r })); }
|
|
152
|
+
else svg.append(s(doc, 'path', { d }));
|
|
153
|
+
}
|
|
154
|
+
return svg;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function tile(doc, { label, chip, value, sub, meterPct = null, icon = null }) {
|
|
158
|
+
const card = h(doc, 'section', 'card stat-tile');
|
|
159
|
+
const lab = h(doc, 'div', 'stat-label');
|
|
160
|
+
if (icon) lab.append(tileIcon(doc, icon));
|
|
161
|
+
lab.append(h(doc, 'span', null, label));
|
|
162
|
+
if (chip) lab.append(chip);
|
|
163
|
+
card.append(lab, h(doc, 'div', 'stat-value mono', value));
|
|
164
|
+
if (meterPct != null) {
|
|
165
|
+
const m = h(doc, 'span', 'stat-meter');
|
|
166
|
+
const f = h(doc, 'span', 'stat-meter-fill'); f.style.width = `${Math.max(0, Math.min(100, meterPct))}%`;
|
|
167
|
+
m.append(f); card.append(m);
|
|
168
|
+
}
|
|
169
|
+
const subEl = h(doc, 'small', 'stat-sub');
|
|
170
|
+
for (const part of sub) subEl.append(typeof part === 'string' ? part : h(doc, 'b', null, part.b));
|
|
171
|
+
card.append(subEl);
|
|
172
|
+
return card;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function renderTmKpiRow(agg, { doc = globalThis.document, now = Date.now(), scopeKind = 'project' } = {}) {
|
|
176
|
+
const k = agg.kpis;
|
|
177
|
+
const d = agg.deltas;
|
|
178
|
+
const prevTitle = PREV_WORD[agg.range.range] ? `vs prev. ${PREV_WORD[agg.range.range]}` : null;
|
|
179
|
+
const withTitle = (t) => (t && prevTitle ? `${t} ${prevTitle}` : t);
|
|
180
|
+
const row = h(doc, 'div', 'stat-row tm-kpis');
|
|
181
|
+
const month = MONTH_FULL[new Date(now).getUTCMonth()];
|
|
182
|
+
// spendThisMonthUsd is computed over the current calendar month regardless of the selected
|
|
183
|
+
// range, so it is only meaningful when "now" actually falls inside that range — e.g. for
|
|
184
|
+
// range=last-month every record predates the current month and the sub-line would always
|
|
185
|
+
// read "$0.00 so far in <this month>" under a correct, non-zero Spend headline.
|
|
186
|
+
const nowInRange = (agg.range.startMs == null || now >= agg.range.startMs) && (agg.range.endMs == null || now < agg.range.endMs);
|
|
187
|
+
row.append(tile(doc, {
|
|
188
|
+
icon: 'spend', label: 'Spend', chip: deltaChip(doc, d && withTitle(signedPct(d.spendPct))), value: TM_FMT.usd(k.spendUsd),
|
|
189
|
+
sub: scopeKind === 'workspace'
|
|
190
|
+
? ['attributed to the workspace, not to single projects']
|
|
191
|
+
: nowInRange ? [{ b: TM_FMT.usd(k.spendThisMonthUsd) }, ` so far in ${month}`] : [],
|
|
192
|
+
}));
|
|
193
|
+
row.append(tile(doc, {
|
|
194
|
+
icon: 'runs', label: 'Runs', chip: deltaChip(doc, d && signedPct(d.runsPct)), value: String(k.runs),
|
|
195
|
+
sub: [{ b: String(k.done) }, ` done · ${k.failed} failed · ${k.stopped} stopped · `, { b: TM_FMT.pct(k.successRate) }, ' success'],
|
|
196
|
+
}));
|
|
197
|
+
const costSub = ['median ', { b: TM_FMT.usd(k.costPerRunMedianUsd) }, ` · P90 ${TM_FMT.usd(k.costPerRunP90Usd)}`];
|
|
198
|
+
if (k.runsWithPr) costSub.push(` · with a PR ${TM_FMT.usd(k.costPerRunWithPrUsd)}`);
|
|
199
|
+
row.append(tile(doc, { icon: 'costPerRun', label: 'Cost per run', chip: deltaChip(doc, d && signedPct(d.costPerRunPct)), value: k.costPerRunUsd == null ? '—' : TM_FMT.usd(k.costPerRunUsd), sub: costSub }));
|
|
200
|
+
row.append(tile(doc, {
|
|
201
|
+
icon: 'duration', label: 'Duration', chip: deltaChip(doc, d && signedPct(d.durationPct)), value: TM_FMT.duration(k.durationMedianMs),
|
|
202
|
+
sub: ['median wall-clock · ', { b: TM_FMT.duration(k.machineMs) }, ' machine time in total'],
|
|
203
|
+
}));
|
|
204
|
+
row.append(tile(doc, {
|
|
205
|
+
icon: 'autonomy', label: 'Autonomy', chip: deltaChip(doc, d && d.autonomyPts != null ? `${d.autonomyPts >= 0 ? '+' : '−'}${Math.abs(d.autonomyPts)} pts` : null),
|
|
206
|
+
value: TM_FMT.pct(k.autonomy), meterPct: k.autonomy == null ? 0 : k.autonomy * 100,
|
|
207
|
+
sub: ['active ÷ wall-clock · ', { b: k.interventionsPerRun == null ? '—' : String(k.interventionsPerRun) }, ' interventions per run'],
|
|
208
|
+
}));
|
|
209
|
+
row.append(tile(doc, {
|
|
210
|
+
icon: 'cycles', label: 'Review cycles', chip: deltaChip(doc, d && d.reviewCycles != null ? `${d.reviewCycles >= 0 ? '+' : '−'}${Math.abs(d.reviewCycles)}` : null),
|
|
211
|
+
value: k.reviewCyclesMean == null ? '—' : String(k.reviewCyclesMean),
|
|
212
|
+
sub: ['mean implement→review loops · ', { b: TM_FMT.pct(k.convergeInOneRate) }, ' converge in one'],
|
|
213
|
+
}));
|
|
214
|
+
return row;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const CW = 560, CH = 240, L = 44, R = 12, T = 18, B = 26;
|
|
218
|
+
const PW = CW - L - R, PH = CH - T - B;
|
|
219
|
+
|
|
220
|
+
/** Stacked weekly column chart. stacks: [{key,label,color}], valueOf(pt,key) → number. */
|
|
221
|
+
export function renderStackedWeekChart({ title, weeks, stacks, valueOf, yFmt, tipFmt = yFmt, legendValue, hint = null, integer = false, headExtra = null }, { doc = globalThis.document } = {}) {
|
|
222
|
+
const card = h(doc, 'section', 'card chart-card');
|
|
223
|
+
const head = h(doc, 'div', 'card-head');
|
|
224
|
+
head.append(h(doc, 'h2', null, title));
|
|
225
|
+
if (headExtra) head.append(headExtra); // a control that belongs to THIS chart (e.g. Group by)
|
|
226
|
+
card.append(head);
|
|
227
|
+
const legend = h(doc, 'div', 'chart-legend');
|
|
228
|
+
for (const st of stacks) {
|
|
229
|
+
const item = h(doc, 'span', 'lg-item');
|
|
230
|
+
const sw = h(doc, 'span', 'lg-swatch'); sw.style.background = st.color;
|
|
231
|
+
item.append(sw, `${st.label} `, h(doc, 'b', 'mono', legendValue(st)));
|
|
232
|
+
legend.append(item);
|
|
233
|
+
}
|
|
234
|
+
card.append(legend);
|
|
235
|
+
if (!weeks.length) { card.append(h(doc, 'div', 'chart-empty hint', 'No runs in this range')); return card; }
|
|
236
|
+
const totals = weeks.map((pt) => stacks.reduce((a, st) => a + (valueOf(pt, st.key) || 0), 0));
|
|
237
|
+
const { step, top } = niceScale(Math.max(...totals, 0), integer);
|
|
238
|
+
const yOf = (v) => T + PH - (v / top) * PH;
|
|
239
|
+
const svg = s(doc, 'svg', { class: 'chart-svg', viewBox: `0 0 ${CW} ${CH}`, 'aria-label': title });
|
|
240
|
+
for (let v = 0; v <= top + 1e-9; v += step) {
|
|
241
|
+
svg.append(s(doc, 'line', { x1: L, y1: yOf(v), x2: CW - R, y2: yOf(v), stroke: v === 0 ? 'var(--line-2)' : 'var(--line)', 'stroke-width': 1 }));
|
|
242
|
+
const t = s(doc, 'text', { x: L - 6, y: yOf(v) + 3.5, 'text-anchor': 'end', 'font-size': 10.5, fill: 'var(--ink-3)' });
|
|
243
|
+
// class="ch-ytick" as in stats-view.mjs chartScaffold (:415-455) — the Stats stylesheet already
|
|
244
|
+
// styles that class, and without it the new charts render their ticks unstyled.
|
|
245
|
+
t.setAttribute('class', 'ch-ytick');
|
|
246
|
+
t.textContent = yFmt(v); svg.append(t);
|
|
247
|
+
}
|
|
248
|
+
const band = PW / weeks.length;
|
|
249
|
+
const every = weeks.length <= 10 ? 1 : Math.ceil(weeks.length / 8);
|
|
250
|
+
weeks.forEach((pt, i) => {
|
|
251
|
+
const bx = L + i * band;
|
|
252
|
+
const w = Math.max(1, Math.min(24, band - 4)); // band - 4 goes negative past ~126 weeks ("All time")
|
|
253
|
+
let acc = 0;
|
|
254
|
+
const tipLines = [];
|
|
255
|
+
for (const st of stacks) {
|
|
256
|
+
const v = valueOf(pt, st.key) || 0;
|
|
257
|
+
if (v <= 0) continue;
|
|
258
|
+
const y0 = yOf(acc + v), y1 = yOf(acc);
|
|
259
|
+
const isTop = stacks.slice(stacks.indexOf(st) + 1).every((o) => !(valueOf(pt, o.key) > 0));
|
|
260
|
+
svg.append(s(doc, 'path', { d: isTop ? roundedTopBar(bx + (band - w) / 2, y0, w, y1 - y0) : `M${bx + (band - w) / 2},${y1} v${y0 - y1} h${w} v${y1 - y0} Z`, fill: st.color }));
|
|
261
|
+
acc += v;
|
|
262
|
+
// TM_FMT.usd, not yFmt: the axis formatter rounds to whole dollars for tick labels, so a
|
|
263
|
+
// $4.12 week read "Auto: $4" and anything under $0.50 read "$0". Ticks keep yFmt.
|
|
264
|
+
tipLines.push(`${st.label}: ${tipFmt(v)}`);
|
|
265
|
+
}
|
|
266
|
+
if (i % every === 0 || i === weeks.length - 1) {
|
|
267
|
+
const t = s(doc, 'text', { x: bx + band / 2, y: CH - 8, 'text-anchor': 'middle', 'font-size': 10.5, fill: 'var(--ink-3)' });
|
|
268
|
+
t.textContent = TM_FMT.day(new Date(pt.weekStartMs).toISOString()); svg.append(t);
|
|
269
|
+
}
|
|
270
|
+
const hit = s(doc, 'rect', { class: 'ch-hit', x: bx, y: T, width: band, height: PH, tabindex: 0, role: 'img', 'aria-label': `Week of ${TM_FMT.day(new Date(pt.weekStartMs).toISOString())}: ${tipLines.join(', ') || 'none'}` });
|
|
271
|
+
hit.dataset.tip = `Week of ${TM_FMT.day(new Date(pt.weekStartMs).toISOString())}\n${tipLines.join('\n') || 'No runs'}`;
|
|
272
|
+
svg.append(hit);
|
|
273
|
+
});
|
|
274
|
+
const fig = h(doc, 'figure', 'chart-fig'); fig.append(svg); card.append(fig);
|
|
275
|
+
// Parity with stats-view.mjs chartScaffold (:415-455): an sr-only table is the accessible
|
|
276
|
+
// equivalent of the bars. Without it the whole series is unreachable to a screen reader, which
|
|
277
|
+
// would be a regression against the page this one is modelled on.
|
|
278
|
+
const srt = h(doc, 'table', 'sr-only');
|
|
279
|
+
const srh = h(doc, 'tr');
|
|
280
|
+
for (const c of ['Week', ...stacks.map((st) => st.label)]) srh.append(h(doc, 'th', null, c));
|
|
281
|
+
srt.append(srh);
|
|
282
|
+
for (const pt of weeks) {
|
|
283
|
+
const tr = h(doc, 'tr');
|
|
284
|
+
tr.append(h(doc, 'td', null, TM_FMT.day(new Date(pt.weekStartMs).toISOString())));
|
|
285
|
+
for (const st of stacks) tr.append(h(doc, 'td', null, tipFmt(valueOf(pt, st.key) || 0)));
|
|
286
|
+
srt.append(tr);
|
|
287
|
+
}
|
|
288
|
+
card.append(srt);
|
|
289
|
+
if (hint) card.append(h(doc, 'small', 'hint', hint));
|
|
290
|
+
return card;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const BREAKDOWN_SPECS = {
|
|
294
|
+
workflow: { title: 'By workflow', sub: 'share of spend', cols: [['label', 'Workflow'], ['runs', 'Runs'], ['usd', 'Spend'], ['perRunUsd', 'Per run'], ['successRate', 'Success'], ['share', '']] },
|
|
295
|
+
source: { title: 'By ticket', sub: 'task source · top 5', limit: 5, cols: [['label', 'Source'], ['runs', 'Runs'], ['usd', 'Spend'], ['perRunUsd', 'Per run'], ['cyclesMean', 'Cycles']] },
|
|
296
|
+
actor: { title: 'By actor', sub: 'who ran it', cols: [['label', 'Actor'], ['runs', 'Runs'], ['usd', 'Spend'], ['perRunUsd', 'Per run'], ['successRate', 'Success']] },
|
|
297
|
+
// No spend column: a run's cost is not a per-project fact. Runs touched and files changed are;
|
|
298
|
+
// the bar is the share of runs in range that touched the project. Cost questions go through
|
|
299
|
+
// the row filter, which narrows every panel to the runs that touched it.
|
|
300
|
+
project: { title: 'By project touched', sub: 'click a project to see only the runs that touched it', cols: [['label', 'Project'], ['runs', 'Runs touched'], ['filesChanged', 'Files changed'], ['runShare', '']] },
|
|
301
|
+
models: { title: 'By model mix', sub: 'models seen in the run', cols: [['label', 'Models'], ['runs', 'Runs'], ['usd', 'Spend'], ['perRunUsd', 'Per run']] },
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
function cellText(key, row) {
|
|
305
|
+
switch (key) {
|
|
306
|
+
case 'usd': case 'perRunUsd': return row[key] == null ? '—' : TM_FMT.usd(row[key]);
|
|
307
|
+
case 'successRate': return TM_FMT.pct(row[key]);
|
|
308
|
+
case 'cyclesMean': case 'filesChanged': return row[key] == null ? '—' : String(row[key]);
|
|
309
|
+
default: return String(row[key] ?? '—');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function renderBreakdownTable(dim, rows, { doc = globalThis.document, sort = null, activeKey = null, homeSlug = null } = {}) {
|
|
314
|
+
const spec = BREAKDOWN_SPECS[dim];
|
|
315
|
+
const card = h(doc, 'section', 'card chart-card tm-breakdown');
|
|
316
|
+
const head = h(doc, 'div', 'card-head');
|
|
317
|
+
head.append(h(doc, 'h2', null, spec.title), h(doc, 'small', 'hint', spec.sub));
|
|
318
|
+
card.append(head);
|
|
319
|
+
const table = h(doc, 'table', 'tm-tbl');
|
|
320
|
+
table.dataset.dim = dim;
|
|
321
|
+
const thr = h(doc, 'tr');
|
|
322
|
+
for (const [key, label] of spec.cols) {
|
|
323
|
+
const th = h(doc, 'th', key === 'label' ? null : 'num', label);
|
|
324
|
+
if (label) { th.dataset.sort = key; th.setAttribute('role', 'button'); th.tabIndex = 0; }
|
|
325
|
+
if (sort && sort.key === key) th.setAttribute('aria-sort', sort.dir === 'asc' ? 'ascending' : 'descending');
|
|
326
|
+
thr.append(th);
|
|
327
|
+
}
|
|
328
|
+
const thead = h(doc, 'thead');
|
|
329
|
+
thead.append(thr);
|
|
330
|
+
table.append(thead);
|
|
331
|
+
let sorted = [...rows];
|
|
332
|
+
if (sort && sort.key) {
|
|
333
|
+
const dir = sort.dir === 'asc' ? 1 : -1;
|
|
334
|
+
sorted.sort((a, b) => (a.isNone - b.isNone) || (a[sort.key] > b[sort.key] ? dir : a[sort.key] < b[sort.key] ? -dir : 0));
|
|
335
|
+
}
|
|
336
|
+
if (spec.limit) sorted = sorted.filter((r) => !r.isNone).slice(0, spec.limit).concat(sorted.filter((r) => r.isNone));
|
|
337
|
+
const tbody = h(doc, 'tbody');
|
|
338
|
+
for (const r of sorted) {
|
|
339
|
+
const tr = h(doc, 'tr', `${r.isNone ? 'dim' : ''}${activeKey === r.key ? ' on' : ''}`.trim() || null);
|
|
340
|
+
tr.dataset.filterDim = dim;
|
|
341
|
+
tr.dataset.filterKey = r.key;
|
|
342
|
+
tr.tabIndex = 0;
|
|
343
|
+
for (const [key] of spec.cols) {
|
|
344
|
+
const td = h(doc, 'td', key === 'label' ? null : 'num mono');
|
|
345
|
+
if (key === 'label') {
|
|
346
|
+
td.append(h(doc, 'span', null, r.label));
|
|
347
|
+
if (r.sub) td.append(' ', h(doc, 'small', 'hint', r.sub));
|
|
348
|
+
if (dim === 'project' && homeSlug && r.key === homeSlug) td.append(' ', h(doc, 'span', 'badge violet', 'metrics home'));
|
|
349
|
+
} else if (key === 'share' || key === 'runShare') {
|
|
350
|
+
const bar = h(doc, 'span', 'tm-share'); const fill = h(doc, 'span', 'tm-share-fill');
|
|
351
|
+
fill.style.width = `${Math.round((r[key] || 0) * 100)}%`; bar.append(fill); td.append(bar);
|
|
352
|
+
if (key === 'runShare') td.title = `${Math.round((r[key] || 0) * 100)}% of the runs in range touched it`;
|
|
353
|
+
} else td.textContent = cellText(key, r);
|
|
354
|
+
// Some runs in the row predate per-project file counts: the number covers the others.
|
|
355
|
+
if (key === 'filesChanged' && r.filesUnknown > 0 && r.filesChanged != null) td.title = `${r.filesUnknown} older run${r.filesUnknown === 1 ? '' : 's'} without per-project file counts not included`;
|
|
356
|
+
tr.append(td);
|
|
357
|
+
}
|
|
358
|
+
tbody.append(tr);
|
|
359
|
+
}
|
|
360
|
+
if (!sorted.length) {
|
|
361
|
+
// `.hint{display:block}` (style.css:355) on a <td> takes the cell out of table layout and the
|
|
362
|
+
// row collapses. Keep the class on an inner <small>.
|
|
363
|
+
const tr = h(doc, 'tr'); const td = h(doc, 'td'); td.colSpan = spec.cols.length;
|
|
364
|
+
td.append(h(doc, 'small', 'hint', 'No runs in this range')); tr.append(td); tbody.append(tr);
|
|
365
|
+
}
|
|
366
|
+
table.append(tbody);
|
|
367
|
+
card.append(table);
|
|
368
|
+
return card;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function renderRunsTable(runs, { doc = globalThis.document, total = runs.length, limit = 50 } = {}) {
|
|
372
|
+
const card = h(doc, 'section', 'card tm-runs');
|
|
373
|
+
const head = h(doc, 'div', 'card-head tm-runs-head');
|
|
374
|
+
const title = h(doc, 'h2', null, 'Runs ');
|
|
375
|
+
title.append(h(doc, 'small', 'hint', `${total} in range`));
|
|
376
|
+
const exp = h(doc, 'button', 'btn-ghost btn-mini tm-export', 'Export CSV'); exp.type = 'button'; exp.disabled = total === 0;
|
|
377
|
+
head.append(title, exp);
|
|
378
|
+
card.append(head);
|
|
379
|
+
if (!runs.length) { card.append(h(doc, 'div', 'chart-empty hint', 'No runs recorded in this range')); return card; }
|
|
380
|
+
const table = h(doc, 'table', 'tm-tbl tm-run-tbl');
|
|
381
|
+
const thead = h(doc, 'thead'); const thr = h(doc, 'tr');
|
|
382
|
+
for (const c of ['Title', 'Workflow', 'Result', 'Cost', 'Duration', 'Cycles', 'PR', 'Actor', 'Started']) thr.append(h(doc, 'th', null, c));
|
|
383
|
+
thead.append(thr); table.append(thead);
|
|
384
|
+
const tbody = h(doc, 'tbody');
|
|
385
|
+
for (const r of runs.slice(0, limit)) {
|
|
386
|
+
const tr = h(doc, 'tr');
|
|
387
|
+
tr.append(h(doc, 'td', 'tm-title', r.title));
|
|
388
|
+
tr.append(h(doc, 'td', null, r.workflow ?? '—'));
|
|
389
|
+
const res = h(doc, 'td'); res.append(h(doc, 'span', `badge ${r.result === 'done' ? 'green' : r.result === 'failed' ? 'red' : 'grey'}`, r.result)); tr.append(res);
|
|
390
|
+
tr.append(h(doc, 'td', 'mono num', TM_FMT.usd(r.usd)));
|
|
391
|
+
tr.append(h(doc, 'td', 'mono num', TM_FMT.duration(r.wallMs)));
|
|
392
|
+
tr.append(h(doc, 'td', 'mono num', r.reviewCycles == null ? '—' : String(r.reviewCycles)));
|
|
393
|
+
const pr = h(doc, 'td', 'mono');
|
|
394
|
+
const href = safeHttpUrl(r.pr?.url); // defence in depth: toRunRow already dropped non-http(s) URLs
|
|
395
|
+
if (href) { const a = h(doc, 'a', null, r.pr.number != null ? `#${r.pr.number}` : 'PR'); a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer'; pr.append(a); }
|
|
396
|
+
else pr.textContent = r.pr?.number != null ? `#${r.pr.number}` : '—';
|
|
397
|
+
tr.append(pr);
|
|
398
|
+
tr.append(h(doc, 'td', null, r.actor ?? '—'));
|
|
399
|
+
tr.append(h(doc, 'td', 'mono', TM_FMT.day(r.startedAt)));
|
|
400
|
+
tbody.append(tr);
|
|
401
|
+
}
|
|
402
|
+
table.append(tbody); card.append(table);
|
|
403
|
+
const shown = Math.min(limit, runs.length);
|
|
404
|
+
const foot = h(doc, 'div', 'tm-runs-foot');
|
|
405
|
+
foot.append(h(doc, 'small', 'hint', `Showing ${shown} of ${total} · newest first`));
|
|
406
|
+
// §4.9 asks for "every record in range". The mockup's first paint is capped for layout, so the
|
|
407
|
+
// remaining rows must stay REACHABLE on screen, not only through Export CSV.
|
|
408
|
+
if (shown < runs.length) {
|
|
409
|
+
const more = h(doc, 'button', 'btn-ghost btn-mini tm-show-all', `Show all ${runs.length} runs`);
|
|
410
|
+
more.type = 'button';
|
|
411
|
+
foot.append(more);
|
|
412
|
+
}
|
|
413
|
+
card.append(foot);
|
|
414
|
+
return card;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* "Group by" lives in the Spend per week card head, not the page filter bar: it only
|
|
419
|
+
* changes that chart's stacking (Runs per week always stacks by result, the breakdown
|
|
420
|
+
* tables have fixed dimensions), so next to scope and range it read as a page setting
|
|
421
|
+
* it is not. `project` is offered for workspace scopes only — on a single project it is
|
|
422
|
+
* always one bar.
|
|
423
|
+
*/
|
|
424
|
+
export function renderGroupBySelect(groupBy, scopeKind, { doc = globalThis.document } = {}) {
|
|
425
|
+
const label = h(doc, 'label', 'tm-group');
|
|
426
|
+
label.append('Group by ');
|
|
427
|
+
const wrap = h(doc, 'span', 'select-wrap');
|
|
428
|
+
const sel = h(doc, 'select', 'select select-sm');
|
|
429
|
+
sel.id = 'tm-group';
|
|
430
|
+
sel.setAttribute('aria-label', 'Stack Spend per week by');
|
|
431
|
+
// The same three for both scope kinds: spend is never split across the projects a run touched.
|
|
432
|
+
const opts = ['workflow', 'result', 'actor'];
|
|
433
|
+
for (const v of opts) { const o = h(doc, 'option', null, v); o.value = v; o.selected = v === groupBy; sel.append(o); }
|
|
434
|
+
wrap.append(sel);
|
|
435
|
+
label.append(wrap);
|
|
436
|
+
return label;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function renderTeamMetricsBody(agg, { doc = globalThis.document, now = Date.now(), scopeKind = 'project', sort = {}, filter = {}, homeHint = null, runLimit = 50 } = {}) {
|
|
440
|
+
const wrap = h(doc, 'div', 'tm-body');
|
|
441
|
+
if (homeHint) wrap.append(homeHint);
|
|
442
|
+
wrap.append(renderTmKpiRow(agg, { doc, now, scopeKind }));
|
|
443
|
+
const charts = h(doc, 'div', 'charts-grid');
|
|
444
|
+
const stacks = agg.series.stackKeys.map((st, i) => ({ ...st, color: SERIES_COLORS[Math.min(i, SERIES_COLORS.length - 1)] }));
|
|
445
|
+
charts.append(renderStackedWeekChart({
|
|
446
|
+
title: 'Spend per week', weeks: agg.series.spend, stacks, valueOf: (pt, key) => pt.stacks[key],
|
|
447
|
+
yFmt: (v) => (v >= 1000 ? `$${(v / 1000).toFixed(1)}k` : `$${Math.round(v)}`), tipFmt: TM_FMT.usd, legendValue: (st) => TM_FMT.usd(st.totalUsd),
|
|
448
|
+
headExtra: renderGroupBySelect(agg.groupBy, scopeKind, { doc }),
|
|
449
|
+
}, { doc }));
|
|
450
|
+
const runTotals = Object.fromEntries(RESULT_STACKS.map((r) => [r.key, agg.series.runs.reduce((a, p) => a + (p[r.key] || 0), 0)]));
|
|
451
|
+
charts.append(renderStackedWeekChart({
|
|
452
|
+
title: 'Runs per week', weeks: agg.series.runs, stacks: RESULT_STACKS, valueOf: (pt, key) => pt[key],
|
|
453
|
+
yFmt: (v) => String(Math.round(v)), legendValue: (st) => String(runTotals[st.key]), integer: true,
|
|
454
|
+
}, { doc }));
|
|
455
|
+
wrap.append(charts);
|
|
456
|
+
const tables = h(doc, 'div', 'charts-grid tm-breakdowns');
|
|
457
|
+
const dims = scopeKind === 'workspace' ? ['project', 'workflow', 'source', 'actor', 'models'] : ['workflow', 'source', 'actor', 'models'];
|
|
458
|
+
for (const dim of dims) {
|
|
459
|
+
const rows = agg.breakdowns[dim];
|
|
460
|
+
if (!rows) continue;
|
|
461
|
+
tables.append(renderBreakdownTable(dim, rows, { doc, sort: sort[dim] || null, activeKey: filter[dim] ?? null, homeSlug: agg.homeSlug ?? null }));
|
|
462
|
+
}
|
|
463
|
+
wrap.append(tables);
|
|
464
|
+
wrap.append(renderRunsTable(agg.runs, { doc, total: agg.runs.length, limit: runLimit }));
|
|
465
|
+
return wrap;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The page's shape while its first payload loads: six KPI tiles, two chart cards, a breakdown
|
|
470
|
+
* grid and the runs card, each filled with shimmer bars instead of "Loading…", so the layout
|
|
471
|
+
* lands once and nothing jumps when the data arrives. Purely decorative (aria-hidden); the
|
|
472
|
+
* live region is #tm-body's aria-busy.
|
|
473
|
+
*/
|
|
474
|
+
export function renderTmSkeleton({ doc = globalThis.document, scopeKind = 'project' } = {}) {
|
|
475
|
+
const wrap = h(doc, 'div', 'tm-body tm-skeleton');
|
|
476
|
+
wrap.setAttribute('aria-hidden', 'true');
|
|
477
|
+
const bar = (w, extra = '') => h(doc, 'span', `skel${w ? ` ${w}` : ''}${extra ? ` ${extra}` : ''}`);
|
|
478
|
+
const row = h(doc, 'div', 'stat-row tm-kpis');
|
|
479
|
+
for (let i = 0; i < 6; i++) {
|
|
480
|
+
const t = h(doc, 'section', 'card stat-tile');
|
|
481
|
+
t.append(h(doc, 'div', 'stat-label').appendChild(bar('w40')).parentNode, h(doc, 'div', 'stat-value').appendChild(bar('w70', 'skel-lg')).parentNode, h(doc, 'small', 'stat-sub').appendChild(bar('w60')).parentNode);
|
|
482
|
+
row.append(t);
|
|
483
|
+
}
|
|
484
|
+
wrap.append(row);
|
|
485
|
+
const charts = h(doc, 'div', 'charts-grid');
|
|
486
|
+
for (let i = 0; i < 2; i++) {
|
|
487
|
+
const c = h(doc, 'section', 'card chart-card');
|
|
488
|
+
const head = h(doc, 'div', 'card-head'); head.append(h(doc, 'h2').appendChild(bar('w25')).parentNode);
|
|
489
|
+
c.append(head, h(doc, 'div', 'skel skel-block'));
|
|
490
|
+
charts.append(c);
|
|
491
|
+
}
|
|
492
|
+
wrap.append(charts);
|
|
493
|
+
const tables = h(doc, 'div', 'charts-grid tm-breakdowns');
|
|
494
|
+
for (let i = 0; i < (scopeKind === 'workspace' ? 5 : 4); i++) {
|
|
495
|
+
const c = h(doc, 'section', 'card');
|
|
496
|
+
const head = h(doc, 'div', 'card-head'); head.append(h(doc, 'h2').appendChild(bar('w25')).parentNode);
|
|
497
|
+
c.append(head);
|
|
498
|
+
for (let r = 0; r < 3; r++) { const line = h(doc, 'div', 'skel-row'); line.append(bar('w60'), bar('w25')); c.append(line); }
|
|
499
|
+
tables.append(c);
|
|
500
|
+
}
|
|
501
|
+
wrap.append(tables);
|
|
502
|
+
const runs = h(doc, 'section', 'card tm-runs');
|
|
503
|
+
const rh = h(doc, 'div', 'card-head tm-runs-head'); rh.append(h(doc, 'h2').appendChild(bar('w25')).parentNode);
|
|
504
|
+
runs.append(rh);
|
|
505
|
+
for (let r = 0; r < 5; r++) { const line = h(doc, 'div', 'skel-row'); line.append(bar('w25'), bar('w40'), bar('w25')); runs.append(line); }
|
|
506
|
+
wrap.append(runs);
|
|
507
|
+
return wrap;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function renderTmEmptyState({ doc = globalThis.document } = {}) {
|
|
511
|
+
const grid = h(doc, 'div', 'empty tm-empty');
|
|
512
|
+
const card = (step, title, body, href, label, primary) => {
|
|
513
|
+
const c = h(doc, 'section', 'card');
|
|
514
|
+
c.append(h(doc, 'div', 'tm-step', step), h(doc, 'h3', null, title));
|
|
515
|
+
const p = h(doc, 'p', 'hint');
|
|
516
|
+
for (const part of body) p.append(typeof part === 'string' ? part : h(doc, 'code', 'mono', part.code));
|
|
517
|
+
const a = h(doc, 'a', primary ? 'btn btn-primary btn-mini' : 'btn btn-ghost btn-mini', label); a.href = href;
|
|
518
|
+
c.append(p, a);
|
|
519
|
+
return c;
|
|
520
|
+
};
|
|
521
|
+
grid.append(
|
|
522
|
+
card('01 · PROJECT', 'Enable team metrics on a project',
|
|
523
|
+
['Creates a ', { code: 'worca-metrics' }, " branch on the project's origin. Every finished run is pushed there as one small file, from every teammate's machine. Nothing is added to your code branches."], '#projects', 'Go to Projects', true),
|
|
524
|
+
card('02 · WORKSPACE', 'Pick a metrics home for a workspace',
|
|
525
|
+
['Workspace runs touch several repositories, so their cost is attributed to the workspace and written to one member you choose. The choice lives on this machine and can be changed on the workspace card.'], '#workspaces', 'Go to Workspaces', false),
|
|
526
|
+
);
|
|
527
|
+
const foot = h(doc, 'small', 'hint tm-empty-foot', "Already enabled by a teammate? Worca checks each project's origin for the branch on start and every hour. ");
|
|
528
|
+
const check = h(doc, 'button', 'linkish tm-check-now', 'Check now'); check.type = 'button';
|
|
529
|
+
foot.append(check);
|
|
530
|
+
const out = h(doc, 'div');
|
|
531
|
+
out.append(grid, foot);
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
@@ -39,10 +39,11 @@ export function orbFrame(pts, t, { speed = ORB_SPEED, tilt = ORB_TILT } = {}) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* @returns {{el: Element, start: () => void, stop: () => void
|
|
43
|
-
* bare inline-block span. It survives being re-parented (the panel
|
|
44
|
-
* one orb into each rebuilt live row) because the phase is `now - t0`,
|
|
45
|
-
* frame counter, so a move never rewinds the spin.
|
|
42
|
+
* @returns {{el: Element, start: () => void, stop: () => void, morphTo: (f: number, ms?: number) => void, morph: () => number}}
|
|
43
|
+
* — `el` is a bare inline-block span. It survives being re-parented (the panel
|
|
44
|
+
* moves the one orb into each rebuilt live row) because the phase is `now - t0`,
|
|
45
|
+
* not a frame counter, so a move never rewinds the spin. `morphTo` drives the
|
|
46
|
+
* 0..1 morph factor the launcher pill uses to grow the sphere out of its mark.
|
|
46
47
|
*/
|
|
47
48
|
export function createThinkingOrb({ doc, win, size = 28.5, ink = '25,25,27' }) {
|
|
48
49
|
const el = doc.createElement('span');
|
|
@@ -72,6 +73,20 @@ export function createThinkingOrb({ doc, win, size = 28.5, ink = '25,25,27' }) {
|
|
|
72
73
|
else cv.remove();
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
// The ink follows the theme (spec D16): the resolved body colour is the --ink
|
|
77
|
+
// token in whatever scheme is active. Read on every start() and on the theme
|
|
78
|
+
// event app.js dispatches; keep the previous value whenever nothing parses
|
|
79
|
+
// (jsdom, a keyword, no stylesheet).
|
|
80
|
+
let inkRgb = ink;
|
|
81
|
+
function refreshInk() {
|
|
82
|
+
try {
|
|
83
|
+
const c = win.getComputedStyle ? win.getComputedStyle(doc.body).color : '';
|
|
84
|
+
const m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(c || '');
|
|
85
|
+
if (m) inkRgb = `${m[1]},${m[2]},${m[3]}`;
|
|
86
|
+
} catch { /* keep inkRgb */ }
|
|
87
|
+
}
|
|
88
|
+
if (typeof doc.addEventListener === 'function') doc.addEventListener('worca:theme', refreshInk);
|
|
89
|
+
|
|
75
90
|
const pts = orbPoints();
|
|
76
91
|
const R = size * 0.40;
|
|
77
92
|
const c = size / 2;
|
|
@@ -81,22 +96,45 @@ export function createThinkingOrb({ doc, win, size = 28.5, ink = '25,25,27' }) {
|
|
|
81
96
|
const t0 = clock();
|
|
82
97
|
let handle = null;
|
|
83
98
|
|
|
99
|
+
// Morph factor 0..1, scaling the sphere radius and every dot's alpha: at 0
|
|
100
|
+
// the dots sit invisible on the centre, at 1 the sphere is whole. The
|
|
101
|
+
// launcher pill grows the orb out of its mark with morphTo(1, ms) and sinks
|
|
102
|
+
// it back with morphTo(0, ms); the tween runs on the draw clock (ease-out
|
|
103
|
+
// cubic, from wherever the factor is), so no caller ticks it. Reduced motion
|
|
104
|
+
// snaps — the CSS half of that morph is an instant swap there too. The
|
|
105
|
+
// transcript's orb never calls it and paints at 1.
|
|
106
|
+
let morph = { from: 1, to: 1, t0: 0, dur: 0 };
|
|
107
|
+
function morphFactor(t) {
|
|
108
|
+
if (morph.dur <= 0) return morph.to;
|
|
109
|
+
const p = Math.min(1, Math.max(0, (t - morph.t0) / morph.dur));
|
|
110
|
+
return morph.from + (morph.to - morph.from) * (1 - (1 - p) ** 3);
|
|
111
|
+
}
|
|
112
|
+
function morphTo(target, durationMs = 0) {
|
|
113
|
+
const t = clock();
|
|
114
|
+
const to = Math.min(1, Math.max(0, Number(target) || 0));
|
|
115
|
+
morph = { from: morphFactor(t), to, t0: t, dur: reduced ? 0 : Math.max(0, Number(durationMs) || 0) };
|
|
116
|
+
}
|
|
117
|
+
|
|
84
118
|
function draw() {
|
|
85
119
|
handle = raf(draw);
|
|
86
120
|
if (doc.hidden) return;
|
|
87
121
|
ctx.clearRect(0, 0, size, size);
|
|
88
|
-
const
|
|
122
|
+
const t = clock();
|
|
123
|
+
const f = morphFactor(t);
|
|
124
|
+
const r = R * f;
|
|
125
|
+
const frame = orbFrame(pts, (t - t0) / 1000, { speed, tilt: ORB_TILT });
|
|
89
126
|
for (let k = 0; k < frame.length; k++) {
|
|
90
127
|
const [x, y, z] = frame[k];
|
|
91
128
|
const d = (z + 1) / 2; // 0 back … 1 front
|
|
92
129
|
ctx.beginPath();
|
|
93
|
-
ctx.arc(c + x *
|
|
94
|
-
ctx.fillStyle = `rgba(${
|
|
130
|
+
ctx.arc(c + x * r, c - y * r, 0.5 + d * 1.05, 0, 6.2832);
|
|
131
|
+
ctx.fillStyle = `rgba(${inkRgb},${((0.08 + d * 0.82) * f).toFixed(3)})`;
|
|
95
132
|
ctx.fill();
|
|
96
133
|
}
|
|
97
134
|
}
|
|
98
135
|
|
|
99
136
|
function start() {
|
|
137
|
+
refreshInk();
|
|
100
138
|
if (handle != null || !ctx || !raf) return;
|
|
101
139
|
handle = raf(draw);
|
|
102
140
|
}
|
|
@@ -106,5 +144,5 @@ export function createThinkingOrb({ doc, win, size = 28.5, ink = '25,25,27' }) {
|
|
|
106
144
|
}
|
|
107
145
|
|
|
108
146
|
start();
|
|
109
|
-
return { el, start, stop };
|
|
147
|
+
return { el, start, stop, ink: () => inkRgb, morphTo, morph: () => morphFactor(clock()) };
|
|
110
148
|
}
|