@bongos/core 1.21.5 → 1.21.6
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 +138 -68
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
- package/docs/api/openapi.json +2 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +156 -144
- package/docs/copy-registry.json +286 -158
- package/docs/module-api-changelog.md +2 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +31 -4
- package/docs/page-readings.json +121 -94
- package/modules/autonomy/config-idle.js +192 -0
- package/modules/autonomy/routes/autonomy.js +22 -0
- package/modules/autonomy/runner-health.js +22 -1
- package/modules/hall-ui/public/city-draw.js +438 -0
- package/modules/hall-ui/public/city.css +110 -0
- package/modules/hall-ui/public/city.html +71 -0
- package/modules/hall-ui/public/city.js +177 -0
- package/modules/hall-ui/public/city.states.json +30 -0
- package/modules/hall-ui/public/gate.js +11 -4
- package/modules/hall-ui/public/profile.css +4 -0
- package/modules/hall-ui/public/profile.js +25 -1
- package/modules/hall-ui/public/settings-autobongos.js +19 -1
- package/modules/hall-ui/public/settings.css +26 -0
- package/modules/hall-ui/public/settings.html +8 -0
- package/modules/hall-ui/public/settings.js +43 -3
- package/modules/platform-identity/craft-rollup.js +72 -0
- package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
- package/modules/platform-identity/platform-identity.js +12 -6
- package/modules/platform-identity/routes/sso.js +4 -0
- package/modules/platform-identity/tests/platform-identity.mjs +1 -1
- package/modules/provisioning/core-upgrade.js +34 -7
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/seams.js +2 -0
- package/modules/public-landing/public/projects.states.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +58 -0
- package/scripts/gds/autobongos-grade-cap.js +187 -0
- package/scripts/gds/autobongos-loop.js +4 -0
- package/scripts/gds/autobongos-run.js +154 -13
- package/scripts/gds/autobongos-verify.js +211 -14
- package/scripts/gds/provision-core-upgrade.js +14 -2
- package/scripts/gds/provision-repo.js +77 -13
- package/scripts/gds/ship-flow.js +5 -0
- package/scripts/gds/ship-preflight-steps.js +2 -1
- package/scripts/gds/ship.js +10 -0
- package/scripts/gds/update-sweep.js +24 -8
- package/scripts/gds/upgrade-outcome.js +1 -0
- package/src/bongos/auth-admission.js +95 -13
- package/src/bongos/db-kernel.js +1 -1
- package/src/bongos/routes/core-update.js +27 -2
- package/src/bongos/serve-internal.js +14 -0
- package/src/bongos/software-update.js +3 -2
- package/src/module-api.js +1 -1
- package/tests/activity_rollup_order_db.mjs +3 -1
- package/tests/activity_snapshots_db.mjs +2 -0
- package/tests/autobongos_grade_cap.mjs +125 -0
- package/tests/autobongos_loop.mjs +230 -1
- package/tests/autobongos_verify.mjs +239 -3
- package/tests/autonomy_config_idle.mjs +272 -0
- package/tests/core_update_banner.mjs +29 -0
- package/tests/core_upgrade_door.mjs +31 -1
- package/tests/core_upgrade_runner.mjs +20 -0
- package/tests/hall_audit.mjs +10 -1
- package/tests/hall_city.mjs +369 -0
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hub_craft_rollup.mjs +350 -0
- package/tests/profile_rollup_consent.mjs +2 -0
- package/tests/profile_route.mjs +16 -2
- package/tests/provision.mjs +78 -0
- package/tests/settings_main_role.mjs +190 -0
- package/tests/software_update.mjs +56 -1
- package/tests/update_subscription_engine.mjs +55 -3
- package/tests/wizard_physics_more.mjs +19 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// modules/hall-ui/public/city.js — THE GOVERNOR CITY's home, wired (task
|
|
2
|
+
// 1004524, goal 1000125, R02).
|
|
3
|
+
//
|
|
4
|
+
// city-draw.js computes the city as data; this file reads the three feeds,
|
|
5
|
+
// hands them to GovCity.cityModel(), and turns its lists into DOM: SVG paths
|
|
6
|
+
// for the ground and the buildings, one span per parcel (CSS offset-path) and
|
|
7
|
+
// one per pin. It also owns the sun/moon switch and the stage's fit to the
|
|
8
|
+
// window. No innerHTML anywhere: every node is created, and every docket string
|
|
9
|
+
// lands as text or an attribute.
|
|
10
|
+
//
|
|
11
|
+
// THE FEEDS (all GETs, all gated server-side):
|
|
12
|
+
// /api/bongos/government/docket the pins and the one line (task 1004523)
|
|
13
|
+
// /api/bongos/goals the open goals: one district each
|
|
14
|
+
// /api/bongos/goals/rollups each goal's task counts: a building per task
|
|
15
|
+
// A feed that fails leaves its part of the city out (no pins, or no districts)
|
|
16
|
+
// and the line says the docket could not be read; it never draws sample data.
|
|
17
|
+
//
|
|
18
|
+
// THE SWITCH. Light and dark use the hall's own preference ('hall-theme' in
|
|
19
|
+
// localStorage, else the OS setting) and the hall's own attribute
|
|
20
|
+
// (html[data-theme]), so the city opens in the mode every other hall page is
|
|
21
|
+
// in, and a change here carries back to them. The drawing's faces come from
|
|
22
|
+
// the mode, so a toggle redraws the city from the last feeds (no refetch).
|
|
23
|
+
//
|
|
24
|
+
// MOTION is CSS only (city.css); nothing here animates. The scale write on
|
|
25
|
+
// resize is layout, not motion.
|
|
26
|
+
|
|
27
|
+
(function () {
|
|
28
|
+
'use strict';
|
|
29
|
+
var City = window.GovCity;
|
|
30
|
+
var SVG_NS = 'http://www.w3.org/2000/svg';
|
|
31
|
+
var THEME_KEY = 'hall-theme';
|
|
32
|
+
var SUN = 'M7 1.5V3M7 11v1.5M1.5 7H3M11 7h1.5M3.1 3.1l1 1M9.9 9.9l1 1M3.1 10.9l1-1M9.9 4.1l1-1M9.5 7A2.5 2.5 0 1 1 4.5 7A2.5 2.5 0 1 1 9.5 7Z';
|
|
33
|
+
var MOON = 'M11.5 8.6A5 5 0 0 1 5.4 2.5A5 5 0 1 0 11.5 8.6Z';
|
|
34
|
+
var FEEDS = {
|
|
35
|
+
docket: '/api/bongos/government/docket',
|
|
36
|
+
goals: '/api/bongos/goals',
|
|
37
|
+
rollups: '/api/bongos/goals/rollups',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
41
|
+
var media = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
|
|
42
|
+
var data = { docket: null, goals: [], rollups: [], loaded: false };
|
|
43
|
+
|
|
44
|
+
// ---------- light / dark ----------
|
|
45
|
+
// A choice made on this page when storage is blocked (private mode) still
|
|
46
|
+
// holds for this page view.
|
|
47
|
+
var chosen = null;
|
|
48
|
+
function storedTheme() {
|
|
49
|
+
try { var v = window.localStorage.getItem(THEME_KEY); if (v === 'dark' || v === 'light') return v; } catch (_) { /* blocked */ }
|
|
50
|
+
return chosen;
|
|
51
|
+
}
|
|
52
|
+
function effectiveTheme() { return storedTheme() || (media && media.matches ? 'dark' : 'light'); }
|
|
53
|
+
function applyTheme() {
|
|
54
|
+
var t = effectiveTheme();
|
|
55
|
+
document.documentElement.setAttribute('data-theme', t);
|
|
56
|
+
var btn = $('cityMode');
|
|
57
|
+
var label = t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
|
|
58
|
+
if (btn) { btn.setAttribute('aria-label', label); btn.setAttribute('title', label); }
|
|
59
|
+
var icon = $('cityModeIcon');
|
|
60
|
+
if (icon) icon.setAttribute('d', t === 'dark' ? SUN : MOON);
|
|
61
|
+
return t;
|
|
62
|
+
}
|
|
63
|
+
function toggleTheme() {
|
|
64
|
+
var next = effectiveTheme() === 'dark' ? 'light' : 'dark';
|
|
65
|
+
chosen = next;
|
|
66
|
+
try { window.localStorage.setItem(THEME_KEY, next); } catch (_) { /* private mode: this page only */ }
|
|
67
|
+
draw(applyTheme());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------- fit the 1440 x 900 artboard to the window ----------
|
|
71
|
+
// 1440 x 900 is scale 1, the prototype exactly. Narrower or shorter, the
|
|
72
|
+
// city (960 px wide on the artboard) shrinks to fit; wider and taller, it grows.
|
|
73
|
+
function fit() {
|
|
74
|
+
var stage = $('cityStage');
|
|
75
|
+
if (!stage) return;
|
|
76
|
+
var w = window.innerWidth || 1440, h = window.innerHeight || 900;
|
|
77
|
+
var s = Math.min(w / 1000, h / 900);
|
|
78
|
+
stage.style.setProperty('--city-scale', String(Math.round(s * 1000) / 1000));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------- the feeds ----------
|
|
82
|
+
function getJson(url) {
|
|
83
|
+
return fetch(url, { credentials: 'same-origin', headers: { accept: 'application/json' } })
|
|
84
|
+
.then(function (r) { return r && r.ok ? r.json() : null; })
|
|
85
|
+
.catch(function () { return null; });
|
|
86
|
+
}
|
|
87
|
+
function load() {
|
|
88
|
+
return Promise.all([getJson(FEEDS.docket), getJson(FEEDS.goals), getJson(FEEDS.rollups)]).then(function (res) {
|
|
89
|
+
data.docket = res[0] && Array.isArray(res[0].matters) ? res[0] : null;
|
|
90
|
+
data.goals = res[1] && Array.isArray(res[1].goals) ? res[1].goals : [];
|
|
91
|
+
data.rollups = res[2] && Array.isArray(res[2].rollups) ? res[2].rollups : [];
|
|
92
|
+
data.loaded = true;
|
|
93
|
+
draw(effectiveTheme());
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------- drawing ----------
|
|
98
|
+
function clear(el) { while (el && el.firstChild) el.removeChild(el.firstChild); }
|
|
99
|
+
function pathEl(p) {
|
|
100
|
+
var el = document.createElementNS(SVG_NS, 'path');
|
|
101
|
+
el.setAttribute('d', p.d);
|
|
102
|
+
el.setAttribute('fill', p.f);
|
|
103
|
+
el.setAttribute('stroke', p.s);
|
|
104
|
+
if (p.dash && p.dash !== 'none') el.setAttribute('stroke-dasharray', p.dash);
|
|
105
|
+
el.setAttribute('stroke-width', String(p.w || 1));
|
|
106
|
+
return el;
|
|
107
|
+
}
|
|
108
|
+
function span(cls) { var el = document.createElement('span'); el.className = cls; return el; }
|
|
109
|
+
|
|
110
|
+
function drawPin(pin) {
|
|
111
|
+
var el = span('city-pin city-pin--' + pin.state);
|
|
112
|
+
el.setAttribute('data-matter', pin.key);
|
|
113
|
+
el.setAttribute('data-building', pin.building);
|
|
114
|
+
el.style.left = pin.x + 'px';
|
|
115
|
+
el.style.top = pin.y + 'px';
|
|
116
|
+
el.style.height = pin.h + 'px';
|
|
117
|
+
el.style.animationDelay = pin.delay + 's';
|
|
118
|
+
var head = span('city-pin__head');
|
|
119
|
+
if (pin.state === 'needs') head.appendChild(span('city-pin__ring'));
|
|
120
|
+
var svg = document.createElementNS(SVG_NS, 'svg');
|
|
121
|
+
svg.setAttribute('width', '14');
|
|
122
|
+
svg.setAttribute('height', '14');
|
|
123
|
+
var fill = document.createElementNS(SVG_NS, 'path');
|
|
124
|
+
fill.setAttribute('d', pin.gf || '');
|
|
125
|
+
var line = document.createElementNS(SVG_NS, 'path');
|
|
126
|
+
line.setAttribute('d', pin.gl || '');
|
|
127
|
+
svg.appendChild(fill);
|
|
128
|
+
svg.appendChild(line);
|
|
129
|
+
head.appendChild(svg);
|
|
130
|
+
el.appendChild(head);
|
|
131
|
+
el.appendChild(span('city-pin__stem'));
|
|
132
|
+
return el;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function drawParcel(pc) {
|
|
136
|
+
var el = span(pc.out ? 'city-parcel city-parcel--out' : 'city-parcel');
|
|
137
|
+
el.style.offsetPath = "path('" + pc.d + "')";
|
|
138
|
+
el.style.animationDuration = pc.dur + 's';
|
|
139
|
+
el.style.animationDelay = pc.delay + 's';
|
|
140
|
+
return el;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function draw(mode) {
|
|
144
|
+
var model = City.cityModel({ docket: data.docket, goals: data.goals, rollups: data.rollups, mode: mode });
|
|
145
|
+
var flat = $('cityFlat'), blds = $('cityBlds'), parcels = $('cityParcels'), pins = $('cityPins');
|
|
146
|
+
clear(flat); clear(blds); clear(parcels); clear(pins);
|
|
147
|
+
model.flat.forEach(function (p) { flat.appendChild(pathEl(p)); });
|
|
148
|
+
model.blds.forEach(function (b) {
|
|
149
|
+
var g = document.createElementNS(SVG_NS, 'g');
|
|
150
|
+
g.setAttribute('data-key', b.key);
|
|
151
|
+
b.polys.forEach(function (p) { g.appendChild(pathEl(p)); });
|
|
152
|
+
blds.appendChild(g);
|
|
153
|
+
});
|
|
154
|
+
model.parcels.forEach(function (pc) { parcels.appendChild(drawParcel(pc)); });
|
|
155
|
+
model.pins.forEach(function (pin) { pins.appendChild(drawPin(pin)); });
|
|
156
|
+
var lineEl = $('cityLine');
|
|
157
|
+
if (lineEl && data.loaded) lineEl.textContent = model.line;
|
|
158
|
+
var stage = $('cityStage');
|
|
159
|
+
if (stage) stage.setAttribute('aria-label', model.label);
|
|
160
|
+
return model;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function boot() {
|
|
164
|
+
var start = $('cityStart');
|
|
165
|
+
if (start) start.setAttribute('href', City.STREET_HREF);
|
|
166
|
+
var btn = $('cityMode');
|
|
167
|
+
if (btn) btn.addEventListener('click', toggleTheme);
|
|
168
|
+
if (media && media.addEventListener) media.addEventListener('change', function () { if (!storedTheme()) draw(applyTheme()); });
|
|
169
|
+
window.addEventListener('resize', fit);
|
|
170
|
+
fit();
|
|
171
|
+
draw(applyTheme());
|
|
172
|
+
return load();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
window.GovCityPage = { boot: boot, draw: draw, load: load, fit: fit, FEEDS: FEEDS, THEME_KEY: THEME_KEY };
|
|
176
|
+
window.GovCityPage.ready = boot();
|
|
177
|
+
}());
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "THE GOVERNOR CITY's home (task 1004524, goal 1000125) through modules/ui-design/kit/render.js. The page draws GET /government/docket, GET /goals and GET /goals/rollups; against the kit's own stub none exists, so every state renders the city with no districts and no pins and the line saying the docket could not be read: a real state the floors must hold in. To look at the populated city, run it against the hall-preview harness, whose government__docket.json fixture carries the reference design's seven sample matters (3 need you, 2 moving, 2 settled) and whose goals fixtures carry the open goals:\n\n node scripts/hall-preview/server.js --port 4624 --fixture-me\n node modules/ui-design/kit/render.js --states modules/hall-ui/public/city.states.json --base-in http://localhost:4624 --base-out http://localhost:4624\n\nThe page follows the OS mode until the switch is pressed (then the hall's own 'hall-theme' preference), so the kit's dark/light emulation renders both modes.",
|
|
3
|
+
"page": "/builders/city",
|
|
4
|
+
"surface": "hall-ui",
|
|
5
|
+
"stub": { "prefix": "/builders/" },
|
|
6
|
+
"modeQuery": false,
|
|
7
|
+
"ignoreRequests": ["/api/(gds|bongos)/", "fonts\\.g(oogleapis|static)\\.com"],
|
|
8
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b403\\b", "\\b404\\b", "Unauthorized", "Forbidden", "Not Found", "load failed", "fonts\\.g(oogleapis|static)\\.com"],
|
|
9
|
+
"states": {
|
|
10
|
+
"city": {
|
|
11
|
+
"auth": true,
|
|
12
|
+
"_": "The home: the wordmark, the switch, the city, start governing and the one line.",
|
|
13
|
+
"actions": [["wait", 900]],
|
|
14
|
+
"expect": { "visible": ["#cityStage", "#cityStart", "#cityLine", "#cityMode"], "href": [["#cityStart", "/builders/street"]] }
|
|
15
|
+
},
|
|
16
|
+
"switched": {
|
|
17
|
+
"auth": true,
|
|
18
|
+
"_": "The sun/moon switch pressed once: the other mode, the city redrawn in it.",
|
|
19
|
+
"actions": [["wait", 600], ["click", "#cityMode"], ["wait", 400]],
|
|
20
|
+
"expect": { "visible": ["#cityStage", "#cityStart"] }
|
|
21
|
+
},
|
|
22
|
+
"focus": {
|
|
23
|
+
"auth": true,
|
|
24
|
+
"_": "Keyboard: the start button focused, its ring drawn.",
|
|
25
|
+
"actions": [["wait", 600], ["focus", "#cityStart"], ["wait", 100]],
|
|
26
|
+
"expect": { "activeId": "cityStart" }
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"reducedMotion": { "auth": true, "actions": [["wait", 600]] }
|
|
30
|
+
}
|
|
@@ -259,8 +259,10 @@
|
|
|
259
259
|
// decides what dead means and the route attaches it — so this page and any
|
|
260
260
|
// future alert cannot drift into two definitions of the same word. These are
|
|
261
261
|
// only the order to pick the runner to describe in, and the colour of its dot.
|
|
262
|
-
|
|
263
|
-
|
|
262
|
+
// idle_config (task 1004539): talking, but idle on a setting the owner can fix —
|
|
263
|
+
// it carries the fix as `remedy`, and draws as a warning, never as "ok".
|
|
264
|
+
const HEALTH_RANK = { alive: 0, late: 1, idle_config: 2, restarting: 3, dead: 4, never_seen: 5 };
|
|
265
|
+
const HEALTH_DOT = { alive: 'ok', late: 'ok', idle_config: 'warn', restarting: 'info', dead: 'danger', never_seen: 'info' };
|
|
264
266
|
|
|
265
267
|
// The panel is drawn from both reads at once; each is kept so a write that only
|
|
266
268
|
// reloads one of them redraws with the other's last answer.
|
|
@@ -284,7 +286,7 @@
|
|
|
284
286
|
// `project`, and then there is no second layer to check.
|
|
285
287
|
function readiness(runner, fence, mine) {
|
|
286
288
|
const h = (runner && runner.health) || {};
|
|
287
|
-
const connected = ['alive', 'late', 'restarting'].includes(h.state);
|
|
289
|
+
const connected = ['alive', 'late', 'restarting', 'idle_config'].includes(h.state);
|
|
288
290
|
const goals = (fence && fence.goals) || [];
|
|
289
291
|
const items = [];
|
|
290
292
|
if (!runner) {
|
|
@@ -316,6 +318,11 @@
|
|
|
316
318
|
if (mine && fence && fence.enabled && mine.enabled === false && (mine.goals || []).length) {
|
|
317
319
|
items.push({ key: 'mine-stop', state: 'todo', text: 'Your runner is held', hint: mine.paused_reason || 'It is not allowed to take work right now.' });
|
|
318
320
|
}
|
|
321
|
+
// The runner's own word that a setting is holding it (task 1004539) — the one
|
|
322
|
+
// the fence read cannot see: an unreadable fence or a gauge with no reading.
|
|
323
|
+
if (h.state === 'idle_config') {
|
|
324
|
+
items.push({ key: 'config-idle', state: 'todo', text: h.why ? `Idle because ${h.why}` : 'Idle because of a setting', hint: h.remedy || '' });
|
|
325
|
+
}
|
|
319
326
|
return { items, ready: !items.some((i) => i.state === 'todo') };
|
|
320
327
|
}
|
|
321
328
|
|
|
@@ -535,7 +542,7 @@
|
|
|
535
542
|
: 'has picked no goals, so their runner takes no work';
|
|
536
543
|
const beats = (b.runners || []).map((r) => {
|
|
537
544
|
const h = r.health || {};
|
|
538
|
-
const label = { dead: 'not checking in', late: 'quiet', alive: 'checking in', restarting: 'restarting' }[h.state] || 'unknown';
|
|
545
|
+
const label = { dead: 'not checking in', late: 'quiet', alive: 'checking in', restarting: 'restarting', idle_config: `idle on a setting — ${h.remedy || 'see Settings'}` }[h.state] || 'unknown';
|
|
539
546
|
return `<span class="ov-fact"><span class="rn-dot rn-dot--sm rn-dot--${HEALTH_DOT[h.state] || 'info'}" aria-hidden="true"></span> ${escapeHtml(h.host || r.host || '')} ${escapeHtml(label)}</span>`;
|
|
540
547
|
}).join('');
|
|
541
548
|
const paused = b.owner_paused === true;
|
|
@@ -276,3 +276,7 @@
|
|
|
276
276
|
@media (max-width: 640px) {
|
|
277
277
|
.pg-ba { grid-template-columns: minmax(0, 1fr); }
|
|
278
278
|
}
|
|
279
|
+
|
|
280
|
+
/* The cross-project section's second band (task 1004440): each craft's own
|
|
281
|
+
numbers under the totals, the same glass, held apart by the scroll's own gap. */
|
|
282
|
+
#xp-scroll .stat-row--craft { margin-top: 12px; }
|
|
@@ -331,6 +331,29 @@
|
|
|
331
331
|
return `<tr><td>${nameCell}</td><td>${held}</td></tr>`;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
// Each craft's own numbers across every project (task 1004440 / WA6.RP10): an
|
|
335
|
+
// artist's approved pages and an ideator's thoughts, so neither reads on the hub
|
|
336
|
+
// as an engineer with nothing shipped. The hub sends a craft only once the
|
|
337
|
+
// builder has earned credits in it, so an absent craft has no tile. A count no
|
|
338
|
+
// project could report is a dash, never 0 ("could not ask" is not "nothing
|
|
339
|
+
// there"). The words are the role-shaped profile's own (profile-roles.js).
|
|
340
|
+
function xpCount(n) {
|
|
341
|
+
return Number.isInteger(n) && n >= 0 ? fmtNum(n) : '—';
|
|
342
|
+
}
|
|
343
|
+
function xpCraftTiles(crafts) {
|
|
344
|
+
if (!crafts || typeof crafts !== 'object') return '';
|
|
345
|
+
const out = [];
|
|
346
|
+
const artist = crafts.artist;
|
|
347
|
+
if (artist && typeof artist === 'object') out.push(tile('Pages approved', xpCount(artist.pages_approved), 'as an artist'));
|
|
348
|
+
const ideator = crafts.ideator;
|
|
349
|
+
if (ideator && typeof ideator === 'object') {
|
|
350
|
+
out.push(tile('Thoughts filed', xpCount(ideator.filed), 'as an ideator'));
|
|
351
|
+
const ratified = Number.isInteger(ideator.ratified) ? `${fmtNum(ideator.ratified)} ratified by the board` : '';
|
|
352
|
+
out.push(tile('Full Ideas', xpCount(ideator.full_ideas), ratified));
|
|
353
|
+
}
|
|
354
|
+
return out.length ? `<div class="stat-row stat-row--craft">${out.join('')}</div>` : '';
|
|
355
|
+
}
|
|
356
|
+
|
|
334
357
|
function renderCrossProject(data, selfId) {
|
|
335
358
|
const section = $('#xp-scroll');
|
|
336
359
|
const body = $('#xp-body');
|
|
@@ -357,7 +380,8 @@
|
|
|
357
380
|
tile('Works shipped', fmtNum(t.works_shipped)) +
|
|
358
381
|
tile('Credits', fmtNum(t.credits)) +
|
|
359
382
|
tile('Karma', fmtNum(t.karma)) +
|
|
360
|
-
`</div
|
|
383
|
+
`</div>` +
|
|
384
|
+
xpCraftTiles(cp.crafts);
|
|
361
385
|
const discs = Array.isArray(cp.disciplines) ? cp.disciplines.filter((d) => typeof d === 'string') : [];
|
|
362
386
|
const metaBits = [];
|
|
363
387
|
if (discs.length) metaBits.push(`${window.OTB.roleAxisLabel()}s: ${discs.map((d) => window.OTB.roleLabel(d)).join(', ')}`);
|
|
@@ -38,6 +38,14 @@
|
|
|
38
38
|
const project = fence.project || {};
|
|
39
39
|
const scope = fence.scope || {};
|
|
40
40
|
const out = [];
|
|
41
|
+
// First, because it is the one that has already cost time: a runner of yours
|
|
42
|
+
// that has been idle on a setting past its grace (task 1004539). The verdict
|
|
43
|
+
// and the fix are the server's (runner-health.js); this only draws them.
|
|
44
|
+
for (const r of myRunners) {
|
|
45
|
+
const h = r.health || {};
|
|
46
|
+
if (h.state !== 'idle_config') continue;
|
|
47
|
+
out.push(`<strong>Your runner${h.host ? ` on ${escapeHtml(h.host)}` : ''} is idle</strong> because ${escapeHtml(h.why || 'of a setting')}. ${escapeHtml(h.remedy || '')}`);
|
|
48
|
+
}
|
|
41
49
|
if (project.enabled !== true) {
|
|
42
50
|
out.push(`The project's runner switch is off right now${project.paused_reason ? `: ${escapeHtml(project.paused_reason)}` : ''}. Your choices are kept and apply when it is back on.`);
|
|
43
51
|
}
|
|
@@ -62,6 +70,16 @@
|
|
|
62
70
|
}
|
|
63
71
|
const goalLabel = (id) => goalNames.get(Number(id)) || `Goal ${id}`;
|
|
64
72
|
|
|
73
|
+
// The caller's own runners, with the server's health verdict attached. Best-
|
|
74
|
+
// effort like the names: a failed read just means no runner warning is drawn.
|
|
75
|
+
let myRunners = [];
|
|
76
|
+
async function loadMyRunners() {
|
|
77
|
+
try {
|
|
78
|
+
const res = await api.request('GET', `${API}/autonomy/runners`);
|
|
79
|
+
if (res.ok && res.data && Array.isArray(res.data.runners)) myRunners = res.data.runners;
|
|
80
|
+
} catch (_) { /* the picker works without it */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
65
83
|
function render(fence) {
|
|
66
84
|
const body = $('#autobongos-body');
|
|
67
85
|
if (!body) return;
|
|
@@ -138,7 +156,7 @@
|
|
|
138
156
|
const section = $('#autobongos-scroll');
|
|
139
157
|
if (!section) return;
|
|
140
158
|
try {
|
|
141
|
-
const [fence] = await Promise.all([load(), loadGoalNames()]);
|
|
159
|
+
const [fence] = await Promise.all([load(), loadGoalNames(), loadMyRunners()]);
|
|
142
160
|
if (!fence) return;
|
|
143
161
|
render(fence);
|
|
144
162
|
section.hidden = false;
|
|
@@ -327,3 +327,29 @@ select.voices__select { min-width: 140px; }
|
|
|
327
327
|
text-underline-offset: 3px;
|
|
328
328
|
}
|
|
329
329
|
.su-entries a:hover { color: var(--ink); text-decoration-color: currentColor; }
|
|
330
|
+
|
|
331
|
+
/* ---- MAIN ROLE (task 1004541) -------------------------------------------- */
|
|
332
|
+
/* A sub-group of Preferred roles: the shell's chip control with a RADIO in it.
|
|
333
|
+
The 24px target and 14px drawn mark are panel.css's; only the shape changes
|
|
334
|
+
(a ring, and a dot when chosen), so a choice of one never reads as a tick. */
|
|
335
|
+
.craft-main { border: 0; margin: var(--sp-sm) 0 0; padding: 0; min-width: 0; }
|
|
336
|
+
.craft-main__h {
|
|
337
|
+
padding: 0;
|
|
338
|
+
font-family: var(--label-face);
|
|
339
|
+
font-size: var(--label-size);
|
|
340
|
+
letter-spacing: var(--label-space);
|
|
341
|
+
text-transform: var(--label-case);
|
|
342
|
+
color: var(--ink-faint);
|
|
343
|
+
}
|
|
344
|
+
.craft-main__lede { margin: 6px 0 0; font-size: 13.5px; color: var(--ink-soft); max-width: 62ch; }
|
|
345
|
+
.craft-main .chip-ctl input::before { border-radius: 50%; }
|
|
346
|
+
.craft-main .chip-ctl input:checked::after {
|
|
347
|
+
left: 9px;
|
|
348
|
+
top: 9px;
|
|
349
|
+
width: 6px;
|
|
350
|
+
height: 6px;
|
|
351
|
+
border: 0;
|
|
352
|
+
border-radius: 50%;
|
|
353
|
+
background: var(--accent-on);
|
|
354
|
+
transform: none;
|
|
355
|
+
}
|
|
@@ -118,6 +118,14 @@
|
|
|
118
118
|
choices — pick one, several, or none.
|
|
119
119
|
</p>
|
|
120
120
|
<div class="craft-chips" id="craft-chips"></div>
|
|
121
|
+
<!-- MAIN ROLE (task 1004541): which of the roles above leads your
|
|
122
|
+
profile, its tabs and your leaderboard entry. Saved with the
|
|
123
|
+
chips, in the same PATCH (main_discipline). -->
|
|
124
|
+
<fieldset class="craft-main" id="craft-main">
|
|
125
|
+
<legend class="craft-main__h">Main role</legend>
|
|
126
|
+
<p class="craft-main__lede">Your main role leads your profile and your leaderboard entry. Let your work decide, and it is the role you have earned the most in.</p>
|
|
127
|
+
<div class="chips" id="craft-main-opts"></div>
|
|
128
|
+
</fieldset>
|
|
121
129
|
<div class="craft-actions">
|
|
122
130
|
<button type="button" class="craft-save" id="craft-save">Save</button>
|
|
123
131
|
<span class="settings-status" id="craft-status" role="status" aria-live="polite"></span>
|
|
@@ -516,7 +516,10 @@
|
|
|
516
516
|
|
|
517
517
|
// Current selection mutated in place as the builder toggles chips; saved on
|
|
518
518
|
// the "Save" button (the hall's profile card behaviour) via PATCH.
|
|
519
|
-
|
|
519
|
+
// `main` (task 1004541) is the builder's chosen MAIN role, one of the ticked
|
|
520
|
+
// roles, or null for "let my work decide" (the profile then leads with the
|
|
521
|
+
// craft earned most in — src/bongos/role-stats.js resolveMainRole).
|
|
522
|
+
const craft = { selected: new Set(), main: null };
|
|
520
523
|
|
|
521
524
|
function renderCraft() {
|
|
522
525
|
const wrap = $('#craft-chips');
|
|
@@ -529,6 +532,29 @@
|
|
|
529
532
|
`${escapeHtml(window.OTB.roleLabel(d))}</label>`
|
|
530
533
|
);
|
|
531
534
|
}).join('');
|
|
535
|
+
renderCraftMain();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// The main-role choice: "Let my work decide", then each role ticked above. A
|
|
539
|
+
// role can only lead once it is one of your roles (the server refuses
|
|
540
|
+
// otherwise), so unticking the main role hands the choice back to your work —
|
|
541
|
+
// the same thing the server does when that role leaves the set.
|
|
542
|
+
const MAIN_BY_WORK = 'Let my work decide';
|
|
543
|
+
function renderCraftMain() {
|
|
544
|
+
const wrap = $('#craft-main-opts');
|
|
545
|
+
if (!wrap) return;
|
|
546
|
+
if (craft.main && !craft.selected.has(craft.main)) craft.main = null;
|
|
547
|
+
const opts = [{ value: '', label: MAIN_BY_WORK }].concat(
|
|
548
|
+
DISCIPLINES.filter((d) => craft.selected.has(d)).map((d) => ({ value: d, label: window.OTB.roleLabel(d) })),
|
|
549
|
+
);
|
|
550
|
+
wrap.innerHTML = opts.map((o) => {
|
|
551
|
+
const on = (craft.main || '') === o.value;
|
|
552
|
+
return (
|
|
553
|
+
`<label class="chip-ctl craft-main__opt">` +
|
|
554
|
+
`<input type="radio" name="craft-main" class="craft-main-pick" value="${escapeHtml(o.value)}"${on ? ' checked' : ''}>` +
|
|
555
|
+
`${escapeHtml(o.label)}</label>`
|
|
556
|
+
);
|
|
557
|
+
}).join('');
|
|
532
558
|
}
|
|
533
559
|
|
|
534
560
|
async function saveCraft() {
|
|
@@ -538,7 +564,10 @@
|
|
|
538
564
|
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
|
|
539
565
|
if (status) status.textContent = '';
|
|
540
566
|
try {
|
|
541
|
-
const
|
|
567
|
+
const main = craft.main && selected.includes(craft.main) ? craft.main : null;
|
|
568
|
+
const res = await api.request('PATCH', `${API}/me/disciplines`, {
|
|
569
|
+
body: { preferred_disciplines: selected, main_discipline: main },
|
|
570
|
+
});
|
|
542
571
|
if (res.status === 401) { toAuth(); return; }
|
|
543
572
|
if (!res.ok) throw new Error(`disciplines PATCH → ${res.status}`);
|
|
544
573
|
const data = res.data;
|
|
@@ -547,8 +576,10 @@
|
|
|
547
576
|
? data.builder.preferred_disciplines
|
|
548
577
|
: selected;
|
|
549
578
|
craft.selected = new Set(prefs.filter((d) => DISCIPLINES.includes(d)));
|
|
579
|
+
craft.main = data?.builder && 'main_discipline' in data.builder ? (data.builder.main_discipline || null) : main;
|
|
550
580
|
renderCraft();
|
|
551
|
-
const
|
|
581
|
+
const leads = craft.main ? ` Main role: ${window.OTB.roleLabel(craft.main)}.` : '';
|
|
582
|
+
const msg = (selected.length ? `Preferred work: ${selected.join(', ')}.` : 'Preference cleared.') + leads;
|
|
552
583
|
if (status) status.textContent = msg;
|
|
553
584
|
toast(msg);
|
|
554
585
|
} catch (err) {
|
|
@@ -570,6 +601,14 @@
|
|
|
570
601
|
else craft.selected.delete(box.value);
|
|
571
602
|
const chip = box.closest('.craft-chip');
|
|
572
603
|
if (chip) chip.dataset.on = box.checked ? '1' : '0';
|
|
604
|
+
renderCraftMain();
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
const mainWrap = $('#craft-main-opts');
|
|
608
|
+
if (mainWrap) {
|
|
609
|
+
mainWrap.addEventListener('change', (e) => {
|
|
610
|
+
const pick = e.target.closest('.craft-main-pick');
|
|
611
|
+
if (pick) craft.main = pick.value || null;
|
|
573
612
|
});
|
|
574
613
|
}
|
|
575
614
|
const btn = $('#craft-save');
|
|
@@ -1176,6 +1215,7 @@
|
|
|
1176
1215
|
// Seed craft state from the /me payload, then render + reveal that section.
|
|
1177
1216
|
const prefs = Array.isArray(builder.preferred_disciplines) ? builder.preferred_disciplines : [];
|
|
1178
1217
|
craft.selected = new Set(prefs.filter((d) => DISCIPLINES.includes(d)));
|
|
1218
|
+
craft.main = typeof builder.main_discipline === 'string' ? builder.main_discipline : null;
|
|
1179
1219
|
renderCraft();
|
|
1180
1220
|
wireCraft();
|
|
1181
1221
|
$('#craft-scroll').hidden = false;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// modules/platform-identity/craft-rollup.js — each craft by its own work, on the
|
|
2
|
+
// hub (task 1004440 / WA6.RP10, goal 1000095, criterion
|
|
3
|
+
// wa6-roles-read-true-in-public).
|
|
4
|
+
//
|
|
5
|
+
// A reporting instance sends, per craft the builder has EARNED credits in, that
|
|
6
|
+
// craft's headline counts (src/bongos/auth-admission.js readCraftRollup):
|
|
7
|
+
//
|
|
8
|
+
// engineer { shipped, credits }
|
|
9
|
+
// artist { pages_approved, credits }
|
|
10
|
+
// ideator { filed, full_ideas, ratified, credits }
|
|
11
|
+
//
|
|
12
|
+
// Two halves live here, next to each other so they cannot disagree on the shape:
|
|
13
|
+
//
|
|
14
|
+
// sanitizeCrafts — what upsertActivity BINDS. The report is third-party
|
|
15
|
+
// self-report (ADR 0171 D7), so the shape and the caps are the hub's, not
|
|
16
|
+
// the sender's: only the three crafts, only the named counts, each a
|
|
17
|
+
// non-negative integer under a ceiling or null. Anything else is dropped.
|
|
18
|
+
// A count is never text, so nothing here can carry a title or wording.
|
|
19
|
+
//
|
|
20
|
+
// sumCrafts — the cross-project aggregate getRollupForBuilder returns. Summed
|
|
21
|
+
// over EVERY project row, named or not, exactly as the totals are: "the
|
|
22
|
+
// aggregate is not the leak, the name is" (ADR 0171 D2). It adds no field a
|
|
23
|
+
// hidden or private account could reach — it rides the same rollup
|
|
24
|
+
// predicate as credits and works shipped (account-visibility.js), and the
|
|
25
|
+
// recruiter sliver is untouched (ADR 0215: a ceiling, not a step).
|
|
26
|
+
//
|
|
27
|
+
// A craft appears in the sum when any project reported it. A count is null in the
|
|
28
|
+
// sum only when no project could report it ("could not ask"), never because one
|
|
29
|
+
// project could not while another could.
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const CRAFT_FIELDS = Object.freeze({
|
|
33
|
+
engineer: Object.freeze(['shipped', 'credits']),
|
|
34
|
+
artist: Object.freeze(['pages_approved', 'credits']),
|
|
35
|
+
ideator: Object.freeze(['filed', 'full_ideas', 'ratified', 'credits']),
|
|
36
|
+
});
|
|
37
|
+
// Far above any honest count, low enough that a sum over every project stays a
|
|
38
|
+
// safe integer and a hostile report cannot dominate a profile by one number.
|
|
39
|
+
const MAX_CRAFT_COUNT = 10_000_000;
|
|
40
|
+
|
|
41
|
+
function cleanCount(v) {
|
|
42
|
+
return Number.isInteger(v) && v >= 0 && v <= MAX_CRAFT_COUNT ? v : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sanitizeCrafts(raw) {
|
|
46
|
+
const out = {};
|
|
47
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
|
48
|
+
for (const [craft, fields] of Object.entries(CRAFT_FIELDS)) {
|
|
49
|
+
const block = raw[craft];
|
|
50
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) continue;
|
|
51
|
+
const clean = {};
|
|
52
|
+
for (const f of fields) clean[f] = cleanCount(block[f]);
|
|
53
|
+
out[craft] = clean;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sumCrafts(rows) {
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
61
|
+
const crafts = sanitizeCrafts(row);
|
|
62
|
+
for (const [craft, block] of Object.entries(crafts)) {
|
|
63
|
+
const acc = out[craft] || (out[craft] = Object.fromEntries(CRAFT_FIELDS[craft].map((f) => [f, null])));
|
|
64
|
+
for (const f of CRAFT_FIELDS[craft]) {
|
|
65
|
+
if (block[f] != null) acc[f] = (acc[f] || 0) + block[f];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { sanitizeCrafts, sumCrafts };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- platform_identity_029_activity_crafts.sql — each craft's own numbers on a
|
|
2
|
+
-- builder's activity row (task 1004440 / WA6.RP10, goal 1000095).
|
|
3
|
+
--
|
|
4
|
+
-- WHY. A rollup carried one ship count and a "Roles" list taken from the declared
|
|
5
|
+
-- preference, so on the hub profile an artist with forty approved pages, or an
|
|
6
|
+
-- ideator whose ideas the board ratified, read as an engineer with nothing
|
|
7
|
+
-- shipped. A reporting instance now sends, for each craft the builder has earned
|
|
8
|
+
-- credits in, that craft's headline counts (an artist's approved pages; an
|
|
9
|
+
-- ideator's filed, Full and ratified ideas; an engineer's shipped works).
|
|
10
|
+
--
|
|
11
|
+
-- WHAT. One jsonb column, the sanitised `crafts` object (craft-rollup.js shapes and
|
|
12
|
+
-- caps it before it is bound — never the raw body). '{}' is "no craft numbers
|
|
13
|
+
-- reported": every row written before this column, and every report from an
|
|
14
|
+
-- instance too old to send them. Counts only, never a title or wording.
|
|
15
|
+
--
|
|
16
|
+
-- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
|
|
17
|
+
|
|
18
|
+
BEGIN;
|
|
19
|
+
|
|
20
|
+
ALTER TABLE platform_identity_builder_project_activity
|
|
21
|
+
ADD COLUMN IF NOT EXISTS crafts jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
22
|
+
|
|
23
|
+
COMMIT;
|
|
@@ -27,6 +27,7 @@ const { urlWithinOrigin } = require('./origin-containment');
|
|
|
27
27
|
// The windowed data plane's append-only snapshot history (task 1002977, community
|
|
28
28
|
// spec D2) — one call from upsertActivity below; the mechanism itself lives there.
|
|
29
29
|
const { recordActivitySnapshot } = require('./activity-snapshots');
|
|
30
|
+
const { sanitizeCrafts, sumCrafts } = require('./craft-rollup');
|
|
30
31
|
|
|
31
32
|
const { pool: defaultPool } = api;
|
|
32
33
|
const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
|
|
@@ -665,7 +666,7 @@ function sanitizeRecentShips(raw, clientOrigin) {
|
|
|
665
666
|
// origin-containment.js:urlWithinOrigin. `asOf` is when the instance read these
|
|
666
667
|
// totals (task 1004268, migration 026): an older report never overwrites a newer
|
|
667
668
|
// one; unstamped (an older instance) keeps last-writer-wins; a future stamp is clamped.
|
|
668
|
-
async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], clientOrigin = null, asOf = null }, { pool = defaultPool } = {}) {
|
|
669
|
+
async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], crafts = null, clientOrigin = null, asOf = null }, { pool = defaultPool } = {}) {
|
|
669
670
|
// api.LIVE_RANK_LADDER is a published doorway export (src/module-api.js, a
|
|
670
671
|
// lazy getter over db-kernel's canonical four-rank ladder).
|
|
671
672
|
const safeRank = api.LIVE_RANK_LADDER.includes(rank) ? rank : null;
|
|
@@ -673,24 +674,26 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
|
|
|
673
674
|
.filter((d) => typeof d === 'string' && d.length > 0 && d.length <= MAX_DISCIPLINE_LENGTH)
|
|
674
675
|
.slice(0, MAX_ROLLUP_DISCIPLINES);
|
|
675
676
|
const ships = sanitizeRecentShips(recentShips, clientOrigin);
|
|
677
|
+
// Each craft's own counts (task 1004440), shaped and capped here — see craft-rollup.js.
|
|
678
|
+
const craftCounts = sanitizeCrafts(crafts);
|
|
676
679
|
// One transaction with the snapshot: the upsert's row lock queues racing reports (task 1004244).
|
|
677
680
|
return api.withTx(async (client) => {
|
|
678
681
|
const { rows } = await client.query(
|
|
679
682
|
`INSERT INTO platform_identity_builder_project_activity
|
|
680
|
-
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at, reported_as_of)
|
|
683
|
+
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at, reported_as_of, crafts)
|
|
681
684
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now(),
|
|
682
|
-
CASE WHEN $9::timestamptz IS NULL THEN NULL ELSE LEAST($9::timestamptz, now()) END)
|
|
685
|
+
CASE WHEN $9::timestamptz IS NULL THEN NULL ELSE LEAST($9::timestamptz, now()) END, $10::jsonb)
|
|
683
686
|
ON CONFLICT (client_id, github_id) DO UPDATE SET
|
|
684
687
|
credits = EXCLUDED.credits, tasks_shipped = EXCLUDED.tasks_shipped, karma = EXCLUDED.karma,
|
|
685
688
|
rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
|
|
686
|
-
recent_ships = EXCLUDED.recent_ships, reported_as_of = EXCLUDED.reported_as_of,
|
|
689
|
+
recent_ships = EXCLUDED.recent_ships, reported_as_of = EXCLUDED.reported_as_of, crafts = EXCLUDED.crafts,
|
|
687
690
|
last_active = now(), updated_at = now()
|
|
688
691
|
WHERE EXCLUDED.reported_as_of IS NULL OR platform_identity_builder_project_activity.reported_as_of IS NULL
|
|
689
692
|
OR EXCLUDED.reported_as_of >= platform_identity_builder_project_activity.reported_as_of
|
|
690
693
|
RETURNING id, client_id, github_id`,
|
|
691
694
|
// jsonb is bound as a JSON STRING: node-postgres serializes a JS array as a
|
|
692
695
|
// Postgres ARRAY literal, which the jsonb column would reject.
|
|
693
|
-
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships), asOf],
|
|
696
|
+
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships), asOf, JSON.stringify(craftCounts)],
|
|
694
697
|
);
|
|
695
698
|
// Windowed data plane (task 1002977) — best-effort, see activity-snapshots.js.
|
|
696
699
|
await recordActivitySnapshot({ clientId, githubId }, { client });
|
|
@@ -804,7 +807,7 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
|
804
807
|
);
|
|
805
808
|
const agg = aggRows[0];
|
|
806
809
|
const { rows: projRows } = await pool.query(
|
|
807
|
-
`SELECT p.name, p.origin, a.rank_held, a.recent_ships,
|
|
810
|
+
`SELECT p.name, p.origin, a.rank_held, a.recent_ships, a.crafts,
|
|
808
811
|
(p.id IS NOT NULL AND ${onMapSql('p')} AND p.visibility = 'public') AS is_public
|
|
809
812
|
FROM platform_identity_builder_project_activity a
|
|
810
813
|
LEFT JOIN platform_identity_sso_clients c ON c.client_id = a.client_id
|
|
@@ -834,6 +837,9 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
|
834
837
|
karma: agg.karma,
|
|
835
838
|
},
|
|
836
839
|
disciplines: agg.disciplines || [],
|
|
840
|
+
// Each craft's own numbers, summed over every project — named or not, like
|
|
841
|
+
// the totals above (task 1004440; craft-rollup.js says why that is safe).
|
|
842
|
+
crafts: sumCrafts(projRows.map((r) => r.crafts)),
|
|
837
843
|
last_active: agg.last_active,
|
|
838
844
|
projects: named,
|
|
839
845
|
more_projects: projRows.length - named.length,
|