@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.
- package/.bongos-core.json +90 -55
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +22 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +1000 -946
- package/docs/copy-registry.json +2009 -1465
- package/docs/module-api-changelog.md +4 -2
- package/docs/page-inventory.json +10 -4
- package/docs/page-readings.json +593 -573
- package/modules/copy-desk/page-status.js +2 -2
- package/modules/copy-desk/tests/copy_page_status.mjs +1 -1
- package/modules/hall-ui/public/approval-queue-lib.js +1 -1
- package/modules/hall-ui/public/approval-queue.css +26 -12
- package/modules/hall-ui/public/approval-queue.js +40 -16
- package/modules/hall-ui/public/studio-lib.js +5 -3
- package/modules/hall-ui/public/studio.html +8 -8
- package/modules/hall-ui/public/studio.js +25 -5
- package/modules/hall-ui/public/studio.states.json +54 -4
- package/modules/hall-ui/public/welcome-boot.js +62 -12
- package/modules/platform-identity/builder-of-the-week.js +180 -0
- package/modules/platform-identity/migrations/platform_identity_032_weekly_features.sql +53 -0
- package/modules/platform-identity/routes/community-leaderboard.js +25 -1
- package/modules/public-landing/public/account.html +2 -1
- package/modules/public-landing/public/community-week-none.states.json +16 -0
- package/modules/public-landing/public/community.html +142 -13
- package/modules/public-landing/public/community.states.json +6 -4
- package/modules/public-landing/public/connections.html +2 -1
- package/modules/public-landing/public/contact.html +2 -1
- package/modules/public-landing/public/guild.html +2 -1
- package/modules/public-landing/public/index.html +2 -1
- package/modules/public-landing/public/invitations.html +2 -1
- package/modules/public-landing/public/privacy.html +2 -1
- package/modules/public-landing/public/projects.html +2 -1
- package/modules/public-landing/public/terms.html +2 -1
- package/modules/ui-design/kit/fixtures/community-week-none.json +6 -0
- package/modules/ui-design/kit/fixtures/community-week.json +11 -0
- package/modules/ui-design/kit/serve.js +8 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +15 -1
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +5 -5
- package/tests/builder_of_the_week.mjs +266 -0
- package/tests/builder_of_the_week_db.mjs +166 -0
- package/tests/hall_approval_queue.mjs +33 -9
- package/tests/hall_studio_home.mjs +74 -7
- package/tests/hub_account_page.mjs +2 -2
- package/tests/hub_community_page.mjs +136 -25
- package/tests/landing_legal_pages.mjs +2 -2
- package/tests/privacy_matrix.mjs +53 -8
- package/tests/welcome_boot_browser.mjs +45 -3
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
// hall underneath.
|
|
11
11
|
//
|
|
12
12
|
// Rules:
|
|
13
|
-
// -
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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&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
|
+
}
|