@bongos/core 1.19.971 → 1.19.972

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.
@@ -2416,5 +2416,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2416
2416
  landed since 1.19.969 with no explicit bump. run 35804568036. (task 1002620)
2417
2417
  1.19.971 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2418
2418
  landed since 1.19.970 with no explicit bump. run 35805746484. (task 1002620)
2419
+ 1.19.972 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2420
+ landed since 1.19.971 with no explicit bump. run 35806478158. (task 1002620)
2419
2421
  ---------------------------------------------------------------------------
2420
2422
  ```
@@ -1052,6 +1052,12 @@
1052
1052
  // ADR 0174: the craft axis again, scoped to the tasks inside one goal.
1053
1053
  { id: 'f-goal-discipline', label: `Task ${window.OTB.roleAxisLabel().toLowerCase()}`, options: window.OTB.roleOptions({ unset: true }) }, // ADR 0174
1054
1054
  { id: 'f-goal-newcomer', label: 'Newcomer', options: [{ value: '', label: '— any —' }, { value: 'yes', label: 'newcomer-friendly only' }] },
1055
+ // The board's "Only mine" lens, scoped to one goal. Inside a goal the
1056
+ // membership half of that question is already answered (you are looking at
1057
+ // one goal, and the page says whether you are on it), so what is left is the
1058
+ // claim half: of the work queued here, what am I holding? Answered from
1059
+ // state.claimsByTask, which the detail already loads for the Release button.
1060
+ { id: 'f-goal-claimed', label: 'Mine', options: [{ value: '', label: '— anyone\'s —' }, { value: 'mine', label: 'claimed by me' }] },
1055
1061
  { id: 'f-goal-fit', label: 'Run mode', options: [{ value: '', label: '— any —' },
1056
1062
  { value: 'parallel', label: 'parallel-safe only' },
1057
1063
  { value: 'conflict', label: 'has file conflicts' }] },
@@ -111,7 +111,14 @@
111
111
  || !!(state.disciplines && state.disciplines.get(String(g.id))?.has(role));
112
112
  const matchesStale = (g) => !staleDays || isStale(goalActivityMs(g), staleDays);
113
113
  const myId = selfId();
114
+ // GET /goals now answers this on the row itself (`viewer_is_member`, an EXISTS
115
+ // over goal_members for the AUTHENTICATED reader), which is what this lens
116
+ // reads first. The per-goal detail is kept as the fallback, and the order
117
+ // matters: the detail prefetch is a best-effort fan-out, so on a slow or
118
+ // partly-failed boot the lens used to return NOTHING and say, in effect, "you
119
+ // are on no goals" — a claim about the reader, made out of a failed fetch.
114
120
  const iAmMember = (g) => {
121
+ if (typeof g.viewer_is_member === 'boolean') return g.viewer_is_member;
115
122
  const d = state.details.get(String(g.id));
116
123
  return d ? (d.members || []).some((m) => sameId(m.builder_id, myId)) : false;
117
124
  };
@@ -505,12 +512,21 @@
505
512
  const kFilter = $('#f-goal-kind')?.value || '';
506
513
  const dFilter = $('#f-goal-discipline')?.value || '';
507
514
  const nFilter = $('#f-goal-newcomer')?.value || '';
515
+ // "Mine" inside a goal = the claims this builder is holding here. Signed out
516
+ // there is no answer, so the lens is inert rather than emptying the list.
517
+ const myId = selfId();
518
+ const mineFilter = (myId != null && $('#f-goal-claimed')?.value === 'mine');
519
+ const claimedByMe = (t) => {
520
+ const c = state.claimsByTask.get(String(t.id));
521
+ return !!(c && sameId(c.builder_id, myId));
522
+ };
508
523
  const sortBy = $('#f-goal-sort')?.value || 'cpm';
509
524
  let list = state.work.todo.filter((t) => {
510
525
  if (!matchesTodoSearch(t, sQuery)) return false;
511
526
  if (kFilter && (t.kind || window.OTB.KIND_UNSET) !== kFilter) return false;
512
527
  if (dFilter && (t.discipline || window.OTB.ROLE_UNSET) !== dFilter) return false;
513
528
  if (nFilter === 'yes' && !t.newcomer_friendly) return false;
529
+ if (mineFilter && !claimedByMe(t)) return false;
514
530
  return true;
515
531
  });
516
532
  // Run-mode filter: parallel-safe (touches[] disjoint from the rest of the
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ // modules/hall-ui/public/mine-lens.js — the hall's ONE definition of "mine":
4
+ // the goals you are added on, and the tasks you have claimed.
5
+ //
6
+ // WHY IT IS A FILE AND NOT A BLOCK IN work.js. Two reasons, and the second is
7
+ // the load-bearing one.
8
+ //
9
+ // 1. Two boards ask the same question. The Tasks board asks it of four tabs;
10
+ // the goal board asks it of its own list and of one goal's To-do queue. A
11
+ // second copy of "what counts as mine" is a copy that drifts — the exact
12
+ // failure task 1002463 carved task-lib.js to end, after the two boards had
13
+ // already disagreed about a refusal message.
14
+ // 2. work.js sits at the fitness size ceiling (OVERSIZE_LINES = 1500 in
15
+ // scripts/gds/fitness-ratchets.js, baseline 0 oversized files). It had
16
+ // exactly zero lines of headroom, so the lens could not live there and the
17
+ // baseline is not the thing that gives — the same reason claim-feed.js and
18
+ // goals-render.js are their own files.
19
+ //
20
+ // THE WORD MEANS A DIFFERENT THING ON EACH SURFACE, and that is deliberate
21
+ // rather than sloppy:
22
+ //
23
+ // a queue of unclaimed work → tasks sitting in a goal you are a member of
24
+ // a list of live claims → the ones you are holding
25
+ // shipped history → the ones you shipped
26
+ //
27
+ // Nothing here is claimable-feed-shaped or goal-board-shaped; each helper takes
28
+ // the rows and the identity and returns the narrowed rows. Pure except for
29
+ // `attach`, which owns the checkbox.
30
+ //
31
+ // Node-require-safe (the goals-lib.js / task-lib.js precedent) so a unit test
32
+ // runs the real predicates instead of agreeing with a copy of them.
33
+ (function (root, factory) {
34
+ var mod = factory();
35
+ if (typeof module !== 'undefined' && module.exports) module.exports = mod;
36
+ else root.OTBMineLens = mod;
37
+ }(typeof self !== 'undefined' ? self : this, function () {
38
+ 'use strict';
39
+
40
+ // Ids compare as strings throughout: a claim row's builder_id arrives as a
41
+ // number from one feed and a bigint-as-string from another, and `===` on the
42
+ // raw values is how a viewer silently stops recognising their own work.
43
+ function sameId(a, b) {
44
+ return a != null && b != null && String(a) === String(b);
45
+ }
46
+
47
+ // The per-surface hint. The control is one checkbox, so the sentence beside it
48
+ // is what keeps it honest about which question this tab is answering.
49
+ var HINTS = {
50
+ ready: 'tasks in goals you are on',
51
+ backlog: 'tasks in goals you are on',
52
+ 'in-progress': 'claims you are holding',
53
+ completed: 'tasks you shipped',
54
+ };
55
+ function hintFor(tabId) { return HINTS[String(tabId)] || ''; }
56
+
57
+ // ---- the three narrowings -------------------------------------------------
58
+
59
+ // Goal membership. `goalIds` is a Set of STRING goal ids the viewer is on.
60
+ // A task with NO goal is excluded: it belongs to nobody by membership, and
61
+ // letting it through would make the lens read as "mine, plus the orphans".
62
+ function inMyGoals(task, goalIds) {
63
+ return !!(task && goalIds && goalIds.has(String(task.goal_id)));
64
+ }
65
+ function narrowByGoal(rows, goalIds) {
66
+ return (rows || []).filter(function (t) { return inMyGoals(t, goalIds); });
67
+ }
68
+
69
+ // Live claims — the literal claim question.
70
+ function isMyClaim(claim, viewerId) {
71
+ return !!(claim && sameId(claim.builder_id, viewerId));
72
+ }
73
+ function narrowByClaim(rows, viewerId) {
74
+ return (rows || []).filter(function (c) { return isMyClaim(c, viewerId); });
75
+ }
76
+
77
+ // Shipped history. The feed behind it is the PUBLIC recent-shipped read, which
78
+ // names its shipper by github_login and carries no builder id — so this one
79
+ // matches on the login, and a caller without one must not narrow at all rather
80
+ // than narrow to nothing.
81
+ function narrowByShipper(rows, login) {
82
+ if (!login) return rows || [];
83
+ return (rows || []).filter(function (t) { return t && t.github_login === login; });
84
+ }
85
+
86
+ // Goal rows → the Set of ids the viewer is on. `viewer_is_member` is the flag
87
+ // GET /goals carries for the authenticated reader.
88
+ function myGoalIds(goals) {
89
+ var set = new Set();
90
+ (goals || []).forEach(function (g) {
91
+ if (g && g.viewer_is_member === true) set.add(String(g.id));
92
+ });
93
+ return set;
94
+ }
95
+
96
+ // ---- the control ----------------------------------------------------------
97
+
98
+ // attach({ box, wrap, hint, onChange }) — owns one checkbox and the sentence
99
+ // beside it. Elements are passed in (already resolved), so this file never
100
+ // learns a page's ids.
101
+ //
102
+ // The lens starts HIDDEN and identity-less. It is revealed by setViewer once
103
+ // an identity resolves, and `unavailable()` takes it away again when the
104
+ // evidence it depends on could not be read. Both matter for the same reason:
105
+ // a checkbox that silently empties every list is worse than one that plainly
106
+ // is not on offer.
107
+ function attach(opts) {
108
+ var o = opts || {};
109
+ var box = o.box || null;
110
+ var wrap = o.wrap || null;
111
+ var hint = o.hint || null;
112
+ var viewerId = null;
113
+ var goalIds = new Set();
114
+
115
+ if (box && o.onChange) box.addEventListener('change', o.onChange);
116
+
117
+ function known() { return viewerId != null && box != null && !box.disabled; }
118
+
119
+ return {
120
+ // Is the lens currently narrowing? False whenever there is no identity, so
121
+ // every caller can read it without repeating the guard.
122
+ on: function () { return !!(box && box.checked && known()); },
123
+ viewerId: function () { return viewerId; },
124
+
125
+ setTab: function (tabId) { if (hint) hint.textContent = hintFor(tabId); },
126
+ hide: function () { if (wrap) wrap.hidden = true; },
127
+
128
+ setViewer: function (id, whenUnknown) {
129
+ viewerId = id == null ? null : id;
130
+ if (wrap) wrap.hidden = false;
131
+ if (!box) return;
132
+ box.disabled = viewerId == null;
133
+ if (viewerId == null) {
134
+ box.checked = false;
135
+ box.title = whenUnknown || 'Sign in to filter to your own work';
136
+ }
137
+ },
138
+
139
+ setGoals: function (goals) { goalIds = myGoalIds(goals); },
140
+
141
+ // The membership read failed, so the lens has no evidence. Turning it off
142
+ // and SAYING SO is the honest failure: left live over an empty set it
143
+ // would narrow the queues to nothing and read as "you are on no goals",
144
+ // which is a different claim from "the board could not find out".
145
+ unavailable: function (why) {
146
+ if (!box) return;
147
+ box.checked = false;
148
+ box.disabled = true;
149
+ box.title = why || 'That could not be loaded — reload to filter to your own work';
150
+ },
151
+
152
+ // The three narrowings, with the lens's own state folded in so a caller
153
+ // never has to ask twice.
154
+ goals: function (rows) { return this.on() ? narrowByGoal(rows, goalIds) : (rows || []); },
155
+ claims: function (rows) { return this.on() ? narrowByClaim(rows, viewerId) : (rows || []); },
156
+ ships: function (rows, login) { return this.on() ? narrowByShipper(rows, login) : (rows || []); },
157
+ inMyGoals: function (task) { return inMyGoals(task, goalIds); },
158
+ };
159
+ }
160
+
161
+ return {
162
+ attach: attach,
163
+ hintFor: hintFor,
164
+ myGoalIds: myGoalIds,
165
+ inMyGoals: inMyGoals,
166
+ isMyClaim: isMyClaim,
167
+ narrowByGoal: narrowByGoal,
168
+ narrowByClaim: narrowByClaim,
169
+ narrowByShipper: narrowByShipper,
170
+ sameId: sameId,
171
+ };
172
+ }));
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ // modules/hall-ui/public/work-summary.js — the Tasks board's "works completed"
4
+ // momentum card (task 1469), lifted out of work.js.
5
+ //
6
+ // WHY IT MOVED. Two reasons, and neither is tidiness for its own sake.
7
+ //
8
+ // 1. It is not board machinery. Everything else in work.js is about claiming,
9
+ // releasing, filtering and selecting TASKS; this is three PROJECT-WIDE
10
+ // public statistics — total works shipped, total <currency> awarded, and
11
+ // the most recent ship — rendered as stat tiles above the Completed list.
12
+ // It shares no state with the claim path and reads no per-builder data.
13
+ // 2. work.js sits ON the fitness size ceiling (OVERSIZE_LINES = 1500 in
14
+ // scripts/gds/fitness-ratchets.js, with the repo's baseline at zero
15
+ // oversized files). It had no headroom at all, so the Only-mine lens could
16
+ // not be added without something leaving, and the ratchet's own rule is to
17
+ // fix the regression rather than raise the baseline. This block is what
18
+ // leaves, because it is the piece with the fewest ties to the rest.
19
+ //
20
+ // NOT the viewer's own output — that lives on the profile page. All three
21
+ // figures come from public reads (/public/progress, /public/cost-summary, and
22
+ // the head of the recent-shipped feed the Completed list already loaded), which
23
+ // is also why the "Only mine" lens does not touch this card: narrowing the list
24
+ // below it must not restate the project's totals as one builder's.
25
+ //
26
+ // Node-require-safe (the goals-lib.js / mine-lens.js precedent) so a unit test
27
+ // can exercise the real tile logic.
28
+ (function (root, factory) {
29
+ var mod = factory();
30
+ if (typeof module !== 'undefined' && module.exports) module.exports = mod;
31
+ else root.OTBWorkSummary = mod;
32
+ }(typeof self !== 'undefined' ? self : this, function () {
33
+ 'use strict';
34
+
35
+ // create(deps) — deps are work.js's own helper variants (a date-only fmtDate,
36
+ // a relative fmtAgo, the branded currency label), passed in rather than
37
+ // re-derived so the card cannot drift from the page around it. `latestShip`
38
+ // is a getter, not a value: the head of the history feed changes as that feed
39
+ // lands and re-renders.
40
+ function create(deps) {
41
+ var d = deps || {};
42
+ var $ = d.$;
43
+ var esc = d.escapeHtml;
44
+ var fmtNum = d.fmtNum;
45
+ var fmtAgo = d.fmtAgo;
46
+ var curLabel = d.curLabel;
47
+ var latestShip = d.latestShip || function () { return null; };
48
+ var state = { progress: null, cost: null };
49
+
50
+ function tile(label, num, sub, subTitle) {
51
+ var subHtml = sub
52
+ ? '<span class="stat-tile__sub" ' + (subTitle ? 'title="' + esc(subTitle) + '" ' : '')
53
+ + 'style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(sub) + '</span>'
54
+ : '';
55
+ return '<div class="stat-tile"><span class="stat-tile__label">' + esc(label) + '</span>'
56
+ + '<span class="stat-tile__num">' + esc(String(num)) + '</span>' + subHtml + '</div>';
57
+ }
58
+
59
+ function render() {
60
+ var host = $('#works-summary');
61
+ if (!host) return;
62
+ var versions = state.progress && Array.isArray(state.progress.progress) ? state.progress.progress : null;
63
+ // Wait for the shipped-count feed; the card stays hidden until then (and
64
+ // on a project that has shipped nothing).
65
+ if (!versions) { host.hidden = true; return; }
66
+ var shipped = versions.reduce(function (n, v) { return n + (Number(v.lifecycle_shipped_count) || 0); }, 0);
67
+ if (!shipped) { host.hidden = true; return; }
68
+ var awarded = state.cost ? Number(state.cost.total_value_credits) || 0 : null;
69
+ var latest = latestShip();
70
+
71
+ var tiles = [tile('Works shipped', fmtNum(shipped))];
72
+ if (awarded != null) tiles.push(tile(curLabel(true) + ' awarded', fmtNum(awarded)));
73
+ if (latest) {
74
+ tiles.push(tile('Latest ship', latest.shipped_at ? fmtAgo(latest.shipped_at) : '—',
75
+ latest.title || ('#' + latest.id), latest.title || ''));
76
+ }
77
+ host.innerHTML = tiles.join('');
78
+ host.hidden = false;
79
+ }
80
+
81
+ return {
82
+ // set() records a feed and repaints; either half may land first, and the
83
+ // card stays hidden until the shipped counts do.
84
+ set: function (progress, cost) { state.progress = progress; state.cost = cost; render(); },
85
+ render: render,
86
+ };
87
+ }
88
+
89
+ return { create: create };
90
+ }));
@@ -328,3 +328,53 @@
328
328
  .bulk-toolbar__actions { margin-left: 0; width: 100%; }
329
329
  body[data-page="work"] .app-content { padding-bottom: 200px; }
330
330
  }
331
+
332
+ /* ---- "Only mine" lens (the board's tab-wide filter; goal board's To-do bar
333
+ borrows the same class) --------------------------------------------------
334
+ Deliberately QUIETER than the filter bar below it: a checkbox in the label
335
+ voice, not a fifth <select>. It is one binary question standing next to a bar
336
+ of dropdowns, and dressing it as one of them would have read as a sixth lens
337
+ with the same reach — which it does not have (each tab answers "mine"
338
+ differently; see the comment on #work-mine in work.html). */
339
+ .mine-lens {
340
+ display: flex;
341
+ flex-wrap: wrap;
342
+ align-items: baseline;
343
+ gap: 4px 10px;
344
+ margin: 0 0 12px;
345
+ font-family: var(--label-face);
346
+ font-size: var(--label-size);
347
+ letter-spacing: var(--label-space);
348
+ text-transform: var(--label-case);
349
+ color: var(--ink-soft);
350
+ }
351
+ .mine-lens__lab {
352
+ display: inline-flex;
353
+ align-items: center;
354
+ gap: 7px;
355
+ /* The board's 24px hit floor (task 1003304). The drawn box is 16px; the label
356
+ is the target, so the floor is bought here rather than by inflating the
357
+ control — the same trade the claimable card's checkbox makes. */
358
+ min-height: 24px;
359
+ cursor: pointer;
360
+ }
361
+ /* The checkbox is the hit target, so it gets a real one — 16px plus the label
362
+ text beside it clears the 24px touch floor without a bespoke control. */
363
+ .mine-lens__lab input[type="checkbox"] {
364
+ width: 16px;
365
+ height: 16px;
366
+ margin: 0;
367
+ accent-color: var(--accent);
368
+ cursor: pointer;
369
+ }
370
+ .mine-lens__lab:has(input:disabled) { cursor: not-allowed; color: var(--ink-faint); }
371
+ .mine-lens__lab input:disabled { cursor: not-allowed; }
372
+ /* The hint carries the per-tab MEANING, so it reads as prose rather than as a
373
+ second label — the label case above would make it shout. */
374
+ .mine-lens__hint {
375
+ text-transform: none;
376
+ letter-spacing: normal;
377
+ font-family: var(--font-body);
378
+ font-size: 12.5px;
379
+ color: var(--ink-faint);
380
+ }
@@ -52,6 +52,26 @@
52
52
  Deep links: /work#in-progress etc. -->
53
53
  <nav id="work-tabs" aria-label="Task sections"></nav>
54
54
 
55
+ <!-- "Only mine" — ONE lens, and the only control on this page that is
56
+ live on all four tabs. It sits OUTSIDE #work-filters on purpose: that
57
+ bar drives the two queue tabs and hides on the other two (task
58
+ 1003828 — a filter over a list it cannot narrow is a bug), whereas
59
+ this question has a true answer on every tab. What "mine" MEANS is
60
+ read from the tab, which is why the hint beside it is rewritten on
61
+ every switch rather than left as a generic word:
62
+ Ready / Backlog → tasks sitting in a goal you are a member of
63
+ In Progress → the claims you are holding right now
64
+ Completed → what you shipped
65
+ Signed out, or an identity the page could not resolve, disables it
66
+ rather than offering a lens that would silently empty every list. -->
67
+ <div class="mine-lens" id="work-mine" hidden>
68
+ <label class="mine-lens__lab">
69
+ <input type="checkbox" id="f-mine">
70
+ <span>Only mine</span>
71
+ </label>
72
+ <span class="mine-lens__hint" id="work-mine-hint"></span>
73
+ </div>
74
+
55
75
  <!-- task 2170: the filter bar is built by OTBKit.makeFilterBar —
56
76
  Search / Sort / Version / Discipline primary (task 1003828 promoted
57
77
  Discipline out of the overflow: a craft is the axis this platform
@@ -160,6 +180,10 @@
160
180
  <script src="/builders/hall-kit.js" defer></script>
161
181
  <!-- shared TASK-DOMAIN helpers (window.OTBTaskLib) — priority/touches/prompt/refusal-copy, one copy for both boards (task 1002463). -->
162
182
  <script src="/builders/task-lib.js" defer></script>
183
+ <!-- the hall's one definition of "mine" (window.OTBMineLens) — goals you are on, claims you hold, ships you made; shared with the goal board. -->
184
+ <script src="/builders/mine-lens.js" defer></script>
185
+ <!-- the Completed tab's project-wide momentum card (window.OTBWorkSummary), carved out of work.js. -->
186
+ <script src="/builders/work-summary.js" defer></script>
163
187
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail" defer></script>
164
188
  <script src="/builders/brand-apply.js?v=2026-06-19-r56" defer></script>
165
189
  <script src="/builders/work.js?v=2026-07-03-workboard3" defer></script>
@@ -194,7 +194,7 @@
194
194
 
195
195
  // task 2170: the four sections are hash-synced tabs (Ready default). Built in
196
196
  // wireEvents; renders feed the tab count pills via setTabCount.
197
- let workTabs = null;
197
+ let workTabs = null, mine = null; // mine: the Only-mine lens (mine-lens.js)
198
198
  function setTabCount(id, n) { if (workTabs) workTabs.setCount(id, n); }
199
199
 
200
200
  // A section-header count pill: reveal it and set its number once data lands.
@@ -216,11 +216,11 @@
216
216
  // reflects the TOTAL active-claim count, even on the empty path below.
217
217
  const all = liveState.active;
218
218
  const isMine = (c) => viewer.id != null && Number(c.builder_id) === Number(viewer.id);
219
- const rows = all.slice().sort((a, b) => (isMine(b) ? 1 : 0) - (isMine(a) ? 1 : 0));
220
- setCountPill('#wip-count', all.length);
219
+ const rows = mine.claims(all).slice().sort((a, b) => (isMine(b) ? 1 : 0) - (isMine(a) ? 1 : 0));
220
+ setCountPill('#wip-count', rows.length);
221
221
  setTabCount('in-progress', all.length);
222
222
  if (!rows.length) {
223
- tbody.innerHTML = `<tr><td colspan="6" class="ledger__empty">No tasks are in progress right now.</td></tr>`;
223
+ tbody.innerHTML = `<tr><td colspan="6" class="ledger__empty">${mine.on() ? 'You are not holding any claims right now.' : 'No tasks are in progress right now.'}</td></tr>`;
224
224
  pagers.wip.page = 1;
225
225
  const wp = $('#wip-pager');
226
226
  if (wp) wp.hidden = true;
@@ -265,7 +265,7 @@
265
265
  // Live view context. viewerId lets renderInFlight decide which active claims
266
266
  // are the viewer's own (→ a Release action). active holds the last in-flight
267
267
  // payload so a poll can re-render claim ages without another round-trip.
268
- const viewer = { id: null };
268
+ const viewer = { id: null, login: null };
269
269
  const liveState = { active: [] };
270
270
  // Guards against double-firing an action while its request is in flight.
271
271
  const pending = new Set();
@@ -323,6 +323,7 @@
323
323
  function boardFilterValues() {
324
324
  const val = (id) => ($(id) ? $(id).value : '');
325
325
  return {
326
+ mine: !!(mine && mine.on()),
326
327
  search: searchQuery(),
327
328
  version: val('#f-version'),
328
329
  goal: val('#f-goal'),
@@ -337,6 +338,7 @@
337
338
  // Does one task survive the current lenses? Pure over (task, values) so both
338
339
  // feeds narrow alike and a new filter cannot reach one tab alone.
339
340
  function matchesBoardFilters(t, f) {
341
+ if (f.mine && !mine.inMyGoals(t)) return false; // "mine" on a QUEUE tab = a goal I am on
340
342
  if (!matchesSearch(t, f.search)) return false;
341
343
  if (f.version && t.version_id !== f.version) return false;
342
344
  // task 1755: goal filter. '__none__' isolates tasks with no goal.
@@ -374,6 +376,7 @@
374
376
  function syncFilterBarVisibility(tabId) {
375
377
  const bar = $('#work-filters');
376
378
  if (bar) bar.hidden = !FILTERED_TABS.has(String(tabId));
379
+ if (mine) mine.setTab(tabId);
377
380
  }
378
381
 
379
382
  function renderClaimable() {
@@ -1015,45 +1018,14 @@
1015
1018
  let historyRows = [];
1016
1019
 
1017
1020
  // ---- task 1469: project-wide "works completed" momentum summary ----------
1018
- //
1019
- // A compact card above the Completed list summarizing the WHOLE project's
1020
- // shipped output (NOT the viewer's own — that lives on the profile page): total
1021
- // works shipped (all-time, summed from /public/progress lifecycle_shipped_count),
1022
- // total <currency> awarded (/public/cost-summary total_value_credits), and the
1023
- // most recent ship (the head of the recent-shipped feed already loaded for the
1024
- // history list). All three are public reads; no per-builder data.
1025
- let worksSummary = { progress: null, cost: null };
1026
-
1027
- function summaryTile(label, num, sub, subTitle) {
1028
- const subHtml = sub
1029
- ? `<span class="stat-tile__sub" ${subTitle ? `title="${escapeHtml(subTitle)}" ` : ''}style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(sub)}</span>`
1030
- : '';
1031
- return `<div class="stat-tile"><span class="stat-tile__label">${escapeHtml(label)}</span>` +
1032
- `<span class="stat-tile__num">${escapeHtml(String(num))}</span>${subHtml}</div>`;
1033
- }
1034
-
1035
- function renderWorksSummary() {
1036
- const root = $('#works-summary');
1037
- if (!root) return;
1038
- const versions = worksSummary.progress && Array.isArray(worksSummary.progress.progress)
1039
- ? worksSummary.progress.progress : null;
1040
- // Wait for the shipped-count feed; the card stays hidden until then (and on a
1041
- // project that has shipped nothing).
1042
- if (!versions) { root.hidden = true; return; }
1043
- const shipped = versions.reduce((n, v) => n + (Number(v.lifecycle_shipped_count) || 0), 0);
1044
- if (!shipped) { root.hidden = true; return; }
1045
- const awarded = worksSummary.cost ? Number(worksSummary.cost.total_value_credits) || 0 : null;
1046
- const latest = Array.isArray(historyRows) && historyRows.length ? historyRows[0] : null;
1047
-
1048
- const tiles = [summaryTile('Works shipped', fmtNum(shipped))];
1049
- if (awarded != null) tiles.push(summaryTile(`${curLabel(true)} awarded`, fmtNum(awarded)));
1050
- if (latest) {
1051
- tiles.push(summaryTile('Latest ship', latest.shipped_at ? fmtAgo(latest.shipped_at) : '—',
1052
- latest.title || `#${latest.id}`, latest.title || ''));
1053
- }
1054
- root.innerHTML = tiles.join('');
1055
- root.hidden = false;
1056
- }
1021
+ // The card itself is work-summary.js — three PUBLIC project figures, not board
1022
+ // machinery, and the piece that left when the Only-mine lens arrived and this
1023
+ // file had no room under the fitness size ceiling. It reads the head of the
1024
+ // history feed, so `latestShip` is a getter rather than a value.
1025
+ const worksSummary = window.OTBWorkSummary.create({
1026
+ $, escapeHtml, fmtNum, fmtAgo, curLabel,
1027
+ latestShip: () => (Array.isArray(historyRows) && historyRows.length ? historyRows[0] : null),
1028
+ });
1057
1029
 
1058
1030
  function renderHistory(tasks) {
1059
1031
  if (Array.isArray(tasks)) historyRows = tasks;
@@ -1061,11 +1033,11 @@
1061
1033
  if (!root) return;
1062
1034
  // task 2170: section visibility is tab-owned now — the render only fills
1063
1035
  // the panel and feeds the counts.
1064
- const rows = historyRows;
1036
+ const rows = mine.ships(historyRows, viewer.login);
1065
1037
  setCountPill('#done-count', rows.length);
1066
- setTabCount('completed', rows.length);
1038
+ setTabCount('completed', historyRows.length);
1067
1039
  if (!rows.length) {
1068
- root.innerHTML = `<div class="profile__placeholder">Nothing shipped yet.</div>`;
1040
+ root.innerHTML = `<div class="profile__placeholder">${mine.on() ? 'None of the recently shipped tasks are yours.' : 'Nothing shipped yet.'}</div>`;
1069
1041
  pagers.done.page = 1;
1070
1042
  const dp = $('#done-pager');
1071
1043
  if (dp) dp.hidden = true;
@@ -1085,7 +1057,7 @@
1085
1057
  }).join('');
1086
1058
  // task 1469: now that historyRows is populated, (re)render the momentum card so
1087
1059
  // its "Latest ship" tile reflects the newest shipped task.
1088
- renderWorksSummary();
1060
+ worksSummary.render();
1089
1061
  }
1090
1062
 
1091
1063
  // ---- backlog (not-yet-ready tasks) ---------------------------------------
@@ -1197,6 +1169,7 @@
1197
1169
  if (gate) gate.hidden = false;
1198
1170
  const tabsMount = $('#work-tabs');
1199
1171
  if (tabsMount) tabsMount.hidden = true;
1172
+ if (mine) mine.hide();
1200
1173
  for (const id of ['claim-scroll', 'wip-scroll', 'backlog-scroll', 'history-scroll']) {
1201
1174
  const el = document.getElementById(id);
1202
1175
  if (el) el.hidden = true;
@@ -1217,6 +1190,8 @@
1217
1190
  throw err;
1218
1191
  }
1219
1192
  viewer.id = me?.builder?.id ?? null;
1193
+ viewer.login = me?.builder?.github_login ?? null;
1194
+ mine.setViewer(viewer.id, 'Sign in to filter to your own goals and claims');
1220
1195
  // task 1003453 — the Kind lens, from the served vocabulary. Optional-called so an
1221
1196
  // older cached dom-utils.js costs one lens, not the board; see OTB.fillKindFilter.
1222
1197
  window.OTB.fillKindFilter?.($('#f-kind'), me?.task_kinds);
@@ -1243,8 +1218,7 @@
1243
1218
  getJSON(`${API}/public/progress`).catch(() => null),
1244
1219
  getJSON(`${API}/public/cost-summary`).catch(() => null),
1245
1220
  ]).then(([progress, cost]) => {
1246
- worksSummary = { progress, cost };
1247
- renderWorksSummary();
1221
+ worksSummary.set(progress, cost);
1248
1222
  });
1249
1223
 
1250
1224
  // task 1754: the Backlog group — not-yet-ready tasks. Best-effort + independent
@@ -1264,11 +1238,15 @@
1264
1238
  taskState.goalTitles[g.id] = g.title;
1265
1239
  taskState.goalCategories[g.id] = g.category || null;
1266
1240
  });
1241
+ mine.setGoals(gr.goals || []);
1267
1242
  // task 1003828 — loadBacklog() fires ABOVE this await, so its first paint
1268
1243
  // has no titles and every row would read "Goal #1000089" until something
1269
1244
  // else repainted it. Harmless if the feed has not landed yet.
1270
1245
  renderBacklog();
1271
- } catch (_) { /* filter labels fall back to 'Goal #id' */ }
1246
+ } catch (_) {
1247
+ /* filter labels fall back to 'Goal #id' */
1248
+ mine.unavailable('Goal membership could not be loaded — reload to filter to your own goals');
1249
+ }
1272
1250
 
1273
1251
  // Claimable + active claims in one call. A 403 means the primer gate — show
1274
1252
  // the friendly notice and hide the claim queue, but keep the in-flight list
@@ -1412,6 +1390,15 @@
1412
1390
  },
1413
1391
  });
1414
1392
 
1393
+ mine = window.OTBMineLens.attach({
1394
+ box: $('#f-mine'), wrap: $('#work-mine'), hint: $('#work-mine-hint'),
1395
+ onChange: () => {
1396
+ if (selectionTotal()) clearSelection();
1397
+ for (const p of Object.values(pagers)) p.page = 1;
1398
+ renderClaimable(); renderBacklog(); renderInFlight(); renderHistory();
1399
+ },
1400
+ });
1401
+
1415
1402
  // task 1003828 — makeTabs fires onChange only on a CHANGE and restores a
1416
1403
  // deep-linked tab before this point, so the opening state is set explicitly:
1417
1404
  // without it, a reload onto #completed shows a filter bar over ship history.
@@ -395,18 +395,35 @@ async function getGoalDependencies(goalId, deps = {}) {
395
395
  // table to `g` would have been tidier and would have rewritten four lines whose
396
396
  // literal text tests/goal_tier.mjs pins ("priority-first, then stable
397
397
  // ordering"). An additive change should read as additive in the diff.
398
- async function listGoals({ versionId = null, status = null, limit = null } = {}, deps = {}) {
398
+ //
399
+ // `viewerId` adds `viewer_is_member` — is the READER of this list one of this
400
+ // goal's members? The "My goals" lens on the goal board used to answer that from
401
+ // the per-goal DETAIL prefetch, which meant the lens silently returned NOTHING
402
+ // whenever that best-effort fan-out had not landed (or had failed), and the Tasks
403
+ // board could not ask the question at all without N extra requests. One EXISTS on
404
+ // a list the client already fetches answers it for both.
405
+ //
406
+ // `null` viewerId yields `false` everywhere, never NULL: an unauthenticated or
407
+ // identity-less read gets a well-formed row whose answer is "not yours", which is
408
+ // the truthful answer and keeps the wire shape one type. EXISTS (not a LEFT JOIN)
409
+ // for the `admitted` reason directly above — goal_members is keyed
410
+ // (goal_id, builder_id) so a join could not duplicate today, but the shape of the
411
+ // query should not depend on that.
412
+ async function listGoals({ versionId = null, status = null, limit = null, viewerId = null } = {}, deps = {}) {
399
413
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
400
414
  const { rows } = await activePool.query(
401
415
  `SELECT ${GOAL_COLS},
402
416
  EXISTS (SELECT 1 FROM lifecycle_goal_version_admissions a
403
- WHERE a.goal_id = goals.id) AS admitted
417
+ WHERE a.goal_id = goals.id) AS admitted,
418
+ ($4::bigint IS NOT NULL
419
+ AND EXISTS (SELECT 1 FROM goal_members gm
420
+ WHERE gm.goal_id = goals.id AND gm.builder_id = $4::bigint)) AS viewer_is_member
404
421
  FROM goals
405
422
  WHERE ($1::text IS NULL OR version_id = $1)
406
423
  AND ($2::text IS NULL OR status = $2)
407
424
  ORDER BY version_id, sort_order, created_at, id
408
425
  LIMIT $3::int`,
409
- [versionId, status, limit]
426
+ [versionId, status, limit, viewerId]
410
427
  );
411
428
  return rows;
412
429
  }