@bongos/core 1.24.41 → 1.24.43
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 +117 -92
- package/docs/copy-inventory.md +98 -137
- package/docs/copy-registry.json +244 -644
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/primer.md +1 -1
- package/docs/page-inventory.json +3 -3
- package/docs/page-readings.json +494 -535
- package/modules/hall-ui/public/ai-studio.html +4 -4
- package/modules/hall-ui/public/atlas.html +4 -4
- package/modules/hall-ui/public/blockers.html +4 -4
- package/modules/hall-ui/public/board-room.html +4 -4
- package/modules/hall-ui/public/builders.js +35 -763
- package/modules/hall-ui/public/city.html +4 -4
- package/modules/hall-ui/public/collab.html +4 -4
- package/modules/hall-ui/public/commands.html +4 -4
- package/modules/hall-ui/public/copy-desk.html +4 -4
- package/modules/hall-ui/public/craft-mode.js +1 -1
- package/modules/hall-ui/public/deploy.html +4 -4
- package/modules/hall-ui/public/dom-utils.js +2 -1
- package/modules/hall-ui/public/goal-map.html +4 -4
- package/modules/hall-ui/public/goals.html +4 -4
- package/modules/hall-ui/public/government.html +4 -4
- package/modules/hall-ui/public/hall-render.js +25 -771
- package/modules/hall-ui/public/hall-widgets.js +14 -123
- package/modules/hall-ui/public/idea.html +4 -4
- package/modules/hall-ui/public/ideas.html +4 -4
- package/modules/hall-ui/public/index.html +8 -22
- package/modules/hall-ui/public/index.states.json +0 -33
- package/modules/hall-ui/public/market-watch-item.html +4 -4
- package/modules/hall-ui/public/market-watch-rules.html +4 -4
- package/modules/hall-ui/public/market-watch.html +4 -4
- package/modules/hall-ui/public/modules.html +4 -4
- package/modules/hall-ui/public/money.html +4 -4
- package/modules/hall-ui/public/my-agents.html +4 -4
- package/modules/hall-ui/public/palette.js +7 -6
- package/modules/hall-ui/public/primer.html +4 -4
- package/modules/hall-ui/public/profile-own.js +639 -0
- package/modules/hall-ui/public/profile.css +2 -1
- package/modules/hall-ui/public/profile.html +48 -5
- package/modules/hall-ui/public/profile.js +27 -2
- package/modules/hall-ui/public/profile.states.json +18 -0
- package/modules/hall-ui/public/project-settings.html +4 -4
- package/modules/hall-ui/public/roadmap.html +4 -4
- package/modules/hall-ui/public/roster.html +4 -4
- package/modules/hall-ui/public/settings.html +4 -4
- package/modules/hall-ui/public/setup-gate.js +156 -0
- package/modules/hall-ui/public/shell-trail.js +2 -1
- package/modules/hall-ui/public/shell.js +44 -26
- package/modules/hall-ui/public/street.html +4 -4
- package/modules/hall-ui/public/studio.html +4 -4
- package/modules/hall-ui/public/style.css +85 -117
- package/modules/hall-ui/public/task.html +4 -4
- package/modules/hall-ui/public/thinking.html +4 -4
- package/modules/hall-ui/public/tweak-editor.html +4 -4
- package/modules/hall-ui/public/watch.html +4 -4
- package/modules/hall-ui/public/work.html +4 -4
- package/modules/hall-ui/records/setup-gate.md +43 -0
- package/modules/lifecycle/routes/tasks.js +2 -2
- package/modules/onboarding/onboarding-state.js +41 -20
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/routes/me.js +2 -2
- package/src/module-api.js +1 -1
- package/tests/curve_controls_delegation.mjs +9 -8
- package/tests/curve_kind_overlay.mjs +5 -5
- package/tests/hall_acclaim_vote_control.mjs +5 -3
- package/tests/hall_audit.mjs +10 -6
- package/tests/hall_board_world.mjs +41 -27
- package/tests/hall_builders_page_pass.mjs +14 -5
- package/tests/hall_home_ships_bounded.mjs +10 -21
- package/tests/hall_kit_charts.mjs +3 -2
- package/tests/hall_landing_boot.mjs +2 -3
- package/tests/hall_loading.mjs +2 -1
- package/tests/hall_nav.mjs +36 -37
- package/tests/hall_numbers_page.mjs +100 -0
- package/tests/hall_onboarding_bongos_setup.mjs +14 -14
- package/tests/hall_palette.mjs +19 -24
- package/tests/hall_profile_world.mjs +7 -2
- package/tests/hall_reading_room.mjs +3 -2
- package/tests/helpers.mjs +8 -2
- package/tests/leaderboard_roles.mjs +1 -1
- package/tests/module_contributions.mjs +10 -6
- package/tests/nav_permission_atoms.mjs +4 -2
- package/tests/onboarding_state.mjs +33 -0
- package/tests/page_tweak_artist.mjs +2 -1
- package/tests/rank_tier_single_source.mjs +3 -0
- package/tests/setup_flow.mjs +242 -0
- package/tests/task_visuals.mjs +3 -1
- package/tests/welcome_boot.mjs +3 -2
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Carved out of builders.js by task 1003206 (goal 1000079, the R03 de-monolith
|
|
4
4
|
// plan): that file was 2,558 lines, the largest first-party file left over the
|
|
5
|
-
// 1,500-line ratchet. This holds the view painters — landing,
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// 1,500-line ratchet. This holds the view painters — landing, the Standing
|
|
6
|
+
// chip, the shipped count, the nav and hall widgets — plus the helpers and view
|
|
7
|
+
// state only they touch. (The Accomplishments view and its curve, reviews and
|
|
8
|
+
// feeds moved to profile-own.js in task 1005050.)
|
|
8
9
|
//
|
|
9
10
|
// WHY A FACTORY, and not a flat namespace like goals-lib.js. goals-lib.js can be
|
|
10
11
|
// flat because everything in it is pure. These renderers are not: they need the
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
'use strict';
|
|
23
24
|
|
|
24
25
|
window.OTBHallRender = function createHallRender(ctx) {
|
|
25
|
-
const { API, RANK_ORDER, api, brandWorld, curLabel,
|
|
26
|
+
const { API, RANK_ORDER, api, brandWorld, curLabel, normalizeRank, postJSON, rankLabel, rankWeight, toast } = ctx;
|
|
26
27
|
const { escapeHtml, $, fmtNum, moduleOn } = window.OTB;
|
|
27
28
|
|
|
28
29
|
const RANK_UNLOCKS = {
|
|
@@ -49,66 +50,6 @@
|
|
|
49
50
|
};
|
|
50
51
|
let rankTipSeq = 0;
|
|
51
52
|
|
|
52
|
-
// "Where you stand" (task 1005046): the personal half of the retired Ranks
|
|
53
|
-
// page, now a card on Accomplishments. The ladder itself — every rank and what
|
|
54
|
-
// it unlocks — is the Primer's rank-ladder chapter, written once; this card
|
|
55
|
-
// says only what is true for the VIEWER: their rank, what it is, the rank
|
|
56
|
-
// above, and how they reach it. Moved from ranks.js with the page.
|
|
57
|
-
const RANK_TITLE = {
|
|
58
|
-
xenos: 'The newcomer',
|
|
59
|
-
thetes: 'The proven builder',
|
|
60
|
-
metic: 'The trusted builder',
|
|
61
|
-
archon: 'The steward',
|
|
62
|
-
};
|
|
63
|
-
const RANK_BLURB = {
|
|
64
|
-
xenos: 'A newcomer with a deliberately small surface: onboarding, safe starter tasks, and your own standing.',
|
|
65
|
-
thetes: 'A builder who has shipped their first work — the day-to-day surfaces are open.',
|
|
66
|
-
metic: 'A trusted builder: triage, reviews, planning sessions, and security reporting are open.',
|
|
67
|
-
archon: 'The steward of the whole system — approvals, the roster, budgets, and the Archon pages.',
|
|
68
|
-
};
|
|
69
|
-
// How a builder rises FROM their current rank. NB: these strings contain
|
|
70
|
-
// markup (<strong>) and are injected via innerHTML without escaping, which is
|
|
71
|
-
// safe only because they are author-controlled constants. Never source them
|
|
72
|
-
// from server or user data without switching the render site to escapeHtml.
|
|
73
|
-
const RISE_FROM = {
|
|
74
|
-
xenos:
|
|
75
|
-
`Claim and ship the newcomer-friendly tasks an Archon has opened to newcomers. Each clean ship earns ${curLabel()}; each ratified idea earns karma. On your <strong>third</strong> clean ship you are promoted automatically to <strong>Thetes</strong>, out of the newcomer sandbox with the whole queue open to you. No fee, no application; the third ship does it.`,
|
|
76
|
-
thetes:
|
|
77
|
-
'You have the run of the queue. Promotion to <strong>Metic</strong> (triage, blockers, planning, the shaping of work) is granted by a standing Archon alone, to a builder whose judgement has earned the project’s trust. Keep shipping; rank is granted, never bought.',
|
|
78
|
-
metic:
|
|
79
|
-
'The <strong>Archon</strong> rank is granted rarely, by a standing Archon alone, to a Metic who has shaped the project’s direction and earned its deepest trust. There is no checklist, only the weight of what you have built.',
|
|
80
|
-
archon:
|
|
81
|
-
'You hold the highest rank. There is no rank above it, only the care of everything below.',
|
|
82
|
-
};
|
|
83
|
-
// The rank one rung up, read off the injected tier map rather than a second
|
|
84
|
-
// copy of the ladder (tests/rank_tier_single_source.mjs).
|
|
85
|
-
function nextRank(rank) {
|
|
86
|
-
const want = (RANK_ORDER[rank] || 0) + 1;
|
|
87
|
-
return Object.keys(RANK_ORDER).find((r) => RANK_ORDER[r] === want) || null;
|
|
88
|
-
}
|
|
89
|
-
// Intentionally not on the factory's return object: renderProfile is its one
|
|
90
|
-
// caller, so it repaints whenever the band does, from the same /me payload.
|
|
91
|
-
function renderRankStanding(builder) {
|
|
92
|
-
const host = document.getElementById('rank-standing-body');
|
|
93
|
-
if (!host) return;
|
|
94
|
-
const rank = normalizeRank(builder && builder.rank);
|
|
95
|
-
const next = nextRank(rank);
|
|
96
|
-
// At the top there is no rung to name; RISE_FROM says so in its own words,
|
|
97
|
-
// so the line is left out rather than said twice.
|
|
98
|
-
const nextLine = next
|
|
99
|
-
? `<p class="ranks-standing__next">The rank above yours is ${renderRankBadge(next)}.</p>`
|
|
100
|
-
: '';
|
|
101
|
-
host.innerHTML = `
|
|
102
|
-
<div class="ranks-standing">
|
|
103
|
-
<div class="ranks-standing__badge">${renderRankBadge(rank)}</div>
|
|
104
|
-
<p class="ranks-standing__title">${escapeHtml(RANK_TITLE[rank] || rankLabel(rank))}</p>
|
|
105
|
-
<p class="ranks-standing__blurb">${escapeHtml(RANK_BLURB[rank] || '')}</p>
|
|
106
|
-
${nextLine}
|
|
107
|
-
</div>
|
|
108
|
-
<p class="ranks-rise__body">${RISE_FROM[rank] || RISE_FROM.xenos}</p>
|
|
109
|
-
<p class="card-foot"><a href="/primer#the-rank-ladder">Every rank and what it unlocks, in the Primer →</a></p>`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
53
|
function renderRankBadge(rank, opts = {}) {
|
|
113
54
|
const label = escapeHtml(rankLabel(rank));
|
|
114
55
|
const ariaLabel = rank ? `Rank: ${rankLabel(rank)}` : 'Rank not yet recorded';
|
|
@@ -151,33 +92,10 @@
|
|
|
151
92
|
// #zone-leaderboard mount it named.
|
|
152
93
|
const VIEW_CONTAINERS = {
|
|
153
94
|
home: 'zone-home-main',
|
|
154
|
-
standing: 'zone-standing',
|
|
155
95
|
};
|
|
156
|
-
const STANDING_GROUPS = [
|
|
157
|
-
{ key: 'work', label: 'Work' },
|
|
158
|
-
{ key: 'karma', label: 'Karma' },
|
|
159
|
-
{ key: 'progress', label: 'Progress' },
|
|
160
|
-
{ key: 'achievements', label: 'Achievements' },
|
|
161
|
-
];
|
|
162
|
-
const standingGroupId = (key) => `standing-${key}`;
|
|
163
|
-
function ensureStandingGroups() {
|
|
164
|
-
const zone = document.getElementById('zone-standing');
|
|
165
|
-
if (!zone || document.getElementById(standingGroupId(STANDING_GROUPS[0].key))) return;
|
|
166
|
-
for (const g of STANDING_GROUPS) {
|
|
167
|
-
const el = document.createElement('div');
|
|
168
|
-
el.id = standingGroupId(g.key);
|
|
169
|
-
el.className = 'standing-group';
|
|
170
|
-
zone.appendChild(el);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
96
|
function widgetHost(wgt) {
|
|
174
97
|
const zone = wgt.zone ? document.getElementById(`zone-${wgt.zone}`) : null;
|
|
175
98
|
if (zone) return zone;
|
|
176
|
-
if (wgt.group) {
|
|
177
|
-
ensureStandingGroups();
|
|
178
|
-
const group = document.getElementById(standingGroupId(wgt.group));
|
|
179
|
-
if (group) return group;
|
|
180
|
-
}
|
|
181
99
|
const viewId = VIEW_CONTAINERS[wgt.view || 'home'] || VIEW_CONTAINERS.home;
|
|
182
100
|
return document.getElementById(viewId) || document.querySelector('main');
|
|
183
101
|
}
|
|
@@ -247,14 +165,12 @@
|
|
|
247
165
|
// label]/[data-currency-tmpl] cells show the instance's unit, not the fallback.
|
|
248
166
|
window.OTBBranding?.applyCurrencyLabels?.(main);
|
|
249
167
|
}
|
|
250
|
-
let standingRail = null;
|
|
251
168
|
|
|
252
169
|
// task 1723: the old sticky chip strip is gone. renderNav now settles the
|
|
253
170
|
// three nav surfaces the shell owns or hosts:
|
|
254
171
|
// 1. the sidebar's gated entries, via window.OTBShell.applyAccess (cosmetic —
|
|
255
172
|
// the server enforces every page + endpoint regardless, ADR 0016);
|
|
256
173
|
function renderNav(builder, onboarding) {
|
|
257
|
-
const isArchon = (builder?.rank || '').toLowerCase() === 'archon';
|
|
258
174
|
const graduated = !!(onboarding && onboarding.graduated);
|
|
259
175
|
// #833: for a newcomer the Work entry surfaces only once the "ship three"
|
|
260
176
|
// stage is current — before then the path reads one action at a time.
|
|
@@ -287,41 +203,10 @@
|
|
|
287
203
|
// task 1004713: the government positions this builder holds, server-resolved —
|
|
288
204
|
// passed here too, or the index would keep the governing tabs for a seatless builder.
|
|
289
205
|
positions: (ctx.lastMeState && ctx.lastMeState.positions) || null,
|
|
206
|
+
// task 1005076: incomplete setup holds the hall behind setup-gate.js.
|
|
207
|
+
setup: (onboarding && onboarding.setup) || null,
|
|
290
208
|
builder,
|
|
291
209
|
});
|
|
292
|
-
|
|
293
|
-
// Re-open affordance: pre-graduation, when the welcome banner is dismissed
|
|
294
|
-
// (hidden), a small link brings it back (reopenWelcomeFromNav).
|
|
295
|
-
const reopen = document.getElementById('welcome-reopen');
|
|
296
|
-
if (reopen) {
|
|
297
|
-
const welcomeEl = document.getElementById('welcome-scroll');
|
|
298
|
-
reopen.hidden = !(welcomeEl && welcomeEl.hidden && !graduated);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Standing section rail: one link per group with a visible member.
|
|
302
|
-
const rail = document.getElementById('standing-rail');
|
|
303
|
-
if (rail) {
|
|
304
|
-
const shown = new Set(
|
|
305
|
-
window.OTB.hallWidgets()
|
|
306
|
-
.filter((wgt) => {
|
|
307
|
-
if (!wgt.group) return false;
|
|
308
|
-
if (wgt.archonOnly && !isArchon) return false;
|
|
309
|
-
const el = document.getElementById(wgt.id);
|
|
310
|
-
return !!el && !el.hidden;
|
|
311
|
-
})
|
|
312
|
-
.map((wgt) => wgt.group),
|
|
313
|
-
);
|
|
314
|
-
// An empty group would otherwise leave a gap in the panel column.
|
|
315
|
-
for (const g of STANDING_GROUPS) {
|
|
316
|
-
const el = document.getElementById(standingGroupId(g.key));
|
|
317
|
-
if (el) el.hidden = !shown.has(g.key);
|
|
318
|
-
}
|
|
319
|
-
const items = STANDING_GROUPS
|
|
320
|
-
.filter((g) => shown.has(g.key))
|
|
321
|
-
.map((g) => ({ id: standingGroupId(g.key), label: g.label }));
|
|
322
|
-
if (standingRail) standingRail.refresh(items);
|
|
323
|
-
else standingRail = window.OTBKit.makeSectionRail({ mount: rail, items, mode: 'scrollspy' });
|
|
324
|
-
}
|
|
325
210
|
}
|
|
326
211
|
// The project's door, as the platform pushed it into this instance's pack
|
|
327
212
|
// (window.__BRANDING__.project.joinability, task 1003044): what a stranger is
|
|
@@ -369,28 +254,6 @@
|
|
|
369
254
|
</details>`;
|
|
370
255
|
}
|
|
371
256
|
|
|
372
|
-
// task 1002835: a karma vote on a peer's ship was a vote on a title alone.
|
|
373
|
-
// The two facts the ship already recorded — what the grader scored it and
|
|
374
|
-
// what it cost — turn it into an informed one. ABSENCE IS NOT ZERO: an
|
|
375
|
-
// ungraded ship says so, and so does an unlogged cost, because "0.0" and
|
|
376
|
-
// "$0.00" would read as measurements that were never taken.
|
|
377
|
-
function acclaimEvidenceHtml(t) {
|
|
378
|
-
const score = t.grade_score == null ? null : Number(t.grade_score);
|
|
379
|
-
const grade = score == null || !Number.isFinite(score)
|
|
380
|
-
? '<span class="ledger__verdict--none">not graded</span>'
|
|
381
|
-
: `<span class="${t.grade_passed ? 'ledger__verdict--ok' : 'ledger__verdict--danger'}">${score.toFixed(1)} / 10</span>`;
|
|
382
|
-
// Three states, not two. The route OMITS cost_usd for a builder who may not
|
|
383
|
-
// vote, and that is not the same fact as a logged cost of nothing: a reader
|
|
384
|
-
// who is shown no figure is told nothing, never "no cost logged".
|
|
385
|
-
const parts = [grade];
|
|
386
|
-
if (Object.prototype.hasOwnProperty.call(t, 'cost_usd')) {
|
|
387
|
-
const usd = t.cost_usd == null ? null : Number(t.cost_usd);
|
|
388
|
-
parts.push(usd == null || !Number.isFinite(usd)
|
|
389
|
-
? '<span class="ledger__verdict--none">no cost logged</span>'
|
|
390
|
-
: `<span class="acclaim__cost">$${usd.toFixed(2)}</span>`);
|
|
391
|
-
}
|
|
392
|
-
return `<span class="acclaim__evidence">${parts.join(' · ')}</span>`;
|
|
393
|
-
}
|
|
394
257
|
// The sign-in itself — the card's one primary control, in every mode, so a
|
|
395
258
|
// returning builder never hunts for it below a form (task 1002851's intent).
|
|
396
259
|
function returningDoorHtml() {
|
|
@@ -547,44 +410,24 @@
|
|
|
547
410
|
}
|
|
548
411
|
});
|
|
549
412
|
}
|
|
413
|
+
// renderProfile(builder) — the Home surfaces fed by the /me payload: the
|
|
414
|
+
// Standing chip's card, the welcome line and the system notices. The full
|
|
415
|
+
// Accomplishments band it also painted went with the view in task 1005050:
|
|
416
|
+
// your numbers are the Profile page now.
|
|
550
417
|
function renderProfile(builder) {
|
|
551
|
-
const karma = ctx.lastKarmaState;
|
|
552
418
|
const cost = ctx.lastCostState;
|
|
553
419
|
const needs = ctx.lastNeedsState;
|
|
554
420
|
const rebase = ctx.lastRebaseState;
|
|
555
|
-
const avatar = builder.avatar_url
|
|
556
|
-
? `<img class="profile__avatar" src="${escapeHtml(builder.avatar_url)}" alt="">`
|
|
557
|
-
: `<div class="profile__avatar" aria-hidden="true"></div>`;
|
|
558
|
-
|
|
559
421
|
const prefs = Array.isArray(builder.preferred_disciplines) ? builder.preferred_disciplines : [];
|
|
560
422
|
|
|
561
|
-
const rankBadge = builder.rank
|
|
562
|
-
? renderRankBadge(normalizeRank(builder.rank), { tip: true })
|
|
563
|
-
: renderRankBadge(builder.rank, { title: 'No rank recorded yet' });
|
|
564
|
-
|
|
565
423
|
// #757: this builder's place on the Roll. Null until the leaderboard rows
|
|
566
|
-
// load (they're fetched async after the
|
|
567
|
-
//
|
|
424
|
+
// load (they're fetched async after the card's first paint; the card
|
|
425
|
+
// re-renders once they arrive) — when null the stat simply omits.
|
|
568
426
|
const rollPlace = leaderboardPlace(builder.github_login);
|
|
569
|
-
|
|
570
|
-
// V3.R26 (#246): karma badge + 30-day delta. Drachmae and karma are two
|
|
571
|
-
// separate counters — drachmae = work shipped, karma = good citizenship
|
|
572
|
-
// (peer recognition + Archon ratifications). The tooltip on each badge
|
|
573
|
-
// spells the distinction out so a new builder can tell them apart.
|
|
574
427
|
const karmaTotal = Number(builder.karma) || 0;
|
|
575
|
-
const karma30d = karma ? Number(karma.delta_30d) || 0 : 0;
|
|
576
|
-
const karmaTrendText = karma30d > 0
|
|
577
|
-
? `+${fmtNum(karma30d)} in 30d`
|
|
578
|
-
: (karma30d < 0 ? `${fmtNum(karma30d)} in 30d` : '±0 in 30d');
|
|
579
|
-
const karmaTrend = `<span class="karma-badge__trend ${signClasses(karma30d, 'karma-badge__trend')}">${karmaTrendText}</span>`;
|
|
580
|
-
const karmaTooltip = `Karma = peer recognition and approvals. ${curLabel(true)} = everything earned. The two counters move independently — see the karma feed for the audit trail.`;
|
|
581
|
-
const drachmaeTooltip = `${curLabel(true)} = everything earned: shipped work, approved pages and ideas. Karma is a separate counter for peer recognition and approvals.`;
|
|
582
428
|
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
// (renderSystemNotices, below), so here they only repeated it. `need`,
|
|
586
|
-
// `actionNeeds` and `rebaseTasks` are still computed for that call.
|
|
587
|
-
// The highest-priority `needs` item that isn't satisfied (ADR 0073).
|
|
429
|
+
// The highest-priority `needs` item that isn't satisfied (ADR 0073); Home's
|
|
430
|
+
// attention cards carry it (renderSystemNotices, below).
|
|
588
431
|
const need = (needs && Array.isArray(needs.items))
|
|
589
432
|
? needs.items.find((n) => n && n.state !== 'satisfied')
|
|
590
433
|
: null;
|
|
@@ -598,35 +441,6 @@
|
|
|
598
441
|
: [];
|
|
599
442
|
// ADR 0120 part 5: un-landed confirmed tasks (Home's "Rebase owed" line).
|
|
600
443
|
const rebaseTasks = Array.isArray(rebase) ? rebase.filter((t) => t && t.id != null) : [];
|
|
601
|
-
$('#profile-body').innerHTML = `
|
|
602
|
-
${avatar}
|
|
603
|
-
<div>
|
|
604
|
-
<div class="profile__name-row">
|
|
605
|
-
<p class="profile__name">${escapeHtml(builder.display_name || builder.github_login || 'Builder')}</p>
|
|
606
|
-
${rankBadge}
|
|
607
|
-
</div>
|
|
608
|
-
<p class="profile__handle">@${escapeHtml(builder.github_login || 'unknown')}</p>
|
|
609
|
-
<div class="profile__stats">
|
|
610
|
-
<span>Member since ${fmtDate(builder.created_at)}</span>
|
|
611
|
-
<span title="${escapeHtml(karmaTooltip)}">${karmaTrend.replace(' in 30d', ' karma in 30d')}</span>
|
|
612
|
-
${rollPlace ? `<span title="Your place on the leaderboard (top ${rollPlace.total}), ranked by ${curLabel()}.">#${rollPlace.place} on the leaderboard</span>` : ''}
|
|
613
|
-
${rolePlaceText(rollPlace) ? `<span title="Your place among the ${rollPlace.role.total} builders shown as ${ROLE_AS[rollPlace.role.craft]}, ranked by the ${curLabel()} that role earned.">${rolePlaceText(rollPlace)}</span>` : ''}
|
|
614
|
-
</div>
|
|
615
|
-
<div class="profile__disciplines">
|
|
616
|
-
<span class="profile__disc-label">Preferred work:</span>
|
|
617
|
-
<span class="profile__disc-value">${prefs.length ? escapeHtml(prefs.join(', ')) : PREFS_EMPTY}</span>
|
|
618
|
-
<a class="profile__disc-edit" href="/settings">Change in Settings →</a>
|
|
619
|
-
</div>
|
|
620
|
-
</div>
|
|
621
|
-
<div class="profile__credits">
|
|
622
|
-
<span class="profile__credits-num" title="${escapeHtml(drachmaeTooltip)}">${fmtNum(builder.total_credits)}</span>
|
|
623
|
-
<span class="profile__credits-label">${(window.OTBBranding?.currencyLabel?.() ?? 'credits')}</span>
|
|
624
|
-
<span class="profile__karma" title="${escapeHtml(karmaTooltip)}">
|
|
625
|
-
<span class="profile__karma-num">${fmtNum(karmaTotal)}</span>
|
|
626
|
-
<span class="profile__karma-label">karma</span>
|
|
627
|
-
</span>
|
|
628
|
-
</div>
|
|
629
|
-
`;
|
|
630
444
|
const welcomeLine = $('#welcome-line');
|
|
631
445
|
if (welcomeLine) {
|
|
632
446
|
welcomeLine.textContent = `Welcome back, ${builder.display_name || builder.github_login || 'builder'}.`;
|
|
@@ -664,7 +478,7 @@
|
|
|
664
478
|
<div><dt>${escapeHtml(curLabel(true))}</dt><dd>${fmtNum(builder.total_credits)}</dd></div>
|
|
665
479
|
<div><dt>Karma</dt><dd>${fmtNum(karmaTotal)}</dd></div>
|
|
666
480
|
<div><dt>Shipped</dt><dd id="standing-shipped">${shipState.seen ? fmtNum(shipState.total) : '—'}</dd></div>
|
|
667
|
-
<div><dt>Leaderboard place</dt><dd>${place}</dd></div>
|
|
481
|
+
<div><dt>Leaderboard place</dt><dd>${place}${rolePlaceText(rollPlace) ? `<span title="Your place among the ${rollPlace.role.total} builders shown as ${ROLE_AS[rollPlace.role.craft]}, ranked by the ${curLabel()} that role earned."> · ${rolePlaceText(rollPlace)}</span>` : ''}</dd></div>
|
|
668
482
|
</dl>
|
|
669
483
|
<dl class="home-facts">
|
|
670
484
|
<div><dt>Preferred work</dt><dd>${prefs.length ? escapeHtml(prefs.join(', ')) : PREFS_EMPTY}</dd></div>
|
|
@@ -678,12 +492,10 @@
|
|
|
678
492
|
// hover it widens left to show the name while .hall-standing__more drops down.
|
|
679
493
|
const standingChip = document.getElementById('hall-standing');
|
|
680
494
|
if (standingChip && !ctx.newcomerGated) standingChip.hidden = false;
|
|
681
|
-
renderRankStanding(builder);
|
|
682
495
|
renderSystemNotices({ rebaseTasks, need, actionNeeds, cost });
|
|
683
496
|
}
|
|
684
497
|
|
|
685
|
-
// The ONE string for "no preferred work chosen" — the mock's copy
|
|
686
|
-
// Standing surfaces (the Home card and the full view), so they cannot drift.
|
|
498
|
+
// The ONE string for "no preferred work chosen" — the mock's copy.
|
|
687
499
|
const PREFS_EMPTY = 'Not set yet';
|
|
688
500
|
|
|
689
501
|
const ordinal = (n) => {
|
|
@@ -695,8 +507,7 @@
|
|
|
695
507
|
// task 1003464: the three system notices as inbox LINES on Home (the
|
|
696
508
|
// placement rule in modules/hall-ui/CLAUDE.md: an action awaiting the builder
|
|
697
509
|
// is a line item in Needs-attention, never a card). Only what awaits the
|
|
698
|
-
// builder lands here — a "we cover it" note is information,
|
|
699
|
-
// full Standing view with the other two, where the banners still render.
|
|
510
|
+
// builder lands here — a "we cover it" note is information, not an action.
|
|
700
511
|
function renderSystemNotices({ rebaseTasks, need, actionNeeds, cost }) {
|
|
701
512
|
const box = document.getElementById('attention-system');
|
|
702
513
|
if (!box) return;
|
|
@@ -755,579 +566,22 @@
|
|
|
755
566
|
h1.textContent = `${greetingWord(new Date().getHours())}, ${builder.display_name || builder.github_login || 'builder'}`;
|
|
756
567
|
}
|
|
757
568
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
'first_claim',
|
|
763
|
-
'ship_three',
|
|
764
|
-
'graduated',
|
|
765
|
-
];
|
|
766
|
-
|
|
767
|
-
function stageIndex(stageId) {
|
|
768
|
-
const i = ONBOARDING_STAGE_ORDER.indexOf(stageId);
|
|
769
|
-
return i === -1 ? 0 : i;
|
|
770
|
-
}
|
|
771
|
-
function welcomeDismissalKey(builderId) {
|
|
772
|
-
return `builders:welcome-dismissed:${builderId}`;
|
|
773
|
-
}
|
|
774
|
-
function readDismissedStageIndex(builderId) {
|
|
775
|
-
try {
|
|
776
|
-
const raw = window.localStorage.getItem(welcomeDismissalKey(builderId));
|
|
777
|
-
if (raw === null) return null;
|
|
778
|
-
const n = Number(raw);
|
|
779
|
-
return Number.isInteger(n) ? n : null;
|
|
780
|
-
} catch (_) {
|
|
781
|
-
// localStorage can throw in private-browsing modes. Treat as "never
|
|
782
|
-
// dismissed" — the panel renders, which is the right fail-open
|
|
783
|
-
// behaviour for an onboarding affordance.
|
|
784
|
-
return null;
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
function renderWelcomeCore(builder, onboarding) {
|
|
788
|
-
const scroll = $('#welcome-scroll');
|
|
789
|
-
const body = $('#welcome-body');
|
|
790
|
-
if (!scroll || !body) return;
|
|
791
|
-
|
|
792
|
-
// No builder yet (unauth landing) → hide and exit.
|
|
793
|
-
if (!builder || !builder.id) { scroll.hidden = true; return; }
|
|
794
|
-
|
|
795
|
-
// Graduated → hide entirely. The checklist below will also hide itself.
|
|
796
|
-
if (onboarding && onboarding.graduated) { scroll.hidden = true; return; }
|
|
797
|
-
|
|
798
|
-
// Compute current stage index. When the /me payload didn't carry an
|
|
799
|
-
// onboarding object (legacy), default to the first stage so the panel
|
|
800
|
-
// still greets the builder rather than vanishing silently.
|
|
801
|
-
const currentStageId = (onboarding && onboarding.current_stage) || ONBOARDING_STAGE_ORDER[0];
|
|
802
|
-
const currentIdx = stageIndex(currentStageId);
|
|
803
|
-
|
|
804
|
-
// Dismissal check. Reappears on stage regression (currentIdx < dismissed).
|
|
805
|
-
const dismissedAt = readDismissedStageIndex(builder.id);
|
|
806
|
-
if (dismissedAt !== null && currentIdx >= dismissedAt) {
|
|
807
|
-
scroll.hidden = true;
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
const rankBadge = renderRankBadge(normalizeRank(builder.rank), { tip: true });
|
|
812
|
-
const greeting = `Welcome, ${escapeHtml(builder.display_name || builder.github_login || 'builder')}.`;
|
|
813
|
-
|
|
814
|
-
// #777: the welcome panel shows ONLY setup — the steps that get a newcomer
|
|
815
|
-
// from "signed into the hall" to a working `claude` session on their own
|
|
816
|
-
// machine. The "what to do next" arc lives in the Path checklist below — not
|
|
817
|
-
// here.
|
|
818
|
-
// The #support pointer assumes the Discord module; drop it when discord is off.
|
|
819
|
-
const supportFoot = moduleOn('discord')
|
|
820
|
-
? `Stuck on any step? <a href="${API}/auth/discord/start">Join our Discord</a> and ask in <strong>#support</strong>.`
|
|
821
|
-
: `Stuck on any step? See the project's README and docs.`;
|
|
822
|
-
const setupSteps = `
|
|
823
|
-
<p class="setup__intro">Build from your own machine — get a copy of the project, sign in once, then work in Claude.</p>
|
|
824
|
-
<ol class="setup__steps">
|
|
825
|
-
<li><strong>Clone the repository</strong> and install dependencies (<code>npm install</code>).</li>
|
|
826
|
-
<li><strong>Run <code>npx bongos setup</code></strong> in that folder to sign this machine in.</li>
|
|
827
|
-
<li><strong><a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Download Claude</a></strong> (or use the Claude Code CLI), open the project, and run <code>/builder-start</code> to claim your first task.</li>
|
|
828
|
-
</ol>
|
|
829
|
-
`;
|
|
830
|
-
// task 469: a "Set up your machine / get the code" door. The steps above
|
|
831
|
-
// get you a session running; this section answers the separate
|
|
832
|
-
// question a first-timer always has — "where does the code come from, and
|
|
833
|
-
// how do I get it onto my own laptop?". It's the hall mirror of the primer's
|
|
834
|
-
// "Step 0 — get the project onto your machine". Collapsed by default so it
|
|
835
|
-
// doesn't crowd the recommended path; the clone block is click-to-copy
|
|
836
|
-
// (copy-on-click.js picks up .welcome__paste). Sign-in is `npx bongos setup`
|
|
837
|
-
// (bin/bongos.js → scripts/bongos/setup.js), one line on every OS — bare
|
|
838
|
-
// `bongos` is not on a fresh clone's PATH, `npx` finds the repo's own bin.
|
|
839
|
-
// The clone commands come from the instance's own repo coords (loaded from
|
|
840
|
-
// GET /api/bongos/instance), not a hardcoded repo, so a fresh instance's newcomer
|
|
841
|
-
// clones ITS project (ADR 0062 §3, task 1705). Falls back to generic guidance
|
|
842
|
-
// until the coords load (or if they're unavailable).
|
|
843
|
-
const cloneBlock = ctx.instanceRepo
|
|
844
|
-
? `git clone ${escapeHtml(ctx.instanceRepo.url)}.git\ncd ${escapeHtml(ctx.instanceRepo.repo)}\nnpm install\nnpx bongos setup`
|
|
845
|
-
: `# clone your instance's repository, cd into it, then run:\nnpm install\nnpx bongos setup`;
|
|
846
|
-
const getCode = `
|
|
847
|
-
<details class="setup__getcode">
|
|
848
|
-
<summary class="setup__getcode-summary">Set up your machine / get the code</summary>
|
|
849
|
-
<div class="setup__getcode-body">
|
|
850
|
-
<p class="setup__hint">First install <a href="https://git-scm.com/downloads" target="_blank" rel="noopener noreferrer">Git</a>,
|
|
851
|
-
<a href="https://nodejs.org" target="_blank" rel="noopener noreferrer">Node</a> (version 22 or newer)
|
|
852
|
-
and <a href="https://claude.com/download" target="_blank" rel="noopener noreferrer">Claude Code</a> — that's all the tooling you need up front.</p>
|
|
853
|
-
<p class="setup__getcode-lede">Then copy the project onto your machine, install its dependencies, and sign in:</p>
|
|
854
|
-
<code class="welcome__paste">${cloneBlock}</code>
|
|
855
|
-
<p class="setup__hint">The same commands work on Mac, Windows and Linux. New to the steps? The
|
|
856
|
-
<a href="/primer">primer</a> walks the whole path in plain language.</p>
|
|
857
|
-
</div>
|
|
858
|
-
</details>`;
|
|
859
|
-
body.innerHTML = `
|
|
860
|
-
<div class="welcome__lede">${greeting} Your standing is ${rankBadge}.</div>
|
|
861
|
-
<div class="setup">
|
|
862
|
-
${setupSteps}
|
|
863
|
-
${getCode}
|
|
864
|
-
<p class="setup__foot">${supportFoot}</p>
|
|
865
|
-
</div>
|
|
866
|
-
`;
|
|
867
|
-
// Remember the stage we rendered for, so the dismiss handler can record it.
|
|
868
|
-
scroll.dataset.stageIdx = String(currentIdx);
|
|
869
|
-
scroll.hidden = false;
|
|
870
|
-
}
|
|
871
|
-
function renderOnboarding(onboarding, discord) {
|
|
872
|
-
const scroll = $('#onboarding-scroll');
|
|
873
|
-
const list = $('#onboarding-list');
|
|
874
|
-
if (!scroll || !list) return;
|
|
875
|
-
if (!onboarding) {
|
|
876
|
-
scroll.hidden = true;
|
|
877
|
-
return;
|
|
878
|
-
}
|
|
879
|
-
// Hide the panel once the builder has graduated — no point lingering.
|
|
880
|
-
if (onboarding.graduated) {
|
|
881
|
-
scroll.hidden = true;
|
|
882
|
-
return;
|
|
883
|
-
}
|
|
884
|
-
// task 2169 (banner budget: one at a time): while the welcome panel is
|
|
885
|
-
// showing, the checklist waits its turn — it appears when the welcome is
|
|
886
|
-
// dismissed or outgrown. renderWelcome runs first in the same loadAll pass
|
|
887
|
-
// (and every SSE nudge re-runs both), so this stays current.
|
|
888
|
-
const welcomeEl = document.getElementById('welcome-scroll');
|
|
889
|
-
if (welcomeEl && !welcomeEl.hidden) {
|
|
890
|
-
scroll.hidden = true;
|
|
891
|
-
return;
|
|
892
|
-
}
|
|
893
|
-
scroll.hidden = false;
|
|
894
|
-
|
|
895
|
-
const discordConfigured = !!(discord && discord.configured);
|
|
896
|
-
const stages = Array.isArray(onboarding.stages) ? onboarding.stages : [];
|
|
897
|
-
if (stages.length === 0) {
|
|
898
|
-
list.innerHTML = `<li class="onboarding-item onboarding-item--placeholder">No onboarding steps recorded yet.</li>`;
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// task 470: a static, INFORMATIONAL lead-in step that sits AHEAD of the
|
|
903
|
-
// data-driven steps — the genuine first action into the city is "obtain the
|
|
904
|
-
// repo" (clone it onto your machine).
|
|
905
|
-
// It mirrors onboarding-state.js PRE_AUTH_STEP. It is NOT one of the tracked
|
|
906
|
-
// checklist steps (it carries no engine stage, never gates graduation, and
|
|
907
|
-
// isn't counted in the "N of M steps cleared" status line) — it just makes
|
|
908
|
-
// the checklist a newcomer sees acknowledge the very first real action,
|
|
909
|
-
// which the auto-ticking steps below silently assumed already happened.
|
|
910
|
-
const preAuthLead = `
|
|
911
|
-
<li class="onboarding-item onboarding-item--informational" data-stage="obtain_repo">
|
|
912
|
-
<span class="onboarding-item__mark" aria-hidden="true">›</span>
|
|
913
|
-
<div class="onboarding-item__body">
|
|
914
|
-
<p class="onboarding-item__label">First — obtain the repo</p>
|
|
915
|
-
<p class="onboarding-item__hint">Clone the project to your machine and run <code>npm install</code> in it. The build commands live inside the repo, so this comes first. The remaining steps below are tracked automatically.</p>
|
|
916
|
-
</div>
|
|
917
|
-
</li>`;
|
|
918
|
-
|
|
919
|
-
list.innerHTML = preAuthLead + stages.map((s) => {
|
|
920
|
-
const status = s.cleared ? 'cleared' : (s.is_current ? 'current' : 'locked');
|
|
921
|
-
const mark = s.cleared ? '✓' : (s.is_current ? '◆' : '○');
|
|
922
|
-
const hint = s.hint ? `<p class="onboarding-item__hint">${escapeHtml(s.hint)}</p>` : '';
|
|
923
|
-
// #766: the terminal 'ship_three' stage carries a live "N of 3" counter
|
|
924
|
-
// in its `detail` field — render it as a small progress line so the
|
|
925
|
-
// builder watches themselves climb toward Thetes with each ship.
|
|
926
|
-
const detail = s.detail
|
|
927
|
-
? `<p class="onboarding-item__detail">${escapeHtml(s.detail)}</p>`
|
|
928
|
-
: '';
|
|
929
|
-
// Per-step affordance, shown ONLY while the step is the current, uncleared
|
|
930
|
-
// one — every step now behaves uniformly (Lars, #833): locked/future steps
|
|
931
|
-
// and cleared steps carry no control, so the path reads as one action at a
|
|
932
|
-
// time. (The Discord step already did this; the craft picker and the
|
|
933
|
-
// See-the-works link used to render on locked/cleared steps too.) Every
|
|
934
|
-
// step auto-detects its own completion server-side (#777): join_discord
|
|
935
|
-
// clears on the Discord link, cli_setup once the Bongos CLI is authenticated,
|
|
936
|
-
// ship_three at the third ship — no manual ticking.
|
|
937
|
-
let action = '';
|
|
938
|
-
if (s.is_current && !s.cleared) {
|
|
939
|
-
if (s.id === 'pick_disciplines') {
|
|
940
|
-
// V3 (2026-05-30): inline discipline picker → PATCH /me/disciplines.
|
|
941
|
-
// Multi-select: each button is a toggle reflecting the saved prefs, so
|
|
942
|
-
// a builder can pick engineer, artist, ideator, or any mix while the
|
|
943
|
-
// step is current. (Settings can revise crafts later for graduated citizens.)
|
|
944
|
-
const prefs = (ctx.lastMeState && ctx.lastMeState.builder && Array.isArray(ctx.lastMeState.builder.preferred_disciplines))
|
|
945
|
-
? ctx.lastMeState.builder.preferred_disciplines : [];
|
|
946
|
-
const discBtn = (value, label) => {
|
|
947
|
-
const on = prefs.includes(value);
|
|
948
|
-
return `<button type="button" class="btn-ghost onboarding-item__disc${on ? ' onboarding-item__disc--on' : ''}" data-disc="${escapeHtml(value)}" aria-pressed="${on}">${escapeHtml(label)}</button>`;
|
|
949
|
-
};
|
|
950
|
-
// The offered crafts come from the server (task 1003487): window.__MODULES__
|
|
951
|
-
// .disciplines IS enabledDisciplines(), the same list the PATCH validates
|
|
952
|
-
// against, so this can never render a button the API would reject. Was a
|
|
953
|
-
// hardcoded triple with a moduleOn('art-pipeline') branch for 'artist', so
|
|
954
|
-
// a module-contributed craft (ui-design → ui) never appeared here at all.
|
|
955
|
-
action = `
|
|
956
|
-
<div class="onboarding-item__action onboarding-item__disc-pick">
|
|
957
|
-
${OTB.disciplines().map((d) => discBtn(d, OTB.roleLabel(d))).join('')}
|
|
958
|
-
</div>`;
|
|
959
|
-
} else if (s.id === 'ship_three') {
|
|
960
|
-
action = `
|
|
961
|
-
<div class="onboarding-item__action">
|
|
962
|
-
<a class="btn-ghost" href="/work">See the work board →</a>
|
|
963
|
-
</div>`;
|
|
964
|
-
} else if (s.id === 'join_discord' && discordConfigured) {
|
|
965
|
-
// #777: the Connect button runs the existing OAuth flow (GET
|
|
966
|
-
// /auth/discord/start → auto-join + rank role). Full-page nav so the
|
|
967
|
-
// server can set the CSRF state cookie before redirecting to Discord.
|
|
968
|
-
// The step then clears itself once the link lands (discord.linked) —
|
|
969
|
-
// no manual mark. Same control as /builders/settings.
|
|
970
|
-
action = `
|
|
971
|
-
<div class="onboarding-item__action">
|
|
972
|
-
<a class="btn-ghost onboarding-item__discord" href="${API}/auth/discord/start">Connect Discord & join the server</a>
|
|
973
|
-
</div>`;
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
return `
|
|
977
|
-
<li class="onboarding-item onboarding-item--${status}" data-stage="${escapeHtml(s.id)}">
|
|
978
|
-
<span class="onboarding-item__mark" aria-hidden="true">${mark}</span>
|
|
979
|
-
<div class="onboarding-item__body">
|
|
980
|
-
<p class="onboarding-item__label">${escapeHtml(s.label)}</p>
|
|
981
|
-
${hint}
|
|
982
|
-
${detail}
|
|
983
|
-
</div>
|
|
984
|
-
${action}
|
|
985
|
-
</li>
|
|
986
|
-
`;
|
|
987
|
-
}).join('');
|
|
988
|
-
|
|
989
|
-
const cleared = onboarding.progress?.cleared ?? 0;
|
|
990
|
-
const total = onboarding.progress?.total ?? stages.length;
|
|
991
|
-
const status = $('#onboarding-status');
|
|
992
|
-
if (status) {
|
|
993
|
-
status.hidden = false;
|
|
994
|
-
status.textContent = `${cleared} of ${total} steps cleared.`;
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
// task 1004854: Home shows the newest few ships and a count, so loadAll (on
|
|
998
|
-
// every load AND every live refresh) asks for HOME_SHIPS_LIMIT rows plus the
|
|
999
|
-
// server's `total`. Only the Accomplishments ledger — its window/version
|
|
1000
|
-
// filters, credits sort and pager work over the whole record — needs every
|
|
1001
|
-
// ship, so `all` is fetched when that view is open, and fetched again only
|
|
1002
|
-
// when the newest ships or the count have moved since (`recentSig`).
|
|
569
|
+
// task 1004854: Home asks for only the newest HOME_SHIPS_LIMIT ships plus the
|
|
570
|
+
// server's `total` — on every load AND every live refresh. The count rides the
|
|
571
|
+
// Standing chip's card. The whole ledger was Accomplishments', which folded into
|
|
572
|
+
// the Profile page in task 1005050 (its Works ledger reads its own record).
|
|
1003
573
|
const HOME_SHIPS_LIMIT = 5;
|
|
1004
|
-
const shipState = { recent: [], total: 0,
|
|
574
|
+
const shipState = { recent: [], total: 0, seen: false };
|
|
1005
575
|
function setRecentShips(payload) {
|
|
1006
576
|
shipState.recent = (payload && payload.ships) || [];
|
|
1007
577
|
shipState.total = Number.isFinite(payload && payload.total) ? payload.total : shipState.recent.length;
|
|
1008
|
-
shipState.
|
|
1009
|
-
shipState.recent.map((s) => [s.id, s.title, s.value_summary, s.credits_awarded, s.visual_url])]);
|
|
1010
|
-
if (!shipState.seen) {
|
|
1011
|
-
// Opening Accomplishments later is what asks for the ledger.
|
|
1012
|
-
document.addEventListener('otbshell:view', (e) => {
|
|
1013
|
-
if (e.detail && e.detail.view === 'standing') loadShipLedger();
|
|
1014
|
-
});
|
|
1015
|
-
shipState.seen = true;
|
|
1016
|
-
}
|
|
578
|
+
shipState.seen = true;
|
|
1017
579
|
renderRecentShips();
|
|
1018
|
-
if (window.OTBShell && window.OTBShell.currentView && window.OTBShell.currentView() === 'standing') loadShipLedger();
|
|
1019
|
-
}
|
|
1020
|
-
function loadShipLedger() {
|
|
1021
|
-
// Never before loadAll has run (a signed-out page must not fetch), never twice at once.
|
|
1022
|
-
if (!shipState.seen || shipState.ledgerSig === shipState.recentSig) return Promise.resolve();
|
|
1023
|
-
if (shipState.ledgerLoading) return shipState.ledgerLoading;
|
|
1024
|
-
const sig = shipState.recentSig;
|
|
1025
|
-
shipState.ledgerLoading = (async () => {
|
|
1026
|
-
try {
|
|
1027
|
-
const data = await getJSON(`${API}/me/recent-ships?limit=all`);
|
|
1028
|
-
shipState.all = (data && data.ships) || [];
|
|
1029
|
-
shipState.ledgerSig = sig;
|
|
1030
|
-
populateShipVersionFilter();
|
|
1031
|
-
renderRecentShips();
|
|
1032
|
-
} catch (err) {
|
|
1033
|
-
console.warn('[builders] ship ledger failed', err && err.message ? err.message : err);
|
|
1034
|
-
const tbody = $('#recent-ships tbody');
|
|
1035
|
-
if (tbody) tbody.innerHTML = emptyRow(4, 'Your work history could not be loaded.');
|
|
1036
|
-
} finally {
|
|
1037
|
-
shipState.ledgerLoading = null;
|
|
1038
|
-
}
|
|
1039
|
-
})();
|
|
1040
|
-
return shipState.ledgerLoading;
|
|
1041
|
-
}
|
|
1042
|
-
// The ledger's version filter: the catalog versions this builder has shipped
|
|
1043
|
-
// to, so it never offers an empty option. Rebuilt (not appended to) on each
|
|
1044
|
-
// call, keeping the reader's pick, so a live refresh cannot duplicate options.
|
|
1045
|
-
function populateShipVersionFilter(versions) {
|
|
1046
|
-
if (versions) shipState.versions = versions;
|
|
1047
|
-
const sel = $('#f-ships-version');
|
|
1048
|
-
if (!sel || !shipState.all) return;
|
|
1049
|
-
const picked = sel.value;
|
|
1050
|
-
const seen = new Set(shipState.all.map((s) => s.version_id).filter(Boolean));
|
|
1051
|
-
while (sel.options.length > 1) sel.remove(1);
|
|
1052
|
-
for (const v of shipState.versions) {
|
|
1053
|
-
if (!seen.has(v.id)) continue;
|
|
1054
|
-
const opt = document.createElement('option');
|
|
1055
|
-
opt.value = v.id;
|
|
1056
|
-
opt.textContent = v.id;
|
|
1057
|
-
if (v.name && v.name !== v.id) opt.title = v.name;
|
|
1058
|
-
sel.appendChild(opt);
|
|
1059
|
-
}
|
|
1060
|
-
sel.value = [...sel.options].some((o) => o.value === picked) ? picked : '';
|
|
1061
|
-
}
|
|
1062
|
-
const shipsPager = window.OTBKit.makePager('ships', 'otb.standing.shipsPerPage', { defaultPerPage: 10 });
|
|
1063
|
-
function filterShips(ships) {
|
|
1064
|
-
const win = $('#f-ships-window')?.value || 'all';
|
|
1065
|
-
const ver = $('#f-ships-version')?.value || '';
|
|
1066
|
-
let cutoff = null;
|
|
1067
|
-
if (win !== 'all') {
|
|
1068
|
-
const days = Number(win);
|
|
1069
|
-
if (Number.isFinite(days) && days > 0) {
|
|
1070
|
-
cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
return ships.filter((s) => {
|
|
1074
|
-
if (ver && s.version_id !== ver) return false;
|
|
1075
|
-
if (cutoff !== null) {
|
|
1076
|
-
const t = s.shipped_at ? new Date(s.shipped_at).getTime() : 0;
|
|
1077
|
-
if (!t || t < cutoff) return false;
|
|
1078
|
-
}
|
|
1079
|
-
return true;
|
|
1080
|
-
});
|
|
1081
|
-
}
|
|
1082
|
-
function shipCredit(s) {
|
|
1083
|
-
return Number(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward) || 0;
|
|
1084
|
-
}
|
|
1085
|
-
function sortShips(ships, mode) {
|
|
1086
|
-
const copy = ships.slice();
|
|
1087
|
-
if (mode === 'oldest') {
|
|
1088
|
-
copy.sort((a, b) => new Date(a.shipped_at || 0) - new Date(b.shipped_at || 0));
|
|
1089
|
-
} else if (mode === 'drachmae-high') {
|
|
1090
|
-
copy.sort((a, b) => shipCredit(b) - shipCredit(a));
|
|
1091
|
-
} else if (mode === 'drachmae-low') {
|
|
1092
|
-
copy.sort((a, b) => shipCredit(a) - shipCredit(b));
|
|
1093
|
-
} else {
|
|
1094
|
-
// default: most recent first
|
|
1095
|
-
copy.sort((a, b) => new Date(b.shipped_at || 0) - new Date(a.shipped_at || 0));
|
|
1096
|
-
}
|
|
1097
|
-
return copy;
|
|
1098
580
|
}
|
|
1099
581
|
function renderRecentShips() {
|
|
1100
|
-
const tbody = $('#recent-ships tbody');
|
|
1101
|
-
const countEl = $('#ships-count');
|
|
1102
|
-
const pagerEl = $('#ships-pager');
|
|
1103
|
-
|
|
1104
582
|
// task 1004774: the Standing hover card carries the shipped count.
|
|
1105
583
|
const standingShipped = document.getElementById('standing-shipped');
|
|
1106
584
|
if (standingShipped) standingShipped.textContent = fmtNum(shipState.total);
|
|
1107
|
-
|
|
1108
|
-
// The ledger waits for its own fetch (loadShipLedger); until then it keeps its placeholder.
|
|
1109
|
-
const all = shipState.all;
|
|
1110
|
-
if (!all || !tbody) return;
|
|
1111
|
-
const filtered = filterShips(all);
|
|
1112
|
-
const mode = $('#f-ships-sort')?.value || 'recent';
|
|
1113
|
-
const sorted = sortShips(filtered, mode);
|
|
1114
|
-
|
|
1115
|
-
if (countEl) {
|
|
1116
|
-
const total = all.length;
|
|
1117
|
-
const shown = sorted.length;
|
|
1118
|
-
countEl.textContent = total
|
|
1119
|
-
? (shown === total
|
|
1120
|
-
? `${fmtNum(total)} work${total === 1 ? '' : 's'} shipped`
|
|
1121
|
-
: `${fmtNum(shown)} of ${fmtNum(total)} in view`)
|
|
1122
|
-
: '';
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
if (!sorted.length) {
|
|
1126
|
-
tbody.innerHTML = all.length
|
|
1127
|
-
? emptyRow(4, 'No work matches these filters. Widen the window or pick another version.')
|
|
1128
|
-
: emptyRow(4, 'Nothing shipped yet. Claim a task on the Work board to get started.');
|
|
1129
|
-
if (pagerEl) pagerEl.hidden = true;
|
|
1130
|
-
return;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
const slice = window.OTBKit.paginate(sorted, shipsPager);
|
|
1134
|
-
|
|
1135
|
-
tbody.innerHTML = slice.map((s) => `
|
|
1136
|
-
<tr>
|
|
1137
|
-
<td class="ledger__num">#${s.id}</td>
|
|
1138
|
-
<td>
|
|
1139
|
-
<div>${escapeHtml(s.title)}</div>
|
|
1140
|
-
${s.value_summary ? `<div style="color:var(--ink-soft);font-size:0.88rem;font-style:italic;margin-top:2px">${escapeHtml(s.value_summary)}</div>` : ''}
|
|
1141
|
-
${s.visual_url && window.OTBVisual ? `<div style="margin-top:6px">${window.OTBVisual.shipVisualThumbHtml(s)}</div>` : ''}
|
|
1142
|
-
</td>
|
|
1143
|
-
<td class="ledger__drachmae">${fmtNum(window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward)}</td>
|
|
1144
|
-
<td class="ledger__date">${fmtDate(s.shipped_at)}</td>
|
|
1145
|
-
</tr>
|
|
1146
|
-
`).join('');
|
|
1147
|
-
}
|
|
1148
|
-
// No `isArchon` here: the curve's scope was rank-gated until task 1004005, and
|
|
1149
|
-
// the field is gone rather than left write-only — a state flag nothing reads is
|
|
1150
|
-
// how a removed gate quietly grows back.
|
|
1151
|
-
//
|
|
1152
|
-
// `kind` is the active filter (null = all work). `kinds` is the picker's source
|
|
1153
|
-
// — the UNFILTERED fingerprint, held separately because a filtered payload's
|
|
1154
|
-
// top_kinds would otherwise shrink to the one kind selected and strand the
|
|
1155
|
-
// reader there with no way back.
|
|
1156
|
-
const analyticsState = { builderId: null, scope: 'self', kind: null, kinds: [], self: null, eco: null };
|
|
1157
|
-
// The line drawing moved to OTBKit.sparklineHtml (task 1003445) — it was one
|
|
1158
|
-
// of four chart implementations in this surface. This wrapper keeps the
|
|
1159
|
-
// Curve's own default colour, which is NOT the kit's: these four charts each
|
|
1160
|
-
// spend a different accent, and --accent-deep is the first one's.
|
|
1161
|
-
function sparkline(values, opts = {}) {
|
|
1162
|
-
return window.OTBKit.sparklineHtml(values, { color: 'var(--accent-deep)', ...opts });
|
|
1163
|
-
}
|
|
1164
|
-
function statCard(label, value, sub) {
|
|
1165
|
-
return `<div class="curve__stat">
|
|
1166
|
-
<div class="curve__stat-value">${escapeHtml(String(value))}</div>
|
|
1167
|
-
<div class="curve__stat-label">${escapeHtml(label)}</div>
|
|
1168
|
-
${sub ? `<div class="curve__stat-sub">${escapeHtml(sub)}</div>` : ''}
|
|
1169
|
-
</div>`;
|
|
1170
|
-
}
|
|
1171
|
-
// `overlay` is the ecosystem benchmark for this same chart, or null. When it is
|
|
1172
|
-
// present the card also carries a legend — two lines with no key cannot be
|
|
1173
|
-
// read. The legend is built here rather than inside sparklineHtml because the
|
|
1174
|
-
// NAMES are this page's ("You" / "All builders"), not the kit's.
|
|
1175
|
-
function chartCard(title, values, color, footer, overlay) {
|
|
1176
|
-
const has = Array.isArray(values) && values.length > 0;
|
|
1177
|
-
const hasOverlay = Array.isArray(overlay) && overlay.length > 0;
|
|
1178
|
-
const body = has
|
|
1179
|
-
? sparkline(values, hasOverlay ? { color, overlay, overlayLabel: 'all builders' } : { color })
|
|
1180
|
-
: '';
|
|
1181
|
-
const legend = has && hasOverlay
|
|
1182
|
-
? window.OTBKit.chartLegendHtml([
|
|
1183
|
-
{ label: 'You', color },
|
|
1184
|
-
{ label: 'All builders', color: 'var(--ink-soft)' },
|
|
1185
|
-
])
|
|
1186
|
-
: '';
|
|
1187
|
-
return window.OTBKit.chartCardHtml(title, body ? body + legend : '', footer);
|
|
1188
|
-
}
|
|
1189
|
-
// The kind picker (task 1004005) — "how am I doing at THIS kind of work".
|
|
1190
|
-
//
|
|
1191
|
-
// Built from analyticsState.kinds, the UNFILTERED fingerprint captured on the
|
|
1192
|
-
// first load, NOT from the current payload's top_kinds. Under a filter the
|
|
1193
|
-
// payload would list only the selected kind, so a picker built from it would
|
|
1194
|
-
// lose every other option the moment one was chosen — the reader could narrow
|
|
1195
|
-
// once and never move again.
|
|
1196
|
-
//
|
|
1197
|
-
// A <select> rather than a row of chips: the kind list is open-ended (it is a
|
|
1198
|
-
// free-text column with an 'unspecified' bucket), so the control has to cope
|
|
1199
|
-
// with more options than fit on a line, and a native select is also the one
|
|
1200
|
-
// that arrives keyboard- and screen-reader-correct with no work.
|
|
1201
|
-
function kindFilterHtml() {
|
|
1202
|
-
const kinds = Array.isArray(analyticsState.kinds) ? analyticsState.kinds : [];
|
|
1203
|
-
if (kinds.length < 2) return ''; // one kind of work is not a choice
|
|
1204
|
-
const cur = analyticsState.kind;
|
|
1205
|
-
const opt = (value, label, n) => {
|
|
1206
|
-
const sel = (value === null ? cur === null : cur === value) ? ' selected' : '';
|
|
1207
|
-
const count = n === undefined ? '' : ` (${n})`;
|
|
1208
|
-
return `<option value="${escapeHtml(value === null ? '' : value)}"${sel}>${escapeHtml(label)}${escapeHtml(count)}</option>`;
|
|
1209
|
-
};
|
|
1210
|
-
return `<span class="curve__filter">`
|
|
1211
|
-
+ `<label class="curve__filter-label" for="curve-kind">Kind of work</label>`
|
|
1212
|
-
+ `<select id="curve-kind" class="curve__filter-select">`
|
|
1213
|
-
+ opt(null, 'All work')
|
|
1214
|
-
+ kinds.map((k) => opt(k.kind, k.kind, k.n)).join('')
|
|
1215
|
-
+ `</select></span>`;
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
function renderCurveBody() {
|
|
1219
|
-
const headlineEl = $('#curve-headline');
|
|
1220
|
-
const gridEl = $('#curve-grid');
|
|
1221
|
-
const kindsEl = $('#curve-kinds');
|
|
1222
|
-
const emptyEl = $('#curve-empty');
|
|
1223
|
-
const noteEl = $('#curve-scope-note');
|
|
1224
|
-
if (!headlineEl || !gridEl || !kindsEl) return;
|
|
1225
|
-
|
|
1226
|
-
const isEco = analyticsState.scope === 'eco';
|
|
1227
|
-
const data = isEco ? analyticsState.eco : analyticsState.self;
|
|
1228
|
-
|
|
1229
|
-
// The controls — scope toggle and kind filter.
|
|
1230
|
-
//
|
|
1231
|
-
// WRITTEN BEFORE EVERY EARLY RETURN BELOW, and that ordering is the whole
|
|
1232
|
-
// reason they are here. A kind with nothing shipped in the window is an
|
|
1233
|
-
// ordinary, reachable state, and it takes the `totalShipped === 0` branch —
|
|
1234
|
-
// which blanks the headline, the grid and the bars. If the filter that
|
|
1235
|
-
// caused the empty view were drawn after that return, the reader would be
|
|
1236
|
-
// left looking at "no data" with no control on screen to undo it, and the
|
|
1237
|
-
// only way out would be a page reload.
|
|
1238
|
-
//
|
|
1239
|
-
// The scope toggle is no longer Archon-gated (task 1004005): the feed behind
|
|
1240
|
-
// "All builders" is a public, identity-stripped aggregate, so the gate
|
|
1241
|
-
// withheld the comparison rather than protecting anything.
|
|
1242
|
-
if (noteEl) {
|
|
1243
|
-
noteEl.innerHTML =
|
|
1244
|
-
`<span class="curve__scope" role="group" aria-label="Analytics scope">` +
|
|
1245
|
-
`<button type="button" class="curve__scope-btn${!isEco ? ' is-active' : ''}" data-scope="self">You</button>` +
|
|
1246
|
-
`<button type="button" class="curve__scope-btn${isEco ? ' is-active' : ''}" data-scope="eco">All builders</button>` +
|
|
1247
|
-
`</span>` +
|
|
1248
|
-
kindFilterHtml();
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
if (!data) { gridEl.innerHTML = ''; headlineEl.innerHTML = ''; kindsEl.innerHTML = ''; return; }
|
|
1252
|
-
|
|
1253
|
-
// Under a kind filter the server cannot answer cost at all (cost_log has no
|
|
1254
|
-
// kind), and says so in `unavailable` rather than sending a misleading zero.
|
|
1255
|
-
const costUnavailable = Array.isArray(data.unavailable) && data.unavailable.includes('cost_trend');
|
|
1256
|
-
|
|
1257
|
-
const totalShipped = data.headline?.total_shipped || 0;
|
|
1258
|
-
if (totalShipped === 0) {
|
|
1259
|
-
if (emptyEl) emptyEl.style.display = '';
|
|
1260
|
-
headlineEl.innerHTML = ''; gridEl.innerHTML = ''; kindsEl.innerHTML = '';
|
|
1261
|
-
return;
|
|
1262
|
-
}
|
|
1263
|
-
if (emptyEl) emptyEl.style.display = 'none';
|
|
1264
|
-
|
|
1265
|
-
// Headline stat cards.
|
|
1266
|
-
const h = data.headline;
|
|
1267
|
-
const cards = [];
|
|
1268
|
-
cards.push(statCard(isEco ? 'Works shipped' : 'Works shipped', totalShipped, `last ${data.window_days}d`));
|
|
1269
|
-
if (isEco) {
|
|
1270
|
-
cards.push(statCard('Active builders', h.active_builders ?? 0));
|
|
1271
|
-
cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
|
|
1272
|
-
} else {
|
|
1273
|
-
// '—', never usd(null) → "$0.00". Zero is a measurement; this is the
|
|
1274
|
-
// absence of one, and printing a price here would be a false statement.
|
|
1275
|
-
cards.push(costUnavailable
|
|
1276
|
-
? statCard('Cost / work', '—', 'not tracked per kind')
|
|
1277
|
-
: statCard('Cost / work', usd(h.cost_per_task)));
|
|
1278
|
-
cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
|
|
1279
|
-
cards.push(statCard('Confirmation rate', pct(h.confirmation_rate), `${h.resolved_claims || 0} claims`));
|
|
1280
|
-
cards.push(statCard('Grade pass rate', pct(h.grade_pass_rate), `${h.graded_n || 0} graded`));
|
|
1281
|
-
}
|
|
1282
|
-
headlineEl.innerHTML = cards.join('');
|
|
1283
|
-
|
|
1284
|
-
// Sparkline charts.
|
|
1285
|
-
//
|
|
1286
|
-
// THE OVERLAY ONLY EVER RIDES ON THE 'self' VIEW, and only where the
|
|
1287
|
-
// ecosystem publishes the SAME series. Overlaying the benchmark on the
|
|
1288
|
-
// benchmark would draw one line twice; and the ecosystem feed carries only
|
|
1289
|
-
// ship-rate and duration (grade and cost have their own public feeds and are
|
|
1290
|
-
// not part of that payload), so those two are the only charts that CAN
|
|
1291
|
-
// compare. A chart with no counterpart simply draws one line, as before.
|
|
1292
|
-
const eco = (!isEco && analyticsState.eco) ? analyticsState.eco : null;
|
|
1293
|
-
const ecoOf = (rows, pick) => (eco && Array.isArray(eco[rows]) ? eco[rows].map(pick) : null);
|
|
1294
|
-
const charts = [];
|
|
1295
|
-
charts.push(chartCard('Works shipped / day', (data.ship_rate || []).map((r) => r.n), 'var(--accent-deep)',
|
|
1296
|
-
`${(data.ship_rate || []).reduce((s, r) => s + Number(r.n), 0)} over ${data.window_days}d`,
|
|
1297
|
-
ecoOf('ship_rate', (r) => r.n)));
|
|
1298
|
-
charts.push(chartCard('Time / work (min)', (data.duration_trend || []).map((r) => r.avg_actual), 'var(--sea)',
|
|
1299
|
-
'actual minutes per shipped work',
|
|
1300
|
-
ecoOf('duration_trend', (r) => r.avg_actual)));
|
|
1301
|
-
if (!isEco) {
|
|
1302
|
-
// COST IS NOT ANSWERABLE PER KIND — cost_log has no kind and reaches tasks
|
|
1303
|
-
// only through a mostly-NULL task_id, so the server returns cost_trend: []
|
|
1304
|
-
// and names it in `unavailable` rather than plotting a near-zero line that
|
|
1305
|
-
// would read as "this kind of work is cheap". Say so where the chart was.
|
|
1306
|
-
if (costUnavailable) {
|
|
1307
|
-
charts.push(window.OTBKit.chartCardHtml('Cost / work ($)', '',
|
|
1308
|
-
'', 'not tracked per kind of work — clear the filter to see cost'));
|
|
1309
|
-
} else {
|
|
1310
|
-
charts.push(chartCard('Cost / work ($)', (data.cost_trend || []).map((r) => r.per_work), 'var(--terracotta)',
|
|
1311
|
-
`total ${usd(h.total_cost_usd)}`));
|
|
1312
|
-
}
|
|
1313
|
-
// n counts quality-graded rows only (task 1002652); a day whose grades
|
|
1314
|
-
// were all outages has n=0 and must not plot as a 0% dip.
|
|
1315
|
-
charts.push(chartCard('Grade pass rate', (data.grade_trend || []).filter((r) => Number(r.n) > 0).map((r) => r.n_passed / r.n), 'var(--accent)',
|
|
1316
|
-
'share of work passing review'));
|
|
1317
|
-
}
|
|
1318
|
-
gridEl.innerHTML = charts.join('');
|
|
1319
|
-
|
|
1320
|
-
// Top kinds — horizontal bars.
|
|
1321
|
-
const kinds = (data.top_kinds || []).slice(0, 8);
|
|
1322
|
-
if (kinds.length) {
|
|
1323
|
-
kindsEl.innerHTML =
|
|
1324
|
-
`<div class="kit-chart__title">Kinds of work shipped</div>` +
|
|
1325
|
-
window.OTBKit.barSeriesHtml(
|
|
1326
|
-
kinds.map((k) => ({ label: k.kind, value: k.n })),
|
|
1327
|
-
);
|
|
1328
|
-
} else {
|
|
1329
|
-
kindsEl.innerHTML = '';
|
|
1330
|
-
}
|
|
1331
585
|
}
|
|
1332
586
|
// The Roll’s rows, kept ONLY for leaderboardPlace() below — the Standing
|
|
1333
587
|
// card’s "#N on the leaderboard" stat. sortBy/sortDir went to /roster with
|
|
@@ -1365,6 +619,6 @@
|
|
|
1365
619
|
}
|
|
1366
620
|
|
|
1367
621
|
|
|
1368
|
-
return {
|
|
622
|
+
return { lbState, mountHallWidgets, renderGreeting, renderLanding, renderSessionRefused, renderNav, renderProfile, renderRecentShips, HOME_SHIPS_LIMIT, setRecentShips, shipState };
|
|
1369
623
|
};
|
|
1370
624
|
})();
|