@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.
- package/.bongos-core.json +29 -19
- package/docs/copy-inventory.md +44 -33
- package/docs/copy-registry.json +176 -57
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/profile-charts.js +524 -0
- package/modules/hall-ui/public/profile.css +90 -1
- package/modules/hall-ui/public/profile.html +146 -97
- package/modules/hall-ui/public/profile.js +165 -31
- package/modules/hall-ui/public/profile.states.json +109 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +6 -0
- package/scripts/hall-preview/server.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/hall_profile_world.mjs +437 -0
|
@@ -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
|
|
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); }
|