@bongos/core 1.23.18 → 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 (60) hide show
  1. package/.bongos-core.json +134 -49
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +43 -1
  8. package/docs/api-reference.md +4 -2
  9. package/docs/copy-inventory.md +992 -935
  10. package/docs/copy-registry.json +1897 -1313
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +12 -2
  13. package/docs/page-readings.json +616 -576
  14. package/modules/platform-identity/builder-of-the-week.js +61 -24
  15. package/modules/platform-identity/community-crowns.js +176 -0
  16. package/modules/platform-identity/community-leaderboard.js +4 -1
  17. package/modules/platform-identity/community-movers.js +152 -0
  18. package/modules/platform-identity/migrations/platform_identity_033_period_features.sql +43 -0
  19. package/modules/platform-identity/routes/community-leaderboard.js +45 -3
  20. package/modules/public-landing/public/account.html +3 -2
  21. package/modules/public-landing/public/community-crowns-first.states.json +15 -0
  22. package/modules/public-landing/public/community-crowns-new-year.states.json +15 -0
  23. package/modules/public-landing/public/community-crowns-none.states.json +15 -0
  24. package/modules/public-landing/public/community-empty.states.json +1 -1
  25. package/modules/public-landing/public/community-movers-none.states.json +15 -0
  26. package/modules/public-landing/public/community-owner-two.states.json +2 -2
  27. package/modules/public-landing/public/community-owner.states.json +1 -1
  28. package/modules/public-landing/public/community-week-none.states.json +1 -1
  29. package/modules/public-landing/public/community.html +188 -22
  30. package/modules/public-landing/public/community.probes.json +20 -3
  31. package/modules/public-landing/public/community.states.json +8 -6
  32. package/modules/public-landing/public/connections.html +3 -2
  33. package/modules/public-landing/public/contact.html +3 -2
  34. package/modules/public-landing/public/guild.html +3 -2
  35. package/modules/public-landing/public/index.html +3 -2
  36. package/modules/public-landing/public/invitations.html +3 -2
  37. package/modules/public-landing/public/privacy.html +3 -2
  38. package/modules/public-landing/public/projects.html +3 -2
  39. package/modules/public-landing/public/terms.html +3 -2
  40. package/modules/ui-design/kit/fixtures/community-crowns-first.json +6 -0
  41. package/modules/ui-design/kit/fixtures/community-crowns-new-year.json +6 -0
  42. package/modules/ui-design/kit/fixtures/community-crowns-none.json +6 -0
  43. package/modules/ui-design/kit/fixtures/community-crowns.json +6 -0
  44. package/modules/ui-design/kit/fixtures/community-movers-none.json +6 -0
  45. package/modules/ui-design/kit/fixtures/community-movers.json +12 -0
  46. package/modules/ui-design/kit/serve.js +22 -0
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +12 -0
  50. package/scripts/bongos/run-unit-tests.js +8 -0
  51. package/src/module-api.js +1 -1
  52. package/src/platform-server.js +4 -3
  53. package/tests/community_crowns.mjs +369 -0
  54. package/tests/community_crowns_db.mjs +164 -0
  55. package/tests/community_movers.mjs +280 -0
  56. package/tests/community_movers_db.mjs +147 -0
  57. package/tests/hub_account_page.mjs +2 -2
  58. package/tests/hub_community_page.mjs +213 -29
  59. package/tests/landing_legal_pages.mjs +2 -2
  60. package/tests/privacy_matrix.mjs +107 -9
@@ -53,20 +53,40 @@ function crownedWeek(now = new Date()) {
53
53
 
54
54
  const isoDate = (d) => d.toISOString().slice(0, 10);
55
55
 
56
- // The week's winner among eligible builders, or null. Arithmetic is bigint for the
57
- // leaderboard's reason: the columns are instance-reported int4.
58
- async function computeWinner(start, end, pool) {
59
- const { rows } = await pool.query(
60
- `WITH act AS (
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
59
+ // columns are instance-reported int4. `fromFirstSnapshot` is the YEAR's rule (owner,
60
+ // 2026-10-27): a row with no snapshot before the start, and not first seen inside, counts
61
+ // from its first snapshot inside instead of not at all, since history began in late
62
+ // September 2026 and the rule above would leave that year to new builders alone.
63
+ // `base_credits` is what a row had at the start by the same rule.
64
+ function periodRowsSql({ fromFirstSnapshot = false } = {}) {
65
+ const base = (col) => (fromFirstSnapshot
66
+ ? `COALESCE(b.${col}, CASE WHEN act.first_seen_at >= $1::timestamptz THEN 0 ELSE f.${col} END, 0)`
67
+ : `COALESCE(b.${col}, 0)`);
68
+ const firstInside = fromFirstSnapshot
69
+ ? `
70
+ LEFT JOIN LATERAL (
71
+ SELECT s.credits, s.tasks_shipped, s.karma
72
+ FROM platform_identity_activity_snapshots s
73
+ WHERE s.client_id = act.client_id AND s.github_id = act.github_id
74
+ AND s.reported_at > $1::timestamptz AND s.reported_at <= $2::timestamptz
75
+ ORDER BY s.reported_at ASC, s.id ASC
76
+ LIMIT 1
77
+ ) f ON true`
78
+ : '';
79
+ return `WITH act AS (
61
80
  SELECT a.client_id, a.github_id, a.first_seen_at, pa.handle, pa.github_login
62
81
  FROM platform_identity_builder_project_activity a
63
82
  JOIN platform_identity_accounts pa ON pa.github_id = a.github_id
64
83
  WHERE ${rollupVisibleSql('pa')}
65
84
  ), per_project AS (
66
- SELECT act.github_id, act.handle, act.github_login,
67
- COALESCE(e.credits, 0)::bigint - COALESCE(b.credits, 0) AS credits,
68
- COALESCE(e.tasks_shipped, 0)::bigint - COALESCE(b.tasks_shipped, 0) AS tasks_shipped,
69
- COALESCE(e.karma, 0)::bigint - COALESCE(b.karma, 0) AS karma
85
+ SELECT act.client_id, act.github_id, act.handle, act.github_login,
86
+ COALESCE(e.credits, 0)::bigint - ${base('credits')} AS credits,
87
+ COALESCE(e.tasks_shipped, 0)::bigint - ${base('tasks_shipped')} AS tasks_shipped,
88
+ COALESCE(e.karma, 0)::bigint - ${base('karma')} AS karma,
89
+ ${base('credits')}::bigint AS base_credits
70
90
  FROM act
71
91
  LEFT JOIN LATERAL (
72
92
  SELECT s.credits, s.tasks_shipped, s.karma
@@ -83,9 +103,15 @@ async function computeWinner(start, end, pool) {
83
103
  AND s.reported_at <= $1::timestamptz
84
104
  ORDER BY s.reported_at DESC, s.id DESC
85
105
  LIMIT 1
86
- ) b ON true
87
- WHERE b.credits IS NOT NULL OR act.first_seen_at >= $1::timestamptz
88
- )
106
+ ) b ON true${firstInside}
107
+ WHERE b.credits IS NOT NULL OR act.first_seen_at >= $1::timestamptz${fromFirstSnapshot ? ' OR f.credits IS NOT NULL' : ''}
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 })}
89
115
  SELECT github_id, sum(credits)::bigint AS credits, sum(tasks_shipped)::bigint AS tasks_shipped
90
116
  FROM per_project
91
117
  GROUP BY github_id, handle, github_login
@@ -98,10 +124,11 @@ async function computeWinner(start, end, pool) {
98
124
  return rows[0] || null;
99
125
  }
100
126
 
101
- // One crowning per week per process: concurrent first reads share it, and a week
102
- // already on record is not asked about again.
127
+ // One crowning per period per process: concurrent first reads share it, and a period
128
+ // already on record is not asked about again. Keyed 'week:', 'month:' or 'year:' plus the
129
+ // period's first day, so community-crowns.js shares it.
103
130
  const crowning = new Map();
104
- let lastCrowned = null;
131
+ const crowned = new Map(); // key → the pool it was crowned on
105
132
 
106
133
  async function crownWeek(start, end, pool) {
107
134
  const week = isoDate(start);
@@ -119,22 +146,27 @@ async function crownWeek(start, end, pool) {
119
146
  );
120
147
  }
121
148
 
122
- function ensureCrowned(start, end, pool) {
123
- const week = isoDate(start);
124
- if (lastCrowned && lastCrowned.week === week && lastCrowned.pool === pool) return Promise.resolve();
125
- const inFlight = crowning.get(week);
149
+ // Runs `crown()` unless `key` is already crowned on this pool; a failed one is not
150
+ // remembered, so the next read tries again.
151
+ function crownOnce(key, pool, crown) {
152
+ if (crowned.get(key) === pool) return Promise.resolve();
153
+ const inFlight = crowning.get(key);
126
154
  if (inFlight && inFlight.pool === pool) return inFlight.promise;
127
155
  const entry = { pool };
128
- entry.promise = crownWeek(start, end, pool)
129
- .then(() => { lastCrowned = { week, pool }; })
130
- .finally(() => { if (crowning.get(week) === entry) crowning.delete(week); });
131
- crowning.set(week, entry);
156
+ entry.promise = crown()
157
+ .then(() => { crowned.set(key, pool); })
158
+ .finally(() => { if (crowning.get(key) === entry) crowning.delete(key); });
159
+ crowning.set(key, entry);
132
160
  return entry.promise;
133
161
  }
134
162
 
163
+ function ensureCrowned(start, end, pool) {
164
+ return crownOnce(`week:${isoDate(start)}`, pool, () => crownWeek(start, end, pool));
165
+ }
166
+
135
167
  function clearCrownMemo() {
136
168
  crowning.clear();
137
- lastCrowned = null;
169
+ crowned.clear();
138
170
  }
139
171
 
140
172
  // readBuilderOfTheWeek — crowns the last full week if nobody has yet, then reads it and
@@ -177,4 +209,9 @@ module.exports = {
177
209
  crownedWeek,
178
210
  clearCrownMemo,
179
211
  ARCHIVE_WEEKS,
212
+ // for community-crowns.js (the month and the year) and community-movers.js (the 30 days)
213
+ computeWinner,
214
+ periodRowsSql,
215
+ crownOnce,
216
+ isoDate,
180
217
  };
@@ -0,0 +1,176 @@
1
+ // modules/platform-identity/community-crowns.js — the builders of the week, the month and
2
+ // the year, the read behind GET /community/crowns for the hub's /community page (task
3
+ // 1004902, goal 1000110). The week is builder-of-the-week.js's; this file adds the other two
4
+ // and answers all three at once.
5
+ //
6
+ // THE RULE is the week's (owner, 2026-10-27): of the builders the leaderboard may show
7
+ // (rollupVisibleSql), the most credits across every project in the period among those who
8
+ // shipped at least one work in it; ties to works, then karma, then handle/login. Read from
9
+ // the snapshot history through builder-of-the-week.js computeWinner, never the live rows.
10
+ //
11
+ // THE MONTH is the last full calendar month (UTC), crowned at the first read on or after
12
+ // the 1st and pinned in platform_identity_period_features (migration 033), exactly as the
13
+ // week is: ON CONFLICT DO NOTHING, never updated, an empty month recorded too. A month that
14
+ // began before snapshot history did is not crowned at all (it would be "nobody" for want of
15
+ // data, not of work); the answer names the day the first month will be.
16
+ //
17
+ // THE YEAR is live: the leader from 1 January (UTC) to now, recomputed at most every 30s. History
18
+ // began in late September 2026, so the year counts a row with no snapshot before 1 January
19
+ // from its first snapshot inside it (computeWinner's fromFirstSnapshot), and `since` says
20
+ // when history began while that is inside the year. The first read on or after 1 January
21
+ // crowns the year that ended, by the same rule, and `last` shows it.
22
+ //
23
+ // WHO IS SHOWN is decided again on every read, as for the week: a pinned winner who has
24
+ // since hidden or gone private is drawn nowhere and their period goes to nobody else (spec
25
+ // D8). Viewer-independent; github_id never crosses the projection.
26
+ 'use strict';
27
+
28
+ const { pool: defaultPool } = require('../../src/module-api');
29
+ const { rollupVisibleSql } = require('./account-visibility');
30
+ const { readBuilderOfTheWeek, computeWinner, crownOnce, isoDate } = require('./builder-of-the-week');
31
+
32
+ const monthStart = (y, m) => new Date(Date.UTC(y, m, 1));
33
+
34
+ // The month the crown covers at `now`: the last full month before `now`'s own.
35
+ function crownedMonth(now = new Date()) {
36
+ const end = monthStart(now.getUTCFullYear(), now.getUTCMonth());
37
+ return { start: monthStart(now.getUTCFullYear(), now.getUTCMonth() - 1), end };
38
+ }
39
+
40
+ // The first month that began at or after `history`: the first one there is data for.
41
+ function firstFullMonth(history) {
42
+ const m = monthStart(history.getUTCFullYear(), history.getUTCMonth());
43
+ return m.getTime() === history.getTime() ? m : monthStart(history.getUTCFullYear(), history.getUTCMonth() + 1);
44
+ }
45
+
46
+ // When snapshot history began: the first snapshot ever written (its primary key, so one
47
+ // index probe). Never changes once there is one, so it is remembered per pool.
48
+ const historyMemo = new Map();
49
+ async function historyStart(pool) {
50
+ if (historyMemo.has(pool)) return historyMemo.get(pool);
51
+ const { rows } = await pool.query(
52
+ 'SELECT reported_at FROM platform_identity_activity_snapshots ORDER BY id ASC LIMIT 1',
53
+ );
54
+ const at = rows[0] ? new Date(rows[0].reported_at) : null;
55
+ if (at) historyMemo.set(pool, at);
56
+ return at;
57
+ }
58
+
59
+ async function crownPeriod(period, start, end, pool, opts) {
60
+ const day = isoDate(start);
61
+ const { rows } = await pool.query(
62
+ 'SELECT 1 FROM platform_identity_period_features WHERE period = $1 AND period_start = $2::date',
63
+ [period, day],
64
+ );
65
+ if (rows.length) return;
66
+ const winner = await computeWinner(start, end, pool, opts);
67
+ await pool.query(
68
+ `INSERT INTO platform_identity_period_features (period, period_start, github_id, credits_delta, ships_delta)
69
+ VALUES ($1, $2::date, $3::bigint, $4::bigint, $5::bigint)
70
+ ON CONFLICT (period, period_start) DO NOTHING`,
71
+ [period, day, winner ? winner.github_id : null, winner ? winner.credits : 0, winner ? winner.tasks_shipped : 0],
72
+ );
73
+ }
74
+
75
+ const shown = (r, credits, ships) => ({
76
+ handle: r.handle,
77
+ github_login: r.github_login,
78
+ display_name: r.display_name,
79
+ avatar_url: r.avatar_url,
80
+ credits: Number(credits),
81
+ tasks_shipped: Number(ships),
82
+ });
83
+
84
+ // A crowned period's winner, if they may still be shown.
85
+ async function readCrowned(period, start, pool) {
86
+ const { rows } = await pool.query(
87
+ `SELECT f.credits_delta, f.ships_delta, pa.handle, pa.github_login, pa.display_name, pa.avatar_url
88
+ FROM platform_identity_period_features f
89
+ JOIN platform_identity_accounts pa ON pa.github_id = f.github_id
90
+ WHERE f.period = $1 AND f.period_start = $2::date
91
+ AND ${rollupVisibleSql('pa')}`,
92
+ [period, isoDate(start)],
93
+ );
94
+ return rows[0] ? shown(rows[0], rows[0].credits_delta, rows[0].ships_delta) : null;
95
+ }
96
+
97
+ async function readMonth(now, history, pool) {
98
+ const { start, end } = crownedMonth(now);
99
+ const out = { start: isoDate(start), end: isoDate(end), winner: null, first_crown: null };
100
+ if (!history) return out;
101
+ if (start < history) {
102
+ const first = firstFullMonth(history);
103
+ out.first_crown = isoDate(monthStart(first.getUTCFullYear(), first.getUTCMonth() + 1));
104
+ return out;
105
+ }
106
+ await crownOnce(`month:${out.start}`, pool, () => crownPeriod('month', start, end, pool));
107
+ out.winner = await readCrowned('month', start, pool);
108
+ return out;
109
+ }
110
+
111
+ // The year so far is the one answer computed on a read rather than pinned, so its leader is
112
+ // memoised per pool for LEADER_TTL_MS, as the leaderboard memoises its totals; whether that
113
+ // leader may be shown is still asked on every read, so a hide beats the memo.
114
+ const LEADER_TTL_MS = 30 * 1000;
115
+ const leaderMemo = new Map(); // pool → { start, at, leader }
116
+ async function yearLeader(start, now, pool) {
117
+ const m = leaderMemo.get(pool);
118
+ const at = now.getTime();
119
+ if (m && m.start === isoDate(start) && at >= m.at && at - m.at < LEADER_TTL_MS) return m.leader;
120
+ const leader = await computeWinner(start, now, pool, { fromFirstSnapshot: true });
121
+ leaderMemo.set(pool, { start: isoDate(start), at, leader });
122
+ return leader;
123
+ }
124
+
125
+ async function readYear(now, history, pool) {
126
+ const start = monthStart(now.getUTCFullYear(), 0);
127
+ const out = { start: isoDate(start), since: isoDate(history && history > start ? history : start), winner: null, last: null };
128
+ if (!history) return out;
129
+ const leader = await yearLeader(start, now, pool);
130
+ if (leader) {
131
+ const { rows } = await pool.query(
132
+ `SELECT pa.handle, pa.github_login, pa.display_name, pa.avatar_url
133
+ FROM platform_identity_accounts pa
134
+ WHERE pa.github_id = $1::bigint AND ${rollupVisibleSql('pa')}`,
135
+ [leader.github_id],
136
+ );
137
+ if (rows[0]) out.winner = shown(rows[0], leader.credits, leader.tasks_shipped);
138
+ }
139
+ if (history < start) {
140
+ const prev = monthStart(now.getUTCFullYear() - 1, 0);
141
+ await crownOnce(`year:${isoDate(prev)}`, pool, () => crownPeriod('year', prev, start, pool, { fromFirstSnapshot: true }));
142
+ const winner = await readCrowned('year', prev, pool);
143
+ if (winner) out.last = { start: isoDate(prev), winner };
144
+ }
145
+ return out;
146
+ }
147
+
148
+ // readCrowns — { week: { start, end, winner }, month: { start, end, winner, first_crown },
149
+ // year: { start, since, winner, last } }: dates YYYY-MM-DD, an end the day the period
150
+ // closed on, a winner null when nobody is to be shown. `now` is injectable for tests.
151
+ async function readCrowns({ now = new Date(), pool = defaultPool } = {}) {
152
+ const history = historyStart(pool);
153
+ const [week, month, year] = await Promise.all([
154
+ readBuilderOfTheWeek({ now, pool }),
155
+ history.then((h) => readMonth(now, h, pool)),
156
+ history.then((h) => readYear(now, h, pool)),
157
+ ]);
158
+ const w = week.winner;
159
+ return {
160
+ week: { start: week.week.start, end: week.week.end, winner: w ? shown(w, w.credits, w.tasks_shipped) : null },
161
+ month,
162
+ year,
163
+ };
164
+ }
165
+
166
+ function clearCrownsMemo() {
167
+ historyMemo.clear();
168
+ leaderMemo.clear();
169
+ }
170
+
171
+ module.exports = {
172
+ readCrowns,
173
+ crownedMonth,
174
+ clearCrownsMemo,
175
+ LEADER_TTL_MS,
176
+ };
@@ -2,7 +2,7 @@
2
2
  // leaderboard (D3, docs/specs/bongos-v1-community-tab.md; task 1002978, goal
3
3
  // 1000110 CT2): "who earned the most across every project, this week / month /
4
4
  // year / ever", optionally narrowed to one discipline. The read behind
5
- // GET /community/leaderboard, for the hub's /community page (task 1003363).
5
+ // GET /community/leaderboard, for the hub's /community/leaderboard page (tasks 1003363, 1004902).
6
6
  //
7
7
  // WHO: rollupVisibleSql — active + public + NOT hide_stats, the privacy matrix's
8
8
  // NUMBERS predicate, imported from account-visibility.js and never restated. A
@@ -231,4 +231,7 @@ module.exports = {
231
231
  MAX_LIMIT,
232
232
  MAX_OFFSET,
233
233
  MAX_DISCIPLINE_CHARS,
234
+ // for community-movers.js (the rising builder), so who is shown and the tie order are one rule
235
+ eligibleIdentities,
236
+ compareRows,
234
237
  };
@@ -0,0 +1,152 @@
1
+ // modules/platform-identity/community-movers.js — the rising builder and the projects on the
2
+ // move, the read behind GET /community/movers for the row under the crowns on the hub's
3
+ // /community home (community spec D7 items 1–2; task 1002983, goal 1000110 CT7).
4
+ //
5
+ // THE 30 DAYS are rolling, up to now, and read from the snapshot history by the year's
6
+ // baseline rule (builder-of-the-week.js periodRowsSql, fromFirstSnapshot): a row first seen
7
+ // inside counts from zero, a row with no snapshot before them from its first one inside (an
8
+ // undercount, never a lifetime).
9
+ //
10
+ // THE RISING BUILDER (owner, 2026-10-28): the most credits earned in the 30 days among the
11
+ // builders who had under RISING_BASE_MAX before them, with at least one work shipped in
12
+ // them; ties as the crowns. "Before" is each row's baseline, and a row with no history in
13
+ // the 30 days at all counts its live total, so a veteran's quiet projects still count.
14
+ //
15
+ // THE PROJECTS ON THE MOVE (owner, 2026-10-28): the most works shipped in the 30 days, then
16
+ // credits, the top PROJECTS_MAX. Only the builders the leaderboard may show are counted
17
+ // (spec D8), and a project is named only where a profile may name it: on the map with the
18
+ // Public door (task 1003558); private and stealth projects are left off, not anonymised.
19
+ //
20
+ // TWO HALVES, as the leaderboard: the 30 days' rows (one snapshot probe per activity row)
21
+ // are memoised per pool for MOVERS_TTL_MS; who and which projects may be shown is asked on
22
+ // every read, so a hide, a private account or a closed door beats the memo. Viewer-
23
+ // independent; github_id and client_id never cross the projection.
24
+ 'use strict';
25
+
26
+ const { pool: defaultPool } = require('../../src/module-api');
27
+ const { periodRowsSql } = require('./builder-of-the-week');
28
+ const { eligibleIdentities, compareRows, windowStart } = require('./community-leaderboard');
29
+ const { onMapSql } = require('./projects-feed');
30
+
31
+ const WINDOW = '30d';
32
+ const RISING_BASE_MAX = 100;
33
+ const PROJECTS_MAX = 5;
34
+ const MOVERS_TTL_MS = 30 * 1000;
35
+
36
+ // Every eligible (project, builder) row's 30 days: [{ clientId, githubId, credits,
37
+ // tasks_shipped, karma, base }]. A row the period rule leaves out moved nowhere in them, so
38
+ // its deltas are zero and its baseline is its live total.
39
+ async function computeRows(start, end, pool) {
40
+ const { rows } = await pool.query(
41
+ `${periodRowsSql({ fromFirstSnapshot: true })}
42
+ SELECT a.client_id, a.github_id, pp.credits, pp.tasks_shipped, pp.karma,
43
+ COALESCE(pp.base_credits, a.credits::bigint) AS base_credits
44
+ FROM act
45
+ JOIN platform_identity_builder_project_activity a
46
+ ON a.client_id = act.client_id AND a.github_id = act.github_id
47
+ LEFT JOIN per_project pp
48
+ ON pp.client_id = act.client_id AND pp.github_id = act.github_id`,
49
+ [start, end],
50
+ );
51
+ return rows.map((r) => ({
52
+ clientId: String(r.client_id),
53
+ githubId: String(r.github_id),
54
+ credits: Number(r.credits || 0),
55
+ tasks_shipped: Number(r.tasks_shipped || 0),
56
+ karma: Number(r.karma || 0),
57
+ base: Number(r.base_credits || 0),
58
+ }));
59
+ }
60
+
61
+ // pool → { at, start, end, promise }. Concurrent reads share one in-flight computation; a
62
+ // failed one is dropped so the next read retries.
63
+ const rowsMemo = new Map();
64
+ function windowRows(now, pool) {
65
+ const m = rowsMemo.get(pool);
66
+ const at = now.getTime();
67
+ if (m && at >= m.at && at - m.at < MOVERS_TTL_MS) return m;
68
+ const start = windowStart(WINDOW, now);
69
+ const entry = { at, start, end: now, promise: computeRows(start, now, pool) };
70
+ entry.promise.catch(() => { if (rowsMemo.get(pool) === entry) rowsMemo.delete(pool); });
71
+ rowsMemo.set(pool, entry);
72
+ return entry;
73
+ }
74
+
75
+ // The projects among `clientIds` a profile may name, now: Map clientId → { name, origin }.
76
+ async function namedProjects(clientIds, pool) {
77
+ if (clientIds.length === 0) return new Map();
78
+ const { rows } = await pool.query(
79
+ `SELECT c.client_id, p.name, p.origin
80
+ FROM platform_identity_sso_clients c
81
+ JOIN platform_identity_projects p ON p.origin = c.origin
82
+ WHERE c.client_id = ANY($1::text[])
83
+ AND ${onMapSql('p')} AND p.visibility = 'public'`,
84
+ [clientIds],
85
+ );
86
+ return new Map(rows.map((r) => [String(r.client_id), { name: r.name, origin: r.origin }]));
87
+ }
88
+
89
+ // Sums `rows` per `key`, from zero.
90
+ function sumBy(rows, key) {
91
+ const out = new Map();
92
+ for (const r of rows) {
93
+ const k = r[key];
94
+ const s = out.get(k) || { credits: 0, tasks_shipped: 0, karma: 0, base: 0 };
95
+ s.credits += r.credits;
96
+ s.tasks_shipped += r.tasks_shipped;
97
+ s.karma += r.karma;
98
+ s.base += r.base;
99
+ out.set(k, s);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ function risingBuilder(rows, identities) {
105
+ const ranked = [...sumBy(rows, 'githubId')]
106
+ .filter(([, s]) => s.base < RISING_BASE_MAX && s.tasks_shipped >= 1)
107
+ .map(([id, s]) => ({ ...identities.get(id), credits: s.credits, tasks_shipped: s.tasks_shipped, karma: s.karma }))
108
+ .sort(compareRows);
109
+ if (!ranked.length) return null;
110
+ const { handle, github_login, display_name, avatar_url, credits, tasks_shipped } = ranked[0];
111
+ return { handle, github_login, display_name, avatar_url, credits, tasks_shipped };
112
+ }
113
+
114
+ const byText = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
115
+ function projectsOnTheMove(rows, named) {
116
+ return [...sumBy(rows.filter((r) => named.has(r.clientId)), 'clientId')]
117
+ .filter(([, s]) => s.tasks_shipped >= 1)
118
+ .map(([id, s]) => ({ ...named.get(id), tasks_shipped: s.tasks_shipped, credits: s.credits }))
119
+ .sort((a, b) => (b.tasks_shipped - a.tasks_shipped) || (b.credits - a.credits)
120
+ || byText(String(a.name), String(b.name)) || byText(String(a.origin), String(b.origin)))
121
+ .slice(0, PROJECTS_MAX);
122
+ }
123
+
124
+ // readMovers — { window: { start, end }, rising, projects }: the window as ISO instants,
125
+ // rising null when nobody qualifies or may be shown, projects best first (at most
126
+ // PROJECTS_MAX). `now` is injectable for tests.
127
+ async function readMovers({ now = new Date(), pool = defaultPool } = {}) {
128
+ const w = windowRows(now, pool);
129
+ const rows = await w.promise;
130
+ // Only a builder or project with a shipped work in the 30 days can be on either card.
131
+ const shipped = rows.filter((r) => r.tasks_shipped > 0);
132
+ const identities = await eligibleIdentities([...new Set(shipped.map((r) => r.githubId))], pool);
133
+ const shown = rows.filter((r) => identities.has(r.githubId));
134
+ const named = await namedProjects([...new Set(shown.filter((r) => r.tasks_shipped > 0).map((r) => r.clientId))], pool);
135
+ return {
136
+ window: { start: w.start.toISOString(), end: w.end.toISOString() },
137
+ rising: risingBuilder(shown, identities),
138
+ projects: projectsOnTheMove(shown, named),
139
+ };
140
+ }
141
+
142
+ function clearMoversMemo() {
143
+ rowsMemo.clear();
144
+ }
145
+
146
+ module.exports = {
147
+ readMovers,
148
+ clearMoversMemo,
149
+ RISING_BASE_MAX,
150
+ PROJECTS_MAX,
151
+ MOVERS_TTL_MS,
152
+ };
@@ -0,0 +1,43 @@
1
+ -- platform_identity_033_period_features.sql — Builder of the month and of the year, one
2
+ -- row per crowned month or year (task 1004902; the week is migration 032's own table).
3
+ --
4
+ -- WHY. The hub's /community page shows a builder of the week, of the month and of the
5
+ -- year (owner, 2026-10-27). The month is decided like the week: the first read of
6
+ -- GET /community/crowns after the 1st (UTC) decides the month that just ended and writes
7
+ -- it here. The year is shown live as "so far", and the first read on or after 1 January
8
+ -- decides the year that just ended and writes it here. The row is what pins the crown:
9
+ -- a late report or a builder going public cannot take a period already decided.
10
+ --
11
+ -- THE SHAPE CARRIES THE RULES (owner, 2026-10-27):
12
+ -- * THE KEY IS (period, period_start), the first day (UTC) of the month or year, so a
13
+ -- period has one row. The writer is INSERT ... ON CONFLICT DO NOTHING, and nothing
14
+ -- updates a row: the first decision stands.
15
+ -- * github_id IS NULL when nobody qualified. The empty period is recorded too, so it
16
+ -- stays empty.
17
+ -- * credits_delta / ships_delta are the period's numbers as decided, bigint like the
18
+ -- leaderboard's arithmetic (builder-of-the-week.js computeWinner).
19
+ -- * NO FOREIGN KEY to platform_identity_accounts, like every github_id column in this
20
+ -- module. Whether a winner may be SHOWN is decided at read time by rollupVisibleSql
21
+ -- (account-visibility.js), never by this row.
22
+ --
23
+ -- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141): descriptive, public analytics.
24
+ --
25
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). A CREATE only, so the previous
26
+ -- release still runs against this schema. Idempotent.
27
+
28
+ BEGIN;
29
+
30
+ CREATE TABLE IF NOT EXISTS platform_identity_period_features (
31
+ period text NOT NULL CHECK (period IN ('month', 'year')),
32
+ period_start date NOT NULL,
33
+ github_id bigint,
34
+ credits_delta bigint NOT NULL,
35
+ ships_delta bigint NOT NULL,
36
+ crowned_at timestamptz NOT NULL DEFAULT now(),
37
+ PRIMARY KEY (period, period_start)
38
+ );
39
+
40
+ COMMENT ON TABLE platform_identity_period_features IS
41
+ 'Builder of the month and of the year, one row per period, keyed on its first day (UTC) (task 1004902). Written once by the first read after the period ends, never updated; github_id NULL = nobody qualified. Whether a winner is shown is decided at read time by the leaderboard''s eligibility predicate.';
42
+
43
+ COMMIT;
@@ -1,13 +1,16 @@
1
1
  // modules/platform-identity/routes/community-leaderboard.js — GET
2
2
  // /community/leaderboard, the PUBLIC windowed leaderboard behind the hub's
3
- // /community page (D3, docs/specs/bongos-v1-community-tab.md; task 1002978,
3
+ // /community/leaderboard page (D3, docs/specs/bongos-v1-community-tab.md; task 1002978,
4
4
  // goal 1000110 CT2). The sibling of routes/community-search.js: same module,
5
5
  // same public posture. Who appears and how a window is counted live entirely in
6
6
  // ../community-leaderboard.js; this file is the HTTP shell — validation, the
7
7
  // per-IP read cap, the response envelope. It never reads a session, so the
8
8
  // board is the same for every viewer.
9
9
  // It also serves GET /community/builder-of-the-week (spec D5, task 1002981), the
10
- // board's weekly crown, from ../builder-of-the-week.js on the same terms.
10
+ // board's weekly crown, from ../builder-of-the-week.js on the same terms, and GET
11
+ // /community/crowns (task 1004902), the week, month and year for the hub's /community
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.
11
14
  //
12
15
  // Reaches core only through the doorway (ADR 0083).
13
16
  'use strict';
@@ -16,6 +19,8 @@ const express = require('express');
16
19
  const api = require('../../../src/module-api');
17
20
  const leaderboard = require('../community-leaderboard');
18
21
  const builderOfTheWeek = require('../builder-of-the-week');
22
+ const communityCrowns = require('../community-crowns');
23
+ const communityMovers = require('../community-movers');
19
24
  const { createReadRateLimit } = require('../read-rate-limit');
20
25
 
21
26
  const { pool, corsPublicGet } = api;
@@ -26,6 +31,10 @@ const leaderboardReadRateLimit = createReadRateLimit({ scope: READ_RATE_SCOPE, l
26
31
  // Builder of the Week spends its own budget (ADR 0209: never a shared one).
27
32
  const WEEK_READ_RATE_SCOPE = 'builder-of-the-week-read';
28
33
  const weekReadRateLimit = createReadRateLimit({ scope: WEEK_READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
34
+ const CROWNS_READ_RATE_SCOPE = 'community-crowns-read';
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 });
29
38
 
30
39
  module.exports = function communityLeaderboardRoutes() {
31
40
  const router = express.Router();
@@ -87,7 +96,40 @@ module.exports = function communityLeaderboardRoutes() {
87
96
  }
88
97
  });
89
98
 
99
+ router.use('/community/crowns', corsPublicGet('/community/crowns'));
100
+
101
+ // rank: public — the builders of the week, the month and the year (task 1004902): the
102
+ // week and month as crowned, the year so far. Takes no parameters; the first read after
103
+ // a week, month or year ends also records it (one row per period). Viewer-independent;
104
+ // no-store so a winner who hides leaves the page on the next read (ADR 0171 D5).
105
+ router.get('/community/crowns', crownsReadRateLimit, async (req, res) => {
106
+ try {
107
+ const out = await communityCrowns.readCrowns({ pool });
108
+ res.set('Cache-Control', 'no-store');
109
+ res.json(out);
110
+ } catch (err) {
111
+ api.logger('platform-identity').error({ err: err && err.message }, 'GET /community/crowns failed');
112
+ res.fail('community_crowns_failed', { status: 500, message: 'internal error' });
113
+ }
114
+ });
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
+
90
132
  return router;
91
133
  };
92
134
 
93
- module.exports._internals = { READ_RATE_LIMIT, READ_RATE_SCOPE, WEEK_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 };
@@ -117,7 +117,8 @@
117
117
  <div class="menuBand" id="menuCommunity" data-menu="community" hidden>
118
118
  <div class="bandIn">
119
119
  <ul class="bandRow">
120
- <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
120
+ <li class="bandCell"><a class="bandT" href="/community">Community home</a><span class="bandC">the builders of the week, month and year</span></li>
121
+ <li class="bandCell"><a class="bandT" href="/community/leaderboard">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
121
122
  <li class="bandCell"><a class="bandT" href="/community/week">Builder of the week</a><span class="bandC">the top earner of each week, and how it is chosen</span></li>
122
123
  <li class="bandCell"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li>
123
124
  <li class="bandCell"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li>
@@ -132,7 +133,7 @@
132
133
  <li class="bandCell"><a class="bandT" href="/projects?view=map&amp;sub=table">Table</a><span class="bandC">the same projects as rows</span></li>
133
134
  <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own</span></li>
134
135
  <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
135
- <li class="bandCell bandFold"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/week">Builder of the week</a><span class="bandC">the top earner of each week, and how it is chosen</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li><li class="bandCell bandFold knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>
136
+ <li class="bandCell bandFold"><a class="bandT" href="/community">Community home</a><span class="bandC">the builders of the week, month and year</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/leaderboard">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/week">Builder of the week</a><span class="bandC">the top earner of each week, and how it is chosen</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/find">Find a builder</a><span class="bandC">anyone building here, by name or role</span></li><li class="bandCell bandFold"><a class="bandT" href="/community/recruiters">Open to recruiters</a><span class="bandC">who is open to recruiting, for project owners</span></li><li class="bandCell bandFold knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>
136
137
  </ul>
137
138
  </div>
138
139
  </div>
@@ -0,0 +1,15 @@
1
+ {
2
+ "_": "The community home before the first month is crowned (task 1004902; fixtures/community-crowns-first.json): what the hub shows in late October 2026. September began before snapshot history did, so the month card names the day October is crowned; the year counts since history began.",
3
+ "page": "/community",
4
+ "surface": "public-landing",
5
+ "modes": ["dark"],
6
+ "modeQuery": false,
7
+ "stub": { "fixtures": { "community-crowns": "community-crowns-first" } },
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-first": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#home", "#homeCrowns", "#crownWeek .crownName", "#crownMonth .commMsg", "#crownYear .crownName"], "hidden": ["#homeFailed", "#crownYearLast"], "text": [["#crownWeekWhen", "19–25 Oct 2026"], ["#crownMonthWhen", "September 2026"], ["#crownMonth .commMsg", "The first builder of the month is named on 1 Nov 2026."], ["#crownYearWhen", "2026 so far, since 27 Sep 2026"], ["#crownYear .crownName", "Priya Nair"]] } }
13
+ },
14
+ "reducedMotion": { "auth": false, "actions": [["wait", 400]] }
15
+ }