@bongos/core 1.19.280 → 1.19.282

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ // goals-lib.js — the Goals page's PURE presentational helpers, exposed as
2
+ // window.OTBGoalsLib. Carved out of the oversized goals-page.js (task 1003135;
3
+ // the fitness excess-lines ratchet). Everything here is a pure function over
4
+ // its arguments (or a constant) — no `state`, no DOM reads — so it can move
5
+ // without dragging the page's IIFE state along. Loaded by goals.html BEFORE
6
+ // goals-page.js (the task-lib.js / hall-kit.js pattern, tasks 1002463 / 2171).
7
+ (function () {
8
+ 'use strict';
9
+
10
+ // Settled task statuses (paid or awaiting land) vs terminal "deliberately
11
+ // cut" states — neither work-to-do nor done. CUT is excluded from the todo
12
+ // list and the conflict scan the same way migration 017 keeps them out of
13
+ // version_progress ("abandoned is a terminal state (deliberately cut), not
14
+ // pending work"). Without this, an abandoned task shows up under a goal's
15
+ // "Work to do" as if it were still queued.
16
+ const DONE_STATUSES = new Set(['completed', 'confirmed', 'shipped']);
17
+ const CUT_STATUSES = new Set(['abandoned', 'dropped']);
18
+ // Genuinely-open work under a goal: not done, not cut → {backlog, ready,
19
+ // active, blocked}.
20
+ const isTodoStatus = (s) => !DONE_STATUSES.has(s) && !CUT_STATUSES.has(s);
21
+
22
+ // The criteria progress ring (satisfied/total), green once achieved.
23
+ function ring(satisfied, total, achieved, size = 42) {
24
+ const pct = total > 0 ? satisfied / total : (achieved ? 1 : 0);
25
+ const r = 16;
26
+ const circ = 2 * Math.PI * r;
27
+ const dash = (pct * circ).toFixed(1);
28
+ const gap = (circ - pct * circ).toFixed(1);
29
+ const stroke = achieved ? 'var(--ok)' : 'var(--accent)';
30
+ return `<svg class="goal-ring" width="${size}" height="${size}" viewBox="0 0 42 42" role="img" aria-label="${satisfied} of ${total} criteria satisfied">
31
+ <circle cx="21" cy="21" r="${r}" fill="none" stroke="var(--rule-soft)" stroke-width="4"></circle>
32
+ <circle cx="21" cy="21" r="${r}" fill="none" stroke="${stroke}" stroke-width="4" stroke-linecap="round" stroke-dasharray="${dash} ${gap}" transform="rotate(-90 21 21)"></circle>
33
+ <text x="21" y="25" text-anchor="middle" class="goal-ring__label">${satisfied}/${total}</text>
34
+ </svg>`;
35
+ }
36
+
37
+ function memberName(m) {
38
+ return m.display_name || (m.github_login ? `@${m.github_login}` : `builder #${m.builder_id}`);
39
+ }
40
+
41
+ // ---- task-readiness rollup (hall redesign task 1002439) -------------------
42
+ // Dependencies are a TASK property; a goal is only "blocked" in the derived
43
+ // sense that all its actionable tasks wait on upstream tasks (the Tasks page,
44
+ // not this one, owns the actual task→task graph). So the Goals page rolls each
45
+ // goal's tasks into these buckets:
46
+ // done — settled (shipped/confirmed/completed)
47
+ // active — a live claim ("in progress")
48
+ // ready — claimable now (deps met + promoted)
49
+ // blocked — backlog with ≥1 unshipped dependency
50
+ // backlog — backlog, no unmet deps (just not started/promoted)
51
+ function readinessOf(t) {
52
+ const s = t.status;
53
+ if (DONE_STATUSES.has(s)) return 'done';
54
+ if (CUT_STATUSES.has(s)) return 'cut';
55
+ if (s === 'active') return 'active';
56
+ if (s === 'ready') return 'ready';
57
+ if (s === 'backlog' && (t.blocked_by || []).some((d) => !d.satisfied)) return 'blocked';
58
+ return 'backlog';
59
+ }
60
+ const EMPTY_ROLLUP = { done: 0, active: 0, ready: 0, blocked: 0, backlog: 0, cut: 0, total: 0, nextReady: null };
61
+
62
+ // task 1002439 — a goal row's readiness rollup line (only the non-zero buckets).
63
+ const ROLL_DOTS = [
64
+ ['ready', 'ready', 'var(--ok)'],
65
+ ['active', 'in progress', 'var(--info)'],
66
+ ['blocked', 'blocked', 'var(--terracotta)'],
67
+ ['backlog', 'backlog', 'var(--ink-faint)'],
68
+ ];
69
+ function goalRollLine(r) {
70
+ const parts = ROLL_DOTS.filter(([k]) => r[k] > 0)
71
+ .map(([k, label, c]) => `<span class="gr-d"><i style="background:${c}"></i><b>${r[k]}</b> ${label}</span>`);
72
+ return parts.length ? `<div class="gr-roll">${parts.join('')}</div>` : '';
73
+ }
74
+
75
+ // Free-text search over a goal's todo list (work.js:261-274, minus the
76
+ // goal-title match — a single goal makes that redundant). '#N', 'N' and a
77
+ // bare number all match the id.
78
+ function matchesTodoSearch(t, q) {
79
+ if (!q) return true;
80
+ const needle = q.replace(/^#/, '');
81
+ if (!needle) return true;
82
+ if (String(t.id).includes(needle)) return true;
83
+ if ((t.title || '').toLowerCase().includes(needle)) return true;
84
+ return false;
85
+ }
86
+
87
+ // ms helpers for the goal ordering (task 1002965): updated_at moves on every
88
+ // task write, shipped_at folded in because a ship path can stamp it without
89
+ // touching updated_at.
90
+ const msOf = (iso) => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; };
91
+ const taskActivityMs = (t) => Math.max(msOf(t.updated_at), msOf(t.shipped_at));
92
+
93
+ window.OTBGoalsLib = {
94
+ DONE_STATUSES, CUT_STATUSES, isTodoStatus,
95
+ ring, memberName,
96
+ readinessOf, EMPTY_ROLLUP, goalRollLine,
97
+ matchesTodoSearch, msOf, taskActivityMs,
98
+ };
99
+ })();
@@ -35,13 +35,18 @@
35
35
  // empty/skeleton states — adopted here by task 2171.
36
36
  const { makePager, paginate, wirePager, pagerBarHtml, makeTabs, makeFilterBar,
37
37
  emptyStateHtml, skeletonRows } = window.OTBKit;
38
+ // This page's pure presentational helpers (goals-lib.js, task 1003135 — carved
39
+ // out of this oversized file; the fitness excess-lines ratchet). Destructured
40
+ // so every call site below reads exactly as before.
41
+ const { DONE_STATUSES, CUT_STATUSES, isTodoStatus, ring, memberName,
42
+ readinessOf, EMPTY_ROLLUP, goalRollLine, matchesTodoSearch,
43
+ msOf, taskActivityMs } = window.OTBGoalsLib;
38
44
  const API = '/api/bongos';
39
45
  // Generated typed client (task 1996 / R09), served as window.BongosClient by
40
46
  // /lib/bongos-client.js. baseUrl:'' passes the full same-origin paths this file
41
47
  // builds through unchanged; result-mode ({ status, ok, data }) preserves control flow 1:1.
42
48
  const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
43
49
 
44
- const DONE_STATUSES = new Set(['completed', 'confirmed', 'shipped']);
45
50
  // task 1888 — the Work tables and the conflicts list render capped; the pager
46
51
  // under each expands to the full set.
47
52
  const CONFLICT_CAP = 3;
@@ -59,15 +64,6 @@
59
64
  todo: makePager('goal-todo', 'otb.goals.todoPerPage'),
60
65
  done: makePager('goal-done', 'otb.goals.donePerPage'),
61
66
  };
62
- // Terminal "deliberately cut" states — neither work-to-do nor done. Excluded
63
- // from the todo list and the conflict scan the same way migration 017 keeps
64
- // them out of version_progress ("abandoned is a terminal state (deliberately
65
- // cut), not pending work"). Without this, an abandoned task shows up under a
66
- // goal's "Work to do" as if it were still queued.
67
- const CUT_STATUSES = new Set(['abandoned', 'dropped']);
68
- // Genuinely-open work under a goal: not done, not cut → {backlog, ready,
69
- // active, blocked}.
70
- const isTodoStatus = (s) => !DONE_STATUSES.has(s) && !CUT_STATUSES.has(s);
71
67
  // Record deep-links (#/task|idea|blocker/N) are owned by the shared overlay
72
68
  // (task-detail.js). This page must NOT treat them as a route change (Bug 2).
73
69
  const DEEPLINK_RE = /^#\/(?:task|idea|blocker)\/\d+$/;
@@ -148,44 +144,7 @@
148
144
  return `<span class="goal-badge ${cls}">${escapeHtml(status)}</span>`;
149
145
  }
150
146
 
151
- function ring(satisfied, total, achieved, size = 42) {
152
- const pct = total > 0 ? satisfied / total : (achieved ? 1 : 0);
153
- const r = 16;
154
- const circ = 2 * Math.PI * r;
155
- const dash = (pct * circ).toFixed(1);
156
- const gap = (circ - pct * circ).toFixed(1);
157
- const stroke = achieved ? 'var(--ok)' : 'var(--accent)';
158
- return `<svg class="goal-ring" width="${size}" height="${size}" viewBox="0 0 42 42" role="img" aria-label="${satisfied} of ${total} criteria satisfied">
159
- <circle cx="21" cy="21" r="${r}" fill="none" stroke="var(--rule-soft)" stroke-width="4"></circle>
160
- <circle cx="21" cy="21" r="${r}" fill="none" stroke="${stroke}" stroke-width="4" stroke-linecap="round" stroke-dasharray="${dash} ${gap}" transform="rotate(-90 21 21)"></circle>
161
- <text x="21" y="25" text-anchor="middle" class="goal-ring__label">${satisfied}/${total}</text>
162
- </svg>`;
163
- }
164
-
165
- function memberName(m) {
166
- return m.display_name || (m.github_login ? `@${m.github_login}` : `builder #${m.builder_id}`);
167
- }
168
-
169
- // ---- task-readiness rollup (hall redesign task 1002439) --------------------
170
- // Dependencies are a TASK property; a goal is only "blocked" in the derived
171
- // sense that all its actionable tasks wait on upstream tasks (the Tasks page,
172
- // not this one, owns the actual task→task graph). So the Goals page rolls each
173
- // goal's tasks into these buckets:
174
- // done — settled (shipped/confirmed/completed)
175
- // active — a live claim ("in progress")
176
- // ready — claimable now (deps met + promoted)
177
- // blocked — backlog with ≥1 unshipped dependency
178
- // backlog — backlog, no unmet deps (just not started/promoted)
179
- function readinessOf(t) {
180
- const s = t.status;
181
- if (DONE_STATUSES.has(s)) return 'done';
182
- if (CUT_STATUSES.has(s)) return 'cut';
183
- if (s === 'active') return 'active';
184
- if (s === 'ready') return 'ready';
185
- if (s === 'backlog' && (t.blocked_by || []).some((d) => !d.satisfied)) return 'blocked';
186
- return 'backlog';
187
- }
188
- const EMPTY_ROLLUP = { done: 0, active: 0, ready: 0, blocked: 0, backlog: 0, cut: 0, total: 0, nextReady: null };
147
+ // ring / memberName / readinessOf / EMPTY_ROLLUP → goals-lib.js (task 1003135).
189
148
  const rollupOf = (goalId) => state.rollups.get(String(goalId)) || EMPTY_ROLLUP;
190
149
 
191
150
  // ---- goal ordering (task 1002965) -----------------------------------------
@@ -197,8 +156,6 @@
197
156
  // falling back to the goal row's own updated_at when the goal
198
157
  // has no tasks at all, so a task-less goal still orders sanely
199
158
  // instead of sinking to the bottom forever.
200
- const msOf = (iso) => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; };
201
- const taskActivityMs = (t) => Math.max(msOf(t.updated_at), msOf(t.shipped_at));
202
159
  const goalCreatedMs = (g) => msOf(g.created_at);
203
160
  const goalActivityMs = (g) => state.activity.get(String(g.id)) || msOf(g.updated_at);
204
161
 
@@ -211,6 +168,8 @@
211
168
  active: { label: 'recently active', cmp: (a, b) => goalActivityMs(b) - goalActivityMs(a), stamp: (g) => ({ label: 'last work', ms: goalActivityMs(g) }) },
212
169
  };
213
170
  const GOAL_SORT_PREF = 'otb.goals.sort';
171
+ // task 1003136 — the list|cards view choice, persisted like the sort above.
172
+ const GOAL_VIEW_PREF = 'otb.goals.view';
214
173
  const currentGoalSort = () => GOAL_SORTS[$('#f-goal-order')?.value] || GOAL_SORTS.priority;
215
174
 
216
175
  // Stable: filteredGoals already returns the server's priority order, and
@@ -271,11 +230,44 @@
271
230
  }
272
231
  }
273
232
 
274
- // task 1760 — priority is a WITHIN-version ordering, so the drag affordance
275
- // only makes sense once the grid is narrowed to exactly one version (the
276
- // "any" filter mixes versions and dragging across them would be meaningless).
277
- // Priority is always visible in that state; only an Archon gets the handle —
278
- // the server-side rank gate on POST /goals/reorder is the real enforcement.
233
+ // task 1003135 — the Owner dropdown. 'Goal owner' is goals.created_by (the
234
+ // fixed authority, migration 160), returned by GET /goals as a raw builder id;
235
+ // labels come from the directory the page already lazily caches (loadDirectory,
236
+ // task 1877), same label idiom as the invite dropdown. created_by is NULLABLE
237
+ // (system-attributed — e.g. the migration-160 '<version> — general' catch-alls),
238
+ // hence the explicit 'system / unattributed' option. Mirrors
239
+ // populateVersionFilter above; fired after boot's fetch resolves (async because
240
+ // of the directory fetch — fire-and-forget, the select fills when ready).
241
+ async function populateOwnerFilter() {
242
+ const sel = $('#f-goal-owner');
243
+ if (!sel) return;
244
+ const ownerIds = Array.from(new Set(
245
+ state.goals.filter((g) => g.created_by != null).map((g) => String(g.created_by))));
246
+ const hasSystem = state.goals.some((g) => g.created_by == null);
247
+ const dir = ownerIds.length ? await loadDirectory() : [];
248
+ const byId = new Map((dir || []).map((b) => [String(b.id), b]));
249
+ const label = (id) => {
250
+ const b = byId.get(id);
251
+ return b ? (b.display_name || '@' + b.github_login) : `builder #${id}`;
252
+ };
253
+ for (const id of ownerIds.sort((a, b) => label(a).localeCompare(label(b)))) {
254
+ const opt = document.createElement('option');
255
+ opt.value = id;
256
+ opt.textContent = label(id);
257
+ sel.appendChild(opt);
258
+ }
259
+ if (hasSystem) {
260
+ const opt = document.createElement('option');
261
+ opt.value = 'none';
262
+ opt.textContent = 'system / unattributed';
263
+ sel.appendChild(opt);
264
+ }
265
+ }
266
+
267
+ // Archon check for the detail view's manage/visibility affordances (the
268
+ // server-side rank gates are the real enforcement). The task-1760 drag-reorder
269
+ // affordance this once also served is retired — see the note where
270
+ // wireDragReorder used to live (task 1003136).
279
271
  const isArchon = () => (state.me?.builder?.rank || '').toLowerCase() === 'archon';
280
272
 
281
273
  // task 1002164 — confirming a criterion is Metic+ (POST /done-when/:id/satisfy,
@@ -292,6 +284,18 @@
292
284
  const ver = $('#f-goal-version')?.value || '';
293
285
  const status = $('#f-goal-status')?.value || '';
294
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);
295
299
  const myId = selfId();
296
300
  const iAmMember = (g) => {
297
301
  const d = state.details.get(String(g.id));
@@ -300,22 +304,11 @@
300
304
  return state.goals.filter((g) =>
301
305
  (!ver || g.version_id === ver) &&
302
306
  (!status || g.status === status) &&
307
+ matchesSearch(g) &&
308
+ matchesOwner(g) &&
303
309
  (!mineOnly || iAmMember(g)));
304
310
  }
305
311
 
306
- // task 1002439 — a goal row's readiness rollup line (only the non-zero buckets).
307
- const ROLL_DOTS = [
308
- ['ready', 'ready', 'var(--ok)'],
309
- ['active', 'in progress', 'var(--info)'],
310
- ['blocked', 'blocked', 'var(--terracotta)'],
311
- ['backlog', 'backlog', 'var(--ink-faint)'],
312
- ];
313
- function goalRollLine(r) {
314
- const parts = ROLL_DOTS.filter(([k]) => r[k] > 0)
315
- .map(([k, label, c]) => `<span class="gr-d"><i style="background:${c}"></i><b>${r[k]}</b> ${label}</span>`);
316
- return parts.length ? `<div class="gr-roll">${parts.join('')}</div>` : '';
317
- }
318
-
319
312
  // task 1002439 — one goal, rendered as a rollup row: criteria ring · title +
320
313
  // meta + readiness rollup · one next action (claim the next ready task, drill
321
314
  // into what's blocking, or a review/achieved note).
@@ -329,13 +322,16 @@
329
322
  return ` · ${escapeHtml(label)} ${escapeHtml(ms ? fmtDate(new Date(ms).toISOString()) : '—')}`;
330
323
  }
331
324
 
332
- function goalRowHtml(g) {
325
+ // The facts a goal row/card renders, computed ONCE so the two views (task
326
+ // 1003136) can't drift: ring, meta line, roll line, and the single
327
+ // next-action ladder (achieved → criteria-met → claim-next → drill → quiet).
328
+ function goalRenderFacts(g, ringSize) {
333
329
  const d = state.details.get(String(g.id));
334
330
  const crit = d ? (d.criteria || []) : [];
335
331
  const sat = crit.filter((c) => c.satisfied).length;
336
332
  const r = rollupOf(g.id);
337
333
  const achieved = g.status === 'achieved';
338
- const ringHtml = crit.length ? ring(sat, crit.length, achieved, 44)
334
+ const ringHtml = crit.length ? ring(sat, crit.length, achieved, ringSize)
339
335
  : `<span class="gr-crit-none" aria-hidden="true">—</span>`;
340
336
  const goalHref = `#/goal/${escapeHtml(String(g.id))}`;
341
337
  let right;
@@ -351,6 +347,12 @@
351
347
  } else {
352
348
  right = `<span class="gr-quiet">No tasks yet</span>`;
353
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
+
354
+ function goalRowHtml(g) {
355
+ const { r, achieved, ringHtml, goalHref, right, metaHtml } = goalRenderFacts(g, 44);
354
356
  // NOTE: a rollup row nests real links (title → goal, Claim → task overlay,
355
357
  // drill → goal), so the row itself is a <div>, not an <a> — nested anchors
356
358
  // are invalid. Only the explicit links navigate.
@@ -358,13 +360,31 @@
358
360
  <div class="gr-ringcol">${ringHtml}</div>
359
361
  <div class="gr-main">
360
362
  <a class="gr-title" href="${goalHref}">${escapeHtml(g.title || 'Untitled goal')}</a>
361
- <div class="gr-meta">#${escapeHtml(String(g.id))} · ${escapeHtml(g.version_id || '')}${(achieved || g.status === 'open') ? '' : ' · ' + escapeHtml(g.status)}${goalStampHtml(g)}</div>
363
+ <div class="gr-meta">${metaHtml}</div>
362
364
  ${achieved ? '' : goalRollLine(r)}
363
365
  </div>
364
366
  <div class="gr-right">${right}</div>
365
367
  </div>`;
366
368
  }
367
369
 
370
+ // task 1003136 — the same goal as a CARD: identical facts as the row via
371
+ // goalRenderFacts, so nothing is lost switching views. Reuses the surviving
372
+ // .goal-card* CSS (goals.css — task 1002439 neutralised it, never deleted it).
373
+ // Deliberately emits NO draggable attribute: drag-to-reorder is retired, see
374
+ // 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}">${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
+
368
388
  // task 1002439 — the Goals page, reorganised around momentum: a task-readiness
369
389
  // summary, then goals grouped Active (has ready/in-progress work) · Blocked
370
390
  // (only waiting-on-upstream tasks) · Achieved. "Blocked" here is a DERIVED
@@ -407,7 +427,15 @@
407
427
  </div>`;
408
428
 
409
429
  const sec = (label, n) => `<div class="gr-sec"><b>${label}</b><span class="gr-sec__n">${n}</span></div>`;
410
- const listOf = (arr) => `<div class="gr-list">${arr.map(goalRowHtml).join('')}</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>`;
411
439
  // task 1002965 — an achieved/archived goal is a compact pill, so its sort
412
440
  // date rides as its own muted span (the row's `gr-meta` equivalent). The
413
441
  // pills row already wraps, so the extra text can't widen the page.
@@ -426,63 +454,14 @@
426
454
  + (archived.length ? sec('Archived', archived.length) + `<div class="gr-pills gr-pills--muted">${archived.map(pill).join('')}</div>` : '');
427
455
  }
428
456
 
429
- // ---- drag-to-reorder (task 1760, Archon-only, one version at a time) ------
430
- let dragEl = null;
431
-
432
- function wireDragReorder() {
433
- const grid = $('#goal-grid');
434
- if (!grid) return;
435
- grid.addEventListener('dragstart', (e) => {
436
- const el = e.target.closest('.goal-card');
437
- if (!el || !el.hasAttribute('draggable')) return;
438
- dragEl = el;
439
- el.classList.add('goal-card--dragging');
440
- e.dataTransfer.effectAllowed = 'move';
441
- });
442
- grid.addEventListener('dragover', (e) => {
443
- if (!dragEl) return;
444
- const over = e.target.closest('.goal-card');
445
- if (!over || over === dragEl || !grid.contains(over)) return;
446
- e.preventDefault();
447
- const rect = over.getBoundingClientRect();
448
- const after = (e.clientX - rect.left) > rect.width / 2;
449
- grid.insertBefore(dragEl, after ? over.nextSibling : over);
450
- });
451
- grid.addEventListener('drop', (e) => { if (dragEl) e.preventDefault(); });
452
- grid.addEventListener('dragend', async () => {
453
- if (!dragEl) return;
454
- const el = dragEl;
455
- el.classList.remove('goal-card--dragging');
456
- dragEl = null;
457
- const ver = $('#f-goal-version')?.value || '';
458
- if (!ver) return;
459
- // task 1932 — with pagination the grid shows only the current PAGE, so the
460
- // reorder must submit the FULL version order: the goals before this page +
461
- // the page's cards in their new DOM order + the goals after it. Without
462
- // this, POSTing only the visible page's ids would corrupt the priorities
463
- // of the goals on other pages. Priority numbers reflect the global
464
- // position (start + i + 1).
465
- const filtered = filteredGoals();
466
- const start = (pagers.list.page - 1) * pagers.list.perPage;
467
- const cards = [...grid.querySelectorAll('.goal-card')];
468
- cards.forEach((card, i) => {
469
- const num = card.querySelector('.goal-card__priority');
470
- if (num) num.textContent = String(start + i + 1);
471
- });
472
- const pageIds = cards.map((card) => Number(card.dataset.goalId));
473
- const beforeIds = filtered.slice(0, start).map((g) => Number(g.id));
474
- const afterIds = filtered.slice(start + pageIds.length).map((g) => Number(g.id));
475
- const orderedIds = [...beforeIds, ...pageIds, ...afterIds];
476
- try {
477
- await postJSON(`${API}/goals/reorder`, { version_id: ver, ordered_ids: orderedIds });
478
- toast('Priority order saved.');
479
- } catch (err) {
480
- toast((err && err.data && err.data.message) || 'Could not save the new order — reloading.');
481
- state.goals = (await getJSON(`${API}/goals`).catch(() => ({ goals: state.goals }))).goals || state.goals;
482
- renderList();
483
- }
484
- });
485
- }
457
+ // ---- drag-to-reorder: RETIRED (task 1003136; was task 1760) ---------------
458
+ // wireDragReorder() lived here, dead since task 1002439's grouped rollup (its
459
+ // .goal-card selector matched nothing). The card view revives .goal-card
460
+ // markup, so the decision is now DELIBERATE: the listener is DELETED — both
461
+ // views render grouped, sorted, filtered sets, so DOM order no longer
462
+ // expresses the version priority the old dragend POSTed to /goals/reorder;
463
+ // re-enabling it would submit a corrupted order. Cards emit no draggable
464
+ // attribute. POST /goals/reorder itself is untouched (Archon, pinned).
486
465
 
487
466
  /* ---------------- detail view ---------------- */
488
467
 
@@ -515,15 +494,6 @@
515
494
  const el = $('#f-goal-search');
516
495
  return el ? el.value.trim().toLowerCase() : '';
517
496
  }
518
- function matchesTodoSearch(t, q) {
519
- if (!q) return true;
520
- const needle = q.replace(/^#/, '');
521
- if (!needle) return true;
522
- if (String(t.id).includes(needle)) return true;
523
- if ((t.title || '').toLowerCase().includes(needle)) return true;
524
- return false;
525
- }
526
-
527
497
  // Copy-prompt (work.js:454-503). deHashRefs stops a bare #NNN autolinking to
528
498
  // GitHub in a pasted session; the prompt points the builder at the hall task URL.
529
499
  async function copyPromptForTask(taskId, btn) {
@@ -1810,6 +1780,9 @@
1810
1780
  const gres = await getJSON(`${API}/goals`);
1811
1781
  state.goals = gres.goals || [];
1812
1782
  populateVersionFilter();
1783
+ // task 1003135 — fire-and-forget: the Owner options fill when the (cached)
1784
+ // directory resolves; boot never waits on a label lookup.
1785
+ populateOwnerFilter().catch(() => {});
1813
1786
  // Prefetch details (ring + members on the cards). Sequentially-parallel,
1814
1787
  // failure-tolerant: a card without detail still renders.
1815
1788
  // task 1002439 — also fold in per-goal task-readiness rollups (one request
@@ -1836,7 +1809,12 @@
1836
1809
  // reads it, so keeping the pref off the URL leaves deep links shareable and
1837
1810
  // unchanged. Private-mode writes throw — swallowed, the pref is a nicety.
1838
1811
  const onListFilterChange = () => {
1839
- try { window.localStorage.setItem(GOAL_SORT_PREF, $('#f-goal-order')?.value || 'priority'); } catch (_) { /* private mode */ }
1812
+ try {
1813
+ window.localStorage.setItem(GOAL_SORT_PREF, $('#f-goal-order')?.value || 'priority');
1814
+ // task 1003136 — the list|cards view choice survives reload, same
1815
+ // precedent (localStorage, never the URL).
1816
+ window.localStorage.setItem(GOAL_VIEW_PREF, $('#f-goal-view')?.value || 'list');
1817
+ } catch (_) { /* private mode */ }
1840
1818
  pagers.list.page = 1;
1841
1819
  renderList();
1842
1820
  };
@@ -1847,17 +1825,36 @@
1847
1825
  // task 1002965 adds Sort (f-goal-order — NOT f-goal-sort, which is already
1848
1826
  // taken by the goal-detail To-do bar further down this file).
1849
1827
  let savedGoalSort = null;
1850
- try { savedGoalSort = window.localStorage.getItem(GOAL_SORT_PREF); } catch (_) { /* private mode */ }
1828
+ let savedGoalView = null;
1829
+ try {
1830
+ savedGoalSort = window.localStorage.getItem(GOAL_SORT_PREF);
1831
+ savedGoalView = window.localStorage.getItem(GOAL_VIEW_PREF);
1832
+ } catch (_) { /* private mode */ }
1833
+ // task 1003135 — Search declared on the kit spec (live 'input' listener comes
1834
+ // free, hall-kit.js:273; no debounce needed, the match is pure client-side over
1835
+ // the already-loaded state.goals). Id is f-goals-search — f-goal-search is
1836
+ // already taken by the goal-detail To-do bar further down this file. Owner
1837
+ // options are filled after boot's fetch (populateOwnerFilter), the
1838
+ // populateVersionFilter pattern. No URL riding (url defaults off here and this
1839
+ // page is deliberately url:false — it owns the #/goal/N hash route).
1851
1840
  makeFilterBar({
1852
1841
  mount: $('#goals-filters'),
1842
+ search: { id: 'f-goals-search', label: 'Search', placeholder: 'goal # or name…' },
1853
1843
  primary: [
1854
1844
  { id: 'f-goal-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
1855
1845
  { id: 'f-goal-status', label: 'Status', options: [{ value: '', label: '— any —' }, 'open', 'achieved', 'archived'] },
1856
1846
  { id: 'f-goal-mine', label: 'Membership', options: [{ value: '', label: 'All goals' }, { value: 'mine', label: 'My goals' }] },
1847
+ { id: 'f-goal-owner', label: 'Owner', options: [{ value: '', label: '— any —' }] },
1857
1848
  { id: 'f-goal-order',
1858
1849
  label: 'Sort',
1859
1850
  value: GOAL_SORTS[savedGoalSort] ? savedGoalSort : undefined,
1860
1851
  options: Object.entries(GOAL_SORTS).map(([value, { label }]) => ({ value, label })) },
1852
+ // task 1003136 — list|cards toggle; renderList forks its group renderer
1853
+ // on this. Persisted via GOAL_VIEW_PREF, never the URL.
1854
+ { id: 'f-goal-view',
1855
+ label: 'View',
1856
+ value: savedGoalView === 'cards' ? 'cards' : undefined,
1857
+ options: [{ value: 'list', label: 'list' }, { value: 'cards', label: 'cards' }] },
1861
1858
  ],
1862
1859
  onChange: onListFilterChange,
1863
1860
  });
@@ -1986,7 +1983,6 @@
1986
1983
  if (denyBtn) { respondToJoinRequest(state.current, denyBtn.dataset.denyReq, 'deny', denyBtn); return; }
1987
1984
  });
1988
1985
  window.addEventListener('hashchange', route);
1989
- wireDragReorder();
1990
1986
 
1991
1987
  boot();
1992
1988
  })();
@@ -6,10 +6,12 @@
6
6
  are in style.css's hall-kit section. Everything derives from the shared
7
7
  design tokens so instance packs reskin this page along with the rest. */
8
8
 
9
- /* ---- goal-card grid (list view) ----
9
+ /* ---- goal-card grid (the CARDS view) ----
10
10
  task 2171 density pass: minmax 300→230 (four across at desktop width),
11
- tighter gaps/padding/type — the grid holds ~26 goals, so card height is
12
- what sets the page height. */
11
+ tighter gaps/padding/type. Retired by task 1002439's grouped rollup, REVIVED
12
+ by task 1003136 as the list|cards toggle's card view: renderList nests
13
+ id-less .goal-grid wrappers per group, which this rule styles — the
14
+ #goal-grid override further down only flattens the OUTER container. */
13
15
  .goal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; }
14
16
  .goal-card {
15
17
  display: flex; flex-direction: column; gap: 7px;
@@ -19,16 +21,9 @@
19
21
  }
20
22
  a.goal-card:hover { text-decoration: none; border-color: var(--border-strong); background: var(--surface-2); }
21
23
  .goal-card__head { display: flex; align-items: flex-start; gap: 9px; }
22
- /* task 1760 — priority (visible once narrowed to one version) + Archon
23
- drag-to-reorder handle (server enforces the write; this is the affordance). */
24
- .goal-card__handle { flex: none; cursor: grab; color: var(--ink-faint); font-size: 14px; line-height: 1.2; touch-action: none; }
25
- .goal-card__handle:active { cursor: grabbing; }
26
- .goal-card__priority {
27
- flex: none; min-width: 1.4em; text-align: center;
28
- font-size: 11.5px; font-weight: 600; color: var(--ink-faint);
29
- font-variant-numeric: tabular-nums;
30
- }
31
- .goal-card--dragging { opacity: 0.45; }
24
+ /* task 1003136: the task-1760 drag handle/priority/dragging rules that lived
25
+ here are deleted with wireDragReorder (see goals-page.js) — the revived
26
+ cards deliberately carry no drag affordance. */
32
27
  /* task 1761: a goal blocked by an unachieved prerequisite is dimmed + hue-shifted. */
33
28
  .goal-card--blocked { opacity: 0.62; border-style: dashed; }
34
29
  .goal-card--blocked:hover { opacity: 0.85; }
@@ -209,8 +204,10 @@ a.goal-card:hover { text-decoration: none; border-color: var(--border-strong); b
209
204
  tasks up into ready/in-progress/blocked; the task→task dependency
210
205
  graph itself lives on the Tasks page. Uses the shared style.css tokens.
211
206
  ============================================================ */
212
- /* The list container used to be an auto-fill CARD grid; the rollup renders
213
- grouped full-width blocks into it, so flip it to normal block flow. */
207
+ /* The OUTER container renders grouped full-width blocks (summary + section
208
+ heads), so flip it to normal block flow. Id-selected on purpose: the cards
209
+ view (task 1003136) nests id-less .goal-grid wrappers per group, which keep
210
+ the real card grid above. */
214
211
  #goal-grid.goal-grid { display: block; gap: 0; }
215
212
  .gr-summary { display: flex; flex-wrap: wrap; gap: 22px; margin: 6px 0 20px; }
216
213
  .gr-sm { display: flex; align-items: baseline; gap: 8px; font-size: 13px; color: var(--ink-soft); }
@@ -198,6 +198,7 @@
198
198
  <script src="/builders/task-lib.js"></script>
199
199
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
200
200
  <script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
201
- <script src="/builders/goals-page.js?v=2026-07-10-goals5"></script>
201
+ <script src="/builders/goals-lib.js?v=2026-08-21-goalslib1"></script>
202
+ <script src="/builders/goals-page.js?v=2026-08-21-goals7"></script>
202
203
  </body>
203
204
  </html>
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.280",
3
+ "version": "1.19.282",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.280",
9
+ "version": "1.19.282",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.280",
3
+ "version": "1.19.282",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -987,8 +987,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
987
987
  // landed since 1.19.278 with no explicit bump. run 32521664476. (task 1002620)
988
988
  // 1.19.280 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
989
989
  // landed since 1.19.279 with no explicit bump. run 32523185119. (task 1002620)
990
+ // 1.19.281 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
991
+ // landed since 1.19.280 with no explicit bump. run 32525071763. (task 1002620)
992
+ // 1.19.282 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
993
+ // landed since 1.19.281 with no explicit bump. run 32526392955. (task 1002620)
990
994
  // ---------------------------------------------------------------------------
991
- const CORE_VERSION = '1.19.280'; // CI auto-patch carrier (ADR 0161); see changelog above
995
+ const CORE_VERSION = '1.19.282'; // CI auto-patch carrier (ADR 0161); see changelog above
992
996
 
993
997
  // A namespaced logger so a module's log lines are attributable + consistent.
994
998
  // Usage: const log = api.logger('dev-box'); log.info('mounted');