@bongos/core 1.19.380 → 1.19.382

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.
@@ -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}`);
@@ -554,6 +554,97 @@ async function countOpenIdeas({ viewerRank = null } = {}) {
554
554
  return rows[0].n;
555
555
  }
556
556
 
557
+ // -------------------------------------------------------------------------
558
+ // The idea TRAIL — every idea one builder filed, and what became of it
559
+ // (task 1003281, goal 1000072, criterion legibility-activity-by-role).
560
+ //
561
+ // THE READ THAT EXISTS NOWHERE ELSE, and the reason the criterion was written:
562
+ // the owner could file an idea and then never find out what happened to it.
563
+ // Every other idea read is scoped to what is STILL OPEN — listOpenIdeas, the
564
+ // spark queue, the nod queue — so a promoted, discarded or merged idea drops
565
+ // out of every surface the moment it is triaged, which is exactly when its
566
+ // outcome becomes interesting. This read is scoped to a PERSON instead of to a
567
+ // status, so nothing they filed ever leaves it.
568
+ //
569
+ // NO NEW STORAGE. The four outcomes are already on the row: `status` says which
570
+ // one, `promoted_to_task_id` + ideas_task_lineage say which task a promotion
571
+ // produced, `merged_into_idea_id` says which idea absorbed a merge, and
572
+ // `triage_note` carries the triager's sentence (core_195, task 1002447). The
573
+ // joins below turn those ids into names so a reader gets "became task #1003281
574
+ // — See what you did, by role" rather than a number they must go look up.
575
+ //
576
+ // AN OPEN IDEA IS AN OUTCOME. It reads as "still open", never as an absence —
577
+ // one of the four the criterion names. That is why status is returned verbatim
578
+ // and the surface renders it, rather than the read filtering open rows out.
579
+ //
580
+ // LINEAGE, NOT JUST THE LEGACY COLUMN. One idea can become MANY tasks
581
+ // (ideas_task_lineage, task 1002997), while `promoted_to_task_id` holds only the
582
+ // first. Both are returned: the lineage array is the complete answer, the legacy
583
+ // column is the only answer for promotions that predate the lineage table. The
584
+ // LATERAL is bounded by the page window, so this stays one query, not an N+1.
585
+ const IDEA_TRAIL_DEFAULT_LIMIT = 20;
586
+ const IDEA_TRAIL_MAX_LIMIT = 100;
587
+
588
+ // The WHERE this list and its count share, so the pager can never claim rows the
589
+ // list will not show. viewerRank applies the SAME skill-friction gate as every
590
+ // other idea read (V3.R70 / #289) — including on a builder reading their OWN
591
+ // trail: the gate is about what a rank may see, not about authorship.
592
+ function ideaTrailWhere(builderId, viewerRank, params) {
593
+ params.push(builderId);
594
+ const where = [`i.captured_by = $${params.length}`];
595
+ if (!rankCanSeeSkillFriction(viewerRank)) {
596
+ params.push(`${SKILL_FRICTION_TAG}%`);
597
+ where.push(`i.title NOT LIKE $${params.length}`);
598
+ }
599
+ return where.join(' AND ');
600
+ }
601
+
602
+ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_LIMIT, offset = 0, viewerRank = null } = {}, deps = {}) {
603
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
604
+ const lim = Math.min(Math.max(Number.isInteger(Number(limit)) ? Number(limit) : IDEA_TRAIL_DEFAULT_LIMIT, 1), IDEA_TRAIL_MAX_LIMIT);
605
+ const off = Number.isInteger(Number(offset)) && Number(offset) > 0 ? Number(offset) : 0;
606
+ const params = [];
607
+ const where = ideaTrailWhere(builderId, viewerRank, params);
608
+ params.push(lim, off);
609
+ const { rows } = await activePool.query(
610
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
611
+ pt.title AS promoted_task_title,
612
+ pt.status AS promoted_task_status,
613
+ mi.title AS merged_into_idea_title,
614
+ mi.status AS merged_into_idea_status,
615
+ COALESCE(lin.tasks, '[]'::json) AS lineage_tasks
616
+ FROM idea_inbox i
617
+ ${IDEA_JOIN_GRADE}
618
+ LEFT JOIN tasks pt ON pt.id = i.promoted_to_task_id
619
+ LEFT JOIN idea_inbox mi ON mi.id = i.merged_into_idea_id
620
+ LEFT JOIN LATERAL (
621
+ SELECT json_agg(json_build_object(
622
+ 'task_id', l.task_id, 'origin', l.origin,
623
+ 'title', lt.title, 'status', lt.status
624
+ ) ORDER BY l.task_id) AS tasks
625
+ FROM ideas_task_lineage l
626
+ JOIN tasks lt ON lt.id = l.task_id
627
+ WHERE l.idea_id = i.id
628
+ ) lin ON true
629
+ WHERE ${where}
630
+ ORDER BY i.captured_at DESC, i.id DESC
631
+ LIMIT $${params.length - 1} OFFSET $${params.length}`,
632
+ params
633
+ );
634
+ return rows;
635
+ }
636
+
637
+ async function countIdeaTrailForBuilder(builderId, { viewerRank = null } = {}, deps = {}) {
638
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
639
+ const params = [];
640
+ const where = ideaTrailWhere(builderId, viewerRank, params);
641
+ const { rows } = await activePool.query(
642
+ `SELECT count(*)::int AS n FROM idea_inbox i WHERE ${where}`,
643
+ params
644
+ );
645
+ return rows[0].n;
646
+ }
647
+
557
648
  // -------------------------------------------------------------------------
558
649
  // Update / merge
559
650
  // -------------------------------------------------------------------------
@@ -1298,6 +1389,13 @@ module.exports = {
1298
1389
  // the spark queue (task 1002921, BV1.R145) — sparks awaiting development
1299
1390
  listSparkQueue,
1300
1391
  countSparkQueue,
1392
+ // the idea TRAIL (task 1003281) — every idea one builder filed, with what
1393
+ // became of it. Scoped to a person, not to a status, so a triaged idea never
1394
+ // drops out of the one surface that answers "what happened to my idea".
1395
+ listIdeaTrailForBuilder,
1396
+ countIdeaTrailForBuilder,
1397
+ IDEA_TRAIL_DEFAULT_LIMIT,
1398
+ IDEA_TRAIL_MAX_LIMIT,
1301
1399
  // criterion-proposal ratification (V3.R31 / #252)
1302
1400
  ratifyCriterionProposal,
1303
1401
  parseProposalBody,
@@ -470,6 +470,45 @@ module.exports = function buildInboxRouter() {
470
470
  ]);
471
471
  return res.json({ sparks, count });
472
472
  }, { errorCode: 'spark_queue_failed' }));
473
+ // rank: any-builder — THE IDEA TRAIL: every idea one builder filed, newest
474
+ // first, with what became of each (task 1003281, goal 1000072, criterion
475
+ // legibility-activity-by-role). Backs the ideator view of the profile's
476
+ // activity log; the tasks and goals halves of that log are served by the core
477
+ // GET /builders/:id/activity, because they are lifecycle rows, not ideas.
478
+ //
479
+ // WHY IT IS A LIST ROUTE AND NOT A FIELD ON THE PROFILE: the trail is paged.
480
+ // A prolific filer's trail is long, and the criterion asks for "every idea you
481
+ // submitted", so it cannot be a top-N attached to another response.
482
+ //
483
+ // Gated to any signed-in builder, matching GET /inbox/:id directly below: idea
484
+ // TITLES and triage notes are builder-visible, not world-visible, and the
485
+ // public profile route deliberately carries none of them. The viewer's rank
486
+ // rides into the read so the skill-friction gate holds here as it does on
487
+ // every other idea list.
488
+ //
489
+ // DECLARED BEFORE `GET /inbox/:id` DELIBERATELY — the fourth route in this file
490
+ // to need that. `/inbox/by-builder/41` would otherwise bind `:id='by-builder'`
491
+ // and parseId would 400 the whole trail. tests/profile_activity.mjs pins the
492
+ // relative order.
493
+ router.get('/inbox/by-builder/:id', api.requireBuilder, api.asyncHandler('GET /inbox/by-builder/:id', async (req, res) => {
494
+ const builderId = parseId(req, res, { code: 'bad_builder_id', positiveInt: true });
495
+ if (builderId === null) return;
496
+ const limitRaw = Number(req.query.limit);
497
+ const limit = Number.isInteger(limitRaw) && limitRaw > 0 && limitRaw <= inbox.IDEA_TRAIL_MAX_LIMIT
498
+ ? limitRaw
499
+ : inbox.IDEA_TRAIL_DEFAULT_LIMIT;
500
+ const offsetRaw = Number(req.query.offset);
501
+ const offset = Number.isInteger(offsetRaw) && offsetRaw > 0 ? offsetRaw : 0;
502
+ // Both halves take the viewer's rank so the count above the list can never
503
+ // claim rows the list will not show (the /inbox/sparks precedent).
504
+ const viewerRank = req.builder && req.builder.rank;
505
+ const [ideas, total] = await Promise.all([
506
+ inbox.listIdeaTrailForBuilder(builderId, { limit, offset, viewerRank }),
507
+ inbox.countIdeaTrailForBuilder(builderId, { viewerRank }),
508
+ ]);
509
+ return res.json({ builder_id: builderId, ideas, total, limit, offset });
510
+ }, { errorCode: 'idea_trail_failed' }));
511
+
473
512
  // rank: any-builder — single idea read for the "#NNN" deep-link resolver
474
513
  // (#1047). Mirrors GET /blockers/:id. The hall resolves /#/idea/N by id, but
475
514
  // the /inbox LIST returns only the open-first-50, so a closed/older idea ref
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/activity.js — a builder's OWN track record, sliced by craft
4
+ // (task 1003281, goal 1000072, criterion legibility-activity-by-role).
5
+ //
6
+ // WHY IT EXISTS. The profile already answers "how much has this person shipped"
7
+ // (a count and the last five works). It could not answer "what did I do AS an
8
+ // artist", which is a different question from the engineer one and the reason
9
+ // the criterion asks for a view per role. These two reads are that axis: the
10
+ // tasks a builder completed and the goals they created, both filterable by
11
+ // craft and both PAGED — a prolific builder's record is long, and the goal's
12
+ // own scale check ("~1,357 tasks on BONGOS-V1 alone") applies to one person's
13
+ // slice of it too.
14
+ //
15
+ // "ROLE" HERE MEANS CRAFT — tasks.discipline (engineer | artist | ideator | ui |
16
+ // unclassified), the axis ADR 0086 already has and ADR 0174 reserves the word
17
+ // "role" for. A goal has no discipline column and deliberately must not get one
18
+ // (the same call task 1003278 made for the Goals page): a goal's craft set is
19
+ // what its tasks carry, derived here, never stored.
20
+ //
21
+ // UNCLASSIFIED IS NOT HIDDEN. Most tasks in the corpus carry
22
+ // discipline='unclassified', so a surface that only ever showed the four named
23
+ // crafts would silently drop the majority of a person's record and read as
24
+ // "you did less than you did". Passing no craft returns everything — that is
25
+ // the profile's default view, and the per-craft tabs narrow from it.
26
+ //
27
+ // PRIVATE GOALS STAY PRIVATE (ADR 0112's leak rule). A goal's prose — its title
28
+ // and subtitle — is the secret a non-member must not learn, so a private goal
29
+ // appears in this read ONLY for a member of it or an Archon. That makes the
30
+ // goals half viewer-dependent, which is why the route that serves it is
31
+ // signed-in rather than public.
32
+ //
33
+ // THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
34
+ // doorway ../../src/module-api.js — never a core internal, never a sibling module.
35
+
36
+ const api = require('../../src/module-api');
37
+ const { pool } = api;
38
+ const { SMOKE_NOT_LIKE } = require('./db-shared.js');
39
+ const { CREDITS_AWARDED_SUBQUERY } = require('./db-tasks.js');
40
+
41
+ // The craft vocabulary this read accepts. Mirrors tasks_discipline_check
42
+ // (migrations 086 + 176). Anything else is treated as "no filter" rather than
43
+ // 400ing the read: a caller naming a craft that does not exist gets the honest
44
+ // full list, not an error card in the middle of a profile.
45
+ const CRAFTS = ['engineer', 'artist', 'ideator', 'ui', 'unclassified'];
46
+
47
+ // One page of an activity list. Clamped here as well as at the route, because
48
+ // these are the functions a CLI or a later surface will call directly.
49
+ const DEFAULT_LIMIT = 20;
50
+ const MAX_LIMIT = 100;
51
+
52
+ function normalizeCraft(role) {
53
+ return CRAFTS.includes(role) ? role : null;
54
+ }
55
+
56
+ function clampLimit(limit) {
57
+ const n = Number(limit);
58
+ if (!Number.isInteger(n) || n < 1) return DEFAULT_LIMIT;
59
+ return Math.min(n, MAX_LIMIT);
60
+ }
61
+
62
+ function clampOffset(offset) {
63
+ const n = Number(offset);
64
+ return Number.isInteger(n) && n > 0 ? n : 0;
65
+ }
66
+
67
+ // activityTasksByBuilder — the tasks this builder COMPLETED, newest first.
68
+ //
69
+ // "Completed" is `shipped_by = me AND status = 'shipped'` — the same definition
70
+ // the profile's Recent works already uses (recentShippedTasksByBuilder), so the
71
+ // ledger and the count above it cannot disagree. Smoke tasks are excluded for
72
+ // the same reason they are excluded there: they are the ship pipeline testing
73
+ // itself, not something a person did.
74
+ //
75
+ // Returns { rows, total } — the total counts the whole filtered set, not the
76
+ // page, so the pager can say "of N" without a second call site inventing it.
77
+ async function activityTasksByBuilder(builderId, { role = null, limit = DEFAULT_LIMIT, offset = 0 } = {}, deps = {}) {
78
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
79
+ const craft = normalizeCraft(role);
80
+ const lim = clampLimit(limit);
81
+ const off = clampOffset(offset);
82
+
83
+ const params = [builderId];
84
+ let where = `t.shipped_by = $1 AND t.status = 'shipped' AND t.${SMOKE_NOT_LIKE}`;
85
+ if (craft) {
86
+ params.push(craft);
87
+ where += ` AND COALESCE(t.discipline, 'unclassified') = $${params.length}`;
88
+ }
89
+
90
+ const { rows: countRows } = await activePool.query(
91
+ `SELECT count(*)::int AS total FROM tasks t WHERE ${where}`,
92
+ params
93
+ );
94
+
95
+ const pageParams = [...params, lim, off];
96
+ const { rows } = await activePool.query(
97
+ `SELECT t.id, t.title, t.value_summary, t.visual_url, t.visual_alt,
98
+ t.version_id, t.goal_id, t.kind, t.status, t.shipped_at,
99
+ COALESCE(t.discipline, 'unclassified') AS discipline,
100
+ t.credits_reward, ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded
101
+ FROM tasks t
102
+ WHERE ${where}
103
+ ORDER BY t.shipped_at DESC NULLS LAST, t.id DESC
104
+ LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length}`,
105
+ pageParams
106
+ );
107
+ return { rows, total: countRows[0]?.total ?? 0 };
108
+ }
109
+
110
+ // activityGoalsByBuilder — the goals this builder CREATED, newest first, with
111
+ // the craft set their tasks carry and a readiness line.
112
+ //
113
+ // created_by is the goal's owner (task 1735), which is what "what did I create"
114
+ // means on this tier — holding a membership in someone else's goal is a
115
+ // different claim and is deliberately not folded in here.
116
+ //
117
+ // THE CRAFT FILTER APPLIES TO THE GOAL'S TASKS, not to the goal: a goal counts
118
+ // as artist work when at least one of its tasks is artist work. A goal with no
119
+ // tasks at all therefore matches NO craft filter and appears only in the
120
+ // unfiltered view — which is honest, because nothing has been done in it yet.
121
+ //
122
+ // VISIBILITY. `visibility='private'` goals are returned only to a member or an
123
+ // Archon (ADR 0112: a non-member must not learn a private goal's title). The
124
+ // filter is in SQL rather than a JS post-pass so the count and the page window
125
+ // agree — thinning rows after a LIMIT would make the pager lie.
126
+ async function activityGoalsByBuilder(
127
+ builderId,
128
+ { role = null, viewerId = null, viewerIsArchon = false, limit = DEFAULT_LIMIT, offset = 0 } = {},
129
+ deps = {}
130
+ ) {
131
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
132
+ const craft = normalizeCraft(role);
133
+ const lim = clampLimit(limit);
134
+ const off = clampOffset(offset);
135
+
136
+ // $1 creator · $2 viewer · $3 archon flag, then an optional craft.
137
+ const params = [builderId, viewerId ?? null, !!viewerIsArchon];
138
+ let craftClause = '';
139
+ if (craft) {
140
+ params.push(craft);
141
+ craftClause = `AND $${params.length} = ANY(c.disciplines)`;
142
+ }
143
+
144
+ // The rollup CTE is scoped to this builder's goals only, so it never walks the
145
+ // whole tasks table — the reason this is a join over `mine` rather than a call
146
+ // into the version-wide goalRollups() read.
147
+ const base = `
148
+ WITH mine AS (
149
+ SELECT g.id, g.version_id, g.title, g.subtitle, g.status, g.visibility,
150
+ g.created_at, g.updated_at
151
+ FROM goals g
152
+ WHERE g.created_by = $1
153
+ AND (g.visibility = 'public'
154
+ OR $3::boolean
155
+ OR EXISTS (SELECT 1 FROM goal_members gm
156
+ WHERE gm.goal_id = g.id AND gm.builder_id = $2::bigint))
157
+ ), c AS (
158
+ SELECT t.goal_id,
159
+ array_agg(DISTINCT COALESCE(t.discipline, 'unclassified')) AS disciplines,
160
+ count(*)::int AS task_total,
161
+ count(*) FILTER (WHERE t.status = 'shipped')::int AS task_shipped,
162
+ max(GREATEST(COALESCE(t.updated_at, 'epoch'::timestamptz),
163
+ COALESCE(t.shipped_at, 'epoch'::timestamptz))) AS last_activity_at
164
+ FROM tasks t
165
+ WHERE t.goal_id IN (SELECT id FROM mine)
166
+ AND t.${SMOKE_NOT_LIKE}
167
+ GROUP BY t.goal_id
168
+ )`;
169
+
170
+ const { rows: countRows } = await activePool.query(
171
+ `${base}
172
+ SELECT count(*)::int AS total
173
+ FROM mine m LEFT JOIN c ON c.goal_id = m.id
174
+ WHERE true ${craftClause}`,
175
+ params
176
+ );
177
+
178
+ const pageParams = [...params, lim, off];
179
+ const { rows } = await activePool.query(
180
+ `${base}
181
+ SELECT m.id, m.version_id, m.title, m.subtitle, m.status, m.visibility,
182
+ m.created_at, m.updated_at,
183
+ COALESCE(c.disciplines, ARRAY[]::text[]) AS disciplines,
184
+ COALESCE(c.task_total, 0) AS task_total,
185
+ COALESCE(c.task_shipped, 0) AS task_shipped,
186
+ c.last_activity_at
187
+ FROM mine m LEFT JOIN c ON c.goal_id = m.id
188
+ WHERE true ${craftClause}
189
+ ORDER BY m.created_at DESC, m.id DESC
190
+ LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length}`,
191
+ pageParams
192
+ );
193
+ return { rows, total: countRows[0]?.total ?? 0 };
194
+ }
195
+
196
+ module.exports = {
197
+ CRAFTS,
198
+ DEFAULT_LIMIT,
199
+ MAX_LIMIT,
200
+ activityGoalsByBuilder,
201
+ activityTasksByBuilder,
202
+ clampLimit,
203
+ clampOffset,
204
+ normalizeCraft,
205
+ };