@bongos/core 1.23.18 → 1.23.19

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 (54) hide show
  1. package/.bongos-core.json +104 -49
  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 +985 -934
  10. package/docs/copy-registry.json +1829 -1309
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-inventory.json +10 -2
  13. package/docs/page-readings.json +601 -576
  14. package/modules/platform-identity/builder-of-the-week.js +48 -20
  15. package/modules/platform-identity/community-crowns.js +176 -0
  16. package/modules/platform-identity/community-leaderboard.js +1 -1
  17. package/modules/platform-identity/migrations/platform_identity_033_period_features.sql +43 -0
  18. package/modules/platform-identity/routes/community-leaderboard.js +25 -3
  19. package/modules/public-landing/public/account.html +3 -2
  20. package/modules/public-landing/public/community-crowns-first.states.json +15 -0
  21. package/modules/public-landing/public/community-crowns-new-year.states.json +15 -0
  22. package/modules/public-landing/public/community-crowns-none.states.json +15 -0
  23. package/modules/public-landing/public/community-empty.states.json +1 -1
  24. package/modules/public-landing/public/community-owner-two.states.json +2 -2
  25. package/modules/public-landing/public/community-owner.states.json +1 -1
  26. package/modules/public-landing/public/community-week-none.states.json +1 -1
  27. package/modules/public-landing/public/community.html +130 -22
  28. package/modules/public-landing/public/community.probes.json +20 -3
  29. package/modules/public-landing/public/community.states.json +8 -6
  30. package/modules/public-landing/public/connections.html +3 -2
  31. package/modules/public-landing/public/contact.html +3 -2
  32. package/modules/public-landing/public/guild.html +3 -2
  33. package/modules/public-landing/public/index.html +3 -2
  34. package/modules/public-landing/public/invitations.html +3 -2
  35. package/modules/public-landing/public/privacy.html +3 -2
  36. package/modules/public-landing/public/projects.html +3 -2
  37. package/modules/public-landing/public/terms.html +3 -2
  38. package/modules/ui-design/kit/fixtures/community-crowns-first.json +6 -0
  39. package/modules/ui-design/kit/fixtures/community-crowns-new-year.json +6 -0
  40. package/modules/ui-design/kit/fixtures/community-crowns-none.json +6 -0
  41. package/modules/ui-design/kit/fixtures/community-crowns.json +6 -0
  42. package/modules/ui-design/kit/serve.js +14 -0
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +6 -0
  46. package/scripts/bongos/run-unit-tests.js +4 -0
  47. package/src/module-api.js +1 -1
  48. package/src/platform-server.js +4 -3
  49. package/tests/community_crowns.mjs +369 -0
  50. package/tests/community_crowns_db.mjs +164 -0
  51. package/tests/hub_account_page.mjs +2 -2
  52. package/tests/hub_community_page.mjs +144 -29
  53. package/tests/landing_legal_pages.mjs +2 -2
  54. package/tests/privacy_matrix.mjs +68 -9
@@ -53,9 +53,27 @@ 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) {
56
+ // The winner of [start, end) among eligible builders, or null; the week's, and the month's
57
+ // and year's (community-crowns.js). Arithmetic is bigint for the leaderboard's reason: the
58
+ // columns are instance-reported int4. `fromFirstSnapshot` is the YEAR's rule (owner,
59
+ // 2026-10-27): a row with no snapshot before the start, and not first seen inside, counts
60
+ // from its first snapshot inside instead of not at all, since history began in late
61
+ // September 2026 and the rule above would leave that year to new builders alone.
62
+ async function computeWinner(start, end, pool, { fromFirstSnapshot = false } = {}) {
63
+ const base = (col) => (fromFirstSnapshot
64
+ ? `COALESCE(b.${col}, CASE WHEN act.first_seen_at >= $1::timestamptz THEN 0 ELSE f.${col} END, 0)`
65
+ : `COALESCE(b.${col}, 0)`);
66
+ const firstInside = fromFirstSnapshot
67
+ ? `
68
+ LEFT JOIN LATERAL (
69
+ SELECT s.credits, s.tasks_shipped, s.karma
70
+ FROM platform_identity_activity_snapshots s
71
+ WHERE s.client_id = act.client_id AND s.github_id = act.github_id
72
+ AND s.reported_at > $1::timestamptz AND s.reported_at <= $2::timestamptz
73
+ ORDER BY s.reported_at ASC, s.id ASC
74
+ LIMIT 1
75
+ ) f ON true`
76
+ : '';
59
77
  const { rows } = await pool.query(
60
78
  `WITH act AS (
61
79
  SELECT a.client_id, a.github_id, a.first_seen_at, pa.handle, pa.github_login
@@ -64,9 +82,9 @@ async function computeWinner(start, end, pool) {
64
82
  WHERE ${rollupVisibleSql('pa')}
65
83
  ), per_project AS (
66
84
  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
+ COALESCE(e.credits, 0)::bigint - ${base('credits')} AS credits,
86
+ COALESCE(e.tasks_shipped, 0)::bigint - ${base('tasks_shipped')} AS tasks_shipped,
87
+ COALESCE(e.karma, 0)::bigint - ${base('karma')} AS karma
70
88
  FROM act
71
89
  LEFT JOIN LATERAL (
72
90
  SELECT s.credits, s.tasks_shipped, s.karma
@@ -83,8 +101,8 @@ async function computeWinner(start, end, pool) {
83
101
  AND s.reported_at <= $1::timestamptz
84
102
  ORDER BY s.reported_at DESC, s.id DESC
85
103
  LIMIT 1
86
- ) b ON true
87
- WHERE b.credits IS NOT NULL OR act.first_seen_at >= $1::timestamptz
104
+ ) b ON true${firstInside}
105
+ WHERE b.credits IS NOT NULL OR act.first_seen_at >= $1::timestamptz${fromFirstSnapshot ? ' OR f.credits IS NOT NULL' : ''}
88
106
  )
89
107
  SELECT github_id, sum(credits)::bigint AS credits, sum(tasks_shipped)::bigint AS tasks_shipped
90
108
  FROM per_project
@@ -98,10 +116,11 @@ async function computeWinner(start, end, pool) {
98
116
  return rows[0] || null;
99
117
  }
100
118
 
101
- // One crowning per week per process: concurrent first reads share it, and a week
102
- // already on record is not asked about again.
119
+ // One crowning per period per process: concurrent first reads share it, and a period
120
+ // already on record is not asked about again. Keyed 'week:', 'month:' or 'year:' plus the
121
+ // period's first day, so community-crowns.js shares it.
103
122
  const crowning = new Map();
104
- let lastCrowned = null;
123
+ const crowned = new Map(); // key → the pool it was crowned on
105
124
 
106
125
  async function crownWeek(start, end, pool) {
107
126
  const week = isoDate(start);
@@ -119,22 +138,27 @@ async function crownWeek(start, end, pool) {
119
138
  );
120
139
  }
121
140
 
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);
141
+ // Runs `crown()` unless `key` is already crowned on this pool; a failed one is not
142
+ // remembered, so the next read tries again.
143
+ function crownOnce(key, pool, crown) {
144
+ if (crowned.get(key) === pool) return Promise.resolve();
145
+ const inFlight = crowning.get(key);
126
146
  if (inFlight && inFlight.pool === pool) return inFlight.promise;
127
147
  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);
148
+ entry.promise = crown()
149
+ .then(() => { crowned.set(key, pool); })
150
+ .finally(() => { if (crowning.get(key) === entry) crowning.delete(key); });
151
+ crowning.set(key, entry);
132
152
  return entry.promise;
133
153
  }
134
154
 
155
+ function ensureCrowned(start, end, pool) {
156
+ return crownOnce(`week:${isoDate(start)}`, pool, () => crownWeek(start, end, pool));
157
+ }
158
+
135
159
  function clearCrownMemo() {
136
160
  crowning.clear();
137
- lastCrowned = null;
161
+ crowned.clear();
138
162
  }
139
163
 
140
164
  // readBuilderOfTheWeek — crowns the last full week if nobody has yet, then reads it and
@@ -177,4 +201,8 @@ module.exports = {
177
201
  crownedWeek,
178
202
  clearCrownMemo,
179
203
  ARCHIVE_WEEKS,
204
+ // for community-crowns.js (the month and the year)
205
+ computeWinner,
206
+ crownOnce,
207
+ isoDate,
180
208
  };
@@ -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
@@ -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,15 @@
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.
11
13
  //
12
14
  // Reaches core only through the doorway (ADR 0083).
13
15
  'use strict';
@@ -16,6 +18,7 @@ const express = require('express');
16
18
  const api = require('../../../src/module-api');
17
19
  const leaderboard = require('../community-leaderboard');
18
20
  const builderOfTheWeek = require('../builder-of-the-week');
21
+ const communityCrowns = require('../community-crowns');
19
22
  const { createReadRateLimit } = require('../read-rate-limit');
20
23
 
21
24
  const { pool, corsPublicGet } = api;
@@ -26,6 +29,8 @@ const leaderboardReadRateLimit = createReadRateLimit({ scope: READ_RATE_SCOPE, l
26
29
  // Builder of the Week spends its own budget (ADR 0209: never a shared one).
27
30
  const WEEK_READ_RATE_SCOPE = 'builder-of-the-week-read';
28
31
  const weekReadRateLimit = createReadRateLimit({ scope: WEEK_READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
32
+ const CROWNS_READ_RATE_SCOPE = 'community-crowns-read';
33
+ const crownsReadRateLimit = createReadRateLimit({ scope: CROWNS_READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
29
34
 
30
35
  module.exports = function communityLeaderboardRoutes() {
31
36
  const router = express.Router();
@@ -87,7 +92,24 @@ module.exports = function communityLeaderboardRoutes() {
87
92
  }
88
93
  });
89
94
 
95
+ router.use('/community/crowns', corsPublicGet('/community/crowns'));
96
+
97
+ // rank: public — the builders of the week, the month and the year (task 1004902): the
98
+ // week and month as crowned, the year so far. Takes no parameters; the first read after
99
+ // a week, month or year ends also records it (one row per period). Viewer-independent;
100
+ // no-store so a winner who hides leaves the page on the next read (ADR 0171 D5).
101
+ router.get('/community/crowns', crownsReadRateLimit, async (req, res) => {
102
+ try {
103
+ const out = await communityCrowns.readCrowns({ pool });
104
+ res.set('Cache-Control', 'no-store');
105
+ res.json(out);
106
+ } catch (err) {
107
+ api.logger('platform-identity').error({ err: err && err.message }, 'GET /community/crowns failed');
108
+ res.fail('community_crowns_failed', { status: 500, message: 'internal error' });
109
+ }
110
+ });
111
+
90
112
  return router;
91
113
  };
92
114
 
93
- module.exports._internals = { READ_RATE_LIMIT, READ_RATE_SCOPE, WEEK_READ_RATE_SCOPE };
115
+ module.exports._internals = { READ_RATE_LIMIT, READ_RATE_SCOPE, WEEK_READ_RATE_SCOPE, CROWNS_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
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "_": "The community home in early January 2027 (task 1004902; fixtures/community-crowns-new-year.json): 2026 crowned for good and named under the new year so far; history began before 2027, so no 'since'.",
3
+ "page": "/community",
4
+ "surface": "public-landing",
5
+ "modes": ["dark"],
6
+ "modeQuery": false,
7
+ "stub": { "fixtures": { "community-crowns": "community-crowns-new-year" } },
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-new-year": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#home", "#homeCrowns", "#crownYearLast"], "hidden": ["#homeFailed"], "text": [["#crownMonthWhen", "December 2026"], ["#crownYearWhen", "2027 so far"], ["#crownYear .crownName", "Lin Wei"], ["#crownYearLast", "2026: Maximiliana van der Berghe-Lindqvist, 11,250 credits"]] } }
13
+ },
14
+ "reducedMotion": { "auth": false, "actions": [["wait", 400]] }
15
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "_": "The community home with no one to show (task 1004902; fixtures/community-crowns-none.json): nobody qualified, or each winner hides their numbers — the two look the same (spec D8). Each card says so in one line.",
3
+ "page": "/community",
4
+ "surface": "public-landing",
5
+ "modes": ["dark"],
6
+ "modeQuery": false,
7
+ "stub": { "fixtures": { "community-crowns": "community-crowns-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-none": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#home", "#homeCrowns", "#crownWeek .commMsg", "#crownMonth .commMsg", "#crownYear .commMsg"], "hidden": ["#homeFailed", "#crownYearLast"], "count": [["#homeCrowns .crownName", 0]], "text": [["#crownWeek .commMsg", "No builder of the week for 26 Oct – 1 Nov 2026."], ["#crownMonth .commMsg", "No builder of the month for October 2026."], ["#crownYear .commMsg", "No builder of 2026 yet."]] } }
13
+ },
14
+ "reducedMotion": { "auth": false, "actions": [["wait", 400]] }
15
+ }
@@ -9,7 +9,7 @@
9
9
  "ignoreConsole": ["/api/(gds|bongos)/me", "/scouting", "\\b40[13]\\b", "Unauthorized", "Forbidden"],
10
10
  "ignoreControls": [".vh"],
11
11
  "states": {
12
- "out-empty": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#boardEmpty", "#boardWindow"], "hidden": ["#boardFailed", "#boardLoading", "#find", "#recruit"], "count": [["#boardList .commRow", 0]], "text": [["#boardEmpty", "No builders on the leaderboard yet."]] } }
12
+ "out-empty": { "auth": false, "url": "/community/leaderboard", "actions": [["wait", 400]], "expect": { "visible": ["#boardEmpty", "#boardWindow"], "hidden": ["#boardFailed", "#boardLoading", "#find", "#recruit"], "count": [["#boardList .commRow", 0]], "text": [["#boardEmpty", "No builders on the leaderboard yet."]] } }
13
13
  },
14
14
  "reducedMotion": { "auth": false, "actions": [["wait", 400]] }
15
15
  }
@@ -9,8 +9,8 @@
9
9
  "ignoreConsole": ["/api/(gds|bongos)/me", "/scouting", "\\b40[13]\\b", "Unauthorized", "Forbidden"],
10
10
  "ignoreControls": [".vh"],
11
11
  "states": {
12
- "in-owner-two": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#boardFor", "#boardForPick"], "value": [["#boardForPick", "0"]], "count": [["#boardForPick option", 2], ["#boardList a.commRecruit", 6]], "href": [["#boardList .commRow:first-child .commRecruit", "https://mercury.example.test/builders/watch?invite=priyanair"]] } },
13
- "in-owner-two-picked": { "auth": true, "actions": [["wait", 600], ["select", "#boardForPick", "1"], ["wait", 200]], "expect": { "value": [["#boardForPick", "1"]], "href": [["#boardList .commRow:first-child .commRecruit", "https://tidepool.example.test/builders/watch?invite=priyanair"]], "attr": [["#boardList .commRow:first-child .commRecruit", "aria-label", "Recruit Priya Nair to Tidepool (opens in a new tab)"]] } }
12
+ "in-owner-two": { "auth": true, "url": "/community/leaderboard", "actions": [["wait", 600]], "expect": { "visible": ["#boardFor", "#boardForPick"], "value": [["#boardForPick", "0"]], "count": [["#boardForPick option", 2], ["#boardList a.commRecruit", 6]], "href": [["#boardList .commRow:first-child .commRecruit", "https://mercury.example.test/builders/watch?invite=priyanair"]] } },
13
+ "in-owner-two-picked": { "auth": true, "url": "/community/leaderboard", "actions": [["wait", 600], ["select", "#boardForPick", "1"], ["wait", 200]], "expect": { "value": [["#boardForPick", "1"]], "href": [["#boardList .commRow:first-child .commRecruit", "https://tidepool.example.test/builders/watch?invite=priyanair"]], "attr": [["#boardList .commRow:first-child .commRecruit", "aria-label", "Recruit Priya Nair to Tidepool (opens in a new tab)"]] } }
14
14
  },
15
15
  "reducedMotion": { "auth": true, "actions": [["wait", 600]] }
16
16
  }
@@ -9,7 +9,7 @@
9
9
  "ignoreConsole": ["/api/(gds|bongos)/me", "/scouting", "\\b40[13]\\b", "Unauthorized", "Forbidden"],
10
10
  "ignoreControls": [".vh"],
11
11
  "states": {
12
- "in-owner": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#board", "#boardFor", "#who"], "hidden": ["#find", "#recruit", "#boardFailed"], "count": [["#boardList .commRow", 6], ["#boardList a.commRecruit", 6]], "text": [["#boardFor", "Recruit opens Mercury’s hall with their name filled in, and you send the invite there."], ["#boardList .commRow:first-child .commRecruit", "Recruit"]], "href": [["#boardList .commRow:first-child .commRecruit", "https://mercury.example.test/builders/watch?invite=priyanair"]] } },
12
+ "in-owner": { "auth": true, "url": "/community/leaderboard", "actions": [["wait", 600]], "expect": { "visible": ["#board", "#boardFor", "#who"], "hidden": ["#find", "#recruit", "#boardFailed"], "count": [["#boardList .commRow", 6], ["#boardList a.commRecruit", 6]], "text": [["#boardFor", "Recruit opens Mercury’s hall with their name filled in, and you send the invite there."], ["#boardList .commRow:first-child .commRecruit", "Recruit"]], "href": [["#boardList .commRow:first-child .commRecruit", "https://mercury.example.test/builders/watch?invite=priyanair"]] } },
13
13
  "in-owner-find": { "auth": true, "url": "/community/find", "actions": [["wait", 600], ["fill", "#findQ", "pri"], ["click", "#findBtn"], ["wait", 300]], "expect": { "visible": ["#find", "#findFor"], "hidden": ["#board", "#recruit"], "count": [["#findList .mineRow", 2], ["#findList .mineRow__actions a.commRecruit", 2]], "href": [["#findList .mineRow:first-child .commRecruit", "https://mercury.example.test/builders/watch?invite=priyanair"]] } }
14
14
  },
15
15
  "reducedMotion": { "auth": true, "actions": [["wait", 600]] }
@@ -10,7 +10,7 @@
10
10
  "ignoreControls": [".vh"],
11
11
  "states": {
12
12
  "out-week-none": { "auth": false, "url": "/community/week", "actions": [["wait", 400]], "expect": { "visible": ["#week", "#weekNone", "#weekRuleH"], "hidden": ["#weekCrown", "#weekLoading", "#weekFailed", "#pastH"], "count": [["#weekPast .commRow", 0]], "text": [["#weekNone", "No builder of the week for 19–25 Oct 2026."]] } },
13
- "out-board-no-crown": { "auth": false, "actions": [["wait", 400]], "expect": { "visible": ["#board", "#boardList"], "hidden": ["#boardCrown"] } }
13
+ "out-board-no-crown": { "auth": false, "url": "/community/leaderboard", "actions": [["wait", 400]], "expect": { "visible": ["#board", "#boardList"], "hidden": ["#boardCrown"] } }
14
14
  },
15
15
  "reducedMotion": { "auth": false, "actions": [["wait", 400]] }
16
16
  }