@bongos/core 1.19.332 → 1.19.334

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.
Files changed (62) hide show
  1. package/.bongos-core.json +173 -43
  2. package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +84 -0
  3. package/docs/adr/README.md +1 -0
  4. package/docs/api/openapi.json +2 -2
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +202 -202
  7. package/docs/copy-registry.json +384 -384
  8. package/modules/grading/grader-config.js +98 -0
  9. package/modules/grading/grader-model.js +150 -0
  10. package/modules/grading/grader-prompt.js +139 -0
  11. package/modules/grading/grader-score.js +706 -0
  12. package/modules/grading/grader-subagent.js +328 -0
  13. package/modules/grading/grader.js +5 -1287
  14. package/modules/hall-ui/public/builders.js +19 -1182
  15. package/modules/hall-ui/public/goals-page.js +11 -997
  16. package/modules/hall-ui/public/goals-render.js +1032 -0
  17. package/modules/hall-ui/public/goals.html +4 -0
  18. package/modules/hall-ui/public/hall-render.js +1211 -0
  19. package/modules/hall-ui/public/index.html +6 -0
  20. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  21. package/modules/lifecycle/routes/goals.js +1 -367
  22. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  23. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  24. package/modules/lifecycle/routes/tasks.js +3 -696
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/gds/box-infra.js +280 -0
  28. package/scripts/gds/box.js +1 -245
  29. package/scripts/gds/fitness-checks-identity.js +320 -0
  30. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  31. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  32. package/scripts/gds/fitness-lib.js +82 -0
  33. package/scripts/gds/fitness.js +55 -668
  34. package/scripts/gds/provision-config.js +204 -0
  35. package/scripts/gds/provision-net.js +221 -0
  36. package/scripts/gds/provision-repo.js +849 -0
  37. package/scripts/gds/provision-units.js +262 -0
  38. package/scripts/gds/provision.js +4 -1395
  39. package/src/bongos/auth-admission.js +264 -0
  40. package/src/bongos/auth-config.js +151 -0
  41. package/src/bongos/auth-discord.js +198 -0
  42. package/src/bongos/auth-github.js +336 -0
  43. package/src/bongos/auth-idp.js +330 -0
  44. package/src/bongos/auth.js +5 -1100
  45. package/src/bongos/routes/auth.js +54 -10
  46. package/src/module-api.js +5 -1
  47. package/tests/auth_admission_gate.mjs +3 -1
  48. package/tests/auth_web_recovery.mjs +63 -0
  49. package/tests/est_advisory.mjs +3 -1
  50. package/tests/goal_advisory.mjs +3 -2
  51. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  52. package/tests/goal_subtitle.mjs +5 -2
  53. package/tests/goals_card_view.mjs +10 -3
  54. package/tests/grader_generated_provenance.mjs +3 -1
  55. package/tests/hall_palette.mjs +3 -1
  56. package/tests/helpers.mjs +58 -25
  57. package/tests/lib_sh_resolution.mjs +3 -1
  58. package/tests/membership_kind_lockstep.mjs +3 -1
  59. package/tests/migration_allocation.mjs +5 -2
  60. package/tests/patch_gate_ordering.mjs +3 -1
  61. package/tests/rank_tier_single_source.mjs +16 -3
  62. package/tests/reward_gate.mjs +3 -2
@@ -49,21 +49,6 @@
49
49
 
50
50
  // task 1888 — the Work tables and the conflicts list render capped; the pager
51
51
  // under each expands to the full set.
52
- const CONFLICT_CAP = 3;
53
- // task 1828 — the band's Completion / Members / Conflicts columns each cap
54
- // their list and offer a "View all →" into a dedicated full view (these grow:
55
- // more members, more criteria). Conflicts uses CONFLICT_CAP above.
56
- const MEMBER_CAP = 5;
57
- const CRITERIA_CAP = 4;
58
- // task 1932/1956 → kit (task 2171): the goal-card grid + the goal-detail Work
59
- // tables (To do / Completed) all paginate through the shared OTBKit pager.
60
- // localStorage keys are unchanged so builders' per-page prefs survive; the bar
61
- // markup is stamped into the #…-pager-mount divs at boot via pagerBarHtml.
62
- const pagers = {
63
- list: makePager('goals', 'otb.goals.perPage'),
64
- todo: makePager('goal-todo', 'otb.goals.todoPerPage'),
65
- done: makePager('goal-done', 'otb.goals.donePerPage'),
66
- };
67
52
  // Record deep-links (#/task|idea|blocker/N) are owned by the shared overlay
68
53
  // (task-detail.js). This page must NOT treat them as a route change (Bug 2).
69
54
  const DEEPLINK_RE = /^#\/(?:task|idea|blocker)\/\d+$/;
@@ -133,19 +118,11 @@
133
118
  // The goal's Done tab groups 'completed' (unsettled, credits not yet landed)
134
119
  // alongside confirmed/shipped, so per-row settle-awareness is required. Local
135
120
  // wrappers with a defensive fallback for the (hall-wide) brand-apply helpers.
136
- const taskSettled = (t) => window.OTBBranding?.taskSettled?.(t) ?? (!!t && (t.status === 'confirmed' || t.status === 'shipped'));
137
- const creditVal = (t) => Number(window.OTBBranding?.taskCreditValue?.(t) ?? t?.credits_reward) || 0;
138
121
  const selfId = () => state.me?.builder?.id ?? null;
139
122
  const sameId = (a, b) => a != null && b != null && String(a) === String(b);
140
123
 
141
- function statusBadge(status) {
142
- const cls = status === 'achieved' ? 'goal-badge--achieved'
143
- : (status === 'archived' ? 'goal-badge--archived' : 'goal-badge--open');
144
- return `<span class="goal-badge ${cls}">${escapeHtml(status)}</span>`;
145
- }
146
124
 
147
125
  // ring / memberName / readinessOf / EMPTY_ROLLUP → goals-lib.js (task 1003135).
148
- const rollupOf = (goalId) => state.rollups.get(String(goalId)) || EMPTY_ROLLUP;
149
126
 
150
127
  // ---- goal ordering (task 1002965) -----------------------------------------
151
128
  // The list is ordered by the Archon's within-version priority (sort_order,
@@ -156,29 +133,16 @@
156
133
  // falling back to the goal row's own updated_at when the goal
157
134
  // has no tasks at all, so a task-less goal still orders sanely
158
135
  // instead of sinking to the bottom forever.
159
- const goalCreatedMs = (g) => msOf(g.created_at);
160
- const goalActivityMs = (g) => state.activity.get(String(g.id)) || msOf(g.updated_at);
161
136
 
162
137
  // value → { label, cmp, stamp } — `stamp` is what the row's meta line shows
163
138
  // for that sort (null = show nothing extra, the priority default).
164
- const GOAL_SORTS = {
165
- priority: { label: 'priority (version order)', cmp: null, stamp: null },
166
- newest: { label: 'newest first', cmp: (a, b) => goalCreatedMs(b) - goalCreatedMs(a), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
167
- oldest: { label: 'oldest first', cmp: (a, b) => goalCreatedMs(a) - goalCreatedMs(b), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
168
- active: { label: 'recently active', cmp: (a, b) => goalActivityMs(b) - goalActivityMs(a), stamp: (g) => ({ label: 'last work', ms: goalActivityMs(g) }) },
169
- };
170
139
  const GOAL_SORT_PREF = 'otb.goals.sort';
171
140
  // task 1003136 — the list|cards view choice, persisted like the sort above.
172
141
  const GOAL_VIEW_PREF = 'otb.goals.view';
173
- const currentGoalSort = () => GOAL_SORTS[$('#f-goal-order')?.value] || GOAL_SORTS.priority;
174
142
 
175
143
  // Stable: filteredGoals already returns the server's priority order, and
176
144
  // Array#sort is stable in every browser this hall supports — so goals sharing
177
145
  // a timestamp (or both missing one) keep their priority order as the tiebreak.
178
- function sortGoals(rows) {
179
- const { cmp } = currentGoalSort();
180
- return cmp ? rows.slice().sort(cmp) : rows;
181
- }
182
146
 
183
147
  // Fetch every task for the versions the goals span (one request each, with dep
184
148
  // summaries) and fold into per-goal rollups. Failure-tolerant: an older server
@@ -268,46 +232,14 @@
268
232
  // server-side rank gates are the real enforcement). The task-1760 drag-reorder
269
233
  // affordance this once also served is retired — see the note where
270
234
  // wireDragReorder used to live (task 1003136).
271
- const isArchon = () => (state.me?.builder?.rank || '').toLowerCase() === 'archon';
272
235
 
273
236
  // task 1002164 — confirming a criterion is Metic+ (POST /done-when/:id/satisfy,
274
237
  // ADR 0086 §6). Client-side this only decides whether to SHOW the control; the
275
238
  // server's requirePermission('criterion.satisfy') is the real gate (BV1.R105).
276
- const RANK_ORDER = { archon: 4, metic: 3, thetes: 2, xenos: 1 };
277
- const canConfirmCriteria = () =>
278
- (RANK_ORDER[(state.me?.builder?.rank || '').toLowerCase()] ?? 0) >= RANK_ORDER.metic;
279
239
 
280
240
  // task 1932 — the current filtered goal set, in display order. Shared by
281
241
  // renderList (what to page) and the drag-reorder dragend (which must submit
282
242
  // the FULL version order, not just the visible page).
283
- function filteredGoals() {
284
- const ver = $('#f-goal-version')?.value || '';
285
- const status = $('#f-goal-status')?.value || '';
286
- const mineOnly = $('#f-goal-mine')?.value === 'mine';
287
- // task 1003135 — free-text search (goal title OR id; '#N', 'N' and a bare
288
- // number all match — the id is already rendered on the row, task 1002163)
289
- // and the Owner dropdown ('none' = created_by NULL, system-attributed).
290
- // Reads the DOM ids directly, matching this function's existing style.
291
- const q = ($('#f-goals-search')?.value || '').trim().toLowerCase();
292
- const needle = q.replace(/^#/, '');
293
- const owner = $('#f-goal-owner')?.value || '';
294
- const matchesSearch = (g) => !needle
295
- || String(g.id).includes(needle)
296
- || (g.title || '').toLowerCase().includes(needle);
297
- const matchesOwner = (g) => !owner
298
- || (owner === 'none' ? g.created_by == null : String(g.created_by) === owner);
299
- const myId = selfId();
300
- const iAmMember = (g) => {
301
- const d = state.details.get(String(g.id));
302
- return d ? (d.members || []).some((m) => sameId(m.builder_id, myId)) : false;
303
- };
304
- return state.goals.filter((g) =>
305
- (!ver || g.version_id === ver) &&
306
- (!status || g.status === status) &&
307
- matchesSearch(g) &&
308
- matchesOwner(g) &&
309
- (!mineOnly || iAmMember(g)));
310
- }
311
243
 
312
244
  // task 1002439 — one goal, rendered as a rollup row: criteria ring · title +
313
245
  // meta + readiness rollup · one next action (claim the next ready task, drill
@@ -315,75 +247,17 @@
315
247
  // task 1002965 — under a date sort, the row states the date it was ordered on
316
248
  // ("created Aug 4" / "last work Aug 16"); under the priority default it shows
317
249
  // nothing extra, so the row is unchanged for anyone who never touches Sort.
318
- function goalStampHtml(g) {
319
- const { stamp } = currentGoalSort();
320
- if (!stamp) return '';
321
- const { label, ms } = stamp(g);
322
- return ` · ${escapeHtml(label)} ${escapeHtml(ms ? fmtDate(new Date(ms).toISOString()) : '—')}`;
323
- }
324
250
 
325
251
  // The facts a goal row/card renders, computed ONCE so the two views (task
326
252
  // 1003136) can't drift: ring, meta line, roll line, and the single
327
253
  // next-action ladder (achieved → criteria-met → claim-next → drill → quiet).
328
- function goalRenderFacts(g, ringSize) {
329
- const d = state.details.get(String(g.id));
330
- const crit = d ? (d.criteria || []) : [];
331
- const sat = crit.filter((c) => c.satisfied).length;
332
- const r = rollupOf(g.id);
333
- const achieved = g.status === 'achieved';
334
- const ringHtml = crit.length ? ring(sat, crit.length, achieved, ringSize)
335
- : `<span class="gr-crit-none" aria-hidden="true">—</span>`;
336
- const goalHref = `#/goal/${escapeHtml(String(g.id))}`;
337
- let right;
338
- if (achieved) {
339
- right = `<span class="gr-met">✓ achieved</span>`;
340
- } else if (crit.length && sat >= crit.length) {
341
- right = `<span class="gr-met">Criteria met — awaiting review</span>`;
342
- } else if (r.nextReady) {
343
- right = `<div class="gr-next"><span class="gr-next__t">${escapeHtml(r.nextReady.title || 'Next task')}</span>`
344
- + `<a class="gr-claim" href="#/task/${escapeHtml(String(r.nextReady.id))}">Claim next</a></div>`;
345
- } else if (r.blocked > 0) {
346
- right = `<a class="gr-drill" href="${goalHref}">See what's blocking <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>`;
347
- } else {
348
- right = `<span class="gr-quiet">No tasks yet</span>`;
349
- }
350
- const metaHtml = `#${escapeHtml(String(g.id))} · ${escapeHtml(g.version_id || '')}${(achieved || g.status === 'open') ? '' : ' · ' + escapeHtml(g.status)}${goalStampHtml(g)}`;
351
- return { r, achieved, ringHtml, goalHref, right, metaHtml };
352
- }
353
254
 
354
- function goalRowHtml(g) {
355
- const { r, achieved, ringHtml, goalHref, right, metaHtml } = goalRenderFacts(g, 44);
356
- // NOTE: a rollup row nests real links (title → goal, Claim → task overlay,
357
- // drill → goal), so the row itself is a <div>, not an <a> — nested anchors
358
- // are invalid. Only the explicit links navigate.
359
- return `<div class="gr-row" data-goal-id="${escapeHtml(String(g.id))}">
360
- <div class="gr-ringcol">${ringHtml}</div>
361
- <div class="gr-main">
362
- <a class="gr-title" href="${goalHref}"${subtitleAttr(g)}>${escapeHtml(g.title || 'Untitled goal')}</a>
363
- <div class="gr-meta">${metaHtml}</div>
364
- ${achieved ? '' : goalRollLine(r)}
365
- </div>
366
- <div class="gr-right">${right}</div>
367
- </div>`;
368
- }
369
255
 
370
256
  // task 1003136 — the same goal as a CARD: identical facts as the row via
371
257
  // goalRenderFacts, so nothing is lost switching views. Reuses the surviving
372
258
  // .goal-card* CSS (goals.css — task 1002439 neutralised it, never deleted it).
373
259
  // Deliberately emits NO draggable attribute: drag-to-reorder is retired, see
374
260
  // the note where wireDragReorder used to live.
375
- function goalCardHtml(g) {
376
- const { r, achieved, ringHtml, goalHref, right, metaHtml } = goalRenderFacts(g, 36);
377
- return `<div class="goal-card" data-goal-id="${escapeHtml(String(g.id))}">
378
- <div class="goal-card__head">
379
- <div class="gr-ringcol">${ringHtml}</div>
380
- <a class="goal-card__title gr-title" href="${goalHref}"${subtitleAttr(g)}>${escapeHtml(g.title || 'Untitled goal')}</a>
381
- </div>
382
- <div class="goal-card__meta">${metaHtml}</div>
383
- ${achieved ? '' : goalRollLine(r)}
384
- <div class="goal-card__foot">${right}</div>
385
- </div>`;
386
- }
387
261
 
388
262
  // task 1002439 — the Goals page, reorganised around momentum: a task-readiness
389
263
  // summary, then goals grouped Active (has ready/in-progress work) · Blocked
@@ -391,68 +265,6 @@
391
265
  // rollup of task readiness — the actual task→task dependency graph lives on the
392
266
  // Tasks page (owner call, this task). Filters (version/status/membership) still
393
267
  // apply; pagination + Archon drag-reorder are retired for the grouped view.
394
- function renderList() {
395
- const grid = $('#goal-grid');
396
- if (!grid) return;
397
- const rows = filteredGoals();
398
- const count = $('#goals-count');
399
- if (count) count.textContent = `${rows.length} goal${rows.length === 1 ? '' : 's'}`;
400
- const pagerMount = $('#goals-pager-mount');
401
- if (pagerMount) pagerMount.hidden = true;
402
-
403
- if (!rows.length) {
404
- grid.innerHTML = emptyStateHtml('No goals match these filters.');
405
- return;
406
- }
407
-
408
- const isBlocked = (g) => {
409
- const r = rollupOf(g.id);
410
- return g.status === 'open' && r.ready === 0 && r.active === 0 && r.blocked > 0;
411
- };
412
- // task 1002965 — the Sort choice orders goals WITHIN each group; the
413
- // Active / Blocked / Achieved / Archived grouping (task 1002439's momentum
414
- // layout) is what the page is for and is never re-ordered away.
415
- const achieved = sortGoals(rows.filter((g) => g.status === 'achieved'));
416
- const archived = sortGoals(rows.filter((g) => g.status === 'archived'));
417
- const openRows = rows.filter((g) => g.status !== 'achieved' && g.status !== 'archived');
418
- const blocked = sortGoals(openRows.filter(isBlocked));
419
- const active = sortGoals(openRows.filter((g) => !isBlocked(g)));
420
-
421
- let R = 0, A = 0, B = 0;
422
- openRows.forEach((g) => { const r = rollupOf(g.id); R += r.ready; A += r.active; B += r.blocked; });
423
- const summary = `<div class="gr-summary">
424
- <span class="gr-sm"><span class="gr-sm__dot" style="background:var(--ok)"></span><b>${R}</b> ready to claim</span>
425
- <span class="gr-sm"><span class="gr-sm__dot" style="background:var(--info)"></span><b>${A}</b> in progress</span>
426
- <span class="gr-sm"><span class="gr-sm__dot" style="background:var(--terracotta)"></span><b>${B}</b> blocked</span>
427
- </div>`;
428
-
429
- const sec = (label, n) => `<div class="gr-sec"><b>${label}</b><span class="gr-sec__n">${n}</span></div>`;
430
- // task 1003136 — the list|cards toggle swaps how the open groups render;
431
- // grouping, filters, and sort run above the fork so both views share them.
432
- // Achieved/archived pills stay pills (already the compact form). The inner
433
- // div is class-only .goal-grid on purpose: task 1002439's block override
434
- // targets #goal-grid.goal-grid, so the original card-grid CSS applies here.
435
- const cardsView = ($('#f-goal-view')?.value || 'list') === 'cards';
436
- const listOf = cardsView
437
- ? (arr) => `<div class="goal-grid">${arr.map(goalCardHtml).join('')}</div>`
438
- : (arr) => `<div class="gr-list">${arr.map(goalRowHtml).join('')}</div>`;
439
- // task 1002965 — an achieved/archived goal is a compact pill, so its sort
440
- // date rides as its own muted span (the row's `gr-meta` equivalent). The
441
- // pills row already wraps, so the extra text can't widen the page.
442
- const pillWhen = (g) => {
443
- const { stamp } = currentGoalSort();
444
- if (!stamp) return '';
445
- const { label, ms } = stamp(g);
446
- return `<span class="gr-pill__when">${escapeHtml(label)} ${escapeHtml(ms ? fmtDate(new Date(ms).toISOString()) : '—')}</span>`;
447
- };
448
- const pill = (g) => `<a class="gr-pill" href="#/goal/${escapeHtml(String(g.id))}"><span class="gr-pill__ok">✓</span><b>${escapeHtml(g.title || 'Untitled')}</b><span class="gr-pill__id">#${escapeHtml(String(g.id))}</span>${pillWhen(g)}</a>`;
449
-
450
- grid.innerHTML = summary
451
- + (active.length ? sec('Active', active.length) + listOf(active) : '')
452
- + (blocked.length ? sec('Blocked', blocked.length) + listOf(blocked) : '')
453
- + (achieved.length ? sec('Achieved', achieved.length) + `<div class="gr-pills">${achieved.map(pill).join('')}</div>` : '')
454
- + (archived.length ? sec('Archived', archived.length) + `<div class="gr-pills gr-pills--muted">${archived.map(pill).join('')}</div>` : '');
455
- }
456
268
 
457
269
  // ---- drag-to-reorder: RETIRED (task 1003136; was task 1760) ---------------
458
270
  // wireDragReorder() lived here, dead since task 1002439's grouped rollup (its
@@ -490,10 +302,6 @@
490
302
  // Per-task rank floor tag (work.js:247-254).
491
303
  // Free-text search over the goal's todo list (work.js:261-274, minus the
492
304
  // goal-title match — a single goal makes that redundant).
493
- function todoSearchQuery() {
494
- const el = $('#f-goal-search');
495
- return el ? el.value.trim().toLowerCase() : '';
496
- }
497
305
  // Copy-prompt (work.js:454-503). deHashRefs stops a bare #NNN autolinking to
498
306
  // GitHub in a pasted session; the prompt points the builder at the hall task URL.
499
307
  async function copyPromptForTask(taskId, btn) {
@@ -519,80 +327,12 @@
519
327
  // The claimable card (ported from work.js renderClaimable, work.js:345-394).
520
328
  // Uses the SAME .task markup + classes as the board, so work.css (linked from
521
329
  // goals.html) styles it identically. The lead cell + actions adapt to status.
522
- function goalTaskCardHtml(t) {
523
- const id = String(t.id);
524
- const cpm = creditsPerMinute(t);
525
- const cpmText = Number.isFinite(cpm) ? `${cpm.toFixed(2)} ${curLabel()}/min` : `— ${curLabel()}/min`;
526
- const minutesText = t.est_minutes ? `${t.est_minutes}m` : '—';
527
- const discipline = t.discipline || 'unclassified';
528
- const disciplineTag = discipline !== 'unclassified' ? `<span class="task__tag">${escapeHtml(discipline)}</span>` : '';
529
- const newcomerTag = t.newcomer_friendly ? '<span class="task__tag task__tag--newcomer">newcomer</span>' : '';
530
- const rankTag = rankTagHtml(t.requires_rank);
531
- const touches = Array.isArray(t.touches) ? t.touches : [];
532
- const touchesChip = touches.length
533
- ? `<span class="task__touches" title="${escapeHtml(touches.join('\n'))}">✎ ${touches.length} file${touches.length === 1 ? '' : 's'}</span>`
534
- : '';
535
- // task 1002955: same age chip the board renders — reviewing a goal for stale
536
- // work is the case that needed it most.
537
- const ageChip = window.OTBKit.ageChipHtml(t.created_at);
538
- const claim = state.claimsByTask.get(id);
539
- const isReady = t.status === 'ready' && !!state.me;
540
- const mineActive = t.status === 'active' && claim && sameId(claim.builder_id, selfId());
541
- const selected = goalSel.claim.has(id);
542
- // Lead: a select checkbox on ready cards (drives bulk claim); otherwise a
543
- // status tag so active/backlog cards read at a glance.
544
- const lead = isReady
545
- ? `<input type="checkbox" class="task__select" data-task-id="${escapeHtml(id)}" aria-label="Select task ${escapeHtml(id)} for bulk claim"${selected ? ' checked' : ''}>`
546
- : `<span class="task__tag" title="Task status">${escapeHtml(t.status || '')}</span>`;
547
- // Actions: ready → Claim + copy; mine-active → Release; other's active →
548
- // "claimed · @who"; anything else → nothing (open via the ⤢ button / card).
549
- let actions = '';
550
- if (isReady) {
551
- actions = `<button class="task__claim" type="button" data-claim-task="${escapeHtml(id)}" aria-label="Claim task ${escapeHtml(id)}">Claim</button>`
552
- + `<button class="task__copy" type="button" data-copy-task="${escapeHtml(id)}" title="Copy the task prompt to your clipboard" aria-label="Copy prompt for task ${escapeHtml(id)}"><span class="task__copy-icon" aria-hidden="true">📋</span></button>`;
553
- } else if (mineActive) {
554
- actions = `<button class="wip__release" type="button" data-release-claim="${escapeHtml(String(claim.claim_id))}" data-release-task="${escapeHtml(id)}" aria-label="Release task ${escapeHtml(id)}">Release</button>`;
555
- } else if (t.status === 'active' && claim) {
556
- const who = (claim.login || claim.name) ? ` · @${escapeHtml(claim.login || claim.name)}` : '';
557
- actions = `<span class="ledger__claimed" title="Claimed by another builder">claimed${who}</span>`;
558
- }
559
- return `
560
- <div class="task${selected ? ' task--selected' : ''}${isReady ? '' : ' task--readonly'}" data-task-id="${escapeHtml(id)}">
561
- <div class="task__id">
562
- ${lead}
563
- <span>#${escapeHtml(id)}</span>
564
- <button class="task__open" type="button" data-task-id="${escapeHtml(id)}" title="Open task details" aria-label="Open details for task ${escapeHtml(id)}">⤢</button>
565
- </div>
566
- <div class="task__body">
567
- <p class="task__title">${escapeHtml(t.title || '')}</p>
568
- <div class="task__meta">
569
- <span class="task__tag">${escapeHtml(t.version_id || '?')}</span>
570
- <span class="task__tag">${escapeHtml(t.kind || 'unclassified')}</span>
571
- ${disciplineTag}
572
- ${newcomerTag}
573
- ${rankTag}
574
- ${t.priority ? `<span class="task__tag">${escapeHtml(t.priority)}</span>` : ''}
575
- <span>${minutesText}</span>
576
- <span>${cpmText}</span>
577
- ${touchesChip}
578
- ${ageChip}
579
- </div>
580
- </div>
581
- <div class="task__credits">
582
- <span class="task__credits-num">${fmtNum(window.OTBBranding?.taskCreditValue?.(t) ?? t.credits_reward)}</span>
583
- <span class="task__credits-label" title="${window.OTBBranding?.rewardModeNote?.() ?? ''}">${window.OTBBranding?.taskCreditLabel?.(t) ?? (curLabel() + ' Est.')}</span>
584
- </div>
585
- <div class="task__actions">${actions}</div>
586
- </div>`;
587
- }
588
330
 
589
331
  // ---- multi-select + bulk claim (work.js:583-828, claim-only subset) --------
590
332
  // Selection holds String(task_id) of READY cards the viewer ticked. Bulk
591
333
  // release + copy parallel/sequential prompts stay board-only for now (a goal is
592
334
  // a narrow slice where the headline value is "grab several of this goal's ready
593
335
  // tasks at once"); see the goals.html toolbar comment.
594
- const goalSel = { claim: new Set() };
595
- function goalSelTotal() { return goalSel.claim.size; }
596
336
  function toggleGoalCardSelect(card, forceChecked) {
597
337
  const cb = card.querySelector('.task__select');
598
338
  if (!cb) return;
@@ -612,23 +352,6 @@
612
352
  // Drop any selected id that's fallen out from under the selection (claimed by
613
353
  // someone else, shipped, or we navigated to another goal) so a stale tick never
614
354
  // outlives its card. Called after every task reload (work.js:606-619).
615
- function pruneGoalSelection() {
616
- const readyIds = new Set(state.work.todo.filter((t) => t.status === 'ready').map((t) => String(t.id)));
617
- for (const id of Array.from(goalSel.claim)) if (!readyIds.has(id)) goalSel.claim.delete(id);
618
- }
619
- function renderGoalBulkToolbar() {
620
- const bar = $('#goal-bulk-toolbar');
621
- if (!bar) return;
622
- if (goalSelTotal() === 0) { bar.hidden = true; return; }
623
- bar.hidden = false;
624
- const countEl = $('#goal-bulk-count');
625
- if (countEl) countEl.textContent = `${goalSel.claim.size} to claim`;
626
- const claimBtn = $('#goal-bulk-claim-btn');
627
- if (claimBtn) {
628
- claimBtn.disabled = goalSel.claim.size === 0;
629
- claimBtn.textContent = goalSel.claim.size > 1 ? `Bulk claim (${goalSel.claim.size})` : 'Bulk claim';
630
- }
631
- }
632
355
  // A single batch-item failure reason → a readable fragment (work.js:545-562).
633
356
  // Bulk claim the selected ready cards (work.js doBulkClaim, claims/batch is
634
357
  // all-or-nothing: a 409 batch_failed means nothing was claimed). Reloads the
@@ -668,106 +391,39 @@
668
391
  else renderGoalBulkToolbar();
669
392
  }
670
393
  }
671
- function taskRowsDone(tasks) {
672
- return tasks.map((t) => `
673
- <tr class="ledger__row--task" data-task-id="${escapeHtml(String(t.id))}" style="cursor:pointer" title="Open task #${escapeHtml(String(t.id))}">
674
- <td class="ledger__num">#${escapeHtml(String(t.id))}</td>
675
- <td>${escapeHtml(t.title || '')}</td>
676
- <td>${escapeHtml(t.kind || '—')}</td>
677
- <td class="ledger__drachmae">${fmtNum(creditVal(t))}${taskSettled(t) ? '' : ' <span class="ledger__est" title="Estimate — credits settle when the task is confirmed">est.</span>'}</td>
678
- <td class="ledger__date">${fmtDate(t.shipped_at)}</td>
679
- </tr>`).join('');
680
- }
681
394
 
682
395
  // Client-side conflict estimate: exact/prefix overlap between the declared
683
396
  // touches[] of not-yet-done tasks. Deliberately conservative — the glob-aware
684
397
  // server-side detector (with claim collisions) is task 1736; when
685
398
  // GET /goals/:id/conflicts exists this panel prefers it.
686
- function pathOverlap(a, b) {
687
- const norm = (p) => String(p).replace(/\/?\*+$/, '').replace(/\/+$/, '');
688
- const hits = [];
689
- for (const pa of a) {
690
- for (const pb of b) {
691
- const na = norm(pa); const nb = norm(pb);
692
- if (!na || !nb) continue;
693
- if (na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/')) {
694
- hits.push(na.length >= nb.length ? na : nb);
695
- }
696
- }
697
- }
698
- return [...new Set(hits)];
699
- }
700
- function computeConflicts(tasks) {
701
- const open = tasks.filter((t) => isTodoStatus(t.status) && Array.isArray(t.touches) && t.touches.length);
702
- const out = [];
703
- for (let i = 0; i < open.length; i++) {
704
- for (let j = i + 1; j < open.length; j++) {
705
- const paths = pathOverlap(open[i].touches, open[j].touches);
706
- if (paths.length) out.push({ a: open[i], b: open[j], paths });
707
- }
708
- }
709
- return out;
710
- }
711
399
 
712
400
  // task 1888 — count pill helper (card headers + Work tabs).
713
- function setCount(sel, n) {
714
- const el = $(sel);
715
- if (!el) return;
716
- el.hidden = false;
717
- el.textContent = String(n);
718
- }
719
401
 
720
402
  // task 1888 → kit (2171): the Work card's To do / Completed toggle is an
721
403
  // OTBKit tabs instance (built in wireKit below). hash:false is load-bearing —
722
404
  // this page owns the #/goal/N sub-route namespace, so the tabs key off
723
405
  // in-page state and never touch location.hash.
724
406
  let workTabs = null;
407
+ // ---- renderers (task 1003206) -------------------------------------------
408
+ // The view painters live in goals-render.js. `state` is a stable object so it
409
+ // is passed straight through; workTabs is a GETTER because it is reassigned
410
+ // after boot and a renderer must see the current controller, not the null it
411
+ // held here at startup. This call sits AFTER every declaration it passes.
412
+ const { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork } = window.OTBGoalsRender({
413
+ API, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state,
414
+ get workTabs() { return workTabs; },
415
+ });
416
+
725
417
  function setWorkTab(tab) { workTabs?.activate(tab); }
726
418
 
727
419
  // task 1828 — sortable column key → header id, per table. Adding a column
728
420
  // means an entry here + a matching <th id> in goals.html.
729
- const WORK_SORT_HEADERS = {
730
- todo: { kind: 'goal-todo-sort-kind', status: 'goal-todo-sort-status', credits: 'goal-todo-sort-credits' },
731
- done: { kind: 'goal-done-sort-kind', credits: 'goal-done-sort-credits', shipped: 'goal-done-sort-shipped' },
732
- };
733
421
 
734
422
  // Stable sort by the active column; null `by` returns the natural order
735
423
  // untouched (todo: server order; done: shipped-desc from load).
736
- function sortWorkRows(rows, sort) {
737
- if (!sort || !sort.by) return rows.slice();
738
- const dir = sort.dir === 'asc' ? 1 : -1;
739
- const val = (t) => {
740
- switch (sort.by) {
741
- case 'kind': return String(t.kind || '').toLowerCase();
742
- case 'status': return String(t.status || '').toLowerCase();
743
- case 'credits': return creditVal(t);
744
- case 'shipped': return new Date(t.shipped_at || 0).getTime();
745
- default: return 0;
746
- }
747
- };
748
- return rows
749
- .map((row, idx) => ({ row, idx }))
750
- .sort((a, b) => {
751
- const av = val(a.row); const bv = val(b.row);
752
- const diff = (typeof av === 'number')
753
- ? (av - bv) * dir
754
- : String(av).localeCompare(String(bv)) * dir;
755
- return diff !== 0 ? diff : a.idx - b.idx; // stable tiebreak
756
- })
757
- .map(({ row }) => row);
758
- }
759
424
 
760
425
  // Reflect the active sort on that table's headers (only the active column
761
426
  // shows an arrow; the rest reset to "none" — the ARIA-sort contract).
762
- function reflectSortHeaders(table) {
763
- const sort = state.workSort[table];
764
- for (const [key, id] of Object.entries(WORK_SORT_HEADERS[table])) {
765
- const th = document.getElementById(id);
766
- if (!th) continue;
767
- th.setAttribute('aria-sort',
768
- sort.by === key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none');
769
- }
770
- }
771
427
 
772
428
  // Cycle a column: pick → desc → asc → natural (clear). Mirrors the
773
429
  // leaderboard's three-click-clears behaviour so there's no dead state.
@@ -782,70 +438,10 @@
782
438
  renderWork();
783
439
  }
784
440
 
785
- function renderWork() {
786
- renderTodoCards();
787
- // ---- Completed → unchanged ledger table (the board's Completed tab is also
788
- // a plain list, so the goal keeps its sortable table here). ----
789
- const doneBody = document.querySelector('#goal-done tbody');
790
- if (doneBody) {
791
- const done = sortWorkRows(state.work.done, state.workSort.done);
792
- const doneSlice = paginate(done, pagers.done);
793
- doneBody.innerHTML = done.length
794
- ? taskRowsDone(doneSlice)
795
- : `<tr><td colspan="5" class="ledger__empty">${emptyStateHtml('Nothing completed yet.')}</td></tr>`;
796
- }
797
- workTabs?.setCount('todo', state.work.todo.length);
798
- workTabs?.setCount('done', state.work.done.length);
799
- reflectSortHeaders('done');
800
- renderGoalBulkToolbar();
801
- }
802
441
 
803
442
  // task 1002425 — the To-do list as Tasks-board cards: filter → run-mode →
804
443
  // sort → paginate → render, mirroring work.js renderClaimable (276-395). The
805
444
  // sort control lives on the filter bar (replacing the old column headers).
806
- function renderTodoCards() {
807
- const listEl = $('#goal-todo-list');
808
- if (!listEl) return;
809
- const sQuery = todoSearchQuery();
810
- const kFilter = $('#f-goal-kind')?.value || '';
811
- const dFilter = $('#f-goal-discipline')?.value || '';
812
- const nFilter = $('#f-goal-newcomer')?.value || '';
813
- const sortBy = $('#f-goal-sort')?.value || 'cpm';
814
- let list = state.work.todo.filter((t) => {
815
- if (!matchesTodoSearch(t, sQuery)) return false;
816
- if (kFilter && (t.kind || 'unclassified') !== kFilter) return false;
817
- if (dFilter && (t.discipline || 'unclassified') !== dFilter) return false;
818
- if (nFilter === 'yes' && !t.newcomer_friendly) return false;
819
- return true;
820
- });
821
- // Run-mode filter: parallel-safe (touches[] disjoint from the rest of the
822
- // visible set) vs. file-conflicting. O(n²) over touches — fine at goal scale.
823
- const fitFilter = $('#f-goal-fit')?.value || '';
824
- if (fitFilter === 'parallel' || fitFilter === 'conflict') {
825
- const base = list;
826
- list = base.filter((t) => {
827
- const conflicts = base.some((o) => o !== t && touchesConflict(t.touches, o.touches));
828
- return fitFilter === 'parallel' ? !conflicts : conflicts;
829
- });
830
- }
831
- if (sortBy === 'cpm') list = list.slice().sort((a, b) => creditsPerMinute(b) - creditsPerMinute(a));
832
- else if (sortBy === 'credits') list = list.slice().sort((a, b) => creditVal(b) - creditVal(a));
833
- else if (sortBy === 'minutes') list = list.slice().sort((a, b) => (Number(a.est_minutes) || Infinity) - (Number(b.est_minutes) || Infinity));
834
- else if (sortBy === 'priority') list = list.slice().sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
835
- else if (sortBy === 'id') list = list.slice().sort((a, b) => Number(a.id) - Number(b.id));
836
- else if (sortBy === 'status') list = list.slice().sort((a, b) => String(a.status).localeCompare(String(b.status)));
837
-
838
- const countEl = $('#goal-todo-count');
839
- if (countEl) countEl.textContent = state.work.todo.length ? `${list.length} of ${state.work.todo.length} shown` : '';
840
- if (!list.length) {
841
- listEl.innerHTML = emptyStateHtml(state.work.todo.length
842
- ? 'No tasks match these filters. Try widening them.'
843
- : `Nothing queued under this goal. Members can author bounded tasks here${state.me ? '' : ' once signed in'}.`);
844
- return;
845
- }
846
- const shown = paginate(list, pagers.todo);
847
- listEl.innerHTML = shown.map(goalTaskCardHtml).join('');
848
- }
849
445
 
850
446
  // task 1828 — claim a ready task straight from a goal's Work list. Mirrors the
851
447
  // Work Board's doClaim (POST /claims, bind_session:false) + its typed-error
@@ -856,34 +452,6 @@
856
452
  // so the typed code is at data.error.code, not data.error. These normalize BOTH
857
453
  // that envelope and the flat legacy `{ error: 'CODE', ...fields }` shape — without
858
454
  // them the switch below fell through to the generic default for every claim error.
859
- function errCode(data) {
860
- const e = data && data.error;
861
- return (e && typeof e === 'object') ? e.code : e;
862
- }
863
- function errDetails(data) {
864
- const e = data && data.error;
865
- return (e && typeof e === 'object' && e.details) ? e.details : (data || {});
866
- }
867
- function friendlyClaimError(status, data) {
868
- const det = errDetails(data);
869
- switch (errCode(data)) {
870
- case 'ALREADY_CLAIMED': return 'Someone else claimed that task first. Reloading.';
871
- case 'TASK_NOT_READY': return 'That task is no longer ready to be claimed.';
872
- case 'TOUCHES_CONFLICT': {
873
- const titles = Array.isArray(det.conflicts) ? det.conflicts.map((c) => c.title).filter(Boolean) : [];
874
- return titles.length ? `That task overlaps an active claim: ${titles.join('; ')}.` : 'That task overlaps another active claim.';
875
- }
876
- case 'DEPS_NOT_SHIPPED': return 'That task waits on unshipped dependencies.';
877
- case 'rank_too_low_for_task':
878
- case 'XENOS_RESTRICTED':
879
- case 'INSUFFICIENT_RANK': return 'Your rank is not yet high enough for that task.';
880
- case 'newcomer_must_ship_first': return det.what_to_do || 'Ship your current claim before opening another.';
881
- case 'NOT_YOUR_CLAIM': return 'That claim is not yours to release.';
882
- case 'ALREADY_RESOLVED': return 'That claim was already resolved. Reloading.';
883
- case 'BUILDER_INACTIVE': return 'Your account is inactive. Contact an Archon.';
884
- default: return `The board could not do that (${status}). Try again.`;
885
- }
886
- }
887
455
  async function doGoalClaim(taskId, btn) {
888
456
  const key = `claim:${taskId}`;
889
457
  if (pendingClaims.has(key)) return;
@@ -927,141 +495,31 @@
927
495
  }
928
496
  }
929
497
 
930
- function conflictHtml(c) {
931
- const sev = c.severity
932
- ? `<span class="conflict__sev conflict__sev--${escapeHtml(c.severity)}">${escapeHtml(c.severity)}</span>`
933
- : '';
934
- const cross = c.crossGoalTitle ? ` <small>(with ${escapeHtml(c.crossGoalTitle)})</small>` : '';
935
- return `<div class="conflict">
936
- <div class="conflict__pair">#${escapeHtml(String(c.a))} ${escapeHtml(c.titleA || '')} ↔ #${escapeHtml(String(c.b))} ${escapeHtml(c.titleB || '')}${cross}${sev}</div>
937
- <div class="conflict__paths">${(c.paths || []).map(escapeHtml).join(' · ')}</div>
938
- </div>`;
939
- }
940
498
 
941
499
  // task 1888/1828 — renders from state.conflictPairs (unified server/client
942
500
  // shape: {a, b, titleA, titleB, paths, severity, crossGoalTitle}); null means
943
501
  // the load failed. Fills a container with the given pairs, or a placeholder.
944
- function fillConflicts(container, pairs) {
945
- if (!container) return;
946
- if (pairs == null) {
947
- container.innerHTML = emptyStateHtml('Conflicts could not be computed.');
948
- return;
949
- }
950
- const note = state.conflictSource === 'client'
951
- ? '<p class="conflict-note">Path-based estimate from declared files. Glob-aware detection with claim collisions — and lead resolution actions (sequencing, rescoping) — light up when the server-side detector lands (tasks 1735/1736).</p>'
952
- : '';
953
- container.innerHTML = (pairs.length
954
- ? pairs.map(conflictHtml).join('')
955
- : emptyStateHtml('No conflicts detected among this goal’s open tasks.')) + note;
956
- }
957
502
 
958
- const currentDetail = () => (state.current == null ? null : state.details.get(String(state.current)));
959
503
 
960
504
  // task 1828 — a band column shows the first `cap` of a growing list; when
961
505
  // there's more, this foot offers "View all N <label> →" into the dedicated
962
506
  // full view at #/goal/N/<view> (the shared pattern behind Conflicts, Members,
963
507
  // and Completion). Hidden when nothing is truncated.
964
- function renderViewAllFoot(footSel, total, cap, view, label) {
965
- const foot = $(footSel);
966
- if (!foot) return;
967
- if (total > cap && state.current != null) {
968
- foot.hidden = false;
969
- foot.innerHTML = `<a class="pill-link" href="#/goal/${escapeHtml(String(state.current))}/${view}">View all ${total} ${label} →</a>`;
970
- } else {
971
- foot.hidden = true;
972
- foot.innerHTML = '';
973
- }
974
- }
975
508
 
976
509
  // task 1828 — the band column shows the first CONFLICT_CAP; the foot routes
977
510
  // to the dedicated full-width conflicts view. Count + amber accent on the card.
978
- function renderConflicts() {
979
- const pairs = state.conflictPairs;
980
- const card = $('#goal-conflicts-card');
981
- fillConflicts($('#goal-conflicts'), pairs == null ? null : pairs.slice(0, CONFLICT_CAP));
982
- const total = pairs ? pairs.length : 0;
983
- renderViewAllFoot('#goal-conflicts-foot', total, CONFLICT_CAP, 'conflicts', 'conflicts');
984
- setCount('#goal-conflicts-count', total);
985
- card?.classList.toggle('goal-conflicts-card--live', total > 0);
986
- renderConflictsFull();
987
- }
988
511
 
989
512
  // task 1828 — the standalone #/goal/N/conflicts view: the whole set, no cap.
990
- function renderConflictsFull() {
991
- const pairs = state.conflictPairs;
992
- const total = pairs ? pairs.length : 0;
993
- fillConflicts($('#goal-conflicts-full'), pairs);
994
- setCount('#goal-conflicts-view-count', total);
995
- $('#goal-conflicts-view-card')?.classList.toggle('goal-conflicts-card--live', total > 0);
996
- const back = $('#goal-conflicts-back');
997
- if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
998
- }
999
513
 
1000
514
  /* ----- Completion (band column + #/goal/N/completion full view) ----- */
1001
515
 
1002
516
  // task 1002164 — a criterion has three states, not two: confirmed (satisfied),
1003
517
  // met-but-awaiting-confirmation (pending_review, derived server-side), and open.
1004
- const isPending = (c) => !c.satisfied && !!c.pending_review;
1005
-
1006
- function completionSummaryHtml(g, crit, sat, ringSize) {
1007
- const pending = crit.filter(isPending).length;
1008
- // task 1002164 — an open goal used to punt confirmation to the /goal-review
1009
- // CLI skill here; confirming now happens on this page, so the line reports
1010
- // what is actually actionable instead of naming a command.
1011
- const openLine = pending
1012
- ? `${fmtNum(pending)} criteri${pending === 1 ? 'on is' : 'a are'} met and ready to confirm.`
1013
- : 'Open — a criterion becomes confirmable once every task linked to it has shipped.';
1014
- const line = g.status === 'achieved' ? 'Goal achieved — every criterion confirmed.'
1015
- : (g.status === 'archived' ? 'Archived.' : openLine);
1016
- return `<div style="display:flex;align-items:center;gap:16px">
1017
- ${ring(sat, crit.length, g.status === 'achieved', ringSize)}
1018
- <div>
1019
- <div style="font-weight:650">${sat} of ${crit.length} criteria satisfied</div>
1020
- <div style="font-size:12.5px;color:var(--ink-faint)">${line}</div>
1021
- </div>
1022
- </div>`;
1023
- }
518
+
1024
519
  // withDetail shows each criterion's full detail always inline — the extra room
1025
520
  // is the point of clicking into the dedicated completion view. The capped band
1026
521
  // (withDetail=false) instead reveals detail via a per-criterion <details> expand
1027
522
  // (BV1.R79) so the full text is one click away, not hover-only.
1028
- function critListHtml(list, withDetail, canConfirm) {
1029
- if (!list.length) return emptyStateHtml('No done-when criteria yet.');
1030
- return `<ul class="crit-list${withDetail ? ' crit-list--full' : ''}">${list.map((c) => {
1031
- const parts = window.OTB.splitCriterion ? window.OTB.splitCriterion(c.criterion_md) : { label: c.criterion_md, detail: '' };
1032
- const body = withDetail
1033
- ? `<div class="crit__col"><span class="crit__text">${escapeHtml(parts.label || '')}</span>${parts.detail ? `<span class="crit__detail">${escapeHtml(parts.detail)}</span>` : ''}</div>`
1034
- : (parts.detail
1035
- ? `<details class="crit-d"><summary class="crit__text">${escapeHtml(parts.label || '')}</summary><div class="crit__detail">${escapeHtml(parts.detail)}</div></details>`
1036
- : `<span class="crit__text">${escapeHtml(parts.label || '')}</span>`);
1037
- const pending = isPending(c);
1038
- // A Xenos sees the state but not the control — the server enforces either way.
1039
- const action = !pending ? ''
1040
- : (canConfirm
1041
- ? `<button type="button" class="crit__confirm" data-confirm-crit="${escapeHtml(String(c.id))}"
1042
- title="Every task linked to this criterion has shipped — confirm it as satisfied">Confirm</button>`
1043
- : '<span class="crit__flag" title="Every task linked to this criterion has shipped — a Metic can confirm it">Ready</span>');
1044
- const cls = c.satisfied ? ' crit--done' : (pending ? ' crit--pending' : '');
1045
- return `<li class="crit${cls}"><span class="crit__mark">${c.satisfied ? '✓' : (pending ? '!' : '')}</span>${body}<span class="crit__act">${action}</span></li>`;
1046
- }).join('')}</ul>`;
1047
- }
1048
- function renderCompletion() {
1049
- const d = currentDetail();
1050
- if (!d) return;
1051
- const g = d.goal;
1052
- const crit = d.criteria || [];
1053
- const sat = crit.filter((c) => c.satisfied).length;
1054
- // task 1002164 — an archived/achieved goal has nothing left to confirm.
1055
- const canConfirm = g.status === 'open' && canConfirmCriteria();
1056
- const band = $('#goal-completion-body');
1057
- if (band) band.innerHTML = completionSummaryHtml(g, crit, sat, 56) + critListHtml(crit.slice(0, CRITERIA_CAP), false, canConfirm);
1058
- renderViewAllFoot('#goal-completion-foot', crit.length, CRITERIA_CAP, 'completion', 'criteria');
1059
- setCount('#goal-completion-count', crit.length);
1060
- const full = $('#goal-completion-full');
1061
- if (full) full.innerHTML = completionSummaryHtml(g, crit, sat, 64) + critListHtml(crit, true, canConfirm);
1062
- const back = $('#goal-completion-back');
1063
- if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
1064
- }
1065
523
 
1066
524
  // task 1002164 — confirm a pending-review criterion straight from the hall
1067
525
  // (POST /done-when/:id/satisfy, Metic+). This is the in-hall replacement for
@@ -1099,168 +557,15 @@
1099
557
 
1100
558
  /* ----- Members (band column + #/goal/N/members full view) ----- */
1101
559
 
1102
- function memberAvatar(m) {
1103
- const name = memberName(m);
1104
- const initials = String(name).replace(/^@/, '').trim().split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase();
1105
- return m.avatar_url ? `<img src="${escapeHtml(m.avatar_url)}" alt="">` : escapeHtml(initials);
1106
- }
1107
- function memberLi(m) {
1108
- return `<li class="member">
1109
- <span class="member__avatar">${memberAvatar(m)}</span>
1110
- <span class="member__name">${escapeHtml(memberName(m))} ${m.rank ? `<small>· ${escapeHtml(rankLabel(m.rank))}</small>` : ''}</span>
1111
- <span class="member-pill${m.membership_kind === 'lead' ? ' member-pill--lead' : ''}">${m.membership_kind === 'lead' ? 'Manager' : 'Member'}</span>
1112
- </li>`;
1113
- }
1114
560
 
1115
561
  // task 1828 — per-member contribution TO THIS GOAL, from the goal's shipped
1116
562
  // tasks (state.work.done carries shipped_by + credits_reward). Returns a Map
1117
563
  // keyed by String(builder_id) → { shipped, earned }.
1118
- function goalMemberStats() {
1119
- const stats = new Map();
1120
- for (const t of state.work.done) {
1121
- if (t.shipped_by == null) continue;
1122
- const key = String(t.shipped_by);
1123
- const s = stats.get(key) || { shipped: 0, earned: 0 };
1124
- s.shipped += 1;
1125
- // Only SETTLED tasks have really-earned drachmae; a 'completed' (pre-confirm)
1126
- // task in state.work.done has not landed credits yet, so it contributes 0.
1127
- s.earned += taskSettled(t) ? creditVal(t) : 0;
1128
- stats.set(key, s);
1129
- }
1130
- return stats;
1131
- }
1132
- function memberStatLi(m, stats) {
1133
- const s = stats.get(String(m.builder_id)) || { shipped: 0, earned: 0 };
1134
- const cur = curLabel();
1135
- const statLine = state.workLoaded
1136
- ? `<strong>${fmtNum(s.shipped)}</strong> shipped · <strong>${fmtNum(s.earned)}</strong> ${escapeHtml(cur)}`
1137
- : '<span class="member__stats--pending">…</span>';
1138
- return `<li class="member member--stat">
1139
- <span class="member__avatar">${memberAvatar(m)}</span>
1140
- <span class="member__name">${escapeHtml(memberName(m))} ${m.rank ? `<small>· ${escapeHtml(rankLabel(m.rank))}</small>` : ''}</span>
1141
- <span class="member__stats" title="Contribution to this goal">${statLine}</span>
1142
- <span class="member-pill${m.membership_kind === 'lead' ? ' member-pill--lead' : ''}">${m.membership_kind === 'lead' ? 'Manager' : 'Member'}</span>
1143
- </li>`;
1144
- }
1145
564
 
1146
- function renderMembers() {
1147
- const d = currentDetail();
1148
- if (!d) return;
1149
- const members = d.members || [];
1150
- const lede = $('#goal-members-lede');
1151
- if (lede) {
1152
- const leads = members.filter((m) => m.membership_kind === 'lead');
1153
- lede.textContent = leads.length
1154
- ? 'Leads manage this goal’s scope and (soon) resolve its conflicts.'
1155
- : 'No lead assigned yet — an Archon can appoint one.';
1156
- }
1157
- setCount('#goal-members-count', members.length);
1158
- const list = $('#goal-members');
1159
- if (list) {
1160
- list.innerHTML = members.length
1161
- ? members.slice(0, MEMBER_CAP).map(memberLi).join('')
1162
- : `<li>${emptyStateHtml('No members yet — be the first to join.')}</li>`;
1163
- }
1164
- renderViewAllFoot('#goal-members-foot', members.length, MEMBER_CAP, 'members', 'members');
1165
- renderMembersFull(d);
1166
- }
1167
565
 
1168
566
  // task 1828 — the standalone #/goal/N/members view: every member plus their
1169
567
  // contribution to this goal, and a goal-level aggregate header.
1170
- function renderMembersFull(d) {
1171
- const members = d.members || [];
1172
- setCount('#goal-members-view-count', members.length);
1173
- const stats = goalMemberStats();
1174
- const agg = $('#goal-members-stats');
1175
- if (agg) {
1176
- if (state.workLoaded) {
1177
- const shipped = state.work.done.length;
1178
- const earned = state.work.done.reduce((n, t) => n + (taskSettled(t) ? creditVal(t) : 0), 0);
1179
- const open = state.work.todo.length;
1180
- const cur = curLabel();
1181
- agg.innerHTML = `
1182
- <span class="mstat"><strong>${fmtNum(members.length)}</strong> member${members.length === 1 ? '' : 's'}</span>
1183
- <span class="mstat"><strong>${fmtNum(shipped)}</strong> task${shipped === 1 ? '' : 's'} shipped</span>
1184
- <span class="mstat"><strong>${fmtNum(earned)}</strong> ${escapeHtml(cur)} earned</span>
1185
- <span class="mstat"><strong>${fmtNum(open)}</strong> open</span>`;
1186
- } else {
1187
- agg.innerHTML = '<span class="mstat"><span class="kit-skel"></span></span>';
1188
- }
1189
- }
1190
- const full = $('#goal-members-full');
1191
- if (full) {
1192
- full.innerHTML = members.length
1193
- ? `<ul class="member-list">${members.map((m) => memberStatLi(m, stats)).join('')}</ul>`
1194
- : emptyStateHtml('No members yet — be the first to join.');
1195
- }
1196
- const back = $('#goal-members-back');
1197
- if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
1198
- }
1199
-
1200
- function renderDetail(goalId) {
1201
- const d = state.details.get(String(goalId));
1202
- if (!d) return;
1203
- const g = d.goal;
1204
- const crit = d.criteria || [];
1205
- const members = d.members || [];
1206
- const sat = crit.filter((c) => c.satisfied).length;
1207
- const joined = members.some((m) => sameId(m.builder_id, selfId()));
1208
- const iAmLead = members.some((m) => sameId(m.builder_id, selfId()) && m.membership_kind === 'lead');
1209
-
1210
- const h = $('#goal-detail-h');
1211
- // task 2163 — surface the numeric goal id alongside the title (escaped title,
1212
- // so switching from textContent to innerHTML stays injection-safe).
1213
- if (h) h.innerHTML = `<span class="goal-detail__id" title="Goal id">#${escapeHtml(String(g.id))}</span> ${escapeHtml(g.title || 'Untitled goal')}`;
1214
- // task 1003224 — name / subtitle / Claude's prose at three weights; the rules live in goals-lib.
1215
- applyGoalProse({ sub: $('#goal-detail-subtitle'), desc: $('#goal-detail-desc'), claude: $('#goal-detail-claude') }, g);
1216
- const meta = $('#goal-detail-meta');
1217
- if (meta) {
1218
- const scope = Array.isArray(g.scope_modules) && g.scope_modules.length
1219
- ? `<span title="Module scope — tasks authored in this goal are bounded to these modules">scope: ${g.scope_modules.map(escapeHtml).join(', ')}</span>` : '';
1220
- // task 1761: this goal's prerequisite goals, each ✓ (achieved) or ○ (not yet).
1221
- // An open goal with any unmet prerequisite is flagged blocked.
1222
- const deps = d.dependencies || [];
1223
- const unmet = deps.filter((dep) => !dep.satisfied);
1224
- const depsSpan = deps.length
1225
- ? `<span class="goal-deps${(g.status === 'open' && unmet.length) ? ' goal-deps--blocked' : ''}" title="This goal is blocked until each prerequisite goal is achieved">${(g.status === 'open' && unmet.length) ? '🔒 ' : ''}depends on: ${deps.map((dep) => `${escapeHtml(dep.title || ('goal ' + dep.id))} ${dep.satisfied ? '✓' : '○'}`).join(', ')}</span>`
1226
- : '';
1227
- // task 1953 — a private goal is join-GATED, not hidden, so the lock badge is
1228
- // informational for every viewer (the toggle that flips it lives in Actions,
1229
- // manager-only).
1230
- const lockSpan = g.visibility === 'private'
1231
- ? '<span class="goal-badge goal-badge--locked" title="Private — join is by invitation or an approved request">🔒 Private</span>' : '';
1232
- // task 1002689 — a PROTECTED-scope goal is join-gated too (Archon-mediated
1233
- // membership, ADR 0086 §3): badge it so it doesn't read as open-join. The
1234
- // flag is server-derived (scope_protected on the goal payload) — the client
1235
- // still never re-derives the protected roster.
1236
- const gateSpan = g.scope_protected
1237
- ? '<span class="goal-badge goal-badge--locked" title="Protected module scope — join by request; an Archon admits members">🛡 Archon-mediated</span>' : '';
1238
- meta.innerHTML = `<span>${escapeHtml(g.version_id || '')}</span> ${statusBadge(g.status)} ${lockSpan} ${gateSpan} ${scope} ${depsSpan}`;
1239
- }
1240
568
 
1241
- // task 1762: the goal's own owner/manager (or an Archon) can invite/manage
1242
- // without an Archon in the loop — mirrors the server's authorizeGoalManagement
1243
- // gate. A protected-scope goal stays Archon-only on the SERVER (goals.js);
1244
- // this client gate deliberately doesn't re-derive the protected-module list
1245
- // (that roster lives server-side only, module-scope-map.js) — a non-Archon
1246
- // owner/manager sees the control on a protected-scope goal and gets a clear
1247
- // toast if the server 403s (see inviteMember's error handling below).
1248
- const isOwner = sameId(g.created_by, selfId());
1249
- const canManage = g.status === 'open' && (isArchon() || isOwner || iAmLead);
1250
- renderActions(g, { joined, canManage });
1251
-
1252
- // task 1828 — Completion + Members each render into their band column AND
1253
- // their dedicated full view (capped in the band, "View all →" into the view).
1254
- renderCompletion();
1255
- renderInviteBox(g.id, canManage, members);
1256
- renderMembers();
1257
-
1258
- loadGoalTasks(g.id);
1259
- // task 1953 — my own pending request (any viewer) + the goal's pending queue
1260
- // (managers only); async, re-renders Actions + the pending panel on arrival.
1261
- loadGoalMembership(g.id, canManage);
1262
- void sat; void crit; // now read inside renderCompletion()
1263
- }
1264
569
 
1265
570
  // task 1953 — the goal-detail Actions row: Leave/Join (unchanged), a manager's
1266
571
  // Private/Public toggle (PATCH /goals/:id/visibility), and — for a non-member
@@ -1271,260 +576,34 @@
1271
576
  // join-request on either (task 1002689 — scope_protected is server-derived on
1272
577
  // the goal payload). An Archon keeps the plain Join button: the server lets
1273
578
  // them self-join any goal, so "request" would be a pointless extra step.
1274
- function renderActions(g, { joined, canManage }) {
1275
- const actions = $('#goal-detail-actions');
1276
- if (!actions) return;
1277
- const parts = [];
1278
- if (canManage) {
1279
- const nextVisibility = g.visibility === 'private' ? 'public' : 'private';
1280
- const label = g.visibility === 'private' ? '🔒 Private — make public' : 'Make private';
1281
- parts.push(`<button class="btn-ghost" id="goal-visibility-btn" type="button" data-next-visibility="${escapeHtml(nextVisibility)}">${label}</button>`);
1282
- }
1283
- if (g.status === 'open') {
1284
- if (joined) {
1285
- parts.push('<button class="btn-ghost" id="goal-leave-btn" type="button">Leave goal</button>');
1286
- } else if ((g.visibility === 'private' || g.scope_protected) && !isArchon()) {
1287
- if (state.myPendingRequest) {
1288
- parts.push('<span class="member-pill" title="Awaiting the owner/manager\'s approval">Requested</span>');
1289
- parts.push('<button class="btn-ghost" id="goal-withdraw-btn" type="button">Withdraw</button>');
1290
- } else {
1291
- parts.push('<button class="btn-ghost" id="goal-request-btn" type="button">🔒 Request to join</button>');
1292
- }
1293
- } else {
1294
- parts.push('<button class="landing__btn" id="goal-join-btn" type="button">Join goal</button>');
1295
- }
1296
- }
1297
- actions.innerHTML = parts.join(' ');
1298
- $('#goal-join-btn')?.addEventListener('click', () => joinLeave(g.id, 'join'));
1299
- $('#goal-leave-btn')?.addEventListener('click', () => joinLeave(g.id, 'leave'));
1300
- $('#goal-visibility-btn')?.addEventListener('click', (e) => toggleVisibility(g.id, e.currentTarget.dataset.nextVisibility));
1301
- $('#goal-request-btn')?.addEventListener('click', () => requestToJoin(g.id));
1302
- $('#goal-withdraw-btn')?.addEventListener('click', () => withdrawRequest(g.id, state.myPendingRequest && state.myPendingRequest.id));
1303
- }
1304
579
 
1305
580
  // task 1828 — the active-claim feed (all builders' in-flight claims) powers the
1306
581
  // Release button + the "claimed · @who" label. Best-effort: the feed is
1307
582
  // primer-gated server-side, so a 403/error just leaves the map empty (Claim
1308
583
  // still works; Release simply doesn't show). Keyed by String(task_id).
1309
- async function loadActiveClaims() {
1310
- try {
1311
- const c = await getJSON(`${API}/tasks/claimable`);
1312
- const map = new Map();
1313
- for (const ac of (c.active_claims || [])) {
1314
- map.set(String(ac.task_id), { claim_id: ac.claim_id, builder_id: ac.builder_id, login: ac.github_login, name: ac.display_name });
1315
- }
1316
- state.claimsByTask = map;
1317
- } catch (_err) {
1318
- state.claimsByTask = new Map();
1319
- }
1320
- }
1321
584
 
1322
- async function loadGoalTasks(goalId) {
1323
- try {
1324
- const [resp] = await Promise.all([
1325
- getJSON(`${API}/tasks?goal_id=${encodeURIComponent(goalId)}`),
1326
- loadActiveClaims(), // fills state.claimsByTask (best-effort)
1327
- ]);
1328
- if (!sameId(state.current, goalId)) return; // navigated away while loading
1329
- const tasks = resp.tasks || [];
1330
- state.work = {
1331
- todo: tasks.filter((t) => isTodoStatus(t.status)),
1332
- done: tasks.filter((t) => DONE_STATUSES.has(t.status))
1333
- .sort((a, b) => new Date(b.shipped_at || 0) - new Date(a.shipped_at || 0)),
1334
- };
1335
- state.workLoaded = true; // task 1828 — member stats can now compute
1336
- pruneGoalSelection(); // task 1002425 — drop ticks for cards that vanished (or a goal switch)
1337
- renderWork();
1338
- renderMembers(); // refresh per-member goal stats now that tasks landed
1339
- // Prefer the server-side detector when it exists (task 1736); any failure
1340
- // (404/501 pre-landing, or a transient error) falls back to the client
1341
- // estimate from declared touches[].
1342
- try {
1343
- const server = await getJSON(`${API}/goals/${encodeURIComponent(goalId)}/conflicts`);
1344
- if (!sameId(state.current, goalId)) return;
1345
- state.conflictPairs = (server.conflicts || []).map((c) => ({
1346
- a: c.task_a, b: c.task_b, titleA: c.title_a || '', titleB: c.title_b || '',
1347
- paths: c.overlapping_paths || [], severity: c.severity || null,
1348
- crossGoalTitle: c.cross_goal ? (c.goal_b_title || '') : '',
1349
- }));
1350
- state.conflictSource = 'server';
1351
- } catch (err) {
1352
- if (!sameId(state.current, goalId)) return;
1353
- state.conflictPairs = computeConflicts(tasks).map((c) => ({
1354
- a: c.a.id, b: c.b.id, titleA: c.a.title || '', titleB: c.b.title || '',
1355
- paths: c.paths, severity: null, crossGoalTitle: '',
1356
- }));
1357
- state.conflictSource = 'client';
1358
- }
1359
- renderConflicts();
1360
- } catch (err) {
1361
- if (err && err.message === 'redirecting to auth') return;
1362
- console.error('[goals] tasks load failed', err);
1363
- if (!sameId(state.current, goalId)) return;
1364
- // task 1002425 — To do is now a card list (#goal-todo-list), not a table.
1365
- const todoList = $('#goal-todo-list');
1366
- const doneBody = document.querySelector('#goal-done tbody');
1367
- if (todoList) todoList.innerHTML = emptyStateHtml('Tasks could not be loaded.');
1368
- if (doneBody) doneBody.innerHTML = '<tr><td colspan="5" class="ledger__empty">Tasks could not be loaded.</td></tr>';
1369
- state.conflictPairs = null;
1370
- state.conflictSource = null;
1371
- renderConflicts();
1372
- }
1373
- }
1374
585
 
1375
586
  // task 1877 — the any-builder-readable directory backing the invite dropdown
1376
587
  // (superseded task 1762's GET /leaderboard lookup, which missed anyone
1377
588
  // outside the top-100-by-credits window). Fetched once per page load and
1378
589
  // cached — the roster doesn't change fast enough to warrant a re-fetch per
1379
590
  // goal, and loadDetail's force-reload on invite already refreshes members.
1380
- async function loadDirectory() {
1381
- if (state.directory) return state.directory;
1382
- try {
1383
- const d = await getJSON(`${API}/builders/directory`);
1384
- state.directory = d.builders || [];
1385
- } catch (err) {
1386
- state.directory = [];
1387
- }
1388
- return state.directory;
1389
- }
1390
591
 
1391
592
  // task 1762/1877 — owner/manager invite control. Shows a compact form (a
1392
593
  // builder dropdown + membership) in place of a button while open. Idempotent
1393
594
  // re-render: always rebuilds the box from scratch, so state.inviteOpen is the
1394
595
  // only thing that survives a renderDetail() call while the form is open.
1395
- function renderInviteBox(goalId, canInvite, members) {
1396
- const box = $('#goal-invite');
1397
- if (!box) return;
1398
- if (!canInvite) {
1399
- box.hidden = true;
1400
- box.innerHTML = '';
1401
- state.inviteOpen = false;
1402
- return;
1403
- }
1404
- box.hidden = false;
1405
- if (!state.inviteOpen) {
1406
- box.innerHTML = '<button class="btn-ghost" id="goal-invite-open" type="button">+ Invite</button>';
1407
- $('#goal-invite-open')?.addEventListener('click', async () => {
1408
- state.inviteOpen = true;
1409
- await loadDirectory();
1410
- renderInviteBox(goalId, canInvite, members);
1411
- });
1412
- return;
1413
- }
1414
- const memberIds = new Set((members || []).map((m) => String(m.builder_id)));
1415
- const invitable = (state.directory || []).filter((b) => !memberIds.has(String(b.id)));
1416
- const options = invitable
1417
- .map((b) => `<option value="${escapeHtml(String(b.id))}">${escapeHtml(b.display_name || ('@' + b.github_login))}</option>`)
1418
- .join('');
1419
- box.innerHTML = `
1420
- <form class="goal-invite__form" id="goal-invite-form">
1421
- <select id="goal-invite-builder" required ${invitable.length ? '' : 'disabled'}>
1422
- ${invitable.length ? `<option value="" disabled selected>Choose a builder…</option>${options}`
1423
- : '<option value="">Everyone is already a member</option>'}
1424
- </select>
1425
- <select id="goal-invite-kind">
1426
- <option value="member">Member</option>
1427
- <option value="lead">Manager</option>
1428
- </select>
1429
- <button class="landing__btn" type="submit" ${invitable.length ? '' : 'disabled'}>Invite</button>
1430
- <button class="btn-ghost" id="goal-invite-cancel" type="button">Cancel</button>
1431
- </form>`;
1432
- $('#goal-invite-cancel')?.addEventListener('click', () => {
1433
- state.inviteOpen = false;
1434
- renderInviteBox(goalId, canInvite, members);
1435
- });
1436
- $('#goal-invite-form')?.addEventListener('submit', (ev) => {
1437
- ev.preventDefault();
1438
- const builderId = $('#goal-invite-builder')?.value;
1439
- const membershipKind = $('#goal-invite-kind')?.value || 'member';
1440
- if (builderId) inviteMember(goalId, builderId, membershipKind);
1441
- });
1442
- }
1443
596
 
1444
- async function inviteMember(goalId, builderId, membershipKind) {
1445
- try {
1446
- // `membership_kind`, not `role` — ADR 0174 reserves that word for the three
1447
- // crafts (task 1003013).
1448
- await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/members`, { builder_id: Number(builderId), membership_kind: membershipKind });
1449
- toast('Invited.');
1450
- state.inviteOpen = false;
1451
- await loadDetail(goalId, { force: true });
1452
- renderDetail(goalId);
1453
- renderList();
1454
- } catch (err) {
1455
- const code = err?.data?.error;
1456
- if (code === 'archon_required_for_protected_scope') toast('This goal is scoped to a protected module — only an Archon may add members.');
1457
- else if (code === 'metic_required_for_protected_scope') toast('A protected-scope goal requires a Metic+ member.');
1458
- else if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may invite.');
1459
- else if (code === 'goal_not_open') toast('This goal is not open.');
1460
- else toast('Could not invite that builder just now.');
1461
- }
1462
- }
1463
597
 
1464
- async function joinLeave(goalId, action) {
1465
- try {
1466
- await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/${action}`);
1467
- toast(action === 'join' ? 'Joined the goal.' : 'Left the goal.');
1468
- await loadDetail(goalId, { force: true });
1469
- renderDetail(goalId);
1470
- renderList();
1471
- } catch (err) {
1472
- const code = err?.data?.error;
1473
- if (code === 'archon_approval_required') toast('This goal is Archon-mediated — request to join instead.');
1474
- else if (code === 'goal_private') toast('This goal is private — request to join instead.');
1475
- else if (code === 'not_a_member') toast('You are not a member of this goal.');
1476
- else if (code === 'goal_not_open') toast('This goal is not open.');
1477
- else toast(`Could not ${action} just now.`);
1478
- }
1479
- }
1480
598
 
1481
599
  // task 1953 — a manager (or Archon) flips a goal public|private.
1482
- async function toggleVisibility(goalId, visibility) {
1483
- try {
1484
- await patchJSON(`${API}/goals/${encodeURIComponent(goalId)}/visibility`, { visibility });
1485
- toast(visibility === 'private' ? 'Goal is now private.' : 'Goal is now public.');
1486
- await loadDetail(goalId, { force: true });
1487
- renderDetail(goalId);
1488
- renderList();
1489
- } catch (err) {
1490
- const code = err?.data?.error;
1491
- if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may do this.');
1492
- else if (code === 'goal_not_open') toast('Only an open goal can be managed.');
1493
- else toast('Could not update visibility just now.');
1494
- }
1495
- }
1496
600
 
1497
601
  // task 1953 — a non-member asks to join a private (or protected) goal.
1498
602
  // renderDetail (below) re-derives canManage/joined and re-triggers
1499
603
  // loadGoalMembership, so a plain reload-and-rerender is enough here — same
1500
604
  // shape as joinLeave/inviteMember.
1501
- async function requestToJoin(goalId) {
1502
- try {
1503
- await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/requests`, {});
1504
- toast('Request sent — the owner or a manager will review it.');
1505
- await loadDetail(goalId, { force: true });
1506
- renderDetail(goalId);
1507
- } catch (err) {
1508
- const code = err?.data?.error;
1509
- if (code === 'request_pending') toast('You already have a pending request or invitation for this goal.');
1510
- else if (code === 'already_a_member') toast('You are already a member of this goal.');
1511
- else if (code === 'goal_is_open') toast('This goal is open — joining directly instead.');
1512
- else toast('Could not send the request just now.');
1513
- }
1514
- }
1515
605
 
1516
606
  // task 1953 — withdraw MY OWN pending join-request (self-served, no manager needed).
1517
- async function withdrawRequest(goalId, reqId) {
1518
- if (reqId == null) return;
1519
- try {
1520
- await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/requests/${encodeURIComponent(reqId)}/withdraw`, {});
1521
- toast('Request withdrawn.');
1522
- await loadDetail(goalId, { force: true });
1523
- renderDetail(goalId);
1524
- } catch (err) {
1525
- toast((err && err.data && err.data.message) || 'Could not withdraw that request just now.');
1526
- }
1527
- }
1528
607
 
1529
608
  // task 1953 — a manager approves/denies a pending join-request from the goal's
1530
609
  // own pending panel (mirrors the home-inbox action, POST …/requests/:reqId/respond).
@@ -1551,37 +630,6 @@
1551
630
  // task 1953 — every pending row on THIS goal, rendered for a manager: join
1552
631
  // requests get Approve/Deny; outstanding invites are informational (only the
1553
632
  // invitee can accept/decline theirs — the server has no manager-cancel route).
1554
- function pendingRequestWho(r) {
1555
- return r.display_name || (r.github_login ? `@${r.github_login}` : `builder #${r.builder_id}`);
1556
- }
1557
- function pendingRequestsHtml(rows) {
1558
- const requests = rows.filter((r) => r.kind === 'request');
1559
- const invites = rows.filter((r) => r.kind === 'invite');
1560
- const requestLi = (r) => `<li class="pending-row">
1561
- <span class="pending-row__who">${escapeHtml(pendingRequestWho(r))}</span>
1562
- ${r.note ? `<span class="pending-row__note">“${escapeHtml(r.note)}”</span>` : ''}
1563
- <span class="pending-row__actions">
1564
- <button class="btn-ghost" type="button" data-approve-req="${escapeHtml(String(r.id))}">Approve</button>
1565
- <button class="btn-ghost" type="button" data-deny-req="${escapeHtml(String(r.id))}">Deny</button>
1566
- </span>
1567
- </li>`;
1568
- const inviteLi = (r) => `<li class="pending-row">
1569
- <span class="pending-row__who">${escapeHtml(pendingRequestWho(r))}</span>
1570
- <span class="pending-row__note">invited — awaiting their response</span>
1571
- </li>`;
1572
- const parts = [];
1573
- if (requests.length) parts.push(`<div class="pending-group"><h3 class="pending-group__h">Join requests<span class="count-pill">${requests.length}</span></h3><ul class="pending-list">${requests.map(requestLi).join('')}</ul></div>`);
1574
- if (invites.length) parts.push(`<div class="pending-group"><h3 class="pending-group__h">Pending invitations<span class="count-pill">${invites.length}</span></h3><ul class="pending-list">${invites.map(inviteLi).join('')}</ul></div>`);
1575
- return parts.join('');
1576
- }
1577
- function renderPendingRequests() {
1578
- const box = $('#goal-pending');
1579
- if (!box) return;
1580
- const rows = state.pendingRequests;
1581
- if (!rows || !rows.length) { box.hidden = true; box.innerHTML = ''; return; }
1582
- box.hidden = false;
1583
- box.innerHTML = pendingRequestsHtml(rows);
1584
- }
1585
633
 
1586
634
  // task 1953 — async membership-queue load, kicked off (fire-and-forget) from
1587
635
  // renderDetail like loadGoalTasks: my own pending request on this goal (any
@@ -1589,43 +637,9 @@
1589
637
  // goal's own pending queue (GET /goals/:id/requests, task 1953's new route).
1590
638
  // Feature-detected the way the conflicts panel 404-detects the server-side
1591
639
  // detector: an older server (or a non-manager 403) just hides the panel.
1592
- async function loadGoalMembership(goalId, canManage) {
1593
- try {
1594
- const inbox = await getJSON(`${API}/goals/inbox`);
1595
- if (!sameId(state.current, goalId)) return;
1596
- state.myPendingRequest = (inbox.myPendingRequests || []).find((r) => sameId(r.goal_id, goalId)) || null;
1597
- } catch (err) {
1598
- if (err && err.message === 'redirecting to auth') return;
1599
- if (!sameId(state.current, goalId)) return;
1600
- state.myPendingRequest = null;
1601
- }
1602
- if (canManage) {
1603
- try {
1604
- const r = await getJSON(`${API}/goals/${encodeURIComponent(goalId)}/requests`);
1605
- if (!sameId(state.current, goalId)) return;
1606
- state.pendingRequests = r.requests || [];
1607
- } catch (err) {
1608
- if (err && err.message === 'redirecting to auth') return;
1609
- if (!sameId(state.current, goalId)) return;
1610
- state.pendingRequests = null;
1611
- }
1612
- } else {
1613
- state.pendingRequests = null;
1614
- }
1615
- if (!sameId(state.current, goalId)) return;
1616
- renderPendingRequests();
1617
- const d = currentDetail();
1618
- if (d) renderActions(d.goal, { joined: (d.members || []).some((m) => sameId(m.builder_id, selfId())), canManage });
1619
- }
1620
640
 
1621
641
  /* ---------------- data + routing ---------------- */
1622
642
 
1623
- async function loadDetail(goalId, { force = false } = {}) {
1624
- if (!force && state.details.has(String(goalId))) return state.details.get(String(goalId));
1625
- const d = await getJSON(`${API}/goals/${encodeURIComponent(goalId)}`);
1626
- state.details.set(String(goalId), d);
1627
- return d;
1628
- }
1629
643
 
1630
644
  // task 1888 — a freshly opened goal starts from the compact defaults: To do
1631
645
  // tab selected, capped lists, counts hidden until its own data lands.