@bongos/core 1.23.13 → 1.23.15

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 (56) hide show
  1. package/.bongos-core.json +90 -55
  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 +1000 -946
  10. package/docs/copy-registry.json +2009 -1465
  11. package/docs/module-api-changelog.md +4 -2
  12. package/docs/page-inventory.json +10 -4
  13. package/docs/page-readings.json +593 -573
  14. package/modules/copy-desk/page-status.js +2 -2
  15. package/modules/copy-desk/tests/copy_page_status.mjs +1 -1
  16. package/modules/hall-ui/public/approval-queue-lib.js +1 -1
  17. package/modules/hall-ui/public/approval-queue.css +26 -12
  18. package/modules/hall-ui/public/approval-queue.js +40 -16
  19. package/modules/hall-ui/public/studio-lib.js +5 -3
  20. package/modules/hall-ui/public/studio.html +8 -8
  21. package/modules/hall-ui/public/studio.js +25 -5
  22. package/modules/hall-ui/public/studio.states.json +54 -4
  23. package/modules/hall-ui/public/welcome-boot.js +62 -12
  24. package/modules/platform-identity/builder-of-the-week.js +180 -0
  25. package/modules/platform-identity/migrations/platform_identity_032_weekly_features.sql +53 -0
  26. package/modules/platform-identity/routes/community-leaderboard.js +25 -1
  27. package/modules/public-landing/public/account.html +2 -1
  28. package/modules/public-landing/public/community-week-none.states.json +16 -0
  29. package/modules/public-landing/public/community.html +142 -13
  30. package/modules/public-landing/public/community.states.json +6 -4
  31. package/modules/public-landing/public/connections.html +2 -1
  32. package/modules/public-landing/public/contact.html +2 -1
  33. package/modules/public-landing/public/guild.html +2 -1
  34. package/modules/public-landing/public/index.html +2 -1
  35. package/modules/public-landing/public/invitations.html +2 -1
  36. package/modules/public-landing/public/privacy.html +2 -1
  37. package/modules/public-landing/public/projects.html +2 -1
  38. package/modules/public-landing/public/terms.html +2 -1
  39. package/modules/ui-design/kit/fixtures/community-week-none.json +6 -0
  40. package/modules/ui-design/kit/fixtures/community-week.json +11 -0
  41. package/modules/ui-design/kit/serve.js +8 -0
  42. package/package-lock.json +2 -2
  43. package/package.json +1 -1
  44. package/release-notes.json +15 -1
  45. package/scripts/bongos/run-unit-tests.js +4 -0
  46. package/src/module-api.js +1 -1
  47. package/src/platform-server.js +5 -5
  48. package/tests/builder_of_the_week.mjs +266 -0
  49. package/tests/builder_of_the_week_db.mjs +166 -0
  50. package/tests/hall_approval_queue.mjs +33 -9
  51. package/tests/hall_studio_home.mjs +74 -7
  52. package/tests/hub_account_page.mjs +2 -2
  53. package/tests/hub_community_page.mjs +136 -25
  54. package/tests/landing_legal_pages.mjs +2 -2
  55. package/tests/privacy_matrix.mjs +53 -8
  56. package/tests/welcome_boot_browser.mjs +45 -3
@@ -10,9 +10,11 @@
10
10
  // hall underneath.
11
11
  //
12
12
  // Rules:
13
- // - Once per builder per LOCAL day, remembered in localStorage. Storage that throws cannot
14
- // remember, so such a browser is never shown it (it would replay on every visit).
15
- // - Never for a visitor who is not signed in, and never over a deep link (#/task/N, #/idea/N).
13
+ // - A signed-in builder sees it once per LOCAL day, and again after every fresh sign-in (the
14
+ // sign-in click leaves a flag in localStorage that the return visit consumes). Storage that
15
+ // throws cannot remember, so such a browser is never shown it (it would replay on every visit).
16
+ // - A visitor who is not signed in sees the Welcome on Home, every visit, with a "Sign in with
17
+ // GitHub" button in place of the role choices. Only on Home, never over a deep link (#/task/N, #/idea/N).
16
18
  // - shell.js asks onAccess() BEFORE the craft picker; a load where the welcome showed marks the
17
19
  // picker asked (OTBCraftMode.markAsked), so the two never stack.
18
20
  // - Picking a role also sets the hall's craft mode. Governing is a seat (craft-mode.js), so the
@@ -26,6 +28,8 @@
26
28
  'use strict';
27
29
 
28
30
  var DAY_KEY = 'hall-welcome-day:';
31
+ var SIGNIN_KEY = 'hall-welcome-signin';
32
+ var SIGNIN_HREF = '/api/bongos/auth/web/start';
29
33
  var ART = { day: '/builders/welcome-day.webp', night: '/builders/welcome-night.webp' };
30
34
  var ART_WAIT = 1500; // the veil lifts once the painting is in, or after this long
31
35
  var RUN_MS = 3400; // the city powering up before the greeting
@@ -159,15 +163,25 @@
159
163
  '<span class="wb__version" id="wb-version" hidden></span></div>' +
160
164
  '<div class="wb__veil" aria-hidden="true"></div>' +
161
165
  '<button class="wb__skip" type="button" data-wb-skip>Skip</button>' +
162
- '<div class="wb__after">' +
163
- '<h2 class="wb__hi" id="wb-hi">' + esc(greetingFor(now) + ', ' + nameOf(builder)) + '</h2>' +
166
+ '<div class="wb__after">' + (builder ? signedInHtml(builder, now) : signedOutHtml()) + '</div>';
167
+ }
168
+ function signedInHtml(builder, now) {
169
+ return '<h2 class="wb__hi" id="wb-hi">' + esc(greetingFor(now) + ', ' + nameOf(builder)) + '</h2>' +
164
170
  '<p class="wb__lede">Where do you want to start the day?</p>' +
165
171
  '<div class="wb__choices">' +
166
172
  '<button class="wb__choice" type="button" data-wb-roles aria-expanded="false" aria-controls="wb-roles"><b>Start with your role</b><span>Pick today’s craft and go straight to its room.</span></button>' +
167
173
  '<button class="wb__choice" type="button" data-wb-home><b>Your home page</b><span>Your work, what needs you, and what shipped.</span></button>' +
168
174
  '</div>' +
169
- '<div class="wb__roles" id="wb-roles" hidden></div>' +
170
- '</div>';
175
+ '<div class="wb__roles" id="wb-roles" hidden></div>';
176
+ }
177
+ // A visitor who is not signed in: the same scene, one way in.
178
+ function signedOutHtml() {
179
+ return '<h2 class="wb__hi" id="wb-hi">Welcome to ' + esc(projectName(window.__BRANDING__)) + '</h2>' +
180
+ '<p class="wb__lede">Sign in to see your work, what needs you, and what shipped.</p>' +
181
+ '<div class="wb__choices">' +
182
+ '<button class="wb__choice" type="button" data-wb-signin><b>Sign in with GitHub</b><span>Pick up where you left off.</span></button>' +
183
+ '<button class="wb__choice" type="button" data-wb-home><b>Look around first</b><span>See the public side of the project.</span></button>' +
184
+ '</div>';
171
185
  }
172
186
  function rolesHtml() {
173
187
  return rolesFor(craft()).map(function (r) {
@@ -250,6 +264,7 @@
250
264
  if (!btn || !root || leaving) return;
251
265
  if (btn.hasAttribute('data-wb-skip')) { begin(); finish(); return; }
252
266
  if (btn.hasAttribute('data-wb-home')) { leave(); return; }
267
+ if (btn.hasAttribute('data-wb-signin')) { markSigningIn(); go(SIGNIN_HREF); return; }
253
268
  if (btn.hasAttribute('data-wb-roles')) {
254
269
  var box = root.querySelector('#wb-roles');
255
270
  var open = box.hidden;
@@ -299,19 +314,54 @@
299
314
  at(ART_WAIT, begin);
300
315
  var skip = root.querySelector('[data-wb-skip]');
301
316
  if (skip && skip.focus) skip.focus();
302
- loadVersion();
317
+ if (builder) loadVersion(); // an anonymous visit costs no request
303
318
  }
304
319
 
305
320
  // A link straight to an overlay (#/task/N, #/idea/N, #/blocker/N) is not greeted over.
306
321
  function deepLink() { return /^#\//.test((window.location && window.location.hash) || ''); }
307
322
 
323
+ // Home is the bare hall URL with no routed view; a signed-out visitor is only greeted there.
324
+ function onHome() {
325
+ var p = String((window.location && window.location.pathname) || '/').replace(/\/+$/, '');
326
+ var h = (window.location && window.location.hash) || '';
327
+ return (p === '' || p === '/builders') && (h === '' || h === '#' || h === '#home');
328
+ }
329
+
330
+ // A sign-in in flight is remembered, so the welcome greets the builder when they come back signed in.
331
+ function markSigningIn() { try { window.localStorage.setItem(SIGNIN_KEY, '1'); } catch (_) { /* cannot remember */ } }
332
+ function takeSignedIn() {
333
+ try {
334
+ var was = window.localStorage.getItem(SIGNIN_KEY) === '1';
335
+ if (was) window.localStorage.removeItem(SIGNIN_KEY);
336
+ return was;
337
+ } catch (_) { return false; }
338
+ }
339
+ // Any sign-in link on the page (the shell's own, in the top bar) counts the same as the welcome's button.
340
+ function watchSignInLinks() {
341
+ if (!document.addEventListener) return;
342
+ document.addEventListener('click', function (e) {
343
+ var a = e.target && e.target.closest && e.target.closest('a[href]');
344
+ // Same-origin only: the href must START with the sign-in path, not merely mention it.
345
+ if (a && String(a.getAttribute('href')).indexOf(SIGNIN_HREF) === 0) markSigningIn();
346
+ }, true);
347
+ }
348
+
308
349
  // shell.js's call, before the craft picker's. True when the welcome is (or was) up on this load.
309
350
  function onAccess(access, builder) {
310
351
  if (shown) return true;
311
- if (!access || access.authed !== true || !builder || builder.id == null || deepLink()) return false;
352
+ if (deepLink()) return false;
353
+ if (access && access.authed === false) {
354
+ if (!onHome()) return false;
355
+ shown = true;
356
+ show(null);
357
+ return true;
358
+ }
359
+ if (!access || access.authed !== true || !builder || builder.id == null) return false;
312
360
  var key = DAY_KEY + builder.id, now = new Date();
313
361
  try {
314
- if (!firstVisitToday(window.localStorage.getItem(key), now)) return false;
362
+ // Every fresh sign-in is greeted; otherwise only the day's first visit.
363
+ var fresh = takeSignedIn();
364
+ if (!fresh && !firstVisitToday(window.localStorage.getItem(key), now)) return false;
315
365
  window.localStorage.setItem(key, localDay(now));
316
366
  } catch (_) { return false; }
317
367
  shown = true;
@@ -322,11 +372,11 @@
322
372
  }
323
373
 
324
374
  var api = {
325
- DAY_KEY: DAY_KEY, ROLES: ROLES, ART: ART, TOWERS: TOWERS, BEACONS: BEACONS, LANES: LANES, AW: AW, AH: AH,
375
+ DAY_KEY: DAY_KEY, SIGNIN_KEY: SIGNIN_KEY, ROLES: ROLES, ART: ART, TOWERS: TOWERS, BEACONS: BEACONS, LANES: LANES, AW: AW, AH: AH,
326
376
  localDay: localDay, firstVisitToday: firstVisitToday, greetingFor: greetingFor, nameOf: nameOf, timeOfDay: timeOfDay,
327
377
  artFor: artFor, projectName: projectName, bannerText: bannerText, rolesFor: rolesFor, pulseRuns: pulseRuns,
328
378
  onAccess: onAccess, finish: finish, close: close,
329
379
  };
330
- if (typeof window !== 'undefined') window.OTBWelcome = api;
380
+ if (typeof window !== 'undefined') { window.OTBWelcome = api; watchSignInLinks(); }
331
381
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
332
382
  })();
@@ -0,0 +1,180 @@
1
+ // modules/platform-identity/builder-of-the-week.js — Builder of the Week (community
2
+ // spec D5, docs/specs/bongos-v1-community-tab.md; task 1002981, goal 1000110 CT5).
3
+ // The read behind GET /community/builder-of-the-week, for the hub's /community/week
4
+ // page and the line above the leaderboard.
5
+ //
6
+ // THE RULE (owner-signed 2026-08-16; the week decided 2026-10-27): of the builders
7
+ // the leaderboard may show (rollupVisibleSql), the one who earned the most credits
8
+ // across every project in the last FULL week — Monday 00:00 to Monday 00:00 UTC —
9
+ // among those who shipped at least one work in it. Ties go to more works shipped,
10
+ // then more karma, then handle/login so the answer is stable.
11
+ //
12
+ // THE WEEK IS READ FROM HISTORY, NOT FROM THE LIVE ROWS. Per (project, builder) it is
13
+ // the last snapshot at-or-before the week's end minus the last at-or-before its start
14
+ // (activity-snapshots.js), summed across projects. With no snapshot before the week, a
15
+ // row counts from zero only if the hub first saw it during the week (first_seen_at,
16
+ // migration 032; owner 2026-10-27): the leaderboard's missing-baseline rule would let a
17
+ // builder last seen before snapshots began win one week with their whole lifetime, and
18
+ // a crown is never corrected. A returning builder's first week back is the cost. A snapshot's reported_at never runs
19
+ // backwards, so once the week has ended its numbers cannot move, and whoever asks
20
+ // first gets the same answer.
21
+ //
22
+ // CROWNED AT RENDER, NO CRON (spec D5). The first read after Monday 00:00 UTC decides
23
+ // the week and writes it to platform_identity_weekly_features (migration 032) with
24
+ // ON CONFLICT DO NOTHING; every later read finds the row. A week nobody won is written
25
+ // too (github_id NULL), so it stays empty. Nothing updates a row.
26
+ //
27
+ // WHO IS SHOWN is decided again on every read: a winner who has since hidden their
28
+ // numbers or gone private is not drawn, on the crown or in the past weeks, and their
29
+ // week is not given to anyone else (spec D8). A hidden winner and an empty week look
30
+ // the same. Viewer-independent; github_id never crosses the projection.
31
+ 'use strict';
32
+
33
+ const { pool: defaultPool } = require('../../src/module-api');
34
+ const { rollupVisibleSql } = require('./account-visibility');
35
+
36
+ const DAY_MS = 24 * 60 * 60 * 1000;
37
+ const WEEK_MS = 7 * DAY_MS;
38
+ // How many weeks before the current one the past-weeks list shows.
39
+ const ARCHIVE_WEEKS = 12;
40
+
41
+ // The Monday 00:00 UTC that opens the week `now` is in.
42
+ function mondayOf(now) {
43
+ const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
44
+ const sinceMonday = (new Date(midnight).getUTCDay() + 6) % 7;
45
+ return new Date(midnight - sinceMonday * DAY_MS);
46
+ }
47
+
48
+ // The week the crown covers at `now`: the last full week before `now`'s own.
49
+ function crownedWeek(now = new Date()) {
50
+ const end = mondayOf(now);
51
+ return { start: new Date(end.getTime() - WEEK_MS), end };
52
+ }
53
+
54
+ const isoDate = (d) => d.toISOString().slice(0, 10);
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 (
61
+ SELECT a.client_id, a.github_id, a.first_seen_at, pa.handle, pa.github_login
62
+ FROM platform_identity_builder_project_activity a
63
+ JOIN platform_identity_accounts pa ON pa.github_id = a.github_id
64
+ WHERE ${rollupVisibleSql('pa')}
65
+ ), 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
70
+ FROM act
71
+ LEFT JOIN LATERAL (
72
+ SELECT s.credits, s.tasks_shipped, s.karma
73
+ FROM platform_identity_activity_snapshots s
74
+ WHERE s.client_id = act.client_id AND s.github_id = act.github_id
75
+ AND s.reported_at <= $2::timestamptz
76
+ ORDER BY s.reported_at DESC, s.id DESC
77
+ LIMIT 1
78
+ ) e ON true
79
+ LEFT JOIN LATERAL (
80
+ SELECT s.credits, s.tasks_shipped, s.karma
81
+ FROM platform_identity_activity_snapshots s
82
+ WHERE s.client_id = act.client_id AND s.github_id = act.github_id
83
+ AND s.reported_at <= $1::timestamptz
84
+ ORDER BY s.reported_at DESC, s.id DESC
85
+ LIMIT 1
86
+ ) b ON true
87
+ WHERE b.credits IS NOT NULL OR act.first_seen_at >= $1::timestamptz
88
+ )
89
+ SELECT github_id, sum(credits)::bigint AS credits, sum(tasks_shipped)::bigint AS tasks_shipped
90
+ FROM per_project
91
+ GROUP BY github_id, handle, github_login
92
+ HAVING sum(tasks_shipped) >= 1
93
+ ORDER BY sum(credits) DESC, sum(tasks_shipped) DESC, sum(karma) DESC,
94
+ handle COLLATE "C", github_login COLLATE "C"
95
+ LIMIT 1`,
96
+ [start, end],
97
+ );
98
+ return rows[0] || null;
99
+ }
100
+
101
+ // One crowning per week per process: concurrent first reads share it, and a week
102
+ // already on record is not asked about again.
103
+ const crowning = new Map();
104
+ let lastCrowned = null;
105
+
106
+ async function crownWeek(start, end, pool) {
107
+ const week = isoDate(start);
108
+ const { rows } = await pool.query(
109
+ 'SELECT 1 FROM platform_identity_weekly_features WHERE week_start = $1::date',
110
+ [week],
111
+ );
112
+ if (rows.length) return;
113
+ const winner = await computeWinner(start, end, pool);
114
+ await pool.query(
115
+ `INSERT INTO platform_identity_weekly_features (week_start, github_id, credits_delta, ships_delta)
116
+ VALUES ($1::date, $2::bigint, $3::bigint, $4::bigint)
117
+ ON CONFLICT (week_start) DO NOTHING`,
118
+ [week, winner ? winner.github_id : null, winner ? winner.credits : 0, winner ? winner.tasks_shipped : 0],
119
+ );
120
+ }
121
+
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);
126
+ if (inFlight && inFlight.pool === pool) return inFlight.promise;
127
+ 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);
132
+ return entry.promise;
133
+ }
134
+
135
+ function clearCrownMemo() {
136
+ crowning.clear();
137
+ lastCrowned = null;
138
+ }
139
+
140
+ // readBuilderOfTheWeek — crowns the last full week if nobody has yet, then reads it and
141
+ // the weeks before it. Returns { week: { start, end }, winner, past }: dates as
142
+ // YYYY-MM-DD (end is the Monday the week closed on), winner null when nobody is to be
143
+ // shown, past newest first. `now` is injectable so a test can pin the week.
144
+ async function readBuilderOfTheWeek({ now = new Date(), pool = defaultPool } = {}) {
145
+ const { start, end } = crownedWeek(now);
146
+ await ensureCrowned(start, end, pool);
147
+ const { rows } = await pool.query(
148
+ `SELECT to_char(f.week_start, 'YYYY-MM-DD') AS week_start, f.credits_delta, f.ships_delta,
149
+ pa.handle, pa.github_login, pa.display_name, pa.avatar_url
150
+ FROM platform_identity_weekly_features f
151
+ JOIN platform_identity_accounts pa ON pa.github_id = f.github_id
152
+ WHERE f.week_start <= $1::date
153
+ AND ${rollupVisibleSql('pa')}
154
+ ORDER BY f.week_start DESC
155
+ LIMIT $2`,
156
+ [isoDate(start), ARCHIVE_WEEKS + 1],
157
+ );
158
+ const shown = rows.map((r) => ({
159
+ week_start: r.week_start,
160
+ handle: r.handle,
161
+ github_login: r.github_login,
162
+ display_name: r.display_name,
163
+ avatar_url: r.avatar_url,
164
+ credits: Number(r.credits_delta),
165
+ tasks_shipped: Number(r.ships_delta),
166
+ }));
167
+ const isThisWeek = shown.length > 0 && shown[0].week_start === isoDate(start);
168
+ return {
169
+ week: { start: isoDate(start), end: isoDate(end) },
170
+ winner: isThisWeek ? shown[0] : null,
171
+ past: (isThisWeek ? shown.slice(1) : shown).slice(0, ARCHIVE_WEEKS),
172
+ };
173
+ }
174
+
175
+ module.exports = {
176
+ readBuilderOfTheWeek,
177
+ crownedWeek,
178
+ clearCrownMemo,
179
+ ARCHIVE_WEEKS,
180
+ };
@@ -0,0 +1,53 @@
1
+ -- platform_identity_032_weekly_features.sql — Builder of the Week, one row per crowned
2
+ -- week (community spec D5, docs/specs/bongos-v1-community-tab.md; task 1002981).
3
+ --
4
+ -- WHY. The crown is computed at render, with no cron: the first read of
5
+ -- GET /community/builder-of-the-week after Monday 00:00 UTC decides the week that just
6
+ -- ended and writes it here. The row is what pins the crown — a builder who goes public
7
+ -- or reports late cannot take a week that was already decided — and what the page's
8
+ -- past-weeks list reads.
9
+ --
10
+ -- THE SHAPE CARRIES THE RULES (owner, 2026-10-27):
11
+ -- * THE KEY IS week_start, the Monday (UTC) the crowned week began, so a week has one
12
+ -- row. The writer is INSERT ... ON CONFLICT (week_start) DO NOTHING, and nothing
13
+ -- updates a row: the first decision stands.
14
+ -- * github_id IS NULL when nobody qualified (no eligible builder shipped a work that
15
+ -- week). The empty week is recorded too, so it stays empty.
16
+ -- * credits_delta / ships_delta are the week's numbers as decided, bigint like the
17
+ -- leaderboard's arithmetic (builder-of-the-week.js).
18
+ -- * NO FOREIGN KEY to platform_identity_accounts, like every github_id column in this
19
+ -- module. Whether a winner may be SHOWN is decided at read time by
20
+ -- rollupVisibleSql (account-visibility.js), never by this row.
21
+ --
22
+ -- AND first_seen_at ON THE ACTIVITY ROWS (owner, 2026-10-27). A week counts a
23
+ -- (project, builder) with no snapshot before it only if the hub first saw that row
24
+ -- during the week: otherwise a builder last seen before snapshots began would bring
25
+ -- their whole lifetime into one week and keep the crown for good. Added with NO
26
+ -- default first, so every existing row stays NULL ("seen before this, history
27
+ -- unknown"); only then the default, so a new row stamps its own first sighting.
28
+ --
29
+ -- NOTHING HERE IS AUTHORITY (ADR 0016 / ADR 0141): descriptive, public analytics.
30
+ --
31
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). A CREATE and a nullable ADD COLUMN, so the
32
+ -- previous release still runs against this schema. Idempotent.
33
+
34
+ BEGIN;
35
+
36
+ CREATE TABLE IF NOT EXISTS platform_identity_weekly_features (
37
+ week_start date PRIMARY KEY,
38
+ github_id bigint,
39
+ credits_delta bigint NOT NULL,
40
+ ships_delta bigint NOT NULL,
41
+ crowned_at timestamptz NOT NULL DEFAULT now()
42
+ );
43
+
44
+ ALTER TABLE platform_identity_builder_project_activity ADD COLUMN IF NOT EXISTS first_seen_at timestamptz;
45
+ ALTER TABLE platform_identity_builder_project_activity ALTER COLUMN first_seen_at SET DEFAULT now();
46
+
47
+ COMMENT ON COLUMN platform_identity_builder_project_activity.first_seen_at IS
48
+ 'When the hub first saw this (project, builder) row; NULL for rows older than migration 032. Builder of the Week counts a row with no snapshot before the week only if it was first seen during the week (task 1002981).';
49
+
50
+ COMMENT ON TABLE platform_identity_weekly_features IS
51
+ 'Builder of the Week, one row per week, keyed on the Monday (UTC) it began (community spec D5, task 1002981). Written once by the first read after the week ends, never updated; github_id NULL = nobody qualified. Whether a winner is shown is decided at read time by the leaderboard''s eligibility predicate.';
52
+
53
+ COMMIT;
@@ -6,6 +6,8 @@
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
+ // 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.
9
11
  //
10
12
  // Reaches core only through the doorway (ADR 0083).
11
13
  'use strict';
@@ -13,6 +15,7 @@
13
15
  const express = require('express');
14
16
  const api = require('../../../src/module-api');
15
17
  const leaderboard = require('../community-leaderboard');
18
+ const builderOfTheWeek = require('../builder-of-the-week');
16
19
  const { createReadRateLimit } = require('../read-rate-limit');
17
20
 
18
21
  const { pool, corsPublicGet } = api;
@@ -20,6 +23,9 @@ const { pool, corsPublicGet } = api;
20
23
  const READ_RATE_LIMIT = 60; // per IP per minute — the community-search budget
21
24
  const READ_RATE_SCOPE = 'community-leaderboard-read';
22
25
  const leaderboardReadRateLimit = createReadRateLimit({ scope: READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
26
+ // Builder of the Week spends its own budget (ADR 0209: never a shared one).
27
+ const WEEK_READ_RATE_SCOPE = 'builder-of-the-week-read';
28
+ const weekReadRateLimit = createReadRateLimit({ scope: WEEK_READ_RATE_SCOPE, limit: READ_RATE_LIMIT });
23
29
 
24
30
  module.exports = function communityLeaderboardRoutes() {
25
31
  const router = express.Router();
@@ -63,7 +69,25 @@ module.exports = function communityLeaderboardRoutes() {
63
69
  }
64
70
  });
65
71
 
72
+ router.use('/community/builder-of-the-week', corsPublicGet('/community/builder-of-the-week'));
73
+
74
+ // rank: public — Builder of the Week (spec D5; task 1002981): the last full
75
+ // week's winner and the weeks before it. Takes no parameters. The first read
76
+ // after Monday 00:00 UTC also records the week (crown at render, no cron), at
77
+ // most one row per week. Viewer-independent; no-store so a winner who hides
78
+ // leaves the page on the next read (ADR 0171 D5).
79
+ router.get('/community/builder-of-the-week', weekReadRateLimit, async (req, res) => {
80
+ try {
81
+ const out = await builderOfTheWeek.readBuilderOfTheWeek({ pool });
82
+ res.set('Cache-Control', 'no-store');
83
+ res.json(out);
84
+ } catch (err) {
85
+ api.logger('platform-identity').error({ err: err && err.message }, 'GET /community/builder-of-the-week failed');
86
+ res.fail('builder_of_the_week_failed', { status: 500, message: 'internal error' });
87
+ }
88
+ });
89
+
66
90
  return router;
67
91
  };
68
92
 
69
- module.exports._internals = { READ_RATE_LIMIT, READ_RATE_SCOPE };
93
+ module.exports._internals = { READ_RATE_LIMIT, READ_RATE_SCOPE, WEEK_READ_RATE_SCOPE };
@@ -118,6 +118,7 @@
118
118
  <div class="bandIn">
119
119
  <ul class="bandRow">
120
120
  <li class="bandCell"><a class="bandT" href="/community">Leaderboard</a><span class="bandC">who earned the most, across every project</span></li>
121
+ <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>
121
122
  <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>
122
123
  <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>
123
124
  <li class="bandCell knowCell" hidden><a class="bandT" href="/connections#connSuggest">Builders you may know</a><span class="bandC">connections or projects in common</span></li>
@@ -131,7 +132,7 @@
131
132
  <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>
132
133
  <li class="bandCell" id="mineCell"><a class="bandT" href="/projects?view=mine">Your projects</a><span class="bandC">the ones you own</span></li>
133
134
  <li class="bandCell"><a class="bandT" href="/projects?view=new">New project</a><span class="bandC">five questions and a home</span></li>
134
- <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/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>
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>
135
136
  </ul>
136
137
  </div>
137
138
  </div>
@@ -0,0 +1,16 @@
1
+ {
2
+ "_": "The builder of the week when the last week has no one to show (task 1002981): nobody qualified, or the winner hides their numbers — the two look the same (spec D8). A hub's first week, so no past weeks either (fixtures/community-week-none.json). The page says so in one line; the board draws no crown line.",
3
+ "page": "/community",
4
+ "surface": "public-landing",
5
+ "modes": ["dark"],
6
+ "modeQuery": false,
7
+ "stub": { "fixtures": { "community-week": "community-week-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-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"] } }
14
+ },
15
+ "reducedMotion": { "auth": false, "actions": [["wait", 400]] }
16
+ }