@bongos/core 1.20.80 → 1.20.82

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.
Files changed (56) hide show
  1. package/.bongos-core.json +98 -53
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +12 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +245 -4
  8. package/docs/api-reference.md +7 -3
  9. package/docs/copy-inventory.md +84 -71
  10. package/docs/copy-registry.json +271 -145
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +2 -1
  13. package/docs/page-readings.json +139 -119
  14. package/modules/autonomy/cadence.js +3 -0
  15. package/modules/autonomy/db.js +136 -0
  16. package/modules/autonomy/fence.js +88 -3
  17. package/modules/autonomy/migrations/autonomy_005_builder_scope.sql +55 -0
  18. package/modules/autonomy/routes/autonomy.js +123 -3
  19. package/modules/economy/earnings.js +117 -0
  20. package/modules/economy/module.json +1 -1
  21. package/modules/economy/routes/earnings.js +47 -0
  22. package/modules/government/catalog.js +9 -1
  23. package/modules/government/migrations/government_022_autonomy_run.sql +38 -0
  24. package/modules/hall-ui/public/gate.css +49 -0
  25. package/modules/hall-ui/public/gate.html +8 -7
  26. package/modules/hall-ui/public/gate.js +272 -115
  27. package/modules/hall-ui/public/profile-charts.js +206 -104
  28. package/modules/hall-ui/public/profile.css +23 -0
  29. package/modules/hall-ui/public/profile.js +3 -3
  30. package/modules/hall-ui/public/profile.states.json +8 -4
  31. package/modules/hall-ui/public/settings-autobongos.js +156 -0
  32. package/modules/hall-ui/public/settings.html +19 -0
  33. package/modules/hall-ui/public/settings.js +1 -0
  34. package/modules/hall-ui/records/profile-roles.md +21 -0
  35. package/modules/lifecycle/db-goals.js +6 -0
  36. package/modules/lifecycle/db-versions.js +3 -0
  37. package/modules/lifecycle/done-when.js +4 -0
  38. package/modules/lifecycle/goal-close-sweep.js +34 -0
  39. package/modules/provisioning/app-status.js +17 -4
  40. package/modules/public-landing/public/projects.html +7 -0
  41. package/package-lock.json +2 -2
  42. package/package.json +1 -1
  43. package/release-notes.json +28 -0
  44. package/scripts/hall-preview/server.js +4 -0
  45. package/src/module-api.js +1 -1
  46. package/tests/auto_satisfy_criteria.mjs +8 -6
  47. package/tests/autonomy_builder_scope.mjs +474 -0
  48. package/tests/autonomy_fence_priority.mjs +7 -3
  49. package/tests/autonomy_runner_identity.mjs +1 -1
  50. package/tests/criterion_goal_attach.mjs +5 -1
  51. package/tests/goal_close_cancels_requests.mjs +82 -0
  52. package/tests/goal_closure_invariants.mjs +5 -2
  53. package/tests/hall_profile_world.mjs +57 -58
  54. package/tests/profile_earnings.mjs +237 -0
  55. package/tests/projects_hub_app_status.mjs +17 -0
  56. package/tests/provisioning_app_status.mjs +25 -1
@@ -16,29 +16,30 @@
16
16
  // Data: GET /builders/:id/activity?section=tasks — any signed-in viewer, any
17
17
  // builder; paged, followed to `total` up to MAX_ROWS. Its rows carry
18
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.
19
+ // The EARNINGS figure (task 1004436, WA6.RP06) is GET /builders/:id/earnings:
20
+ // what the ledger paid this builder, summed per day and per KIND of credit
21
+ // (modules/economy/earnings.js owns the kinds). Any signed-in viewer, any
22
+ // builder, own profile included — the owner's decision (a). It draws for
23
+ // EVERYONE with credits, a shipped task or not: an artist is paid per approved
24
+ // page and an ideator per idea stream, and this chart used to stop at "No works
25
+ // shipped yet" before it read a credit (it once read the itemised ledger, which
26
+ // another builder's viewer may read only with builder.roster.read, and fell back
27
+ // to an estimate summed from shipped tasks — nothing, for those two crafts). The
28
+ // line is the days cumulated, ending at the balance; "where it came from" is the
29
+ // kinds in the lookback window. The works charts still need a shipped task and
30
+ // say so on their own. The ship rate is GET /analytics/builder/:id,
31
+ // own-or-archon; it is asked for only when the viewer IS the builder and renders
32
+ // nothing elsewhere — never a 403 toast.
30
33
  //
31
34
  // 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
+ // signedIn, name?, now? }) plus the pure series builders (cumulativeByDay,
36
+ // craftMix, weeklyBuckets, clipToWindow, earningsKinds, earningsLede) so tests
37
+ // can pin the arithmetic without a DOM.
35
38
  (() => {
36
39
  'use strict';
37
40
 
38
41
  const MAX_ROWS = 500; // five pages: a prolific builder's whole record, bounded
39
42
  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
43
  const DAY = 86400000;
43
44
  const WEEK = 7 * DAY;
44
45
  const MIX_MAX = 7; // bars past this fold into "other" — never an eighth hue
@@ -60,6 +61,18 @@
60
61
  // a second copy of this number silently mis-aims the crosshair the day the
61
62
  // kit's plot changes size. Its height and padding are the kit's alone now.
62
63
  const W = window.OTBKit.plotBox().width;
64
+ // The earnings plot sits in 1.5fr of a full-width figure, about half again a
65
+ // grid card's width, so its viewBox is that much wider (and a touch taller)
66
+ // to keep its type the size of the cards' type rather than scaling up. On a
67
+ // phone the figure stacks (profile.css, 720px) and the plot is a card's width
68
+ // again, so it takes the kit's own box — read at draw time, as the grid is.
69
+ const EARN_BOX_WIDE = { width: Math.round(W * 1.5), height: Math.round(window.OTBKit.plotBox().height * 1.2) };
70
+ function earnBox() {
71
+ try {
72
+ if (window.matchMedia && window.matchMedia('(max-width: 720px)').matches) return {};
73
+ } catch (_) { /* no media queries: the wide box */ }
74
+ return EARN_BOX_WIDE;
75
+ }
63
76
 
64
77
  const esc = (s) => window.OTB.escapeHtml(s);
65
78
  const $ = (sel) => window.OTB.$(sel);
@@ -84,15 +97,64 @@
84
97
 
85
98
  // ---- series ---------------------------------------------------------------
86
99
 
87
- // The ESTIMATE of what a shipped row pays — credits_reward, the figure the
88
- // task carried before it shipped. The FALLBACK only: the drachmae series is
89
- // the ledger wherever the viewer may read it, and this stands in, labelled
90
- // "estimated" (ADR 0146), when the ledger answers 403. Never the per-task
91
- // award column — on a cost-plus instance it is 0 for every task, and summing
92
- // it is the flatline this file was fixed for.
93
- function estimateOf(row) {
94
- const e = Number(row && row.credits_reward);
95
- return Number.isFinite(e) ? e : 0;
100
+ // The words for each kind of credit, the approved mock's (RP01). The KEYS are
101
+ // economy's (modules/economy/earnings.js KINDS); a key this table does not
102
+ // know reads as "Other credits" rather than vanishing from the sum.
103
+ const KIND_WORDS = {
104
+ task: 'Tasks shipped',
105
+ session: 'Session rewards',
106
+ page: 'Pages approved',
107
+ 'idea-ship': 'Ideas that became shipped work',
108
+ 'idea-descent': 'Work split from their ideas',
109
+ 'idea-full': 'Full Ideas passed',
110
+ 'idea-board': 'Ratified by the board',
111
+ 'net-negative': 'Simplification bonuses',
112
+ other: 'Other credits',
113
+ };
114
+ // The same kinds in a word or two, for the sentence above the figure. The four
115
+ // idea streams are one source there: "ideas".
116
+ const KIND_SOURCE = {
117
+ task: 'shipped tasks',
118
+ session: 'sessions',
119
+ page: 'approved pages',
120
+ 'idea-ship': 'ideas',
121
+ 'idea-descent': 'ideas',
122
+ 'idea-full': 'ideas',
123
+ 'idea-board': 'ideas',
124
+ 'net-negative': 'simplification bonuses',
125
+ other: 'other credits',
126
+ };
127
+
128
+ // earnings days → [{ kind, label, credits }] for "where it came from": summed
129
+ // per kind, largest first, only the kinds that earned something. Ties keep
130
+ // the KIND_WORDS order, so the list does not reshuffle between draws.
131
+ function earningsKinds(days) {
132
+ const sums = new Map();
133
+ for (const d of days || []) {
134
+ const k = Object.prototype.hasOwnProperty.call(KIND_WORDS, d && d.kind) ? d.kind : 'other';
135
+ sums.set(k, (sums.get(k) || 0) + (Number(d && d.credits) || 0));
136
+ }
137
+ const order = Object.keys(KIND_WORDS);
138
+ return [...sums.entries()]
139
+ .filter(([, v]) => v !== 0)
140
+ .sort((a, b) => b[1] - a[1] || order.indexOf(a[0]) - order.indexOf(b[0]))
141
+ .map(([kind, credits]) => ({ kind, label: KIND_WORDS[kind], credits }));
142
+ }
143
+
144
+ // The one sentence under the figure's title. Someone who never shipped a task
145
+ // is told so first, because that is exactly who this chart used to leave out,
146
+ // and then where their credits did come from, largest source first.
147
+ function earningsLede({ name, isOwn, shipped, kinds }) {
148
+ const who = isOwn ? 'you' : (name || 'this builder');
149
+ const Who = who.charAt(0).toUpperCase() + who.slice(1);
150
+ if (!shipped) {
151
+ const never = `${Who} ${isOwn ? 'have' : 'has'} never shipped a task.`;
152
+ const sources = [...new Set(kinds.filter((k) => k.credits > 0).map((k) => KIND_SOURCE[k.kind] || KIND_SOURCE.other))];
153
+ if (!sources.length) return never;
154
+ const list = sources.length === 1 ? sources[0] : `${sources.slice(0, -1).join(', ')} and ${sources[sources.length - 1]}`;
155
+ return `${never} Every credit here came from ${list}.`;
156
+ }
157
+ return `Every credit ${who} ${isOwn ? 'have' : 'has'} earned, by the kind of work that earned it.`;
96
158
  }
97
159
 
98
160
  // rows → [{ day, value }] ascending by day, value cumulative over the WHOLE
@@ -163,7 +225,7 @@
163
225
  // crosshair snaps to the nearest x, the line under the title says what is
164
226
  // there, and the same walk is on the arrow keys. Labels are set through
165
227
  // textContent — the readout is data, never markup.
166
- function wireReadout(fig, xs, textAt) {
228
+ function wireReadout(fig, xs, textAt, width = W) {
167
229
  const svg = fig.querySelector('svg');
168
230
  const read = fig.querySelector('.pchart__read');
169
231
  const hair = fig.querySelector('.kit-plot__hair');
@@ -185,7 +247,7 @@
185
247
  svg.addEventListener('pointermove', (e) => {
186
248
  const r = svg.getBoundingClientRect();
187
249
  if (!r.width) return;
188
- const fx = ((e.clientX - r.left) / r.width) * W;
250
+ const fx = ((e.clientX - r.left) / r.width) * width;
189
251
  let best = 0;
190
252
  xs.forEach((x, i) => { if (Math.abs(x - fx) < Math.abs(xs[best] - fx)) best = i; });
191
253
  show(best);
@@ -214,15 +276,21 @@
214
276
  // A cumulative step-line: the value holds until the next day with a ship,
215
277
  // then runs flat out to the window's end. The first drawn point enters at its
216
278
  // running height (clipToWindow), the last value carries the only direct label.
217
- function timeChart({ id, title, points, valueText, footer, now, windowStart }) {
279
+ // `bare` returns the plot and its readout, twin and foot WITHOUT the figure
280
+ // and caption around them, for a figure that composes more than one plot's
281
+ // worth (the earnings figure sets its kinds beside this). `box` is the kit's
282
+ // plot size ({ width, height }) for a plot drawn wider than a grid card, so
283
+ // its type keeps the cards' size instead of scaling up with the column.
284
+ function timeChart({ id, title, points, valueText, footer, now, windowStart, bare = false, box = {} }) {
218
285
  // A series with nothing in it (a ledger no drachma has reached yet) is one
219
286
  // honest line under the title, not a crash on points[0].
220
287
  if (!points.length) {
288
+ const foot = `<p class="pchart__foot">nothing recorded yet · ${esc(footer)}</p>`;
221
289
  return {
222
290
  id,
223
- html: `<figure class="pchart" id="${esc(id)}">
291
+ html: bare ? foot : `<figure class="pchart" id="${esc(id)}">
224
292
  <figcaption class="pchart__title">${esc(title)}</figcaption>
225
- <p class="pchart__foot">nothing recorded yet · ${esc(footer)}</p>
293
+ ${foot}
226
294
  </figure>`,
227
295
  wire() {},
228
296
  };
@@ -235,14 +303,13 @@
235
303
  const last = points[points.length - 1];
236
304
  const series = window.OTBKit.timeStepSeriesHtml(
237
305
  points.map((p) => ({ x: p.day, value: p.value })),
238
- { x0, x1, yMax: last.value },
306
+ { x0, x1, yMax: last.value, ...box },
239
307
  );
240
308
  const { endX, endY } = series.geometry;
241
309
  const xs = series.xs;
242
- const html = `<figure class="pchart" id="${esc(id)}">
243
- <figcaption class="pchart__title">${esc(title)}</figcaption>
244
- <p class="pchart__read" aria-live="polite"></p>
310
+ const inner = `<p class="pchart__read" aria-live="polite"></p>
245
311
  ${window.OTBKit.plotSvgHtml({
312
+ ...box,
246
313
  series: series.body,
247
314
  ariaLabel: `${title}: ${valueText(last.value)} as of today; arrow keys walk the days`,
248
315
  label: window.OTBKit.plotValueLabelHtml(endX, endY - 8, valueText(last.value), 'end'),
@@ -250,13 +317,16 @@
250
317
  rightTick: 'today',
251
318
  })}
252
319
  ${twinHtml(['Day', title], points.map((p) => [fmtDay(p.day), valueText(p.value)]))}
253
- <p class="pchart__foot">${esc(footer)}</p>
320
+ <p class="pchart__foot">${esc(footer)}</p>`;
321
+ const html = bare ? inner : `<figure class="pchart" id="${esc(id)}">
322
+ <figcaption class="pchart__title">${esc(title)}</figcaption>
323
+ ${inner}
254
324
  </figure>`;
255
325
  return {
256
326
  id,
257
327
  html,
258
328
  wire(fig) {
259
- wireReadout(fig, xs, (i) => `${valueText(points[i].value)} · ${i === points.length - 1 ? 'as of today' : fmtDay(points[i].day)}`);
329
+ wireReadout(fig, xs, (i) => `${valueText(points[i].value)} · ${i === points.length - 1 ? 'as of today' : fmtDay(points[i].day)}`, box.width || W);
260
330
  },
261
331
  };
262
332
  }
@@ -363,6 +433,40 @@
363
433
  };
364
434
  }
365
435
 
436
+ // The earnings figure (task 1004436): the approved mock's "Earnings over
437
+ // time" — a cumulative step line beside "where it came from", the kinds of
438
+ // credit as bars, one sentence above both. It spans the grid, because it is
439
+ // the one figure every builder has. The kinds read the lookback window, as
440
+ // every other figure does; a window that earned nothing says so.
441
+ function earningsChart({ id, points, kinds, lede, footer, now, windowStart }) {
442
+ const title = `${curLabel(true)} earned`;
443
+ const plot = timeChart({
444
+ id, title, points, now, windowStart, bare: true, box: earnBox(),
445
+ valueText: (v) => `${fmtNum(v)} ${curLabel()}`,
446
+ footer,
447
+ });
448
+ const max = kinds.reduce((s, k) => s + Math.max(0, k.credits), 0);
449
+ const bars = window.OTBKit.barSeriesHtml(
450
+ kinds.map((k) => ({ label: k.label, value: Math.max(0, k.credits), valueLabel: fmtNum(k.credits) })),
451
+ { max, pill: true, track: 'soft', barHeight: '4px', list: true, ariaLabel: `Where the ${curLabel()} came from` },
452
+ );
453
+ return {
454
+ id,
455
+ html: `<figure class="pchart pchart--wide pearn" id="${esc(id)}">
456
+ <figcaption class="pchart__title">Earnings over time</figcaption>
457
+ <p class="pearn__lede">${esc(lede)}</p>
458
+ <div class="pearn__grid">
459
+ <div class="pearn__plot">${plot.html}</div>
460
+ <div class="pearn__kinds">
461
+ <h3 class="pearn__h">where it came from</h3>
462
+ ${bars || '<p class="pchart__foot">nothing earned in this window</p>'}
463
+ </div>
464
+ </div>
465
+ </figure>`,
466
+ wire: plot.wire,
467
+ };
468
+ }
469
+
366
470
  // ---- data ------------------------------------------------------------------
367
471
 
368
472
  // Every shipped row, first page to `total`, bounded. A 401 is the signed-out
@@ -384,33 +488,21 @@
384
488
  return { signedOut: false, rows };
385
489
  }
386
490
 
387
- // The credits ledger, first page to `total`, bounded: /me/credits on your own
388
- // profile, /builders/:id/credits on another's. The SERVER decides who may
389
- // read another builder's ledger (builder.roster.read is a delegable atom, so
390
- // no rank is hard-coded here): a refusal comes back as `denied` and the
391
- // caller falls back to the estimate rather than the section failing. Any
392
- // other failure reads the same way — a ledger the page could not read is
393
- // still a ledger it must not pretend to have.
394
- async function fetchLedger(api, API, builderId, isOwn) {
395
- const base = isOwn
396
- ? `${API}/me/credits`
397
- : `${API}/builders/${encodeURIComponent(builderId)}/credits`;
398
- const rows = [];
399
- let total = Infinity;
491
+ // What this builder earned, per day and kind, in ONE read — the same route on
492
+ // your own profile and on anyone else's. A 401 is the signed-out answer; any
493
+ // other failure is `failed`, and the figure says it could not load rather
494
+ // than drawing a zero it never read.
495
+ async function fetchEarnings(api, API, builderId) {
400
496
  try {
401
- while (rows.length < Math.min(total, LEDGER_MAX_ROWS)) {
402
- const res = await api.request('GET', `${base}?limit=${LEDGER_PAGE}&offset=${rows.length}`);
403
- if (!res.ok) return { denied: true, rows: [] };
404
- const page = (res.data && res.data.rows) || [];
405
- total = Number(res.data && res.data.page && res.data.page.total) || 0;
406
- rows.push(...page);
407
- if (!page.length) break;
408
- }
497
+ const res = await api.request('GET', `${API}/builders/${encodeURIComponent(builderId)}/earnings`);
498
+ if (res.status === 401) return { signedOut: true, days: [] };
499
+ if (!res.ok) return { failed: true, days: [] };
500
+ const days = (res.data && Array.isArray(res.data.days)) ? res.data.days : [];
501
+ return { days };
409
502
  } catch (err) {
410
- console.error('[profile] credits ledger failed', err);
411
- return { denied: true, rows: [] };
503
+ console.error('[profile] earnings failed', err);
504
+ return { failed: true, days: [] };
412
505
  }
413
- return { denied: false, rows };
414
506
  }
415
507
 
416
508
  async function fetchOwnRate(api, API, builderId, days) {
@@ -438,7 +530,7 @@
438
530
 
439
531
  // Draw every figure for one lookback window into #charts-grid. Split from
440
532
  // render() so the range buttons can re-draw without re-fetching the record.
441
- async function draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId, selfStreak }) {
533
+ async function draw({ api, API, builderId, isOwn, name, rows, earnings, now, rangeId, selfStreak }) {
442
534
  const grid = $('#charts-grid');
443
535
  if (!grid) return;
444
536
  const range = RANGES.find((r) => r.id === rangeId) || RANGES[RANGES.length - 1];
@@ -446,36 +538,48 @@
446
538
  const inWindow = windowStart == null ? rows : rows.filter((r) => { const t = Date.parse(r.shipped_at); return Number.isFinite(t) && t >= windowStart; });
447
539
  const span = range.days == null ? 'all time' : `the last ${range.label}`;
448
540
 
449
- const worksFull = cumulativeByDay(rows, () => 1);
450
- const works = clipToWindow(worksFull, windowStart);
451
- const paid = clipToWindow(paidFull, windowStart);
452
- const mix = craftMix(inWindow);
453
-
454
- const figs = [
455
- timeChart({
456
- id: 'chart-works', title: 'Works shipped', points: works, now, windowStart,
457
- valueText: (v) => `${fmtNum(v)} work${v === 1 ? '' : 's'}`,
458
- footer: `${fmtNum(worksFull.length ? worksFull[worksFull.length - 1].value : 0)} total · ${span}`,
459
- }),
460
- timeChart({
461
- id: 'chart-drachmae', title: `${curLabel(true)} earned`, points: paid, now, windowStart,
462
- valueText: (v) => `${fmtNum(v)} ${curLabel()}`,
463
- footer: estimated
464
- ? `estimated from shipped work, not the ledger · ${span}`
465
- : `${fmtNum(paidFull.length ? paidFull[paidFull.length - 1].value : 0)} paid, from the credits ledger · ${span}`,
466
- }),
467
- mixChart({
468
- id: 'chart-mix', title: `Works by ${window.OTB.roleAxisLabel().toLowerCase()}`, mix,
469
- footer: `${fmtNum(inWindow.length)} work${inWindow.length === 1 ? '' : 's'} · ${span}`,
470
- }),
471
- ];
541
+ // The earnings figure leads and always draws; the works figures need a
542
+ // shipped task, and without one they are one line saying so, not two empty
543
+ // cards. The earnings ARE the reading for an artist or an ideator.
544
+ const figs = [];
545
+ if (earnings.failed) {
546
+ figs.push({ id: 'chart-drachmae', html: `<figure class="pchart pchart--wide" id="chart-drachmae"><figcaption class="pchart__title">Earnings over time</figcaption><p class="pchart__foot">Could not load the earnings.</p></figure>`, wire() {} });
547
+ } else {
548
+ const paidFull = cumulativeByDay(earnings.days, (d) => d && d.credits, (d) => d && `${d.day}T00:00:00Z`);
549
+ const daysIn = windowStart == null ? earnings.days : earnings.days.filter((d) => Date.parse(`${d.day}T00:00:00Z`) >= windowStart);
550
+ const kinds = earningsKinds(daysIn);
551
+ const balance = paidFull.length ? paidFull[paidFull.length - 1].value : 0;
552
+ figs.push(earningsChart({
553
+ id: 'chart-drachmae', now, windowStart,
554
+ points: clipToWindow(paidFull, windowStart),
555
+ kinds,
556
+ lede: earningsLede({ name, isOwn, shipped: rows.length > 0, kinds: earningsKinds(earnings.days) }),
557
+ footer: `${fmtNum(balance)} ${curLabel()} in all · ${span} · the line holds flat on days with nothing earned`,
558
+ }));
559
+ }
560
+ if (rows.length) {
561
+ const worksFull = cumulativeByDay(rows, () => 1);
562
+ figs.push(
563
+ timeChart({
564
+ id: 'chart-works', title: 'Works shipped', points: clipToWindow(worksFull, windowStart), now, windowStart,
565
+ valueText: (v) => `${fmtNum(v)} work${v === 1 ? '' : 's'}`,
566
+ footer: `${fmtNum(worksFull[worksFull.length - 1].value)} total · ${span}`,
567
+ }),
568
+ mixChart({
569
+ id: 'chart-mix', title: `Works by ${window.OTB.roleAxisLabel().toLowerCase()}`, mix: craftMix(inWindow),
570
+ footer: `${fmtNum(inWindow.length)} work${inWindow.length === 1 ? '' : 's'} · ${span}`,
571
+ }),
572
+ );
573
+ } else {
574
+ figs.push({ id: 'chart-works-none', html: `<p class="pchart__note pcharts__wide" id="chart-works-none">No works shipped yet — the works charts draw once the first one lands.</p>`, wire() {} });
575
+ }
472
576
  if (isOwn && selfStreak) {
473
577
  // The calendar leads the viewer's own overview — the year-at-a-glance the goal's
474
578
  // thesis is about. Null (no qualifying ships yet) → simply not shown.
475
579
  const cal = calendarChart({ id: 'chart-calendar', streak: selfStreak });
476
580
  if (cal) figs.unshift(cal);
477
581
  }
478
- if (isOwn) {
582
+ if (isOwn && rows.length) {
479
583
  const paceDays = Math.min(range.days || PACE_MAX_DAYS, PACE_MAX_DAYS);
480
584
  const own = await fetchOwnRate(api, API, builderId, paceDays);
481
585
  if (own && Array.isArray(own.ship_rate)) {
@@ -492,43 +596,41 @@
492
596
  figs.forEach((f) => { const el = grid.querySelector(`#${f.id}`); if (el) f.wire(el); });
493
597
  }
494
598
 
495
- async function render({ api, API, builderId, selfId, signedIn, selfStreak = null, now = Date.now() }) {
599
+ async function render({ api, API, builderId, selfId, signedIn, name = null, selfStreak = null, now = Date.now() }) {
496
600
  const mount = $('#charts-grid');
497
601
  const controls = $('#charts-controls');
498
602
  if (!mount) return;
499
603
  if (controls) controls.innerHTML = '';
500
604
  const isOwn = selfId != null && String(selfId) === String(builderId);
605
+ const signIn = `Earnings and shipped work are visible to signed-in builders — <a href="${esc(API)}/auth/web/start">sign in</a> to see them.`;
501
606
  if (!signedIn) {
502
- 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.`);
607
+ say(mount, signIn);
503
608
  return;
504
609
  }
610
+ // The record and the earnings, read once and together; the lookback only
611
+ // clips them.
505
612
  let got;
613
+ let earnings;
506
614
  try {
507
- got = await fetchAllShipped(api, API, builderId);
615
+ [got, earnings] = await Promise.all([
616
+ fetchAllShipped(api, API, builderId),
617
+ fetchEarnings(api, API, builderId),
618
+ ]);
508
619
  } catch (err) {
509
620
  console.error('[profile] charts failed', err);
510
621
  say(mount, 'Could not load the charts.');
511
622
  return;
512
623
  }
513
- if (got.signedOut) {
514
- 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.`);
624
+ if (got.signedOut || earnings.signedOut) {
625
+ say(mount, signIn);
515
626
  return;
516
627
  }
517
628
  const rows = got.rows;
518
- if (!rows.length) {
519
- say(mount, 'No works shipped yet — the charts draw once the first one lands.');
629
+ if (!rows.length && !earnings.failed && !earnings.days.length) {
630
+ say(mount, 'Nothing earned or shipped yet — the charts draw once the first credit lands.');
520
631
  return;
521
632
  }
522
633
 
523
- // The drachmae series: the ledger wherever the viewer may read it, the
524
- // estimate (labelled) where the server refuses. Fetched once, like the
525
- // record; the lookback only clips it.
526
- const ledger = await fetchLedger(api, API, builderId, isOwn);
527
- const estimated = ledger.denied;
528
- const paidFull = estimated
529
- ? cumulativeByDay(rows, estimateOf)
530
- : cumulativeByDay(ledger.rows, (r) => r && r.delta, (r) => r && r.recorded_at);
531
-
532
634
  // The lookback control. Its choice is remembered per viewer; changing it
533
635
  // re-draws from the rows already in hand — the record is not re-fetched.
534
636
  let rangeId = readRange();
@@ -544,11 +646,11 @@
544
646
  b.classList.toggle('is-active', on);
545
647
  b.setAttribute('aria-pressed', String(on));
546
648
  });
547
- draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId, selfStreak });
649
+ draw({ api, API, builderId, isOwn, name, rows, earnings, now, rangeId, selfStreak });
548
650
  });
549
651
  }
550
- await draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId, selfStreak });
652
+ await draw({ api, API, builderId, isOwn, name, rows, earnings, now, rangeId, selfStreak });
551
653
  }
552
654
 
553
- window.OTBProfileCharts = { render, cumulativeByDay, craftMix, weeklyBuckets, estimateOf, clipToWindow };
655
+ window.OTBProfileCharts = { render, cumulativeByDay, craftMix, weeklyBuckets, clipToWindow, earningsKinds, earningsLede };
554
656
  })();
@@ -194,6 +194,29 @@
194
194
  .pchart-range__btn:hover { color: var(--ink); background: var(--glass); }
195
195
  .pchart-range__btn.is-active { background: var(--accent-tint); color: var(--accent-ink); }
196
196
 
197
+ /* the earnings figure (task 1004436) — the approved mock's "Earnings over time":
198
+ the kit's step line beside "where it came from", one sentence above both. It
199
+ spans the grid, because it is the one figure every builder has. The kinds are
200
+ the kit's bars in the surfaces row (name and number on one line, a 4px bar
201
+ under them) and in INK, stepped by rank, as the mock draws them: the accent
202
+ belongs to the line, and a second accent beside it would read as a second
203
+ series. */
204
+ .pchart--wide, .pcharts__wide { grid-column: 1 / -1; }
205
+ .pearn__lede { margin: 2px 0 var(--sp-sm); max-width: var(--measure); font-size: 14px; color: var(--ink-soft); }
206
+ .pearn__grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: var(--sp-md); align-items: start; }
207
+ .pearn__h { margin: 0 0 12px; font-family: var(--label-face); font-size: var(--label-size); font-weight: 400; letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-faint); }
208
+ .pearn__kinds .kit-bars { gap: 12px; margin: 0; }
209
+ .pearn__kinds .kit-bars__row { grid-template-columns: minmax(0, 1fr) auto; row-gap: 7px; column-gap: 10px; }
210
+ .pearn__kinds .kit-bars__label { grid-column: 1; grid-row: 1; font-size: 13.5px; color: var(--ink); }
211
+ .pearn__kinds .kit-bars__val { grid-column: 2; grid-row: 1; font-size: 13.5px; font-variant-numeric: tabular-nums; color: var(--ink); }
212
+ .pearn__kinds .kit-bars__track { grid-column: 1 / -1; grid-row: 2; }
213
+ .pearn__kinds .kit-bars__row .kit-bars__fill { background: color-mix(in srgb, var(--ink) 38%, var(--bg-card)); }
214
+ .pearn__kinds .kit-bars__row:nth-child(1) .kit-bars__fill { background: var(--ink); }
215
+ .pearn__kinds .kit-bars__row:nth-child(2) .kit-bars__fill { background: color-mix(in srgb, var(--ink) 62%, var(--bg-card)); }
216
+ @media (max-width: 720px) {
217
+ .pearn__grid { grid-template-columns: minmax(0, 1fr); }
218
+ }
219
+
197
220
  /* ---- the ideator's trail (task 1004435) ------------------------------------
198
221
  The room's public trail, on this page's own ground: the sky's five marks by
199
222
  the day each thought was filed (the approved mock's .trail and .m-*). Size is
@@ -915,10 +915,10 @@
915
915
  renderHead(builder, view);
916
916
  renderFoundingMark(builder);
917
917
  renderNumbers(view);
918
- // The charts (task 1003489) read the activity route and the credits ledger
919
- // (task 1003491), so they need the same session the ledgers do; on the
918
+ // The charts (task 1003489) read the activity route and the earnings by
919
+ // kind (task 1004436), so they need the same session the ledgers do; on the
920
920
  // viewer's own profile they add the pace.
921
- if (window.OTBProfileCharts) window.OTBProfileCharts.render({ api, API, builderId: builder.id, selfId, signedIn: selfId != null, selfStreak: (selfMe && selfMe.streak) || null });
921
+ if (window.OTBProfileCharts) window.OTBProfileCharts.render({ api, API, builderId: builder.id, selfId, signedIn: selfId != null, name: builder.display_name || builder.github_login || null, selfStreak: (selfMe && selfMe.streak) || null });
922
922
  renderCrossProject(data, selfId);
923
923
  renderAchievements(data.achievements);
924
924
  buildWorksLedger(builder.id, selfId != null, data.recent_ships);
@@ -125,7 +125,7 @@
125
125
  }
126
126
  },
127
127
  "artist": {
128
- "_": "An artist who has never shipped a task (task 1004434): the header and By the numbers lead with pages approved and the per-surface N of M tweaked. Fixture builders__2001__profile.",
128
+ "_": "An artist who has never shipped a task (task 1004434): the header and By the numbers lead with pages approved and the per-surface N of M tweaked. Fixture builders__2001__profile. Since task 1004436 the earnings figure draws for them too, from builders__2001__earnings beside an empty builders__2001__activity.",
129
129
  "auth": true,
130
130
  "url": "/profile?id=2001",
131
131
  "actions": [
@@ -138,12 +138,14 @@
138
138
  "visible": [
139
139
  "#profile-head",
140
140
  ".profile__tally",
141
- ".profile-nums"
141
+ ".profile-nums",
142
+ "#chart-drachmae",
143
+ ".pearn__kinds"
142
144
  ]
143
145
  }
144
146
  },
145
147
  "ideator": {
146
- "_": "An ideator who has never shipped a task (task 1004434): thoughts filed, ignited, Full Ideas, total size with its historical-estimate mark, and what is still alive. Fixture builders__2002__profile.",
148
+ "_": "An ideator who has never shipped a task (task 1004434): thoughts filed, ignited, Full Ideas, total size with its historical-estimate mark, and what is still alive. Fixture builders__2002__profile. Since task 1004436 the earnings figure draws for them too, every credit from an idea stream, from builders__2002__earnings beside an empty builders__2002__activity.",
147
149
  "auth": true,
148
150
  "url": "/profile?id=2002",
149
151
  "actions": [
@@ -156,7 +158,9 @@
156
158
  "visible": [
157
159
  "#profile-head",
158
160
  ".profile__tally",
159
- ".profile-nums"
161
+ ".profile-nums",
162
+ "#chart-drachmae",
163
+ ".pearn__kinds"
160
164
  ]
161
165
  }
162
166
  },