@bongos/core 1.23.19 → 1.23.21

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.
Files changed (44) hide show
  1. package/.bongos-core.json +82 -42
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +22 -1
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +107 -101
  10. package/docs/copy-registry.json +189 -125
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +3 -1
  13. package/docs/page-readings.json +73 -58
  14. package/modules/copy-desk/chrome-pages.js +178 -0
  15. package/modules/copy-desk/page-data.js +34 -2
  16. package/modules/copy-desk/tests/copy_chrome_pages.mjs +127 -0
  17. package/modules/copy-desk/tests/copy_no_cms.mjs +1 -1
  18. package/modules/platform-identity/builder-of-the-week.js +18 -9
  19. package/modules/platform-identity/community-leaderboard.js +3 -0
  20. package/modules/platform-identity/community-movers.js +152 -0
  21. package/modules/platform-identity/routes/community-leaderboard.js +22 -2
  22. package/modules/public-landing/public/community-movers-none.states.json +15 -0
  23. package/modules/public-landing/public/community.html +62 -4
  24. package/modules/public-landing/public/community.states.json +1 -1
  25. package/modules/ui-design/kit/fixtures/community-movers-none.json +6 -0
  26. package/modules/ui-design/kit/fixtures/community-movers.json +12 -0
  27. package/modules/ui-design/kit/serve.js +8 -0
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/release-notes.json +12 -0
  31. package/scripts/bongos/copy-apply.js +3 -1
  32. package/scripts/bongos/run-unit-tests.js +4 -0
  33. package/scripts/bongos/tweak-renders.js +9 -2
  34. package/src/module-api.js +1 -1
  35. package/tests/community_movers.mjs +280 -0
  36. package/tests/community_movers_db.mjs +147 -0
  37. package/tests/copy_desk_page_docx.mjs +3 -3
  38. package/tests/copy_desk_page_editor.mjs +6 -6
  39. package/tests/copy_desk_page_next.mjs +3 -2
  40. package/tests/copy_desk_page_reads.mjs +2 -1
  41. package/tests/copy_desk_page_submit.mjs +30 -2
  42. package/tests/hub_community_page.mjs +78 -9
  43. package/tests/privacy_matrix.mjs +48 -9
  44. package/tests/profile_role_stats.mjs +2 -1
@@ -0,0 +1,127 @@
1
+ // modules/copy-desk/tests/copy_chrome_pages.mjs — the shared page chrome as its
2
+ // own pages (task 1004904), pure.
3
+ //
4
+ // The skip link, top bar, navigation and footer are on every page of a surface.
5
+ // A real page's reading must not carry them, and each surface must gain one
6
+ // page per chrome section, each line once. Run over a small hand-made reading
7
+ // (the rules) and over the committed artifacts (the real data holds them).
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { createRequire } from 'node:module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const chrome = require('../chrome-pages.js');
14
+ const pageData = require('../page-data.js');
15
+
16
+ let passed = 0;
17
+ let failed = 0;
18
+ function test(name, fn) {
19
+ try { fn(); passed++; console.log(` ok ${name}`); }
20
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
21
+ }
22
+
23
+ const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
24
+ const READINGS = {
25
+ counts: { pages: 3, lines: 9, placed: 3, shared: 5, unplaced: 1 },
26
+ pages: [
27
+ { id: 'landing:index', surface: 'landing', reading_hash: 'h-index', files_hash: 'f1', states_read: ['in'], lines: [
28
+ L('L0001', 'skip link', 'Skip to content', 'shared', { file: 'shell.js', line: 9, string_id: 'a1' }),
29
+ L('L0002', 'top bar', 'Projects', 'shared', { file: 'nav.js', line: 3, string_id: 'a2' }),
30
+ L('L0003', 'hero', 'Build here', 'placed', { file: 'index.html', line: 4, string_id: 'a3' }),
31
+ ] },
32
+ { id: 'landing:account', surface: 'landing', reading_hash: 'h-acct', counts: { lines: 5, placed: 1, shared: 3, unplaced: 1 }, files_hash: 'f2', states_read: ['in', 'out'], lines: [
33
+ L('L0001', 'skip link', 'Skip to content', 'shared'),
34
+ L('L0002', 'top bar', 'Projects', 'shared'),
35
+ L('L0003', 'top bar', 'Sign in', 'shared'),
36
+ L('L0004', 'hero', 'Your account', 'placed'),
37
+ L('L0005', 'list', 'Something unplaced', 'unplaced'),
38
+ ] },
39
+ // No surface on the reading: it is read from the id.
40
+ { id: 'status:explore', reading_hash: 'h-exp', files_hash: 'f3', lines: [L('L0001', 'hero', 'Explore', 'placed')] },
41
+ ],
42
+ };
43
+ const INVENTORY = {
44
+ counts: { pages: 3, surfaces: 2 },
45
+ surfaces: [{ id: 'landing', pages: 2 }, { id: 'status', pages: 1 }],
46
+ pages: [
47
+ { id: 'landing:index', surface: 'landing', module: 'public-landing', page: 'index', title: 'Home', path: '/', url: 'https://{apex}/', files: ['index.html'] },
48
+ { id: 'landing:account', surface: 'landing', module: 'public-landing', page: 'account', title: 'Account', path: '/account', url: 'https://{apex}/account', files: ['account.html'] },
49
+ { id: 'status:explore', surface: 'status', module: 'status-ui', page: 'explore', title: 'Explore', path: '/explore', url: 'https://status.{apex}/explore', files: ['explore.html'] },
50
+ ],
51
+ };
52
+
53
+ const split = chrome.splitReadings(READINGS);
54
+ const byId = (rd, id) => rd.pages.find((p) => p.id === id);
55
+
56
+ test('a real page no longer carries its chrome, and keeps its own lines and keys', () => {
57
+ assert.deepEqual(byId(split.readings, 'landing:index').lines.map((l) => [l.key, l.section]), [['L0003', 'hero']]);
58
+ assert.deepEqual(byId(split.readings, 'landing:account').lines.map((l) => l.key), ['L0004', 'L0005']);
59
+ assert.equal(byId(split.readings, 'landing:index').reading_hash, 'h-index', 'a draft on the reading stays on it');
60
+ assert.deepEqual(byId(split.readings, 'landing:account').counts, { lines: 2, placed: 1, shared: 0, unplaced: 1 });
61
+ });
62
+
63
+ test('each chrome section becomes one page per surface, every distinct line once, in first-seen order', () => {
64
+ const top = byId(split.readings, 'landing:top-bar');
65
+ assert.deepEqual(top.lines.map((l) => [l.key, l.text]), [['L0001', 'Projects'], ['L0002', 'Sign in']]);
66
+ assert.deepEqual(byId(split.readings, 'landing:skip-link').lines.map((l) => l.text), ['Skip to content']);
67
+ assert.equal(byId(split.readings, 'landing:footer'), undefined, 'a surface with no footer gains no footer page');
68
+ assert.equal(byId(split.readings, 'status:top-bar'), undefined, 'a surface with no chrome gains no chrome page');
69
+ });
70
+
71
+ test('a chrome line keeps the registry row of its first occurrence that has one', () => {
72
+ const top = byId(split.readings, 'landing:top-bar').lines[0];
73
+ assert.deepEqual([top.file, top.line, top.string_id, top.placement], ['nav.js', 3, 'a2', 'shared']);
74
+ });
75
+
76
+ test('the reading totals follow the pages', () => {
77
+ assert.equal(split.readings.counts.pages, 5);
78
+ assert.equal(split.readings.counts.lines, 1 + 2 + 1 + 2 + 1);
79
+ assert.equal(split.readings.counts.shared, 3);
80
+ });
81
+
82
+ test('the inventory gains the same pages, after their surface, opening on a page that shows them', () => {
83
+ const inv = chrome.addChromePages(INVENTORY, split.chrome);
84
+ assert.deepEqual(inv.pages.map((p) => p.id), ['landing:index', 'landing:account', 'status:explore', 'landing:top-bar', 'landing:skip-link']);
85
+ const top = inv.pages.find((p) => p.id === 'landing:top-bar');
86
+ assert.deepEqual([top.surface, top.page, top.title, top.url], ['landing', 'top-bar', 'Top bar', 'https://{apex}/']);
87
+ assert.deepEqual(top.files, ['nav.js']);
88
+ assert.equal(inv.counts.pages, 5);
89
+ assert.deepEqual(inv.surfaces.map((s) => [s.id, s.pages]), [['landing', 4], ['status', 1]]);
90
+ });
91
+
92
+ test('a chrome page id is told from a page id', () => {
93
+ assert.equal(chrome.isChromePageId('landing:top-bar'), true);
94
+ assert.equal(chrome.isChromePageId('landing:index'), false);
95
+ });
96
+
97
+ test('only a shared line in a chrome section is chrome', () => {
98
+ assert.equal(chrome.isChromeLine(L('L1', 'top bar', 'x', 'placed')), false);
99
+ assert.equal(chrome.isChromeLine(L('L1', 'hero', 'x', 'shared')), false);
100
+ assert.equal(chrome.isChromeLine(L('L1', 'top bar', 'x', 'shared')), true);
101
+ });
102
+
103
+ test('the committed readings: no real page lists a skip link, top bar, navigation or footer line', () => {
104
+ const rd = pageData.loadReadings();
105
+ assert.equal(rd.ok, true);
106
+ for (const p of rd.data.pages) {
107
+ if (chrome.isChromePageId(p.id)) continue;
108
+ const bad = p.lines.filter(chrome.isChromeLine);
109
+ assert.equal(bad.length, 0, `${p.id} still lists ${bad.length} chrome lines`);
110
+ }
111
+ });
112
+
113
+ test('the committed data: every surface that had a top bar has a Top bar page, in both reads', () => {
114
+ const rd = pageData.loadReadings().data;
115
+ const inv = pageData.loadInventory().data;
116
+ for (const surface of ['builders', 'landing', 'status']) {
117
+ const id = `${surface}:top-bar`;
118
+ const r = rd.pages.find((p) => p.id === id);
119
+ assert.ok(r && r.lines.length > 0, `${id} in the readings`);
120
+ assert.ok(inv.pages.find((p) => p.id === id), `${id} in the inventory`);
121
+ assert.equal(new Set(r.lines.map((l) => l.text)).size, r.lines.length, `${id} lists each line once`);
122
+ }
123
+ assert.equal(rd.pages.length, inv.pages.length, 'the two reads name the same pages');
124
+ });
125
+
126
+ console.log(`\n${passed} passed, ${failed} failed`);
127
+ process.exit(failed ? 1 : 0);
@@ -70,7 +70,7 @@ const stripComments = (src) => src
70
70
  // Task 1004435 added routes/copy-desk-artist.js (one artist's approved pages, a
71
71
  // read) and artist-stats.js's readArtistPages under it.
72
72
  const ROUTE_FILES = fs.readdirSync(path.join(MODULE_DIR, 'routes')).filter((f) => f.endsWith('.js')).sort().map((f) => `routes/${f}`);
73
- const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'docx.js', 'page-decisions.js', 'artist-stats.js', 'auto-close.js', ...ROUTE_FILES];
73
+ const MODULE_FILES = ['registry.js', 'flags.js', 'proposals.js', 'pages.js', 'page-status.js', 'page-data.js', 'chrome-pages.js', 'docx.js', 'page-decisions.js', 'artist-stats.js', 'auto-close.js', ...ROUTE_FILES];
74
74
 
75
75
  console.log('copy-desk: NO LIVE CMS');
76
76
 
@@ -53,13 +53,15 @@ function crownedWeek(now = new Date()) {
53
53
 
54
54
  const isoDate = (d) => d.toISOString().slice(0, 10);
55
55
 
56
- // The winner of [start, end) among eligible builders, or null; the week's, and the month's
57
- // and year's (community-crowns.js). Arithmetic is bigint for the leaderboard's reason: the
56
+ // The per-(project, builder) deltas over [$1, $2] among eligible builders, as the CTEs
57
+ // `act` and `per_project`: the week's, month's and year's (computeWinner below) and the 30
58
+ // days' (community-movers.js). Arithmetic is bigint for the leaderboard's reason: the
58
59
  // columns are instance-reported int4. `fromFirstSnapshot` is the YEAR's rule (owner,
59
60
  // 2026-10-27): a row with no snapshot before the start, and not first seen inside, counts
60
61
  // from its first snapshot inside instead of not at all, since history began in late
61
62
  // September 2026 and the rule above would leave that year to new builders alone.
62
- async function computeWinner(start, end, pool, { fromFirstSnapshot = false } = {}) {
63
+ // `base_credits` is what a row had at the start by the same rule.
64
+ function periodRowsSql({ fromFirstSnapshot = false } = {}) {
63
65
  const base = (col) => (fromFirstSnapshot
64
66
  ? `COALESCE(b.${col}, CASE WHEN act.first_seen_at >= $1::timestamptz THEN 0 ELSE f.${col} END, 0)`
65
67
  : `COALESCE(b.${col}, 0)`);
@@ -74,17 +76,17 @@ async function computeWinner(start, end, pool, { fromFirstSnapshot = false } = {
74
76
  LIMIT 1
75
77
  ) f ON true`
76
78
  : '';
77
- const { rows } = await pool.query(
78
- `WITH act AS (
79
+ return `WITH act AS (
79
80
  SELECT a.client_id, a.github_id, a.first_seen_at, pa.handle, pa.github_login
80
81
  FROM platform_identity_builder_project_activity a
81
82
  JOIN platform_identity_accounts pa ON pa.github_id = a.github_id
82
83
  WHERE ${rollupVisibleSql('pa')}
83
84
  ), per_project AS (
84
- SELECT act.github_id, act.handle, act.github_login,
85
+ SELECT act.client_id, act.github_id, act.handle, act.github_login,
85
86
  COALESCE(e.credits, 0)::bigint - ${base('credits')} AS credits,
86
87
  COALESCE(e.tasks_shipped, 0)::bigint - ${base('tasks_shipped')} AS tasks_shipped,
87
- COALESCE(e.karma, 0)::bigint - ${base('karma')} AS karma
88
+ COALESCE(e.karma, 0)::bigint - ${base('karma')} AS karma,
89
+ ${base('credits')}::bigint AS base_credits
88
90
  FROM act
89
91
  LEFT JOIN LATERAL (
90
92
  SELECT s.credits, s.tasks_shipped, s.karma
@@ -103,7 +105,13 @@ async function computeWinner(start, end, pool, { fromFirstSnapshot = false } = {
103
105
  LIMIT 1
104
106
  ) b ON true${firstInside}
105
107
  WHERE b.credits IS NOT NULL OR act.first_seen_at >= $1::timestamptz${fromFirstSnapshot ? ' OR f.credits IS NOT NULL' : ''}
106
- )
108
+ )`;
109
+ }
110
+
111
+ // The winner of [start, end) among eligible builders, or null.
112
+ async function computeWinner(start, end, pool, { fromFirstSnapshot = false } = {}) {
113
+ const { rows } = await pool.query(
114
+ `${periodRowsSql({ fromFirstSnapshot })}
107
115
  SELECT github_id, sum(credits)::bigint AS credits, sum(tasks_shipped)::bigint AS tasks_shipped
108
116
  FROM per_project
109
117
  GROUP BY github_id, handle, github_login
@@ -201,8 +209,9 @@ module.exports = {
201
209
  crownedWeek,
202
210
  clearCrownMemo,
203
211
  ARCHIVE_WEEKS,
204
- // for community-crowns.js (the month and the year)
212
+ // for community-crowns.js (the month and the year) and community-movers.js (the 30 days)
205
213
  computeWinner,
214
+ periodRowsSql,
206
215
  crownOnce,
207
216
  isoDate,
208
217
  };
@@ -231,4 +231,7 @@ module.exports = {
231
231
  MAX_LIMIT,
232
232
  MAX_OFFSET,
233
233
  MAX_DISCIPLINE_CHARS,
234
+ // for community-movers.js (the rising builder), so who is shown and the tie order are one rule
235
+ eligibleIdentities,
236
+ compareRows,
234
237
  };
@@ -0,0 +1,152 @@
1
+ // modules/platform-identity/community-movers.js — the rising builder and the projects on the
2
+ // move, the read behind GET /community/movers for the row under the crowns on the hub's
3
+ // /community home (community spec D7 items 1–2; task 1002983, goal 1000110 CT7).
4
+ //
5
+ // THE 30 DAYS are rolling, up to now, and read from the snapshot history by the year's
6
+ // baseline rule (builder-of-the-week.js periodRowsSql, fromFirstSnapshot): a row first seen
7
+ // inside counts from zero, a row with no snapshot before them from its first one inside (an
8
+ // undercount, never a lifetime).
9
+ //
10
+ // THE RISING BUILDER (owner, 2026-10-28): the most credits earned in the 30 days among the
11
+ // builders who had under RISING_BASE_MAX before them, with at least one work shipped in
12
+ // them; ties as the crowns. "Before" is each row's baseline, and a row with no history in
13
+ // the 30 days at all counts its live total, so a veteran's quiet projects still count.
14
+ //
15
+ // THE PROJECTS ON THE MOVE (owner, 2026-10-28): the most works shipped in the 30 days, then
16
+ // credits, the top PROJECTS_MAX. Only the builders the leaderboard may show are counted
17
+ // (spec D8), and a project is named only where a profile may name it: on the map with the
18
+ // Public door (task 1003558); private and stealth projects are left off, not anonymised.
19
+ //
20
+ // TWO HALVES, as the leaderboard: the 30 days' rows (one snapshot probe per activity row)
21
+ // are memoised per pool for MOVERS_TTL_MS; who and which projects may be shown is asked on
22
+ // every read, so a hide, a private account or a closed door beats the memo. Viewer-
23
+ // independent; github_id and client_id never cross the projection.
24
+ 'use strict';
25
+
26
+ const { pool: defaultPool } = require('../../src/module-api');
27
+ const { periodRowsSql } = require('./builder-of-the-week');
28
+ const { eligibleIdentities, compareRows, windowStart } = require('./community-leaderboard');
29
+ const { onMapSql } = require('./projects-feed');
30
+
31
+ const WINDOW = '30d';
32
+ const RISING_BASE_MAX = 100;
33
+ const PROJECTS_MAX = 5;
34
+ const MOVERS_TTL_MS = 30 * 1000;
35
+
36
+ // Every eligible (project, builder) row's 30 days: [{ clientId, githubId, credits,
37
+ // tasks_shipped, karma, base }]. A row the period rule leaves out moved nowhere in them, so
38
+ // its deltas are zero and its baseline is its live total.
39
+ async function computeRows(start, end, pool) {
40
+ const { rows } = await pool.query(
41
+ `${periodRowsSql({ fromFirstSnapshot: true })}
42
+ SELECT a.client_id, a.github_id, pp.credits, pp.tasks_shipped, pp.karma,
43
+ COALESCE(pp.base_credits, a.credits::bigint) AS base_credits
44
+ FROM act
45
+ JOIN platform_identity_builder_project_activity a
46
+ ON a.client_id = act.client_id AND a.github_id = act.github_id
47
+ LEFT JOIN per_project pp
48
+ ON pp.client_id = act.client_id AND pp.github_id = act.github_id`,
49
+ [start, end],
50
+ );
51
+ return rows.map((r) => ({
52
+ clientId: String(r.client_id),
53
+ githubId: String(r.github_id),
54
+ credits: Number(r.credits || 0),
55
+ tasks_shipped: Number(r.tasks_shipped || 0),
56
+ karma: Number(r.karma || 0),
57
+ base: Number(r.base_credits || 0),
58
+ }));
59
+ }
60
+
61
+ // pool → { at, start, end, promise }. Concurrent reads share one in-flight computation; a
62
+ // failed one is dropped so the next read retries.
63
+ const rowsMemo = new Map();
64
+ function windowRows(now, pool) {
65
+ const m = rowsMemo.get(pool);
66
+ const at = now.getTime();
67
+ if (m && at >= m.at && at - m.at < MOVERS_TTL_MS) return m;
68
+ const start = windowStart(WINDOW, now);
69
+ const entry = { at, start, end: now, promise: computeRows(start, now, pool) };
70
+ entry.promise.catch(() => { if (rowsMemo.get(pool) === entry) rowsMemo.delete(pool); });
71
+ rowsMemo.set(pool, entry);
72
+ return entry;
73
+ }
74
+
75
+ // The projects among `clientIds` a profile may name, now: Map clientId → { name, origin }.
76
+ async function namedProjects(clientIds, pool) {
77
+ if (clientIds.length === 0) return new Map();
78
+ const { rows } = await pool.query(
79
+ `SELECT c.client_id, p.name, p.origin
80
+ FROM platform_identity_sso_clients c
81
+ JOIN platform_identity_projects p ON p.origin = c.origin
82
+ WHERE c.client_id = ANY($1::text[])
83
+ AND ${onMapSql('p')} AND p.visibility = 'public'`,
84
+ [clientIds],
85
+ );
86
+ return new Map(rows.map((r) => [String(r.client_id), { name: r.name, origin: r.origin }]));
87
+ }
88
+
89
+ // Sums `rows` per `key`, from zero.
90
+ function sumBy(rows, key) {
91
+ const out = new Map();
92
+ for (const r of rows) {
93
+ const k = r[key];
94
+ const s = out.get(k) || { credits: 0, tasks_shipped: 0, karma: 0, base: 0 };
95
+ s.credits += r.credits;
96
+ s.tasks_shipped += r.tasks_shipped;
97
+ s.karma += r.karma;
98
+ s.base += r.base;
99
+ out.set(k, s);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ function risingBuilder(rows, identities) {
105
+ const ranked = [...sumBy(rows, 'githubId')]
106
+ .filter(([, s]) => s.base < RISING_BASE_MAX && s.tasks_shipped >= 1)
107
+ .map(([id, s]) => ({ ...identities.get(id), credits: s.credits, tasks_shipped: s.tasks_shipped, karma: s.karma }))
108
+ .sort(compareRows);
109
+ if (!ranked.length) return null;
110
+ const { handle, github_login, display_name, avatar_url, credits, tasks_shipped } = ranked[0];
111
+ return { handle, github_login, display_name, avatar_url, credits, tasks_shipped };
112
+ }
113
+
114
+ const byText = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
115
+ function projectsOnTheMove(rows, named) {
116
+ return [...sumBy(rows.filter((r) => named.has(r.clientId)), 'clientId')]
117
+ .filter(([, s]) => s.tasks_shipped >= 1)
118
+ .map(([id, s]) => ({ ...named.get(id), tasks_shipped: s.tasks_shipped, credits: s.credits }))
119
+ .sort((a, b) => (b.tasks_shipped - a.tasks_shipped) || (b.credits - a.credits)
120
+ || byText(String(a.name), String(b.name)) || byText(String(a.origin), String(b.origin)))
121
+ .slice(0, PROJECTS_MAX);
122
+ }
123
+
124
+ // readMovers — { window: { start, end }, rising, projects }: the window as ISO instants,
125
+ // rising null when nobody qualifies or may be shown, projects best first (at most
126
+ // PROJECTS_MAX). `now` is injectable for tests.
127
+ async function readMovers({ now = new Date(), pool = defaultPool } = {}) {
128
+ const w = windowRows(now, pool);
129
+ const rows = await w.promise;
130
+ // Only a builder or project with a shipped work in the 30 days can be on either card.
131
+ const shipped = rows.filter((r) => r.tasks_shipped > 0);
132
+ const identities = await eligibleIdentities([...new Set(shipped.map((r) => r.githubId))], pool);
133
+ const shown = rows.filter((r) => identities.has(r.githubId));
134
+ const named = await namedProjects([...new Set(shown.filter((r) => r.tasks_shipped > 0).map((r) => r.clientId))], pool);
135
+ return {
136
+ window: { start: w.start.toISOString(), end: w.end.toISOString() },
137
+ rising: risingBuilder(shown, identities),
138
+ projects: projectsOnTheMove(shown, named),
139
+ };
140
+ }
141
+
142
+ function clearMoversMemo() {
143
+ rowsMemo.clear();
144
+ }
145
+
146
+ module.exports = {
147
+ readMovers,
148
+ clearMoversMemo,
149
+ RISING_BASE_MAX,
150
+ PROJECTS_MAX,
151
+ MOVERS_TTL_MS,
152
+ };
@@ -9,7 +9,8 @@
9
9
  // It also serves GET /community/builder-of-the-week (spec D5, task 1002981), the
10
10
  // board's weekly crown, from ../builder-of-the-week.js on the same terms, and GET
11
11
  // /community/crowns (task 1004902), the week, month and year for the hub's /community
12
- // page, from ../community-crowns.js.
12
+ // page, from ../community-crowns.js, and GET /community/movers (task 1002983), the rising
13
+ // builder and the projects on the move under them, from ../community-movers.js.
13
14
  //
14
15
  // Reaches core only through the doorway (ADR 0083).
15
16
  'use strict';
@@ -19,6 +20,7 @@ const api = require('../../../src/module-api');
19
20
  const leaderboard = require('../community-leaderboard');
20
21
  const builderOfTheWeek = require('../builder-of-the-week');
21
22
  const communityCrowns = require('../community-crowns');
23
+ const communityMovers = require('../community-movers');
22
24
  const { createReadRateLimit } = require('../read-rate-limit');
23
25
 
24
26
  const { pool, corsPublicGet } = api;
@@ -31,6 +33,8 @@ const WEEK_READ_RATE_SCOPE = 'builder-of-the-week-read';
31
33
  const weekReadRateLimit = createReadRateLimit({ scope: WEEK_READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
32
34
  const CROWNS_READ_RATE_SCOPE = 'community-crowns-read';
33
35
  const crownsReadRateLimit = createReadRateLimit({ scope: CROWNS_READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
36
+ const MOVERS_READ_RATE_SCOPE = 'community-movers-read';
37
+ const moversReadRateLimit = createReadRateLimit({ scope: MOVERS_READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
34
38
 
35
39
  module.exports = function communityLeaderboardRoutes() {
36
40
  const router = express.Router();
@@ -109,7 +113,23 @@ module.exports = function communityLeaderboardRoutes() {
109
113
  }
110
114
  });
111
115
 
116
+ router.use('/community/movers', corsPublicGet('/community/movers'));
117
+
118
+ // rank: public — the rising builder and the projects on the move, the last 30 days (task
119
+ // 1002983). Takes no parameters and writes nothing. Viewer-independent; no-store so a
120
+ // builder who hides leaves it on the next read (ADR 0171 D5).
121
+ router.get('/community/movers', moversReadRateLimit, async (req, res) => {
122
+ try {
123
+ const out = await communityMovers.readMovers({ pool });
124
+ res.set('Cache-Control', 'no-store');
125
+ res.json(out);
126
+ } catch (err) {
127
+ api.logger('platform-identity').error({ err: err && err.message }, 'GET /community/movers failed');
128
+ res.fail('community_movers_failed', { status: 500, message: 'internal error' });
129
+ }
130
+ });
131
+
112
132
  return router;
113
133
  };
114
134
 
115
- module.exports._internals = { READ_RATE_LIMIT, READ_RATE_SCOPE, WEEK_READ_RATE_SCOPE, CROWNS_READ_RATE_SCOPE };
135
+ module.exports._internals = { READ_RATE_LIMIT, READ_RATE_SCOPE, WEEK_READ_RATE_SCOPE, CROWNS_READ_RATE_SCOPE, MOVERS_READ_RATE_SCOPE };
@@ -0,0 +1,15 @@
1
+ {
2
+ "_": "The community home with no movers to show (task 1002983; fixtures/community-movers-none.json): the row under the crowns stands with one line in each card.",
3
+ "page": "/community",
4
+ "surface": "public-landing",
5
+ "modes": ["dark"],
6
+ "modeQuery": false,
7
+ "stub": { "fixtures": { "community-movers": "community-movers-none" } },
8
+ "ignoreRequests": ["/api/(gds|bongos)/me$", "/my-projects", "/me/guild-", "/scouting$"],
9
+ "ignoreConsole": ["/api/(gds|bongos)/me", "/scouting", "\\b40[13]\\b", "Unauthorized", "Forbidden"],
10
+ "ignoreControls": [".vh"],
11
+ "states": {
12
+ "out-home-movers-none": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#homeMovers", "#moveRising .commMsg", "#moveNone", "#moversRule"], "count": [["#moveProjects .commRow", 0]], "text": [["#moveRising .commMsg", "No rising builder in the last 30 days."], ["#moveNone", "No public project shipped a work in the last 30 days."]] } }
13
+ },
14
+ "reducedMotion": { "auth": false, "actions": [["wait", 400]] }
15
+ }
@@ -7,15 +7,17 @@
7
7
  spec docs/specs/bongos-v1-community-tab.md D1/D3/D4/D8). Who is building, across every
8
8
  project, as five sub-pages of the bar's Community menu (owner, 2026-10-27), all served
9
9
  from this one file: /community is the community home (task 1004902: the builders of the
10
- week, month and year, and a door to each page), /community/leaderboard the leaderboard,
10
+ week, month and year, under them the rising builder and the projects on the move (task
11
+ 1002983), and a door to each page), /community/leaderboard the leaderboard,
11
12
  /community/week the builder of the week (task 1002981, spec D5), /community/find the
12
13
  builder search, /community/recruiters the builders who hide their numbers but are open
13
14
  to recruiters.
14
15
  The script shows the section the path names and marks it current in the menu.
15
16
  VIEWING IS UNGATED (ADR 0217 D1): the home, the leaderboard, the builder of the week and
16
- the search are the same for every viewer, signed in or not. All four reads are public,
17
+ the search are the same for every viewer, signed in or not. All five reads are public,
17
18
  viewer-independent routes (modules/platform-identity/routes/community-leaderboard.js,
18
- which also serves the builder of the week and the crowns, and community-search.js),
19
+ which also serves the builder of the week, the crowns and the movers, and
20
+ community-search.js),
19
21
  which decide WHO appears through account-visibility.js; these pages add no rule of
20
22
  their own. The one viewer-relative page is the recruiter list: drawn only from a 200 of
21
23
  GET /scouting, whose door (a project owner, or project.curate) is the server's, never
@@ -126,8 +128,18 @@
126
128
  .homeLast strong{font-weight:500;color:var(--ink);}
127
129
  .homeDoors{list-style:none;display:flex;flex-wrap:wrap;gap:10px 12px;margin:10px 0 0;padding:0;}
128
130
  .homeDoors .btn{text-transform:none;}
131
+ /* the row under the crowns (task 1002983): the rising builder in the first column, the
132
+ projects on the move across the other two as board rows with no face */
133
+ .homeMovers{margin-top:14px;}
134
+ .homeMovers .homeWide{grid-column:span 2;align-items:stretch;}
135
+ .moveRows{margin:0;}
136
+ .moveRows .commRow{grid-template-columns:3ch minmax(0,1fr) auto;padding:10px 0;}
137
+ .moveRows a{display:inline-block;min-height:24px;padding:3px 0;line-height:18px;color:var(--ink);
138
+ text-decoration:underline;text-decoration-color:var(--accent);text-underline-offset:4px;}
139
+ .moveRows a:focus-visible{outline:2px solid var(--focus);outline-offset:3px;}
129
140
  @media (max-width:760px){
130
141
  .homeCrowns{grid-template-columns:minmax(0,1fr);}
142
+ .homeMovers .homeWide{grid-column:auto;}
131
143
  }
132
144
  @media (max-width:480px){
133
145
  .commCrown{grid-template-columns:minmax(0,1fr);}
@@ -226,6 +238,14 @@
226
238
  <li class="homeCard"><h3 class="commSub">Builder of the year</h3><p class="crownWhen" id="crownYearWhen"></p><div class="homeBody" id="crownYear"></div><p class="homeLast" id="crownYearLast" hidden></p></li>
227
239
  </ul>
228
240
  <p class="commNote" id="homeRule">Each is the builder who earned the most {{currency}} across every project in that time, among the builders who shipped at least one work in it. A tie goes to whoever shipped more works, then to whoever earned more karma. Builders who hide their numbers or keep their account private are not counted. The week and the month are decided once, when they end; the year shows the leader so far, and is decided on 1 January.</p>
241
+ <!-- THE ROW UNDER THE CROWNS (task 1002983; owner, 2026-10-28): the rising builder and
242
+ the projects on the move, the last 30 days, from GET /community/movers
243
+ (community-movers.js decides both). Its own read: a failed one drops only this row. -->
244
+ <ul class="homeCrowns homeMovers" id="homeMovers" hidden>
245
+ <li class="homeCard"><h3 class="commSub">Rising builder</h3><p class="crownWhen">The last 30 days</p><div class="homeBody" id="moveRising"></div></li>
246
+ <li class="homeCard homeWide"><h3 class="commSub">Projects on the move</h3><p class="crownWhen">The last 30 days</p><ol class="commRows moveRows" id="moveProjects"></ol><p class="commMsg" id="moveNone" hidden>No public project shipped a work in the last 30 days.</p></li>
247
+ </ul>
248
+ <p class="commNote" id="moversRule" hidden>The rising builder is the one who earned the most {{currency}} in the last 30 days, among the builders who had earned under 100 {{currency}} before them and shipped at least one work in them. Projects on the move are the public projects that shipped the most works in the last 30 days, then earned the most {{currency}}. Builders who hide their numbers or keep their account private are not counted in either.</p>
229
249
  <h3 class="commSub" id="doorsH">Community pages</h3>
230
250
  <ul class="homeDoors" aria-labelledby="doorsH">
231
251
  <li><a class="btn ghost" href="/community/leaderboard">Leaderboard</a></li>
@@ -677,6 +697,44 @@
677
697
  });
678
698
  }
679
699
 
700
+ // ── the row under the crowns: the rising builder and the projects on the move ──
701
+ // (task 1002983) community-movers.js decides both; a failed read leaves the row down.
702
+ function moveRow(p, i) {
703
+ var li = el('li', 'commRow');
704
+ li.appendChild(el('span', 'commPlace', String(i + 1)));
705
+ var main = el('div', 'mineRow__main');
706
+ var name = String(p.name || 'A project');
707
+ var href = safeHttpUrl(p.origin);
708
+ if (href) {
709
+ var a = el('a', 'mineRow__name', name);
710
+ a.setAttribute('href', href);
711
+ main.appendChild(a);
712
+ } else main.appendChild(el('div', 'mineRow__name', name));
713
+ main.appendChild(el('p', 'mineRow__hint', num(p.credits).toLocaleString() + ' ' + UNIT + ' earned'));
714
+ li.appendChild(main);
715
+ var works = el('div', 'commCredits');
716
+ var n = num(p.tasks_shipped);
717
+ works.appendChild(el('span', 'commNum', String(n)));
718
+ works.appendChild(el('span', 'commUnit', n === 1 ? 'work shipped' : 'works shipped'));
719
+ li.appendChild(works);
720
+ return li;
721
+ }
722
+ function loadMovers() {
723
+ return read('/community/movers').then(function (r) {
724
+ if (!r.ok) return;
725
+ var box = $('moveRising');
726
+ box.textContent = '';
727
+ if (r.data.rising) fillCrown(box, r.data.rising);
728
+ else box.appendChild(el('p', 'commMsg', 'No rising builder in the last 30 days.'));
729
+ var list = $('moveProjects');
730
+ list.textContent = '';
731
+ arr(r.data.projects).forEach(function (p, i) { if (p) list.appendChild(moveRow(p, i)); });
732
+ $('moveNone').hidden = list.children.length > 0;
733
+ $('homeMovers').hidden = false;
734
+ $('moversRule').hidden = false;
735
+ });
736
+ }
737
+
680
738
  // ── the search ──────────────────────────────────────────────────────────────
681
739
  function say(text, kind) {
682
740
  var s = $('findStatus');
@@ -806,7 +864,7 @@
806
864
  if (view.title) document.title = document.title + ' · ' + view.title;
807
865
  $('signinTop').setAttribute('href', '/api/bongos/auth/web/start?return=' + view.path + '&repos=1');
808
866
  if (VIEW === 'leaderboard') { loadBoard(); read('/community/builder-of-the-week').then(crownLine); }
809
- if (VIEW === 'home') loadHome();
867
+ if (VIEW === 'home') { loadHome(); loadMovers(); }
810
868
  else if (VIEW === 'week') loadWeek();
811
869
  else if (VIEW === 'recruiters') bar.ready.then(function (me) { if (me) loadRecruit(); else showRecruit('recruitDoor'); });
812
870
  else bar.ready.then(function (me) { if (me) loadOwned(me, $(VIEW === 'find' ? 'findFor' : 'boardFor')); });
@@ -8,7 +8,7 @@
8
8
  "ignoreConsole": ["/api/(gds|bongos)/me", "/scouting", "\\b40[13]\\b", "Unauthorized", "Forbidden"],
9
9
  "ignoreControls": [".vh"],
10
10
  "states": {
11
- "out": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#home", "#homeCrowns", "#crownWeek .crownName", "#crownMonth .crownName", "#crownYear .crownName", "#homeRule", "#signinTop"], "hidden": ["#board", "#week", "#find", "#recruit", "#who", "#homeLoading", "#homeFailed", "#crownYearLast", ".homeDoors .knowCell"], "count": [[".homeDoors li:not([hidden])", 4]], "href": [[".homeDoors li:first-child a", "/community/leaderboard"]], "attr": [["#commTrig", "aria-current", "true"], ["#menuCommunity a[href=\"/community\"]", "aria-current", "page"]], "text": [["#crownWeekWhen", "26 Oct – 1 Nov 2026"], ["#crownWeek .crownName", "Ada Okafor"], ["#crownMonthWhen", "October 2026"], ["#crownMonth .crownName", "Priya Nair"], ["#crownMonth .crownNums div:first-child .commNum", "6,120"], ["#crownYearWhen", "2026 so far, since 27 Sep 2026"], ["#crownYear .crownName", "Maximiliana van der Berghe-Lindqvist"]] } },
11
+ "out": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#home", "#homeCrowns", "#crownWeek .crownName", "#crownMonth .crownName", "#crownYear .crownName", "#homeRule", "#signinTop", "#homeMovers", "#moveRising .crownName", "#moversRule"], "hidden": ["#board", "#week", "#find", "#recruit", "#who", "#homeLoading", "#homeFailed", "#crownYearLast", ".homeDoors .knowCell", "#moveNone"], "count": [[".homeDoors li:not([hidden])", 4], ["#moveProjects .commRow", 5]], "href": [[".homeDoors li:first-child a", "/community/leaderboard"], ["#moveProjects .commRow:first-child a", "https://tidepool.example/"]], "attr": [["#commTrig", "aria-current", "true"], ["#menuCommunity a[href=\"/community\"]", "aria-current", "page"]], "text": [["#crownWeekWhen", "26 Oct – 1 Nov 2026"], ["#crownWeek .crownName", "Ada Okafor"], ["#crownMonthWhen", "October 2026"], ["#crownMonth .crownName", "Priya Nair"], ["#crownMonth .crownNums div:first-child .commNum", "6,120"], ["#crownYearWhen", "2026 so far, since 27 Sep 2026"], ["#crownYear .crownName", "Maximiliana van der Berghe-Lindqvist"], ["#moveRising .crownName", "Kofi Mensah"], ["#moveProjects .commRow:first-child .commNum", "23"], ["#moveProjects .commRow:first-child .mineRow__hint", "1,840 credits earned"]] } },
12
12
  "in": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#home", "#homeCrowns", "#who", ".homeDoors .knowCell"], "hidden": ["#board", "#signinTop", "#homeFailed"], "count": [[".homeDoors li:not([hidden])", 5]], "href": [[".homeDoors .knowCell a", "/connections#connSuggest"]] } },
13
13
  "out-board": { "auth": false, "url": "/community/leaderboard", "actions": [["wait", 400]], "expect": { "visible": ["#board", "#boardList", "#boardWindow", "#signinTop", "#projTrig", "#boardCrown"], "hidden": ["#home", "#week", "#find", "#recruit", "#who", "#boardLoading", "#boardFailed", "#boardEmpty"], "href": [["#boardCrown a", "/community/week"]], "count": [["#boardList .commRow", 6]], "value": [["#boardWindow", "all"]], "attr": [["#craftTabs [data-craft=\"\"]", "aria-pressed", "true"], ["#commTrig", "aria-current", "true"], ["#menuCommunity a[href=\"/community/leaderboard\"]", "aria-current", "page"]], "text": [["#boardList .commRow:first-child .mineRow__name", "Priya Nair"], ["#boardList .commRow:first-child .commNum", "18,420"], ["#boardList .commRow:first-child .mineRow__hint", "212 works shipped · 64 karma"], ["#boardNote", "Credits earned across every project, since each builder started."], ["#boardCrown", "Builder of the week: Ada Okafor, 1,240 credits, 19–25 Oct"]] } },
14
14
  "out-30d": { "auth": false, "url": "/community/leaderboard", "actions": [["wait", 400], ["select", "#boardWindow", "30d"], ["wait", 300]], "expect": { "count": [["#boardList .commRow", 3]], "value": [["#boardWindow", "30d"]], "text": [["#boardList .commRow:first-child .mineRow__name", "Ada Okafor"], ["#boardNote", "Credits earned in the last 30 days, across every project."]] } },
@@ -0,0 +1,6 @@
1
+ {
2
+ "_": "GET /community/movers with nobody to show (task 1002983): no newer builder shipped a work in the last 30 days, and no public project did — what the hub may show while its numbers are quiet.",
3
+ "window": { "start": "2026-09-28T09:30:00.000Z", "end": "2026-10-28T09:30:00.000Z" },
4
+ "rising": null,
5
+ "projects": []
6
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "_": "GET /community/movers (task 1002983): the rising builder and the projects on the move over the last 30 days, as community-movers.js answers — five projects, one with a long name, and the rising builder a newer builder than the crowns'.",
3
+ "window": { "start": "2026-09-28T09:30:00.000Z", "end": "2026-10-28T09:30:00.000Z" },
4
+ "rising": { "handle": "kofi-mensah", "github_login": "kofimensah", "display_name": "Kofi Mensah", "avatar_url": "/__kit/avatar.svg", "credits": 640, "tasks_shipped": 5 },
5
+ "projects": [
6
+ { "name": "Tidepool", "origin": "https://tidepool.example", "tasks_shipped": 23, "credits": 1840 },
7
+ { "name": "Lantern Atlas", "origin": "https://lantern-atlas.example", "tasks_shipped": 17, "credits": 2210 },
8
+ { "name": "The Long Weekend Collective's Field Notes and Recipes", "origin": "https://field-notes.example", "tasks_shipped": 9, "credits": 610 },
9
+ { "name": "Orbit Garden", "origin": "https://orbit-garden.example", "tasks_shipped": 4, "credits": 380 },
10
+ { "name": "Quill", "origin": "https://quill.example", "tasks_shipped": 1, "credits": 45 }
11
+ ]
12
+ }
@@ -749,6 +749,13 @@ function communityCrowns(res, origin) {
749
749
  year: { ...year, winner: shown(year.winner), last: year.last ? { ...year.last, winner: shown(year.last.winner) } : null },
750
750
  });
751
751
  }
752
+ // The movers (task 1002983): the fixture is already the route's answer — the 30 days, the
753
+ // rising builder or null, and the projects on the move best first (community-movers.js).
754
+ function communityMovers(res, origin) {
755
+ const f = heldFixture('community-movers');
756
+ if (stubStatus(res, f)) return;
757
+ return json(res, 200, { window: f.window, rising: f.rising ? withAvatar(f.rising, origin) : null, projects: f.projects || [] });
758
+ }
752
759
  function scoutingRead(res, origin) {
753
760
  const f = heldFixture('scouting');
754
761
  if (stubStatus(res, f)) return;
@@ -788,6 +795,7 @@ const server = http.createServer((req, res) => {
788
795
  if (rest === '/community/search' && req.method === 'GET') return communitySearch(res, u, origin);
789
796
  if (rest === '/community/builder-of-the-week' && req.method === 'GET') return communityWeek(res, origin);
790
797
  if (rest === '/community/crowns' && req.method === 'GET') return communityCrowns(res, origin);
798
+ if (rest === '/community/movers' && req.method === 'GET') return communityMovers(res, origin);
791
799
  if (rest === '/scouting' && req.method === 'GET') return AUTH ? scoutingRead(res, origin) : json(res, 401, UNAUTH);
792
800
  if (rest === '/connections' && req.method === 'GET') return AUTH ? connRead(res, 'list', origin) : json(res, 401, UNAUTH);
793
801
  if (rest === '/connections/requests' && req.method === 'GET') return AUTH ? connRead(res, 'requests', origin) : json(res, 401, UNAUTH);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.19",
3
+ "version": "1.23.21",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.23.19",
9
+ "version": "1.23.21",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.23.19",
3
+ "version": "1.23.21",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",