@bongos/core 1.19.539 → 1.19.541
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 +34 -24
- package/.claude/skills/blocker-solve/SKILL.md +1 -1
- package/docs/copy-inventory.md +38 -36
- package/docs/copy-registry.json +63 -45
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/hall-kit.js +105 -6
- package/modules/hall-ui/public/profile-charts.js +39 -4
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/profile.js +8 -1
- package/modules/hall-ui/public/style.css +31 -0
- package/modules/ideas/blockers.js +14 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/me.js +42 -0
- package/src/module-api.js +1 -1
- package/tests/blocker_detail_hydration.mjs +128 -0
- package/tests/hall_audit.mjs +4 -1
- package/tests/hall_kit_charts.mjs +75 -0
- package/tests/hall_profile_world.mjs +4 -1
- package/tests/me_ship_days_bound.mjs +76 -0
|
@@ -1988,6 +1988,37 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
1988
1988
|
.kit-chart__empty { font-size: 12px; color: var(--ink-faint); }
|
|
1989
1989
|
.kit-chart__foot { font-size: 11.5px; color: var(--ink-faint); margin-top: 4px; }
|
|
1990
1990
|
|
|
1991
|
+
/* The contribution calendar — OTBKit.calendarGridHtml (task 1003572), the hall's
|
|
1992
|
+
THIRD chart family. A year grid of small cells, banded by ship count 0..5. Theme-
|
|
1993
|
+
aware by construction: every band is a color-mix off --accent and --surface-2, both
|
|
1994
|
+
theme tokens, so light and dark read without a second palette and level 5 is the
|
|
1995
|
+
full accent in either. The grid is wide, so .kit-cal__scroll holds it in its OWN
|
|
1996
|
+
horizontal strip — the document never scrolls sideways (hall-ui/CLAUDE.md). */
|
|
1997
|
+
.kit-cal { display: flex; flex-direction: column; gap: 6px; --kit-cal-cell: 11px; }
|
|
1998
|
+
.kit-cal__scroll { overflow-x: auto; padding-bottom: 2px; }
|
|
1999
|
+
.kit-cal__grid {
|
|
2000
|
+
display: grid;
|
|
2001
|
+
grid-template-rows: repeat(7, var(--kit-cal-cell));
|
|
2002
|
+
grid-auto-flow: column;
|
|
2003
|
+
grid-auto-columns: var(--kit-cal-cell);
|
|
2004
|
+
gap: 3px;
|
|
2005
|
+
width: max-content; /* intrinsic width; the scroll strip, not the document, holds it */
|
|
2006
|
+
}
|
|
2007
|
+
.kit-cal__cell {
|
|
2008
|
+
width: var(--kit-cal-cell); height: var(--kit-cal-cell);
|
|
2009
|
+
border-radius: 2px;
|
|
2010
|
+
background: var(--surface-2);
|
|
2011
|
+
}
|
|
2012
|
+
.kit-cal__cell[data-level="1"] { background: color-mix(in srgb, var(--accent) 25%, var(--surface-2)); }
|
|
2013
|
+
.kit-cal__cell[data-level="2"] { background: color-mix(in srgb, var(--accent) 45%, var(--surface-2)); }
|
|
2014
|
+
.kit-cal__cell[data-level="3"] { background: color-mix(in srgb, var(--accent) 65%, var(--surface-2)); }
|
|
2015
|
+
.kit-cal__cell[data-level="4"] { background: color-mix(in srgb, var(--accent) 82%, var(--surface-2)); }
|
|
2016
|
+
.kit-cal__cell[data-level="5"] { background: var(--accent); }
|
|
2017
|
+
.kit-cal__cell--void { background: transparent; }
|
|
2018
|
+
.kit-cal__key { display: flex; align-items: center; gap: 3px; font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-faint); }
|
|
2019
|
+
.kit-cal__key .kit-cal__cell { --kit-cal-cell: 10px; }
|
|
2020
|
+
.kit-cal__key-label { margin: 0 2px; }
|
|
2021
|
+
|
|
1991
2022
|
.kit-spark { width: 100%; height: 44px; display: block; }
|
|
1992
2023
|
/* The stroke/fill here are the FALLBACK: sparklineHtml writes the caller's
|
|
1993
2024
|
colour inline, because the Curve spends a different accent per chart. */
|
|
@@ -9,15 +9,20 @@
|
|
|
9
9
|
|
|
10
10
|
const { pool } = require('../../src/module-api');
|
|
11
11
|
|
|
12
|
+
// Shared by listOpenBlockers + getBlocker so the two reads can never drift
|
|
13
|
+
// (task 1002459 — getBlocker used to omit this column entirely, so a
|
|
14
|
+
// link-then-verify against the detail route read as a false negative).
|
|
15
|
+
const BLOCKING_TASK_IDS_SQL = `COALESCE(
|
|
16
|
+
(SELECT array_agg(tb.task_id ORDER BY tb.task_id)
|
|
17
|
+
FROM task_blockers tb WHERE tb.blocker_id = b.id),
|
|
18
|
+
ARRAY[]::bigint[]
|
|
19
|
+
) AS blocking_task_ids`;
|
|
20
|
+
|
|
12
21
|
async function listOpenBlockers() {
|
|
13
22
|
const { rows } = await pool.query(
|
|
14
23
|
`SELECT b.id, b.title, b.body_md, b.status, b.created_at,
|
|
15
24
|
b.source, b.source_ref,
|
|
16
|
-
|
|
17
|
-
(SELECT array_agg(tb.task_id ORDER BY tb.task_id)
|
|
18
|
-
FROM task_blockers tb WHERE tb.blocker_id = b.id),
|
|
19
|
-
ARRAY[]::bigint[]
|
|
20
|
-
) AS blocking_task_ids
|
|
25
|
+
${BLOCKING_TASK_IDS_SQL}
|
|
21
26
|
FROM blockers b
|
|
22
27
|
WHERE b.status = 'open'
|
|
23
28
|
ORDER BY b.id`
|
|
@@ -27,9 +32,10 @@ async function listOpenBlockers() {
|
|
|
27
32
|
|
|
28
33
|
async function getBlocker(id) {
|
|
29
34
|
const { rows } = await pool.query(
|
|
30
|
-
`SELECT id, title, body_md, status, created_at, resolved_at, resolved_by,
|
|
31
|
-
source, source_ref
|
|
32
|
-
|
|
35
|
+
`SELECT b.id, b.title, b.body_md, b.status, b.created_at, b.resolved_at, b.resolved_by,
|
|
36
|
+
b.resolution_note, b.source, b.source_ref,
|
|
37
|
+
${BLOCKING_TASK_IDS_SQL}
|
|
38
|
+
FROM blockers b WHERE b.id = $1`,
|
|
33
39
|
[id]
|
|
34
40
|
);
|
|
35
41
|
return rows[0] || null;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.541",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.541",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.541",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -45,6 +45,34 @@ const seams = require('../../module-seams');
|
|
|
45
45
|
// `lifecycle` kernel PORT (lc()); the grade-TREND read (builderRollingAvgGrade)
|
|
46
46
|
// stays core db (the sessions BFG also consumes it — kept on the doorway).
|
|
47
47
|
const lc = () => seams.resolveOptional('lifecycle');
|
|
48
|
+
|
|
49
|
+
// boundShipDaysToYear(rows, todayStr) — the last ~year of a builder's ship-day
|
|
50
|
+
// counts, for the hall's contribution calendar (task 1003572). `rows` is
|
|
51
|
+
// computeStreak's `ship_day_counts` ([{ local_date:'YYYY-MM-DD', count }]) ascending
|
|
52
|
+
// in the builder's own timezone; `todayStr` is that same day. A calendar shows a
|
|
53
|
+
// year, and /me is the hall's busiest boot read, so an unbounded array is the exact
|
|
54
|
+
// over-fetch criterion legibility-sort-and-staleness names — the bound is the point.
|
|
55
|
+
//
|
|
56
|
+
// The cutoff is computed on the YYYY-MM-DD-as-UTC convention the streak math already
|
|
57
|
+
// uses (Date.UTC on the parsed parts), never through the server's local clock, so it
|
|
58
|
+
// cannot drift a day the way `new Date('2026-09-03')` would. 371 days = 53 whole
|
|
59
|
+
// weeks, so the grid the calendar draws is always full-width with no partial column.
|
|
60
|
+
const YMD_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
61
|
+
function boundShipDaysToYear(rows, todayStr) {
|
|
62
|
+
if (!Array.isArray(rows) || rows.length === 0) return [];
|
|
63
|
+
const toOrd = (ymd) => {
|
|
64
|
+
const m = YMD_RE.exec(String(ymd || ''));
|
|
65
|
+
if (!m) return null;
|
|
66
|
+
return Math.floor(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) / 86400000);
|
|
67
|
+
};
|
|
68
|
+
const todayOrd = toOrd(todayStr);
|
|
69
|
+
if (todayOrd == null) return rows.slice(-371); // no today anchor → last 371 rows, still bounded
|
|
70
|
+
const cutoff = todayOrd - 370;
|
|
71
|
+
return rows.filter((r) => {
|
|
72
|
+
const o = toOrd(r && r.local_date);
|
|
73
|
+
return o != null && o >= cutoff && o <= todayOrd;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
48
76
|
// The dev-box "has this builder ever connected a box?" signal, read through the
|
|
49
77
|
// kernel seam instead of a direct `require('../boxes')` (a forbidden core→module
|
|
50
78
|
// edge once dev-box moved to modules/dev-box/, ADR 0083 / BV1.R43). The dev-box
|
|
@@ -302,6 +330,15 @@ module.exports = function buildMeRouter() {
|
|
|
302
330
|
started_on: streakInfo.current_started_on,
|
|
303
331
|
timezone: streakInfo.timezone,
|
|
304
332
|
today: streakInfo.today,
|
|
333
|
+
// The per-day ship counts computeStreak already produced and this block
|
|
334
|
+
// used to drop (task 1003572, criterion legibility-series-are-drawn): the
|
|
335
|
+
// hall's contribution calendar draws them. BOUNDED to roughly a year — a
|
|
336
|
+
// calendar shows a year, and an unbounded array would grow without limit on
|
|
337
|
+
// the busiest boot read the hall has. `ship_day_counts` is ascending in the
|
|
338
|
+
// builder's own timezone ({local_date:'YYYY-MM-DD', count:1..5}); the cutoff
|
|
339
|
+
// is computed on the same YYYY-MM-DD-as-UTC convention the streak math uses,
|
|
340
|
+
// never through the request's local clock.
|
|
341
|
+
ship_days: boundShipDaysToYear(streakInfo.ship_day_counts, streakInfo.today),
|
|
305
342
|
},
|
|
306
343
|
grades: {
|
|
307
344
|
recent: recentGrades,
|
|
@@ -531,3 +568,8 @@ module.exports = function buildMeRouter() {
|
|
|
531
568
|
|
|
532
569
|
return router;
|
|
533
570
|
};
|
|
571
|
+
|
|
572
|
+
// Exported for tests only: the "bounded to a year" + timezone-safe cutoff behind the
|
|
573
|
+
// contribution calendar (task 1003572). The route mounts the router; this is the one
|
|
574
|
+
// pure function inside it whose correctness is worth pinning without a DB.
|
|
575
|
+
module.exports.boundShipDaysToYear = boundShipDaysToYear;
|
package/src/module-api.js
CHANGED
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.541'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// tests/blocker_detail_hydration.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002459 — GET /blockers/:id was missing `blocking_task_ids`, the array
|
|
4
|
+
// the list route (GET /blockers) already returns for the same row. A caller
|
|
5
|
+
// doing the natural link-then-verify (link a task, then re-fetch the blocker
|
|
6
|
+
// by id to confirm) read that as a false negative — the ids were never gone,
|
|
7
|
+
// `getBlocker`'s SELECT just never asked for them.
|
|
8
|
+
//
|
|
9
|
+
// The fix hoists the array_agg fragment into one shared constant
|
|
10
|
+
// (BLOCKING_TASK_IDS_SQL) so `listOpenBlockers` and `getBlocker` cannot drift
|
|
11
|
+
// apart again the way they did here. This test pins that: both queries must
|
|
12
|
+
// carry the identical fragment, and the detail route's SELECT must not add a
|
|
13
|
+
// status filter the list route has (task_blockers rows persist after resolve,
|
|
14
|
+
// so a resolved blocker's field reads as "tasks it WAS blocking" — a
|
|
15
|
+
// deliberate widening, not a bug, per the task's decision note).
|
|
16
|
+
//
|
|
17
|
+
// DB-free: `modules/ideas/blockers.js` destructures `pool` off the doorway at
|
|
18
|
+
// require time, so patching `pool.query` on that same object — BEFORE
|
|
19
|
+
// requiring blockers.js — intercepts every query. No live Postgres needed.
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/blocker_detail_hydration.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
|
|
28
|
+
const api = require('../src/module-api.js');
|
|
29
|
+
|
|
30
|
+
let queries = [];
|
|
31
|
+
|
|
32
|
+
api.pool.query = async (sql, params = []) => {
|
|
33
|
+
queries.push({ sql: String(sql), params });
|
|
34
|
+
if (/FROM blockers b\s+WHERE b\.status = 'open'/.test(sql)) {
|
|
35
|
+
return {
|
|
36
|
+
rows: [
|
|
37
|
+
{ id: '1000085', title: 'the open one', body_md: '', status: 'open',
|
|
38
|
+
created_at: new Date(), source: 'manual', source_ref: null,
|
|
39
|
+
blocking_task_ids: ['1001838', '1001839'] },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (/FROM blockers b WHERE b\.id = \$1/.test(sql)) {
|
|
44
|
+
const id = String(params[0]);
|
|
45
|
+
if (id === '1000085') {
|
|
46
|
+
return {
|
|
47
|
+
rows: [
|
|
48
|
+
{ id: '1000085', title: 'the open one', body_md: '', status: 'open',
|
|
49
|
+
created_at: new Date(), resolved_at: null, resolved_by: null,
|
|
50
|
+
resolution_note: null, source: 'manual', source_ref: null,
|
|
51
|
+
blocking_task_ids: ['1001838', '1001839'] },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (id === '1000099') {
|
|
56
|
+
// A resolved blocker: task_blockers rows persist, so the field still
|
|
57
|
+
// reports the tasks it WAS blocking (see comment above).
|
|
58
|
+
return {
|
|
59
|
+
rows: [
|
|
60
|
+
{ id: '1000099', title: 'the resolved one', body_md: '', status: 'resolved',
|
|
61
|
+
created_at: new Date(), resolved_at: new Date(), resolved_by: '41',
|
|
62
|
+
resolution_note: 'done', source: 'manual', source_ref: null,
|
|
63
|
+
blocking_task_ids: ['1002000'] },
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { rows: [] };
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const blockers = require('../modules/ideas/blockers.js');
|
|
73
|
+
|
|
74
|
+
let passed = 0;
|
|
75
|
+
let failed = 0;
|
|
76
|
+
|
|
77
|
+
async function test(name, fn) {
|
|
78
|
+
queries = [];
|
|
79
|
+
try {
|
|
80
|
+
await fn();
|
|
81
|
+
passed += 1;
|
|
82
|
+
console.log(` ok ${name}`);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
failed += 1;
|
|
85
|
+
console.log(` FAIL ${name}\n ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function blockingTaskIdsFragment(sql) {
|
|
90
|
+
const match = sql.match(/COALESCE\(\s*\(SELECT array_agg\(tb\.task_id[\s\S]*?blocking_task_ids/);
|
|
91
|
+
assert.ok(match, `query does not carry a blocking_task_ids projection:\n${sql}`);
|
|
92
|
+
return match[0].replace(/\s+/g, ' ').trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await test('listOpenBlockers and getBlocker project blocking_task_ids from the identical fragment', async () => {
|
|
96
|
+
await blockers.listOpenBlockers();
|
|
97
|
+
const listSql = queries[queries.length - 1].sql;
|
|
98
|
+
|
|
99
|
+
queries = [];
|
|
100
|
+
await blockers.getBlocker(1000085);
|
|
101
|
+
const detailSql = queries[queries.length - 1].sql;
|
|
102
|
+
|
|
103
|
+
assert.equal(blockingTaskIdsFragment(detailSql), blockingTaskIdsFragment(listSql));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await test('getBlocker returns blocking_task_ids on the row — the gap this task closes', async () => {
|
|
107
|
+
const row = await blockers.getBlocker(1000085);
|
|
108
|
+
assert.deepEqual(row.blocking_task_ids, ['1001838', '1001839']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await test('getBlocker does not restrict by status — the list route is open-only, the detail route is not', async () => {
|
|
112
|
+
const detailSql = (await (async () => { await blockers.getBlocker(1000085); return queries[queries.length - 1].sql; })());
|
|
113
|
+
assert.ok(!/WHERE b\.status = 'open'/.test(detailSql), 'detail query must not gain the list route\'s open-only filter');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await test('a resolved blocker still reports the tasks it WAS blocking (task_blockers rows persist)', async () => {
|
|
117
|
+
const row = await blockers.getBlocker(1000099);
|
|
118
|
+
assert.equal(row.status, 'resolved');
|
|
119
|
+
assert.deepEqual(row.blocking_task_ids, ['1002000']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('getBlocker still returns null for an id with no row', async () => {
|
|
123
|
+
const row = await blockers.getBlocker(999999);
|
|
124
|
+
assert.equal(row, null);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
128
|
+
process.exit(failed ? 1 : 0);
|
package/tests/hall_audit.mjs
CHANGED
|
@@ -256,7 +256,9 @@ test('the kit is the ONLY thing in the hall that draws a bar series', () => {
|
|
|
256
256
|
const stamped = [];
|
|
257
257
|
for (const f of fs.readdirSync(HALL)) {
|
|
258
258
|
if (!/\.(html|js)$/.test(f) || f === OWNER) continue;
|
|
259
|
-
|
|
259
|
+
// `cal` joins the family (task 1003572): the contribution calendar is stamped by
|
|
260
|
+
// OTBKit.calendarGridHtml, so a `kit-cal` class in any other file is a hand-roll.
|
|
261
|
+
if (/class="kit-(?:bars|spark|plot|cal)[\s"_-]/.test(read(f))) stamped.push(f);
|
|
260
262
|
}
|
|
261
263
|
assert.deepEqual(stamped, [],
|
|
262
264
|
`${stamped.join(', ')} writes the kit's own chart classes by hand — call `
|
|
@@ -317,6 +319,7 @@ test('the three bar copies are gone, and their class families with them', () =>
|
|
|
317
319
|
assert.match(read('hall-render.js'), /OTBKit\.barSeriesHtml\(/, 'the Curve\'s kinds draw through the kit');
|
|
318
320
|
assert.match(read('watch.js'), /OTBKit\.barSeriesHtml\(/, 'Watch\'s trend draws through the kit');
|
|
319
321
|
assert.match(read('profile-charts.js'), /OTBKit\.barSeriesHtml\(/, 'the craft mix draws through the kit');
|
|
322
|
+
assert.match(read('profile-charts.js'), /OTBKit\.calendarGridHtml\(/, 'the contribution calendar draws through the kit (task 1003572)');
|
|
320
323
|
assert.match(read('hall-render.js'), /OTBKit\.sparklineHtml\(/, 'and the Curve\'s line does too');
|
|
321
324
|
});
|
|
322
325
|
|
|
@@ -548,3 +548,78 @@ test('RUN: the timeline refuses to draw rather than drawing an empty axis', asyn
|
|
|
548
548
|
assert.doesNotMatch(card.html, />C</, 'the unstarted version is not a bar');
|
|
549
549
|
assert.match(card.html, /1 version with no start date is not shown/, 'the foot says so');
|
|
550
550
|
});
|
|
551
|
+
|
|
552
|
+
// ---- the contribution calendar (task 1003572) -------------------------------
|
|
553
|
+
//
|
|
554
|
+
// The hall's third chart family: a year grid, banded by count, read by per-cell
|
|
555
|
+
// title + an aria summary. These pin the arithmetic (banding, the 5-cap), the
|
|
556
|
+
// timezone trap the task names, and the empty-history contract.
|
|
557
|
+
|
|
558
|
+
test('CAL: a day lands on the cell its own YYYY-MM-DD names, banded by count', () => {
|
|
559
|
+
const html = kit.calendarGridHtml([
|
|
560
|
+
{ local_date: '2026-09-01', count: 2 },
|
|
561
|
+
{ local_date: '2026-09-03', count: 5 },
|
|
562
|
+
], { today: '2026-09-03', weeks: 6 });
|
|
563
|
+
assert.match(html, /2026-09-03 · 5 ships/);
|
|
564
|
+
assert.match(html, /2026-09-01 · 2 ships/);
|
|
565
|
+
assert.match(html, /data-level="5"/, 'a 5-ship day hits the top band');
|
|
566
|
+
assert.match(html, /data-level="2"/);
|
|
567
|
+
assert.match(html, /· no ships/, 'quiet days are titled too, not blank');
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test('CAL: the band caps at 5 — the same anti-gaming cap streak.js applies', () => {
|
|
571
|
+
// count can only be 1..5 from the server, but the primitive must not emit a 6th
|
|
572
|
+
// band if a caller ever over-counts.
|
|
573
|
+
const html = kit.calendarGridHtml([{ local_date: '2026-09-03', count: 9 }], { today: '2026-09-03', weeks: 2 });
|
|
574
|
+
assert.match(html, /2026-09-03 · 9 ships/, 'the title reports the real number');
|
|
575
|
+
assert.match(html, /data-level="5"/, 'but the band is clamped to 5');
|
|
576
|
+
assert.doesNotMatch(html, /data-level="[6-9]"/);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('CAL: the aria summary states the figure in words for a non-picture reader', () => {
|
|
580
|
+
const html = kit.calendarGridHtml([
|
|
581
|
+
{ local_date: '2026-09-01', count: 2 },
|
|
582
|
+
{ local_date: '2026-09-02', count: 1 },
|
|
583
|
+
], { today: '2026-09-03' });
|
|
584
|
+
assert.match(html, /aria-label="Ships per day over the last year: 3 ships across 2 days\./);
|
|
585
|
+
assert.match(html, /role="img"/);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('CAL: singular grammar — one ship, one day', () => {
|
|
589
|
+
const html = kit.calendarGridHtml([{ local_date: '2026-09-03', count: 1 }], { today: '2026-09-03' });
|
|
590
|
+
assert.match(html, /2026-09-03 · 1 ship"/, "no plural s on one ship");
|
|
591
|
+
assert.match(html, /1 ship across 1 day\./);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test('CAL: no timezone shift — the cell is fixed by the date string, not the runner clock', () => {
|
|
595
|
+
// The trap the task names: `new Date("2026-09-03")` is UTC-midnight and shifts a
|
|
596
|
+
// day west of Greenwich. The primitive uses Date.UTC on the parsed parts, so the
|
|
597
|
+
// title for a date is that date regardless of process.env.TZ.
|
|
598
|
+
const days = [{ local_date: '2026-01-01', count: 3 }];
|
|
599
|
+
const run = () => kit.calendarGridHtml(days, { today: '2026-01-07', weeks: 3 });
|
|
600
|
+
const saved = process.env.TZ;
|
|
601
|
+
try {
|
|
602
|
+
process.env.TZ = 'Pacific/Kiritimati'; // UTC+14, the worst case east
|
|
603
|
+
const a = run();
|
|
604
|
+
process.env.TZ = 'Pacific/Pago_Pago'; // UTC-11, the worst case west
|
|
605
|
+
const b = run();
|
|
606
|
+
assert.match(a, /2026-01-01 · 3 ships/);
|
|
607
|
+
assert.match(b, /2026-01-01 · 3 ships/);
|
|
608
|
+
// and the cell index is identical in both — no column/row drift.
|
|
609
|
+
assert.equal(a, b, 'the grid is byte-identical across extreme timezones');
|
|
610
|
+
} finally { process.env.TZ = saved; }
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test('CAL: empty history draws nothing, so the caller shows its own empty state', () => {
|
|
614
|
+
assert.equal(kit.calendarGridHtml([], {}), '');
|
|
615
|
+
assert.equal(kit.calendarGridHtml(null, {}), '');
|
|
616
|
+
assert.equal(kit.calendarGridHtml([{ local_date: '2026-09-03', count: 0 }], {}), '',
|
|
617
|
+
'a zero-count day is not a ship — nothing to draw');
|
|
618
|
+
assert.equal(kit.calendarGridHtml([{ local_date: 'not-a-date', count: 2 }], {}), '',
|
|
619
|
+
'an unparseable date is dropped, not floored to the epoch');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test('CAL: the grid gets its own horizontal strip — it never widens the document', () => {
|
|
623
|
+
const html = kit.calendarGridHtml([{ local_date: '2026-09-03', count: 1 }], { today: '2026-09-03' });
|
|
624
|
+
assert.match(html, /kit-cal__scroll/, 'the wide grid scrolls inside its own strip');
|
|
625
|
+
});
|
|
@@ -122,7 +122,10 @@ test('profile-charts.js loads before profile.js, and profile.js hands it the pag
|
|
|
122
122
|
const a = html.indexOf('/builders/profile-charts.js');
|
|
123
123
|
const b = html.indexOf('/builders/profile.js');
|
|
124
124
|
assert.ok(a > 0 && a < b, 'the charts script is in the page, ahead of profile.js');
|
|
125
|
-
|
|
125
|
+
// task 1003572 threads the viewer's own streak (for the contribution calendar) as a
|
|
126
|
+
// fifth field; the assertion checks the stable prefix rather than the exact tail.
|
|
127
|
+
assert.match(profileJs, /window\.OTBProfileCharts\.render\(\{ api, API, builderId: builder\.id, selfId, signedIn: selfId != null/);
|
|
128
|
+
assert.match(profileJs, /selfStreak: \(selfMe && selfMe\.streak\) \|\| null/, 'the own-streak is handed to the charts for the calendar');
|
|
126
129
|
assert.match(html, /<section class="scroll" id="charts-scroll"[^>]*>[\s\S]*?<div class="pcharts" id="charts-grid">/, 'the charts section is a .pcharts grid');
|
|
127
130
|
});
|
|
128
131
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// tests/me_ship_days_bound.mjs — the year bound on /me's ship_days (task 1003572,
|
|
2
|
+
// criterion legibility-series-are-drawn).
|
|
3
|
+
//
|
|
4
|
+
// The row array computeStreak produces is unbounded, and /me is the hall's busiest
|
|
5
|
+
// boot read, so shipping the whole history is the exact over-fetch a sibling criterion
|
|
6
|
+
// (legibility-sort-and-staleness) names. This pins the two properties that make the
|
|
7
|
+
// field safe to add: it is BOUNDED to about a year, and the cutoff is TIMEZONE-SAFE —
|
|
8
|
+
// computed on the YYYY-MM-DD-as-UTC convention, never the server's local clock.
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const { boundShipDaysToYear } = require('../src/bongos/routes/me.js');
|
|
14
|
+
|
|
15
|
+
// A pure ordinal helper for building fixtures, matching the module's own convention.
|
|
16
|
+
const ord = (y, m, d) => Math.floor(Date.UTC(y, m - 1, d) / 86400000);
|
|
17
|
+
const ymd = (o) => { const dt = new Date(o * 86400000); return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}`; };
|
|
18
|
+
|
|
19
|
+
test('keeps roughly a year and drops what is older', () => {
|
|
20
|
+
const today = '2026-09-03';
|
|
21
|
+
const todayOrd = ord(2026, 9, 3);
|
|
22
|
+
const rows = [];
|
|
23
|
+
// one row every 10 days going back ~2 years
|
|
24
|
+
for (let o = todayOrd; o > todayOrd - 730; o -= 10) rows.unshift({ local_date: ymd(o), count: 1 });
|
|
25
|
+
const out = boundShipDaysToYear(rows, today);
|
|
26
|
+
assert.ok(out.length > 0);
|
|
27
|
+
// nothing older than the 371-day window survives
|
|
28
|
+
const cutoff = todayOrd - 370;
|
|
29
|
+
for (const r of out) {
|
|
30
|
+
const o = ord(...r.local_date.split('-').map(Number));
|
|
31
|
+
assert.ok(o >= cutoff && o <= todayOrd, `${r.local_date} is inside the year window`);
|
|
32
|
+
}
|
|
33
|
+
// and it is strictly smaller than the full 2-year input
|
|
34
|
+
assert.ok(out.length < rows.length, 'the old rows were dropped');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a future-dated row (clock skew) is not kept', () => {
|
|
38
|
+
const out = boundShipDaysToYear([
|
|
39
|
+
{ local_date: '2026-09-03', count: 2 },
|
|
40
|
+
{ local_date: '2027-01-01', count: 1 }, // after today
|
|
41
|
+
], '2026-09-03');
|
|
42
|
+
assert.deepEqual(out.map((r) => r.local_date), ['2026-09-03']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('empty in, empty out — and a missing today anchor still bounds the array', () => {
|
|
46
|
+
assert.deepEqual(boundShipDaysToYear([], '2026-09-03'), []);
|
|
47
|
+
assert.deepEqual(boundShipDaysToYear(null, '2026-09-03'), []);
|
|
48
|
+
// No usable today → still bounded (last 371 rows), never the whole history.
|
|
49
|
+
const rows = Array.from({ length: 1000 }, (_, i) => ({ local_date: ymd(ord(2020, 1, 1) + i), count: 1 }));
|
|
50
|
+
const out = boundShipDaysToYear(rows, 'not-a-date');
|
|
51
|
+
assert.equal(out.length, 371, 'falls back to a bounded tail, not all 1000');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('timezone-safe: the same rows bound to the same set under any TZ', () => {
|
|
55
|
+
// The point is invariance, not a specific boundary: `new Date("2025-01-01")` would be
|
|
56
|
+
// UTC-midnight and shift a day west of Greenwich, so a naive cutoff would keep a
|
|
57
|
+
// different set under UTC+14 vs UTC-11. Date.UTC on the parts does not.
|
|
58
|
+
const rows = [
|
|
59
|
+
{ local_date: '2024-01-01', count: 1 }, // ~20 months old → always dropped
|
|
60
|
+
{ local_date: '2025-06-15', count: 2 }, // ~15 months? within a year of 2026-09-03 → kept
|
|
61
|
+
{ local_date: '2026-09-03', count: 1 }, // today → kept
|
|
62
|
+
];
|
|
63
|
+
const saved = process.env.TZ;
|
|
64
|
+
const results = [];
|
|
65
|
+
try {
|
|
66
|
+
for (const tz of ['UTC', 'Pacific/Kiritimati', 'Pacific/Pago_Pago']) {
|
|
67
|
+
process.env.TZ = tz;
|
|
68
|
+
results.push(boundShipDaysToYear(rows, '2026-09-03').map((r) => r.local_date).join(','));
|
|
69
|
+
}
|
|
70
|
+
} finally { process.env.TZ = saved; }
|
|
71
|
+
assert.equal(results[0], results[1], 'UTC vs UTC+14 keep the same set');
|
|
72
|
+
assert.equal(results[1], results[2], 'UTC+14 vs UTC-11 keep the same set');
|
|
73
|
+
// and 2024 is out while today is in, whatever the TZ
|
|
74
|
+
assert.match(results[0], /2026-09-03/);
|
|
75
|
+
assert.doesNotMatch(results[0], /2024-01-01/);
|
|
76
|
+
});
|