@bongos/core 1.19.539 → 1.19.540

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. */
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.540",
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.540",
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.540",
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.540'; // 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');
@@ -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
+ });