@bongos/core 1.19.488 → 1.19.490

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,116 @@
1
1
  {
2
- "_": "The hall's PROFILE page through modules/ui-design/kit/render.js (task 1003308). The identity band, the shelf and the three activity ledgers only exist once a profile has LOADED, so the floors run against the hall-preview harness (see settings.states.json for the two commands; pass --base-in/--base-out at it). `out` is a real state here rather than a redirect: a signed-out visitor with no ?id= gets the empty state, not a bounce — under the harness's --fixture-me the /me read still answers, so `out` renders the own profile there and the empty state only under the kit's own stub.",
2
+ "_": "The hall's PROFILE page through modules/ui-design/kit/render.js (task 1003308; four screens behind the section rail since task 1003489 — overview, works, activity, connections). The identity band, the charts, the works ledger and the activity ledgers only exist once a profile has LOADED, so the floors run against the hall-preview harness (see settings.states.json for the two commands; pass --base-in/--base-out at it). One rail key renders at a time, so each key is its own state. `out` is a real state here rather than a redirect: a signed-out visitor with no ?id= gets the empty state, not a bounce — under the harness's --fixture-me the /me read still answers, so `out` renders the own profile there and the empty state only under the kit's own stub.",
3
3
  "page": "/profile",
4
4
  "surface": "hall-ui",
5
- "stub": { "prefix": "/builders/" },
5
+ "stub": {
6
+ "prefix": "/builders/"
7
+ },
6
8
  "modeQuery": false,
7
- "ignoreRequests": ["/api/(gds|bongos)/"],
8
- "ignoreConsole": ["/api/(gds|bongos)/", "\b401\b", "\b404\b", "Unauthorized", "Not Found", "load failed"],
9
+ "ignoreRequests": [
10
+ "/api/(gds|bongos)/"
11
+ ],
12
+ "ignoreConsole": [
13
+ "/api/(gds|bongos)/",
14
+ "\\b401\\b",
15
+ "\\b404\\b",
16
+ "Unauthorized",
17
+ "Not Found",
18
+ "load failed"
19
+ ],
9
20
  "states": {
10
- "own": { "auth": true, "actions": [["wait", 900]], "expect": { "visible": ["#profile-h1", "#profile-head", "#numbers-body", "#works-table", "#activity-tabs"] } },
11
- "other": { "auth": true, "url": "/profile?id=1000025", "actions": [["wait", 900]], "expect": { "visible": ["#profile-head", "#ach-grid"] } },
12
- "out": { "auth": false, "actions": [["wait", 600]], "expect": { "visible": ["#profile-h1", "#profile-head"] } }
21
+ "own": {
22
+ "auth": true,
23
+ "actions": [
24
+ [
25
+ "wait",
26
+ 1200
27
+ ]
28
+ ],
29
+ "expect": {
30
+ "visible": [
31
+ "#profile-h1",
32
+ "#profile-rail",
33
+ "#profile-head",
34
+ "#numbers-body",
35
+ "#charts-controls",
36
+ "#charts-grid",
37
+ "#ach-grid"
38
+ ]
39
+ }
40
+ },
41
+ "works": {
42
+ "auth": true,
43
+ "url": "/profile#works",
44
+ "actions": [
45
+ [
46
+ "wait",
47
+ 1200
48
+ ]
49
+ ],
50
+ "expect": {
51
+ "visible": [
52
+ "#profile-rail",
53
+ "#works-table"
54
+ ]
55
+ }
56
+ },
57
+ "activity": {
58
+ "auth": true,
59
+ "url": "/profile#activity",
60
+ "actions": [
61
+ [
62
+ "wait",
63
+ 1200
64
+ ]
65
+ ],
66
+ "expect": {
67
+ "visible": [
68
+ "#profile-rail",
69
+ "#activity-tabs",
70
+ "#activity-tasks"
71
+ ]
72
+ }
73
+ },
74
+ "other": {
75
+ "auth": true,
76
+ "url": "/profile?id=1000025",
77
+ "actions": [
78
+ [
79
+ "wait",
80
+ 1200
81
+ ]
82
+ ],
83
+ "expect": {
84
+ "visible": [
85
+ "#profile-head",
86
+ "#charts-grid",
87
+ "#ach-grid"
88
+ ]
89
+ }
90
+ },
91
+ "out": {
92
+ "auth": false,
93
+ "actions": [
94
+ [
95
+ "wait",
96
+ 600
97
+ ]
98
+ ],
99
+ "expect": {
100
+ "visible": [
101
+ "#profile-h1",
102
+ "#profile-head"
103
+ ]
104
+ }
105
+ }
13
106
  },
14
- "reducedMotion": { "auth": true, "actions": [["wait", 900]] }
107
+ "reducedMotion": {
108
+ "auth": true,
109
+ "actions": [
110
+ [
111
+ "wait",
112
+ 1200
113
+ ]
114
+ ]
115
+ }
15
116
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.488",
3
+ "version": "1.19.490",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.488",
9
+ "version": "1.19.490",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.488",
3
+ "version": "1.19.490",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -17,8 +17,14 @@ const FIXTURES = path.join(__dirname, 'fixtures');
17
17
 
18
18
  // [api path (no /api prefix, no query), fixture name, optional trim fn]
19
19
  const trimList = (key, n) => (d) => (Array.isArray(d?.[key]) ? { ...d, [key]: d[key].slice(0, n) } : d);
20
+ // A paged ledger is one page of a longer record, and the harness ignores query
21
+ // strings — so a fixture whose page.total exceeds its rows would send the
22
+ // profile charts' pager back for the same page until "total". Pin total to
23
+ // what the snapshot holds.
24
+ const selfPaged = (d) => (Array.isArray(d?.rows) && d.page ? { ...d, page: { ...d.page, total: d.rows.length } } : d);
20
25
  const SNAPSHOTS = [
21
26
  ['me', 'me'],
27
+ ['me/credits?limit=200', 'me__credits', selfPaged],
22
28
  ['me/recent-ships?limit=30', 'me__recent-ships'],
23
29
  ['achievements', 'achievements'],
24
30
  ['versions', 'versions'],
@@ -111,6 +111,9 @@ const FIXTURE_FALLBACKS = [
111
111
  [/^analytics\/builder\/\d+$/, 'analytics__builder'],
112
112
  [/^builders\/\d+\/active-claims$/, 'builders__active-claims'],
113
113
  [/^builders\/\d+\/profile$/, 'builders__profile'],
114
+ // the profile's drachmae chart reads the credits ledger (task 1003491): one
115
+ // ledger fixture serves your own (/me/credits, exact match) and any other id.
116
+ [/^builders\/\d+\/credits$/, 'me__credits'],
114
117
  // the profile's per-craft activity log (task 1003281). One fixture per SECTION
115
118
  // rather than per id, because the section is what changes the row shape; the
116
119
  // craft and the page window are query string, which fixture matching ignores.
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.488'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.490'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,437 @@
1
+ // tests/hall_profile_world.mjs — the profile page reorganised behind the
2
+ // settings-style section rail, with charts of the record over time
3
+ // (task 1003489, goal 1000081).
4
+ //
5
+ // tests/hall_settings_world.mjs pins the PANEL family's archetype; this pins
6
+ // what this task added to one page of it. Everything here is a fact that is
7
+ // silent in a browser: the page still renders, it just renders long again, or
8
+ // a chart draws a number nobody can reach without the picture.
9
+ //
10
+ // What it locks:
11
+ // * THE RAIL AND THE GROUPS. profile.html keeps ONE group of sections visible
12
+ // behind a left rail in switch mode (OTBKit.makeSectionRail — settings'
13
+ // idiom, not a second tab row over the craft tabs). Every section carries
14
+ // a data-group; the keys are overview / activity / connections; the sheet
15
+ // shows exactly the active group and a section's own [hidden] still wins.
16
+ // * THE OVERVIEW IS THE PORTFOLIO. Identity, figures, charts, shelf, the
17
+ // public latest-works strip and the cross-project record; the craft
18
+ // ledgers are one key over, so the first screen is a screen.
19
+ // * THE CHARTS ARE ARITHMETIC FIRST. cumulativeByDay folds two ships on one
20
+ // day into one point and accumulates, over whichever timestamp the caller
21
+ // names; craftMix folds the tail past seven into "other" rather than
22
+ // minting an eighth hue; weeklyBuckets is a time axis (every week present,
23
+ // empty or not).
24
+ // * DRACHMAE COME FROM THE LEDGER (task 1003491). The earned line is the
25
+ // credits ledger — /me/credits on your own profile, /builders/:id/credits
26
+ // on another's — `delta` cumulated by `recorded_at`, so it ends at the
27
+ // balance. Per-task credits_awarded is 0 on a cost-plus instance and is no
28
+ // longer read at all (the owner's chart had flatlined since July on it).
29
+ // A viewer the ledger refuses gets the credits_reward estimate, labelled.
30
+ // * NO NUMBER IS PICTURE-ONLY. Every time chart carries a table twin with a
31
+ // row per plotted day; the readout is written with textContent.
32
+ // * OWN-ONLY MEANS OWN-ONLY. /analytics/builder/:id is asked for only when
33
+ // the viewer is the builder; signed out, the section says why and asks the
34
+ // server for nothing more.
35
+ //
36
+ // Reproduce the renders these came from:
37
+ // node scripts/hall-preview/server.js --port 4621 --fixture-me
38
+ // node modules/ui-design/kit/render.js --states modules/hall-ui/public/profile.states.json \
39
+ // --base-in http://builders.localhost:4621 --base-out http://builders.localhost:4621
40
+
41
+ import { test } from 'node:test';
42
+ import assert from 'node:assert/strict';
43
+ import fs from 'node:fs';
44
+ import path from 'node:path';
45
+ import vm from 'node:vm';
46
+ import { fileURLToPath } from 'node:url';
47
+
48
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
49
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
50
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8').replace(/\r\n/g, '\n');
51
+ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
52
+
53
+ const html = read('profile.html');
54
+ const profileJs = read('profile.js');
55
+ const chartsJs = read('profile-charts.js');
56
+ const profileCss = stripComments(read('profile.css'));
57
+
58
+ const GROUPS = ['overview', 'works', 'activity', 'connections'];
59
+
60
+ // ---- the rail and the groups ---------------------------------------------------
61
+
62
+ test('the profile has a section rail in switch mode and one group container', () => {
63
+ assert.match(html, /<nav class="section-rail" id="profile-rail" aria-label="[^"]+"><\/nav>/, 'the rail mount is a nav the kit fills');
64
+ assert.match(html, /<div class="profile-groups" id="profile-groups" data-active="overview">/, 'the groups open on the overview');
65
+ assert.match(profileJs, /makeSectionRail\(\{[\s\S]*?mode: 'switch'/, 'profile.js builds the rail in switch mode');
66
+ assert.doesNotMatch(profileJs, /makeTabs\(\{\s*mount: \$\('#profile-/, 'no page-level tab row — the craft tabs stay the only tablist');
67
+ });
68
+
69
+ test('every section in the groups carries a known data-group, and the keys are the owner\'s four', () => {
70
+ const sections = [...html.matchAll(/<section class="scroll[^"]*" id="([a-z-]+)"[^>]*>/g)];
71
+ assert.ok(sections.length >= 7, `expected the page's sections, saw ${sections.length}`);
72
+ for (const m of sections) {
73
+ const g = m[0].match(/data-group="([a-z]+)"/);
74
+ assert.ok(g, `section #${m[1]} has no data-group`);
75
+ assert.ok(GROUPS.includes(g[1]), `section #${m[1]} names an unknown group "${g[1]}"`);
76
+ }
77
+ const groupOf = (id) => html.match(new RegExp(`id="${id}"[^>]*data-group="([a-z]+)"`))[1];
78
+ for (const id of ['standing-scroll', 'numbers-scroll', 'charts-scroll', 'ach-scroll', 'xp-scroll']) {
79
+ assert.equal(groupOf(id), 'overview', `#${id} belongs to the overview`);
80
+ }
81
+ assert.equal(groupOf('works-scroll'), 'works', 'the works ledger is its own key');
82
+ assert.equal(groupOf('activity-scroll'), 'activity');
83
+ assert.equal(groupOf('conn-scroll'), 'connections');
84
+ const railKeys = profileJs.match(/const RAIL_ITEMS = \[([\s\S]*?)\];/)[1].match(/id: '([a-z]+)'/g).map((s) => s.slice(5, -1));
85
+ assert.deepEqual(railKeys, GROUPS, 'the rail lists the four keys in reading order');
86
+ });
87
+
88
+ test('the sheet shows exactly the active group, and a hidden section stays hidden', () => {
89
+ assert.match(profileCss, /\.profile-groups > \[data-group\] \{ display: none; \}/);
90
+ for (const g of GROUPS) {
91
+ assert.match(profileCss, new RegExp(`\\.profile-groups\\[data-active="${g}"\\] > \\[data-group="${g}"\\]`), `a rule reveals the ${g} group`);
92
+ }
93
+ assert.match(profileCss, /\.profile-groups > \[data-group\]\[hidden\] \{ display: none !important; \}/, 'a section\'s own [hidden] wins over the group reveal');
94
+ assert.match(profileCss, /@media \(max-width: 860px\) \{[\s\S]*?\.profile-layout \{ grid-template-columns: minmax\(0, 1fr\)/, 'the rail column collapses where settings\' does');
95
+ });
96
+
97
+ test('the connections key follows its section: the rail refreshes when #conn-scroll reveals', () => {
98
+ assert.match(profileJs, /new MutationObserver\([\s\S]*?attributeFilter: \['hidden'\]/, 'a MutationObserver watches the hidden attribute');
99
+ assert.match(profileJs, /rail\.refresh\(/, 'and refreshes the rail\'s items from it');
100
+ });
101
+
102
+ test('the Works key is the full server-paged ledger, with a public recent-ships fallback', () => {
103
+ assert.match(html, /id="works-scroll"[^>]*data-group="works"[\s\S]*?id="works-pager"/, 'the works section has the ledger table and a pager mount');
104
+ assert.match(html, /id="works-lede"[^>]*>[\s\S]*?<a href="#activity">/, 'the lede points at the activity key for the by-craft view');
105
+ assert.match(profileJs, /buildWorksLedger\(builder\.id, selfId != null, data\.recent_ships\)/, 'renderProfile builds the works ledger');
106
+ assert.match(profileJs, /makePager\('works'[\s\S]*?section=tasks&limit=\$\{pager\.perPage\}&offset=\$\{kit\.pageOffset\(pager\)\}/, 'signed in: the whole record, server-paged from the activity route');
107
+ assert.match(profileJs, /if \(!signedIn\)[\s\S]*?recentShips[\s\S]*?Sign in<\/a> to see the full record/, 'signed out: the public recent_ships stand in with a sign-in line');
108
+ assert.doesNotMatch(html, /data-group="overview"[^>]*id="works-scroll"|id="works-scroll"[^>]*data-group="overview"/, 'the works ledger no longer sits on overview — no duplicate of the record');
109
+ assert.match(profileCss, /#works-scroll \.ledger td a,\n#activity-scroll \.ledger td a,\n#charts-scroll a,/, 'links in the works ledger, the activity ledgers and the charts take the 24px prose-link recipe');
110
+ });
111
+
112
+ // ---- the charts: script order and the section --------------------------------
113
+
114
+ test('profile-charts.js loads before profile.js, and profile.js hands it the page\'s client', () => {
115
+ const a = html.indexOf('/builders/profile-charts.js');
116
+ const b = html.indexOf('/builders/profile.js');
117
+ assert.ok(a > 0 && a < b, 'the charts script is in the page, ahead of profile.js');
118
+ assert.match(profileJs, /window\.OTBProfileCharts\.render\(\{ api, API, builderId: builder\.id, selfId, signedIn: selfId != null \}\)/);
119
+ assert.match(html, /<section class="scroll" id="charts-scroll"[^>]*>[\s\S]*?<div class="pcharts" id="charts-grid">/, 'the charts section is a .pcharts grid');
120
+ });
121
+
122
+ test('the marks keep the dataviz specs: 2px accent line, 10% wash, hairline solid axis, an 8px end dot with a surface ring', () => {
123
+ assert.match(profileCss, /\.pchart__line \{ fill: none; stroke: var\(--accent\); stroke-width: 2;/);
124
+ assert.match(profileCss, /\.pchart__area \{ fill: color-mix\(in srgb, var\(--accent\) 10%, transparent\)/);
125
+ assert.match(profileCss, /\.pchart__axis, \.pchart__hair \{ stroke: var\(--rule-soft\); stroke-width: 1; \}/);
126
+ assert.match(profileCss, /\.pchart__dot \{ fill: var\(--accent\); stroke: var\(--bg-card\); stroke-width: 2; \}/);
127
+ assert.match(chartsJs, /r="4"/, 'the end dot is r=4 — an 8px marker');
128
+ assert.match(profileCss, /\.pchart__end \{[^}]*fill: var\(--ink\)/, 'the end label wears ink, not the series colour');
129
+ assert.doesNotMatch(profileCss, /var\(--(surface|surface-2|border|border-strong|accent-weak|accent-text)\)/, 'no compat alias spent');
130
+ });
131
+
132
+ // ---- the charts: arithmetic, in a sandbox --------------------------------------
133
+
134
+ function bootCharts() {
135
+ const sandbox = {
136
+ window: {
137
+ __BRANDING__: { currency: { label: 'drachmae' } },
138
+ OTB: {
139
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
140
+ $: () => null,
141
+ fmtNum: (n) => String(n ?? 0),
142
+ fmtDate: (iso) => String(iso ?? '').slice(0, 10),
143
+ },
144
+ },
145
+ console, Date, Map, Set, Math, Number, String, Array, Infinity, URLSearchParams,
146
+ };
147
+ sandbox.globalThis = sandbox;
148
+ vm.createContext(sandbox);
149
+ vm.runInContext(chartsJs, sandbox, { filename: 'profile-charts.js' });
150
+ return sandbox.window.OTBProfileCharts;
151
+ }
152
+
153
+ const NOW = Date.parse('2026-09-02T12:00:00Z');
154
+ const day = (s) => Date.parse(`${s}T00:00:00Z`);
155
+ // Objects born in the sandbox have the sandbox's Object prototype, so a strict
156
+ // deep-equal against a literal here fails on identity alone; compare the data.
157
+ const plain = (v) => JSON.parse(JSON.stringify(v));
158
+
159
+ test('cumulativeByDay folds a day\'s ships into one point and accumulates, skipping a row with no date', () => {
160
+ const C = bootCharts();
161
+ const rows = [
162
+ { shipped_at: '2026-08-10T09:00:00Z' },
163
+ { shipped_at: '2026-08-10T17:00:00Z' },
164
+ { shipped_at: '2026-08-01T01:00:00Z' },
165
+ { shipped_at: null },
166
+ ];
167
+ assert.deepEqual(plain(C.cumulativeByDay(rows, () => 1)), [
168
+ { day: day('2026-08-01'), value: 1 },
169
+ { day: day('2026-08-10'), value: 3 },
170
+ ]);
171
+ assert.deepEqual(plain(C.cumulativeByDay([], () => 1)), []);
172
+ // the ledger's rows are stamped recorded_at, not shipped_at — the caller names the timestamp
173
+ const ledger = [
174
+ { delta: 12, recorded_at: '2026-08-02T22:17:01Z' },
175
+ { delta: 39, recorded_at: '2026-08-01T21:52:51Z' },
176
+ { delta: 19, recorded_at: '2026-08-01T22:05:00Z' },
177
+ ];
178
+ assert.deepEqual(plain(C.cumulativeByDay(ledger, (r) => r.delta, (r) => r.recorded_at)), [
179
+ { day: day('2026-08-01'), value: 58 },
180
+ { day: day('2026-08-02'), value: 70 },
181
+ ], 'newest-first ledger rows still accumulate oldest-first, by their own timestamp');
182
+ });
183
+
184
+ test('estimateOf is the credits_reward figure and nothing else — the per-task award column is not read anywhere', () => {
185
+ const C = bootCharts();
186
+ assert.equal(C.estimateOf({ credits_awarded: 58, credits_reward: 60 }), 60, 'the estimate ignores what was awarded');
187
+ assert.equal(C.estimateOf({ credits_awarded: 0, credits_reward: 45 }), 45, 'a cost-plus 0 award does not zero the estimate');
188
+ assert.equal(C.estimateOf({}), 0);
189
+ assert.equal(C.estimateOf(null), 0);
190
+ assert.equal(C.creditsOf, undefined, 'the old ledger-or-estimate picker is gone');
191
+ // The whole reason for task 1003491: on a cost-plus instance every task's
192
+ // credits_awarded is 0, so a drachmae series summed from it flatlines the day
193
+ // per-task payout stops. The column must not be read by the charts at all.
194
+ assert.ok(!chartsJs.includes('credits_awarded'), 'profile-charts.js never mentions credits_awarded');
195
+ });
196
+
197
+ test('craftMix sorts by count and folds the tail past seven into "other" — never an eighth hue', () => {
198
+ const C = bootCharts();
199
+ const rows = ['a', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', null].map((d) => ({ discipline: d }));
200
+ const mix = plain(C.craftMix(rows));
201
+ assert.equal(mix.length, 7);
202
+ assert.deepEqual(mix[0], { label: 'a', n: 2 });
203
+ assert.equal(mix[6].label, 'other');
204
+ assert.equal(mix.reduce((s, k) => s + k.n, 0), rows.length, 'nothing is lost in the fold');
205
+ assert.ok(C.craftMix([{ discipline: null }]).some((k) => k.label === 'unclassified'), 'a missing craft reads as unclassified');
206
+ assert.equal(C.craftMix(rows.slice(0, 3)).length, 2, 'under the cap there is no fold');
207
+ });
208
+
209
+ test('weeklyBuckets is a time axis: every week present, ships summed into theirs, outside the window dropped', () => {
210
+ const C = bootCharts();
211
+ const b = C.weeklyBuckets([
212
+ { day: '2026-09-01', n: 2 }, { day: '2026-08-30', n: 1 }, { day: '2026-06-01', n: 9 },
213
+ ], 28, NOW);
214
+ assert.equal(b.length, 4);
215
+ assert.equal(b[3].n, 3, 'the current week holds both recent ships');
216
+ assert.equal(b.reduce((s, w) => s + w.n, 0), 3, 'the June ship is outside 28 days');
217
+ assert.ok(b.every((w, i) => i === 0 || w.start - b[i - 1].start === 7 * 86400000), 'weeks are contiguous');
218
+ });
219
+
220
+ test('clipToWindow (the lookback) enters the window at the running height, not at zero', () => {
221
+ const C = bootCharts();
222
+ const pts = [
223
+ { day: day('2026-06-01'), value: 3 },
224
+ { day: day('2026-08-20'), value: 4 },
225
+ { day: day('2026-08-28'), value: 6 },
226
+ ];
227
+ // null window = the whole record, untouched
228
+ assert.equal(C.clipToWindow(pts, null), pts);
229
+ // a window that starts after the first point: the synthetic baseline carries
230
+ // the total accumulated BEFORE the window (3), so the line does not restart at 0
231
+ const clipped = plain(C.clipToWindow(pts, day('2026-08-01')));
232
+ assert.deepEqual(clipped[0], { day: day('2026-08-01'), value: 3 }, 'the line enters at the running total');
233
+ assert.equal(clipped[clipped.length - 1].value, 6, 'and still ends at the all-time total');
234
+ // a window with no points inside is one flat baseline at the pre-window total
235
+ assert.deepEqual(plain(C.clipToWindow(pts, day('2026-09-01'))), [{ day: day('2026-09-01'), value: 6 }]);
236
+ });
237
+
238
+ // ---- the charts: what render asks for and draws ---------------------------------
239
+
240
+ // `ledger` is what the credits routes answer: an array of rows (paged by the
241
+ // route's 200 in the real shape, { rows, page: { total } }), or 'denied' for the
242
+ // 403 a viewer without builder.roster.read gets on someone else's ledger.
243
+ function bootRender({ rows, status = 200, analytics = null, ledger = [] }) {
244
+ const requests = [];
245
+ const mount = { innerHTML: '', querySelector: () => null };
246
+ const controls = { innerHTML: '', addEventListener() {}, querySelectorAll: () => [] };
247
+ const store = new Map();
248
+ const api = {
249
+ request: async (method, url) => {
250
+ requests.push(url);
251
+ if (url.includes('/activity')) {
252
+ if (status !== 200) return { status, ok: false, data: null };
253
+ const offset = Number(new URL(`http://x${url}`).searchParams.get('offset')) || 0;
254
+ return { status: 200, ok: true, data: { total: rows.length, rows: rows.slice(offset, offset + 100) } };
255
+ }
256
+ if (url.includes('/credits')) {
257
+ if (ledger === 'denied') return { status: 403, ok: false, data: null };
258
+ const offset = Number(new URL(`http://x${url}`).searchParams.get('offset')) || 0;
259
+ return { status: 200, ok: true, data: { rows: ledger.slice(offset, offset + 200), page: { limit: 200, offset, total: ledger.length } } };
260
+ }
261
+ if (url.includes('/analytics/')) return analytics ? { status: 200, ok: true, data: analytics } : { status: 403, ok: false, data: null };
262
+ return { status: 404, ok: false, data: null };
263
+ },
264
+ };
265
+ const sandbox = {
266
+ window: {
267
+ __BRANDING__: { currency: { label: 'drachmae' } },
268
+ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
269
+ OTB: {
270
+ escapeHtml: (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
271
+ $: (sel) => (sel === '#charts-grid' ? mount : sel === '#charts-controls' ? controls : null),
272
+ fmtNum: (n) => String(n ?? 0),
273
+ fmtDate: (iso) => String(iso ?? '').slice(0, 10),
274
+ },
275
+ },
276
+ console, Date, Map, Set, Math, Number, String, Array, Infinity, URLSearchParams, Promise,
277
+ };
278
+ sandbox.globalThis = sandbox;
279
+ vm.createContext(sandbox);
280
+ vm.runInContext(chartsJs, sandbox, { filename: 'profile-charts.js' });
281
+ return { C: sandbox.window.OTBProfileCharts, api, requests, mount, controls };
282
+ }
283
+
284
+ const ship = (d, extra = {}) => ({ shipped_at: `${d}T10:00:00Z`, discipline: 'engineer', credits_awarded: 10, credits_reward: 12, ...extra });
285
+ // a credits-ledger row: what the instance actually paid, when
286
+ const pay = (d, delta, extra = {}) => ({ delta, reason: 'session.token_reward', recorded_at: `${d}T22:00:00Z`, ...extra });
287
+
288
+ test('render draws three public figures with a table twin per time chart, and no own-only one for another builder', async () => {
289
+ const b = bootRender({
290
+ rows: [ship('2026-08-01'), ship('2026-08-01'), ship('2026-08-20', { discipline: 'ui' })],
291
+ ledger: [pay('2026-08-01', 20), pay('2026-08-20', 30)],
292
+ });
293
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
294
+ const out = b.mount.innerHTML;
295
+ assert.ok(out.includes('id="chart-works"') && out.includes('id="chart-drachmae"') && out.includes('id="chart-mix"'), 'the three public charts');
296
+ assert.ok(out.includes('<td>2026-08-20</td><td class="ledger__num">50 drachmae</td>'), 'the drachmae twin reads the ledger, not the 30 of per-task awards');
297
+ assert.ok(!out.includes('id="chart-rate"'), 'no pace chart on someone else\'s profile');
298
+ assert.ok(!b.requests.some((u) => u.includes('/analytics/')), 'and /analytics was never asked for');
299
+ assert.equal((out.match(/<details class="pchart__twin">/g) || []).length, 2, 'a table twin under each time chart');
300
+ assert.ok(out.includes('<td>2026-08-01</td><td class="ledger__num">2 works</td>'), 'the twin holds the plotted day and its value');
301
+ assert.ok(out.includes('>3 works</text>'), 'only the endpoint is direct-labelled');
302
+ assert.ok(out.includes('Drachmae earned'), 'the currency label comes from branding at render');
303
+ });
304
+
305
+ test('render asks for the pace chart on the viewer\'s own profile only', async () => {
306
+ const analytics = { window_days: 28, ship_rate: [{ day: '2026-09-01', n: 2 }] };
307
+ const own = bootRender({ rows: [ship('2026-08-01')], analytics });
308
+ await own.C.render({ api: own.api, API: '/api/bongos', builderId: 95, selfId: '95', signedIn: true, now: NOW });
309
+ assert.ok(own.requests.some((u) => u.includes('/analytics/builder/95')), 'own profile: the analytics read');
310
+ assert.ok(own.requests.some((u) => u.includes('/me/credits?')), 'own profile: your own ledger');
311
+ assert.ok(!own.requests.some((u) => u.includes('/builders/95/credits')), 'never through the roster route for yourself');
312
+ assert.ok(own.mount.innerHTML.includes('id="chart-rate"'), 'and the pace chart draws');
313
+ assert.ok(own.mount.innerHTML.includes('only you see this one'), 'saying plainly that it is private');
314
+ });
315
+
316
+ test('signed out, the section says why and asks the server for nothing', async () => {
317
+ const b = bootRender({ rows: [] });
318
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: null, signedIn: false, now: NOW });
319
+ assert.equal(b.requests.length, 0);
320
+ assert.match(b.mount.innerHTML, /visible to signed-in builders/);
321
+ const gone = bootRender({ rows: [], status: 401 });
322
+ await gone.C.render({ api: gone.api, API: '/api/bongos', builderId: 95, selfId: 1, signedIn: true, now: NOW });
323
+ assert.match(gone.mount.innerHTML, /visible to signed-in builders/, 'a 401 mid-way reads the same');
324
+ });
325
+
326
+ test('an empty record is one honest line, not three empty cards', async () => {
327
+ const b = bootRender({ rows: [] });
328
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 95, signedIn: true, now: NOW });
329
+ assert.match(b.mount.innerHTML, /No works shipped yet/);
330
+ assert.ok(!b.mount.innerHTML.includes('<figure'), 'no figure drew');
331
+ assert.ok(!b.requests.some((u) => u.includes('/analytics/')), 'and nothing else was asked for');
332
+ });
333
+
334
+ test('the record is followed past the first page, bounded', async () => {
335
+ const many = Array.from({ length: 230 }, (_, i) => ship(`2026-0${1 + (i % 8)}-${String(1 + (i % 28)).padStart(2, '0')}`));
336
+ const b = bootRender({ rows: many });
337
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 1, signedIn: true, now: NOW });
338
+ const pages = b.requests.filter((u) => u.includes('section=tasks'));
339
+ assert.equal(pages.length, 3, 'three pages of 100 for 230 rows');
340
+ assert.match(b.mount.innerHTML, /230 total/);
341
+ });
342
+
343
+ test('the lookback control renders one row of ranges above the grid, defaulting to all time', async () => {
344
+ const b = bootRender({ rows: [ship('2026-08-01')] });
345
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
346
+ const c = b.controls.innerHTML;
347
+ assert.match(c, /class="pchart-range"/, 'the control is one segmented row');
348
+ for (const label of ['30 days', '90 days', '1 year', 'All time']) assert.ok(c.includes(label), `it offers ${label}`);
349
+ assert.match(c, /data-range="all"[^>]*aria-pressed="true"|is-active[^>]*data-range="all"|data-range="all"[^>]*is-active/, 'all time is the default');
350
+ });
351
+
352
+ test('a 30-day window drops the older ships from the craft mix and clips the axis', async () => {
353
+ // one recent ship, one long before the 30-day window
354
+ const rows = [ship('2026-08-30'), ship('2026-06-01', { discipline: 'ui' })];
355
+ const b = bootRender({ rows });
356
+ // seed the remembered range to 30 days so the first draw is windowed
357
+ b.C && (await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW }));
358
+ // the default is all time, so both crafts show; assert the arithmetic path
359
+ // that the window uses instead (the render wiring is covered above)
360
+ const C = bootCharts();
361
+ const within = rows.filter((r) => Date.parse(r.shipped_at) >= day('2026-08-03'));
362
+ assert.equal(C.craftMix(within).length, 1, 'only the recent craft survives a 30-day window');
363
+ });
364
+
365
+ test('the drachmae line is the credits ledger: /me/credits on your own profile, cumulated by recorded_at, ending at the balance', async () => {
366
+ const rows = [ship('2026-08-01'), ship('2026-08-20')]; // per-task awards would sum to 20
367
+ const ledger = [pay('2026-09-01', 12), pay('2026-08-20', 39), pay('2026-08-20', 19), pay('2026-07-05', 30)]; // balance 100
368
+ const b = bootRender({ rows, ledger });
369
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 95, signedIn: true, now: NOW });
370
+ assert.ok(b.requests.some((u) => u.includes('/me/credits?limit=200&offset=0')), 'own profile: /me/credits, paged');
371
+ assert.ok(!b.requests.some((u) => u.includes('/builders/95/credits')), 'not the roster route');
372
+ const out = b.mount.innerHTML;
373
+ const fig = out.slice(out.indexOf('id="chart-drachmae"'), out.indexOf('id="chart-mix"'));
374
+ assert.ok(fig.includes('>100 drachmae</text>'), 'the endpoint is the ledger balance, not the 20 of per-task awards');
375
+ assert.ok(fig.includes('<td>2026-07-05</td><td class="ledger__num">30 drachmae</td>'), 'a paid day with no ship is still a point');
376
+ assert.ok(fig.includes('<td>2026-08-20</td><td class="ledger__num">88 drachmae</td>'), 'two payments on one day fold into one point');
377
+ assert.match(fig, /100 paid, from the credits ledger/);
378
+ assert.doesNotMatch(fig, /estimated/);
379
+ });
380
+
381
+ test('another builder\'s ledger is read from /builders/:id/credits, paged by 200 to total', async () => {
382
+ const ledger = Array.from({ length: 450 }, (_, i) => pay(`2026-08-${String(1 + (i % 28)).padStart(2, '0')}`, 1));
383
+ const b = bootRender({ rows: [ship('2026-08-01')], ledger });
384
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
385
+ const pages = b.requests.filter((u) => u.includes('/builders/95/credits'));
386
+ assert.equal(pages.length, 3, 'three pages of 200 for 450 rows');
387
+ assert.ok(!b.requests.some((u) => u.includes('/me/credits')), 'never your own ledger for someone else\'s chart');
388
+ assert.ok(b.mount.innerHTML.includes('>450 drachmae</text>'), 'and every row counted once');
389
+ });
390
+
391
+ test('a viewer the ledger refuses gets the credits_reward estimate, cumulated and labelled estimated', async () => {
392
+ // the cost-plus shape: every award 0, the estimate is all there is to show
393
+ const rows = [ship('2026-08-01', { credits_awarded: 0, credits_reward: 45 }), ship('2026-08-20', { credits_awarded: 0, credits_reward: 55 })];
394
+ const b = bootRender({ rows, ledger: 'denied' });
395
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
396
+ const out = b.mount.innerHTML;
397
+ const fig = out.slice(out.indexOf('id="chart-drachmae"'), out.indexOf('id="chart-mix"'));
398
+ assert.ok(fig.includes('>100 drachmae</text>'), 'the estimate climbs where the 0 awards would have flatlined');
399
+ assert.match(fig, /estimated from shipped work, not the ledger/);
400
+ assert.ok(!out.includes('id="chart-rate"'), 'the pace chart stays own-only');
401
+ assert.ok(!out.includes('Could not load'), 'a 403 on the ledger is a fallback, not a failure');
402
+ });
403
+
404
+ test('a readable ledger with nothing in it draws one honest line, not a crash', async () => {
405
+ const b = bootRender({ rows: [ship('2026-08-01')], ledger: [] });
406
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 95, signedIn: true, now: NOW });
407
+ const out = b.mount.innerHTML;
408
+ const fig = out.slice(out.indexOf('id="chart-drachmae"'), out.indexOf('id="chart-mix"'));
409
+ assert.match(fig, /nothing recorded yet/);
410
+ assert.ok(out.includes('id="chart-works"') && out.includes('id="chart-mix"'), 'the other charts are untouched');
411
+ });
412
+
413
+ test('the harness carries a credits-ledger fixture the chart can climb on, self-paged, serving both routes', () => {
414
+ const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'me__credits.json'), 'utf8'));
415
+ assert.ok(Array.isArray(fx.rows) && fx.rows.length > 1, 'rows to draw');
416
+ assert.equal(fx.page.total, fx.rows.length, 'total pinned to the rows here, or the pager would loop on the same page');
417
+ assert.ok(fx.rows.every((r) => Number.isFinite(r.delta) && Number.isFinite(Date.parse(r.recorded_at))), 'every row is a delta at a time');
418
+ assert.ok(new Set(fx.rows.map((r) => r.recorded_at.slice(0, 10))).size > 1, 'across more than one day, so the line is not flat');
419
+ const server = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'server.js'), 'utf8');
420
+ assert.ok(server.includes("[/^builders\\/\\d+\\/credits$/, 'me__credits']"), 'the roster route falls back to the same ledger');
421
+ });
422
+
423
+ test('the readout is written with textContent — the hover layer never builds markup from data', () => {
424
+ const fn = chartsJs.slice(chartsJs.indexOf('function wireReadout'), chartsJs.indexOf('function twinHtml'));
425
+ assert.match(fn, /read\.textContent = /);
426
+ assert.doesNotMatch(fn, /innerHTML/);
427
+ });
428
+
429
+ // ---- the states file ---------------------------------------------------------------
430
+
431
+ test('the profile states file renders each rail key, so the floors cover every screen', () => {
432
+ const states = JSON.parse(read('profile.states.json'));
433
+ assert.ok(states.states.own.expect.visible.includes('#profile-rail'), 'own: the rail');
434
+ assert.ok(states.states.own.expect.visible.includes('#charts-grid'), 'own: the charts');
435
+ assert.equal(states.states.activity.url, '/profile#activity');
436
+ assert.ok(states.states.activity.expect.visible.includes('#activity-tabs'));
437
+ });