@bongos/core 1.19.381 → 1.19.383
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 +69 -29
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +141 -3
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +14 -9
- package/docs/copy-registry.json +87 -42
- package/docs/design/landing-page-direction-v2.md +90 -0
- package/docs/design/mocks/landing/landing-sheet.html +131 -0
- package/docs/design/mocks/landing/v1.html +956 -0
- package/docs/design/mocks/landing/v2.html +708 -0
- package/docs/design/mocks/landing/v3.html +911 -0
- package/migrations/core_228_idea_inbox_captured_by_idx.sql +61 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/hall-kit.js +67 -6
- package/modules/hall-ui/public/profile.html +34 -2
- package/modules/hall-ui/public/profile.js +279 -0
- package/modules/ideas/inbox.js +98 -0
- package/modules/ideas/routes/inbox.js +39 -0
- package/modules/lifecycle/activity.js +205 -0
- package/modules/lifecycle/lifecycle.js +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +13 -3
- package/src/bongos/routes/builders.js +67 -0
- package/src/module-api.js +5 -1
- package/tests/profile_activity.mjs +625 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
-- core_228_idea_inbox_captured_by_idx.sql — task 1003281 (grader panel finding,
|
|
2
|
+
-- efficiency worker, on the goal-1000072 activity log).
|
|
3
|
+
--
|
|
4
|
+
-- THE ACTIVITY LOG IS THE FIRST SURFACE THAT ASKS "WHAT DID *THIS PERSON* DO".
|
|
5
|
+
-- Every read that came before scoped by version, by status, or by goal, so those
|
|
6
|
+
-- are the columns that got indexed. Three per-person predicates arrived with this
|
|
7
|
+
-- task, each on a route any signed-in builder can hit repeatedly — once per
|
|
8
|
+
-- profile load, again per craft switch, again per pager click — and each paired
|
|
9
|
+
-- with a COUNT(*) that has no LIMIT to escape through. Unindexed, all three
|
|
10
|
+
-- sequentially scan a shared table on every one of those requests.
|
|
11
|
+
--
|
|
12
|
+
-- 1. idea_inbox (captured_by, captured_at DESC) — THE FLAGGED ONE.
|
|
13
|
+
-- Migration 006 indexed `status` and `captured_at`, exactly right for every
|
|
14
|
+
-- idea read that existed until now: all of them are scoped to what is still
|
|
15
|
+
-- OPEN and ordered newest-first. `captured_by` was never a predicate.
|
|
16
|
+
-- GET /inbox/by-builder/:id inverts that — it drops the status filter entirely
|
|
17
|
+
-- (a triaged idea must NOT leave the trail; that IS the criterion) and scopes
|
|
18
|
+
-- on the author instead.
|
|
19
|
+
--
|
|
20
|
+
-- 2. tasks (shipped_by, shipped_at DESC) WHERE status = 'shipped' — the works
|
|
21
|
+
-- half. The only existing index on `shipped_by` is core_193's, partial on
|
|
22
|
+
-- `WHERE needs_rebase`, which cannot serve this at all. PARTIAL on the status
|
|
23
|
+
-- because the predicate is always `status = 'shipped'` and shipped tasks are a
|
|
24
|
+
-- minority of the table: the index stays small and every row in it is a row
|
|
25
|
+
-- the query wants.
|
|
26
|
+
--
|
|
27
|
+
-- 3. tasks (goal_id) — the goals half's rollup CTE derives each goal's craft set
|
|
28
|
+
-- and progress from its tasks, and nothing indexed `goal_id` (migration 160
|
|
29
|
+
-- added the column and an FK, not an index). This one is the broadest win
|
|
30
|
+
-- here: it is the join every per-goal rollup in the codebase makes.
|
|
31
|
+
--
|
|
32
|
+
-- EQUALITY LEADS, SORT TRAILS, in 1 and 2. The btree seeks straight to one
|
|
33
|
+
-- person's rows and then walks them already ordered, so the page window stops at
|
|
34
|
+
-- LIMIT instead of sorting a whole history to return ten rows. A bare
|
|
35
|
+
-- (captured_by) index would serve the COUNT and leave the list with a sort.
|
|
36
|
+
--
|
|
37
|
+
-- DELIBERATELY NOT INDEXED: goals.created_by. The `mine` CTE filters it, but the
|
|
38
|
+
-- goals table holds tens of rows, not thousands — an index there would cost more
|
|
39
|
+
-- to maintain than the scan it replaces. Revisit if a project ever carries
|
|
40
|
+
-- thousands of goals.
|
|
41
|
+
--
|
|
42
|
+
-- NOT THE MODULE'S MIGRATION, deliberately. `idea_inbox` and `tasks` are CORE
|
|
43
|
+
-- tables (both predate the module system), and fitness.js
|
|
44
|
+
-- checkModuleMigrationsAdditive hard-fails a CREATE INDEX against a
|
|
45
|
+
-- non-`ideas_`-prefixed table from modules/ideas/migrations/ (ADR 0083
|
|
46
|
+
-- §Decision #5). Task 1002447 hit the same wall — core_195 began life as
|
|
47
|
+
-- ideas_001 — so this lands in core beside it.
|
|
48
|
+
--
|
|
49
|
+
-- Additive and reversible: an index carries no data and no constraint, so a
|
|
50
|
+
-- rollback is a DROP INDEX and nothing depends on it for correctness, only for
|
|
51
|
+
-- speed. This migration changes no column, no type and no row.
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_idea_inbox_captured_by
|
|
53
|
+
ON idea_inbox (captured_by, captured_at DESC);
|
|
54
|
+
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_shipped_by_shipped_at
|
|
56
|
+
ON tasks (shipped_by, shipped_at DESC) WHERE status = 'shipped';
|
|
57
|
+
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_goal ON tasks (goal_id);
|
|
59
|
+
|
|
60
|
+
INSERT INTO schema_migrations (version) VALUES ('core_228_idea_inbox_captured_by_idx')
|
|
61
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -222,6 +222,8 @@ function createClient(opts = {}) {
|
|
|
222
222
|
"builders": {
|
|
223
223
|
// GET /builders/{id}/active-claims — rank: metic+archon — GET /builders/:id/active-claims
|
|
224
224
|
getBuildersIdActiveClaims: (args) => request("GET", "/builders/{id}/active-claims", { hasBody: false }, args),
|
|
225
|
+
// GET /builders/{id}/activity — rank: any-builder — GET /builders/:id/activity
|
|
226
|
+
getBuildersIdActivity: (args) => request("GET", "/builders/{id}/activity", { hasBody: false }, args),
|
|
225
227
|
// PATCH /builders/{id}/budget — rank: metic+archon — PATCH /builders/:id/budget
|
|
226
228
|
patchBuildersIdBudget: (args) => request("PATCH", "/builders/{id}/budget", { hasBody: true }, args),
|
|
227
229
|
// GET /builders/{id}/onboarding — rank: public — GET /builders/:id/onboarding
|
|
@@ -440,6 +442,8 @@ function createClient(opts = {}) {
|
|
|
440
442
|
postInboxIdSpark: (args) => request("POST", "/inbox/{id}/spark", { hasBody: true }, args),
|
|
441
443
|
// GET /inbox/awaiting-nod — rank: metic+archon — GET /inbox/awaiting-nod
|
|
442
444
|
getInboxAwaitingNod: (args) => request("GET", "/inbox/awaiting-nod", { hasBody: false }, args),
|
|
445
|
+
// GET /inbox/by-builder/{id} — rank: any-builder — GET /inbox/by-builder/:id
|
|
446
|
+
getInboxByBuilderId: (args) => request("GET", "/inbox/by-builder/{id}", { hasBody: false }, args),
|
|
443
447
|
// POST /inbox/score — rank: any-builder — POST /inbox/score
|
|
444
448
|
postInboxScore: (args) => request("POST", "/inbox/score", { hasBody: true }, args),
|
|
445
449
|
// GET /inbox/sparks — rank: any-builder — GET /inbox/sparks
|
|
@@ -115,6 +115,43 @@
|
|
|
115
115
|
if (sel && sel.value !== String(p.perPage)) sel.value = String(p.perPage);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// ---- server-offset paging (task 1003281) -----------------------------------
|
|
119
|
+
//
|
|
120
|
+
// `paginate()` above slices an array the browser already holds. Some lists
|
|
121
|
+
// cannot be held: the profile's activity log pages ONE builder's whole record,
|
|
122
|
+
// and hall-ui/CLAUDE.md's own rule says that when you need a server-offset
|
|
123
|
+
// pager you add the mode to the kit rather than hand-rolling a second copy
|
|
124
|
+
// (sessions.js is the one grandfathered hand-roll, audited task 2179).
|
|
125
|
+
//
|
|
126
|
+
// The pager object is the SAME object — page, perPage, per-page persistence
|
|
127
|
+
// and wirePager all work unchanged. Only the slicing moves: the caller asks
|
|
128
|
+
// the server for `pageOffset(p)`..`+perPage` and then reports back what it got
|
|
129
|
+
// with `renderServerPage`. That keeps ONE pager contract in the kit instead of
|
|
130
|
+
// two, which is the whole reason this lives here.
|
|
131
|
+
|
|
132
|
+
// Row offset of the pager's current page — what a `?offset=` query takes.
|
|
133
|
+
function pageOffset(p) {
|
|
134
|
+
return Math.max(0, (p.page - 1) * p.perPage);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Paint the bar for a server-paged list: `total` is the server's count of the
|
|
138
|
+
// whole filtered set, `shownCount` the rows this page actually returned.
|
|
139
|
+
// Clamps `p.page` down when the set shrank under the caller (a re-filter, a
|
|
140
|
+
// background change) and returns true when it did — the caller re-fetches, so
|
|
141
|
+
// the bar and the rows can never disagree about which page is showing.
|
|
142
|
+
function renderServerPage(p, total, shownCount) {
|
|
143
|
+
const totalPages = Math.max(1, Math.ceil(total / p.perPage));
|
|
144
|
+
const clamped = p.page > totalPages || p.page < 1;
|
|
145
|
+
if (clamped) p.page = Math.min(Math.max(1, p.page), totalPages);
|
|
146
|
+
const offset = pageOffset(p);
|
|
147
|
+
// "Showing 11–13 of 12" is what an unclamped count reads as when the rows
|
|
148
|
+
// and the total come from two statements that disagree. The total is the
|
|
149
|
+
// authority for the range, so the shown count can never run past it.
|
|
150
|
+
const shown = Math.max(0, Math.min(shownCount, total - offset));
|
|
151
|
+
renderPagerBar(p, total, totalPages, offset, shown);
|
|
152
|
+
return clamped;
|
|
153
|
+
}
|
|
154
|
+
|
|
118
155
|
// Wire a pager's controls once. `rerender` re-renders that list; a per-page
|
|
119
156
|
// change resets to page 1 and persists. Fills the per-page <select> from the
|
|
120
157
|
// pager's own options so pagerBarHtml stays option-agnostic.
|
|
@@ -136,35 +173,57 @@
|
|
|
136
173
|
}
|
|
137
174
|
|
|
138
175
|
// ---- ledger ----------------------------------------------------------------
|
|
139
|
-
// makeLedger({ mount, columns, rowHtml, emptyMsg, emptyAction?, pager? })
|
|
176
|
+
// makeLedger({ mount, columns, rowHtml, emptyMsg, emptyAction?, pager?, serverPager?, onPage? })
|
|
140
177
|
// Stamps the standard table.ledger (+ pager bar when a pager is given) into
|
|
141
|
-
// `mount` and returns { el, tbody, render(rows) }. `columns` entries are
|
|
178
|
+
// `mount` and returns { el, tbody, render(rows, opts) }. `columns` entries are
|
|
142
179
|
// strings or { label, class }. `rowHtml(row, i)` returns a <tr> string —
|
|
143
180
|
// callers keep full control of cells (and must esc() their own data).
|
|
144
|
-
|
|
181
|
+
//
|
|
182
|
+
// `pager` slices client-side (the browser holds the whole list). `serverPager`
|
|
183
|
+
// is the same pager object driving a SERVER-offset list (task 1003281): the
|
|
184
|
+
// rows handed to render() are already one page, so nothing is sliced, and the
|
|
185
|
+
// caller passes `{ total }` — the server's count of the whole set — so the bar
|
|
186
|
+
// can say "of N". Page/per-page clicks call `onPage(pageOffset(p))`; the
|
|
187
|
+
// caller fetches and calls render() again. Pass one or the other, never both.
|
|
188
|
+
function makeLedger({
|
|
189
|
+
mount, columns = [], rowHtml, emptyMsg = 'Nothing here yet.', emptyAction = null,
|
|
190
|
+
pager = null, serverPager = null, onPage = null,
|
|
191
|
+
}) {
|
|
145
192
|
if (!mount || typeof rowHtml !== 'function') {
|
|
146
193
|
throw new Error('makeLedger({ mount, columns, rowHtml, … }) requires mount + rowHtml()');
|
|
147
194
|
}
|
|
195
|
+
if (pager && serverPager) {
|
|
196
|
+
throw new Error('makeLedger: pass pager (client-sliced) OR serverPager (server-offset), not both');
|
|
197
|
+
}
|
|
148
198
|
const head = columns.map((c) => {
|
|
149
199
|
const col = typeof c === 'string' ? { label: c } : c;
|
|
150
200
|
return `<th${col.class ? ` class="${esc(col.class)}"` : ''}>${esc(col.label)}</th>`;
|
|
151
201
|
}).join('');
|
|
202
|
+
const bar = pager || serverPager;
|
|
152
203
|
mount.innerHTML = `
|
|
153
204
|
<table class="ledger">
|
|
154
205
|
<thead><tr>${head}</tr></thead>
|
|
155
206
|
<tbody></tbody>
|
|
156
207
|
</table>
|
|
157
|
-
${
|
|
208
|
+
${bar ? pagerBarHtml(bar.prefix, { label: 'rows' }) : ''}`;
|
|
158
209
|
const tbody = mount.querySelector('tbody');
|
|
159
210
|
let last = [];
|
|
160
|
-
|
|
211
|
+
let lastTotal = 0;
|
|
212
|
+
function render(rows, opts = {}) {
|
|
161
213
|
if (Array.isArray(rows)) last = rows;
|
|
162
|
-
|
|
214
|
+
if (typeof opts.total === 'number') lastTotal = opts.total;
|
|
215
|
+
let slice = last;
|
|
216
|
+
if (pager) slice = paginate(last, pager);
|
|
217
|
+
else if (serverPager) renderServerPage(serverPager, lastTotal, last.length);
|
|
163
218
|
tbody.innerHTML = slice.length
|
|
164
219
|
? slice.map((r, i) => rowHtml(r, i)).join('')
|
|
165
220
|
: `<tr><td class="ledger__empty" colspan="${columns.length || 1}">${emptyStateHtml(emptyMsg, emptyAction)}</td></tr>`;
|
|
166
221
|
}
|
|
167
222
|
if (pager) wirePager(pager, () => render());
|
|
223
|
+
// A server-paged ledger cannot re-render from what it holds — it has to go
|
|
224
|
+
// ask. wirePager has already moved p.page by the time onPage runs, so the
|
|
225
|
+
// offset it hands over is the NEW page's.
|
|
226
|
+
if (serverPager) wirePager(serverPager, () => { if (onPage) onPage(pageOffset(serverPager)); });
|
|
168
227
|
return { el: mount, tbody, render };
|
|
169
228
|
}
|
|
170
229
|
|
|
@@ -552,6 +611,8 @@
|
|
|
552
611
|
esc,
|
|
553
612
|
// pager (low-level exports so pages can adopt incrementally)
|
|
554
613
|
makePager, pagerBarHtml, paginate, renderPagerBar, wirePager,
|
|
614
|
+
// server-offset paging (task 1003281) — same pager object, server slicing
|
|
615
|
+
pageOffset, renderServerPage,
|
|
555
616
|
// composed primitives
|
|
556
617
|
makeLedger, makeFilterBar, makeTabs, makeSectionRail,
|
|
557
618
|
// states
|
|
@@ -75,6 +75,38 @@
|
|
|
75
75
|
</table>
|
|
76
76
|
</section>
|
|
77
77
|
|
|
78
|
+
<!-- Activity, by craft — task 1003281, goal 1000072 / criterion
|
|
79
|
+
legibility-activity-by-role. A view per role over the same record:
|
|
80
|
+
the tasks this builder completed, the goals they created, and (in
|
|
81
|
+
the ideator view) every idea they filed with what became of it.
|
|
82
|
+
All three ledgers page server-side; profile.js fills them. -->
|
|
83
|
+
<section class="scroll" id="activity-scroll" aria-labelledby="activity-h">
|
|
84
|
+
<h2 id="activity-h" class="scroll__h">Activity, by craft</h2>
|
|
85
|
+
<p class="scroll__lede" id="activity-lede">What this builder has done, seen one craft at a time.</p>
|
|
86
|
+
<div id="activity-tabs"></div>
|
|
87
|
+
<div id="activity-body">
|
|
88
|
+
<div class="profile__placeholder" id="activity-placeholder">…</div>
|
|
89
|
+
|
|
90
|
+
<div id="activity-tasks-panel" hidden>
|
|
91
|
+
<h3 class="scroll__h">Works completed</h3>
|
|
92
|
+
<div id="activity-tasks"></div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div id="activity-goals-panel" hidden>
|
|
96
|
+
<h3 class="scroll__h">Goals created</h3>
|
|
97
|
+
<div id="activity-goals"></div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<!-- The idea trail. Revealed only in the craft views where filing an
|
|
101
|
+
idea is the work being shown (all + ideator) — see profile.js. -->
|
|
102
|
+
<div id="activity-ideas-panel" hidden>
|
|
103
|
+
<h3 class="scroll__h">Ideas filed, and what became of them</h3>
|
|
104
|
+
<p class="scroll__lede">Every idea this builder captured — promoted, discarded, merged, or still open.</p>
|
|
105
|
+
<div id="activity-ideas"></div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</section>
|
|
109
|
+
|
|
78
110
|
<!-- Reserved slots — connections, guilds, and actions (profile-page
|
|
79
111
|
spec sections 6–8) mount here in later phases; keeping the order
|
|
80
112
|
fixed now means the page is not re-laid-out twice (Decision 5). -->
|
|
@@ -97,7 +129,7 @@
|
|
|
97
129
|
<script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
|
|
98
130
|
<!-- hall-kit (window.OTBKit) — profile.js renders ship-time task visuals through
|
|
99
131
|
its shared thumbnail helper (task 1003109), so it must load first. -->
|
|
100
|
-
<script src="/builders/hall-kit.js?v=2026-08-
|
|
101
|
-
<script src="/builders/profile.js?v=2026-08-
|
|
132
|
+
<script src="/builders/hall-kit.js?v=2026-08-27-activity"></script>
|
|
133
|
+
<script src="/builders/profile.js?v=2026-08-27-activity"></script>
|
|
102
134
|
</body>
|
|
103
135
|
</html>
|
|
@@ -242,6 +242,278 @@
|
|
|
242
242
|
section.hidden = false;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
// ---- render: activity, by craft -----------------------------------------
|
|
246
|
+
//
|
|
247
|
+
// Task 1003281, goal 1000072 / criterion legibility-activity-by-role. The
|
|
248
|
+
// sections above answer "how much has this person shipped"; this one answers
|
|
249
|
+
// "what did they do, AS an engineer / an artist / an ideator" — a different
|
|
250
|
+
// question per craft, which is why it is a view per craft and not a filter.
|
|
251
|
+
//
|
|
252
|
+
// THE IDEA TRAIL is the part that exists nowhere else, and the reason the
|
|
253
|
+
// criterion was written: every other idea surface is scoped to what is still
|
|
254
|
+
// OPEN, so an idea drops out of the system the moment it is triaged — exactly
|
|
255
|
+
// when its outcome becomes interesting. Here nothing you filed ever leaves the
|
|
256
|
+
// list, and each row carries its verdict: became a task (linked), discarded
|
|
257
|
+
// (with the triager's note), merged into another idea (linked), or STILL OPEN.
|
|
258
|
+
// Open reads as open, never as an absence.
|
|
259
|
+
//
|
|
260
|
+
// WHY IT NEEDS A SIGN-IN when the rest of the page does not: idea titles and
|
|
261
|
+
// triage notes are builder-visible, not world-visible, and a private goal's
|
|
262
|
+
// title is what ADR 0112 says a non-member must not learn. Signed out, this
|
|
263
|
+
// one section says so and the public portfolio above it is untouched.
|
|
264
|
+
//
|
|
265
|
+
// PAGING IS SERVER-SIDE. A prolific builder's record is long and the criterion
|
|
266
|
+
// asks for *every* idea, so each ledger asks for one page at a time through
|
|
267
|
+
// OTBKit's server-offset pager mode — the kit's own mode, added by this task,
|
|
268
|
+
// rather than an eleventh hand-rolled pager (hall-ui/CLAUDE.md's hard rule).
|
|
269
|
+
|
|
270
|
+
// The craft tabs. 'all' leads because most tasks in the corpus carry
|
|
271
|
+
// discipline='unclassified': a page that only offered the four named crafts
|
|
272
|
+
// would hide the majority of a person's record and read as "you did less than
|
|
273
|
+
// you did". The four named crafts match the Goals page's Craft filter
|
|
274
|
+
// (goals-page.js f-goal-role) so the two surfaces speak one vocabulary.
|
|
275
|
+
const CRAFT_TABS = [
|
|
276
|
+
{ id: 'all', label: 'All', role: null },
|
|
277
|
+
{ id: 'engineer', label: 'Engineer', role: 'engineer' },
|
|
278
|
+
{ id: 'artist', label: 'Artist', role: 'artist' },
|
|
279
|
+
{ id: 'ideator', label: 'Ideator', role: 'ideator' },
|
|
280
|
+
{ id: 'ui', label: 'UI', role: 'ui' },
|
|
281
|
+
];
|
|
282
|
+
// Filing an idea is ideator work whatever craft the resulting task turned out
|
|
283
|
+
// to be, so the trail belongs to the ideator view — and to 'all', which is the
|
|
284
|
+
// whole record by definition. It is not a per-craft slice of the ideas.
|
|
285
|
+
const TRAIL_TABS = new Set(['all', 'ideator']);
|
|
286
|
+
|
|
287
|
+
// Every href below goes through BOTH encodings, and it needs both: the ids are
|
|
288
|
+
// DB integers today, but encodeURIComponent leaves a literal `'` alone, so on
|
|
289
|
+
// its own it is a URL encoder standing in for an HTML-attribute escape. escape
|
|
290
|
+
// the URL for the path, escape the result for the attribute (grader nit).
|
|
291
|
+
const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
|
|
292
|
+
const href = (pathStr) => escapeHtml(pathStr);
|
|
293
|
+
const taskHref = (id) => href(`${hallBase()}/task/${encodeURIComponent(id)}`);
|
|
294
|
+
const ideaHref = (id) => href(`${hallBase()}/idea/${encodeURIComponent(id)}`);
|
|
295
|
+
const goalHref = (id) => href(`${hallBase()}/goals#/goal/${encodeURIComponent(id)}`);
|
|
296
|
+
|
|
297
|
+
// What became of one idea, in a sentence a person can read. The four outcomes
|
|
298
|
+
// the criterion names, each linked through to the thing it became.
|
|
299
|
+
function ideaOutcomeHtml(idea) {
|
|
300
|
+
const note = idea.triage_note
|
|
301
|
+
? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(idea.triage_note)}</span>`
|
|
302
|
+
: '';
|
|
303
|
+
if (idea.status === 'promoted') {
|
|
304
|
+
// ideas_task_lineage is the complete answer (one idea can become many
|
|
305
|
+
// tasks, task 1002997); promoted_to_task_id is the only answer for
|
|
306
|
+
// promotions that predate that table, so fall back to it.
|
|
307
|
+
const lineage = Array.isArray(idea.lineage_tasks) ? idea.lineage_tasks : [];
|
|
308
|
+
const links = lineage.length
|
|
309
|
+
? lineage.map((t) => `<a href="${taskHref(t.task_id)}">#${escapeHtml(String(t.task_id))} ${escapeHtml(t.title || '')}</a>`)
|
|
310
|
+
: (idea.promoted_to_task_id
|
|
311
|
+
? [`<a href="${taskHref(idea.promoted_to_task_id)}">#${escapeHtml(String(idea.promoted_to_task_id))} ${escapeHtml(idea.promoted_task_title || '')}</a>`]
|
|
312
|
+
: []);
|
|
313
|
+
// 'promoted' with nothing to point at is a real (old) state, not a bug to
|
|
314
|
+
// paper over — say what is known rather than inventing a link.
|
|
315
|
+
if (!links.length) return `<span class="task__tag">Promoted</span>${note}`;
|
|
316
|
+
return `<span class="task__tag">Became ${links.length === 1 ? 'a task' : `${links.length} tasks`}</span> ${links.join(' · ')}${note}`;
|
|
317
|
+
}
|
|
318
|
+
if (idea.status === 'merged') {
|
|
319
|
+
const target = idea.merged_into_idea_id
|
|
320
|
+
? ` <a href="${ideaHref(idea.merged_into_idea_id)}">#${escapeHtml(String(idea.merged_into_idea_id))} ${escapeHtml(idea.merged_into_idea_title || '')}</a>`
|
|
321
|
+
: '';
|
|
322
|
+
return `<span class="task__tag">Merged into</span>${target}${note}`;
|
|
323
|
+
}
|
|
324
|
+
if (idea.status === 'discarded') {
|
|
325
|
+
return `<span class="task__tag">Discarded</span>${note}`;
|
|
326
|
+
}
|
|
327
|
+
return `<span class="task__tag">Still open</span>${note}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// One ledger driven by one server-paged endpoint. `fetchPage(offset, perPage)`
|
|
331
|
+
// resolves to { rows, total }; everything else — the pager wiring, the empty
|
|
332
|
+
// state, the reload-on-craft-change — is the same for all three lists, so it
|
|
333
|
+
// is written once here rather than three times below.
|
|
334
|
+
function makeActivityList({ mountId, storageKey, columns, rowHtml, emptyMsg, fetchPage }) {
|
|
335
|
+
const mount = $(`#${mountId}`);
|
|
336
|
+
if (!mount || !window.OTBKit) return null;
|
|
337
|
+
const pager = window.OTBKit.makePager(mountId, storageKey, { defaultPerPage: 10, minPerPage: 10 });
|
|
338
|
+
let inFlight = 0;
|
|
339
|
+
const ledger = window.OTBKit.makeLedger({
|
|
340
|
+
mount, columns, rowHtml, emptyMsg,
|
|
341
|
+
serverPager: pager,
|
|
342
|
+
onPage: () => load(),
|
|
343
|
+
});
|
|
344
|
+
async function load() {
|
|
345
|
+
const seq = ++inFlight;
|
|
346
|
+
try {
|
|
347
|
+
const out = await fetchPage(window.OTBKit.pageOffset(pager), pager.perPage);
|
|
348
|
+
// A craft switch or a fast pager click can land two loads at once; only
|
|
349
|
+
// the newest may paint, or the ledger shows a page nobody asked for.
|
|
350
|
+
if (seq !== inFlight) return;
|
|
351
|
+
ledger.render(out.rows, { total: out.total });
|
|
352
|
+
return out.total;
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (seq !== inFlight) return;
|
|
355
|
+
console.error(`[profile] activity list ${mountId} failed`, err);
|
|
356
|
+
ledger.render([], { total: 0 });
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
return { reset() { pager.page = 1; }, load, ledger };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Built once, on first render, and kept — switching craft re-fetches, it does
|
|
364
|
+
// not rebuild the DOM (which would drop the reader's per-page preference).
|
|
365
|
+
let activity = null;
|
|
366
|
+
|
|
367
|
+
function buildActivity(builderId) {
|
|
368
|
+
const kit = window.OTBKit;
|
|
369
|
+
if (!kit) return null;
|
|
370
|
+
let role = null;
|
|
371
|
+
|
|
372
|
+
const tasks = makeActivityList({
|
|
373
|
+
mountId: 'activity-tasks',
|
|
374
|
+
storageKey: 'otb.profile.activityTasksPerPage',
|
|
375
|
+
columns: [
|
|
376
|
+
{ label: '#', class: 'ledger__num' },
|
|
377
|
+
'Work',
|
|
378
|
+
{ label: 'Craft' },
|
|
379
|
+
{ label: 'Shipped', class: 'ledger__date' },
|
|
380
|
+
],
|
|
381
|
+
emptyMsg: 'No completed works in this craft.',
|
|
382
|
+
fetchPage: async (offset, limit) => {
|
|
383
|
+
const res = await api.request('GET', `${API}/builders/${encodeURIComponent(builderId)}/activity`
|
|
384
|
+
+ `?section=tasks&limit=${limit}&offset=${offset}${role ? `&role=${encodeURIComponent(role)}` : ''}`);
|
|
385
|
+
if (!res.ok) throw new Error(`activity tasks → ${res.status}`);
|
|
386
|
+
return { rows: res.data.rows || [], total: Number(res.data.total) || 0 };
|
|
387
|
+
},
|
|
388
|
+
rowHtml: (t) => {
|
|
389
|
+
const summary = t.value_summary
|
|
390
|
+
? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(t.value_summary)}</span>`
|
|
391
|
+
: '';
|
|
392
|
+
const visual = kit.shipVisualThumbHtml ? kit.shipVisualThumbHtml(t) : '';
|
|
393
|
+
return `<tr>
|
|
394
|
+
<td class="ledger__num">#${escapeHtml(String(t.id))}</td>
|
|
395
|
+
<td><a href="${taskHref(t.id)}">${escapeHtml(t.title || `Task ${t.id}`)}</a>${summary}${visual ? `<span style="display:block;margin-top:6px">${visual}</span>` : ''}</td>
|
|
396
|
+
<td><span class="task__tag">${escapeHtml(t.discipline || 'unclassified')}</span></td>
|
|
397
|
+
<td class="ledger__date">${fmtDate(t.shipped_at)}</td>
|
|
398
|
+
</tr>`;
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const goals = makeActivityList({
|
|
403
|
+
mountId: 'activity-goals',
|
|
404
|
+
storageKey: 'otb.profile.activityGoalsPerPage',
|
|
405
|
+
columns: [
|
|
406
|
+
{ label: '#', class: 'ledger__num' },
|
|
407
|
+
'Goal',
|
|
408
|
+
'Crafts',
|
|
409
|
+
'Progress',
|
|
410
|
+
],
|
|
411
|
+
emptyMsg: 'No goals created in this craft.',
|
|
412
|
+
fetchPage: async (offset, limit) => {
|
|
413
|
+
const res = await api.request('GET', `${API}/builders/${encodeURIComponent(builderId)}/activity`
|
|
414
|
+
+ `?section=goals&limit=${limit}&offset=${offset}${role ? `&role=${encodeURIComponent(role)}` : ''}`);
|
|
415
|
+
if (!res.ok) throw new Error(`activity goals → ${res.status}`);
|
|
416
|
+
return { rows: res.data.rows || [], total: Number(res.data.total) || 0 };
|
|
417
|
+
},
|
|
418
|
+
rowHtml: (g) => {
|
|
419
|
+
const sub = g.subtitle
|
|
420
|
+
? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(g.subtitle)}</span>`
|
|
421
|
+
: '';
|
|
422
|
+
const crafts = (Array.isArray(g.disciplines) ? g.disciplines : [])
|
|
423
|
+
.map((d) => `<span class="task__tag">${escapeHtml(d)}</span>`).join(' ') || '—';
|
|
424
|
+
const shipped = Number(g.task_shipped) || 0;
|
|
425
|
+
const total = Number(g.task_total) || 0;
|
|
426
|
+
return `<tr>
|
|
427
|
+
<td class="ledger__num">#${escapeHtml(String(g.id))}</td>
|
|
428
|
+
<td><a href="${goalHref(g.id)}">${escapeHtml(g.title || `Goal ${g.id}`)}</a>${sub}</td>
|
|
429
|
+
<td>${crafts}</td>
|
|
430
|
+
<td>${fmtNum(shipped)} of ${fmtNum(total)} shipped</td>
|
|
431
|
+
</tr>`;
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const ideas = makeActivityList({
|
|
436
|
+
mountId: 'activity-ideas',
|
|
437
|
+
storageKey: 'otb.profile.activityIdeasPerPage',
|
|
438
|
+
columns: [
|
|
439
|
+
{ label: '#', class: 'ledger__num' },
|
|
440
|
+
'Idea',
|
|
441
|
+
'What became of it',
|
|
442
|
+
{ label: 'Filed', class: 'ledger__date' },
|
|
443
|
+
],
|
|
444
|
+
emptyMsg: 'No ideas filed yet.',
|
|
445
|
+
fetchPage: async (offset, limit) => {
|
|
446
|
+
const res = await api.request('GET', `${API}/inbox/by-builder/${encodeURIComponent(builderId)}`
|
|
447
|
+
+ `?limit=${limit}&offset=${offset}`);
|
|
448
|
+
if (!res.ok) throw new Error(`idea trail → ${res.status}`);
|
|
449
|
+
return { rows: res.data.ideas || [], total: Number(res.data.total) || 0 };
|
|
450
|
+
},
|
|
451
|
+
rowHtml: (i) => `<tr>
|
|
452
|
+
<td class="ledger__num">#${escapeHtml(String(i.id))}</td>
|
|
453
|
+
<td><a href="${ideaHref(i.id)}">${escapeHtml(i.title || `Idea ${i.id}`)}</a></td>
|
|
454
|
+
<td>${ideaOutcomeHtml(i)}</td>
|
|
455
|
+
<td class="ledger__date">${fmtDate(i.captured_at)}</td>
|
|
456
|
+
</tr>`,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// NO `panel` PER TAB, and that is deliberate. makeTabs toggles [hidden] on
|
|
460
|
+
// one panel PER TAB; here every craft shows the same three ledgers with
|
|
461
|
+
// different rows, so pointing all five tabs at one element would make the
|
|
462
|
+
// last tab's `hidden = (id !== 'ui')` win and blank the section. Omitting
|
|
463
|
+
// `panel` makes the toggle a no-op ($id(undefined) is null) and leaves this
|
|
464
|
+
// file owning what a craft change means: a re-fetch, not a panel swap.
|
|
465
|
+
const tabs = kit.makeTabs({
|
|
466
|
+
mount: $('#activity-tabs'),
|
|
467
|
+
tabs: CRAFT_TABS.map((t) => ({ id: t.id, label: t.label })),
|
|
468
|
+
onChange: (id) => {
|
|
469
|
+
const tab = CRAFT_TABS.find((t) => t.id === id) || CRAFT_TABS[0];
|
|
470
|
+
role = tab.role;
|
|
471
|
+
show(id);
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
function show(tabId) {
|
|
476
|
+
const trail = TRAIL_TABS.has(tabId);
|
|
477
|
+
const ideasPanel = $('#activity-ideas-panel');
|
|
478
|
+
if (ideasPanel) ideasPanel.hidden = !trail;
|
|
479
|
+
[tasks, goals].forEach((l) => { if (l) { l.reset(); l.load(); } });
|
|
480
|
+
if (trail && ideas) { ideas.reset(); ideas.load(); }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// makeTabs activates its initial tab during construction, which already ran
|
|
484
|
+
// onChange → show(). So the caller must NOT kick it again on first build.
|
|
485
|
+
return { show, active: () => tabs.active() || 'all' };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function renderActivity(builderId, signedIn) {
|
|
489
|
+
const section = $('#activity-scroll');
|
|
490
|
+
const placeholder = $('#activity-placeholder');
|
|
491
|
+
if (!section) return;
|
|
492
|
+
if (!signedIn) {
|
|
493
|
+
// The public portfolio above stays public; this one section says plainly
|
|
494
|
+
// why it is not, rather than rendering an empty log that reads as "this
|
|
495
|
+
// person did nothing".
|
|
496
|
+
if (placeholder) {
|
|
497
|
+
placeholder.hidden = false;
|
|
498
|
+
placeholder.innerHTML = `Idea trails and goals are visible to signed-in builders — `
|
|
499
|
+
+ `<a href="${API}/auth/web/start">sign in</a> to see this builder's activity.`;
|
|
500
|
+
}
|
|
501
|
+
['activity-tasks-panel', 'activity-goals-panel', 'activity-ideas-panel']
|
|
502
|
+
.forEach((id) => { const el = $(`#${id}`); if (el) el.hidden = true; });
|
|
503
|
+
const tabsMount = $('#activity-tabs');
|
|
504
|
+
if (tabsMount) tabsMount.innerHTML = '';
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (placeholder) { placeholder.hidden = true; placeholder.innerHTML = ''; }
|
|
508
|
+
['activity-tasks-panel', 'activity-goals-panel']
|
|
509
|
+
.forEach((id) => { const el = $(`#${id}`); if (el) el.hidden = false; });
|
|
510
|
+
if (!activity) {
|
|
511
|
+
activity = buildActivity(builderId); // its makeTabs already loaded page 1
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
activity.show(activity.active());
|
|
515
|
+
}
|
|
516
|
+
|
|
245
517
|
// ---- states -------------------------------------------------------------
|
|
246
518
|
|
|
247
519
|
function showMessage(text) {
|
|
@@ -259,6 +531,9 @@
|
|
|
259
531
|
renderCrossProject(data, selfId);
|
|
260
532
|
renderAchievements(data.achievements);
|
|
261
533
|
renderWorks(data.recent_ships);
|
|
534
|
+
// The activity log needs a session (private goals + the idea trail), so it
|
|
535
|
+
// keys off whether /me resolved — the same signal the self-detect uses.
|
|
536
|
+
renderActivity(builder.id, selfId != null);
|
|
262
537
|
}
|
|
263
538
|
|
|
264
539
|
// Render the friendly "no builder chosen" empty state for a signed-out visitor
|
|
@@ -281,12 +556,16 @@
|
|
|
281
556
|
const works = $('#works-table tbody'); if (works) works.innerHTML = '';
|
|
282
557
|
const worksLede = $('#works-lede'); if (worksLede) worksLede.hidden = true;
|
|
283
558
|
const xp = $('#xp-scroll'); if (xp) xp.hidden = true;
|
|
559
|
+
const act = $('#activity-scroll'); if (act) act.hidden = true;
|
|
284
560
|
}
|
|
285
561
|
|
|
286
562
|
async function fetchProfile(id, selfId) {
|
|
287
563
|
const res = await api.request('GET', `${API}/builders/${encodeURIComponent(id)}/profile`);
|
|
288
564
|
if (res.status === 404) {
|
|
289
565
|
showMessage('No builder found with that id.');
|
|
566
|
+
// There is no builder, so there is no activity to log — hide the section
|
|
567
|
+
// rather than leaving its loading dots on a page about nobody.
|
|
568
|
+
const act = $('#activity-scroll'); if (act) act.hidden = true;
|
|
290
569
|
return;
|
|
291
570
|
}
|
|
292
571
|
if (!res.ok) throw new Error(`profile → ${res.status}`);
|