@cspeach/cli 1.1.30 → 1.1.32
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 +11 -1
- package/dist/agent/loop.js +132 -9
- package/dist/cli.js +49 -18
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/help.js +16 -1
- package/dist/commands/plan-chain.js +3 -0
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +17 -1
- package/dist/commands/plan-resume.js +28 -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-rules.js +187 -0
- package/dist/commands/team.js +971 -95
- package/dist/journey/chain.js +978 -0
- package/dist/journey/clean-core.js +235 -0
- package/dist/journey/flow.js +216 -0
- package/dist/journey/rebuild.js +464 -0
- package/dist/journey/run.js +628 -0
- package/dist/journey/session-deps.js +81 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +21 -9
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +28 -2
- package/dist/projects/clean-core-confirm.js +192 -3
- 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 +100 -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/baseline.js +39 -2
- package/dist/register/decision-rules.js +35 -0
- package/dist/register/discover.js +931 -0
- package/dist/register/export-files.js +58 -0
- package/dist/register/findings.js +5 -2
- package/dist/register/measure.js +191 -9
- package/dist/register/offer.js +100 -8
- package/dist/register/picture.js +6 -2
- package/dist/register/plan-import.js +328 -0
- package/dist/register/records.js +40 -3
- package/dist/register/rule-proposal.js +4 -0
- package/dist/register/rules-edit.js +257 -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 +106 -90
- package/dist/register/status-file/report-model.js +569 -0
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +4 -0
- package/dist/register/status-html.js +27 -6
- package/dist/register/store.js +44 -5
- package/dist/register/variant.js +65 -0
- package/dist/register/workbooks.js +28 -42
- package/dist/register/write-whole.js +30 -2
- 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 +3 -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/builtin-commands.js +4 -0
- package/dist/repl/journey-entry.js +27 -0
- package/dist/repl/plan-hop.js +2 -9
- package/dist/repl/rules-menu.js +177 -0
- package/dist/repl/slash-completer.js +1 -0
- package/dist/repl/slash-picker.js +5 -4
- package/dist/repl.js +236 -59
- package/dist/router/classifier.js +8 -4
- package/dist/router/router-core.js +43 -7
- package/dist/router/routing-decision.js +1 -1
- package/dist/runs/bundled-runs.js +100 -0
- package/dist/runs/front-door.js +19 -0
- package/dist/runs/manifest-runs.js +50 -0
- package/dist/runs/run-command.js +24 -0
- package/dist/runs/runs.js +27 -0
- package/dist/runs/shadow.js +100 -0
- package/dist/runs/types.js +1 -0
- package/dist/session/backfill.js +342 -0
- package/dist/session/project-match.js +63 -0
- package/dist/session/queued-step.js +45 -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 +53 -53
- 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/dispatch-skill.js +8 -4
- 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/lane/app-lane-resolve.js +1 -1
- package/dist/tools/project/cca_merge.js +8 -2
- package/dist/tools/project/rules_propose.js +59 -0
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +89 -5
- 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/upgrade-check.js +49 -4
- package/dist/util/open-in-browser.js +143 -0
- package/package.json +3 -3
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision-rule edits behind `/rules` and `cspeach team rules` (step 2,
|
|
3
|
+
* Task 10; spec 2026-10-08 §7 D7).
|
|
4
|
+
*
|
|
5
|
+
* Pure: every edit takes the rules as they are and gives back the new list,
|
|
6
|
+
* the one line said about it (History, the printed line) and the confirm card's
|
|
7
|
+
* question. Each result is read back through register-core's strict reader, so
|
|
8
|
+
* nothing this file makes could be refused by the next read. `null` rules mean
|
|
9
|
+
* "restore the defaults": the file then names no rules at all.
|
|
10
|
+
*/
|
|
11
|
+
import { readDecisionRules, levelOf, } from '@cspeach/register-core';
|
|
12
|
+
import { RegisterError } from './store.js';
|
|
13
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
14
|
+
const DISPOSITIONS = ['keep', 'fix', 'rebuild', 'retire-candidate'];
|
|
15
|
+
/* ── plain words ─────────────────────────────────────────────────────────── */
|
|
16
|
+
const TYPE_WORDS = {
|
|
17
|
+
PROG: 'program', CLAS: 'class', INTF: 'interface', FUGR: 'function group', DDLS: 'CDS view', TABL: 'table', ENHO: 'enhancement',
|
|
18
|
+
};
|
|
19
|
+
const CLASS_WORDS = {
|
|
20
|
+
'not-released': 'unreleased APIs', 'not-to-be-released': 'APIs SAP will not release', deprecated: 'deprecated APIs',
|
|
21
|
+
'cloud-language': 'statements ABAP Cloud does not allow', quality: 'quality findings (for example ORDER BY or WRITE in a loop)', modification: 'modifications', unclassified: 'unclassified findings',
|
|
22
|
+
};
|
|
23
|
+
/** Finding-rule prefixes with plain words; any other is said by its id. */
|
|
24
|
+
const RULE_WORDS = { SEARCH_PROBLEMATIC_STATEMENTS_FOR_ALV_CALLS: 'ALV list output' };
|
|
25
|
+
const DISPOSITION_WORDS = { keep: 'keep', fix: 'fix', rebuild: 'rebuild', 'retire-candidate': 'retire candidate' };
|
|
26
|
+
const or = (words) => (words.length <= 1 ? words.join('') : `${words.slice(0, -1).join(', ')} or ${words.at(-1)}`);
|
|
27
|
+
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
|
28
|
+
/** "more than 25 findings", "10 or fewer findings", "3 to 10 findings"; null when the rule counts nothing. */
|
|
29
|
+
export function countWords(w) {
|
|
30
|
+
const min = w.minFindings;
|
|
31
|
+
const max = w.maxFindings;
|
|
32
|
+
if (min === undefined && max === undefined)
|
|
33
|
+
return null;
|
|
34
|
+
if (min !== undefined && max !== undefined)
|
|
35
|
+
return `${min} to ${max} findings`;
|
|
36
|
+
if (min !== undefined)
|
|
37
|
+
return min === 0 ? '0 or more findings' : `more than ${min - 1} findings`;
|
|
38
|
+
return `${max} or fewer findings`;
|
|
39
|
+
}
|
|
40
|
+
/** One phrase per condition. `first` = it follows the subject directly ("Any object with …"). */
|
|
41
|
+
function phrases(w) {
|
|
42
|
+
const out = [];
|
|
43
|
+
const both = (s) => out.push({ first: s, next: s });
|
|
44
|
+
const known = new Set(['types', 'minFindings', 'maxFindings']);
|
|
45
|
+
// The order a lead reads them in; any key not named here follows by its own name.
|
|
46
|
+
const said = [
|
|
47
|
+
['noOpenForTarget', () => both('that meets its target already')],
|
|
48
|
+
['findingClasses', () => both(`with ${or(w.findingClasses.map((c) => CLASS_WORDS[c] ?? c))}`)],
|
|
49
|
+
['onlyClasses', () => both(`with only ${or(w.onlyClasses.map((c) => CLASS_WORDS[c] ?? c))}`)],
|
|
50
|
+
['findingRules', () => both(`with ${or(w.findingRules.map((r) => RULE_WORDS[r] ?? `findings of ${r}`))}`)],
|
|
51
|
+
['levelNow', () => both(`now at level ${or(w.levelNow)}`)],
|
|
52
|
+
['targetAtMost', () => out.push({ first: `with target ${w.targetAtMost} or stricter`, next: `target ${w.targetAtMost} or stricter` })],
|
|
53
|
+
['callers', () => both('with no callers found')],
|
|
54
|
+
['usage', () => both('with usage read as unused')],
|
|
55
|
+
];
|
|
56
|
+
for (const [key, say] of said) {
|
|
57
|
+
known.add(key);
|
|
58
|
+
if (w[key] !== undefined)
|
|
59
|
+
say();
|
|
60
|
+
}
|
|
61
|
+
const counted = countWords(w);
|
|
62
|
+
if (counted)
|
|
63
|
+
out.push({ first: `with ${counted}`, next: counted });
|
|
64
|
+
for (const [key, v] of Object.entries(w)) {
|
|
65
|
+
if (known.has(key) || v === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
// A condition this build has no words for is said by its name, never dropped.
|
|
68
|
+
const values = Array.isArray(v) ? v.map(String) : [String(v)];
|
|
69
|
+
both(`${key} ${or(values)}`);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** The "when" half: "Program with ALV list output, target A or stricter". */
|
|
74
|
+
export function whenWords(w) {
|
|
75
|
+
const types = w.types?.length ? w.types.map((t) => TYPE_WORDS[t] ?? t) : null;
|
|
76
|
+
const subject = types ? cap(or(types)) : 'Any object';
|
|
77
|
+
const ps = phrases(w);
|
|
78
|
+
if (!ps.length)
|
|
79
|
+
return subject;
|
|
80
|
+
return `${subject} ${ps[0].first}${ps.slice(1).map((p) => `, ${p.next}`).join('')}`;
|
|
81
|
+
}
|
|
82
|
+
/** The "then" half: "rebuild", "rebuild, target B". */
|
|
83
|
+
export function thenWords(t) {
|
|
84
|
+
return `${DISPOSITION_WORDS[t.disposition] ?? t.disposition}${t.target ? `, target ${t.target}` : ''}`;
|
|
85
|
+
}
|
|
86
|
+
/** One rule in plain words: "Program with ALV list output, target A or stricter → rebuild". */
|
|
87
|
+
export const ruleWords = (r) => `${whenWords(r.when)} → ${thenWords(r.then)}`;
|
|
88
|
+
const dayMonth = (iso) => {
|
|
89
|
+
const d = new Date(iso);
|
|
90
|
+
return Number.isNaN(d.getTime()) ? '' : `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
|
|
91
|
+
};
|
|
92
|
+
const changedWords = (r) => (r.changedBy ? `${r.changedBy}${r.changedAt && dayMonth(r.changedAt) ? ` on ${dayMonth(r.changedAt)}` : ''}` : 'default');
|
|
93
|
+
/** The numbered table: id · when → then · on/off · last changed by. */
|
|
94
|
+
export function rulesTableLines(rules, from, warning) {
|
|
95
|
+
const head = from === 'default'
|
|
96
|
+
? 'Decision rules — the defaults (cspeach-team.json names none).'
|
|
97
|
+
: 'Decision rules — from cspeach-team.json.';
|
|
98
|
+
return [
|
|
99
|
+
`${head} The first rule that is on and holds decides; a person confirms every row.`,
|
|
100
|
+
...(warning ? [` ${warning}`] : []),
|
|
101
|
+
...rules.map((r, i) => `${i + 1}. ${r.id} · ${ruleWords(r)} · ${r.on ? 'on' : 'off'} · ${changedWords(r)}`),
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
/* ── the edits ───────────────────────────────────────────────────────────── */
|
|
105
|
+
const copy = (v) => JSON.parse(JSON.stringify(v));
|
|
106
|
+
function find(rules, id) {
|
|
107
|
+
const i = rules.findIndex((r) => r.id.toUpperCase() === id.trim().toUpperCase());
|
|
108
|
+
if (i < 0)
|
|
109
|
+
throw new RegisterError(`There is no rule ${id.trim() || '(none given)'}. Rules: ${rules.map((r) => r.id).join(', ') || 'none'}.`);
|
|
110
|
+
return i;
|
|
111
|
+
}
|
|
112
|
+
/** The list as the next read will see it; anything the strict reader refuses is refused here, in its words. */
|
|
113
|
+
function checked(rules) {
|
|
114
|
+
const read = readDecisionRules({ decisionRules: rules });
|
|
115
|
+
if (read.warning) {
|
|
116
|
+
const why = /\((.*)\)/.exec(read.warning)?.[1] ?? read.warning;
|
|
117
|
+
throw new RegisterError(`That change cannot be saved: ${why}.`);
|
|
118
|
+
}
|
|
119
|
+
return read.rules;
|
|
120
|
+
}
|
|
121
|
+
function replaced(rules, i, next, stamp) {
|
|
122
|
+
const out = copy(rules);
|
|
123
|
+
out[i] = { ...next, changedBy: stamp.by, changedAt: stamp.at };
|
|
124
|
+
return checked(out);
|
|
125
|
+
}
|
|
126
|
+
export function toggleRule(rules, id, on, stamp) {
|
|
127
|
+
const i = find(rules, id);
|
|
128
|
+
const before = rules[i];
|
|
129
|
+
if (before.on === on)
|
|
130
|
+
throw new RegisterError(`Rule ${before.id} is already ${on ? 'on' : 'off'}.`);
|
|
131
|
+
const next = replaced(rules, i, { ...copy(before), on }, stamp);
|
|
132
|
+
const words = ruleWords(before);
|
|
133
|
+
return {
|
|
134
|
+
rules: next, basis: copy(rules), change: on ? 'on' : 'off', id: before.id, before: copy(before), after: next[i],
|
|
135
|
+
summary: `Rule ${before.id} turned ${on ? 'on' : 'off'}: ${words}`,
|
|
136
|
+
card: `Turn ${on ? 'on' : 'off'} rule ${before.id}: ${words}.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** A count from a flag or a typed answer: a whole number of 0 or more, else the one sentence. */
|
|
140
|
+
export function wholeCount(v, flag) {
|
|
141
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' && /^\s*\d+\s*$/.test(v) ? Number(v) : Number.NaN;
|
|
142
|
+
if (!Number.isInteger(n) || n < 0)
|
|
143
|
+
throw new RegisterError(`${flag} needs a whole number of 0 or more, for example ${flag} 15.`);
|
|
144
|
+
return n;
|
|
145
|
+
}
|
|
146
|
+
export function setThreshold(rules, id, t, stamp) {
|
|
147
|
+
const i = find(rules, id);
|
|
148
|
+
const before = rules[i];
|
|
149
|
+
const when = { ...copy(before.when) };
|
|
150
|
+
if (t.minFindings !== undefined)
|
|
151
|
+
when.minFindings = wholeCount(t.minFindings, '--min-findings');
|
|
152
|
+
if (t.maxFindings !== undefined)
|
|
153
|
+
when.maxFindings = wholeCount(t.maxFindings, '--max-findings');
|
|
154
|
+
if (when.minFindings !== undefined && when.maxFindings !== undefined && when.minFindings > when.maxFindings) {
|
|
155
|
+
throw new RegisterError(`That would need at least ${when.minFindings} and at most ${when.maxFindings} findings: no object could match.`);
|
|
156
|
+
}
|
|
157
|
+
const next = replaced(rules, i, { ...copy(before), when }, stamp);
|
|
158
|
+
const was = countWords(before.when);
|
|
159
|
+
const now = countWords(when);
|
|
160
|
+
const change = was ? `${was.replace(/ findings$/, now.endsWith(' findings') ? '' : ' findings')} → ${now}` : `${now} (was no count)`;
|
|
161
|
+
const said = `rule ${before.id}: ${change} → ${thenWords(before.then)}`;
|
|
162
|
+
return { rules: next, basis: copy(rules), change: 'set', id: before.id, before: copy(before), after: next[i], summary: cap(said), card: `Change ${said}.` };
|
|
163
|
+
}
|
|
164
|
+
export function setOutcome(rules, id, disposition, stamp, target) {
|
|
165
|
+
if (!DISPOSITIONS.includes(disposition))
|
|
166
|
+
throw new RegisterError('--then is keep, fix, rebuild or retire-candidate.');
|
|
167
|
+
const i = find(rules, id);
|
|
168
|
+
const before = rules[i];
|
|
169
|
+
const then = { disposition };
|
|
170
|
+
const t = target === undefined ? before.then.target : target === null ? undefined : levelOrThrow(target);
|
|
171
|
+
if (t)
|
|
172
|
+
then.target = t;
|
|
173
|
+
const next = replaced(rules, i, { ...copy(before), then }, stamp);
|
|
174
|
+
const said = `rule ${before.id}: ${thenWords(before.then)} → ${thenWords(then)} (${whenWords(before.when)})`;
|
|
175
|
+
return { rules: next, basis: copy(rules), change: 'set', id: before.id, before: copy(before), after: next[i], summary: cap(said), card: `Change ${said}.` };
|
|
176
|
+
}
|
|
177
|
+
function levelOrThrow(v) {
|
|
178
|
+
const l = levelOf(v);
|
|
179
|
+
if (!l)
|
|
180
|
+
throw new RegisterError('--target is A, B, C or D.');
|
|
181
|
+
return l;
|
|
182
|
+
}
|
|
183
|
+
/** The level the rule proposes; null = the object's own target (the default). */
|
|
184
|
+
export function setTarget(rules, id, target, stamp) {
|
|
185
|
+
const t = target === null ? null : levelOrThrow(target);
|
|
186
|
+
const i = find(rules, id);
|
|
187
|
+
const before = rules[i];
|
|
188
|
+
const then = { disposition: before.then.disposition, ...(t ? { target: t } : {}) };
|
|
189
|
+
const next = replaced(rules, i, { ...copy(before), then }, stamp);
|
|
190
|
+
const own = "the object's own target";
|
|
191
|
+
const said = `rule ${before.id} target: ${before.then.target ?? own} → ${t ?? own}`;
|
|
192
|
+
return { rules: next, basis: copy(rules), change: 'set', id: before.id, before: copy(before), after: next[i], summary: cap(said), card: `Change ${said}.` };
|
|
193
|
+
}
|
|
194
|
+
/** The rules a lead can add by name. Each is checked by the strict reader like any other. */
|
|
195
|
+
export const RULE_TEMPLATES = {
|
|
196
|
+
'many-findings': { when: { minFindings: 26 }, then: { disposition: 'rebuild' }, note: 'too many findings to fix in place' },
|
|
197
|
+
'few-findings': { when: { maxFindings: 5 }, then: { disposition: 'fix' }, note: 'few findings: fix in place' },
|
|
198
|
+
'meets-target': { when: { noOpenForTarget: true }, then: { disposition: 'keep' }, note: 'meets its target already' },
|
|
199
|
+
'no-callers': { when: { callers: 'none-read-ok', usage: 'unused' }, then: { disposition: 'retire-candidate' }, note: 'no callers found; candidate until the lead confirms' },
|
|
200
|
+
'unreleased-class': {
|
|
201
|
+
when: { types: ['CLAS', 'INTF'], onlyClasses: ['not-released', 'deprecated'], maxFindings: 10 },
|
|
202
|
+
then: { disposition: 'fix' }, note: 'a small class using unreleased or deprecated APIs: swap to released ones',
|
|
203
|
+
},
|
|
204
|
+
'classic-program': {
|
|
205
|
+
when: { types: ['PROG'], findingRules: ['SEARCH_PROBLEMATIC_STATEMENTS_FOR_ALV_CALLS'], targetAtMost: 'A' }, then: { disposition: 'rebuild' }, note: 'a classic list program cannot reach A in place',
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
/** The next free "R<n>" after the highest one in use. */
|
|
209
|
+
function nextId(rules) {
|
|
210
|
+
const n = Math.max(0, ...rules.map((r) => Number(/^R(\d+)$/i.exec(r.id)?.[1] ?? 0)));
|
|
211
|
+
return `R${n + 1}`;
|
|
212
|
+
}
|
|
213
|
+
/** Added at the end: rules are tried in order, so a new one never jumps ahead of the lead's own. */
|
|
214
|
+
export function addFromTemplate(rules, template, stamp) {
|
|
215
|
+
const t = RULE_TEMPLATES[template.trim().toLowerCase()];
|
|
216
|
+
if (!t)
|
|
217
|
+
throw new RegisterError(`There is no template "${template.trim()}". Templates: ${Object.keys(RULE_TEMPLATES).join(', ')}.`);
|
|
218
|
+
const rule = { id: nextId(rules), on: true, ...copy(t), changedBy: stamp.by, changedAt: stamp.at };
|
|
219
|
+
const next = checked([...copy(rules), rule]);
|
|
220
|
+
const said = `rule ${rule.id} added from the template ${template.trim().toLowerCase()}: ${ruleWords(rule)}`;
|
|
221
|
+
return { rules: next, basis: copy(rules), change: 'add', id: rule.id, before: null, after: next.at(-1), summary: cap(said), card: `Add ${said.replace(' added', '')}.` };
|
|
222
|
+
}
|
|
223
|
+
export function removeRule(rules, id) {
|
|
224
|
+
const i = find(rules, id);
|
|
225
|
+
const before = rules[i];
|
|
226
|
+
const next = checked(copy(rules).filter((_, k) => k !== i));
|
|
227
|
+
return {
|
|
228
|
+
rules: next, basis: copy(rules), change: 'remove', id: before.id, before: copy(before), after: null,
|
|
229
|
+
summary: `Rule ${before.id} removed: ${ruleWords(before)}`, card: `Remove rule ${before.id}: ${ruleWords(before)}.`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
export function resetRules(basis) {
|
|
233
|
+
return {
|
|
234
|
+
rules: null, ...(basis ? { basis: copy(basis) } : {}), change: 'reset', before: null, after: null,
|
|
235
|
+
summary: 'Decision rules restored to the defaults',
|
|
236
|
+
card: 'Restore the default decision rules? Every change made to the rules is replaced.',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* A rule from the person's words (the model's `rules_propose`): the proposal
|
|
241
|
+
* is data, never trusted. Its own id, on/off and stamp are ignored — the rule
|
|
242
|
+
* gets the next free id, starts on, and is stamped by the person who saves it —
|
|
243
|
+
* and the whole list must pass the strict reader. Added at the end.
|
|
244
|
+
*/
|
|
245
|
+
export function addProposed(rules, proposal, stamp) {
|
|
246
|
+
let candidate = proposal;
|
|
247
|
+
if (proposal && typeof proposal === 'object' && !Array.isArray(proposal)) {
|
|
248
|
+
const { id: _id, on: _on, changedBy: _by, changedAt: _at, ...rest } = proposal;
|
|
249
|
+
candidate = { id: nextId(rules), on: true, ...rest, changedBy: stamp.by, changedAt: stamp.at };
|
|
250
|
+
}
|
|
251
|
+
const next = checked([...copy(rules), candidate]);
|
|
252
|
+
const rule = next.at(-1);
|
|
253
|
+
return {
|
|
254
|
+
rules: next, basis: copy(rules), change: 'add', id: rule.id, before: null, after: rule,
|
|
255
|
+
summary: `Rule ${rule.id} added from your words: ${ruleWords(rule)}`, card: `Add rule ${rule.id}: ${ruleWords(rule)}.`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -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>`;
|