@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.
@@ -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
- COALESCE(
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, resolution_note,
31
- source, source_ref
32
- FROM blockers WHERE id = $1`,
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.539",
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.539",
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.539",
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",
@@ -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.539'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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);
@@ -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
- if (/class="kit-(?:bars|spark|plot)[\s"_-]/.test(read(f))) stamped.push(f);
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
- assert.match(profileJs, /window\.OTBProfileCharts\.render\(\{ api, API, builderId: builder\.id, selfId, signedIn: selfId != null \}\)/);
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
+ });