@bongos/core 1.20.81 → 1.20.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +71 -46
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +46 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +33 -26
- package/docs/copy-registry.json +164 -92
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +58 -43
- package/modules/economy/earnings.js +117 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/routes/earnings.js +47 -0
- package/modules/hall-ui/public/gate.css +49 -0
- package/modules/hall-ui/public/gate.html +5 -7
- package/modules/hall-ui/public/gate.js +215 -127
- package/modules/hall-ui/public/profile-charts.js +206 -104
- package/modules/hall-ui/public/profile.css +23 -0
- package/modules/hall-ui/public/profile.js +3 -3
- package/modules/hall-ui/public/profile.states.json +8 -4
- package/modules/hall-ui/records/profile-roles.md +21 -0
- package/modules/lifecycle/db-goals.js +6 -0
- package/modules/lifecycle/db-versions.js +3 -0
- package/modules/lifecycle/done-when.js +85 -41
- package/modules/lifecycle/goal-close-sweep.js +34 -0
- package/modules/provisioning/app-status.js +17 -4
- package/modules/public-landing/public/projects.html +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/hall-preview/server.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +28 -6
- package/tests/autonomy_builder_scope.mjs +1 -1
- package/tests/autonomy_fence_priority.mjs +5 -3
- package/tests/autonomy_runner_identity.mjs +1 -1
- package/tests/criterion_goal_attach.mjs +5 -1
- package/tests/goal_close_cancels_requests.mjs +82 -0
- package/tests/goal_closure_invariants.mjs +73 -2
- package/tests/hall_profile_world.mjs +57 -58
- package/tests/profile_earnings.mjs +237 -0
- package/tests/projects_hub_app_status.mjs +17 -0
- 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
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// is
|
|
29
|
-
//
|
|
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,
|
|
33
|
-
// weeklyBuckets,
|
|
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
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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) *
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
388
|
-
// profile
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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]
|
|
411
|
-
return {
|
|
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,
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
id: 'chart-drachmae',
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
})
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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, '
|
|
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,
|
|
649
|
+
draw({ api, API, builderId, isOwn, name, rows, earnings, now, rangeId, selfStreak });
|
|
548
650
|
});
|
|
549
651
|
}
|
|
550
|
-
await draw({ api, API, builderId, isOwn,
|
|
652
|
+
await draw({ api, API, builderId, isOwn, name, rows, earnings, now, rangeId, selfStreak });
|
|
551
653
|
}
|
|
552
654
|
|
|
553
|
-
window.OTBProfileCharts = { render, cumulativeByDay, craftMix, weeklyBuckets,
|
|
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
|
|
919
|
-
// (task
|
|
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
|
},
|
|
@@ -48,3 +48,24 @@ Signed out, the section is one sign-in line (owner decision (a)).
|
|
|
48
48
|
- **The whole after line is highlighted**, where the mock highlights only the changed words: the batch stores whole lines, not the changed span.
|
|
49
49
|
- **The artist tab's lede does not say what each page paid**: the pay per page is not in the read.
|
|
50
50
|
- **The trail's window** is the span of the person's own thoughts (at least a month), with a line at each month, rather than the mock's fixed three months.
|
|
51
|
+
|
|
52
|
+
## Earnings for everyone, labelled by kind of credit (task 1004436, WA6.RP06)
|
|
53
|
+
|
|
54
|
+
The Overview's charts used to stop at "No works shipped yet" before they read a credit, so an artist or an ideator with a full credit history got no chart at all. Now the **earnings figure** leads the "Over time" grid for anyone who has earned, shipped task or not: the mock's "Earnings over time", a sentence about the person, the kit's cumulative step line, and beside it "where it came from", one bar per kind of credit in the mock's words, largest first.
|
|
55
|
+
|
|
56
|
+
- **The read is `GET /builders/:builderId/earnings`** (economy, `modules/economy/earnings.js` + `routes/earnings.js`): the ledger summed per UTC day and per kind, for any signed-in viewer and any builder, own profile included. It carries sums only, so the itemised ledger (`/builders/:builderId/credits`) stays behind `builder.roster.read`; the chart no longer reads it, and the estimate a refused viewer used to get (summed from shipped tasks, which is nothing for these two crafts) is gone.
|
|
57
|
+
- **The kinds are economy's** (`KINDS`), the words the page's (`KIND_WORDS` in `profile-charts.js`). A descent payment shares stream 1's reason key, so it is told apart by the descent writer's own description ("N hops below idea #"); `tests/profile_earnings.mjs` holds that phrase against the writer.
|
|
58
|
+
- **The kinds follow the lookback window**, as every other figure does; the line still enters the window at its running total and ends at the balance.
|
|
59
|
+
- **The works charts still need a shipped task**, and without one they are a single line under the figure ("No works shipped yet ..."), not two empty cards. Someone with neither credits nor works gets one line for the whole section.
|
|
60
|
+
- **On a phone** the figure stacks: the line, then "where it came from". The plot's viewBox is half again a card's on a wide screen so its type matches the cards', and the kit's own on a phone (read at draw time, at the same 720px the grid stacks).
|
|
61
|
+
|
|
62
|
+
Harness: `builders__earnings.json` serves any id; the artist (2001) and the ideator (2002) carry their own earnings beside an empty `builders__<id>__activity.json`, and the `artist`/`ideator` states expect `#chart-drachmae` and `.pearn__kinds`.
|
|
63
|
+
|
|
64
|
+
### Deliberate differences from the mock (RP06)
|
|
65
|
+
|
|
66
|
+
- **No "Spark splits" row.** A spark author's 30% share is counted under the stream that paid it (shipped work, a Full Idea pass, the board), and the task's own list of kinds does not name it; a separate row would count the same credits under two words.
|
|
67
|
+
- **No "historical estimate" foot.** The figure is only what the ledger paid. An idea's historical estimate is part of its SIZE (By the numbers says so with its ~ mark), never money booked, so the earnings line has none to include.
|
|
68
|
+
- **Two kinds the mock does not draw:** "Simplification bonuses" (the net-negative ship bonus, which the task names) and "Other credits" (achievements, a hand grant), so the bars always add up to the balance.
|
|
69
|
+
- **The live chart chrome stays:** the crosshair readout line above the plot, the "as a table" twin and the kit's end ticks (the first day and "today", no "0" label), rather than the mock's "8 weeks ago / this week". No number is reachable only through the picture.
|
|
70
|
+
- **It sits in "Over time" under the lookback control**, so the kinds follow the window rather than the mock's fixed eight weeks.
|
|
71
|
+
- **The sentence uses the person's full display name**, not a first name the hall does not have, and for someone who never shipped a task it names where their credits came from ("approved pages and ideas"), where the mock's artist sample had the general sentence.
|
|
@@ -18,6 +18,8 @@ const { nonTerminalSql, isTerminalStatus } = require('./task-status');
|
|
|
18
18
|
const { maintenanceGoalTitle } = require('./goal-advisory.js');
|
|
19
19
|
const { SMOKE_NOT_LIKE } = require('./db-shared.js');
|
|
20
20
|
const { executorFor, inTransaction } = require('./db-executor.js');
|
|
21
|
+
// Task 1003660 — closing a goal cancels its pending invites + join requests.
|
|
22
|
+
const { cancelPendingMembershipRequests } = require('./goal-close-sweep.js');
|
|
21
23
|
// BV1.R16 (task 1003603) — auto-close, shared with the done-when cascade so the
|
|
22
24
|
// two achievement writers cannot drift apart (ADR 0263 §4).
|
|
23
25
|
const { autoCloseVersionsForGoals } = require('./version-autoclose.js');
|
|
@@ -282,6 +284,7 @@ async function carryBugsForward({ fromVersionId, toVersionId }, deps = {}) {
|
|
|
282
284
|
// never "done", it stopped being the live queue. Leaving it open on a shipped
|
|
283
285
|
// version is the mirror of the defect this whole goal exists to remove.
|
|
284
286
|
await exec.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [from[0].id]);
|
|
287
|
+
await cancelPendingMembershipRequests(exec, from[0].id);
|
|
285
288
|
return { fromGoalId: String(from[0].id), toGoalId: String(to.id), moved: moved.length, reason: null };
|
|
286
289
|
}
|
|
287
290
|
|
|
@@ -493,6 +496,8 @@ async function setGoalStatus({ goalId, status }, deps = {}) {
|
|
|
493
496
|
RETURNING ${GOAL_COLS}`,
|
|
494
497
|
[goalId, status]
|
|
495
498
|
);
|
|
499
|
+
// A goal leaving `open` takes no new members, so its pending asks are dead.
|
|
500
|
+
if (rows[0] && status !== 'open') await cancelPendingMembershipRequests(activePool, goalId);
|
|
496
501
|
return rows[0] ?? null;
|
|
497
502
|
}
|
|
498
503
|
|
|
@@ -797,6 +802,7 @@ async function archiveGoalWithDispositions({ goalId, plan = [], reason }, deps =
|
|
|
797
802
|
`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1 RETURNING ${GOAL_COLS}`,
|
|
798
803
|
[goalId]
|
|
799
804
|
);
|
|
805
|
+
await cancelPendingMembershipRequests(client, goalId);
|
|
800
806
|
await client.query('COMMIT');
|
|
801
807
|
return { goal: archived[0], applied, alreadyArchived: false };
|
|
802
808
|
} catch (err) {
|
|
@@ -20,6 +20,7 @@ const { executorFor, inTransaction } = require('./db-executor.js');
|
|
|
20
20
|
// (fitness pins the count). createVersion needs it to seed a version's FIRST goal
|
|
21
21
|
// in the same transaction — task 1003701.
|
|
22
22
|
const { createGoal } = require('./db-goals.js');
|
|
23
|
+
const { cancelPendingMembershipRequests } = require('./goal-close-sweep.js');
|
|
23
24
|
|
|
24
25
|
async function listVersions() {
|
|
25
26
|
const { rows } = await pool.query(
|
|
@@ -405,6 +406,7 @@ async function closeVersion({ versionId, plan = [], reason, planningVersionId =
|
|
|
405
406
|
[step.goalId, `Abandoned: ${reason}`]
|
|
406
407
|
);
|
|
407
408
|
await client.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [step.goalId]);
|
|
409
|
+
await cancelPendingMembershipRequests(client, step.goalId);
|
|
408
410
|
applied.push({ goal_id: String(step.goalId), verb: 'abandon', result: 'archived', tasks_abandoned: killed.length });
|
|
409
411
|
} else {
|
|
410
412
|
// roll_forward (BV1.R17, task 1003604, ADR 0263 §6): the goal survives the
|
|
@@ -496,6 +498,7 @@ async function closeVersion({ versionId, plan = [], reason, planningVersionId =
|
|
|
496
498
|
// on the way out rather than being special-cased around it (the brief's
|
|
497
499
|
// explicit ask).
|
|
498
500
|
await client.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [step.goalId]);
|
|
501
|
+
await cancelPendingMembershipRequests(client, step.goalId);
|
|
499
502
|
|
|
500
503
|
applied.push({
|
|
501
504
|
goal_id: String(step.goalId), verb: 'roll_forward', result: 'succeeded',
|