@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
@@ -65,8 +65,8 @@ const errDetail = (b) => (b && b.error && b.error.details) || {};
65
65
  // and an unplaced line.
66
66
  const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
67
67
  const STUDIO_LINES = [
68
- L('L0001', 'top bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
69
- L('L0002', 'top bar', 'Ctrl K', 'shared'),
68
+ L('L0001', 'shell bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
69
+ L('L0002', 'shell bar', 'Ctrl K', 'shared'),
70
70
  L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
71
71
  L('L0004', 'greeting', '{…} pages tweaked', 'placed', { file: 'modules/hall-ui/public/studio.js', line: 88, string_id: 'st0004' }),
72
72
  L('L0005', 'greeting', 'Your desk is quiet today', 'unplaced'),
@@ -360,6 +360,34 @@ try {
360
360
  guarded._setList(null);
361
361
  }
362
362
  });
363
+
364
+ // Task 1004904: a shared-chrome page is a page like any other, whole path.
365
+ await test('a chrome page: claimed, drafted and submitted through the real routes, its lines placed from the first occurrence', async () => {
366
+ const TOP = '/copy-desk/pages/builders:top-bar';
367
+ const chromeLine = (key, text, row) => L(key, 'top bar', text, 'shared', row);
368
+ const r0 = readingsWith(
369
+ { hash: 'studio-h1', lines: [chromeLine('L0001', 'Projects', { file: 'modules/hall-ui/public/shell.js', line: 41, string_id: 'sh0041' }), chromeLine('L0002', 'Sign out', { file: 'modules/hall-ui/public/shell.js', line: 42, string_id: 'sh0042' }), ...STUDIO_LINES.slice(2)] },
370
+ { hash: 'index-h2', lines: INDEX_LINES },
371
+ );
372
+ writeReadings(r0);
373
+ const studio = await call('GET', '/copy-desk/pages/builders:studio', ALICE);
374
+ assert.equal(studio.status, 200);
375
+ const page = await call('GET', TOP, ALICE);
376
+ assert.equal(page.status, 200, JSON.stringify(page.body));
377
+ assert.equal((await call('POST', `${TOP}/claim`, ALICE)).status, 201);
378
+ const read = await call('GET', `${TOP}/draft`, ALICE);
379
+ assert.equal(read.status, 200, JSON.stringify(read.body));
380
+ const hash = read.body.reading.reading_hash || read.body.reading_hash;
381
+ assert.ok(hash, 'the chrome page has a reading hash of its own');
382
+ assert.deepEqual(read.body.reading.lines.map((l) => l.text), ['Projects', 'Sign out']);
383
+ const saved = await call('PUT', `${TOP}/draft`, ALICE, { reading_hash: hash, lines: [{ key: 'L0001', after: 'Your projects' }] });
384
+ assert.equal(saved.status, 200, JSON.stringify(saved.body));
385
+ const sub = await call('POST', `${TOP}/submit`, ALICE);
386
+ assert.equal(sub.status, 200, JSON.stringify(sub.body));
387
+ const round = db.tasks.find((t) => t.source_ref === 'page-tweak/builders:top-bar/r1');
388
+ const batch = pages.parseBatchBlock(round.description).batch;
389
+ assert.deepEqual(batch.lines.map((l) => [l.key, l.file, l.line, l.before, l.after]), [['L0001', 'modules/hall-ui/public/shell.js', 41, 'Projects', 'Your projects']]);
390
+ });
363
391
  } finally {
364
392
  server.close();
365
393
  pageData._setRoot(null);
@@ -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`);
@@ -69,7 +69,8 @@ const LEDGER_ROWS = [
69
69
  // ---- the page-tweak ledger (Ines and one other artist) ----------------------
70
70
 
71
71
  const pages = require('../modules/copy-desk/pages.js');
72
- const inventory = require('../docs/page-inventory.json');
72
+ // The studio's own inventory: the real pages plus each surface's chrome pages.
73
+ const inventory = require('../modules/copy-desk/page-data.js').loadInventory().data;
73
74
  const onSurface = (s) => inventory.pages.filter((p) => p.surface === s).map((p) => p.id);
74
75
  const LANDING = onSurface('landing');
75
76
  const BUILDERS = onSurface('builders');