@bongos/core 1.19.971 → 1.19.973
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +116 -76
- package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
- package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
- package/docs/adr/0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md +1 -1
- package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +107 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -1
- package/docs/api-reference.md +1 -1
- package/docs/architecture.md +6 -6
- package/docs/branding-contract.md +1 -1
- package/docs/canonical-permissions.md +24 -24
- package/docs/copy-inventory.md +193 -187
- package/docs/copy-registry.json +320 -276
- package/docs/design/projects-hub-direction-v2.md +1 -1
- package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -2
- package/docs/file-map.md +7 -7
- package/docs/handoff-template.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -1
- package/docs/onboarding/drachmae-and-karma.md +2 -2
- package/docs/packs/engineer.md +5 -5
- package/docs/packs/ideator.md +1 -1
- package/docs/recipes/README.md +1 -1
- package/docs/recipes/audit-replay.md +2 -2
- package/docs/recipes/gds-db-backup.md +3 -3
- package/docs/recipes/managed-settings-remote-control.md +9 -9
- package/docs/recipes/memory-sync-conflicts.md +2 -2
- package/docs/recipes/ops-gotchas.md +24 -5
- package/docs/recipes/overnight-builder.md +3 -3
- package/docs/recipes/parallel-goal-waves.md +3 -3
- package/docs/recipes/reranking-a-goals-board.md +2 -2
- package/docs/recipes/search-index-ingestion.md +1 -1
- package/docs/recipes/ship-and-regrade.md +2 -2
- package/docs/recipes/staging-environment.md +1 -1
- package/docs/recipes/windows-builders.md +1 -1
- package/modules/hall-ui/public/deploy.html +10 -5
- package/modules/hall-ui/public/deploy.js +199 -110
- package/modules/hall-ui/public/goals-page.js +6 -0
- package/modules/hall-ui/public/goals-render.js +16 -0
- package/modules/hall-ui/public/mine-lens.js +172 -0
- package/modules/hall-ui/public/project-settings.css +36 -0
- package/modules/hall-ui/public/work-summary.js +90 -0
- package/modules/hall-ui/public/work.css +50 -0
- package/modules/hall-ui/public/work.html +24 -0
- package/modules/hall-ui/public/work.js +38 -51
- package/modules/lifecycle/db-goals.js +20 -3
- package/modules/lifecycle/routes/goals.js +2 -2
- package/modules/provisioning/core-upgrade.js +61 -2
- package/modules/public-landing/public/contact.html +1 -1
- package/modules/public-landing/public/index.html +1 -1
- package/modules/public-landing/public/privacy.html +1 -1
- package/modules/public-landing/public/projects.html +1 -1
- package/modules/public-landing/public/terms.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/api-alias-caller-scan.js +47 -9
- package/scripts/gds/cloudbongos-token-isolation.conf +33 -0
- package/scripts/gds/fitness.js +3 -0
- package/scripts/gds/provision-core-upgrade.js +132 -1
- package/scripts/gds/run-unit-tests.js +14 -9
- package/scripts/gds/scope-roster-guard.js +118 -0
- package/scripts/hall-preview/server.js +5 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/api_alias_caller_ratchet.mjs +68 -2
- package/tests/core_upgrade_door.mjs +81 -0
- package/tests/core_upgrade_runner.mjs +132 -0
- package/tests/deploy_page_projects.mjs +234 -79
- package/tests/fitness_scope_roster.mjs +117 -0
- package/tests/goal_mine_lens.mjs +233 -0
- package/tests/goal_tier.mjs +30 -4
- package/tests/hall_mine_lens.mjs +381 -0
- package/tests/idea_spark_hall.mjs +101 -12
- package/tests/known_failing_ratchet.mjs +19 -9
- package/tests/landing_legal_pages.mjs +5 -1
- package/tests/projects_hub_pre_uat.mjs +12 -6
- package/tests/served_paths_shipped.mjs +36 -2
|
@@ -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
|
+
}));
|
|
@@ -128,6 +128,23 @@
|
|
|
128
128
|
overflow-x: auto;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/* What the move would BRING IN (task 1004173) — the ships' own summaries between the two
|
|
132
|
+
releases. It sits above the pre-flight because it is the question the owner asked
|
|
133
|
+
("I don't actually see what would change"); the verdict below it is the machinery's
|
|
134
|
+
answer to a different one. No mark of its own: this is the body of step two, not a
|
|
135
|
+
state, and a coloured bar here would compete with the three that DO mean something. */
|
|
136
|
+
.dp-changes { margin: 0 0 0.7rem; }
|
|
137
|
+
.dp-changes__list {
|
|
138
|
+
margin: 0.35rem 0 0;
|
|
139
|
+
padding-left: 1.1rem;
|
|
140
|
+
color: var(--ink-soft);
|
|
141
|
+
font-size: 0.86rem;
|
|
142
|
+
line-height: 1.55;
|
|
143
|
+
}
|
|
144
|
+
.dp-changes__list li { margin-bottom: 0.35rem; }
|
|
145
|
+
.dp-changes__more { color: var(--ink-soft); font-style: italic; }
|
|
146
|
+
.dp-changes--unknown { color: var(--ink-soft); }
|
|
147
|
+
|
|
131
148
|
/* A move in flight, and how it ended (task 1004145). The owner pressed Upgrade and got
|
|
132
149
|
a toast and then silence; these are the two states that silence was hiding. */
|
|
133
150
|
.dp-moving {
|
|
@@ -143,3 +160,22 @@
|
|
|
143
160
|
border-left: 3px solid var(--danger);
|
|
144
161
|
padding-left: 0.7rem;
|
|
145
162
|
}
|
|
163
|
+
/* A failure the box has already moved past (task 1004172). It keeps a mark, because it
|
|
164
|
+
is still a thing that happened and worth reading — but not the danger mark, which on
|
|
165
|
+
this page means "your project is in this state now". A two-day-old refusal wearing the
|
|
166
|
+
same red as a live one is how the owner read a solved problem as the current one. */
|
|
167
|
+
.dp-settled--past {
|
|
168
|
+
border-left: 3px solid var(--rule);
|
|
169
|
+
padding-left: 0.7rem;
|
|
170
|
+
color: var(--ink-soft);
|
|
171
|
+
}
|
|
172
|
+
/* The runner's own output, folded. It runs to thirty lines when the refusal is a dirty
|
|
173
|
+
tree (it prints the whole `git status`) and it addresses whoever holds a shell, which
|
|
174
|
+
the owner does not — so the sentence above it leads and this opens on request. */
|
|
175
|
+
.dp-settled__detail > summary {
|
|
176
|
+
cursor: pointer;
|
|
177
|
+
font-size: 0.82rem;
|
|
178
|
+
color: var(--ink-soft);
|
|
179
|
+
padding: 0.35rem 0;
|
|
180
|
+
min-height: 24px;
|
|
181
|
+
}
|
|
@@ -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',
|
|
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"
|
|
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
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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',
|
|
1038
|
+
setTabCount('completed', historyRows.length);
|
|
1067
1039
|
if (!rows.length) {
|
|
1068
|
-
root.innerHTML = `<div class="profile__placeholder"
|
|
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
|
-
|
|
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
|
|
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 (_) {
|
|
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
|
-
|
|
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
|
}
|
|
@@ -215,7 +215,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
215
215
|
// rank: any-builder — GET /goals/graph, the goal board's map (task 1003479, ADR 0227); before /goals/:id.
|
|
216
216
|
router.get('/goals/graph', auth.requireBuilder, goalGraph.goalGraphHandler({ log }));
|
|
217
217
|
|
|
218
|
-
// GET /goals?version=&status= — list goals (read).
|
|
218
|
+
// GET /goals?version=&status= — list goals (read); each row carries `viewer_is_member` for the reader.
|
|
219
219
|
// rank: any-builder — viewing the goal board.
|
|
220
220
|
router.get('/goals', auth.requireBuilder, async (req, res) => {
|
|
221
221
|
const { version, status } = req.query;
|
|
@@ -223,7 +223,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
223
223
|
return res.fail('bad_status', 400, { valid: VALID_GOAL_STATUSES });
|
|
224
224
|
}
|
|
225
225
|
try {
|
|
226
|
-
const goals = await db.listGoals({ versionId: version || null, status: status || null });
|
|
226
|
+
const goals = await db.listGoals({ versionId: version || null, status: status || null, viewerId: req.builder.id });
|
|
227
227
|
// task 1003275: ONE category query decorates the whole board (never a join
|
|
228
228
|
// per goal). Purely a label for the reader — nothing downstream branches on it.
|
|
229
229
|
const byId = await goalCategory.categoryIndex();
|