@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.
- package/.bongos-core.json +82 -42
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +22 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +107 -101
- package/docs/copy-registry.json +189 -125
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +3 -1
- package/docs/page-readings.json +73 -58
- package/modules/copy-desk/chrome-pages.js +178 -0
- package/modules/copy-desk/page-data.js +34 -2
- package/modules/copy-desk/tests/copy_chrome_pages.mjs +127 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +1 -1
- package/modules/platform-identity/builder-of-the-week.js +18 -9
- package/modules/platform-identity/community-leaderboard.js +3 -0
- package/modules/platform-identity/community-movers.js +152 -0
- package/modules/platform-identity/routes/community-leaderboard.js +22 -2
- package/modules/public-landing/public/community-movers-none.states.json +15 -0
- package/modules/public-landing/public/community.html +62 -4
- package/modules/public-landing/public/community.states.json +1 -1
- package/modules/ui-design/kit/fixtures/community-movers-none.json +6 -0
- package/modules/ui-design/kit/fixtures/community-movers.json +12 -0
- package/modules/ui-design/kit/serve.js +8 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/copy-apply.js +3 -1
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/scripts/bongos/tweak-renders.js +9 -2
- package/src/module-api.js +1 -1
- package/tests/community_movers.mjs +280 -0
- package/tests/community_movers_db.mjs +147 -0
- package/tests/copy_desk_page_docx.mjs +3 -3
- package/tests/copy_desk_page_editor.mjs +6 -6
- package/tests/copy_desk_page_next.mjs +3 -2
- package/tests/copy_desk_page_reads.mjs +2 -1
- package/tests/copy_desk_page_submit.mjs +30 -2
- package/tests/hub_community_page.mjs +78 -9
- package/tests/privacy_matrix.mjs +48 -9
- 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
|
|
57
|
-
// and year's (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
|
@@ -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,
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|