@bongos/core 1.19.280 → 1.19.281

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
 
@@ -271,6 +228,40 @@
271
228
  }
272
229
  }
273
230
 
231
+ // task 1003135 — the Owner dropdown. 'Goal owner' is goals.created_by (the
232
+ // fixed authority, migration 160), returned by GET /goals as a raw builder id;
233
+ // labels come from the directory the page already lazily caches (loadDirectory,
234
+ // task 1877), same label idiom as the invite dropdown. created_by is NULLABLE
235
+ // (system-attributed — e.g. the migration-160 '<version> — general' catch-alls),
236
+ // hence the explicit 'system / unattributed' option. Mirrors
237
+ // populateVersionFilter above; fired after boot's fetch resolves (async because
238
+ // of the directory fetch — fire-and-forget, the select fills when ready).
239
+ async function populateOwnerFilter() {
240
+ const sel = $('#f-goal-owner');
241
+ if (!sel) return;
242
+ const ownerIds = Array.from(new Set(
243
+ state.goals.filter((g) => g.created_by != null).map((g) => String(g.created_by))));
244
+ const hasSystem = state.goals.some((g) => g.created_by == null);
245
+ const dir = ownerIds.length ? await loadDirectory() : [];
246
+ const byId = new Map((dir || []).map((b) => [String(b.id), b]));
247
+ const label = (id) => {
248
+ const b = byId.get(id);
249
+ return b ? (b.display_name || '@' + b.github_login) : `builder #${id}`;
250
+ };
251
+ for (const id of ownerIds.sort((a, b) => label(a).localeCompare(label(b)))) {
252
+ const opt = document.createElement('option');
253
+ opt.value = id;
254
+ opt.textContent = label(id);
255
+ sel.appendChild(opt);
256
+ }
257
+ if (hasSystem) {
258
+ const opt = document.createElement('option');
259
+ opt.value = 'none';
260
+ opt.textContent = 'system / unattributed';
261
+ sel.appendChild(opt);
262
+ }
263
+ }
264
+
274
265
  // task 1760 — priority is a WITHIN-version ordering, so the drag affordance
275
266
  // only makes sense once the grid is narrowed to exactly one version (the
276
267
  // "any" filter mixes versions and dragging across them would be meaningless).
@@ -292,6 +283,18 @@
292
283
  const ver = $('#f-goal-version')?.value || '';
293
284
  const status = $('#f-goal-status')?.value || '';
294
285
  const mineOnly = $('#f-goal-mine')?.value === 'mine';
286
+ // task 1003135 — free-text search (goal title OR id; '#N', 'N' and a bare
287
+ // number all match — the id is already rendered on the row, task 1002163)
288
+ // and the Owner dropdown ('none' = created_by NULL, system-attributed).
289
+ // Reads the DOM ids directly, matching this function's existing style.
290
+ const q = ($('#f-goals-search')?.value || '').trim().toLowerCase();
291
+ const needle = q.replace(/^#/, '');
292
+ const owner = $('#f-goal-owner')?.value || '';
293
+ const matchesSearch = (g) => !needle
294
+ || String(g.id).includes(needle)
295
+ || (g.title || '').toLowerCase().includes(needle);
296
+ const matchesOwner = (g) => !owner
297
+ || (owner === 'none' ? g.created_by == null : String(g.created_by) === owner);
295
298
  const myId = selfId();
296
299
  const iAmMember = (g) => {
297
300
  const d = state.details.get(String(g.id));
@@ -300,22 +303,11 @@
300
303
  return state.goals.filter((g) =>
301
304
  (!ver || g.version_id === ver) &&
302
305
  (!status || g.status === status) &&
306
+ matchesSearch(g) &&
307
+ matchesOwner(g) &&
303
308
  (!mineOnly || iAmMember(g)));
304
309
  }
305
310
 
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
311
  // task 1002439 — one goal, rendered as a rollup row: criteria ring · title +
320
312
  // meta + readiness rollup · one next action (claim the next ready task, drill
321
313
  // into what's blocking, or a review/achieved note).
@@ -515,15 +507,6 @@
515
507
  const el = $('#f-goal-search');
516
508
  return el ? el.value.trim().toLowerCase() : '';
517
509
  }
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
510
  // Copy-prompt (work.js:454-503). deHashRefs stops a bare #NNN autolinking to
528
511
  // GitHub in a pasted session; the prompt points the builder at the hall task URL.
529
512
  async function copyPromptForTask(taskId, btn) {
@@ -1810,6 +1793,9 @@
1810
1793
  const gres = await getJSON(`${API}/goals`);
1811
1794
  state.goals = gres.goals || [];
1812
1795
  populateVersionFilter();
1796
+ // task 1003135 — fire-and-forget: the Owner options fill when the (cached)
1797
+ // directory resolves; boot never waits on a label lookup.
1798
+ populateOwnerFilter().catch(() => {});
1813
1799
  // Prefetch details (ring + members on the cards). Sequentially-parallel,
1814
1800
  // failure-tolerant: a card without detail still renders.
1815
1801
  // task 1002439 — also fold in per-goal task-readiness rollups (one request
@@ -1848,12 +1834,21 @@
1848
1834
  // taken by the goal-detail To-do bar further down this file).
1849
1835
  let savedGoalSort = null;
1850
1836
  try { savedGoalSort = window.localStorage.getItem(GOAL_SORT_PREF); } catch (_) { /* private mode */ }
1837
+ // task 1003135 — Search declared on the kit spec (live 'input' listener comes
1838
+ // free, hall-kit.js:273; no debounce needed, the match is pure client-side over
1839
+ // the already-loaded state.goals). Id is f-goals-search — f-goal-search is
1840
+ // already taken by the goal-detail To-do bar further down this file. Owner
1841
+ // options are filled after boot's fetch (populateOwnerFilter), the
1842
+ // populateVersionFilter pattern. No URL riding (url defaults off here and this
1843
+ // page is deliberately url:false — it owns the #/goal/N hash route).
1851
1844
  makeFilterBar({
1852
1845
  mount: $('#goals-filters'),
1846
+ search: { id: 'f-goals-search', label: 'Search', placeholder: 'goal # or name…' },
1853
1847
  primary: [
1854
1848
  { id: 'f-goal-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
1855
1849
  { id: 'f-goal-status', label: 'Status', options: [{ value: '', label: '— any —' }, 'open', 'achieved', 'archived'] },
1856
1850
  { id: 'f-goal-mine', label: 'Membership', options: [{ value: '', label: 'All goals' }, { value: 'mine', label: 'My goals' }] },
1851
+ { id: 'f-goal-owner', label: 'Owner', options: [{ value: '', label: '— any —' }] },
1857
1852
  { id: 'f-goal-order',
1858
1853
  label: 'Sort',
1859
1854
  value: GOAL_SORTS[savedGoalSort] ? savedGoalSort : undefined,
@@ -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-goals6"></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.281",
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.281",
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.281",
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,10 @@ 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)
990
992
  // ---------------------------------------------------------------------------
991
- const CORE_VERSION = '1.19.280'; // CI auto-patch carrier (ADR 0161); see changelog above
993
+ const CORE_VERSION = '1.19.281'; // CI auto-patch carrier (ADR 0161); see changelog above
992
994
 
993
995
  // A namespaced logger so a module's log lines are attributable + consistent.
994
996
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -37,6 +37,9 @@ const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-page.j
37
37
  // the REAL file in the same context rather than stubbing it — stubbing would let
38
38
  // the extraction rot without this, the only test that executes either board.
39
39
  const TASK_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/task-lib.js'), 'utf8');
40
+ // task 1003135: the page's pure presentational helpers moved to goals-lib.js
41
+ // (window.OTBGoalsLib) — run the REAL file too, same reasoning as task-lib above.
42
+ const GOALS_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-lib.js'), 'utf8');
40
43
 
41
44
  const { test, summary } = makeRunner();
42
45
 
@@ -132,6 +135,7 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
132
135
  sandbox.globalThis = sandbox;
133
136
  vm.createContext(sandbox);
134
137
  vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
138
+ vm.runInContext(GOALS_LIB_SRC, sandbox, { filename: 'goals-lib.js' });
135
139
  vm.runInContext(SRC, sandbox, { filename: 'goals-page.js' });
136
140
 
137
141
  // Let boot()'s promise chain settle (me → goals → details → route → render).
@@ -0,0 +1,70 @@
1
+ // tests/goals_lib.mjs — the Goals page's extracted pure helpers (goals-lib.js,
2
+ // task 1003135). Pins (a) that the lib loads standalone in a bare window — it
3
+ // must not silently grow a dependency on the page's IIFE state, which is the
4
+ // whole reason it could move out of the oversized goals-page.js — and (b) the
5
+ // behaviour of the helpers the page destructures.
6
+ //
7
+ // Run: node tests/goals_lib.mjs
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import vm from 'node:vm';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { makeRunner } from './helpers.mjs';
15
+
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-lib.js'), 'utf8');
18
+ const { test, summary } = makeRunner();
19
+
20
+ const windowObj = {};
21
+ const sandbox = { window: windowObj, Set, Map, Math, Number, String, Array, Object, Date, JSON };
22
+ sandbox.globalThis = sandbox;
23
+ vm.createContext(sandbox);
24
+ vm.runInContext(SRC, sandbox, { filename: 'goals-lib.js' });
25
+ const lib = windowObj.OTBGoalsLib;
26
+
27
+ await test('loads standalone in a bare window (no page-state dependency) and exports the full surface', () => {
28
+ assert.ok(lib, 'window.OTBGoalsLib must be set');
29
+ for (const k of ['DONE_STATUSES', 'CUT_STATUSES', 'isTodoStatus', 'ring', 'memberName',
30
+ 'readinessOf', 'EMPTY_ROLLUP', 'goalRollLine', 'matchesTodoSearch', 'msOf', 'taskActivityMs']) {
31
+ assert.ok(k in lib, `missing export: ${k}`);
32
+ }
33
+ });
34
+
35
+ await test('readinessOf buckets match the task-1002439 rules', () => {
36
+ assert.equal(lib.readinessOf({ status: 'shipped' }), 'done');
37
+ assert.equal(lib.readinessOf({ status: 'abandoned' }), 'cut');
38
+ assert.equal(lib.readinessOf({ status: 'active' }), 'active');
39
+ assert.equal(lib.readinessOf({ status: 'ready' }), 'ready');
40
+ assert.equal(lib.readinessOf({ status: 'backlog', blocked_by: [{ satisfied: false }] }), 'blocked');
41
+ assert.equal(lib.readinessOf({ status: 'backlog' }), 'backlog');
42
+ });
43
+
44
+ await test('matchesTodoSearch: bare number, #N and title fragments all match (task 1003135 search semantics)', () => {
45
+ const t = { id: 1002163, title: 'Show the goal id on the row' };
46
+ assert.ok(lib.matchesTodoSearch(t, ''));
47
+ assert.ok(lib.matchesTodoSearch(t, '1002163'));
48
+ assert.ok(lib.matchesTodoSearch(t, '#1002163'));
49
+ assert.ok(lib.matchesTodoSearch(t, '2163'), 'substring id match');
50
+ assert.ok(lib.matchesTodoSearch(t, 'goal id'));
51
+ assert.ok(!lib.matchesTodoSearch(t, 'zzz'));
52
+ assert.ok(lib.matchesTodoSearch(t, '#'), 'a lone # narrows nothing');
53
+ });
54
+
55
+ await test('goalRollLine renders only non-zero buckets; ring is well-formed SVG', () => {
56
+ const line = lib.goalRollLine({ ready: 2, active: 0, blocked: 1, backlog: 0 });
57
+ assert.match(line, /<b>2<\/b> ready/);
58
+ assert.match(line, /<b>1<\/b> blocked/);
59
+ assert.ok(!line.includes('in progress'), 'zero buckets are omitted');
60
+ assert.equal(lib.goalRollLine({ ready: 0, active: 0, blocked: 0, backlog: 0 }), '');
61
+ assert.match(lib.ring(2, 5, false), /aria-label="2 of 5 criteria satisfied"/);
62
+ });
63
+
64
+ await test('memberName label idiom: display name, then @login, then builder #id', () => {
65
+ assert.equal(lib.memberName({ display_name: 'A', github_login: 'a', builder_id: 1 }), 'A');
66
+ assert.equal(lib.memberName({ github_login: 'a', builder_id: 1 }), '@a');
67
+ assert.equal(lib.memberName({ builder_id: 1 }), 'builder #1');
68
+ });
69
+
70
+ summary();