@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.
- package/.bongos-core.json +173 -43
- package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +84 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -2
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +202 -202
- package/docs/copy-registry.json +384 -384
- package/modules/grading/grader-config.js +98 -0
- package/modules/grading/grader-model.js +150 -0
- package/modules/grading/grader-prompt.js +139 -0
- package/modules/grading/grader-score.js +706 -0
- package/modules/grading/grader-subagent.js +328 -0
- package/modules/grading/grader.js +5 -1287
- package/modules/hall-ui/public/builders.js +19 -1182
- package/modules/hall-ui/public/goals-page.js +11 -997
- package/modules/hall-ui/public/goals-render.js +1032 -0
- package/modules/hall-ui/public/goals.html +4 -0
- package/modules/hall-ui/public/hall-render.js +1211 -0
- package/modules/hall-ui/public/index.html +6 -0
- package/modules/lifecycle/routes/goal-route-authz.js +400 -0
- package/modules/lifecycle/routes/goals.js +1 -367
- package/modules/lifecycle/routes/task-route-helpers.js +219 -0
- package/modules/lifecycle/routes/task-write-routes.js +542 -0
- package/modules/lifecycle/routes/tasks.js +3 -696
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-infra.js +280 -0
- package/scripts/gds/box.js +1 -245
- package/scripts/gds/fitness-checks-identity.js +320 -0
- package/scripts/gds/fitness-checks-vocabulary.js +230 -0
- package/scripts/gds/fitness-checks-write-validation.js +144 -0
- package/scripts/gds/fitness-lib.js +82 -0
- package/scripts/gds/fitness.js +55 -668
- package/scripts/gds/provision-config.js +204 -0
- package/scripts/gds/provision-net.js +221 -0
- package/scripts/gds/provision-repo.js +849 -0
- package/scripts/gds/provision-units.js +262 -0
- package/scripts/gds/provision.js +4 -1395
- package/src/bongos/auth-admission.js +264 -0
- package/src/bongos/auth-config.js +151 -0
- package/src/bongos/auth-discord.js +198 -0
- package/src/bongos/auth-github.js +336 -0
- package/src/bongos/auth-idp.js +330 -0
- package/src/bongos/auth.js +5 -1100
- package/src/bongos/routes/auth.js +54 -10
- package/src/module-api.js +5 -1
- package/tests/auth_admission_gate.mjs +3 -1
- package/tests/auth_web_recovery.mjs +63 -0
- package/tests/est_advisory.mjs +3 -1
- package/tests/goal_advisory.mjs +3 -2
- package/tests/goal_criterion_confirm_ui.mjs +6 -0
- package/tests/goal_subtitle.mjs +5 -2
- package/tests/goals_card_view.mjs +10 -3
- package/tests/grader_generated_provenance.mjs +3 -1
- package/tests/hall_palette.mjs +3 -1
- package/tests/helpers.mjs +58 -25
- package/tests/lib_sh_resolution.mjs +3 -1
- package/tests/membership_kind_lockstep.mjs +3 -1
- package/tests/migration_allocation.mjs +5 -2
- package/tests/patch_gate_ordering.mjs +3 -1
- package/tests/rank_tier_single_source.mjs +16 -3
- 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
|
-
|
|
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.
|