@bongos/core 1.23.17 → 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 (59) hide show
  1. package/.bongos-core.json +109 -54
  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 +4 -0
  12. package/docs/page-inventory.json +10 -2
  13. package/docs/page-readings.json +601 -576
  14. package/modules/copy-desk/auto-close.js +9 -3
  15. package/modules/copy-desk/page-status.js +25 -13
  16. package/modules/copy-desk/tests/copy_page_auto_close.mjs +8 -0
  17. package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
  18. package/modules/platform-identity/builder-of-the-week.js +48 -20
  19. package/modules/platform-identity/community-crowns.js +176 -0
  20. package/modules/platform-identity/community-leaderboard.js +1 -1
  21. package/modules/platform-identity/migrations/platform_identity_033_period_features.sql +43 -0
  22. package/modules/platform-identity/routes/community-leaderboard.js +25 -3
  23. package/modules/public-landing/public/account.html +3 -2
  24. package/modules/public-landing/public/community-crowns-first.states.json +15 -0
  25. package/modules/public-landing/public/community-crowns-new-year.states.json +15 -0
  26. package/modules/public-landing/public/community-crowns-none.states.json +15 -0
  27. package/modules/public-landing/public/community-empty.states.json +1 -1
  28. package/modules/public-landing/public/community-owner-two.states.json +2 -2
  29. package/modules/public-landing/public/community-owner.states.json +1 -1
  30. package/modules/public-landing/public/community-week-none.states.json +1 -1
  31. package/modules/public-landing/public/community.html +130 -22
  32. package/modules/public-landing/public/community.probes.json +20 -3
  33. package/modules/public-landing/public/community.states.json +8 -6
  34. package/modules/public-landing/public/connections.html +3 -2
  35. package/modules/public-landing/public/contact.html +3 -2
  36. package/modules/public-landing/public/guild.html +3 -2
  37. package/modules/public-landing/public/index.html +3 -2
  38. package/modules/public-landing/public/invitations.html +3 -2
  39. package/modules/public-landing/public/privacy.html +3 -2
  40. package/modules/public-landing/public/projects.html +3 -2
  41. package/modules/public-landing/public/terms.html +3 -2
  42. package/modules/ui-design/kit/fixtures/community-crowns-first.json +6 -0
  43. package/modules/ui-design/kit/fixtures/community-crowns-new-year.json +6 -0
  44. package/modules/ui-design/kit/fixtures/community-crowns-none.json +6 -0
  45. package/modules/ui-design/kit/fixtures/community-crowns.json +6 -0
  46. package/modules/ui-design/kit/serve.js +14 -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 +4 -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/copy_stuck_page_tweaks.mjs +39 -2
  56. package/tests/hub_account_page.mjs +2 -2
  57. package/tests/hub_community_page.mjs +144 -29
  58. package/tests/landing_legal_pages.mjs +2 -2
  59. package/tests/privacy_matrix.mjs +68 -9
@@ -13,8 +13,11 @@
13
13
  // rewrite left the batch for an engineer task at submit
14
14
  // (unplaced, shared or removed lines), so /tweak has no line
15
15
  // to apply and the round can never move on its own.
16
+ // draft_expired `ready` with a draft and no batch, untouched for
17
+ // page-status's DRAFT_EXPIRES_AFTER (14 days, task 1004901):
18
+ // its artist was reminded at 7 days and never came back.
16
19
  //
17
- // Both are abandoned through the lifecycle port's releaseStuckTweak (no compose,
20
+ // All three are abandoned through the lifecycle port's releaseStuckTweak (no compose,
18
21
  // so the note is the reason and nothing is written to the description). The
19
22
  // engineer tasks a submit filed are untouched: they carry the artist's words.
20
23
  //
@@ -24,20 +27,23 @@
24
27
 
25
28
  const api = require('../../src/module-api');
26
29
  const pages = require('./pages');
30
+ const pageStatus = require('./page-status');
27
31
 
28
32
  const GRACE_MS = Number(process.env.COPY_AUTO_CLOSE_GRACE_MS) || 60 * 60_000;
29
33
 
30
34
  const NOTES = Object.freeze({
31
35
  blank: 'Closed by itself: the round was opened but nothing was written in it.',
32
36
  nothing_to_apply: 'Closed by itself: every rewritten line went to an engineer task, so nothing was left to apply on the page.',
37
+ draft_expired: 'Closed by itself: the draft sat untouched for two weeks. Its words stay on this task; claim the page again to start over.',
33
38
  });
34
39
 
35
- function closeReason(task, { now = Date.now(), graceMs = GRACE_MS } = {}) {
40
+ function closeReason(task, { now = Date.now(), graceMs = GRACE_MS, draftExpiresMs = pageStatus.DRAFT_EXPIRES_AFTER } = {}) {
36
41
  if (!task || task.status !== 'ready' || task.holder_id != null) return null;
37
42
  const touched = Date.parse(task.updated_at || task.created_at || '');
38
43
  if (!Number.isFinite(touched) || now - touched < graceMs) return null;
39
44
  const p = pages.blockPresence(task.description);
40
- if (!p.batch) return p.draft ? null : 'blank';
45
+ // task 1004901: a draft its artist left untouched for two weeks expires.
46
+ if (!p.batch) return p.draft ? (now - touched >= draftExpiresMs ? 'draft_expired' : null) : 'blank';
41
47
  const b = pages.parseBatchBlock(task.description);
42
48
  if (b.ok && b.batch.lines.length === 0) return 'nothing_to_apply';
43
49
  return null;
@@ -306,22 +306,29 @@ function orderedSurfaces(inventory) {
306
306
  // (applied rounds only) stayed empty: the artist's finished work looked lost.
307
307
  // This is every page with an open round, where it is, since when (the round's
308
308
  // last update), and whether it has sat there too long. Waiting for review is
309
- // the artist's own move and a draft is theirs to finish; a draft is never stuck,
310
- // and a review is stuck only after 3 days (task 1004777, so its artist is told);
311
- // a state the reads cannot name always needs to be reviewed.
309
+ // the artist's own move and a draft is theirs to finish: a draft or a page
310
+ // being written is stuck after 7 days and a draft expires after 14 (task
311
+ // 1004901); a review is stuck after 3 days (task 1004777, so its artist is
312
+ // told); a state the reads cannot name always needs to be reviewed.
312
313
  // ---------------------------------------------------------------------------
313
314
 
314
315
  const DAY_MS = 24 * 60 * 60 * 1000;
316
+ // task 1004901: every state names WHO moves it next (waits_on: 'artist' is the
317
+ // round's own artist, 'appliers' the Metic and up builders who apply and land
318
+ // work) and WHEN it is overdue (stuck_after: then it is pushed to that person on
319
+ // Home and in the daily digest). A draft nobody touches also EXPIRES
320
+ // (expires_after): the auto-close sweep abandons it, its words kept on the task.
321
+ const DRAFT_EXPIRES_AFTER = 14 * DAY_MS;
315
322
  const IN_FLIGHT = Object.freeze({
316
- draft: { word: 'Being written', stuck_after: null },
317
- writing: { word: 'Being written', stuck_after: null },
318
- words_review: { word: 'Words waiting for review', stuck_after: 3 * DAY_MS },
319
- submitted: { word: 'Approved, waiting to be applied', stuck_after: 3 * DAY_MS },
320
- applying: { word: 'Being applied', stuck_after: DAY_MS },
323
+ draft: { word: 'Being written', stuck_after: 7 * DAY_MS, waits_on: 'artist', expires_after: DRAFT_EXPIRES_AFTER },
324
+ writing: { word: 'Being written', stuck_after: 7 * DAY_MS, waits_on: 'artist' },
325
+ words_review: { word: 'Words waiting for review', stuck_after: 3 * DAY_MS, waits_on: 'artist' },
326
+ submitted: { word: 'Approved, waiting to be applied', stuck_after: 3 * DAY_MS, waits_on: 'appliers' },
327
+ applying: { word: 'Being applied', stuck_after: DAY_MS, waits_on: 'appliers' },
321
328
  // task 1004777: a review left 3 days is stuck too, so its artist is told on Home.
322
- waiting_for_artist: { word: 'Waiting for your review', stuck_after: 3 * DAY_MS },
323
- landing: { word: 'Approved, going live', stuck_after: DAY_MS },
324
- unknown: { word: 'Needs to be reviewed', stuck_after: 0 },
329
+ waiting_for_artist: { word: 'Waiting for your review', stuck_after: 3 * DAY_MS, waits_on: 'artist' },
330
+ landing: { word: 'Approved, going live', stuck_after: DAY_MS, waits_on: 'appliers' },
331
+ unknown: { word: 'Needs to be reviewed', stuck_after: 0, waits_on: 'appliers' },
325
332
  });
326
333
  const IN_FLIGHT_ORDER = ['unknown', 'landing', 'applying', 'submitted', 'waiting_for_artist', 'words_review', 'writing', 'draft'];
327
334
 
@@ -344,6 +351,9 @@ function inFlightFor(list, byPage, nowMs) {
344
351
  state_word: kind.word,
345
352
  since,
346
353
  stuck: kind.stuck_after != null && (kind.stuck_after === 0 || (age != null && age > kind.stuck_after)),
354
+ waits_on: kind.waits_on,
355
+ due_at: since && kind.stuck_after != null ? new Date(Date.parse(since) + kind.stuck_after).toISOString() : null,
356
+ expires_at: since && kind.expires_after != null ? new Date(Date.parse(since) + kind.expires_after).toISOString() : null,
347
357
  holder: o.holder,
348
358
  artist_id: r.artist_id != null ? String(r.artist_id) : null,
349
359
  });
@@ -380,8 +390,8 @@ function stuckPageTweaks({ inventory, tasks, now = Date.now() }) {
380
390
  state_word: f.state_word,
381
391
  since: f.since,
382
392
  days: ms == null ? null : Math.max(0, Math.floor(ms / DAY_MS)),
383
- // Submitted words (task 1004900) wait on their artist's review, like an applied page.
384
- waits_on: f.state === 'waiting_for_artist' || f.state === 'words_review' ? 'artist' : 'appliers',
393
+ // The state's own owner (task 1004901), one table for the Studio and the pushes.
394
+ waits_on: f.waits_on,
385
395
  artist_id: f.artist_id,
386
396
  };
387
397
  });
@@ -968,4 +978,6 @@ module.exports = {
968
978
  stuckPageTweaks,
969
979
  stuckPageTweaksFor,
970
980
  stuckDigestText,
981
+ // task 1004901: when an untouched draft expires (the auto-close sweep reads it).
982
+ DRAFT_EXPIRES_AFTER,
971
983
  };
@@ -56,6 +56,14 @@ await test('a blank round and an empty batch are the only two that close', () =>
56
56
  });
57
57
  });
58
58
 
59
+ await test('a draft left untouched for two weeks expires; a younger one stays (task 1004901)', () => {
60
+ const D = 86400000;
61
+ const at = (days) => round({ description: draft, updated_at: new Date(NOW - days * D).toISOString() });
62
+ assert.equal(autoClose.closeReason(at(13.9), { now: NOW }), null);
63
+ assert.equal(autoClose.closeReason(at(14.1), { now: NOW }), 'draft_expired');
64
+ assert.equal(autoClose.closeReason({ ...at(30), holder_id: '7' }, { now: NOW }), null, 'a held draft is being written, never expired');
65
+ });
66
+
59
67
  await test('the grace period is honoured: a round touched within it stays', () => {
60
68
  const t = round({ updated_at: new Date(NOW - 59 * 60_000).toISOString() });
61
69
  assert.equal(autoClose.closeReason(t, { now: NOW, graceMs: 60 * 60_000 }), null);
@@ -257,7 +257,7 @@ ${pages.composeWordsApprovedBlock({ at: ago(9), by: '7' })}`;
257
257
  round(2, 'landing:b', { status: 'ready', description: `${pages.roundTaskBody(pg('landing:b'), 1)}\n\n${batch('landing:b')}`, updated_at: ago(1) }),
258
258
  round(3, 'landing:c', { status: 'confirmed', updated_at: ago(17) }),
259
259
  round(4, 'builders:d', { status: 'completed', grade_passed: true, updated_at: ago(20) }),
260
- round(5, 'builders:e', { status: 'active', holder_id: '7', holder_login: 'me', holder_worktree: false, updated_at: ago(30) }),
260
+ round(5, 'builders:e', { status: 'active', holder_id: '7', holder_login: 'me', holder_worktree: false, updated_at: ago(5) }),
261
261
  round(6, 'builders:f', { status: 'ready', updated_at: ago(0.2) }),
262
262
  round(7, 'builders:g', { status: 'shipped', shipped_at: ago(3), updated_at: ago(3) }),
263
263
  round(8, 'status:h', { status: 'active', holder_id: '8', holder_worktree: true, updated_at: ago(2) }),
@@ -271,7 +271,7 @@ ${pages.composeWordsApprovedBlock({ at: ago(9), by: '7' })}`;
271
271
  ['builders:d', 'waiting_for_artist', 'Waiting for your review', true],
272
272
  ['landing:b', 'submitted', 'Approved, waiting to be applied', false],
273
273
  ['builders:e', 'writing', 'Being written', false],
274
- ], 'a shipped round is not in flight; writing is never stuck, and a review left 20 days is (task 1004777)');
274
+ ], 'a shipped round is not in flight; writing is not overdue before 7 days (task 1004901), and a review left 20 days is (task 1004777)');
275
275
  assert.equal(out.in_flight[1].since, ago(17));
276
276
  assert.equal(out.in_flight[1].task_id, '3');
277
277
  assert.equal(out.pages.find((p) => p.id === 'landing:a').status_label, 'Untweaked', 'the status still counts only what shipped');
@@ -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
+ }