@bongos/core 1.19.660 → 1.19.661

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.
@@ -62,15 +62,23 @@
62
62
  // forever. Its own function because Creator/Age re-fetch it, same as the
63
63
  // claimable feed. Best-effort: on failure the section stays as it was.
64
64
  function loadBacklog() {
65
- const q = serverFilterQuery().replace(/^\?/, '&');
65
+ const q = backlogFilterQuery().replace(/^\?/, '&');
66
66
  return getJSON(`${API}/tasks?status=backlog&limit=${BACKLOG_LIMIT}${q}`)
67
- .then((r) => { taskState.backlog = r.tasks || []; renderBacklog(taskState.backlog); })
67
+ .then((r) => {
68
+ taskState.backlog = r.tasks || [];
69
+ renderBacklog(taskState.backlog);
70
+ // task 1003828 — the Goal filter draws on this feed too and races it.
71
+ // Re-offer the options now; populateGoalFilter keeps the selection.
72
+ populateGoalFilter();
73
+ })
68
74
  .catch(() => { /* backlog stays as it was */ });
69
75
  }
70
76
 
71
- // The last server-facing filter query we actually fetched with. Compared on
72
- // every filter change to decide re-fetch vs re-render.
77
+ // The last server-facing filter queries we actually fetched with one per
78
+ // feed, because the two routes answer DIFFERENT questions (see below).
79
+ // Compared on every filter change to decide re-fetch vs re-render.
73
80
  let lastServerFilterQuery = '';
81
+ let lastBacklogQuery = '';
74
82
 
75
83
  function serverFilterQuery() {
76
84
  const parts = [];
@@ -81,6 +89,24 @@
81
89
  return parts.length ? `?${parts.join('&')}` : '';
82
90
  }
83
91
 
92
+ // task 1003828 — the BACKLOG's server query, deliberately NOT shared with the
93
+ // claimable feed's. The routes differ (GET /tasks answers version/kind/
94
+ // discipline/goal_id; /tasks/claimable answers only version+discipline), and the
95
+ // backlog read is CAPPED at BACKLOG_LIMIT while the Ready feed is not — so a
96
+ // client-only narrow here would lie, hiding the artist tasks at row 501.
97
+ // '__none__' on Goal has no server predicate and is never sent.
98
+ function backlogFilterQuery() {
99
+ const f = boardFilterValues();
100
+ const parts = [];
101
+ if (f.creator) parts.push(`created_by=${encodeURIComponent(f.creator === '__none__' ? 'none' : f.creator)}`);
102
+ if (f.stale) parts.push(`stale_days=${encodeURIComponent(f.stale)}`);
103
+ if (f.version) parts.push(`version=${encodeURIComponent(f.version)}`);
104
+ if (f.kind) parts.push(`kind=${encodeURIComponent(f.kind)}`);
105
+ if (f.discipline) parts.push(`discipline=${encodeURIComponent(f.discipline)}`);
106
+ if (f.goal && f.goal !== '__none__') parts.push(`goal_id=${encodeURIComponent(f.goal)}`);
107
+ return parts.length ? `?${parts.join('&')}` : '';
108
+ }
109
+
84
110
  // task 1003278 — the ceiling on the Backlog read. Comfortably above today's
85
111
  // ~300 rows so nothing visibly changes now, and low enough that the page can
86
112
  // never be asked to hold the whole table as the project grows.
@@ -289,54 +315,76 @@
289
315
  return false;
290
316
  }
291
317
 
318
+ // task 1003828 — the board's filter lenses, read once from the DOM. ONE reader,
319
+ // so a control that exists can never be one a tab forgot to consult: before
320
+ // this, every dropdown was read inside renderClaimable and nowhere else, which
321
+ // left all six silently inert on the Backlog tab. ('__none__' on Creator
322
+ // isolates the unattributed — task 1003278.)
323
+ function boardFilterValues() {
324
+ const val = (id) => ($(id) ? $(id).value : '');
325
+ return {
326
+ search: searchQuery(),
327
+ version: val('#f-version'),
328
+ goal: val('#f-goal'),
329
+ kind: val('#f-kind'),
330
+ discipline: val('#f-discipline'),
331
+ newcomer: val('#f-newcomer'),
332
+ creator: val('#f-creator'),
333
+ stale: val('#f-stale'),
334
+ };
335
+ }
336
+
337
+ // Does one task survive the current lenses? Pure over (task, values) so both
338
+ // feeds narrow alike and a new filter cannot reach one tab alone.
339
+ function matchesBoardFilters(t, f) {
340
+ if (!matchesSearch(t, f.search)) return false;
341
+ if (f.version && t.version_id !== f.version) return false;
342
+ // task 1755: goal filter. '__none__' isolates tasks with no goal.
343
+ if (f.goal === '__none__' && t.goal_id != null) return false;
344
+ if (f.goal && f.goal !== '__none__' && String(t.goal_id) !== f.goal) return false;
345
+ if (f.kind && t.kind !== f.kind) return false;
346
+ if (f.discipline && (t.discipline || 'unclassified') !== f.discipline) return false;
347
+ if (f.newcomer === 'yes' && !t.newcomer_friendly) return false;
348
+ // Creator + Age were applied by the SERVER, so they are re-checked here only
349
+ // as a belt on a stale render — cheap, and it keeps the view honest if a poll
350
+ // lands between a control change and its re-fetch.
351
+ if (f.creator === '__none__' && t.created_by != null) return false;
352
+ if (f.creator && f.creator !== '__none__' && String(t.created_by) !== f.creator) return false;
353
+ if (f.stale && !isStale(taskActivityMs(t), f.stale)) return false;
354
+ return true;
355
+ }
356
+
357
+ // task 1756: run-mode — a task is "parallel-safe" when its touches[] don't
358
+ // overlap any OTHER task in the visible set. Set-RELATIVE, which is why it runs
359
+ // after matchesBoardFilters rather than inside it: it answers "of what I'm
360
+ // looking at, what can run together." O(n²) over touches — fine at board scale.
361
+ function applyRunMode(list, fitFilter) {
362
+ if (fitFilter !== 'parallel' && fitFilter !== 'conflict') return list;
363
+ return list.filter((t) => {
364
+ const conflicts = list.some((o) => o !== t && touchesConflict(t.touches, o.touches));
365
+ return fitFilter === 'parallel' ? !conflicts : conflicts;
366
+ });
367
+ }
368
+
369
+ // task 1003828 — the bar sits above the four panels now, so it must say which it
370
+ // speaks for. Ready and Backlog are the QUEUE tabs; In Progress (live claims)
371
+ // and Completed (shipped history) render other objects, so the bar hides there
372
+ // rather than offering a control that does nothing — this task's whole defect.
373
+ const FILTERED_TABS = new Set(['ready', 'backlog']);
374
+ function syncFilterBarVisibility(tabId) {
375
+ const bar = $('#work-filters');
376
+ if (bar) bar.hidden = !FILTERED_TABS.has(String(tabId));
377
+ }
378
+
292
379
  function renderClaimable() {
293
380
  const root = $('#task-list');
294
381
  if (!root) return;
295
- const vFilter = $('#f-version').value;
296
- const gFilter = $('#f-goal') ? $('#f-goal').value : '';
297
- const kFilter = $('#f-kind').value;
298
- const dFilter = $('#f-discipline').value;
299
- const nFilter = $('#f-newcomer') ? $('#f-newcomer').value : '';
300
- // task 1003278 — creator + staleness. '__none__' on Creator isolates the
301
- // unattributed (created before tasks.created_by existed, or by a system path)
302
- // — the goals owner filter's 'system / unattributed' convention.
303
- const cFilter = $('#f-creator') ? $('#f-creator').value : '';
304
- const staleFilter = $('#f-stale') ? $('#f-stale').value : '';
382
+ const filters = boardFilterValues();
305
383
  const sortBy = $('#f-sort').value;
306
- const sQuery = searchQuery();
307
-
308
- let list = taskState.all.filter((t) => {
309
- if (!matchesSearch(t, sQuery)) return false;
310
- if (vFilter && t.version_id !== vFilter) return false;
311
- // task 1755: goal filter. '__none__' isolates tasks with no goal.
312
- if (gFilter === '__none__' && t.goal_id != null) return false;
313
- if (gFilter && gFilter !== '__none__' && String(t.goal_id) !== gFilter) return false;
314
- if (kFilter && t.kind !== kFilter) return false;
315
- if (dFilter && (t.discipline || 'unclassified') !== dFilter) return false;
316
- if (nFilter === 'yes' && !t.newcomer_friendly) return false;
317
- // Creator + Age were applied by the SERVER (serverFilterQuery), so they are
318
- // re-checked here only as a belt on a stale render — never as the primary
319
- // filter. Re-applying them is cheap and keeps the view honest if a poll
320
- // lands between a control change and its re-fetch.
321
- if (cFilter === '__none__' && t.created_by != null) return false;
322
- if (cFilter && cFilter !== '__none__' && String(t.created_by) !== cFilter) return false;
323
- if (staleFilter && !isStale(taskActivityMs(t), staleFilter)) return false;
324
- return true;
325
- });
326
384
 
327
- // task 1756: run-mode filter a task is "parallel-safe" when its touches[]
328
- // don't overlap any OTHER task in the currently-visible set; "conflict" is the
329
- // complement (would collide, so run them sequentially). Computed over the
330
- // already-filtered list so it answers "of what I'm looking at, what can run
331
- // together." O(n²) over touches — fine at board scale.
332
- const fitFilter = $('#f-fit') ? $('#f-fit').value : '';
333
- if (fitFilter === 'parallel' || fitFilter === 'conflict') {
334
- const base = list;
335
- list = base.filter((t) => {
336
- const conflicts = base.some((o) => o !== t && touchesConflict(t.touches, o.touches));
337
- return fitFilter === 'parallel' ? !conflicts : conflicts;
338
- });
339
- }
385
+ let list = taskState.all.filter((t) => matchesBoardFilters(t, filters));
386
+
387
+ list = applyRunMode(list, $('#f-fit') ? $('#f-fit').value : '');
340
388
 
341
389
  if (sortBy === 'cpm') {
342
390
  list = list.slice().sort((a, b) => creditsPerMinute(b) - creditsPerMinute(a));
@@ -508,7 +556,10 @@
508
556
  if (!sel) return;
509
557
  const cur = sel.value;
510
558
  const titles = taskState.goalTitles || {};
511
- const ids = [...new Set(taskState.all.map((t) => t.goal_id).filter((x) => x != null).map(String))];
559
+ // task 1003828 options come from BOTH feeds: built from the claimable one
560
+ // alone, a goal whose only work sits at backlog was unofferable.
561
+ const rows = [...taskState.all, ...(taskState.backlog || [])];
562
+ const ids = [...new Set(rows.map((t) => t.goal_id).filter((x) => x != null).map(String))];
512
563
  ids.sort((a, b) => String(titles[a] || `Goal #${a}`).localeCompare(String(titles[b] || `Goal #${b}`)));
513
564
  sel.length = 1; // keep the leading "— all goals —" option
514
565
  for (const id of ids) {
@@ -517,7 +568,7 @@
517
568
  opt.textContent = titles[id] || `Goal #${id}`;
518
569
  sel.appendChild(opt);
519
570
  }
520
- if (taskState.all.some((t) => t.goal_id == null)) {
571
+ if (rows.some((t) => t.goal_id == null)) {
521
572
  const opt = document.createElement('option');
522
573
  opt.value = '__none__';
523
574
  opt.textContent = 'No goal';
@@ -1055,17 +1106,44 @@
1055
1106
  root.innerHTML = '<div class="profile__placeholder">Nothing in the backlog right now.</div>';
1056
1107
  return;
1057
1108
  }
1058
- const q = searchQuery();
1059
- const list = q ? backlogRows.filter((t) => matchesSearch(t, q)) : backlogRows;
1109
+ // task 1003828 — the SAME lenses the Ready tab uses. This read used to be
1110
+ // search-only, so six controls silently did nothing on the tab holding the
1111
+ // most rows. The server already narrowed this feed; re-applying the predicate
1112
+ // answers the two client-only lenses (search, Goal's '__none__') and keeps the
1113
+ // view honest between a control change and its re-fetch.
1114
+ const filters = boardFilterValues();
1115
+ let list = backlogRows.filter((t) => matchesBoardFilters(t, filters));
1116
+ list = applyRunMode(list, $('#f-fit') ? $('#f-fit').value : '');
1060
1117
  const countEl = $('#backlog-count');
1061
1118
  if (countEl) countEl.textContent = `${list.length} ${list.length === 1 ? 'task' : 'tasks'}`;
1119
+ if (!list.length) {
1120
+ root.innerHTML = '<div class="profile__placeholder">No backlog tasks match these filters. Try widening them.</div>';
1121
+ pagers.backlog.page = 1;
1122
+ return;
1123
+ }
1062
1124
  const shown = paginate(list, pagers.backlog);
1063
1125
  root.innerHTML = shown.map((t) => {
1064
1126
  const ver = t.version_id ? `<span class="task__tag">${escapeHtml(t.version_id)}</span>` : '';
1127
+ // task 1003828 — the row says WHOSE CRAFT and WHICH GOAL. A Ready card has
1128
+ // carried its discipline tag since 1755; a backlog row carried neither, so a
1129
+ // correctly-goaled task (an auto-filed artist review, say) read as
1130
+ // uncategorised in the one place a person goes looking for it. Same
1131
+ // vocabulary as the card, so the two tabs describe a task alike.
1132
+ const discipline = t.discipline || 'unclassified';
1133
+ const disciplineTag = discipline !== 'unclassified'
1134
+ ? `<span class="task__tag">${escapeHtml(discipline)}</span>`
1135
+ : '';
1136
+ // The goal's TITLE, from the map the Goal filter already loads, falling back
1137
+ // to its id before that resolves. A goal-less row says so rather than
1138
+ // rendering a gap — "no goal" is a real, findable state.
1139
+ const goalTitle = t.goal_id != null
1140
+ ? ((taskState.goalTitles || {})[String(t.goal_id)] || `Goal #${t.goal_id}`)
1141
+ : 'No goal';
1142
+ const goalTag = `<span class="history__goal" title="${escapeHtml(`Goal: ${goalTitle}`)}">${escapeHtml(goalTitle)}</span>`;
1065
1143
  return `
1066
1144
  <div class="history__row" data-task-id="${escapeHtml(String(t.id))}" title="Open task #${escapeHtml(String(t.id))}">
1067
1145
  <span class="history__id">#${escapeHtml(String(t.id))}</span>
1068
- <span class="history__title">${escapeHtml(t.title || '(untitled)')} ${ver} ${rankTagHtml(t.requires_rank)}</span>
1146
+ <span class="history__title">${escapeHtml(t.title || '(untitled)')} ${ver} ${disciplineTag} ${rankTagHtml(t.requires_rank)}<br>${goalTag}</span>
1069
1147
  <span class="history__when">${escapeHtml(t.kind || '')}</span>
1070
1148
  </div>`;
1071
1149
  }).join('');
@@ -1182,6 +1260,10 @@
1182
1260
  taskState.goalTitles[g.id] = g.title;
1183
1261
  taskState.goalCategories[g.id] = g.category || null;
1184
1262
  });
1263
+ // task 1003828 — loadBacklog() fires ABOVE this await, so its first paint
1264
+ // has no titles and every row would read "Goal #1000089" until something
1265
+ // else repainted it. Harmless if the feed has not landed yet.
1266
+ renderBacklog();
1185
1267
  } catch (_) { /* filter labels fall back to 'Goal #id' */ }
1186
1268
 
1187
1269
  // Claimable + active claims in one call. A 403 means the primer gate — show
@@ -1242,7 +1324,10 @@
1242
1324
  { id: 'backlog', label: 'Backlog', panel: 'backlog-scroll' },
1243
1325
  { id: 'completed', label: 'Completed', panel: 'history-scroll' },
1244
1326
  ],
1245
- onChange: () => { if (selectionTotal()) clearSelection(); },
1327
+ onChange: (id) => {
1328
+ if (selectionTotal()) clearSelection();
1329
+ syncFilterBarVisibility(id);
1330
+ },
1246
1331
  });
1247
1332
 
1248
1333
  // task 2170: filter diet — Search/Sort/Version primary, the rest behind
@@ -1266,6 +1351,16 @@
1266
1351
  ],
1267
1352
  },
1268
1353
  { id: 'f-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
1354
+ // task 1003828 — Discipline is PRIMARY, not overflow. A craft is the axis
1355
+ // this platform organises people by (CLAUDE.md's role packs; a task's work
1356
+ // type IS its discipline), so "show me my craft's work" is a first-class
1357
+ // question, not one to find behind a disclosure. The mixed option form is
1358
+ // kit-supported — pinned by tests/goal_sort_staleness.mjs against this id.
1359
+ {
1360
+ id: 'f-discipline',
1361
+ label: 'Discipline',
1362
+ options: [{ value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui', 'unclassified'],
1363
+ },
1269
1364
  ],
1270
1365
  overflow: [
1271
1366
  { id: 'f-goal', label: 'Goal', options: [{ value: '', label: '— all goals —' }] },
@@ -1276,11 +1371,6 @@
1276
1371
  'feature', 'bug', 'infra', 'refactor', 'spike', 'learning-capture',
1277
1372
  'cleanup', 'decision', 'blocker-resolution', 'unclassified'],
1278
1373
  },
1279
- {
1280
- id: 'f-discipline',
1281
- label: 'Discipline',
1282
- options: [{ value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui', 'unclassified'],
1283
- },
1284
1374
  {
1285
1375
  id: 'f-newcomer',
1286
1376
  label: 'Newcomer',
@@ -1310,18 +1400,23 @@
1310
1400
  // changed id (hall-kit.js:269), and comparing the query is the direct
1311
1401
  // question anyway — a control that does not appear in it can never cost
1312
1402
  // a round-trip, which is what keeps the other filters instant.
1403
+ //
1404
+ // task 1003828 — decided SEPARATELY per feed now, because the backlog asks
1405
+ // the server more than claimable can answer. Each side re-fetches (ending
1406
+ // in its own render) or re-renders in place; the old early return left the
1407
+ // other tab's client-side lenses unapplied until the next poll.
1313
1408
  const q = serverFilterQuery();
1314
- if (q !== lastServerFilterQuery) {
1315
- lastServerFilterQuery = q;
1316
- refreshLive();
1317
- loadBacklog();
1318
- return;
1319
- }
1320
- renderClaimable();
1321
- renderBacklog();
1409
+ const bq = backlogFilterQuery();
1410
+ if (q !== lastServerFilterQuery) { lastServerFilterQuery = q; refreshLive(); } else { renderClaimable(); }
1411
+ if (bq !== lastBacklogQuery) { lastBacklogQuery = bq; loadBacklog(); } else { renderBacklog(); }
1322
1412
  },
1323
1413
  });
1324
1414
 
1415
+ // task 1003828 — makeTabs fires onChange only on a CHANGE and restores a
1416
+ // deep-linked tab before this point, so the opening state is set explicitly:
1417
+ // without it, a reload onto #completed shows a filter bar over ship history.
1418
+ syncFilterBarVisibility(workTabs && workTabs.active ? workTabs.active() : 'ready');
1419
+
1325
1420
  $('#task-list')?.addEventListener('click', (e) => {
1326
1421
  // task 1759: the multi-select checkbox. Toggle the Set + the card's
1327
1422
  // highlight class directly (cheaper than a full renderClaimable) and
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.660",
3
+ "version": "1.19.661",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.660",
9
+ "version": "1.19.661",
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.660",
3
+ "version": "1.19.661",
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
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.660'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.661'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -202,7 +202,18 @@ await test('a server-side filter change RE-FETCHES; every other filter stays ins
202
202
  // direct question anyway, and means a control absent from that query can never
203
203
  // cost a round-trip.
204
204
  assert.match(WORK_SRC, /let lastServerFilterQuery = '';/);
205
- assert.match(WORK_SRC, /if \(q !== lastServerFilterQuery\) \{[\s\S]{0,200}refreshLive\(\);\s*\n\s*loadBacklog\(\);/);
205
+ // task 1003828 split this into ONE DECISION PER FEED. It used to be a single
206
+ // comparison that re-fetched both and returned early — which was fine while the
207
+ // two asked the server the same question, and wrong once the backlog started
208
+ // asking a narrower one (version/kind/discipline/goal_id, which GET /tasks
209
+ // answers and GET /tasks/claimable does not). The early return also meant a
210
+ // change to a CLIENT-side lens skipped the other tab's re-render until the next
211
+ // poll repainted it. Each side now re-fetches or re-renders on its own; the
212
+ // property under test is unchanged — a server-facing change costs a round-trip
213
+ // and nothing else does.
214
+ assert.match(WORK_SRC, /let lastBacklogQuery = '';/);
215
+ assert.match(WORK_SRC, /if \(q !== lastServerFilterQuery\) \{ lastServerFilterQuery = q; refreshLive\(\); \} else \{ renderClaimable\(\); \}/);
216
+ assert.match(WORK_SRC, /if \(bq !== lastBacklogQuery\) \{ lastBacklogQuery = bq; loadBacklog\(\); \} else \{ renderBacklog\(\); \}/);
206
217
  const kit = read('modules/hall-ui/public/hall-kit.js');
207
218
  assert.match(kit, /if \(onChange\) onChange\(values\(\)\);/,
208
219
  'the contract this decision depends on — if it ever hands the changed id instead, revisit');
@@ -471,3 +471,106 @@ test('the tab strip is left alone — it scrolls in its own row and never moved
471
471
  assert.ok(strip, 'the tab strip rule is present');
472
472
  assert.match(strip[1], /overflow-x:\s*auto/, 'it scrolls itself, which is why it is not the overflow cause');
473
473
  });
474
+
475
+ // ---- the board's filters drive BOTH queue tabs (task 1003828) ---------------
476
+ //
477
+ // The owner reported an auto-filed artist review as "going into uncategorized
478
+ // BONGOS-V2 backlog tasks" and asked for tasks to be "filterable by role type".
479
+ // The review was never uncategorised — cascade.js inherits its parent's goal —
480
+ // but the Backlog tab it lands on could not say so and could not be narrowed:
481
+ //
482
+ // * renderBacklog() applied the SEARCH BOX ONLY. Version, Goal, Kind,
483
+ // Discipline, Newcomer and Run mode were read inside renderClaimable and
484
+ // nowhere else, so six controls were silently inert over the tab holding the
485
+ // most rows. Nothing errored; the list simply did not change.
486
+ // * The filter bar was INSIDE #claim-scroll, which makeTabs hides. So on the
487
+ // Backlog tab those controls were not merely inert — they were off-screen.
488
+ // * A backlog row rendered id + title + version + rank + kind. No goal, no
489
+ // discipline. A correctly-goaled task genuinely read as uncategorised.
490
+ //
491
+ // Each is invisible in a browser the way this file's other entries are: the page
492
+ // renders, it just renders a control that does nothing.
493
+
494
+ const workJs = read('work.js');
495
+
496
+ test('the filter bar is above the panels, not inside the Ready one that gets hidden', () => {
497
+ const filters = workHtml.indexOf('id="work-filters"');
498
+ const tabs = workHtml.indexOf('id="work-tabs"');
499
+ const claim = workHtml.indexOf('id="claim-scroll"');
500
+ assert.ok(filters > 0 && tabs > 0 && claim > 0, 'all three mounts present');
501
+ assert.ok(filters > tabs, 'the bar follows the tab strip it belongs to');
502
+ assert.ok(filters < claim,
503
+ 'and precedes the Ready panel — inside it, makeTabs hides the filters with the tab');
504
+ });
505
+
506
+ test('the bar shows on the two QUEUE tabs and hides on the two that it cannot narrow', () => {
507
+ assert.match(workJs, /const FILTERED_TABS = new Set\(\['ready', 'backlog'\]\)/,
508
+ 'Ready and Backlog are task feeds; In Progress and Completed render other objects');
509
+ assert.match(workJs, /function syncFilterBarVisibility/);
510
+ // Wired BOTH ways: on a tab change, and once at boot — makeTabs restores a
511
+ // deep-linked tab (/work#completed) before the bar is built, and fires
512
+ // onChange only on a CHANGE, so opening state has to be set explicitly.
513
+ assert.match(workJs, /onChange: \(id\) => \{[\s\S]{0,160}syncFilterBarVisibility\(id\)/,
514
+ 'a tab change re-syncs the bar');
515
+ assert.match(workJs, /syncFilterBarVisibility\(workTabs && workTabs\.active \? workTabs\.active\(\) : 'ready'\)/,
516
+ 'and the opening tab decides the opening state');
517
+ });
518
+
519
+ test('both queue tabs narrow through ONE predicate, so a filter cannot reach only one', () => {
520
+ assert.match(workJs, /function matchesBoardFilters\(t, f\)/,
521
+ 'the predicate is shared, not copied per tab');
522
+ // Both renderers must consult it. This is the whole defect: before, only the
523
+ // claimable one did.
524
+ const claimable = workJs.slice(workJs.indexOf('function renderClaimable'), workJs.indexOf('async function populateCreatorFilter'));
525
+ const backlog = workJs.slice(workJs.indexOf('function renderBacklog'), workJs.indexOf('async function refreshLive'));
526
+ for (const [name, body] of [['renderClaimable', claimable], ['renderBacklog', backlog]]) {
527
+ assert.ok(body.length, `${name} body located`);
528
+ assert.match(body, /matchesBoardFilters\(t, filters\)/, `${name} narrows through the shared predicate`);
529
+ assert.match(body, /applyRunMode\(list, \$\('#f-fit'\)/, `${name} applies the set-relative run-mode lens too`);
530
+ }
531
+ });
532
+
533
+ test('a backlog row names its craft and its goal', () => {
534
+ const backlog = workJs.slice(workJs.indexOf('function renderBacklog'));
535
+ // The same vocabulary the Ready card has carried since 1755 — a second spelling
536
+ // would let the two tabs describe one task differently.
537
+ assert.match(backlog, /const discipline = t\.discipline \|\| 'unclassified'/);
538
+ assert.match(backlog, /disciplineTag/, 'the row renders the discipline tag');
539
+ assert.match(backlog, /taskState\.goalTitles \|\| \{\}/, 'the goal is named by TITLE, not by bare id');
540
+ assert.match(backlog, /'No goal'/, 'and a goal-less row says so rather than rendering a gap');
541
+ assert.ok(rule(workCss, '.history__goal'), '.history__goal has a rule — an unstyled sub-line inherits the title size');
542
+ });
543
+
544
+ test('the backlog asks the SERVER the narrow question, because its read is capped', () => {
545
+ // A client-side-only narrow of a capped read LIES: filtering the 500 fetched
546
+ // rows to the artist ones hides the artist tasks that sat at row 501.
547
+ assert.match(workJs, /function backlogFilterQuery/);
548
+ const q = workJs.slice(workJs.indexOf('function backlogFilterQuery'), workJs.indexOf('BACKLOG_LIMIT ='));
549
+ for (const param of ['version=', 'kind=', 'discipline=', 'goal_id=']) {
550
+ assert.ok(q.includes(param), `the backlog read sends ${param} — GET /tasks answers it`);
551
+ }
552
+ // '__none__' (ungoaled) has no server predicate, so it must not be sent.
553
+ assert.match(q, /f\.goal && f\.goal !== '__none__'/,
554
+ "the client-only 'No goal' lens is never sent to the route");
555
+ // It reads through boardFilterValues rather than re-querying the DOM, so the
556
+ // query and the client predicate can never disagree about a control's value.
557
+ assert.match(q, /const f = boardFilterValues\(\);/);
558
+ // And it stays SEPARATE from the claimable query: GET /tasks/claimable answers
559
+ // only version + discipline, so kind/goal_id there would be a silent no-op.
560
+ const claimQ = workJs.slice(workJs.indexOf('function serverFilterQuery'), workJs.indexOf('task 1003828 — the BACKLOG'));
561
+ assert.ok(!claimQ.includes('goal_id='), 'the claimable feed is not sent goal_id');
562
+ assert.ok(!claimQ.includes('kind='), 'nor kind');
563
+ });
564
+
565
+ test('each feed decides its own re-fetch, so a server change cannot strand the other tab', () => {
566
+ // The old shape compared ONE query and returned early, which left the other
567
+ // tab's client-side lenses unapplied until the next poll happened to repaint.
568
+ assert.match(workJs, /if \(q !== lastServerFilterQuery\) \{ lastServerFilterQuery = q; refreshLive\(\); \} else \{ renderClaimable\(\); \}/);
569
+ assert.match(workJs, /if \(bq !== lastBacklogQuery\) \{ lastBacklogQuery = bq; loadBacklog\(\); \} else \{ renderBacklog\(\); \}/);
570
+ });
571
+
572
+ test('the Goal filter offers goals that only have BACKLOG work', () => {
573
+ const populate = workJs.slice(workJs.indexOf('function populateGoalFilter'), workJs.indexOf('async function copyPromptForTask'));
574
+ assert.match(populate, /\[\.\.\.taskState\.all, \.\.\.\(taskState\.backlog \|\| \[\]\)\]/,
575
+ 'built from the claimable feed alone, a backlog-only goal was unofferable');
576
+ });