@cspeach/cli 1.1.30 → 1.1.31
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/.cspeach/project.json +4 -0
- package/README.md +11 -1
- package/dist/agent/loop.js +121 -8
- package/dist/cli.js +36 -14
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +8 -1
- package/dist/commands/plan-resume.js +24 -18
- package/dist/commands/plan-startup.js +49 -6
- package/dist/commands/team-discover.js +195 -0
- package/dist/commands/team-plan.js +585 -0
- package/dist/commands/team.js +375 -41
- package/dist/journey/chain.js +728 -0
- package/dist/journey/clean-core.js +213 -0
- package/dist/journey/flow.js +166 -0
- package/dist/journey/run.js +435 -0
- package/dist/journey/session-deps.js +77 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +9 -7
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +20 -1
- package/dist/projects/clean-core-confirm.js +156 -0
- package/dist/projects/detail-path.js +9 -4
- package/dist/projects/extract-clean-core.js +3 -0
- package/dist/projects/migration.js +4 -105
- package/dist/projects/plan-lane.js +67 -17
- package/dist/projects/plan-sources.js +13 -7
- package/dist/projects/project-folder.js +131 -0
- package/dist/projects/promote-command.js +39 -0
- package/dist/projects/save-command.js +83 -12
- package/dist/projects/saved-lines.js +17 -3
- package/dist/projects/sort-command.js +499 -0
- package/dist/projects/sort-core.js +248 -0
- package/dist/projects/workspace-retire.js +48 -0
- package/dist/projects/workspace.js +182 -121
- package/dist/register/discover.js +901 -0
- package/dist/register/findings.js +5 -2
- package/dist/register/offer.js +100 -8
- package/dist/register/picture.js +6 -2
- package/dist/register/plan-import.js +325 -0
- package/dist/register/records.js +25 -0
- package/dist/register/status-file/estate-parts.js +168 -0
- package/dist/register/status-file/plan-parts.js +68 -0
- package/dist/register/status-file/progress-chart.js +110 -0
- package/dist/register/status-file/register-map.js +11 -3
- package/dist/register/status-file/register-model.js +40 -6
- package/dist/register/status-file/register-parts.js +57 -11
- package/dist/register/status-file/register-status-file.js +24 -6
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +3 -0
- package/dist/register/status-html.js +3 -1
- package/dist/register/store.js +40 -2
- package/dist/register/workbooks.js +27 -7
- package/dist/register/xlsx.js +13 -3
- package/dist/register/xref.js +117 -0
- package/dist/renderer/startup-lines.js +11 -0
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/renderer/tool-widget.js +15 -1
- package/dist/repl/at-completer.js +6 -2
- package/dist/repl/at-picker.js +1 -1
- package/dist/repl/journey-entry.js +25 -0
- package/dist/repl.js +157 -39
- package/dist/session/backfill.js +342 -0
- package/dist/session/project-match.js +63 -0
- package/dist/session/resume.js +72 -24
- package/dist/session/schema.js +3 -1
- package/dist/session/store.js +96 -51
- package/dist/skill-catalog.js +2 -2
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/_filesystem-shared.js +174 -12
- package/dist/tools/_team-work-path.js +133 -0
- package/dist/tools/approval.js +7 -1
- package/dist/tools/filesystem/extract-xlsx.js +27 -3
- package/dist/tools/filesystem/file-edit.js +12 -4
- package/dist/tools/filesystem/file-read.js +8 -4
- package/dist/tools/filesystem/file-write.js +12 -4
- package/dist/tools/filesystem/glob.js +33 -8
- package/dist/tools/filesystem/grep.js +9 -4
- package/dist/tools/filesystem/read-document.js +6 -3
- package/dist/tools/project/cca_merge.js +8 -2
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +77 -4
- package/dist/tools/project/upgrade_merge_progress.js +8 -2
- package/dist/tools/sap-read.js +21 -4
- package/dist/tools/sap-write.js +200 -10
- package/dist/tools/subagent/read_agent.js +3 -0
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/transport-fit-check.js +117 -0
- package/dist/tools/transport-fit.js +15 -7
- package/dist/tools/transport.js +57 -2
- package/dist/ui/app.js +2 -2
- package/dist/ui/approval-modal.js +74 -6
- package/dist/ui/widgets/confirm-card.js +68 -5
- package/dist/util/open-in-browser.js +143 -0
- package/package.json +120 -120
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// GENERATED COPY — do not edit here. Edit viewer/src/lib/estate-parts.js,
|
|
3
|
+
// then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
|
|
4
|
+
// src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.
|
|
5
|
+
/**
|
|
6
|
+
* The estate: what custom code the system holds, before anyone splits it up.
|
|
7
|
+
*
|
|
8
|
+
* Drawn from register-core's `EstateSummary` (the newest discovery file, read
|
|
9
|
+
* the strict way) and nothing else, so the page, the status file and the
|
|
10
|
+
* workbook count the same packages. Two places wear it: the Estate section of
|
|
11
|
+
* a team project page, and the whole page of a folder the lead has run
|
|
12
|
+
* discovery into but not yet made a team project from.
|
|
13
|
+
*
|
|
14
|
+
* The same rules as every other part of this page: every value out of the
|
|
15
|
+
* folder goes through `cell()` (or `esc()` for our own words), colour is a
|
|
16
|
+
* class and never a style attribute, and a count is a count — "last change" is
|
|
17
|
+
* when the code was changed and is never called use.
|
|
18
|
+
*/
|
|
19
|
+
import { esc } from './register-map.js';
|
|
20
|
+
import { cell, n } from './register-parts.js';
|
|
21
|
+
import { formatWhen } from './register-model.js';
|
|
22
|
+
import { findingsByPackage, plainDate, shortUsageWindow, usageWindowOf } from '@cspeach/register-core';
|
|
23
|
+
export const NO_ESTATE = 'No estate inventory yet. The lead runs cspeach team discover.';
|
|
24
|
+
/** A discovery file is there but is not one this build can read (another kind or version). */
|
|
25
|
+
export const ESTATE_UNREADABLE = 'The estate inventory in this folder could not be read.';
|
|
26
|
+
/** As many package rows as a reader scans before the table stops helping. */
|
|
27
|
+
const PACKAGE_ROWS = 100;
|
|
28
|
+
const numberTile = (label, value, note) => `<li class="tile"><span class="tile-n">${n(value)}</span> <span class="tile-label">${esc(label)}</span> <span class="tile-note">${esc(note)}</span></li>`;
|
|
29
|
+
/**
|
|
30
|
+
* Whether runtime usage is recorded, in the tile's own short words. The source
|
|
31
|
+
* is not named on screen; the workbook's Usage sources sheet says which one.
|
|
32
|
+
* Only an ACTIVE source can say usage is recorded — empty, off and not read
|
|
33
|
+
* all mean nothing on this system records which programs run.
|
|
34
|
+
*
|
|
35
|
+
* A window under 90 days, or one that ended more than 90 days before the
|
|
36
|
+
* report, says so: it is too short (or too old) to say what is unused, and the
|
|
37
|
+
* tile must not read as if it could (rehearsal fix 2).
|
|
38
|
+
*
|
|
39
|
+
* @param {string} [reportAt] when the report was made; the inventory's own time when not given
|
|
40
|
+
*/
|
|
41
|
+
function usageWords(estate, reportAt) {
|
|
42
|
+
const active = (estate.usageSources ?? []).find((s) => s.state === 'active');
|
|
43
|
+
if (!active)
|
|
44
|
+
return 'none recorded on this system';
|
|
45
|
+
if (!active.period)
|
|
46
|
+
return 'recorded, period not stated';
|
|
47
|
+
const window = usageWindowOf(estate.usageSources);
|
|
48
|
+
const weak = window ? shortUsageWindow(window, reportAt || estate.takenAt || '') : null;
|
|
49
|
+
if (weak) {
|
|
50
|
+
const span = `${plainDate(window.from)} – ${plainDate(window.to)}`;
|
|
51
|
+
return weak.short
|
|
52
|
+
? `only ${weak.days} days (${span}) — too short to say what is unused`
|
|
53
|
+
: `${weak.days} days (${span}), ended over 90 days ago — too old to say what is unused`;
|
|
54
|
+
}
|
|
55
|
+
return `recorded from ${active.period.from} to ${active.period.to}`;
|
|
56
|
+
}
|
|
57
|
+
function tilesHtml(estate, reportAt) {
|
|
58
|
+
const t = estate.totals ?? {};
|
|
59
|
+
return '<ul class="tiles estate-tiles" data-role="estate-tiles">'
|
|
60
|
+
+ numberTile('Packages', t.packages ?? 0, 'with custom code')
|
|
61
|
+
+ numberTile('Code objects', t.codeObjects ?? 0, 'programs, classes, function groups')
|
|
62
|
+
+ numberTile('Non-code objects', t.nonCodeObjects ?? 0, 'tables, data elements and the rest')
|
|
63
|
+
+ numberTile('Outside packages', t.local ?? 0, 'local objects, never transported')
|
|
64
|
+
+ numberTile('Modifications of SAP objects', t.modifications ?? 0, t.noteCorrections ? `not counting ${n(t.noteCorrections)} note corrections` : 'changes to SAP\x27s own code')
|
|
65
|
+
+ `<li class="tile tile-words"><span class="tile-text">${esc(usageWords(estate, reportAt))}</span> <span class="tile-label">Usage data</span> <span class="tile-note">which programs run</span></li>`
|
|
66
|
+
+ '</ul>';
|
|
67
|
+
}
|
|
68
|
+
function unknownsHtml(estate) {
|
|
69
|
+
const list = estate.unknowns ?? [];
|
|
70
|
+
if (!list.length)
|
|
71
|
+
return '<div class="estate-unknowns" data-role="estate-unknowns"><h3>Unknowns</h3><p class="quiet-note">Nothing is unknown: every step of the inventory read.</p></div>';
|
|
72
|
+
return '<div class="estate-unknowns" data-role="estate-unknowns"><h3>Unknowns</h3><ul>'
|
|
73
|
+
+ list.map((u) => `<li><b>${cell(u.what)}</b> — ${u.why ? cell(u.why) : '<span class="quiet-note">no reason recorded</span>'}</li>`).join('')
|
|
74
|
+
+ '</ul></div>';
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* @param {Record<string, number>} [findings] open findings per package, over
|
|
78
|
+
* the objects a check has measured (register-core `findingsByPackage`); a
|
|
79
|
+
* package with none measured has no key and says so.
|
|
80
|
+
*/
|
|
81
|
+
function packagesHtml(estate, { inScope, findings }) {
|
|
82
|
+
const all = estate.packages ?? [];
|
|
83
|
+
const shown = all.slice(0, PACKAGE_ROWS);
|
|
84
|
+
const heads = ['Package', 'Area', 'Objects', ...(findings ? ['Open findings'] : []), 'Last change', ...(inScope ? ['In scope'] : [])]
|
|
85
|
+
.map((h) => `<th scope="col">${h}</th>`).join('');
|
|
86
|
+
const row = (p) => `<tr><td><span class="ident">${cell(p.name)}</span>${p.text ? ` <span class="quiet-note">${cell(p.text)}</span>` : ''}</td>`
|
|
87
|
+
+ `<td>${cell(p.area)}</td><td>${n(p.objects)}</td>`
|
|
88
|
+
+ (findings ? `<td>${Object.hasOwn(findings, p.name) ? n(findings[p.name]) : '<span class="quiet-note">not measured</span>'}</td>` : '')
|
|
89
|
+
+ `<td>${p.lastChanged ? cell(p.lastChanged.at) : '<span class="quiet-note">not recorded</span>'}</td>`
|
|
90
|
+
+ (inScope ? `<td>${p.inScope ? 'in scope' : '<span class="quiet-note">not in scope</span>'}</td>` : '')
|
|
91
|
+
+ '</tr>';
|
|
92
|
+
const more = all.length - shown.length;
|
|
93
|
+
return `<div class="scroller"><table data-role="estate-packages" aria-label="Packages with custom code"><thead><tr>${heads}</tr></thead>`
|
|
94
|
+
+ `<tbody>${shown.map(row).join('')}</tbody></table></div>`
|
|
95
|
+
+ (more > 0 ? `<p class="quiet-note" data-role="estate-more">and ${n(more)} more</p>` : '');
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Inside a team project the whole system is one line, not a table of every
|
|
99
|
+
* package on it (rehearsal fix 3): the project is what the page is about, and
|
|
100
|
+
* the inventory workbook holds the rest.
|
|
101
|
+
*/
|
|
102
|
+
function wholeSystemHtml(estate) {
|
|
103
|
+
const t = estate.totals ?? {};
|
|
104
|
+
return `<p class="estate-whole" data-role="estate-whole">Whole system: ${n(t.packages ?? 0)} package${t.packages === 1 ? '' : 's'}, `
|
|
105
|
+
+ `${n(t.codeObjects ?? 0)} code object${t.codeObjects === 1 ? '' : 's'} — see the inventory (estate.xlsx in the discovery folder).</p>`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The estate section's body.
|
|
109
|
+
*
|
|
110
|
+
* @param {object|null|undefined} estate register-core `EstateSummary`
|
|
111
|
+
* @param {{ inScope?: boolean, findings?: Record<string, number>, unreadable?: boolean, now?: string }} [opts]
|
|
112
|
+
* `inScope` — drawn inside a team project: the whole system is one line and
|
|
113
|
+
* the In scope line follows; the package table is the estate-only view's.
|
|
114
|
+
* `unreadable` — a discovery file was found but could not be read, so the
|
|
115
|
+
* empty sentence must not say there is none.
|
|
116
|
+
* `now` — when the report was made, for the usage tile.
|
|
117
|
+
*/
|
|
118
|
+
export function estateHtml(estate, { inScope = false, findings, unreadable = false, now } = {}) {
|
|
119
|
+
if (!estate) {
|
|
120
|
+
return unreadable
|
|
121
|
+
? `<p class="empty-note" data-role="no-estate">${ESTATE_UNREADABLE}</p>`
|
|
122
|
+
: `<p class="empty-note" data-role="no-estate">${NO_ESTATE}</p>`;
|
|
123
|
+
}
|
|
124
|
+
const scope = inScope && estate.inScope
|
|
125
|
+
? `<p class="estate-scope" data-role="estate-in-scope">In scope: ${n(estate.inScope.packages)} package${estate.inScope.packages === 1 ? '' : 's'}, ${n(estate.inScope.objects)} object${estate.inScope.objects === 1 ? '' : 's'}</p>`
|
|
126
|
+
: '';
|
|
127
|
+
if (inScope)
|
|
128
|
+
return `${tilesHtml(estate, now)}${wholeSystemHtml(estate)}${scope}${unknownsHtml(estate)}`;
|
|
129
|
+
return `${tilesHtml(estate, now)}${scope}${unknownsHtml(estate)}${packagesHtml(estate, { inScope: false, findings })}`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The Estate section's body inside a team project, as the page and the status
|
|
133
|
+
* file both draw it: open findings per package come from register-core, so the
|
|
134
|
+
* two surfaces never count them two ways.
|
|
135
|
+
* @param {object} register
|
|
136
|
+
* @param {{ fileGiven?: boolean, now?: string }} [opts] a discovery file was in the folder; when the report was made
|
|
137
|
+
*/
|
|
138
|
+
export function estateSectionHtml(register, { fileGiven = false, now } = {}) {
|
|
139
|
+
return estateHtml(register.estate, {
|
|
140
|
+
inScope: true, findings: findingsByPackage(register), unreadable: fileGiven && !register.estate, now,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The whole page for a folder that holds a discovery and no team project yet.
|
|
145
|
+
* It says what it is, who took it and when, and the one step that comes next.
|
|
146
|
+
*/
|
|
147
|
+
export function estateOnlyPageHtml(estate, folderName, now) {
|
|
148
|
+
const taken = estate?.takenAt ? `Taken ${esc(formatWhen(estate.takenAt))}${estate.by ? ` by ${cell(estate.by)}` : ''}.` : '';
|
|
149
|
+
return `
|
|
150
|
+
<header class="masthead">
|
|
151
|
+
<p class="provenance">Estate inventory, read from the folder ${cell(folderName)}.</p>
|
|
152
|
+
<h1>The custom code on this system</h1>
|
|
153
|
+
<p class="summary">${taken} No team project yet. The lead makes one from this inventory with cspeach team init --from-discovery.</p>
|
|
154
|
+
<p class="notice" data-role="reload-note" hidden></p>
|
|
155
|
+
<div class="actions">
|
|
156
|
+
<button type="button" data-action="reload" class="primary">Read the folder again</button>
|
|
157
|
+
<button type="button" data-action="print" class="quiet">Print or save as PDF</button>
|
|
158
|
+
<button type="button" data-action="change-folder" class="quiet">Open another folder</button>
|
|
159
|
+
</div>
|
|
160
|
+
</header>
|
|
161
|
+
<section data-role="estate" class="estate" aria-labelledby="team-estate-heading">
|
|
162
|
+
<h2 id="team-estate-heading">Estate</h2>
|
|
163
|
+
${estateHtml(estate, { unreadable: !estate, now })}
|
|
164
|
+
</section>
|
|
165
|
+
<footer>
|
|
166
|
+
<p data-role="trust">This page reads the files in your folder and draws them here, in your browser. Your browser blocks it from sending anything to any other site. The inventory holds package names, object counts, SAP user ids and dates. No source code. No passwords.</p>
|
|
167
|
+
</footer>`;
|
|
168
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// GENERATED COPY — do not edit here. Edit viewer/src/lib/plan-parts.js,
|
|
3
|
+
// then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
|
|
4
|
+
// src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.
|
|
5
|
+
/**
|
|
6
|
+
* Plan and changes (spec 2026-10-06 §9 item 8): how the working lists are
|
|
7
|
+
* ordered, and who changed that, when and why.
|
|
8
|
+
*
|
|
9
|
+
* Three things, read top to bottom: the priority weights in one line with where
|
|
10
|
+
* they came from; the focus stack in order, each with how much of it is done;
|
|
11
|
+
* and the change history, newest first, as register-core hands it. Shared by
|
|
12
|
+
* the page and the status file, so a manager reading either sees one plan.
|
|
13
|
+
*
|
|
14
|
+
* Every value out of the folder goes through `cell()`; our own words through
|
|
15
|
+
* `esc()`. No style attribute anywhere.
|
|
16
|
+
*/
|
|
17
|
+
import { esc } from './register-map.js';
|
|
18
|
+
import { formatWhen } from './register-model.js';
|
|
19
|
+
import { cell, dayWords, n, NO_FOCUS } from './register-parts.js';
|
|
20
|
+
import { DEFAULT_WEIGHTS } from '@cspeach/register-core';
|
|
21
|
+
export const NOTHING_REPLANNED = 'Nothing has been re-planned. Lists follow the suggested order.';
|
|
22
|
+
/** How many changes are listed before the rest is counted. */
|
|
23
|
+
export const HISTORY_ROWS = 50;
|
|
24
|
+
const PARTS = ['effort', 'risk', 'shared', 'usage', 'disposition'];
|
|
25
|
+
const whole = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
26
|
+
function weightsHtml(plan) {
|
|
27
|
+
const w = plan?.weights ?? DEFAULT_WEIGHTS;
|
|
28
|
+
const source = plan?.weightsFrom === 'set'
|
|
29
|
+
? (() => {
|
|
30
|
+
const day = dayWords(plan.weightsAt);
|
|
31
|
+
return `Set${plan.weightsBy ? ` by ${cell(plan.weightsBy)}` : ''}${day ? ` on ${esc(day)}` : ''}.`;
|
|
32
|
+
})()
|
|
33
|
+
: 'The default weights.';
|
|
34
|
+
return `<p class="plan-weights" data-role="weights">Priority weights: ${PARTS.map((p) => `${p} ${n(whole(w[p]))}`).join(', ')}. ${source}</p>`;
|
|
35
|
+
}
|
|
36
|
+
function focusStackHtml(focuses) {
|
|
37
|
+
if (!focuses.length)
|
|
38
|
+
return `<p class="quiet-note" data-role="no-focus">${esc(NO_FOCUS)}</p>`;
|
|
39
|
+
const item = (f) => {
|
|
40
|
+
const day = dayWords(f.at);
|
|
41
|
+
return `<li>${cell(f.label)} — ${n(whole(f.donePct))}% done, ${n(whole(f.proven))} of ${n(whole(f.active))} proven. `
|
|
42
|
+
+ `Set by ${cell(f.by)}${day ? ` on ${esc(day)}` : ''}${f.reason ? ` because ${cell(f.reason)}` : ''}.</li>`;
|
|
43
|
+
};
|
|
44
|
+
return `<h3>The focus, in order</h3><ol class="focus-stack" data-role="focus-stack">${focuses.map(item).join('')}</ol>`;
|
|
45
|
+
}
|
|
46
|
+
function historyHtml(history) {
|
|
47
|
+
const shown = history.slice(0, HISTORY_ROWS);
|
|
48
|
+
const row = (c) => `<tr><td>${esc(formatWhen(c.at))}</td><td>${cell(c.by)}</td><td class="what">${cell(c.what)}</td>`
|
|
49
|
+
+ `<td class="what">${c.why ? cell(c.why) : '<span class="quiet-note">no reason recorded</span>'}</td></tr>`;
|
|
50
|
+
const more = history.length - shown.length;
|
|
51
|
+
return '<h3>Changes, newest first</h3>'
|
|
52
|
+
+ '<div class="scroller"><table data-role="plan-history" aria-label="Plan changes, newest first"><thead><tr>'
|
|
53
|
+
+ '<th scope="col">When</th><th scope="col">Who</th><th scope="col">What</th><th scope="col">Why</th></tr></thead>'
|
|
54
|
+
+ `<tbody>${shown.map(row).join('')}</tbody></table></div>`
|
|
55
|
+
+ (more > 0 ? `<p class="quiet-note" data-role="plan-more">and ${n(more)} more</p>` : '');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The Plan and changes section's body.
|
|
59
|
+
* @param {object} register register-core `Register` (an older one with no `plan` reads as not re-planned)
|
|
60
|
+
* @returns {string} escaped markup
|
|
61
|
+
*/
|
|
62
|
+
export function planSectionHtml(register) {
|
|
63
|
+
const plan = register?.plan;
|
|
64
|
+
const history = Array.isArray(plan?.history) ? plan.history : [];
|
|
65
|
+
if (!history.length)
|
|
66
|
+
return `${weightsHtml(plan)}<p class="empty-note" data-role="plan-empty">${NOTHING_REPLANNED}</p>`;
|
|
67
|
+
return weightsHtml(plan) + focusStackHtml(Array.isArray(plan.focuses) ? plan.focuses : []) + historyHtml(history);
|
|
68
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// GENERATED COPY — do not edit here. Edit viewer/src/lib/progress-chart.js,
|
|
3
|
+
// then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
|
|
4
|
+
// src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.
|
|
5
|
+
/**
|
|
6
|
+
* Progress over time (spec 2026-10-06 §9 item 5).
|
|
7
|
+
*
|
|
8
|
+
* One small line chart a manager reads in one look: how many objects are
|
|
9
|
+
* decided, claimed, proven, and at level A, one point per check run. Drawn from
|
|
10
|
+
* register-core's `progress` series and nothing else, so the page, the status
|
|
11
|
+
* file and the workbook's Progress sheet all show the same counts.
|
|
12
|
+
*
|
|
13
|
+
* It lives under the page's strict policy and inside a mailed document, so it
|
|
14
|
+
* is plain inline SVG: no reference to anything outside itself, no style
|
|
15
|
+
* attribute (every colour and dash is a class in team.css), and every string
|
|
16
|
+
* escaped. Colour never stands alone: each line has its own dash, and the
|
|
17
|
+
* legend says the numbers in words.
|
|
18
|
+
*
|
|
19
|
+
* Under the chart, the done-% bars per lane and per focus — the same numbers
|
|
20
|
+
* the lane table and the focus line print. Bar widths are classes in 5 % steps
|
|
21
|
+
* (`w-0` … `w-100`), because a width written into the markup would be a style.
|
|
22
|
+
*/
|
|
23
|
+
import { esc } from './register-map.js';
|
|
24
|
+
import { cell, dayWords, n, ownerLabel } from './register-parts.js';
|
|
25
|
+
export const NO_RUN_YET = 'No check run yet, so there is no line to draw.';
|
|
26
|
+
/** Back to front, so the line that matters most — proven — is drawn over the others. */
|
|
27
|
+
const SERIES = [
|
|
28
|
+
{ id: 'decided', label: 'Decided', of: (p) => p.decided },
|
|
29
|
+
{ id: 'claimed', label: 'Claimed', of: (p) => p.claimed },
|
|
30
|
+
{ id: 'proven', label: 'Proven', of: (p) => p.proven },
|
|
31
|
+
{ id: 'a', label: 'At level A', of: (p) => p.aNow },
|
|
32
|
+
];
|
|
33
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0);
|
|
34
|
+
const fix = (v) => Number(v.toFixed(1));
|
|
35
|
+
/** The width class for a percentage: nearest 5 % step, kept within 0 and 100. */
|
|
36
|
+
export function pctClass(pct) {
|
|
37
|
+
const v = Number(pct);
|
|
38
|
+
if (!Number.isFinite(v) || v <= 0)
|
|
39
|
+
return 'w-0';
|
|
40
|
+
return `w-${Math.min(100, Math.round(v / 5) * 5)}`;
|
|
41
|
+
}
|
|
42
|
+
function legendHtml(points) {
|
|
43
|
+
const first = points[0];
|
|
44
|
+
const last = points[points.length - 1];
|
|
45
|
+
const was = (s) => (points.length > 1 ? ` (${n(num(s.of(first)))} at the first run shown)` : '');
|
|
46
|
+
const item = (s) => {
|
|
47
|
+
const value = s.id === 'a' ? `${n(num(last.aNow))}, ${n(num(last.aShare))}% of active` : n(num(s.of(last)));
|
|
48
|
+
return `<li><span class="key key-${s.id}"></span>${esc(s.label)}: ${value}${was(s)}</li>`;
|
|
49
|
+
};
|
|
50
|
+
return `<ul class="chart-legend" data-role="progress-legend">${SERIES.map(item).join('')}</ul>`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The chart as one figure: the SVG, the legend in words, what a point is, and —
|
|
54
|
+
* when register-core cut the series — how many runs are not shown.
|
|
55
|
+
*
|
|
56
|
+
* @param {object[]} points register-core `ProgressPoint[]`, oldest first
|
|
57
|
+
* @param {{ width?: number, height?: number, capped?: { shown: number, total: number } }} [opts]
|
|
58
|
+
* @returns {string} escaped markup
|
|
59
|
+
*/
|
|
60
|
+
export function progressChartSvg(points, { width = 640, height = 220, capped } = {}) {
|
|
61
|
+
if (!Array.isArray(points) || !points.length)
|
|
62
|
+
return `<p class="empty-note" data-role="no-progress">${NO_RUN_YET}</p>`;
|
|
63
|
+
const left = 44;
|
|
64
|
+
const right = 12;
|
|
65
|
+
const top = 12;
|
|
66
|
+
const bottom = 28;
|
|
67
|
+
const plotW = width - left - right;
|
|
68
|
+
const plotH = height - top - bottom;
|
|
69
|
+
const max = Math.max(1, ...points.map((p) => Math.max(num(p.active), ...SERIES.map((s) => num(s.of(p))))));
|
|
70
|
+
const x = (i) => fix(points.length === 1 ? left + plotW / 2 : left + (plotW * i) / (points.length - 1));
|
|
71
|
+
const y = (v) => fix(top + plotH - (plotH * num(v)) / max);
|
|
72
|
+
const ticks = [...new Set([0, max >= 4 ? Math.round(max / 2) : null, max].filter((t) => t !== null))];
|
|
73
|
+
const grid = ticks.map((t) => `<line class="grid" x1="${left}" x2="${width - right}" y1="${y(t)}" y2="${y(t)}"></line>`
|
|
74
|
+
+ `<text class="axis" x="${left - 6}" y="${fix(y(t) + 4)}" text-anchor="end">${esc(n(t))}</text>`).join('');
|
|
75
|
+
const firstDay = dayWords(points[0].at);
|
|
76
|
+
const lastDay = dayWords(points[points.length - 1].at);
|
|
77
|
+
const base = height - 8;
|
|
78
|
+
const xLabels = points.length === 1
|
|
79
|
+
? `<text class="axis" x="${x(0)}" y="${base}" text-anchor="middle">${esc(firstDay)}</text>`
|
|
80
|
+
: `<text class="axis" x="${left}" y="${base}" text-anchor="start">${esc(firstDay)}</text>`
|
|
81
|
+
+ (lastDay && lastDay !== firstDay ? `<text class="axis" x="${width - right}" y="${base}" text-anchor="end">${esc(lastDay)}</text>` : '');
|
|
82
|
+
const lines = SERIES.map((s) => `<polyline class="pl pl-${s.id}" points="${points.map((p, i) => `${x(i)},${y(s.of(p))}`).join(' ')}"></polyline>`).join('');
|
|
83
|
+
// One run is a point, not a line: a polyline with one vertex draws nothing.
|
|
84
|
+
const dots = points.length === 1 ? SERIES.map((s) => `<circle class="pd pd-${s.id}" cx="${x(0)}" cy="${y(s.of(points[0]))}" r="3.5"></circle>`).join('') : '';
|
|
85
|
+
const last = points[points.length - 1];
|
|
86
|
+
const label = `Progress over time, ${points.length} point${points.length === 1 ? '' : 's'}: `
|
|
87
|
+
+ SERIES.map((s) => `${s.label} ${num(s.of(last))}`).join(', ');
|
|
88
|
+
return '<figure class="progress-chart" data-role="progress-chart">'
|
|
89
|
+
+ `<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(label)}">${grid}${lines}${dots}${xLabels}</svg>`
|
|
90
|
+
+ `<figcaption>${legendHtml(points)}`
|
|
91
|
+
+ '<p class="legend-line">Each point is a check run, oldest on the left. The A line counts the objects at level A. Lanes, scope and the focus are counted as they are now.</p>'
|
|
92
|
+
+ (capped ? `<p class="legend-line" data-role="progress-capped">Showing the last ${n(num(capped.shown))} of ${n(num(capped.total))} check runs.</p>` : '')
|
|
93
|
+
+ '</figcaption></figure>';
|
|
94
|
+
}
|
|
95
|
+
const barRow = (label, pct, proven, active, extra = '') => `<li><span class="pct-label">${label}</span> `
|
|
96
|
+
+ `<span class="pct-bar"><span class="pct-fill${extra} ${pctClass(pct)}"></span></span>`
|
|
97
|
+
+ `<span class="pct-n">${n(num(pct))}% · ${n(num(proven))} of ${n(num(active))} proven</span></li>`;
|
|
98
|
+
/** How far each lane and each focus is now, as bars with the numbers beside them. */
|
|
99
|
+
function doneBarsHtml(register) {
|
|
100
|
+
const lanes = (register.lanes ?? []).map((l) => barRow(cell(ownerLabel(l)), l.donePct, l.proven, l.active));
|
|
101
|
+
const focuses = (register.plan?.focuses ?? []).map((f) => barRow(`Focus: ${cell(f.label)}`, f.donePct, f.proven, f.active, ' f-focus'));
|
|
102
|
+
if (!lanes.length && !focuses.length)
|
|
103
|
+
return '';
|
|
104
|
+
return '<h3>Done now, per developer and focus</h3>'
|
|
105
|
+
+ `<ul class="pct-bars" data-role="done-bars">${[...lanes, ...focuses].join('')}</ul>`;
|
|
106
|
+
}
|
|
107
|
+
/** The Progress over time section's body, as the page and the status file both draw it. */
|
|
108
|
+
export function progressSectionHtml(register) {
|
|
109
|
+
return progressChartSvg(register.progress, { capped: register.progressCapped }) + doneBarsHtml(register);
|
|
110
|
+
}
|
|
@@ -160,9 +160,14 @@ export function blockSvg(block, { cell = 10, gap = 2, role = 'group', colourBy =
|
|
|
160
160
|
// A retired object keeps its state colour in level mode: the level counts
|
|
161
161
|
// leave retired objects out, and a cell painted C that no count includes
|
|
162
162
|
// would show a manager two numbers for one folder.
|
|
163
|
-
|
|
163
|
+
//
|
|
164
|
+
// A clean (or verified) object nobody decided has no level and nothing to
|
|
165
|
+
// decide: it keeps its state colour too, and its title says so — never
|
|
166
|
+
// "level not decided" (register-core leaves it out of the level counts).
|
|
167
|
+
const nothing = byLevel && o.inScope !== false && !o.decision && !LEVEL_SET.has(o.levelNow) && (o.state === 'clean' || o.state === 'verified');
|
|
168
|
+
const asLevel = byLevel && o.state !== 'retired' && !nothing;
|
|
164
169
|
return `<rect class="cell ${asLevel ? levelClass(o.levelNow) : stateClass(o.state)}"${keyed ? ` data-key="${esc(o.key)}"` : ''} x="${x}" y="${y}" width="${cell}" height="${cell}" rx="2">`
|
|
165
|
-
+ `<title>${esc(o.name)} · ${esc(o.type)}${asLevel ? ` · level ${esc(LEVEL_WORD[LEVEL_SET.has(o.levelNow) ? o.levelNow : 'none'])}` : ''} · ${esc(stateWord(o))}</title></rect>`;
|
|
170
|
+
+ `<title>${esc(o.name)} · ${esc(o.type)}${asLevel ? ` · level ${esc(LEVEL_WORD[LEVEL_SET.has(o.levelNow) ? o.levelNow : 'none'])}` : nothing ? ' · clean, nothing to decide' : ''} · ${esc(stateWord(o))}</title></rect>`;
|
|
166
171
|
}).join('');
|
|
167
172
|
const label = `${block.package}: ${n.toLocaleString('en-US')} ${n === 1 ? 'object' : 'objects'}`;
|
|
168
173
|
return `<svg class="block-map" role="${safeRole}" aria-label="${esc(label)}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${rects}</svg>`;
|
|
@@ -200,13 +205,16 @@ export function legendHtml(byState, { unknown = 0 } = {}) {
|
|
|
200
205
|
* @param {{ retired?: number }} [opts]
|
|
201
206
|
* @returns {string}
|
|
202
207
|
*/
|
|
203
|
-
export function levelLegendHtml(counts, { retired = 0 } = {}) {
|
|
208
|
+
export function levelLegendHtml(counts, { retired = 0, nothingToDecide = 0 } = {}) {
|
|
204
209
|
const entry = (cls, label, count) => `<li><span class="swatch ${cls}"></span>${esc(label)} <b>${esc((count ?? 0).toLocaleString('en-US'))}</b></li>`;
|
|
205
210
|
// A folder from an older build has no level counts: say so, as the level bars
|
|
206
211
|
// do, rather than print five zeros that read as "measured, nothing there".
|
|
207
212
|
const items = counts
|
|
208
213
|
? LEVEL_BUCKETS.map((l) => entry(`l-${l}`, LEVEL_WORD[l], counts[l]))
|
|
209
214
|
: ['<li>No levels recorded yet.</li>'];
|
|
215
|
+
// Drawn in the clean colour, the colour their cells keep (blockSvg).
|
|
216
|
+
if (counts && nothingToDecide > 0)
|
|
217
|
+
items.push(entry(stateClass('clean'), 'clean, nothing to decide', nothingToDecide));
|
|
210
218
|
if (retired > 0)
|
|
211
219
|
items.push(entry(stateClass('retired'), 'retired', retired));
|
|
212
220
|
return `<ul class="legend">${items.join('')}</ul>`;
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
* measurement has none) or clean (measured, never had any). A developer's
|
|
13
13
|
* "fixed" is a claim and is never counted here.
|
|
14
14
|
*/
|
|
15
|
-
import { activeOf, provenOf, settledOf } from '@cspeach/register-core';
|
|
15
|
+
import { activeOf, laneActiveOf, laneProvenOf, provenOf, settledOf } from '@cspeach/register-core';
|
|
16
16
|
/**
|
|
17
17
|
* The three counts every surface divides by are defined once, in register-core,
|
|
18
18
|
* where the terminal reads them too. They are passed on from here so that
|
|
19
19
|
* register-parts.js — which draws the lane table for both the page and the
|
|
20
20
|
* status file — takes its numbers from the model, like everything else it draws.
|
|
21
21
|
*/
|
|
22
|
-
export { activeOf, provenOf, settledOf };
|
|
22
|
+
export { activeOf, laneActiveOf, laneProvenOf, provenOf, settledOf };
|
|
23
23
|
/** Worst first: the order cells are banded in a block and listed in a legend. */
|
|
24
24
|
export const STATE_ORDER = ['regressed', 'did-not-hold', 'untouched', 'decided', 'working', 'claimed', 'verified', 'clean', 'retired'];
|
|
25
25
|
export const STATE_LABEL = {
|
|
@@ -79,6 +79,8 @@ const andList = (names) => (names.length < 3 ? names.join(' and ') : `${names.sl
|
|
|
79
79
|
/** As many names as a person reads before they stop reading names. */
|
|
80
80
|
const LIST_CAP = 12;
|
|
81
81
|
const count = (value) => value.toLocaleString('en-US');
|
|
82
|
+
/** A clean (or verified) object nobody decided: counted on its own, never as undecided. */
|
|
83
|
+
export const NOTHING_TO_DECIDE = 'clean, nothing to decide';
|
|
82
84
|
/**
|
|
83
85
|
* How a cut list ends.
|
|
84
86
|
*
|
|
@@ -145,11 +147,18 @@ export function summarize(register, { unreadable = 0 } = {}) {
|
|
|
145
147
|
// A folder from an older build carries no `undecided`; work it out the same
|
|
146
148
|
// way register-core does, so the line never prints a blank.
|
|
147
149
|
const decided = totals.decided ?? 0;
|
|
148
|
-
|
|
150
|
+
// Recording fix 4 (2026-10-07): a clean object nobody decided has nothing
|
|
151
|
+
// to decide. register-core counts it on its own; it is never "undecided".
|
|
152
|
+
const nothingToDecide = Number.isFinite(totals.cleanNothingToDecide) ? totals.cleanNothingToDecide : 0;
|
|
153
|
+
const undecided = Number.isFinite(totals.undecided) ? totals.undecided : Math.max(0, totals.objects - decided - nothingToDecide);
|
|
154
|
+
const decidedPhrase = nothingToDecide > 0
|
|
155
|
+
? `${count(decided)} decided${undecided > 0 ? `, ${count(undecided)} undecided` : ''} · ${count(nothingToDecide)} ${NOTHING_TO_DECIDE}`
|
|
156
|
+
: `${count(decided)} decided, ${count(undecided)} undecided`;
|
|
149
157
|
return {
|
|
150
158
|
objects: totals.objects, active: totals.active, proven, provenPct, decided: totals.decided, undecided, needAttention,
|
|
151
159
|
lastMeasured, unreadable, fromFile,
|
|
152
|
-
|
|
160
|
+
...(nothingToDecide > 0 ? { nothingToDecide } : {}),
|
|
161
|
+
line: `${plural(totals.objects, 'object', 'objects')} · ${provenPhrase}${fromFilePhrase} · ${decidedPhrase} · ${plural(needAttention, 'needs attention', 'need attention')} · ${lastMeasured}`,
|
|
153
162
|
};
|
|
154
163
|
}
|
|
155
164
|
const DAY_MS = 86_400_000;
|
|
@@ -199,6 +208,21 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
|
|
|
199
208
|
items.push({ kind: 'exception-no-reason', title: `${plural(noReason.length, 'accepted exception has', 'accepted exceptions have')} no reason`,
|
|
200
209
|
detail: upTo(noReason.map((o) => o.name), { tail: onMap }), keys: noReason.map((o) => o.key) });
|
|
201
210
|
}
|
|
211
|
+
// A developer's "retire" is a candidate until the lead confirms it with a
|
|
212
|
+
// reason (spec 2026-10-06 D2). Until then the object stays active, and the
|
|
213
|
+
// lead is the one person who can move it.
|
|
214
|
+
const candidates = register.objects.filter((o) => o.retireCandidate === true);
|
|
215
|
+
if (candidates.length) {
|
|
216
|
+
items.push({ kind: 'retire-candidate', title: `${plural(candidates.length, 'retire candidate waits', 'retire candidates wait')} for your confirmation`,
|
|
217
|
+
detail: upTo(candidates.map((o) => o.name), { tail: onMap }), keys: candidates.map((o) => o.key) });
|
|
218
|
+
}
|
|
219
|
+
// Out of scope is a decision about work nobody will do, so it needs a reason
|
|
220
|
+
// the lead can defend. A record from an older build may carry none.
|
|
221
|
+
const outNoReason = register.objects.filter((o) => o.inScope === false && !o.scopeReason);
|
|
222
|
+
if (outNoReason.length) {
|
|
223
|
+
items.push({ kind: 'out-of-scope-no-reason', title: `${plural(outNoReason.length, 'object was', 'objects were')} taken out of scope with no reason`,
|
|
224
|
+
detail: upTo(outNoReason.map((o) => o.name), { tail: onMap }), keys: outNoReason.map((o) => o.key) });
|
|
225
|
+
}
|
|
202
226
|
const unowned = register.objects.filter((o) => o.lane === null);
|
|
203
227
|
if (unowned.length) {
|
|
204
228
|
const packages = [...new Set(unowned.map((o) => o.package))].sort();
|
|
@@ -213,6 +237,16 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
|
|
|
213
237
|
items.push({ kind: 'overlap', title: `${plural(overlap.length, 'object is', 'objects are')} claimed by more than one lane`,
|
|
214
238
|
detail: upTo([...new Set(overlap.map((o) => o.laneOverlap.join(' + ')))].sort(), { tail: justHowMany, sep: '; ' }), keys: overlap.map((o) => o.key) });
|
|
215
239
|
}
|
|
240
|
+
// Rows of the last Excel import that clashed with a change recorded after the
|
|
241
|
+
// workbook was made were skipped, not applied. Only the newest import counts:
|
|
242
|
+
// an older one's skips were either entered again or are no longer wanted.
|
|
243
|
+
const lastImport = (register.plan?.history ?? []).find((c) => c.action === 'excel');
|
|
244
|
+
const clashes = lastImport?.clashesSkipped ?? 0;
|
|
245
|
+
if (Number.isInteger(clashes) && clashes > 0) {
|
|
246
|
+
items.push({ kind: 'excel-clashes', title: `${plural(clashes, 'Excel plan row was', 'Excel plan rows were')} skipped at the last import`,
|
|
247
|
+
detail: 'They clashed with changes recorded after the workbook was made. Make a fresh workbook with cspeach team report and enter them again.',
|
|
248
|
+
keys: [] });
|
|
249
|
+
}
|
|
216
250
|
// A lane that has finished its work stops writing records, and that is the
|
|
217
251
|
// point — it is not quiet, it is done. Only a lane with work still open can
|
|
218
252
|
// be waiting for someone.
|
|
@@ -220,8 +254,8 @@ export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface
|
|
|
220
254
|
for (const lane of register.lanes) {
|
|
221
255
|
if (!lane.objects || !lane.lastRecordAt || !Number.isFinite(nowMs))
|
|
222
256
|
continue;
|
|
223
|
-
if (lane
|
|
224
|
-
continue;
|
|
257
|
+
if (laneActiveOf(lane) - laneProvenOf(lane) <= 0)
|
|
258
|
+
continue; // the lane's own Done % denominator
|
|
225
259
|
const days = Math.floor((nowMs - Date.parse(lane.lastRecordAt)) / DAY_MS);
|
|
226
260
|
if (days >= quietDays) {
|
|
227
261
|
items.push({ kind: 'quiet-lane', title: `${lane.owner} (${lane.id}): no records for ${plural(days, 'day', 'days')}`,
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* style attribute.
|
|
22
22
|
*/
|
|
23
23
|
import { esc, blockSvg, legendHtml, levelLegendHtml, stateWord, LEVEL_BUCKETS as LEVELS, LEVEL_WORD as LEVEL_NAME } from './register-map.js';
|
|
24
|
-
import {
|
|
24
|
+
import { formatWhen, laneActiveOf, laneProvenOf, measurementHistory, notMeasuredCount, provenOf, unknownStateCount, FROM_FILE, NOTHING_TO_DECIDE, } from './register-model.js';
|
|
25
25
|
import { DISPOSITION_ORDER, DISPOSITION_WORD, developerTable, dispositionOf, overlapNote, } from '@cspeach/register-core';
|
|
26
26
|
// The map owns it, because the map's cells need it too; it is offered here as
|
|
27
27
|
// well, where every other shared mark lives.
|
|
@@ -119,6 +119,8 @@ export function lanesTableHtml(lanes, objects) {
|
|
|
119
119
|
for (const o of objects ?? []) {
|
|
120
120
|
if (o.measuredBasis !== 'file' || (o.state !== 'verified' && o.state !== 'clean'))
|
|
121
121
|
continue;
|
|
122
|
+
if (o.inScope === false)
|
|
123
|
+
continue; // the same objects the lane's Proven counts
|
|
122
124
|
// Every lane that claims the object, exactly as the lane's own counts are
|
|
123
125
|
// taken: an object two lanes both claim is proven for both of them, so
|
|
124
126
|
// "(n from a file)" can never be larger than the Proven it sits beside.
|
|
@@ -129,10 +131,10 @@ export function lanesTableHtml(lanes, objects) {
|
|
|
129
131
|
.map((h) => `<th scope="col">${h}</th>`).join('');
|
|
130
132
|
const provenCell = (l) => {
|
|
131
133
|
const said = fromFile.get(l.id) ?? 0;
|
|
132
|
-
return `${n(
|
|
134
|
+
return `${n(laneProvenOf(l))}${said ? ` (${n(said)} ${FROM_FILE})` : ''}`;
|
|
133
135
|
};
|
|
134
136
|
const row = (l) => `<tr><td>${esc(ownerLabel(l))}</td><td>${n(l.objects)}</td>`
|
|
135
|
-
+ `<td>${n(
|
|
137
|
+
+ `<td>${n(laneActiveOf(l))}</td><td>${provenCell(l)}</td>`
|
|
136
138
|
+ `<td>${n((l.byState?.regressed ?? 0) + (l.byState?.['did-not-hold'] ?? 0))}</td>`
|
|
137
139
|
+ `<td>${esc(formatWhen(l.lastRecordAt))}</td></tr>`;
|
|
138
140
|
return `<div class="scroller"><table><thead><tr>${heads}</tr></thead>`
|
|
@@ -165,7 +167,7 @@ export function mapBodyHtml(register, blocks, { role, colourBy } = {}) {
|
|
|
165
167
|
return '<p>No objects in scope yet.</p>';
|
|
166
168
|
// The status file never passes `colourBy`: it prints the state map only.
|
|
167
169
|
const legend = colourBy === 'level'
|
|
168
|
-
? levelLegendHtml(register.totals.byLevel?.now, { retired: register.totals.byState?.retired ?? 0 })
|
|
170
|
+
? levelLegendHtml(register.totals.byLevel?.now, { retired: register.totals.byState?.retired ?? 0, nothingToDecide: register.totals.byLevel?.nothingToDecide ?? 0 })
|
|
169
171
|
: legendHtml(register.totals.byState, { unknown: unknownStateCount(register) });
|
|
170
172
|
return `${legend}
|
|
171
173
|
<div class="blocks">${blocks.map((b) => blockFigureHtml(b, { role, colourBy })).join('')}</div>`;
|
|
@@ -401,9 +403,10 @@ const CLASSIFICATION_WORD = { keep: 'keep', fix: 'fix', redesign: 'redesign', re
|
|
|
401
403
|
* it (the caller escapes it), never as "not decided": somebody DID decide, and
|
|
402
404
|
* calling it undecided would put the object in a bucket nobody chose.
|
|
403
405
|
*/
|
|
404
|
-
export function decisionWord(decision, projectType) {
|
|
406
|
+
export function decisionWord(decision, projectType, state) {
|
|
407
|
+
// Recording fix 4: a clean (or verified) object nobody decided has nothing to decide.
|
|
405
408
|
if (!decision)
|
|
406
|
-
return 'not decided';
|
|
409
|
+
return state === 'clean' || state === 'verified' ? 'clean — nothing to decide' : 'not decided';
|
|
407
410
|
if (projectType === 'clean-core') {
|
|
408
411
|
const d = dispositionOf(decision);
|
|
409
412
|
if (d)
|
|
@@ -428,6 +431,9 @@ export function whereItStandsHtml(register) {
|
|
|
428
431
|
+ tile('Proven', provenOf(s), fromFile ? `${n(fromFile)} of them ${FROM_FILE}` : 'a check run says so')
|
|
429
432
|
+ tile('Did not hold', didNotHold, 'claimed, and the check still finds it', didNotHold ? 'bad' : '')
|
|
430
433
|
+ tile('Retired', s.retired ?? 0, 'nothing left to do')
|
|
434
|
+
// Only when there are some: a developer said retire and the lead has not
|
|
435
|
+
// confirmed it yet, so these objects still count as active work.
|
|
436
|
+
+ ((t.retireCandidates ?? 0) > 0 ? tile('Retire candidates', t.retireCandidates, 'wait for the lead to confirm', 'retire-candidates') : '')
|
|
431
437
|
+ '</ul>'
|
|
432
438
|
+ (!(t.decided > 0) ? '<p class="empty-note" data-role="nothing-decided">Nothing decided yet. Developers record decisions with /abap-clean-core or cspeach team decide.</p>' : '')
|
|
433
439
|
+ levelBarsHtml(register);
|
|
@@ -437,7 +443,7 @@ export function whereItStandsHtml(register) {
|
|
|
437
443
|
* never stands alone. Segments are paths, not rects: a `rect` on these surfaces
|
|
438
444
|
* is a map cell, and the budget and the keyboard both count cells by that tag.
|
|
439
445
|
*/
|
|
440
|
-
function levelBar(title, counts) {
|
|
446
|
+
function levelBar(title, counts, nothingToDecide = 0) {
|
|
441
447
|
const total = LEVELS.reduce((sum, l) => sum + (counts?.[l] ?? 0), 0);
|
|
442
448
|
let x = 0;
|
|
443
449
|
const rects = total
|
|
@@ -451,7 +457,9 @@ function levelBar(title, counts) {
|
|
|
451
457
|
return r;
|
|
452
458
|
}).join('')
|
|
453
459
|
: '<path class="lvl l-none" d="M0 0h100v10h-100z"></path>';
|
|
454
|
-
|
|
460
|
+
// A clean object nobody decided is not "not decided": it is said on its own, after the buckets.
|
|
461
|
+
const words = LEVELS.map((l) => `${LEVEL_NAME[l]} ${n(counts?.[l] ?? 0)}`).join(' · ')
|
|
462
|
+
+ (nothingToDecide > 0 ? ` · ${NOTHING_TO_DECIDE} ${n(nothingToDecide)}` : '');
|
|
455
463
|
return `<figure class="levelbar"><figcaption>${esc(title)}</figcaption>`
|
|
456
464
|
+ `<svg viewBox="0 0 100 10" preserveAspectRatio="none" role="img" aria-label="${esc(`${title}: ${words}`)}">${rects}</svg>`
|
|
457
465
|
+ `<p class="levelwords">${esc(words)}</p></figure>`;
|
|
@@ -460,7 +468,7 @@ function levelBar(title, counts) {
|
|
|
460
468
|
export function levelBarsHtml(register) {
|
|
461
469
|
const b = register.totals?.byLevel;
|
|
462
470
|
const none = !b || ['A', 'B', 'C', 'D'].every((l) => !(b.decided?.[l]) && !(b.now?.[l]));
|
|
463
|
-
return `<div class="levels" data-role="levels">${levelBar('Levels at decision', b?.decided)}${levelBar('Levels now', b?.now)}`
|
|
471
|
+
return `<div class="levels" data-role="levels">${levelBar('Levels at decision', b?.decided, b?.nothingToDecide ?? 0)}${levelBar('Levels now', b?.now, b?.nothingToDecide ?? 0)}`
|
|
464
472
|
+ `<p class="legend-line">${none ? 'No levels recorded yet. ' : ''}A level moves only when a check proves the fix.</p></div>`;
|
|
465
473
|
}
|
|
466
474
|
/**
|
|
@@ -500,10 +508,48 @@ export function developersTableHtml(register) {
|
|
|
500
508
|
const said = col === provenAt ? fromFile.get(lane.id) ?? 0 : 0;
|
|
501
509
|
return `<td>${n(v)}${said ? ` (${n(said)} ${FROM_FILE})` : ''}</td>`;
|
|
502
510
|
};
|
|
503
|
-
|
|
511
|
+
// Spec 2026-10-06 §9 item 7: how far each lane is, what it does next (the
|
|
512
|
+
// first three by effective priority) and how much recorded work sits outside
|
|
513
|
+
// the focus — a plain count, recorded and never blocked.
|
|
514
|
+
const nameOf = new Map(register.objects.map((o) => [o.key, o.name]));
|
|
515
|
+
const plan = (lane) => {
|
|
516
|
+
// A key with no object behind it names nothing a reader can find, so it is
|
|
517
|
+
// left out rather than printed as the raw key.
|
|
518
|
+
const keys = (lane.next ?? []).slice(0, 3);
|
|
519
|
+
const next = keys.map((k) => nameOf.get(k)).filter((name) => name);
|
|
520
|
+
return `<td>${n(lane.donePct ?? 0)}%</td>`
|
|
521
|
+
+ `<td class="next">${keys.length ? next.map((name) => `<span class="ident">${cell(name)}</span>`).join(', ') : '<span class="quiet-note">nothing open</span>'}</td>`
|
|
522
|
+
+ `<td>${n(lane.outsideFocus ?? 0)}</td>`;
|
|
523
|
+
};
|
|
524
|
+
const head = [...t.header, 'Done %', 'Next', 'Outside the focus'].map((h) => `<th scope="col">${esc(h)}</th>`).join('');
|
|
504
525
|
const body = t.rows.map((r, i) => `<tr><td>${cell(r[0])}</td>${r.slice(1, -1).map((v, j) => num(v, j + 1, register.lanes[i])).join('')}`
|
|
505
|
-
+ `<td>${esc(formatWhen(register.lanes[i].lastRecordAt))}</td
|
|
526
|
+
+ `<td>${esc(formatWhen(register.lanes[i].lastRecordAt))}</td>${plan(register.lanes[i])}</tr>`).join('');
|
|
506
527
|
const note = overlapNote(register);
|
|
507
528
|
return `<div class="scroller"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`
|
|
508
529
|
+ (note ? `<p class="quiet-note">${esc(note)}</p>` : '');
|
|
509
530
|
}
|
|
531
|
+
/* ── The focus, under the headline (spec 2026-10-06 §9 item 1) ──────────── */
|
|
532
|
+
const MONTH_WORDS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
533
|
+
/** A day as a person says it: 24 Sep 2026. UTC, like every time on this page. */
|
|
534
|
+
export const dayWords = (iso) => {
|
|
535
|
+
const t = iso ? new Date(iso) : null;
|
|
536
|
+
return !t || Number.isNaN(t.getTime()) ? '' : `${t.getUTCDate()} ${MONTH_WORDS[t.getUTCMonth()]} ${t.getUTCFullYear()}`;
|
|
537
|
+
};
|
|
538
|
+
export const NO_FOCUS = 'No focus set. Working lists follow the suggested order.';
|
|
539
|
+
/**
|
|
540
|
+
* The second line of the headline: the focus on top of the stack — what, who
|
|
541
|
+
* set it and when, why, and how much of it is done — or the sentence that says
|
|
542
|
+
* there is none. Escaped markup.
|
|
543
|
+
*/
|
|
544
|
+
export function focusLineHtml(register) {
|
|
545
|
+
const f = register?.plan?.focuses?.[0];
|
|
546
|
+
if (!f)
|
|
547
|
+
return esc(NO_FOCUS);
|
|
548
|
+
const day = dayWords(f.at);
|
|
549
|
+
return [
|
|
550
|
+
`Now: ${cell(f.label)}`,
|
|
551
|
+
`set by ${cell(f.by)}${day ? ` ${esc(day)}` : ''}`,
|
|
552
|
+
...(f.reason ? [`because ${cell(f.reason)}`] : []),
|
|
553
|
+
`${n(f.donePct ?? 0)}% done`,
|
|
554
|
+
].join(' · ');
|
|
555
|
+
}
|