@bongos/core 1.23.19 → 1.23.20

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 (32) hide show
  1. package/.bongos-core.json +62 -32
  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 +2 -0
  12. package/docs/page-inventory.json +3 -1
  13. package/docs/page-readings.json +73 -58
  14. package/modules/platform-identity/builder-of-the-week.js +18 -9
  15. package/modules/platform-identity/community-leaderboard.js +3 -0
  16. package/modules/platform-identity/community-movers.js +152 -0
  17. package/modules/platform-identity/routes/community-leaderboard.js +22 -2
  18. package/modules/public-landing/public/community-movers-none.states.json +15 -0
  19. package/modules/public-landing/public/community.html +62 -4
  20. package/modules/public-landing/public/community.states.json +1 -1
  21. package/modules/ui-design/kit/fixtures/community-movers-none.json +6 -0
  22. package/modules/ui-design/kit/fixtures/community-movers.json +12 -0
  23. package/modules/ui-design/kit/serve.js +8 -0
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/release-notes.json +6 -0
  27. package/scripts/bongos/run-unit-tests.js +4 -0
  28. package/src/module-api.js +1 -1
  29. package/tests/community_movers.mjs +280 -0
  30. package/tests/community_movers_db.mjs +147 -0
  31. package/tests/hub_community_page.mjs +78 -9
  32. package/tests/privacy_matrix.mjs +48 -9
@@ -0,0 +1,147 @@
1
+ // tests/community_movers_db.mjs — task 1002983 (the rising builder and the projects on the
2
+ // move; real DB).
3
+ //
4
+ // Proves against real SQL what a fake pool cannot (tests/community_movers.mjs): the 30 days
5
+ // keep the year's baseline rule (a lifetime brought into them counts from its first snapshot
6
+ // inside, a row first seen inside from zero), a quiet row's live total counts toward the
7
+ // "under 100 before" test, a hidden builder counts nowhere, and a private project is never
8
+ // named, however much it shipped.
9
+ //
10
+ // The read is global (every eligible builder on the hub), so the fixture's 30 days end in
11
+ // March 2099: a shared database's real builders have no snapshot there, so they ship nothing
12
+ // in them and the shipped-work floor keeps them out.
13
+ //
14
+ // Real-DB integration test: self-skips without a reachable Postgres; in the INTEGRATION set
15
+ // (scripts/bongos/run-unit-tests.js) so the DB-free lane never runs it.
16
+ //
17
+ // Run: DATABASE_URL=... node tests/community_movers_db.mjs
18
+
19
+ import { createRequire } from 'node:module';
20
+ import { readFileSync } from 'node:fs';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { dirname, join } from 'node:path';
23
+ import { strict as assert } from 'node:assert';
24
+ const require = createRequire(import.meta.url);
25
+ const { Pool } = require('pg');
26
+ const { readMovers, clearMoversMemo } = require('../modules/platform-identity/community-movers.js');
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
30
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
31
+
32
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
33
+ try {
34
+ await pool.query('SELECT 1');
35
+ } catch (e) {
36
+ console.log(`community_movers_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
37
+ await pool.end().catch(() => {});
38
+ process.exit(0);
39
+ }
40
+
41
+ const R = 90298301, V = 90298302, Q = 90298303, L = 90298304, N = 90298305, S = 90298306, HIDDEN = 90298307;
42
+ const IDS = [R, V, Q, L, N, S, HIDDEN];
43
+ const P1 = 't1002983-p1', P2 = 't1002983-p2', P3 = 't1002983-p3', PRIV = 't1002983-priv';
44
+ const CLIENTS = [P1, P2, P3, PRIV];
45
+ const origin = (c) => `https://${c}.example`;
46
+ const NOW = new Date('2099-03-12T10:00:00.000Z'); // the 30 days open 2099-02-10T10:00Z
47
+ const OLD = new Date('2098-06-01T00:00:00Z');
48
+ const d = (iso) => new Date(iso);
49
+ const q = (sql, params) => pool.query(sql, params);
50
+
51
+ async function cleanup() {
52
+ await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
53
+ await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
54
+ await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
55
+ await q(`DELETE FROM platform_identity_projects WHERE origin = ANY($1::text[])`, [CLIENTS.map(origin)]).catch(() => {});
56
+ await q(`DELETE FROM platform_identity_sso_clients WHERE client_id = ANY($1::text[])`, [CLIENTS]).catch(() => {});
57
+ }
58
+ async function account(id, handle, { hide = false } = {}) {
59
+ await q(
60
+ `INSERT INTO platform_identity_accounts
61
+ (github_id, github_login, display_name, handle, profile_state, account_visibility, hide_stats, terms_accepted_at)
62
+ VALUES ($1, $2, $2, $2, 'active', 'public', $3, now())`,
63
+ [id, handle, hide],
64
+ );
65
+ }
66
+ async function project(client, name, visibility) {
67
+ await q(`INSERT INTO platform_identity_sso_clients (client_id, secret_hash, name, origin) VALUES ($1, 'x', $2, $3)`, [client, name, origin(client)]);
68
+ await q(
69
+ `INSERT INTO platform_identity_projects (origin, name, description, featured, status, visibility, visibility_source)
70
+ VALUES ($1, $2, 'described', false, 'active', $3, 'control-plane')`,
71
+ [origin(client), name, visibility],
72
+ );
73
+ }
74
+ // One (project, builder) row: its live totals, when the hub first saw it, and its snapshots.
75
+ async function row(client, id, live, firstSeen, history) {
76
+ await q(
77
+ `INSERT INTO platform_identity_builder_project_activity (client_id, github_id, credits, tasks_shipped, karma, first_seen_at) VALUES ($1, $2, $3, 0, 0, $4)`,
78
+ [client, id, live, firstSeen],
79
+ );
80
+ for (const [when, credits, ships] of history) {
81
+ await q(
82
+ `INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at) VALUES ($1, $2, $3, $4, 0, $5)`,
83
+ [client, id, credits, ships, d(when)],
84
+ );
85
+ }
86
+ }
87
+ const read = () => { clearMoversMemo(); return readMovers({ now: NOW, pool }); };
88
+ const who = (w) => w && [w.handle, w.credits, w.tasks_shipped];
89
+ const strip = (ps) => ps.map((p) => [p.name, p.tasks_shipped, p.credits]);
90
+
91
+ let pass = 0;
92
+ const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
93
+
94
+ try {
95
+ for (const f of [
96
+ 'platform_identity_001_tables.sql', 'platform_identity_004_projects.sql', 'platform_identity_005_builder_activity.sql',
97
+ 'platform_identity_007_profile_consent.sql', 'platform_identity_008_handle.sql',
98
+ 'platform_identity_011_project_visibility.sql', 'platform_identity_012_account_privacy.sql',
99
+ 'platform_identity_019_terms_acceptance.sql', 'platform_identity_020_visibility_pull.sql',
100
+ 'platform_identity_022_activity_snapshots.sql', 'platform_identity_032_weekly_features.sql',
101
+ ]) await q(migrationSql(f));
102
+ await cleanup();
103
+
104
+ for (const [id, h] of [[R, 'r'], [V, 'v'], [Q, 'q'], [L, 'l'], [N, 'n'], [S, 's']]) await account(id, `t1002983-${h}`);
105
+ await account(HIDDEN, 't1002983-hidden', { hide: true });
106
+ await project(P1, 'Alpha', 'public');
107
+ await project(P2, 'Beta', 'public');
108
+ await project(PRIV, 'Shut', 'private');
109
+
110
+ // R: 40 before the 30 days, then 300 and two works in them — the rising builder.
111
+ await row(P1, R, 340, OLD, [['2099-02-01T00:00:00Z', 40, 0], ['2099-03-01T00:00:00Z', 340, 2]]);
112
+ // V: a veteran — 5000 before, 900 and four works in them.
113
+ await row(P1, V, 5900, OLD, [['2099-02-01T00:00:00Z', 5000, 50], ['2099-03-01T00:00:00Z', 5900, 54]]);
114
+ // Q: 400 and three works on a project first seen inside (from zero), but 800 on a project
115
+ // with no history in the 30 days at all, so Q had 800 before them and is not rising.
116
+ await row(P2, Q, 400, d('2099-02-14T00:00:00Z'), [['2099-02-15T00:00:00Z', 400, 3]]);
117
+ await row(P3, Q, 800, OLD, []);
118
+ // L: a lifetime brought into the 30 days — no snapshot before them, seen before
119
+ // first_seen_at existed. Counted from its first snapshot inside: 150 and a work, never 90150.
120
+ await row(P1, L, 90150, null, [['2099-02-20T00:00:00Z', 90000, 40], ['2099-03-02T00:00:00Z', 90150, 41]]);
121
+ // N: first seen inside, so from zero: 250 and a work.
122
+ await row(P2, N, 250, d('2099-02-12T00:00:00Z'), [['2099-02-12T00:00:00Z', 250, 1]]);
123
+ // S: seven works on the private project.
124
+ await row(PRIV, S, 70, d('2099-02-11T00:00:00Z'), [['2099-02-11T00:00:00Z', 70, 7]]);
125
+ // HIDDEN out-earns and out-ships everyone on Alpha, and the leaderboard may not show them.
126
+ await row(P1, HIDDEN, 9000, OLD, [['2099-02-01T00:00:00Z', 0, 0], ['2099-03-01T00:00:00Z', 9000, 9]]);
127
+
128
+ const first = await read();
129
+ same([first.window.start, first.window.end], ['2099-02-10T10:00:00.000Z', '2099-03-12T10:00:00.000Z'], 'the 30 days up to now');
130
+ same(who(first.rising), ['t1002983-r', 300, 2], 'rising: R’s 300 — V, Q and L had 100 or more before (Q by a quiet project, L by its first snapshot), and hidden counts nowhere');
131
+ same(strip(first.projects), [['Alpha', 7, 1350], ['Beta', 4, 650]],'projects: works then credits; L counted from its first snapshot, hidden not counted, the private project never named');
132
+ same(first.projects[0].origin, origin(P1), 'a project links to its own address');
133
+ same(JSON.stringify(first).match(/github_id|client_id/), null, 'github_id and client_id never cross');
134
+
135
+ await q(`UPDATE platform_identity_accounts SET hide_stats = true WHERE github_id = $1`, [R]);
136
+ const hid = await readMovers({ now: NOW, pool }); // inside the memo
137
+ same(who(hid.rising), ['t1002983-n', 250, 1], 'R hides: the next one under the cap rises, without waiting for the memo');
138
+ same(strip(hid.projects), [['Alpha', 5, 1050], ['Beta', 4, 650]], 'and R’s numbers leave Alpha too');
139
+
140
+ await q(`UPDATE platform_identity_projects SET visibility = 'stealth' WHERE origin = $1`, [origin(P1)]);
141
+ same(strip((await readMovers({ now: NOW, pool })).projects), [['Beta', 4, 650]],'a project that goes stealth leaves the strip on the next read');
142
+
143
+ console.log(`\ncommunity_movers_db.mjs: ${pass} passed`);
144
+ } finally {
145
+ await cleanup();
146
+ await pool.end();
147
+ }
@@ -17,11 +17,12 @@
17
17
  // its refusals; the recruiter list as a door signed out, a refusal on a 403, and rows
18
18
  // only from a 200 — name, face and login, never the ranked half's numbers; the builder
19
19
  // of the week as one line on the board and as its own page, drawn as the server sent it;
20
- // the home's three crowns, its empty and failed states, and its doors
20
+ // the home's three crowns, its empty and failed states, and its doors; the row under
21
+ // them (task 1002983), the rising builder and the projects on the move, from its own read
21
22
  // • the Community menu on every page that wears the hub's bar (apex-only where the page
22
23
  // also serves a child), folded into the Projects band on phones
23
24
  // • the routes: the five paths served on the hub's apex only
24
- // • the kit: the states files, and the stub answering as the five reads do
25
+ // • the kit: the states files, and the stub answering as the six reads do
25
26
  //
26
27
  // DB-free. Run: node --test tests/hub_community_page.mjs
27
28
 
@@ -51,6 +52,7 @@ const SEARCH_JS = fs.readFileSync(path.join(PI, 'builder-search.js'), 'utf8');
51
52
  const SUBPAGES = [['/community', 'Community home'], ['/community/leaderboard', 'Leaderboard'], ['/community/week', 'Builder of the week'], ['/community/find', 'Find a builder'], ['/community/recruiters', 'Open to recruiters']];
52
53
  const WEEK_JS = fs.readFileSync(path.join(PI, 'builder-of-the-week.js'), 'utf8');
53
54
  const CROWNS_JS = fs.readFileSync(path.join(PI, 'community-crowns.js'), 'utf8');
55
+ const MOVERS_JS = fs.readFileSync(path.join(PI, 'community-movers.js'), 'utf8');
54
56
 
55
57
  // ── 1. the markup ────────────────────────────────────────────────────────────
56
58
 
@@ -119,7 +121,8 @@ test('the home states the month’s and year’s rule, and it is the module’s;
119
121
  assert.match(rule, /The week and the month are decided once, when they end; the year shows the leader so far, and is decided on 1 January\./);
120
122
  assert.match(CROWNS_JS, /ON CONFLICT \(period, period_start\) DO NOTHING/);
121
123
  assert.match(CROWNS_JS, /computeWinner\(start, now, pool, \{ fromFirstSnapshot: true \}\)/, 'the year so far is live');
122
- for (const id of ['homeFailed', 'homeCrowns', 'crownYearLast']) assert.match(home, new RegExp(` id="${id}"[^>]*hidden`), `${id} waits for the route’s answer`);
124
+ for (const id of ['homeFailed', 'homeCrowns', 'crownYearLast', 'homeMovers', 'moversRule', 'moveNone']) assert.match(home, new RegExp(` id="${id}"[^>]*hidden`), `${id} waits for the route’s answer`);
125
+ assert.ok(home.indexOf('id="homeMovers"') > home.indexOf('id="homeRule"') && home.indexOf('id="homeMovers"') < home.indexOf('id="doorsH"'), 'the row under the crowns and their rule, above the doors (owner decision 5, task 1004902)');
123
126
  const doors = [...home.split('<ul class="homeDoors" aria-labelledby="doorsH">')[1].split('</ul>')[0].matchAll(/<li( class="knowCell" hidden)?><a class="btn ghost" href="([^"]+)">([^<]+)<\/a><\/li>/g)];
124
127
  assert.deepEqual(doors.map((m) => [m[2], m[3], !!m[1]]), [
125
128
  ['/community/leaderboard', 'Leaderboard', false], ['/community/week', 'Builder of the week', false], ['/community/find', 'Find a builder', false],
@@ -127,6 +130,19 @@ test('the home states the month’s and year’s rule, and it is the module’s;
127
130
  ], 'one door per page, the menu’s order; Builders you may know signed in only, like its menu item');
128
131
  });
129
132
 
133
+ test('the movers’ rule is the module’s: the base cap, the shipped-work floor, the projects’ order and who is counted (task 1002983)', () => {
134
+ const rule = MAIN.split('<p class="commNote" id="moversRule" hidden>')[1].split('</p>')[0];
135
+ const cap = MOVERS_JS.match(/const RISING_BASE_MAX = (\d+);/)[1];
136
+ assert.match(rule, new RegExp(`the most \\{\\{currency\\}\\} in the last 30 days, among the builders who had earned under ${cap} \\{\\{currency\\}\\} before them and shipped at least one work in them\\.`));
137
+ assert.match(MOVERS_JS, /const WINDOW = '30d';/);
138
+ assert.match(MOVERS_JS, /s\.base < RISING_BASE_MAX && s\.tasks_shipped >= 1/, 'the cap and the floor the page states are the ones applied');
139
+ assert.match(rule, /Projects on the move are the public projects that shipped the most works in the last 30 days, then earned the most \{\{currency\}\}\./);
140
+ assert.match(MOVERS_JS, /\(b\.tasks_shipped - a\.tasks_shipped\) \|\| \(b\.credits - a\.credits\)/);
141
+ assert.match(MOVERS_JS, /onMapSql\('p'\)\} AND p\.visibility = 'public'/, '“public projects”: on the map with the Public door');
142
+ assert.match(rule, /Builders who hide their numbers or keep their account private are not counted in either\./);
143
+ assert.match(MOVERS_JS, /eligibleIdentities/);
144
+ });
145
+
130
146
  test('the window dropdown and the query limits are the routes’ own (boundary-forced copies)', () => {
131
147
  const days = BOARD_JS.match(/const WINDOW_DAYS = Object\.freeze\(\{([^}]*)\}\);/);
132
148
  const windows = [...days[1].matchAll(/'?(\w+)'?:/g)].map((m) => m[1]);
@@ -144,12 +160,12 @@ test('the window dropdown and the query limits are the routes’ own (boundary-f
144
160
  assert.match(MAIN, new RegExp(`<input id="findQ" type="search"[^>]* maxlength="${max}"`));
145
161
  });
146
162
 
147
- test('the script builds no markup from strings, reads only the six routes, and never re-ranks or reads the ranked tier', () => {
163
+ test('the script builds no markup from strings, reads only the seven routes, and never re-ranks or reads the ranked tier', () => {
148
164
  assert.doesNotMatch(SCRIPT, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
149
- // every path literal in the code: the API base, the six reads, the five sub-pages, the
165
+ // every path literal in the code: the API base, the seven reads, the five sub-pages, the
150
166
  // sign-in door and the hall's invite box the Recruit door opens (task 1002292)
151
167
  const paths = [...new Set([...CODE.matchAll(/'(\/[^'?]*)/g)].map((m) => m[1]))];
152
- assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/builders/watch', '/community', '/community/builder-of-the-week', '/community/crowns', '/community/find', '/community/leaderboard', '/community/recruiters', '/community/search', '/community/week', '/my-projects', '/scouting']);
168
+ assert.deepEqual(paths.sort(), ['/api/bongos', '/api/bongos/auth/web/start', '/builders/watch', '/community', '/community/builder-of-the-week', '/community/crowns', '/community/find', '/community/leaderboard', '/community/movers', '/community/recruiters', '/community/search', '/community/week', '/my-projects', '/scouting']);
153
169
  assert.doesNotMatch(CODE, /method:/, 'it only reads');
154
170
  assert.doesNotMatch(CODE, /'\/me\b/, 'who is signed in is the bar’s question');
155
171
  assert.doesNotMatch(CODE, /\.sort\(|\.count\b|_count\b/, 'the server ranks; every count is a list’s length');
@@ -201,8 +217,14 @@ const CROWNS = () => ({
201
217
  month: { start: '2026-10-01', end: '2026-11-01', first_crown: null, winner: WINNER('bo', 6120, 1, { display_name: null, avatar_url: 'javascript:alert(1)' }) },
202
218
  year: { start: '2026-01-01', since: '2026-09-27', last: null, winner: WINNER('cy', 9870, 31) },
203
219
  });
220
+ const MOVERS = () => ({
221
+ window: { start: '2026-09-28T09:30:00.000Z', end: '2026-10-28T09:30:00.000Z' },
222
+ rising: WINNER('kofi', 640, 5, { display_name: 'Kofi <b>M</b>' }),
223
+ projects: [{ name: 'Tide <i>pool</i>', origin: 'https://tidepool.example', tasks_shipped: 23, credits: 1840 }, { name: 'Quill', origin: 'javascript:alert(1)', tasks_shipped: 1, credits: 45 }],
224
+ });
204
225
  const READS = () => ({
205
226
  '/community/crowns': { status: 200, body: CROWNS() },
227
+ '/community/movers': { status: 200, body: MOVERS() },
206
228
  '/community/builder-of-the-week': { status: 200, body: WEEK() },
207
229
  '/community/leaderboard?window=all': { status: 200, body: { window: 'all', count: 99, results: [ROW(26, 'ana', 1200), ROW(27, 'bo', 1500, { tasks_shipped: 1, karma: 0, avatar_url: 'javascript:alert(1)', display_name: null })] } },
208
230
  '/community/leaderboard?window=30d': { status: 200, body: { window: '30d', results: [ROW(1, 'cy', 40)] } },
@@ -271,7 +293,7 @@ test('each path shows its one section, marks its menu items current and names it
271
293
 
272
294
  test('the home: one read, the three crowns with their periods, names as text, and nothing asked about projects', async () => {
273
295
  const ui = await boot({ pathname: '/community' });
274
- assert.deepEqual(ui.gets(), ['/api/bongos/community/crowns']);
296
+ assert.deepEqual(ui.gets(), ['/api/bongos/community/crowns', '/api/bongos/community/movers']);
275
297
  assert.equal(ui.$('homeLoading').hidden, true);
276
298
  assert.equal(ui.$('homeFailed').hidden, true);
277
299
  assert.equal(ui.$('homeCrowns').hidden, false);
@@ -286,7 +308,7 @@ test('the home: one read, the three crowns with their periods, names as text, an
286
308
  assert.equal(find(ui.$('crownYear'), 'mineRow__host').textContent, '@cy');
287
309
  assert.equal(ui.$('crownYearLast').hidden, true, 'no year crowned yet');
288
310
  const signedIn = await boot({ pathname: '/community', me: { github_login: 'owner' } });
289
- assert.deepEqual(signedIn.gets(), ['/api/bongos/community/crowns'], 'signed in, the same one read: no Recruit door here');
311
+ assert.deepEqual(signedIn.gets(), ['/api/bongos/community/crowns', '/api/bongos/community/movers'], 'signed in, the same two reads: no Recruit door here');
290
312
  assert.equal(signedIn.$('homeCrowns').textContent, ui.$('homeCrowns').textContent, 'the same crowns for every viewer');
291
313
  });
292
314
 
@@ -332,6 +354,45 @@ test('the home: a failed read is an alert, never three empty crowns; a rate limi
332
354
  assert.match((await boot({ pathname: '/community', reads })).$('homeFailed').textContent, /^Too many requests/);
333
355
  });
334
356
 
357
+ test('the row under the crowns: the rising builder as a crown, the projects in the server’s order, names as text, a link only to a web address (task 1002983)', async () => {
358
+ const ui = await boot({ pathname: '/community' });
359
+ assert.equal(ui.$('homeMovers').hidden, false);
360
+ assert.equal(ui.$('moversRule').hidden, false);
361
+ assert.equal(find(ui.$('moveRising'), 'crownName').textContent, 'Kofi <b>M</b>', 'a name is text, never markup');
362
+ assert.equal(find(ui.$('moveRising'), 'crownNums').textContent, '640credits5works shipped');
363
+ const rows = kids(ui.$('moveProjects'));
364
+ assert.deepEqual(rows.map((r) => find(r, 'commPlace').textContent), ['1', '2'], 'positions in the order sent');
365
+ const first = find(rows[0], 'mineRow__name');
366
+ assert.equal(first.tag, 'a');
367
+ assert.equal(first.attrs.href, 'https://tidepool.example/');
368
+ assert.equal(first.textContent, 'Tide <i>pool</i>');
369
+ assert.equal(find(rows[0], 'mineRow__hint').textContent, (1840).toLocaleString() + ' credits earned');
370
+ assert.equal(find(rows[0], 'commCredits').textContent, '23works shipped');
371
+ assert.equal(find(rows[1], 'mineRow__name').tag, 'div', 'an address that is not http(s) is never a link');
372
+ assert.equal(find(rows[1], 'commCredits').textContent, '1work shipped', 'one work, singular');
373
+ assert.equal(ui.$('moveNone').hidden, true);
374
+ const signedIn = await boot({ pathname: '/community', me: { github_login: 'owner' } });
375
+ assert.equal(signedIn.$('homeMovers').textContent, ui.$('homeMovers').textContent, 'the same row for every viewer');
376
+ });
377
+
378
+ test('the row under the crowns: nobody to show is one line in each card; a failed read drops only the row', async () => {
379
+ const reads = READS();
380
+ reads['/community/movers'] = { status: 200, body: { window: {}, rising: null, projects: [] } };
381
+ const quiet = await boot({ pathname: '/community', reads });
382
+ assert.equal(quiet.$('homeMovers').hidden, false);
383
+ assert.equal(find(quiet.$('moveRising'), 'commMsg').textContent, 'No rising builder in the last 30 days.');
384
+ assert.equal(kids(quiet.$('moveProjects')).length, 0);
385
+ assert.equal(quiet.$('moveNone').hidden, false);
386
+ for (const answer of [{ status: 500, body: {} }, { status: 429, body: {} }, { reject: true }]) {
387
+ reads['/community/movers'] = answer;
388
+ const failed = await boot({ pathname: '/community', reads });
389
+ assert.equal(failed.$('homeMovers').hidden, true);
390
+ assert.equal(failed.$('moversRule').hidden, true, 'no rule for cards that are not there');
391
+ assert.equal(failed.$('homeCrowns').hidden, false, 'the crowns stand on their own');
392
+ assert.equal(failed.$('homeFailed').hidden, true, 'and no alert: the row is the home’s extra');
393
+ }
394
+ });
395
+
335
396
  test('the leaderboard, signed out: the all-time board in the server’s order with the server’s places, and nothing asked about the viewer', async () => {
336
397
  const ui = await boot();
337
398
  assert.deepEqual(ui.gets(), ['/api/bongos/community/leaderboard?window=all', '/api/bongos/community/builder-of-the-week']);
@@ -747,6 +808,8 @@ test('the kit renders each sub-page for everyone, the empty board, and a project
747
808
  assert.equal(states.states['out-find'].url, '/community/find');
748
809
  assert.equal(states.states['out-week'].url, '/community/week');
749
810
  assert.ok(states.states['out-board'].expect.visible.includes('#boardCrown'), 'the board carries the crown line');
811
+ assert.ok(states.states.out.expect.visible.includes('#homeMovers'), 'the home carries the row under the crowns (task 1002983)');
812
+ assert.equal(json('community-movers-none.states.json').stub.fixtures['community-movers'], 'community-movers-none');
750
813
  // the home's other answers (task 1004902): before the first month, nobody, and a new year
751
814
  for (const [file, fixture] of [['community-crowns-first.states.json', 'community-crowns-first'], ['community-crowns-none.states.json', 'community-crowns-none'], ['community-crowns-new-year.states.json', 'community-crowns-new-year']]) {
752
815
  assert.equal(json(file).stub.fixtures['community-crowns'], fixture);
@@ -790,7 +853,7 @@ async function call(origin, p) {
790
853
  return { status: res.status, body: await res.json() };
791
854
  }
792
855
 
793
- test('the kit stub answers the crowns, the board, the builder of the week, the search and the recruiting door the way the routes do', async () => {
856
+ test('the kit stub answers the crowns, the movers, the board, the builder of the week, the search and the recruiting door the way the routes do', async () => {
794
857
  const s = await stub();
795
858
  try {
796
859
  const crowns = await call(s.origin, '/community/crowns');
@@ -798,6 +861,12 @@ test('the kit stub answers the crowns, the board, the builder of the week, the s
798
861
  const { week: w, month: m, year: y } = crowns.body;
799
862
  assert.deepEqual([Object.keys(w).sort(), Object.keys(m).sort(), Object.keys(y).sort()], [['end', 'start', 'winner'], ['end', 'first_crown', 'start', 'winner'], ['last', 'since', 'start', 'winner']], 'community-crowns.js’s shape');
800
863
  assert.ok([w.winner, m.winner, y.winner].every((r) => r && Object.keys(r).sort().join() === 'avatar_url,credits,display_name,github_login,handle,tasks_shipped'), 'the module’s projection, no github_id');
864
+ const movers = await call(s.origin, '/community/movers');
865
+ assert.deepEqual(Object.keys(movers.body), ['window', 'rising', 'projects'], 'community-movers.js’s shape');
866
+ assert.equal(Object.keys(movers.body.rising).sort().join(), 'avatar_url,credits,display_name,github_login,handle,tasks_shipped', 'the crown’s projection, no github_id');
867
+ assert.ok(movers.body.projects.length && movers.body.projects.every((p) => Object.keys(p).sort().join() === 'credits,name,origin,tasks_shipped'), 'a project’s name, address and two numbers, no client_id');
868
+ const ships = movers.body.projects.map((p) => p.tasks_shipped);
869
+ assert.deepEqual(ships, [...ships].sort((a, b) => b - a), 'most works first');
801
870
  const week = await call(s.origin, '/community/builder-of-the-week');
802
871
  assert.deepEqual(Object.keys(week.body).sort(), ['past', 'week', 'winner'], 'the route’s three keys');
803
872
  assert.ok(week.body.winner && week.body.past.length, 'a crown and the weeks before it');
@@ -158,7 +158,11 @@ function identitySentinels(acct) {
158
158
  // the year so far and last year (task 1004902): a crown IS stats, so the board's
159
159
  // rule. Each account in turn is made the winner of all three, so the reads'
160
160
  // own predicates decide: 'listed' | 'absent'
161
- // directory GET /scouting Recruiter — 'full' | 'name-only' | 'absent'
161
+ // movers GET /community/movers Anon · Builder · Recruiter — the rising builder and the
162
+ // projects on the move (task 1002983): a card IS stats, so the board's rule, and
163
+ // a project's totals are the sum of what may be shown. Each account in turn is
164
+ // the one who moved, so the reads' own predicates decide: 'listed' | 'absent'
165
+ // directory GET /scouting Recruiter — 'full' | 'name-only' | 'absent'
162
166
  // sliver GET /scouting/:handle Recruiter — 'full' (D3) | 'name-only' (D3, hidden) | 404
163
167
  // self GET /me/cross-project Self — 'own' (own numbers, any flags) | 'n/a' (no session exists)
164
168
  // guild GET /guilds/:slug Anon · Builder · Recruiter · Self — the guild page's PUBLIC
@@ -174,13 +178,13 @@ function identitySentinels(acct) {
174
178
  // people the guild page already shows them — shown_publicly AND existence;
175
179
  // hide_stats never hides membership, and private or provisional is never listed
176
180
  const MATRIX = {
177
- PROV: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', directory: 'absent', sliver: 404, self: 'n/a', guild: 'absent', members: 'absent', invite: 'absent' },
178
- PUB: { profile: 'full', search: 'listed', board: 'listed', week: 'listed', crowns: 'listed', directory: 'full', sliver: 'full', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
179
- SHY: { profile: 'identity', search: 'name-only', board: 'absent', week: 'absent', crowns: 'absent', directory: 'name-only', sliver: 'name-only', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
180
- PRIV: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', directory: 'absent', sliver: 404, self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
181
- PRIVD: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', directory: 'absent', sliver: 'full', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
182
- PRIVDH: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', directory: 'absent', sliver: 'name-only', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
183
- OPTOUT: { profile: 'full', search: 'listed', board: 'listed', week: 'listed', crowns: 'listed', directory: 'absent', sliver: 404, self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
181
+ PROV: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', movers: 'absent', directory: 'absent', sliver: 404, self: 'n/a', guild: 'absent', members: 'absent', invite: 'absent' },
182
+ PUB: { profile: 'full', search: 'listed', board: 'listed', week: 'listed', crowns: 'listed', movers: 'listed', directory: 'full', sliver: 'full', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
183
+ SHY: { profile: 'identity', search: 'name-only', board: 'absent', week: 'absent', crowns: 'absent', movers: 'absent', directory: 'name-only', sliver: 'name-only', self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
184
+ PRIV: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', movers: 'absent', directory: 'absent', sliver: 404, self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
185
+ PRIVD: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', movers: 'absent', directory: 'absent', sliver: 'full', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
186
+ PRIVDH: { profile: 404, search: 'absent', board: 'absent', week: 'absent', crowns: 'absent', movers: 'absent', directory: 'absent', sliver: 'name-only', self: 'own', guild: 'absent', members: 'listed', invite: 'absent' },
187
+ OPTOUT: { profile: 'full', search: 'listed', board: 'listed', week: 'listed', crowns: 'listed', movers: 'listed', directory: 'absent', sliver: 404, self: 'own', guild: 'listed', members: 'listed', invite: 'listed' },
184
188
  };
185
189
 
186
190
  // ===========================================================================
@@ -303,6 +307,9 @@ function weekOf(key) {
303
307
  // THE ACCOUNT that won the month, the year so far and last year (task 1004902); the
304
308
  // crowns test makes each fixture account the winner in turn.
305
309
  let CROWNED = 'PUB';
310
+ // THE ACCOUNT that moved in the 30 days, on MX_CLIENT's project (task 1002983); the movers
311
+ // test makes each fixture account the one in turn.
312
+ let MOVED = 'PUB';
306
313
 
307
314
  const pool = {
308
315
  query: async (text, params = []) => {
@@ -393,6 +400,20 @@ const pool = {
393
400
  }));
394
401
  return { rows, rowCount: rows.length };
395
402
  }
403
+ // THE MOVERS (task 1002983): the 30 days' rows carry the floor on the act CTE, as the
404
+ // year's; MOVED had nothing before them. The project names are MX_CLIENT's.
405
+ if (/^WITH act AS/.test(sql) && /LEFT JOIN per_project pp/.test(sql)) {
406
+ const where = lift(sql, /JOIN platform_identity_accounts pa ON pa\.github_id = a\.github_id WHERE (.+?) \), per_project AS/, 'movers');
407
+ const rows = ACCOUNTS.filter((a) => a.key === MOVED).filter(accountFilter(where, [])).map((a) => ({
408
+ client_id: MX_CLIENT.client_id, github_id: a.github_id, credits: String(ROLLUP[a.github_id].credits),
409
+ tasks_shipped: String(ROLLUP[a.github_id].works_shipped), karma: String(ROLLUP[a.github_id].karma), base_credits: '0',
410
+ }));
411
+ return { rows, rowCount: rows.length };
412
+ }
413
+ if (/FROM platform_identity_sso_clients c JOIN platform_identity_projects p ON p\.origin = c\.origin WHERE c\.client_id = ANY\(\$1::text\[\]\)/.test(sql)) {
414
+ const rows = (params[0] || []).includes(MX_CLIENT.client_id) ? [{ client_id: MX_CLIENT.client_id, name: MX_CLIENT.name, origin: MX_CLIENT.origin }] : [];
415
+ return { rows, rowCount: rows.length };
416
+ }
396
417
  // ... the year so far: computeWinner's act CTE carries the floor, then the leader's re-read
397
418
  if (/^WITH act AS/.test(sql) && /\) f ON true/.test(sql)) {
398
419
  const where = lift(sql, /JOIN platform_identity_accounts pa ON pa\.github_id = a\.github_id WHERE (.+?) \), per_project AS/, 'year so far');
@@ -868,6 +889,24 @@ test('the crowns — the month, the year so far and last year only for shown, pu
868
889
  } finally { CROWNED = 'PUB'; }
869
890
  });
870
891
 
892
+ test('the movers — the rising builder and a project’s totals only from shown, public, active accounts, the same for every viewer', async () => {
893
+ const { clearMoversMemo } = require('../modules/platform-identity/community-movers.js');
894
+ try {
895
+ for (const k of KEYS) {
896
+ MOVED = k;
897
+ clearMoversMemo(); // the 30 days are memoised: a new mover is a new computation
898
+ const seen = [];
899
+ for (const who of [ANON, STRANGER, RECRUITER]) seen.push(await get(who, '/api/bongos/community/movers'));
900
+ for (const r of seen.slice(1)) assert.equal(r.text, seen[0].text, `${r.who} got different movers with ${k} moving`);
901
+ const { rising, projects } = seen[0].json;
902
+ const listed = MATRIX[k].movers === 'listed';
903
+ assert.equal(!!rising && rising.handle === ACCT[k].handle, listed, `${k} as the rising builder: expected ${MATRIX[k].movers}`);
904
+ assert.deepEqual(projects.map((p) => p.tasks_shipped), listed ? [ROLLUP[ACCT[k].github_id].works_shipped] : [], `${k}'s works in a project's totals: expected ${MATRIX[k].movers}`);
905
+ assert.doesNotMatch(seen[0].text, /github_id|client_id/, 'github_id and client_id never cross');
906
+ }
907
+ } finally { MOVED = 'PUB'; }
908
+ });
909
+
871
910
  test('the recruiting directory — the recruiter sees the full and name-only tiers; Anon and Builder are refused', async () => {
872
911
  const r = await get(RECRUITER, '/api/bongos/scouting');
873
912
  assert.equal(r.status, 200);
@@ -910,7 +949,7 @@ function disclosed(r) {
910
949
  }
911
950
 
912
951
  test('the walk covered every surface for every row (so the claims below are about something)', () => {
913
- for (const p of ['/profiles/', '/me/cross-project', '/community/search', '/community/leaderboard', '/community/builder-of-the-week', '/community/crowns', '/scouting', '/guilds/mx-guild', '/guilds/mx-guild/members', '/projects/invite-guild']) {
952
+ for (const p of ['/profiles/', '/me/cross-project', '/community/search', '/community/leaderboard', '/community/builder-of-the-week', '/community/crowns', '/community/movers', '/scouting', '/guilds/mx-guild', '/guilds/mx-guild/members', '/projects/invite-guild']) {
914
953
  assert.ok(SEEN.some((r) => r.path.includes(p) && r.status === 200), `no 200 from ${p} — the sweep below would be vacuous for it`);
915
954
  }
916
955
  assert.ok(SEEN.length >= KEYS.length * 6, `only ${SEEN.length} responses recorded`);