@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.
@@ -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
- function makeLedger({ mount, columns = [], rowHtml, emptyMsg = 'Nothing here yet.', emptyAction = null, pager = null }) {
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
- ${pager ? pagerBarHtml(pager.prefix, { label: 'rows' }) : ''}`;
208
+ ${bar ? pagerBarHtml(bar.prefix, { label: 'rows' }) : ''}`;
158
209
  const tbody = mount.querySelector('tbody');
159
210
  let last = [];
160
- function render(rows) {
211
+ let lastTotal = 0;
212
+ function render(rows, opts = {}) {
161
213
  if (Array.isArray(rows)) last = rows;
162
- const slice = pager ? paginate(last, pager) : last;
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-25-visual"></script>
101
- <script src="/builders/profile.js?v=2026-08-25-visual"></script>
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}`);