@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
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
// profile-own.js — the numbers only you see, on your own profile (task 1005050).
|
|
2
|
+
//
|
|
3
|
+
// Owner decision 2026-11-03 (idea 1001315, item 2): Accomplishments and Profile
|
|
4
|
+
// both showed your shipped work, karma and achievements, so they are one page.
|
|
5
|
+
// Profile already draws everything that is public; this draws the rest, the part
|
|
6
|
+
// that was only ever yours — where you stand on the rank ladder, how your work was
|
|
7
|
+
// reviewed, your karma feed, the peer-recognition votes, and your progress curve —
|
|
8
|
+
// into the profile's "Just you" screen, and ONLY when the profile is the viewer's
|
|
9
|
+
// own (profile.js calls render() after its self-detect; for anyone else's profile
|
|
10
|
+
// it is never called, and the screen and its rail key stay hidden).
|
|
11
|
+
//
|
|
12
|
+
// The renderers MOVED here from builders.js and hall-render.js with the
|
|
13
|
+
// Accomplishments view, rather than being copied: the index no longer draws any
|
|
14
|
+
// of it. Every read below is the viewer's own or public, and each route keeps its
|
|
15
|
+
// own gate; hiding the screen is presentation, not the wall.
|
|
16
|
+
(function () {
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const { escapeHtml, $, fmtNum } = window.OTB;
|
|
20
|
+
const fmtDate = (iso) => window.OTB.fmtDate(iso);
|
|
21
|
+
const rankLabel = (rank) => window.OTB.rankLabel(rank);
|
|
22
|
+
const curLabel = (caps) => {
|
|
23
|
+
const b = window.OTBBranding;
|
|
24
|
+
return (b && b.currencyLabel && b.currencyLabel(caps ? { caps: true } : undefined)) || (caps ? 'Credits' : 'credits');
|
|
25
|
+
};
|
|
26
|
+
// The ladder, pinned in tests/rank_tier_single_source.mjs.
|
|
27
|
+
const RANK_LADDER = ['xenos', 'thetes', 'metic', 'archon'];
|
|
28
|
+
const normalizeRank = (rank) => (RANK_LADDER.includes(rank) ? rank : 'xenos');
|
|
29
|
+
function renderRankBadge(rank) {
|
|
30
|
+
const variant = RANK_LADDER.includes(rank) ? `rank-badge--${rank}` : 'rank-badge--unknown';
|
|
31
|
+
return `<span class="rank-badge ${variant}" aria-label="Rank: ${escapeHtml(rankLabel(rank))}">${escapeHtml(rankLabel(rank))}</span>`;
|
|
32
|
+
}
|
|
33
|
+
function emptyRow(colspan, message) {
|
|
34
|
+
return `<tr><td colspan="${colspan}" class="ledger__empty">${escapeHtml(message)}</td></tr>`;
|
|
35
|
+
}
|
|
36
|
+
function signClasses(n, prefix) {
|
|
37
|
+
const v = Number(n) || 0;
|
|
38
|
+
return `${prefix}--${v > 0 ? 'up' : v < 0 ? 'down' : 'flat'}`;
|
|
39
|
+
}
|
|
40
|
+
const pct = (v) => (v == null ? '—' : `${Math.round(Number(v) * 100)}%`);
|
|
41
|
+
const usd = (v) => `$${(Number(v) || 0).toFixed(2)}`;
|
|
42
|
+
const mins = (v) => (v == null ? '—' : `${Math.round(Number(v))}m`);
|
|
43
|
+
|
|
44
|
+
// Set by render(): the page's client, and whether this is a newcomer (#748: a
|
|
45
|
+
// gated newcomer never shows the karma or acclaim feed, as on Accomplishments).
|
|
46
|
+
let API = '/api/bongos';
|
|
47
|
+
let api = null;
|
|
48
|
+
let newcomerGated = false;
|
|
49
|
+
async function getJSON(path) {
|
|
50
|
+
const res = await api.request('GET', path);
|
|
51
|
+
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
52
|
+
return res.data;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// "Where you stand" (task 1005046): the personal half of the retired Ranks
|
|
56
|
+
// page, a card on Accomplishments until task 1005050 put it here. The ladder itself — every rank and what
|
|
57
|
+
// it unlocks — is the Primer's rank-ladder chapter, written once; this card
|
|
58
|
+
// says only what is true for the VIEWER: their rank, what it is, the rank
|
|
59
|
+
// above, and how they reach it. Moved from ranks.js with the page.
|
|
60
|
+
const RANK_TITLE = {
|
|
61
|
+
xenos: 'The newcomer',
|
|
62
|
+
thetes: 'The proven builder',
|
|
63
|
+
metic: 'The trusted builder',
|
|
64
|
+
archon: 'The steward',
|
|
65
|
+
};
|
|
66
|
+
const RANK_BLURB = {
|
|
67
|
+
xenos: 'A newcomer with a deliberately small surface: onboarding, safe starter tasks, and your own standing.',
|
|
68
|
+
thetes: 'A builder who has shipped their first work — the day-to-day surfaces are open.',
|
|
69
|
+
metic: 'A trusted builder: triage, reviews, planning sessions, and security reporting are open.',
|
|
70
|
+
archon: 'The steward of the whole system — approvals, the roster, budgets, and the Archon pages.',
|
|
71
|
+
};
|
|
72
|
+
// How a builder rises FROM their current rank. NB: these strings contain
|
|
73
|
+
// markup (<strong>) and are injected via innerHTML without escaping, which is
|
|
74
|
+
// safe only because they are author-controlled constants. Never source them
|
|
75
|
+
// from server or user data without switching the render site to escapeHtml.
|
|
76
|
+
const RISE_FROM = {
|
|
77
|
+
xenos:
|
|
78
|
+
`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.`,
|
|
79
|
+
thetes:
|
|
80
|
+
'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.',
|
|
81
|
+
metic:
|
|
82
|
+
'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.',
|
|
83
|
+
archon:
|
|
84
|
+
'You hold the highest rank. There is no rank above it, only the care of everything below.',
|
|
85
|
+
};
|
|
86
|
+
// The rank one rung up, off the ladder (pinned in tests/rank_tier_single_source.mjs).
|
|
87
|
+
function nextRank(rank) {
|
|
88
|
+
const i = RANK_LADDER.indexOf(rank);
|
|
89
|
+
return i >= 0 && i < RANK_LADDER.length - 1 ? RANK_LADDER[i + 1] : null;
|
|
90
|
+
}
|
|
91
|
+
function renderRankStanding(builder) {
|
|
92
|
+
const host = document.getElementById('rank-standing-body');
|
|
93
|
+
if (!host) return;
|
|
94
|
+
const scroll = $('#rank-scroll');
|
|
95
|
+
if (scroll) scroll.hidden = false;
|
|
96
|
+
const rank = normalizeRank(builder && builder.rank);
|
|
97
|
+
const next = nextRank(rank);
|
|
98
|
+
// At the top there is no rung to name; RISE_FROM says so in its own words,
|
|
99
|
+
// so the line is left out rather than said twice.
|
|
100
|
+
const nextLine = next
|
|
101
|
+
? `<p class="ranks-standing__next">The rank above yours is ${renderRankBadge(next)}.</p>`
|
|
102
|
+
: '';
|
|
103
|
+
host.innerHTML = `
|
|
104
|
+
<div class="ranks-standing">
|
|
105
|
+
<div class="ranks-standing__badge">${renderRankBadge(rank)}</div>
|
|
106
|
+
<p class="ranks-standing__title">${escapeHtml(RANK_TITLE[rank] || rankLabel(rank))}</p>
|
|
107
|
+
<p class="ranks-standing__blurb">${escapeHtml(RANK_BLURB[rank] || '')}</p>
|
|
108
|
+
${nextLine}
|
|
109
|
+
</div>
|
|
110
|
+
<p class="ranks-rise__body">${RISE_FROM[rank] || RISE_FROM.xenos}</p>
|
|
111
|
+
<p class="card-foot"><a href="/primer#the-rank-ladder">Every rank and what it unlocks, in the Primer →</a></p>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// task 1002835: a karma vote on a peer's ship was a vote on a title alone.
|
|
115
|
+
// The two facts the ship already recorded — what the grader scored it and
|
|
116
|
+
// what it cost — turn it into an informed one. ABSENCE IS NOT ZERO: an
|
|
117
|
+
// ungraded ship says so, and so does an unlogged cost, because "0.0" and
|
|
118
|
+
// "$0.00" would read as measurements that were never taken.
|
|
119
|
+
function acclaimEvidenceHtml(t) {
|
|
120
|
+
const score = t.grade_score == null ? null : Number(t.grade_score);
|
|
121
|
+
const grade = score == null || !Number.isFinite(score)
|
|
122
|
+
? '<span class="ledger__verdict--none">not graded</span>'
|
|
123
|
+
: `<span class="${t.grade_passed ? 'ledger__verdict--ok' : 'ledger__verdict--danger'}">${score.toFixed(1)} / 10</span>`;
|
|
124
|
+
// Three states, not two. The route OMITS cost_usd for a builder who may not
|
|
125
|
+
// vote, and that is not the same fact as a logged cost of nothing: a reader
|
|
126
|
+
// who is shown no figure is told nothing, never "no cost logged".
|
|
127
|
+
const parts = [grade];
|
|
128
|
+
if (Object.prototype.hasOwnProperty.call(t, 'cost_usd')) {
|
|
129
|
+
const usd = t.cost_usd == null ? null : Number(t.cost_usd);
|
|
130
|
+
parts.push(usd == null || !Number.isFinite(usd)
|
|
131
|
+
? '<span class="ledger__verdict--none">no cost logged</span>'
|
|
132
|
+
: `<span class="acclaim__cost">$${usd.toFixed(2)}</span>`);
|
|
133
|
+
}
|
|
134
|
+
return `<span class="acclaim__evidence">${parts.join(' · ')}</span>`;
|
|
135
|
+
}
|
|
136
|
+
// ---- recent grades (task 197) -------------------------------------------
|
|
137
|
+
|
|
138
|
+
function renderRecentGrades(grades) {
|
|
139
|
+
const tbody = $('#recent-grades tbody');
|
|
140
|
+
if (!tbody) return;
|
|
141
|
+
if (!grades || !grades.length) {
|
|
142
|
+
tbody.innerHTML = emptyRow(5, 'No work reviewed yet.');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
tbody.innerHTML = grades.map((g) => {
|
|
146
|
+
// A grader OUTAGE row (panel_outcome 'unavailable') carries a placeholder
|
|
147
|
+
// score of 0 — label it, never render it as a 0.0 fail (task 1002652).
|
|
148
|
+
const isOutage = g.panel_outcome === 'unavailable';
|
|
149
|
+
// A json-null gating axis (task 1003318 — the Quality worker never ran)
|
|
150
|
+
// is likewise not a judgment: the score is the surviving axis, not a fail.
|
|
151
|
+
const isLensOut = !isOutage && !!g.rubric_scores && g.rubric_scores.code_quality === null;
|
|
152
|
+
const score = isOutage || isLensOut ? '—' : Number(g.score).toFixed(1);
|
|
153
|
+
// A verdict is verification state: it wears the fixed vocabulary through
|
|
154
|
+
// .ledger__verdict--* (style.css), never an inline colour. The literals
|
|
155
|
+
// that stood here painted `passed` from a --olive that is not a token of
|
|
156
|
+
// the world, so every hall ever rendered it as the fallback (task 1003495).
|
|
157
|
+
const verdict = isOutage
|
|
158
|
+
? `<span class="ledger__verdict--none">not graded (grader outage)</span>`
|
|
159
|
+
: isLensOut
|
|
160
|
+
? `<span class="ledger__verdict--none">not graded (quality lens unavailable)</span>`
|
|
161
|
+
: g.passed
|
|
162
|
+
? `<span class="ledger__verdict--ok">passed</span>`
|
|
163
|
+
: `<span class="ledger__verdict--danger">fell short</span>`;
|
|
164
|
+
return `
|
|
165
|
+
<tr>
|
|
166
|
+
<td class="ledger__num">#${g.task_id}</td>
|
|
167
|
+
<td>${escapeHtml(g.title || '')}</td>
|
|
168
|
+
<td class="ledger__drachmae">${isOutage || isLensOut ? '—' : `${score} / 10`}</td>
|
|
169
|
+
<td>${verdict}</td>
|
|
170
|
+
<td class="ledger__date">${fmtDate(g.graded_at)}</td>
|
|
171
|
+
</tr>
|
|
172
|
+
`;
|
|
173
|
+
}).join('');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderRollingNote(rolling) {
|
|
177
|
+
const note = $('#grades-rolling-note');
|
|
178
|
+
if (!note) return;
|
|
179
|
+
if (!rolling || !rolling.n || Number(rolling.n) === 0) {
|
|
180
|
+
note.hidden = true;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const avg = Number(rolling.avg_score).toFixed(1);
|
|
184
|
+
const passed = Number(rolling.n_passed || 0);
|
|
185
|
+
const n = Number(rolling.n);
|
|
186
|
+
note.textContent = `Last 14 days: ${avg} / 10 average across ${n} graded work${n === 1 ? '' : 's'} (${passed} passed).`;
|
|
187
|
+
note.hidden = false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// No `isArchon` here: the curve's scope was rank-gated until task 1004005, and
|
|
191
|
+
// the field is gone rather than left write-only — a state flag nothing reads is
|
|
192
|
+
// how a removed gate quietly grows back.
|
|
193
|
+
//
|
|
194
|
+
// `kind` is the active filter (null = all work). `kinds` is the picker's source
|
|
195
|
+
// — the UNFILTERED fingerprint, held separately because a filtered payload's
|
|
196
|
+
// top_kinds would otherwise shrink to the one kind selected and strand the
|
|
197
|
+
// reader there with no way back.
|
|
198
|
+
const analyticsState = { builderId: null, scope: 'self', kind: null, kinds: [], self: null, eco: null };
|
|
199
|
+
// The line drawing moved to OTBKit.sparklineHtml (task 1003445) — it was one
|
|
200
|
+
// of four chart implementations in this surface. This wrapper keeps the
|
|
201
|
+
// Curve's own default colour, which is NOT the kit's: these four charts each
|
|
202
|
+
// spend a different accent, and --accent-deep is the first one's.
|
|
203
|
+
function sparkline(values, opts = {}) {
|
|
204
|
+
return window.OTBKit.sparklineHtml(values, { color: 'var(--accent-deep)', ...opts });
|
|
205
|
+
}
|
|
206
|
+
function statCard(label, value, sub) {
|
|
207
|
+
return `<div class="curve__stat">
|
|
208
|
+
<div class="curve__stat-value">${escapeHtml(String(value))}</div>
|
|
209
|
+
<div class="curve__stat-label">${escapeHtml(label)}</div>
|
|
210
|
+
${sub ? `<div class="curve__stat-sub">${escapeHtml(sub)}</div>` : ''}
|
|
211
|
+
</div>`;
|
|
212
|
+
}
|
|
213
|
+
// `overlay` is the ecosystem benchmark for this same chart, or null. When it is
|
|
214
|
+
// present the card also carries a legend — two lines with no key cannot be
|
|
215
|
+
// read. The legend is built here rather than inside sparklineHtml because the
|
|
216
|
+
// NAMES are this page's ("You" / "All builders"), not the kit's.
|
|
217
|
+
function chartCard(title, values, color, footer, overlay) {
|
|
218
|
+
const has = Array.isArray(values) && values.length > 0;
|
|
219
|
+
const hasOverlay = Array.isArray(overlay) && overlay.length > 0;
|
|
220
|
+
const body = has
|
|
221
|
+
? sparkline(values, hasOverlay ? { color, overlay, overlayLabel: 'all builders' } : { color })
|
|
222
|
+
: '';
|
|
223
|
+
const legend = has && hasOverlay
|
|
224
|
+
? window.OTBKit.chartLegendHtml([
|
|
225
|
+
{ label: 'You', color },
|
|
226
|
+
{ label: 'All builders', color: 'var(--ink-soft)' },
|
|
227
|
+
])
|
|
228
|
+
: '';
|
|
229
|
+
return window.OTBKit.chartCardHtml(title, body ? body + legend : '', footer);
|
|
230
|
+
}
|
|
231
|
+
// The kind picker (task 1004005) — "how am I doing at THIS kind of work".
|
|
232
|
+
//
|
|
233
|
+
// Built from analyticsState.kinds, the UNFILTERED fingerprint captured on the
|
|
234
|
+
// first load, NOT from the current payload's top_kinds. Under a filter the
|
|
235
|
+
// payload would list only the selected kind, so a picker built from it would
|
|
236
|
+
// lose every other option the moment one was chosen — the reader could narrow
|
|
237
|
+
// once and never move again.
|
|
238
|
+
//
|
|
239
|
+
// A <select> rather than a row of chips: the kind list is open-ended (it is a
|
|
240
|
+
// free-text column with an 'unspecified' bucket), so the control has to cope
|
|
241
|
+
// with more options than fit on a line, and a native select is also the one
|
|
242
|
+
// that arrives keyboard- and screen-reader-correct with no work.
|
|
243
|
+
function kindFilterHtml() {
|
|
244
|
+
const kinds = Array.isArray(analyticsState.kinds) ? analyticsState.kinds : [];
|
|
245
|
+
if (kinds.length < 2) return ''; // one kind of work is not a choice
|
|
246
|
+
const cur = analyticsState.kind;
|
|
247
|
+
const opt = (value, label, n) => {
|
|
248
|
+
const sel = (value === null ? cur === null : cur === value) ? ' selected' : '';
|
|
249
|
+
const count = n === undefined ? '' : ` (${n})`;
|
|
250
|
+
return `<option value="${escapeHtml(value === null ? '' : value)}"${sel}>${escapeHtml(label)}${escapeHtml(count)}</option>`;
|
|
251
|
+
};
|
|
252
|
+
return `<span class="curve__filter">`
|
|
253
|
+
+ `<label class="curve__filter-label" for="curve-kind">Kind of work</label>`
|
|
254
|
+
+ `<select id="curve-kind" class="curve__filter-select">`
|
|
255
|
+
+ opt(null, 'All work')
|
|
256
|
+
+ kinds.map((k) => opt(k.kind, k.kind, k.n)).join('')
|
|
257
|
+
+ `</select></span>`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renderCurveBody() {
|
|
261
|
+
const headlineEl = $('#curve-headline');
|
|
262
|
+
const gridEl = $('#curve-grid');
|
|
263
|
+
const kindsEl = $('#curve-kinds');
|
|
264
|
+
const emptyEl = $('#curve-empty');
|
|
265
|
+
const noteEl = $('#curve-scope-note');
|
|
266
|
+
if (!headlineEl || !gridEl || !kindsEl) return;
|
|
267
|
+
|
|
268
|
+
const isEco = analyticsState.scope === 'eco';
|
|
269
|
+
const data = isEco ? analyticsState.eco : analyticsState.self;
|
|
270
|
+
|
|
271
|
+
// The controls — scope toggle and kind filter.
|
|
272
|
+
//
|
|
273
|
+
// WRITTEN BEFORE EVERY EARLY RETURN BELOW, and that ordering is the whole
|
|
274
|
+
// reason they are here. A kind with nothing shipped in the window is an
|
|
275
|
+
// ordinary, reachable state, and it takes the `totalShipped === 0` branch —
|
|
276
|
+
// which blanks the headline, the grid and the bars. If the filter that
|
|
277
|
+
// caused the empty view were drawn after that return, the reader would be
|
|
278
|
+
// left looking at "no data" with no control on screen to undo it, and the
|
|
279
|
+
// only way out would be a page reload.
|
|
280
|
+
//
|
|
281
|
+
// The scope toggle is no longer Archon-gated (task 1004005): the feed behind
|
|
282
|
+
// "All builders" is a public, identity-stripped aggregate, so the gate
|
|
283
|
+
// withheld the comparison rather than protecting anything.
|
|
284
|
+
if (noteEl) {
|
|
285
|
+
noteEl.innerHTML =
|
|
286
|
+
`<span class="curve__scope" role="group" aria-label="Analytics scope">` +
|
|
287
|
+
`<button type="button" class="curve__scope-btn${!isEco ? ' is-active' : ''}" data-scope="self">You</button>` +
|
|
288
|
+
`<button type="button" class="curve__scope-btn${isEco ? ' is-active' : ''}" data-scope="eco">All builders</button>` +
|
|
289
|
+
`</span>` +
|
|
290
|
+
kindFilterHtml();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!data) { gridEl.innerHTML = ''; headlineEl.innerHTML = ''; kindsEl.innerHTML = ''; return; }
|
|
294
|
+
|
|
295
|
+
// Under a kind filter the server cannot answer cost at all (cost_log has no
|
|
296
|
+
// kind), and says so in `unavailable` rather than sending a misleading zero.
|
|
297
|
+
const costUnavailable = Array.isArray(data.unavailable) && data.unavailable.includes('cost_trend');
|
|
298
|
+
|
|
299
|
+
const totalShipped = data.headline?.total_shipped || 0;
|
|
300
|
+
if (totalShipped === 0) {
|
|
301
|
+
if (emptyEl) emptyEl.style.display = '';
|
|
302
|
+
headlineEl.innerHTML = ''; gridEl.innerHTML = ''; kindsEl.innerHTML = '';
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (emptyEl) emptyEl.style.display = 'none';
|
|
306
|
+
|
|
307
|
+
// Headline stat cards.
|
|
308
|
+
const h = data.headline;
|
|
309
|
+
const cards = [];
|
|
310
|
+
cards.push(statCard(isEco ? 'Works shipped' : 'Works shipped', totalShipped, `last ${data.window_days}d`));
|
|
311
|
+
if (isEco) {
|
|
312
|
+
cards.push(statCard('Active builders', h.active_builders ?? 0));
|
|
313
|
+
cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
|
|
314
|
+
} else {
|
|
315
|
+
// '—', never usd(null) → "$0.00". Zero is a measurement; this is the
|
|
316
|
+
// absence of one, and printing a price here would be a false statement.
|
|
317
|
+
cards.push(costUnavailable
|
|
318
|
+
? statCard('Cost / work', '—', 'not tracked per kind')
|
|
319
|
+
: statCard('Cost / work', usd(h.cost_per_task)));
|
|
320
|
+
cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
|
|
321
|
+
cards.push(statCard('Confirmation rate', pct(h.confirmation_rate), `${h.resolved_claims || 0} claims`));
|
|
322
|
+
cards.push(statCard('Grade pass rate', pct(h.grade_pass_rate), `${h.graded_n || 0} graded`));
|
|
323
|
+
}
|
|
324
|
+
headlineEl.innerHTML = cards.join('');
|
|
325
|
+
|
|
326
|
+
// Sparkline charts.
|
|
327
|
+
//
|
|
328
|
+
// THE OVERLAY ONLY EVER RIDES ON THE 'self' VIEW, and only where the
|
|
329
|
+
// ecosystem publishes the SAME series. Overlaying the benchmark on the
|
|
330
|
+
// benchmark would draw one line twice; and the ecosystem feed carries only
|
|
331
|
+
// ship-rate and duration (grade and cost have their own public feeds and are
|
|
332
|
+
// not part of that payload), so those two are the only charts that CAN
|
|
333
|
+
// compare. A chart with no counterpart simply draws one line, as before.
|
|
334
|
+
const eco = (!isEco && analyticsState.eco) ? analyticsState.eco : null;
|
|
335
|
+
const ecoOf = (rows, pick) => (eco && Array.isArray(eco[rows]) ? eco[rows].map(pick) : null);
|
|
336
|
+
const charts = [];
|
|
337
|
+
charts.push(chartCard('Works shipped / day', (data.ship_rate || []).map((r) => r.n), 'var(--accent-deep)',
|
|
338
|
+
`${(data.ship_rate || []).reduce((s, r) => s + Number(r.n), 0)} over ${data.window_days}d`,
|
|
339
|
+
ecoOf('ship_rate', (r) => r.n)));
|
|
340
|
+
charts.push(chartCard('Time / work (min)', (data.duration_trend || []).map((r) => r.avg_actual), 'var(--sea)',
|
|
341
|
+
'actual minutes per shipped work',
|
|
342
|
+
ecoOf('duration_trend', (r) => r.avg_actual)));
|
|
343
|
+
if (!isEco) {
|
|
344
|
+
// COST IS NOT ANSWERABLE PER KIND — cost_log has no kind and reaches tasks
|
|
345
|
+
// only through a mostly-NULL task_id, so the server returns cost_trend: []
|
|
346
|
+
// and names it in `unavailable` rather than plotting a near-zero line that
|
|
347
|
+
// would read as "this kind of work is cheap". Say so where the chart was.
|
|
348
|
+
if (costUnavailable) {
|
|
349
|
+
charts.push(window.OTBKit.chartCardHtml('Cost / work ($)', '',
|
|
350
|
+
'', 'not tracked per kind of work — clear the filter to see cost'));
|
|
351
|
+
} else {
|
|
352
|
+
charts.push(chartCard('Cost / work ($)', (data.cost_trend || []).map((r) => r.per_work), 'var(--terracotta)',
|
|
353
|
+
`total ${usd(h.total_cost_usd)}`));
|
|
354
|
+
}
|
|
355
|
+
// n counts quality-graded rows only (task 1002652); a day whose grades
|
|
356
|
+
// were all outages has n=0 and must not plot as a 0% dip.
|
|
357
|
+
charts.push(chartCard('Grade pass rate', (data.grade_trend || []).filter((r) => Number(r.n) > 0).map((r) => r.n_passed / r.n), 'var(--accent)',
|
|
358
|
+
'share of work passing review'));
|
|
359
|
+
}
|
|
360
|
+
gridEl.innerHTML = charts.join('');
|
|
361
|
+
|
|
362
|
+
// Top kinds — horizontal bars.
|
|
363
|
+
const kinds = (data.top_kinds || []).slice(0, 8);
|
|
364
|
+
if (kinds.length) {
|
|
365
|
+
kindsEl.innerHTML =
|
|
366
|
+
`<div class="kit-chart__title">Kinds of work shipped</div>` +
|
|
367
|
+
window.OTBKit.barSeriesHtml(
|
|
368
|
+
kinds.map((k) => ({ label: k.kind, value: k.n })),
|
|
369
|
+
);
|
|
370
|
+
} else {
|
|
371
|
+
kindsEl.innerHTML = '';
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Fire-and-forget loader: fetch the builder's own curve (and, for an Archon,
|
|
375
|
+
// the ecosystem aggregate) and render. A failed/empty feed simply leaves the
|
|
376
|
+
// section hidden rather than holding up the rest of the page.
|
|
377
|
+
async function loadAnalytics(builder) {
|
|
378
|
+
const scroll = $('#analytics-scroll');
|
|
379
|
+
if (!scroll || !builder?.id) return;
|
|
380
|
+
analyticsState.builderId = builder.id;
|
|
381
|
+
analyticsState.scope = 'self';
|
|
382
|
+
analyticsState.kind = null;
|
|
383
|
+
try {
|
|
384
|
+
const self = await getJSON(`${API}/analytics/builder/${builder.id}?days=90`);
|
|
385
|
+
analyticsState.self = self;
|
|
386
|
+
// The picker's options come from THIS first, unfiltered load and are then
|
|
387
|
+
// left alone — see kindFilterHtml. A later filtered payload's top_kinds is
|
|
388
|
+
// deliberately not allowed to overwrite them.
|
|
389
|
+
analyticsState.kinds = Array.isArray(self?.top_kinds) ? self.top_kinds : [];
|
|
390
|
+
// A gated newcomer with nothing shipped: keep the section hidden.
|
|
391
|
+
if (!self || (self.headline?.total_shipped || 0) === 0) { scroll.hidden = true; return; }
|
|
392
|
+
scroll.hidden = false;
|
|
393
|
+
renderCurveBody();
|
|
394
|
+
// THE BENCHMARK IS FOR EVERY BUILDER, NOT JUST THE ARCHON (task 1004005).
|
|
395
|
+
// This fetch used to sit behind `if (analyticsState.isArchon)`, so the
|
|
396
|
+
// ecosystem average a builder is meant to measure themselves against was
|
|
397
|
+
// fetched for exactly one of them. The route is `rank: public` — no auth
|
|
398
|
+
// middleware, CORS '*', per-builder identity stripped server-side — and the
|
|
399
|
+
// unauthenticated status page already reads it, so the gate protected
|
|
400
|
+
// nothing and only withheld the comparison. Still fire-and-forget: the
|
|
401
|
+
// ecosystem feed is optional context, never a reason to hold up the curve.
|
|
402
|
+
getJSON(`${API}/public/analytics/ecosystem?days=90`)
|
|
403
|
+
// ALWAYS re-render, not only in the 'eco' scope. That condition was
|
|
404
|
+
// correct while the two scopes merely SWAPPED which single series was
|
|
405
|
+
// drawn; now the ecosystem series is also the benchmark overlaid on the
|
|
406
|
+
// 'self' view, so a self-scoped reader who arrived before this resolved
|
|
407
|
+
// would sit with no overlay until they touched an unrelated control.
|
|
408
|
+
.then((eco) => { analyticsState.eco = eco; renderCurveBody(); })
|
|
409
|
+
.catch(() => { /* ecosystem optional */ });
|
|
410
|
+
} catch (err) {
|
|
411
|
+
console.error('[profile] analytics load failed', err);
|
|
412
|
+
scroll.hidden = true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Re-fetch BOTH series under a kind filter (task 1004005). Both, always: an
|
|
417
|
+
// overlay that narrowed one side and not the other would compare one kind of
|
|
418
|
+
// work against all work and still look like a like-for-like benchmark — the
|
|
419
|
+
// failure this task's own text calls worse than no benchmark at all.
|
|
420
|
+
//
|
|
421
|
+
// `kinds` is deliberately not refreshed here; see kindFilterHtml.
|
|
422
|
+
let curveKindSeq = 0;
|
|
423
|
+
async function reloadCurveForKind(kind) {
|
|
424
|
+
const id = analyticsState.builderId;
|
|
425
|
+
if (!id) return;
|
|
426
|
+
analyticsState.kind = kind;
|
|
427
|
+
// A dropdown can be changed faster than a round trip returns, and the two
|
|
428
|
+
// fetches can land out of order. Only the newest request may write state.
|
|
429
|
+
const seq = ++curveKindSeq;
|
|
430
|
+
const q = kind ? `&kind=${encodeURIComponent(kind)}` : '';
|
|
431
|
+
renderCurveBody(); // reflect the new selection immediately
|
|
432
|
+
try {
|
|
433
|
+
const [self, eco] = await Promise.all([
|
|
434
|
+
getJSON(`${API}/analytics/builder/${id}?days=90${q}`),
|
|
435
|
+
getJSON(`${API}/public/analytics/ecosystem?days=90${q}`).catch(() => null),
|
|
436
|
+
]);
|
|
437
|
+
if (seq !== curveKindSeq) return;
|
|
438
|
+
analyticsState.self = self;
|
|
439
|
+
if (eco) analyticsState.eco = eco;
|
|
440
|
+
renderCurveBody();
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (seq !== curveKindSeq) return;
|
|
443
|
+
console.error('[profile] curve kind filter failed', err);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---- karma feed (V3.R26 #246) -------------------------------------------
|
|
448
|
+
//
|
|
449
|
+
// The recent karma_log entries for the current builder. The /me payload
|
|
450
|
+
// already includes them under `karma.recent`. The scroll stays hidden when
|
|
451
|
+
// the builder has no history yet (zero karma is a common starting state,
|
|
452
|
+
// so we don't want to push an empty table at every new citizen).
|
|
453
|
+
|
|
454
|
+
function renderKarmaFeed(entries) {
|
|
455
|
+
const scroll = $('#karma-scroll');
|
|
456
|
+
const tbody = $('#karma-feed tbody');
|
|
457
|
+
if (!scroll || !tbody) return;
|
|
458
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
459
|
+
// Hide the scroll entirely if the builder has no karma history.
|
|
460
|
+
// Keeps the page tidy for fresh citizens; reappears the moment a
|
|
461
|
+
// karma_log row lands.
|
|
462
|
+
scroll.hidden = true;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// #748: a gated newcomer never shows the karma feed, even if karma exists.
|
|
466
|
+
scroll.hidden = newcomerGated;
|
|
467
|
+
tbody.innerHTML = entries.map((k) => {
|
|
468
|
+
const delta = Number(k.delta) || 0;
|
|
469
|
+
const deltaCls = signClasses(delta, 'karma-feed__delta');
|
|
470
|
+
const deltaText = delta > 0 ? `+${fmtNum(delta)}` : fmtNum(delta);
|
|
471
|
+
const granted = k.granted_by
|
|
472
|
+
? `@${escapeHtml(k.granted_by_login || k.granted_by_display_name || `builder #${k.granted_by}`)}`
|
|
473
|
+
: '<span style="opacity:0.6;">— all builders —</span>';
|
|
474
|
+
return `
|
|
475
|
+
<tr>
|
|
476
|
+
<td class="ledger__date">${escapeHtml(fmtDate(k.recorded_at))}</td>
|
|
477
|
+
<td class="karma-feed__delta ${deltaCls}">${deltaText}</td>
|
|
478
|
+
<td>${escapeHtml(k.reason || '')}</td>
|
|
479
|
+
<td>${granted}</td>
|
|
480
|
+
</tr>
|
|
481
|
+
`;
|
|
482
|
+
}).join('');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ---- peer acclaim feed (V3.R25 #245) ------------------------------------
|
|
486
|
+
//
|
|
487
|
+
// Other builders' recently confirmed/shipped work, each with a +/- vote
|
|
488
|
+
// widget. Votes fold into the shipping builder's karma on a weekly tally,
|
|
489
|
+
// lagged 7 days (server-enforced). Metic+ may vote; the endpoint returns
|
|
490
|
+
// can_vote so Xenos see the tallies read-only. The endpoint already excludes
|
|
491
|
+
// the caller's own ships (anti-self-vote), so there's nothing to vote on that
|
|
492
|
+
// isn't a peer's work.
|
|
493
|
+
|
|
494
|
+
let acclaimCanVote = false;
|
|
495
|
+
// task 1002446: this was the hall's last unpaged list. The endpoint returns the
|
|
496
|
+
// whole feed in one call, so the rows are held here and the kit's client-side
|
|
497
|
+
// pager slices them — a page change re-renders without a refetch.
|
|
498
|
+
const acclaimState = { tasks: [] };
|
|
499
|
+
const acclaimPager = window.OTBKit.makePager('acclaim', 'otb.standing.acclaimPerPage', { defaultPerPage: 10 });
|
|
500
|
+
|
|
501
|
+
function renderAcclaimFeed(data) {
|
|
502
|
+
const scroll = $('#acclaim-scroll');
|
|
503
|
+
if (!scroll) return;
|
|
504
|
+
const tasks = (data && Array.isArray(data.tasks)) ? data.tasks : [];
|
|
505
|
+
acclaimState.tasks = tasks;
|
|
506
|
+
acclaimCanVote = !!(data && data.can_vote);
|
|
507
|
+
if (tasks.length === 0) {
|
|
508
|
+
// Nothing to acclaim yet (e.g. the only confirmed work so far is your
|
|
509
|
+
// own). Hide rather than show an empty ledger.
|
|
510
|
+
scroll.hidden = true;
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
// #748: a gated newcomer never shows the acclaim feed.
|
|
514
|
+
scroll.hidden = newcomerGated;
|
|
515
|
+
renderAcclaimRows();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function renderAcclaimRows() {
|
|
519
|
+
const tbody = $('#acclaim-feed tbody');
|
|
520
|
+
if (!tbody) return;
|
|
521
|
+
const slice = window.OTBKit.paginate(acclaimState.tasks, acclaimPager);
|
|
522
|
+
tbody.innerHTML = slice.map((t) => {
|
|
523
|
+
// task 1004432: an approved page is its artist's; the applying session rides along as a helper.
|
|
524
|
+
const helperName = t.helper_display_name || (t.helper_login ? `@${t.helper_login}` : '');
|
|
525
|
+
const by = escapeHtml(t.display_name || (t.github_login ? `@${t.github_login}` : `builder #${t.shipped_by}`)) + (helperName ? ` <span class="acclaim__evidence">with help from ${escapeHtml(helperName)}</span>` : '');
|
|
526
|
+
// task 1004833: the votes are THE shared list control (vote-control.js),
|
|
527
|
+
// pre-painted from the feed's own net_votes/my_vote so it costs no extra
|
|
528
|
+
// read. A viewer below the vote floor sees the net alone.
|
|
529
|
+
const net = Number(t.net_votes) || 0;
|
|
530
|
+
const votes = acclaimCanVote && window.OTBVote
|
|
531
|
+
? window.OTBVote.taskHtml(t)
|
|
532
|
+
: `<span class="vote__net" title="Metic rank and above may vote">${net > 0 ? `+${net}` : `${net}`}</span>`;
|
|
533
|
+
const title = escapeHtml(t.value_summary || t.title || `task #${t.id}`);
|
|
534
|
+
return `
|
|
535
|
+
<tr>
|
|
536
|
+
<td>${title}${acclaimEvidenceHtml(t)}</td>
|
|
537
|
+
<td>${by}</td>
|
|
538
|
+
<td>${votes}</td>
|
|
539
|
+
</tr>
|
|
540
|
+
`;
|
|
541
|
+
}).join('');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function loadAcclaimFeed() {
|
|
545
|
+
try {
|
|
546
|
+
const data = await getJSON(`${API}/tasks/peer-votes/feed?limit=25`);
|
|
547
|
+
renderAcclaimFeed(data);
|
|
548
|
+
} catch (err) {
|
|
549
|
+
// Non-fatal: a feed failure shouldn't take down the page.
|
|
550
|
+
console.warn('[profile] acclaim feed failed:', err.message);
|
|
551
|
+
const scroll = $('#acclaim-scroll');
|
|
552
|
+
if (scroll) scroll.hidden = true;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// task 1004833: the shared vote control casts the vote and repaints its own
|
|
557
|
+
// row; it then raises 'otb-vote' with the server's answer. The feed is paged,
|
|
558
|
+
// so the vote must land in the HELD rows too (task 1002446), or flipping to
|
|
559
|
+
// page 2 and back re-renders the stale tally over the one just cast.
|
|
560
|
+
function onAcclaimVote(ev) {
|
|
561
|
+
const d = ev && ev.detail;
|
|
562
|
+
if (!d || d.kind !== 'task') return;
|
|
563
|
+
const taskIdNum = Number(d.id);
|
|
564
|
+
acclaimState.tasks = acclaimState.tasks.map((t) => (Number(t.id) === taskIdNum
|
|
565
|
+
? { ...t, net_votes: d.net_votes, my_vote: d.my_vote }
|
|
566
|
+
: t));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
let wired = false;
|
|
571
|
+
function wire() {
|
|
572
|
+
if (wired) return;
|
|
573
|
+
wired = true;
|
|
574
|
+
// The curve's scope toggle and kind filter, delegated from the whole Progress
|
|
575
|
+
// section: renderCurveBody replaces the controls' innerHTML, and they render
|
|
576
|
+
// into #curve-scope-note in the lede, a sibling of #curve-body (task 1004079).
|
|
577
|
+
$('#analytics-scroll')?.addEventListener('click', (e) => {
|
|
578
|
+
const btn = e.target.closest('.curve__scope-btn[data-scope]');
|
|
579
|
+
if (!btn) return;
|
|
580
|
+
const scope = btn.dataset.scope === 'eco' ? 'eco' : 'self';
|
|
581
|
+
if (scope === analyticsState.scope) return;
|
|
582
|
+
analyticsState.scope = scope;
|
|
583
|
+
renderCurveBody();
|
|
584
|
+
});
|
|
585
|
+
// 'change', not 'input': arrowing through a native select fires input per option.
|
|
586
|
+
$('#analytics-scroll')?.addEventListener('change', (e) => {
|
|
587
|
+
const sel = e.target.closest('#curve-kind');
|
|
588
|
+
if (!sel) return;
|
|
589
|
+
const kind = sel.value ? sel.value : null;
|
|
590
|
+
if (kind === analyticsState.kind) return;
|
|
591
|
+
reloadCurveForKind(kind);
|
|
592
|
+
});
|
|
593
|
+
// task 1004833: the shared vote control reports each landed vote to the feed.
|
|
594
|
+
$('#acclaim-feed')?.addEventListener('otb-vote', onAcclaimVote);
|
|
595
|
+
// The kit's pager bar, stamped under the feed (task 1002446), then wired.
|
|
596
|
+
const bar = $('#acclaim-pager-mount');
|
|
597
|
+
if (bar && !bar.innerHTML) bar.innerHTML = window.OTBKit.pagerBarHtml('acclaim', { label: 'entries' });
|
|
598
|
+
window.OTBKit.wirePager(acclaimPager, renderAcclaimRows);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// render({ api, API, me }) — `me` is the viewer's own /me payload (grades, karma
|
|
602
|
+
// and the builder ride on it), so the sections it fills cost no read at all. The
|
|
603
|
+
// two feeds that need their own (peer recognition; the curve, two reads) wait
|
|
604
|
+
// for open(): a profile visit for its Works should not pay for a screen it never
|
|
605
|
+
// opens.
|
|
606
|
+
let viewer = null;
|
|
607
|
+
let opened = false;
|
|
608
|
+
function render(opts) {
|
|
609
|
+
api = opts.api;
|
|
610
|
+
API = opts.API || API;
|
|
611
|
+
const me = opts.me || {};
|
|
612
|
+
const builder = me.builder || me;
|
|
613
|
+
if (!builder || builder.id == null) return;
|
|
614
|
+
newcomerGated = normalizeRank(builder.rank) === 'xenos';
|
|
615
|
+
wire();
|
|
616
|
+
renderRankStanding(builder);
|
|
617
|
+
const grades = $('#grades-scroll');
|
|
618
|
+
if (grades) grades.hidden = false;
|
|
619
|
+
renderRecentGrades((me.grades && me.grades.recent) || []);
|
|
620
|
+
renderRollingNote((me.grades && me.grades.rolling) || null);
|
|
621
|
+
renderKarmaFeed((me.karma && me.karma.recent) || []);
|
|
622
|
+
viewer = builder;
|
|
623
|
+
if (opened) loadFeeds();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// open() — the "Just you" screen was shown (profile.js calls it on the switch).
|
|
627
|
+
// Loads the two feeds once; each hides itself when empty.
|
|
628
|
+
function open() {
|
|
629
|
+
if (opened) return;
|
|
630
|
+
opened = true;
|
|
631
|
+
if (viewer) loadFeeds();
|
|
632
|
+
}
|
|
633
|
+
function loadFeeds() {
|
|
634
|
+
loadAcclaimFeed();
|
|
635
|
+
loadAnalytics(viewer);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
window.OTBProfileOwn = { render, open };
|
|
639
|
+
})();
|
|
@@ -164,7 +164,8 @@
|
|
|
164
164
|
.profile-groups[data-active="overview"] > [data-group="overview"],
|
|
165
165
|
.profile-groups[data-active="works"] > [data-group="works"],
|
|
166
166
|
.profile-groups[data-active="activity"] > [data-group="activity"],
|
|
167
|
-
.profile-groups[data-active="connections"] > [data-group="connections"]
|
|
167
|
+
.profile-groups[data-active="connections"] > [data-group="connections"],
|
|
168
|
+
.profile-groups[data-active="yours"] > [data-group="yours"] { display: block; }
|
|
168
169
|
.profile-groups > [data-group][hidden] { display: none !important; }
|
|
169
170
|
.profile-groups .scroll { margin: 0 0 var(--sp-sm); }
|
|
170
171
|
#profile-rail .rail-link { min-height: 38px; padding: 0 14px; font-size: 14.5px; text-transform: none; border-radius: var(--radius-pill); }
|