@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.
@@ -0,0 +1,524 @@
1
+ // modules/hall-ui/public/profile-charts.js — the charts on a builder's profile
2
+ // Overview (task 1003489, goal 1000081): works shipped over time, drachmae
3
+ // earned over time, the craft mix, and — on your own profile only — your ship
4
+ // rate by week. The owner asked for the figures the page already states to be
5
+ // SEEN over time; this file draws them, and a lookback control scopes them all.
6
+ //
7
+ // No chart library: the hall loads none, and each figure here is a hand-built
8
+ // SVG in the hall's own sparkline idiom. The dataviz rules it keeps: one axis,
9
+ // one series per chart drawn in the accent, 2px lines and thin bars, hairline
10
+ // solid axes, only the endpoint labelled, a crosshair readout on hover AND on
11
+ // the keyboard, and a table twin under every time chart so no number is
12
+ // reachable only through the picture. Text wears the ink tokens, never the
13
+ // series colour. One filter row (the lookback) sits above the grid and scopes
14
+ // every chart, the dataviz "date range first" rule.
15
+ //
16
+ // Data: GET /builders/:id/activity?section=tasks — any signed-in viewer, any
17
+ // builder; paged, followed to `total` up to MAX_ROWS. Its rows carry
18
+ // shipped_at and discipline, so the works and craft series derive client-side.
19
+ // The drachmae series is the CREDITS LEDGER (task 1003491): GET /me/credits on
20
+ // your own profile, GET /builders/:id/credits on another's (builder.roster.read,
21
+ // metic+), each row a `delta` at `recorded_at`, cumulated by day — the curve
22
+ // that ends at the headline balance. On a cost-plus instance (ADR 0146) the
23
+ // per-task award is structurally 0 and the pay is the session token reward,
24
+ // which only the ledger records; a series summed from per-task awards went
25
+ // flat the day per-task payout stopped. A viewer the ledger refuses (403)
26
+ // gets the activity rows' credits_reward ESTIMATE instead, cumulated and
27
+ // labelled so. The ship rate is GET /analytics/builder/:id, own-or-archon; it
28
+ // is asked for only when the viewer IS the builder and renders nothing
29
+ // elsewhere — never a 403 toast.
30
+ //
31
+ // Exposed as window.OTBProfileCharts: render({ api, API, builderId, selfId,
32
+ // signedIn, now? }) plus the pure series builders (cumulativeByDay, craftMix,
33
+ // weeklyBuckets, estimateOf, clipToWindow) so tests can pin the arithmetic
34
+ // without a DOM.
35
+ (() => {
36
+ 'use strict';
37
+
38
+ const MAX_ROWS = 500; // five pages: a prolific builder's whole record, bounded
39
+ const PAGE = 100;
40
+ const LEDGER_PAGE = 200; // the ledger route's MAX_LIMIT
41
+ const LEDGER_MAX_ROWS = 2000; // ten pages; the busiest builder's ledger is ~600 rows
42
+ const DAY = 86400000;
43
+ const WEEK = 7 * DAY;
44
+ const MIX_MAX = 7; // bars past this fold into "other" — never an eighth hue
45
+
46
+ // The lookback control. `days: null` is the whole record; the pace chart caps
47
+ // its own weekly view at PACE_MAX_DAYS so "all time" never draws 300 columns.
48
+ const RANGES = [
49
+ { id: '30', label: '30 days', days: 30 },
50
+ { id: '90', label: '90 days', days: 90 },
51
+ { id: '365', label: '1 year', days: 365 },
52
+ { id: 'all', label: 'All time', days: null },
53
+ ];
54
+ const DEFAULT_RANGE = 'all';
55
+ const PACE_MAX_DAYS = 365;
56
+ const RANGE_KEY = 'otb.profile.chartRange';
57
+
58
+ const W = 320; // SVG viewBox; the card scales it
59
+ const H = 132;
60
+ const PAD = { l: 6, r: 8, t: 20, b: 24 }; // t: the end label's room; b: the axis band
61
+
62
+ const esc = (s) => window.OTB.escapeHtml(s);
63
+ const $ = (sel) => window.OTB.$(sel);
64
+ const fmtNum = (n) => window.OTB.fmtNum(n);
65
+ const fmtDate = (iso) => window.OTB.fmtDate(iso);
66
+ const dayIso = (ms) => new Date(ms).toISOString().slice(0, 10);
67
+ const fmtDay = (ms) => fmtDate(dayIso(ms));
68
+ const midnight = (ms) => Math.floor(ms / DAY) * DAY;
69
+
70
+ const curLabel = (caps) => {
71
+ const l = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits';
72
+ return caps ? l.charAt(0).toUpperCase() + l.slice(1) : l;
73
+ };
74
+
75
+ function readRange() {
76
+ try { const v = window.localStorage.getItem(RANGE_KEY); if (RANGES.some((r) => r.id === v)) return v; } catch (_) { /* private mode */ }
77
+ return DEFAULT_RANGE;
78
+ }
79
+ function writeRange(id) {
80
+ try { window.localStorage.setItem(RANGE_KEY, id); } catch (_) { /* private mode */ }
81
+ }
82
+
83
+ // ---- series ---------------------------------------------------------------
84
+
85
+ // The ESTIMATE of what a shipped row pays — credits_reward, the figure the
86
+ // task carried before it shipped. The FALLBACK only: the drachmae series is
87
+ // the ledger wherever the viewer may read it, and this stands in, labelled
88
+ // "estimated" (ADR 0146), when the ledger answers 403. Never the per-task
89
+ // award column — on a cost-plus instance it is 0 for every task, and summing
90
+ // it is the flatline this file was fixed for.
91
+ function estimateOf(row) {
92
+ const e = Number(row && row.credits_reward);
93
+ return Number.isFinite(e) ? e : 0;
94
+ }
95
+
96
+ // rows → [{ day, value }] ascending by day, value cumulative over the WHOLE
97
+ // record. dateOf names the row's timestamp: shipped_at for activity rows (the
98
+ // default), recorded_at for ledger rows. Two entries on one day are one
99
+ // point; a row with no parsable date is skipped, not zeroed. The lookback
100
+ // clips this for drawing (clipToWindow) rather than re-accumulating, so the
101
+ // running total stays true.
102
+ function cumulativeByDay(rows, valueOf, dateOf = (r) => r && r.shipped_at) {
103
+ const byDay = new Map();
104
+ for (const r of rows || []) {
105
+ const t = Date.parse(dateOf(r));
106
+ if (!Number.isFinite(t)) continue;
107
+ const d = midnight(t);
108
+ byDay.set(d, (byDay.get(d) || 0) + Number(valueOf(r)) || 0);
109
+ }
110
+ const days = [...byDay.keys()].sort((a, b) => a - b);
111
+ let acc = 0;
112
+ return days.map((d) => ({ day: d, value: (acc += byDay.get(d)) }));
113
+ }
114
+
115
+ // Clip a cumulative series to [windowStart, ∞) for drawing. The line must
116
+ // enter the window at its running height, not at zero, so a synthetic point
117
+ // at windowStart carries the total accumulated BEFORE the window. null
118
+ // windowStart (All time) returns the series untouched.
119
+ function clipToWindow(points, windowStart) {
120
+ if (windowStart == null || !points.length) return points;
121
+ const inWin = points.filter((p) => p.day >= windowStart);
122
+ if (inWin.length === points.length) return points;
123
+ let baseline = 0;
124
+ for (const p of points) { if (p.day < windowStart) baseline = p.value; else break; }
125
+ if (!inWin.length) return [{ day: windowStart, value: baseline }];
126
+ return [{ day: windowStart, value: baseline }, ...inWin];
127
+ }
128
+
129
+ // rows → [{ label, n }] desc by n; the tail past MIX_MAX folds into "other".
130
+ function craftMix(rows, max = MIX_MAX) {
131
+ const counts = new Map();
132
+ for (const r of rows || []) {
133
+ const k = (r && r.discipline) || 'unclassified';
134
+ counts.set(k, (counts.get(k) || 0) + 1);
135
+ }
136
+ const sorted = [...counts.entries()].map(([label, n]) => ({ label, n })).sort((a, b) => b.n - a.n || a.label.localeCompare(b.label));
137
+ if (sorted.length <= max) return sorted;
138
+ const head = sorted.slice(0, max - 1);
139
+ const other = sorted.slice(max - 1).reduce((s, k) => s + k.n, 0);
140
+ return head.concat({ label: 'other', n: other });
141
+ }
142
+
143
+ // analytics ship_rate [{ day: 'YYYY-MM-DD', n }] → weekly buckets over the
144
+ // last `days` days ending today: [{ start, n }], every week present even when
145
+ // empty, so the axis is time and not "days something happened".
146
+ function weeklyBuckets(shipRate, days, now) {
147
+ const end = midnight(now) + DAY;
148
+ const weeks = Math.max(1, Math.ceil(days / 7));
149
+ const start = end - weeks * WEEK;
150
+ const out = Array.from({ length: weeks }, (_, i) => ({ start: start + i * WEEK, n: 0 }));
151
+ for (const r of shipRate || []) {
152
+ const t = Date.parse(r && r.day);
153
+ if (!Number.isFinite(t) || t < start || t >= end) continue;
154
+ out[Math.floor((t - start) / WEEK)].n += Number(r.n) || 0;
155
+ }
156
+ return out;
157
+ }
158
+
159
+ // ---- the figures --------------------------------------------------------------
160
+
161
+ // A hover/keyboard readout shared by the time charts and the columns: the
162
+ // crosshair snaps to the nearest x, the line under the title says what is
163
+ // there, and the same walk is on the arrow keys. Labels are set through
164
+ // textContent — the readout is data, never markup.
165
+ function wireReadout(fig, xs, textAt) {
166
+ const svg = fig.querySelector('svg');
167
+ const read = fig.querySelector('.pchart__read');
168
+ const hair = fig.querySelector('.pchart__hair');
169
+ if (!svg || !read || !xs.length) return;
170
+ let idx = xs.length - 1;
171
+ const show = (i) => {
172
+ idx = Math.max(0, Math.min(xs.length - 1, i));
173
+ read.textContent = textAt(idx);
174
+ if (hair) {
175
+ hair.setAttribute('x1', xs[idx].toFixed(1));
176
+ hair.setAttribute('x2', xs[idx].toFixed(1));
177
+ hair.style.visibility = 'visible';
178
+ }
179
+ };
180
+ const rest = () => {
181
+ read.textContent = textAt(xs.length - 1);
182
+ if (hair) hair.style.visibility = 'hidden';
183
+ };
184
+ svg.addEventListener('pointermove', (e) => {
185
+ const r = svg.getBoundingClientRect();
186
+ if (!r.width) return;
187
+ const fx = ((e.clientX - r.left) / r.width) * W;
188
+ let best = 0;
189
+ xs.forEach((x, i) => { if (Math.abs(x - fx) < Math.abs(xs[best] - fx)) best = i; });
190
+ show(best);
191
+ });
192
+ svg.addEventListener('pointerleave', rest);
193
+ svg.addEventListener('keydown', (e) => {
194
+ if (e.key === 'ArrowLeft') { e.preventDefault(); show(idx - 1); }
195
+ else if (e.key === 'ArrowRight') { e.preventDefault(); show(idx + 1); }
196
+ else if (e.key === 'Home') { e.preventDefault(); show(0); }
197
+ else if (e.key === 'End') { e.preventDefault(); show(xs.length - 1); }
198
+ });
199
+ svg.addEventListener('blur', rest);
200
+ rest();
201
+ }
202
+
203
+ function twinHtml(head, rows) {
204
+ return `<details class="pchart__twin">
205
+ <summary>as a table</summary>
206
+ <table class="ledger pchart__table">
207
+ <thead><tr><th>${esc(head[0])}</th><th class="ledger__num">${esc(head[1])}</th></tr></thead>
208
+ <tbody>${rows.map(([a, b]) => `<tr><td>${esc(a)}</td><td class="ledger__num">${esc(b)}</td></tr>`).join('')}</tbody>
209
+ </table>
210
+ </details>`;
211
+ }
212
+
213
+ // A cumulative step-line: the value holds until the next day with a ship,
214
+ // then runs flat out to the window's end. The first drawn point enters at its
215
+ // running height (clipToWindow), the last value carries the only direct label.
216
+ function timeChart({ id, title, points, valueText, footer, now, windowStart }) {
217
+ // A series with nothing in it (a ledger no drachma has reached yet) is one
218
+ // honest line under the title, not a crash on points[0].
219
+ if (!points.length) {
220
+ return {
221
+ id,
222
+ html: `<figure class="pchart" id="${esc(id)}">
223
+ <figcaption class="pchart__title">${esc(title)}</figcaption>
224
+ <p class="pchart__foot">nothing recorded yet · ${esc(footer)}</p>
225
+ </figure>`,
226
+ wire() {},
227
+ };
228
+ }
229
+ const today = midnight(now);
230
+ const x0 = windowStart != null ? windowStart : points[0].day;
231
+ const x1 = Math.max(today, x0 + WEEK);
232
+ const last = points[points.length - 1];
233
+ const vMax = last.value || 1;
234
+ const px = (t) => PAD.l + ((t - x0) / (x1 - x0)) * (W - PAD.l - PAD.r);
235
+ const py = (v) => H - PAD.b - (v / vMax) * (H - PAD.t - PAD.b);
236
+ const base = H - PAD.b;
237
+ let d = `M${px(points[0].day).toFixed(1)},${py(points[0].value).toFixed(1)}`;
238
+ points.forEach((p, i) => {
239
+ if (i === 0) return;
240
+ const prev = points[i - 1].value;
241
+ d += ` L${px(p.day).toFixed(1)},${py(prev).toFixed(1)} L${px(p.day).toFixed(1)},${py(p.value).toFixed(1)}`;
242
+ });
243
+ d += ` L${px(x1).toFixed(1)},${py(last.value).toFixed(1)}`;
244
+ const area = `${d} L${px(x1).toFixed(1)},${base} L${px(points[0].day).toFixed(1)},${base} Z`;
245
+ const endX = px(x1);
246
+ const endY = py(last.value);
247
+ const xs = points.map((p) => px(p.day));
248
+ const html = `<figure class="pchart" id="${esc(id)}">
249
+ <figcaption class="pchart__title">${esc(title)}</figcaption>
250
+ <p class="pchart__read" aria-live="polite"></p>
251
+ <svg class="pchart__svg" viewBox="0 0 ${W} ${H}" role="img" tabindex="0"
252
+ aria-label="${esc(title)}: ${esc(valueText(last.value))} as of today; arrow keys walk the days">
253
+ <path class="pchart__area" d="${area}"></path>
254
+ <path class="pchart__line" d="${d}"></path>
255
+ <line class="pchart__axis" x1="${PAD.l}" y1="${base}" x2="${W - PAD.r}" y2="${base}"></line>
256
+ <line class="pchart__hair" x1="0" y1="${PAD.t}" x2="0" y2="${base}" style="visibility:hidden"></line>
257
+ <circle class="pchart__dot" cx="${endX.toFixed(1)}" cy="${endY.toFixed(1)}" r="4"></circle>
258
+ <text class="pchart__end" x="${endX.toFixed(1)}" y="${(endY - 8).toFixed(1)}" text-anchor="end">${esc(valueText(last.value))}</text>
259
+ <text class="pchart__tick" x="${PAD.l}" y="${H - 6}">${esc(fmtDay(x0))}</text>
260
+ <text class="pchart__tick" x="${W - PAD.r}" y="${H - 6}" text-anchor="end">today</text>
261
+ </svg>
262
+ ${twinHtml(['Day', title], points.map((p) => [fmtDay(p.day), valueText(p.value)]))}
263
+ <p class="pchart__foot">${esc(footer)}</p>
264
+ </figure>`;
265
+ return {
266
+ id,
267
+ html,
268
+ wire(fig) {
269
+ wireReadout(fig, xs, (i) => `${valueText(points[i].value)} · ${i === points.length - 1 ? 'as of today' : fmtDay(points[i].day)}`);
270
+ },
271
+ };
272
+ }
273
+
274
+ // Weekly columns: at most 24px thick, a 4px rounded cap on a square base, a
275
+ // 2px surface gap between neighbours, the tallest column labelled.
276
+ function columnChart({ id, title, buckets, footer }) {
277
+ const n = buckets.length;
278
+ const max = Math.max(1, ...buckets.map((b) => b.n));
279
+ const slot = (W - PAD.l - PAD.r) / n;
280
+ const bw = Math.min(24, Math.max(2, slot - 2));
281
+ const base = H - PAD.b;
282
+ const py = (v) => base - (v / max) * (H - PAD.t - PAD.b);
283
+ const xs = buckets.map((_, i) => PAD.l + i * slot + slot / 2);
284
+ const peak = buckets.reduce((best, b, i) => (b.n > buckets[best].n ? i : best), 0);
285
+ const cols = buckets.map((b, i) => {
286
+ const x = xs[i] - bw / 2;
287
+ const y = py(b.n);
288
+ const h = base - y;
289
+ if (!b.n) return `<rect class="pchart__col pchart__col--empty" x="${x.toFixed(1)}" y="${base - 1}" width="${bw.toFixed(1)}" height="1"></rect>`;
290
+ const r = Math.min(4, h / 2, bw / 2);
291
+ const d = `M${x.toFixed(1)},${base} V${(y + r).toFixed(1)} Q${x.toFixed(1)},${y.toFixed(1)} ${(x + r).toFixed(1)},${y.toFixed(1)} H${(x + bw - r).toFixed(1)} Q${(x + bw).toFixed(1)},${y.toFixed(1)} ${(x + bw).toFixed(1)},${(y + r).toFixed(1)} V${base} Z`;
292
+ return `<path class="pchart__col" d="${d}"></path>`;
293
+ }).join('');
294
+ const label = buckets[peak].n
295
+ ? `<text class="pchart__end" x="${xs[peak].toFixed(1)}" y="${(py(buckets[peak].n) - 6).toFixed(1)}" text-anchor="middle">${buckets[peak].n}</text>`
296
+ : '';
297
+ const total = buckets.reduce((s, b) => s + b.n, 0);
298
+ const html = `<figure class="pchart" id="${esc(id)}">
299
+ <figcaption class="pchart__title">${esc(title)}</figcaption>
300
+ <p class="pchart__read" aria-live="polite"></p>
301
+ <svg class="pchart__svg" viewBox="0 0 ${W} ${H}" role="img" tabindex="0"
302
+ aria-label="${esc(title)}: ${total} in ${n} weeks; arrow keys walk the weeks">
303
+ ${cols}
304
+ <line class="pchart__axis" x1="${PAD.l}" y1="${base}" x2="${W - PAD.r}" y2="${base}"></line>
305
+ <line class="pchart__hair" x1="0" y1="${PAD.t}" x2="0" y2="${base}" style="visibility:hidden"></line>
306
+ ${label}
307
+ <text class="pchart__tick" x="${PAD.l}" y="${H - 6}">${esc(fmtDay(buckets[0].start))}</text>
308
+ <text class="pchart__tick" x="${W - PAD.r}" y="${H - 6}" text-anchor="end">this week</text>
309
+ </svg>
310
+ ${twinHtml(['Week of', 'Works'], buckets.map((b) => [fmtDay(b.start), String(b.n)]))}
311
+ <p class="pchart__foot">${esc(footer)}</p>
312
+ </figure>`;
313
+ return {
314
+ id,
315
+ html,
316
+ wire(fig) {
317
+ wireReadout(fig, xs, (i) => `${buckets[i].n} work${buckets[i].n === 1 ? '' : 's'} · week of ${fmtDay(buckets[i].start)}`);
318
+ },
319
+ };
320
+ }
321
+
322
+ // Horizontal bars for the craft mix. Each row is its own table row already —
323
+ // label, bar, count — so it needs no twin; the count at the tip is the value.
324
+ function mixChart({ id, title, mix, footer }) {
325
+ const max = Math.max(1, ...mix.map((k) => k.n));
326
+ const rows = mix.map((k) => `<div class="pmix__row" role="listitem">
327
+ <span class="pmix__label">${esc(k.label)}</span>
328
+ <span class="pmix__track" aria-hidden="true"><span class="pmix__fill" style="width:${Math.round((k.n / max) * 100)}%"></span></span>
329
+ <span class="pmix__n">${fmtNum(k.n)}</span>
330
+ </div>`).join('');
331
+ return {
332
+ id,
333
+ html: `<figure class="pchart" id="${esc(id)}">
334
+ <figcaption class="pchart__title">${esc(title)}</figcaption>
335
+ <div class="pmix" role="list" aria-label="${esc(title)}">${rows || `<p class="pchart__foot">nothing in this window</p>`}</div>
336
+ <p class="pchart__foot">${esc(footer)}</p>
337
+ </figure>`,
338
+ wire() {},
339
+ };
340
+ }
341
+
342
+ // ---- data ------------------------------------------------------------------
343
+
344
+ // Every shipped row, first page to `total`, bounded. A 401 is the signed-out
345
+ // answer and comes back as such — the section says why, the way the activity
346
+ // ledgers do, rather than drawing three empty cards.
347
+ async function fetchAllShipped(api, API, builderId) {
348
+ const rows = [];
349
+ let total = Infinity;
350
+ while (rows.length < Math.min(total, MAX_ROWS)) {
351
+ const res = await api.request('GET', `${API}/builders/${encodeURIComponent(builderId)}/activity`
352
+ + `?section=tasks&limit=${PAGE}&offset=${rows.length}`);
353
+ if (res.status === 401) return { signedOut: true, rows: [] };
354
+ if (!res.ok) throw new Error(`activity → ${res.status}`);
355
+ const page = (res.data && res.data.rows) || [];
356
+ total = Number(res.data && res.data.total) || 0;
357
+ rows.push(...page);
358
+ if (!page.length) break;
359
+ }
360
+ return { signedOut: false, rows };
361
+ }
362
+
363
+ // The credits ledger, first page to `total`, bounded: /me/credits on your own
364
+ // profile, /builders/:id/credits on another's. The SERVER decides who may
365
+ // read another builder's ledger (builder.roster.read is a delegable atom, so
366
+ // no rank is hard-coded here): a refusal comes back as `denied` and the
367
+ // caller falls back to the estimate rather than the section failing. Any
368
+ // other failure reads the same way — a ledger the page could not read is
369
+ // still a ledger it must not pretend to have.
370
+ async function fetchLedger(api, API, builderId, isOwn) {
371
+ const base = isOwn
372
+ ? `${API}/me/credits`
373
+ : `${API}/builders/${encodeURIComponent(builderId)}/credits`;
374
+ const rows = [];
375
+ let total = Infinity;
376
+ try {
377
+ while (rows.length < Math.min(total, LEDGER_MAX_ROWS)) {
378
+ const res = await api.request('GET', `${base}?limit=${LEDGER_PAGE}&offset=${rows.length}`);
379
+ if (!res.ok) return { denied: true, rows: [] };
380
+ const page = (res.data && res.data.rows) || [];
381
+ total = Number(res.data && res.data.page && res.data.page.total) || 0;
382
+ rows.push(...page);
383
+ if (!page.length) break;
384
+ }
385
+ } catch (err) {
386
+ console.error('[profile] credits ledger failed', err);
387
+ return { denied: true, rows: [] };
388
+ }
389
+ return { denied: false, rows };
390
+ }
391
+
392
+ async function fetchOwnRate(api, API, builderId, days) {
393
+ try {
394
+ const q = days ? `?days=${encodeURIComponent(days)}` : '';
395
+ const res = await api.request('GET', `${API}/analytics/builder/${encodeURIComponent(builderId)}${q}`);
396
+ return res.ok ? res.data : null;
397
+ } catch (err) {
398
+ console.error('[profile] own ship rate failed', err);
399
+ return null;
400
+ }
401
+ }
402
+
403
+ // ---- render --------------------------------------------------------------------
404
+
405
+ function say(mount, html) {
406
+ mount.innerHTML = `<p class="pchart__note">${html}</p>`;
407
+ }
408
+
409
+ function rangeControlHtml(current) {
410
+ return `<div class="pchart-range" role="group" aria-label="Lookback window">
411
+ ${RANGES.map((r) => `<button type="button" class="pchart-range__btn${r.id === current ? ' is-active' : ''}" data-range="${esc(r.id)}" aria-pressed="${r.id === current}">${esc(r.label)}</button>`).join('')}
412
+ </div>`;
413
+ }
414
+
415
+ // Draw every figure for one lookback window into #charts-grid. Split from
416
+ // render() so the range buttons can re-draw without re-fetching the record.
417
+ async function draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId }) {
418
+ const grid = $('#charts-grid');
419
+ if (!grid) return;
420
+ const range = RANGES.find((r) => r.id === rangeId) || RANGES[RANGES.length - 1];
421
+ const windowStart = range.days == null ? null : midnight(now) - range.days * DAY + DAY;
422
+ const inWindow = windowStart == null ? rows : rows.filter((r) => { const t = Date.parse(r.shipped_at); return Number.isFinite(t) && t >= windowStart; });
423
+ const span = range.days == null ? 'all time' : `the last ${range.label}`;
424
+
425
+ const worksFull = cumulativeByDay(rows, () => 1);
426
+ const works = clipToWindow(worksFull, windowStart);
427
+ const paid = clipToWindow(paidFull, windowStart);
428
+ const mix = craftMix(inWindow);
429
+
430
+ const figs = [
431
+ timeChart({
432
+ id: 'chart-works', title: 'Works shipped', points: works, now, windowStart,
433
+ valueText: (v) => `${fmtNum(v)} work${v === 1 ? '' : 's'}`,
434
+ footer: `${fmtNum(worksFull.length ? worksFull[worksFull.length - 1].value : 0)} total · ${span}`,
435
+ }),
436
+ timeChart({
437
+ id: 'chart-drachmae', title: `${curLabel(true)} earned`, points: paid, now, windowStart,
438
+ valueText: (v) => `${fmtNum(v)} ${curLabel()}`,
439
+ footer: estimated
440
+ ? `estimated from shipped work, not the ledger · ${span}`
441
+ : `${fmtNum(paidFull.length ? paidFull[paidFull.length - 1].value : 0)} paid, from the credits ledger · ${span}`,
442
+ }),
443
+ mixChart({
444
+ id: 'chart-mix', title: 'Works by craft', mix,
445
+ footer: `${fmtNum(inWindow.length)} work${inWindow.length === 1 ? '' : 's'} · ${span}`,
446
+ }),
447
+ ];
448
+ if (isOwn) {
449
+ const paceDays = Math.min(range.days || PACE_MAX_DAYS, PACE_MAX_DAYS);
450
+ const own = await fetchOwnRate(api, API, builderId, paceDays);
451
+ if (own && Array.isArray(own.ship_rate)) {
452
+ const days = Number(own.window_days) || paceDays;
453
+ const buckets = weeklyBuckets(own.ship_rate, days, now);
454
+ const total = buckets.reduce((s, b) => s + b.n, 0);
455
+ figs.push(columnChart({
456
+ id: 'chart-rate', title: 'Your pace, by week', buckets,
457
+ footer: `${fmtNum(total)} in the last ${days} days · only you see this one`,
458
+ }));
459
+ }
460
+ }
461
+ grid.innerHTML = figs.map((f) => f.html).join('');
462
+ figs.forEach((f) => { const el = grid.querySelector(`#${f.id}`); if (el) f.wire(el); });
463
+ }
464
+
465
+ async function render({ api, API, builderId, selfId, signedIn, now = Date.now() }) {
466
+ const mount = $('#charts-grid');
467
+ const controls = $('#charts-controls');
468
+ if (!mount) return;
469
+ if (controls) controls.innerHTML = '';
470
+ const isOwn = selfId != null && String(selfId) === String(builderId);
471
+ if (!signedIn) {
472
+ say(mount, `Charts of shipped work are visible to signed-in builders — <a href="${esc(API)}/auth/web/start">sign in</a> to see them.`);
473
+ return;
474
+ }
475
+ let got;
476
+ try {
477
+ got = await fetchAllShipped(api, API, builderId);
478
+ } catch (err) {
479
+ console.error('[profile] charts failed', err);
480
+ say(mount, 'Could not load the charts.');
481
+ return;
482
+ }
483
+ if (got.signedOut) {
484
+ say(mount, `Charts of shipped work are visible to signed-in builders — <a href="${esc(API)}/auth/web/start">sign in</a> to see them.`);
485
+ return;
486
+ }
487
+ const rows = got.rows;
488
+ if (!rows.length) {
489
+ say(mount, 'No works shipped yet — the charts draw once the first one lands.');
490
+ return;
491
+ }
492
+
493
+ // The drachmae series: the ledger wherever the viewer may read it, the
494
+ // estimate (labelled) where the server refuses. Fetched once, like the
495
+ // record; the lookback only clips it.
496
+ const ledger = await fetchLedger(api, API, builderId, isOwn);
497
+ const estimated = ledger.denied;
498
+ const paidFull = estimated
499
+ ? cumulativeByDay(rows, estimateOf)
500
+ : cumulativeByDay(ledger.rows, (r) => r && r.delta, (r) => r && r.recorded_at);
501
+
502
+ // The lookback control. Its choice is remembered per viewer; changing it
503
+ // re-draws from the rows already in hand — the record is not re-fetched.
504
+ let rangeId = readRange();
505
+ if (controls) {
506
+ controls.innerHTML = rangeControlHtml(rangeId);
507
+ controls.addEventListener('click', (e) => {
508
+ const btn = e.target.closest('[data-range]');
509
+ if (!btn || btn.dataset.range === rangeId) return;
510
+ rangeId = btn.dataset.range;
511
+ writeRange(rangeId);
512
+ controls.querySelectorAll('[data-range]').forEach((b) => {
513
+ const on = b.dataset.range === rangeId;
514
+ b.classList.toggle('is-active', on);
515
+ b.setAttribute('aria-pressed', String(on));
516
+ });
517
+ draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId });
518
+ });
519
+ }
520
+ await draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId });
521
+ }
522
+
523
+ window.OTBProfileCharts = { render, cumulativeByDay, craftMix, weeklyBuckets, estimateOf, clipToWindow };
524
+ })();
@@ -1,7 +1,8 @@
1
1
  /* profile.css — what the builder's profile page has and the rest of the panel
2
2
  family does not (task 1003308, goal 1000081; the archetype is panel.css). The
3
3
  page everyone else reads: the identity band, the figures, the shelf, the
4
- ledgers. The figures are the shell's glass band (.stat-row) and the ledgers
4
+ ledgers — and since task 1003489 the section rail that makes it three
5
+ screens, and the charts on the first of them. The figures are the shell's glass band (.stat-row) and the ledgers
5
6
  the shell's .ledger; this sheet owns the identity band and the shelf, both
6
7
  drawn from docs/design/mocks/hall/settings/v3.html (the profile half).
7
8
 
@@ -67,6 +68,8 @@
67
68
  the negative margin hands both back so the cell does not re-space. */
68
69
  #works-scroll .ledger td a,
69
70
  #activity-scroll .ledger td a,
71
+ #charts-scroll a,
72
+ #works-scroll .scroll__lede a,
70
73
  #xp-scroll a {
71
74
  display: inline-block;
72
75
  min-width: 24px;
@@ -83,3 +86,89 @@
83
86
  .profile--ident .profile__avatar { width: 48px; height: 48px; }
84
87
  .profile--ident .profile__credits { grid-column: 2; align-items: flex-start; text-align: left; }
85
88
  }
89
+
90
+ /* ---- the rail and the groups (task 1003489) ---------------------------------
91
+ The settings idiom, borrowed whole: a left rail in switch mode and ONE group
92
+ of sections showing at a time, so each key is a screen and the window stays
93
+ the only scroller. The rail itself (.section-rail / .rail-link) is the
94
+ shell's; this sheet widens its column to settings' and lists the keys. A
95
+ section's own [hidden] still wins — the cross-project and connections reveal
96
+ logic is untouched. */
97
+ .profile-layout {
98
+ display: grid;
99
+ grid-template-columns: 206px minmax(0, 1fr);
100
+ gap: 30px;
101
+ align-items: start;
102
+ }
103
+ .profile-layout[hidden] { display: none !important; }
104
+ .profile-groups > [data-group] { display: none; }
105
+ .profile-groups[data-active="overview"] > [data-group="overview"],
106
+ .profile-groups[data-active="works"] > [data-group="works"],
107
+ .profile-groups[data-active="activity"] > [data-group="activity"],
108
+ .profile-groups[data-active="connections"] > [data-group="connections"] { display: block; }
109
+ .profile-groups > [data-group][hidden] { display: none !important; }
110
+ .profile-groups .scroll { margin: 0 0 var(--sp-sm); }
111
+ #profile-rail .rail-link { min-height: 38px; padding: 0 14px; font-size: 14.5px; text-transform: none; border-radius: var(--radius-pill); }
112
+ #profile-rail .rail-link--active::before { left: 0; top: 7px; bottom: 7px; }
113
+ @media (max-width: 860px) {
114
+ .profile-layout { grid-template-columns: minmax(0, 1fr); gap: var(--sp-md); }
115
+ }
116
+
117
+ /* ---- the charts ---------------------------------------------------------------
118
+ Four figures on the Overview, each a card one tonal step down from the panel
119
+ (glass on the card, like settings' quote), the series in the accent and every
120
+ word in ink. The SVG scales with its card; the axis band is inside the
121
+ viewBox, so the card grows with its content and never scrolls. */
122
+ .pcharts {
123
+ display: grid;
124
+ grid-template-columns: repeat(2, minmax(0, 1fr));
125
+ gap: var(--sp-sm);
126
+ }
127
+ .pchart {
128
+ margin: 0;
129
+ padding: var(--sp-xs) var(--sp-sm) var(--sp-xs);
130
+ background: var(--glass);
131
+ border: var(--line) solid var(--glass-edge);
132
+ border-radius: var(--radius-sm);
133
+ min-width: 0;
134
+ }
135
+ .pchart__title { margin: 0; font-size: 13.5px; font-weight: 500; color: var(--ink); }
136
+ .pchart__read { margin: 2px 0 var(--sp-hair); font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); min-height: 1.4em; }
137
+ .pchart__svg { display: block; width: 100%; height: auto; overflow: visible; border-radius: var(--radius-sm); }
138
+ .pchart__svg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
139
+ .pchart__line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
140
+ .pchart__area { fill: color-mix(in srgb, var(--accent) 10%, transparent); stroke: none; }
141
+ .pchart__axis, .pchart__hair { stroke: var(--rule-soft); stroke-width: 1; }
142
+ .pchart__hair { stroke: var(--ink-faint); }
143
+ .pchart__dot { fill: var(--accent); stroke: var(--bg-card); stroke-width: 2; }
144
+ .pchart__col { fill: var(--accent); }
145
+ .pchart__col--empty { fill: var(--rule-soft); }
146
+ .pchart__end { font-family: var(--font-body); font-size: 11.5px; font-weight: 500; fill: var(--ink); }
147
+ .pchart__tick { font-family: var(--label-face); font-size: 10px; letter-spacing: var(--label-space); text-transform: var(--label-case); fill: var(--ink-faint); }
148
+ .pchart__twin { margin-top: var(--sp-hair); }
149
+ .pchart__twin > summary { display: inline-flex; align-items: center; min-height: 24px; padding: 0 4px; margin: 0 -4px; font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); cursor: pointer; border-radius: var(--radius-sm); }
150
+ .pchart__twin > summary:hover { color: var(--ink); background: var(--glass-strong); }
151
+ .pchart__table { margin-top: var(--sp-hair); font-size: 12.5px; }
152
+ .pchart__table td, .pchart__table th { padding: 3px 6px; }
153
+ .pchart__foot { margin: var(--sp-hair) 0 0; font-size: 12px; color: var(--ink-faint); }
154
+ .pchart__note { margin: 0; font-size: 14px; color: var(--ink-soft); }
155
+ /* the craft mix: label, bar, count — a row is its own table row */
156
+ .pmix { display: flex; flex-direction: column; gap: 6px; margin: var(--sp-hair) 0; }
157
+ .pmix__row { display: grid; grid-template-columns: minmax(72px, 110px) minmax(0, 1fr) auto; align-items: center; gap: 10px; font-size: 12.5px; min-height: 24px; }
158
+ .pmix__label { color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
159
+ .pmix__track { display: block; height: 8px; background: var(--rule-soft); border-radius: 0 4px 4px 0; overflow: hidden; }
160
+ .pmix__fill { display: block; height: 100%; background: var(--accent); border-radius: 0 4px 4px 0; }
161
+ .pmix__row:hover .pmix__fill { opacity: .8; }
162
+ .pmix__n { color: var(--ink-faint); font-variant-numeric: tabular-nums; }
163
+ @media (max-width: 720px) {
164
+ .pcharts { grid-template-columns: minmax(0, 1fr); }
165
+ }
166
+
167
+ /* the lookback control (task 1003489, owner request) — one segmented row above
168
+ the grid, scoping every chart. The pressed side takes the accent TINT the
169
+ world gives a current thing, never the accent fill. */
170
+ .pchart-range { display: inline-flex; gap: 0; border: var(--line) solid var(--glass-edge); border-radius: var(--radius-pill); overflow: hidden; margin: 0 0 var(--sp-sm); }
171
+ .pchart-range__btn { border: none; background: transparent; color: var(--ink-soft); font-family: var(--font-body); font-size: 12.5px; font-weight: 500; padding: 5px 14px; min-height: 24px; cursor: pointer; }
172
+ .pchart-range__btn + .pchart-range__btn { border-left: var(--line) solid var(--glass-edge); }
173
+ .pchart-range__btn:hover { color: var(--ink); background: var(--glass); }
174
+ .pchart-range__btn.is-active { background: var(--accent-tint); color: var(--accent-ink); }