@bongos/core 1.19.489 → 1.19.491

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 CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.489",
6
- "core_contract": "1.19.489",
7
- "source_commit": "3506481b0b600021907f5446133f6d663e294300",
5
+ "core_version": "1.19.491",
6
+ "core_contract": "1.19.491",
7
+ "source_commit": "7c6f9058ca961e49439235e4aec6dd39a3ffe68a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-02T21:52:48.302Z",
9
+ "built_at": "2026-09-02T22:49:35.926Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 422,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2301,
20
- "tree_sha256": "2c5e88df303f5244bd7b5940fab359f3e11425e9e63a9f0ab6f83de29087057d",
20
+ "tree_sha256": "f8d28b162d783a50b0d62ba88bf1ac779f94f646c0b53e4d58b7553bc1385804",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1707,12 +1707,12 @@
1707
1707
  {
1708
1708
  "path": "docs/copy-inventory.md",
1709
1709
  "mode": "0000644",
1710
- "sha256": "771d81a06ad4902935fc2efcc600928cae46beb88a444b0a1014a864bc6e1ff6"
1710
+ "sha256": "86eb1bbcd8457069f957c6d46244481450e177ead7dff06bfb4db0e81eab7ee3"
1711
1711
  },
1712
1712
  {
1713
1713
  "path": "docs/copy-registry.json",
1714
1714
  "mode": "0000644",
1715
- "sha256": "d81d1e8c002faec92f9c2d7014ee0ba5a08251a0036c9a781c56e5dbc65c6036"
1715
+ "sha256": "0fe9dc2df8e22113b9d5ae7d18b762be3e0f05d546989905e6ba2cd37c72049b"
1716
1716
  },
1717
1717
  {
1718
1718
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2162,7 +2162,7 @@
2162
2162
  {
2163
2163
  "path": "docs/module-api-changelog.md",
2164
2164
  "mode": "0000644",
2165
- "sha256": "77ac9fa31b1d7a011d9a9a886c2d31075357cc091f2362b0cce9a7842a5eac76"
2165
+ "sha256": "946a1c2234ae65a5474c6c60871c5c82e0ca9e3726cf02f099b7c43fa3c22c02"
2166
2166
  },
2167
2167
  {
2168
2168
  "path": "docs/modules-contract.md",
@@ -4472,7 +4472,7 @@
4472
4472
  {
4473
4473
  "path": "modules/hall-ui/public/profile-charts.js",
4474
4474
  "mode": "0000644",
4475
- "sha256": "eb09aa6816138d934a117577b4d2a2a5ca662bc265b4c3ef98afae965b947f48"
4475
+ "sha256": "865df71a66dd944006b01dfb1317778a1d733d9320b4486efd8ee1ed3ea76f19"
4476
4476
  },
4477
4477
  {
4478
4478
  "path": "modules/hall-ui/public/profile-connections.js",
@@ -4497,7 +4497,7 @@
4497
4497
  {
4498
4498
  "path": "modules/hall-ui/public/profile.js",
4499
4499
  "mode": "0000644",
4500
- "sha256": "561186d432caf2ed6ca3c713da0211571d6fb622370865e92c31d0777b8cc012"
4500
+ "sha256": "15d0f56fa577161ed5ad78ae4117e6e8324f9b35cf7f2ffff51d8fc7bed4e382"
4501
4501
  },
4502
4502
  {
4503
4503
  "path": "modules/hall-ui/public/profile.states.json",
@@ -6797,12 +6797,12 @@
6797
6797
  {
6798
6798
  "path": "package-lock.json",
6799
6799
  "mode": "0000644",
6800
- "sha256": "d03ddd3947795df0340ece2ac8c1b7e63fc042eb60f5d042a77a1f0bdd66a905"
6800
+ "sha256": "ca75c416a0104e2e57e5b9c45d2b74b1c6ddbf5de524ff487df095134634b53d"
6801
6801
  },
6802
6802
  {
6803
6803
  "path": "package.json",
6804
6804
  "mode": "0000644",
6805
- "sha256": "b03444ad929ff36ff840783ccfc753dd9fed6a1d973cb6eb66be8851fb62afbb"
6805
+ "sha256": "91a2d397dca70394d2ca4d7f40e942f18672449781d8f08446ab8fdd8ddd9437"
6806
6806
  },
6807
6807
  {
6808
6808
  "path": "public-docs/index.html",
@@ -8087,12 +8087,12 @@
8087
8087
  {
8088
8088
  "path": "scripts/hall-preview/refresh-fixtures.js",
8089
8089
  "mode": "0000644",
8090
- "sha256": "b5819ccfabd209ced41613c6f9109faa2723a49c017c115a27383d907f777f3b"
8090
+ "sha256": "7f4c827ec125a719686f71b0d8abf5177062fef9d06b78fabe2993871f69c964"
8091
8091
  },
8092
8092
  {
8093
8093
  "path": "scripts/hall-preview/server.js",
8094
8094
  "mode": "0000644",
8095
- "sha256": "fb7e575fe38ea1cc9808eaece549670d6a3cbc6bb0ff6258dfc1ca00bfbba726"
8095
+ "sha256": "8f2b9f348d7372db1a5c9be3cdd9c14d48f20884b3f816b664ff3e590fa3b951"
8096
8096
  },
8097
8097
  {
8098
8098
  "path": "scripts/migrate.sh",
@@ -8462,7 +8462,7 @@
8462
8462
  {
8463
8463
  "path": "src/module-api.js",
8464
8464
  "mode": "0000644",
8465
- "sha256": "5f6456c747feac5626b882b25a9686a1038fe13aca4b3418e7c00e9086fafd11"
8465
+ "sha256": "ad32edff429153aa8a1231a90be444f349bdbcd38b4940011a10f41c86b2e2fb"
8466
8466
  },
8467
8467
  {
8468
8468
  "path": "src/module-loader/catalog.js",
@@ -9887,7 +9887,7 @@
9887
9887
  {
9888
9888
  "path": "tests/hall_profile_world.mjs",
9889
9889
  "mode": "0000644",
9890
- "sha256": "1497c135cf064a1465290a319391714608f2c33b1cd6f9db7b43f6b17cad7fbe"
9890
+ "sha256": "9e5047b39d55875e167c992157c6c04a9a766ec331c1a3bf343fe7ea194a8309"
9891
9891
  },
9892
9892
  {
9893
9893
  "path": "tests/hall_reading_world.mjs",
@@ -4,13 +4,13 @@
4
4
  > Deterministic on a given tree — regenerate and commit rather than hand-editing.
5
5
  > Machine-readable twin: [`copy-registry.json`](copy-registry.json).
6
6
 
7
- **1589 strings** across 3 surfaces · 1380 distinct pieces of copy · 1589 certain, 0 heuristic · 150 shown in more than one place.
7
+ **1590 strings** across 3 surfaces · 1381 distinct pieces of copy · 1590 certain, 0 heuristic · 150 shown in more than one place.
8
8
 
9
9
  ## Surfaces
10
10
 
11
11
  | surface | lives at | files | strings |
12
12
  |---|---|---|---|
13
- | `builders-hall` | `modules/hall-ui/public` | 73 | 1146 |
13
+ | `builders-hall` | `modules/hall-ui/public` | 73 | 1147 |
14
14
  | `status-dashboard` | `modules/status-ui/public` | 4 | 98 |
15
15
  | `landing` | `modules/public-landing/public` | 5 | 345 |
16
16
 
@@ -78,7 +78,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
78
78
  | `563d29ed86d8` | 2x | Bulk actions | modules/hall-ui/public/goals.html:172, modules/hall-ui/public/work.html:129 |
79
79
  | … | | _110 more in the JSON registry_ | |
80
80
 
81
- ## `builders-hall` — 1146 strings
81
+ ## `builders-hall` — 1147 strings
82
82
 
83
83
  | id | conf | text | where |
84
84
  |---|---|---|---|
@@ -284,7 +284,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
284
284
  | `7775fec0fa85` | certain | Could not load sessions. | `modules/hall-ui/public/sessions.js:187` |
285
285
  | `7775fec0fa85` | certain | Could not load sessions. | `modules/hall-ui/public/sessions.js:226` |
286
286
  | `b2147cc90e36` | certain | Could not load the idea template. Reload, or file from the terminal with capture.js. | `modules/hall-ui/public/ideas.js:438` |
287
- | `7604af13c40e` | certain | Could not load the profile. | `modules/hall-ui/public/profile.js:758` |
287
+ | `7604af13c40e` | certain | Could not load the profile. | `modules/hall-ui/public/profile.js:759` |
288
288
  | `8b69e445592a` | certain | Could not load the question labels; showing the answers as stored. | `modules/hall-ui/public/idea.js:244` |
289
289
  | `edd5bafbe075` | certain | Could not load the queue just now. | `modules/hall-ui/public/ideas.js:387` |
290
290
  | `02bb0570996a` | certain | Could not load the ranks. | `modules/hall-ui/public/ranks.js:197` |
@@ -533,7 +533,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
533
533
  | `7a239a17afc8` | certain | Loading your preferences… | `modules/hall-ui/public/settings.html:32` |
534
534
  | `04df1bd30129` | certain | Loading your rank… | `modules/hall-ui/public/ranks.html:30` |
535
535
  | `c27711f9eb4d` | certain | Locked until you reach the end of the article above. | `modules/hall-ui/public/primer.html:103` |
536
- | `4017ee030bc2` | certain | Lookback window | `modules/hall-ui/public/profile-charts.js:356` |
536
+ | `4017ee030bc2` | certain | Lookback window | `modules/hall-ui/public/profile-charts.js:410` |
537
537
  | `a93ca2841b96` | certain | Mac (Intel) | `modules/hall-ui/public/hall-render.js:744` |
538
538
  | `06b4ede3fb58` | certain | Machine principals | `modules/hall-ui/public/government.html:124` |
539
539
  | `6b3d660e0e31` | certain | Map view | `modules/hall-ui/public/atlas.html:46` |
@@ -1004,7 +1004,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1004
1004
  | `181d339c9318` | certain | and build right here in the browser — no remote control needed. Either way, reopening the online terminal late | `modules/hall-ui/public/hall-render.js:744` |
1005
1005
  | `bc960f460250` | certain | and install dependencies ( | `modules/hall-ui/public/hall-render.js:785` |
1006
1006
  | `b867876e8b6c` | certain | and press Enter. | `modules/hall-ui/public/hall-render.js:744` |
1007
- | `b4033000c537` | certain | as a table | `modules/hall-ui/public/profile-charts.js:191` |
1007
+ | `b4033000c537` | certain | as a table | `modules/hall-ui/public/profile-charts.js:204` |
1008
1008
  | `d7c511423bc0` | certain | as configuration: {…} | `modules/hall-ui/public/government.js:707` |
1009
1009
  | `09db3fd4355b` | certain | asked by {…} | `modules/hall-ui/public/collab.js:69` |
1010
1010
  | `63bd14f73960` | certain | author trusted | `modules/hall-ui/public/gate.js:141` |
@@ -1081,6 +1081,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1081
1081
  | `3916a3dc8e98` | certain | not started | `modules/hall-ui/public/roadmap.js:108` |
1082
1082
  | `3916a3dc8e98` | certain | not started | `modules/hall-ui/public/roadmap.js:263` |
1083
1083
  | `b5567b42fa52` | certain | not yet | `modules/hall-ui/public/not-ready.html:25` |
1084
+ | `8ca0a499ec86` | certain | nothing recorded yet · {…} | `modules/hall-ui/public/profile-charts.js:222` |
1084
1085
  | `e2c643fbf4d6` | certain | npm install | `modules/hall-ui/public/hall-render.js:785` |
1085
1086
  | `e90b5601ce16` | certain | objected to | `modules/hall-ui/public/government.js:824` |
1086
1087
  | `351d56b80a6c` | certain | of all in-flight tasks shipped | `modules/hall-ui/public/roadmap.js:273` |
@@ -1107,8 +1108,8 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1107
1108
  | `9bec9eb4829e` | certain | scope: {…} | `modules/hall-ui/public/goals-render.js:774` |
1108
1109
  | `a6f55d7aa6f7` | certain | shipped (live) | `modules/hall-ui/public/roadmap.js:273` |
1109
1110
  | `026ba1289d4d` | certain | shipped · | `modules/hall-ui/public/goals-render.js:696` |
1110
- | `ec6d8c900bc8` | certain | sign in | `modules/hall-ui/public/profile-charts.js:418` |
1111
- | `ec6d8c900bc8` | certain | sign in | `modules/hall-ui/public/profile-charts.js:430` |
1111
+ | `ec6d8c900bc8` | certain | sign in | `modules/hall-ui/public/profile-charts.js:472` |
1112
+ | `ec6d8c900bc8` | certain | sign in | `modules/hall-ui/public/profile-charts.js:484` |
1112
1113
  | `0215b2cdca8c` | certain | submit for review | `modules/hall-ui/public/modules.js:132` |
1113
1114
  | `da30b952a60b` | certain | system / unattributed | `modules/hall-ui/public/goals-page.js:249` |
1114
1115
  | `da30b952a60b` | certain | system / unattributed | `modules/hall-ui/public/work.js:498` |
@@ -1121,7 +1122,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1121
1122
  | `bd3402efa59e` | certain | task{…} shipped | `modules/hall-ui/public/goals-render.js:737` |
1122
1123
  | `95854f8315ca` | certain | the page everyone else reads | `modules/hall-ui/public/profile.html:32` |
1123
1124
  | `632527db5bca` | certain | this session | `modules/hall-ui/public/settings-sessions.js:44` |
1124
- | `130c3731ad83` | certain | this week | `modules/hall-ui/public/profile-charts.js:273` |
1125
+ | `130c3731ad83` | certain | this week | `modules/hall-ui/public/profile-charts.js:298` |
1125
1126
  | `ee431f143549` | certain | three tasks | `modules/hall-ui/public/not-ready.html:33` |
1126
1127
  | `d70c287bcd7f` | certain | to authenticate this machine, then | `modules/hall-ui/public/hall-render.js:785` |
1127
1128
  | `65702f92a8c8` | certain | to claim your first task. | `modules/hall-ui/public/hall-render.js:785` |
@@ -1190,8 +1191,8 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1190
1191
  | `2a2728a4ec4f` | certain | {…}% of tasks complete | `modules/hall-ui/public/roadmap.js:70` |
1191
1192
  | `15e545eb3ce2` | certain | {…}, no such task | `modules/hall-ui/public/task.js:505` |
1192
1193
  | `c43050b62297` | certain | {…}/100 complete | `modules/hall-ui/public/idea.js:87` |
1193
- | `afea9a3a8e9e` | certain | {…}: {…} as of today; arrow keys walk the days | `modules/hall-ui/public/profile-charts.js:223` |
1194
- | `b3d764eaf258` | certain | {…}: {…} in {…} weeks; arrow keys walk the weeks | `modules/hall-ui/public/profile-charts.js:273` |
1194
+ | `afea9a3a8e9e` | certain | {…}: {…} as of today; arrow keys walk the days | `modules/hall-ui/public/profile-charts.js:248` |
1195
+ | `b3d764eaf258` | certain | {…}: {…} in {…} weeks; arrow keys walk the weeks | `modules/hall-ui/public/profile-charts.js:298` |
1195
1196
  | `8e8c7da4f7f5` | certain | {…}criteria met · awaiting review | `modules/hall-ui/public/goals-render.js:155` |
1196
1197
  | `1e275ee0299c` | certain | {…}s only | `modules/hall-ui/public/government.js:1121` |
1197
1198
  | `499916045b19` | certain | {…}{…} asks to join | `modules/hall-ui/public/goal-inbox.js:69` |
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1589,
7
- "unique_ids": 1380,
8
- "certain": 1589,
6
+ "strings": 1590,
7
+ "unique_ids": 1381,
8
+ "certain": 1590,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 150
11
11
  },
@@ -13,7 +13,7 @@
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 73,
16
- "strings": 1146
16
+ "strings": 1147
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -1443,8 +1443,8 @@
1443
1443
  "text": "sign in",
1444
1444
  "occurrences": 2,
1445
1445
  "at": [
1446
- "modules/hall-ui/public/profile-charts.js:418",
1447
- "modules/hall-ui/public/profile-charts.js:430"
1446
+ "modules/hall-ui/public/profile-charts.js:472",
1447
+ "modules/hall-ui/public/profile-charts.js:484"
1448
1448
  ]
1449
1449
  },
1450
1450
  {
@@ -3422,7 +3422,7 @@
3422
3422
  "surface": "builders-hall",
3423
3423
  "text": "Could not load the profile.",
3424
3424
  "file": "modules/hall-ui/public/profile.js",
3425
- "line": 758,
3425
+ "line": 759,
3426
3426
  "origin": "js-notice",
3427
3427
  "confidence": "certain"
3428
3428
  },
@@ -5663,7 +5663,7 @@
5663
5663
  "surface": "builders-hall",
5664
5664
  "text": "Lookback window",
5665
5665
  "file": "modules/hall-ui/public/profile-charts.js",
5666
- "line": 356,
5666
+ "line": 410,
5667
5667
  "origin": "js-markup",
5668
5668
  "confidence": "certain"
5669
5669
  },
@@ -9902,7 +9902,7 @@
9902
9902
  "surface": "builders-hall",
9903
9903
  "text": "as a table",
9904
9904
  "file": "modules/hall-ui/public/profile-charts.js",
9905
- "line": 191,
9905
+ "line": 204,
9906
9906
  "origin": "js-markup",
9907
9907
  "confidence": "certain"
9908
9908
  },
@@ -10590,6 +10590,15 @@
10590
10590
  "origin": "html-text",
10591
10591
  "confidence": "certain"
10592
10592
  },
10593
+ {
10594
+ "id": "8ca0a499ec86",
10595
+ "surface": "builders-hall",
10596
+ "text": "nothing recorded yet · {…}",
10597
+ "file": "modules/hall-ui/public/profile-charts.js",
10598
+ "line": 222,
10599
+ "origin": "js-markup",
10600
+ "confidence": "certain"
10601
+ },
10593
10602
  {
10594
10603
  "id": "e2c643fbf4d6",
10595
10604
  "surface": "builders-hall",
@@ -10829,7 +10838,7 @@
10829
10838
  "surface": "builders-hall",
10830
10839
  "text": "sign in",
10831
10840
  "file": "modules/hall-ui/public/profile-charts.js",
10832
- "line": 418,
10841
+ "line": 472,
10833
10842
  "origin": "js-markup",
10834
10843
  "confidence": "certain"
10835
10844
  },
@@ -10838,7 +10847,7 @@
10838
10847
  "surface": "builders-hall",
10839
10848
  "text": "sign in",
10840
10849
  "file": "modules/hall-ui/public/profile-charts.js",
10841
- "line": 430,
10850
+ "line": 484,
10842
10851
  "origin": "js-markup",
10843
10852
  "confidence": "certain"
10844
10853
  },
@@ -10955,7 +10964,7 @@
10955
10964
  "surface": "builders-hall",
10956
10965
  "text": "this week",
10957
10966
  "file": "modules/hall-ui/public/profile-charts.js",
10958
- "line": 273,
10967
+ "line": 298,
10959
10968
  "origin": "js-markup",
10960
10969
  "confidence": "certain"
10961
10970
  },
@@ -11576,7 +11585,7 @@
11576
11585
  "surface": "builders-hall",
11577
11586
  "text": "{…}: {…} as of today; arrow keys walk the days",
11578
11587
  "file": "modules/hall-ui/public/profile-charts.js",
11579
- "line": 223,
11588
+ "line": 248,
11580
11589
  "origin": "js-markup",
11581
11590
  "confidence": "certain"
11582
11591
  },
@@ -11585,7 +11594,7 @@
11585
11594
  "surface": "builders-hall",
11586
11595
  "text": "{…}: {…} in {…} weeks; arrow keys walk the weeks",
11587
11596
  "file": "modules/hall-ui/public/profile-charts.js",
11588
- "line": 273,
11597
+ "line": 298,
11589
11598
  "origin": "js-markup",
11590
11599
  "confidence": "certain"
11591
11600
  },
@@ -1415,5 +1415,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1415
1415
  landed since 1.19.487 with no explicit bump. run 33686951657. (task 1002620)
1416
1416
  1.19.489 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1417
1417
  landed since 1.19.488 with no explicit bump. run 33687461982. (task 1002620)
1418
+ 1.19.490 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1419
+ landed since 1.19.489 with no explicit bump. run 33691767867. (task 1002620)
1420
+ 1.19.491 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1421
+ landed since 1.19.490 with no explicit bump. run 33692191645. (task 1002620)
1418
1422
  ---------------------------------------------------------------------------
1419
1423
  ```
@@ -15,20 +15,30 @@
15
15
  //
16
16
  // Data: GET /builders/:id/activity?section=tasks — any signed-in viewer, any
17
17
  // builder; paged, followed to `total` up to MAX_ROWS. Its rows carry
18
- // shipped_at, credits_awarded (credits_reward as the estimate when unpaid) and
19
- // discipline, so all three public series derive client-side. The ship rate is
20
- // GET /analytics/builder/:id, own-or-archon; it is asked for only when the
21
- // viewer IS the builder and renders nothing elsewhere — never a 403 toast.
18
+ // shipped_at and discipline, so the works and craft series derive client-side.
19
+ // The drachmae series is the CREDITS LEDGER (task 1003491): GET /me/credits on
20
+ // your own profile, GET /builders/:id/credits on another's (builder.roster.read,
21
+ // metic+), each row a `delta` at `recorded_at`, cumulated by day — the curve
22
+ // that ends at the headline balance. On a cost-plus instance (ADR 0146) the
23
+ // per-task award is structurally 0 and the pay is the session token reward,
24
+ // which only the ledger records; a series summed from per-task awards went
25
+ // flat the day per-task payout stopped. A viewer the ledger refuses (403)
26
+ // gets the activity rows' credits_reward ESTIMATE instead, cumulated and
27
+ // labelled so. The ship rate is GET /analytics/builder/:id, own-or-archon; it
28
+ // is asked for only when the viewer IS the builder and renders nothing
29
+ // elsewhere — never a 403 toast.
22
30
  //
23
31
  // Exposed as window.OTBProfileCharts: render({ api, API, builderId, selfId,
24
32
  // signedIn, now? }) plus the pure series builders (cumulativeByDay, craftMix,
25
- // weeklyBuckets, creditsOf, clipToWindow) so tests can pin the arithmetic
33
+ // weeklyBuckets, estimateOf, clipToWindow) so tests can pin the arithmetic
26
34
  // without a DOM.
27
35
  (() => {
28
36
  'use strict';
29
37
 
30
38
  const MAX_ROWS = 500; // five pages: a prolific builder's whole record, bounded
31
39
  const PAGE = 100;
40
+ const LEDGER_PAGE = 200; // the ledger route's MAX_LIMIT
41
+ const LEDGER_MAX_ROWS = 2000; // ten pages; the busiest builder's ledger is ~600 rows
32
42
  const DAY = 86400000;
33
43
  const WEEK = 7 * DAY;
34
44
  const MIX_MAX = 7; // bars past this fold into "other" — never an eighth hue
@@ -72,24 +82,27 @@
72
82
 
73
83
  // ---- series ---------------------------------------------------------------
74
84
 
75
- // What a shipped row paid. credits_awarded is the ledger; when it is absent
76
- // (not yet paid, or a cost-plus instance that never fills it) the estimate
77
- // stands in, and the caller is told so it can say "estimated" (ADR 0146).
78
- function creditsOf(row) {
79
- const a = row.credits_awarded;
80
- if (a !== null && a !== undefined && Number.isFinite(Number(a))) return { value: Number(a), estimated: false };
81
- const e = Number(row.credits_reward);
82
- return { value: Number.isFinite(e) ? e : 0, estimated: true };
85
+ // The ESTIMATE of what a shipped row pays — credits_reward, the figure the
86
+ // task carried before it shipped. The FALLBACK only: the drachmae series is
87
+ // the ledger wherever the viewer may read it, and this stands in, labelled
88
+ // "estimated" (ADR 0146), when the ledger answers 403. Never the per-task
89
+ // award column — on a cost-plus instance it is 0 for every task, and summing
90
+ // it is the flatline this file was fixed for.
91
+ function estimateOf(row) {
92
+ const e = Number(row && row.credits_reward);
93
+ return Number.isFinite(e) ? e : 0;
83
94
  }
84
95
 
85
96
  // rows → [{ day, value }] ascending by day, value cumulative over the WHOLE
86
- // record. Two ships on one day are one point; a row with no parsable
87
- // shipped_at is skipped, not zeroed. The lookback clips this for drawing
88
- // (clipToWindow) rather than re-accumulating, so the running total stays true.
89
- function cumulativeByDay(rows, valueOf) {
97
+ // record. dateOf names the row's timestamp: shipped_at for activity rows (the
98
+ // default), recorded_at for ledger rows. Two entries on one day are one
99
+ // point; a row with no parsable date is skipped, not zeroed. The lookback
100
+ // clips this for drawing (clipToWindow) rather than re-accumulating, so the
101
+ // running total stays true.
102
+ function cumulativeByDay(rows, valueOf, dateOf = (r) => r && r.shipped_at) {
90
103
  const byDay = new Map();
91
104
  for (const r of rows || []) {
92
- const t = Date.parse(r && r.shipped_at);
105
+ const t = Date.parse(dateOf(r));
93
106
  if (!Number.isFinite(t)) continue;
94
107
  const d = midnight(t);
95
108
  byDay.set(d, (byDay.get(d) || 0) + Number(valueOf(r)) || 0);
@@ -201,6 +214,18 @@
201
214
  // then runs flat out to the window's end. The first drawn point enters at its
202
215
  // running height (clipToWindow), the last value carries the only direct label.
203
216
  function timeChart({ id, title, points, valueText, footer, now, windowStart }) {
217
+ // A series with nothing in it (a ledger no drachma has reached yet) is one
218
+ // honest line under the title, not a crash on points[0].
219
+ if (!points.length) {
220
+ return {
221
+ id,
222
+ html: `<figure class="pchart" id="${esc(id)}">
223
+ <figcaption class="pchart__title">${esc(title)}</figcaption>
224
+ <p class="pchart__foot">nothing recorded yet · ${esc(footer)}</p>
225
+ </figure>`,
226
+ wire() {},
227
+ };
228
+ }
204
229
  const today = midnight(now);
205
230
  const x0 = windowStart != null ? windowStart : points[0].day;
206
231
  const x1 = Math.max(today, x0 + WEEK);
@@ -335,6 +360,35 @@
335
360
  return { signedOut: false, rows };
336
361
  }
337
362
 
363
+ // The credits ledger, first page to `total`, bounded: /me/credits on your own
364
+ // profile, /builders/:id/credits on another's. The SERVER decides who may
365
+ // read another builder's ledger (builder.roster.read is a delegable atom, so
366
+ // no rank is hard-coded here): a refusal comes back as `denied` and the
367
+ // caller falls back to the estimate rather than the section failing. Any
368
+ // other failure reads the same way — a ledger the page could not read is
369
+ // still a ledger it must not pretend to have.
370
+ async function fetchLedger(api, API, builderId, isOwn) {
371
+ const base = isOwn
372
+ ? `${API}/me/credits`
373
+ : `${API}/builders/${encodeURIComponent(builderId)}/credits`;
374
+ const rows = [];
375
+ let total = Infinity;
376
+ try {
377
+ while (rows.length < Math.min(total, LEDGER_MAX_ROWS)) {
378
+ const res = await api.request('GET', `${base}?limit=${LEDGER_PAGE}&offset=${rows.length}`);
379
+ if (!res.ok) return { denied: true, rows: [] };
380
+ const page = (res.data && res.data.rows) || [];
381
+ total = Number(res.data && res.data.page && res.data.page.total) || 0;
382
+ rows.push(...page);
383
+ if (!page.length) break;
384
+ }
385
+ } catch (err) {
386
+ console.error('[profile] credits ledger failed', err);
387
+ return { denied: true, rows: [] };
388
+ }
389
+ return { denied: false, rows };
390
+ }
391
+
338
392
  async function fetchOwnRate(api, API, builderId, days) {
339
393
  try {
340
394
  const q = days ? `?days=${encodeURIComponent(days)}` : '';
@@ -360,7 +414,7 @@
360
414
 
361
415
  // Draw every figure for one lookback window into #charts-grid. Split from
362
416
  // render() so the range buttons can re-draw without re-fetching the record.
363
- async function draw({ api, API, builderId, isOwn, rows, now, rangeId }) {
417
+ async function draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId }) {
364
418
  const grid = $('#charts-grid');
365
419
  if (!grid) return;
366
420
  const range = RANGES.find((r) => r.id === rangeId) || RANGES[RANGES.length - 1];
@@ -369,8 +423,6 @@
369
423
  const span = range.days == null ? 'all time' : `the last ${range.label}`;
370
424
 
371
425
  const worksFull = cumulativeByDay(rows, () => 1);
372
- let estimated = false;
373
- const paidFull = cumulativeByDay(rows, (r) => { const c = creditsOf(r); estimated = estimated || c.estimated; return c.value; });
374
426
  const works = clipToWindow(worksFull, windowStart);
375
427
  const paid = clipToWindow(paidFull, windowStart);
376
428
  const mix = craftMix(inWindow);
@@ -384,7 +436,9 @@
384
436
  timeChart({
385
437
  id: 'chart-drachmae', title: `${curLabel(true)} earned`, points: paid, now, windowStart,
386
438
  valueText: (v) => `${fmtNum(v)} ${curLabel()}`,
387
- footer: `from shipped work${estimated ? ' · some still estimated' : ''}`,
439
+ footer: estimated
440
+ ? `estimated from shipped work, not the ledger · ${span}`
441
+ : `${fmtNum(paidFull.length ? paidFull[paidFull.length - 1].value : 0)} paid, from the credits ledger · ${span}`,
388
442
  }),
389
443
  mixChart({
390
444
  id: 'chart-mix', title: 'Works by craft', mix,
@@ -436,6 +490,15 @@
436
490
  return;
437
491
  }
438
492
 
493
+ // The drachmae series: the ledger wherever the viewer may read it, the
494
+ // estimate (labelled) where the server refuses. Fetched once, like the
495
+ // record; the lookback only clips it.
496
+ const ledger = await fetchLedger(api, API, builderId, isOwn);
497
+ const estimated = ledger.denied;
498
+ const paidFull = estimated
499
+ ? cumulativeByDay(rows, estimateOf)
500
+ : cumulativeByDay(ledger.rows, (r) => r && r.delta, (r) => r && r.recorded_at);
501
+
439
502
  // The lookback control. Its choice is remembered per viewer; changing it
440
503
  // re-draws from the rows already in hand — the record is not re-fetched.
441
504
  let rangeId = readRange();
@@ -451,11 +514,11 @@
451
514
  b.classList.toggle('is-active', on);
452
515
  b.setAttribute('aria-pressed', String(on));
453
516
  });
454
- draw({ api, API, builderId, isOwn, rows, now, rangeId });
517
+ draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId });
455
518
  });
456
519
  }
457
- await draw({ api, API, builderId, isOwn, rows, now, rangeId });
520
+ await draw({ api, API, builderId, isOwn, rows, paidFull, estimated, now, rangeId });
458
521
  }
459
522
 
460
- window.OTBProfileCharts = { render, cumulativeByDay, craftMix, weeklyBuckets, creditsOf, clipToWindow };
523
+ window.OTBProfileCharts = { render, cumulativeByDay, craftMix, weeklyBuckets, estimateOf, clipToWindow };
461
524
  })();
@@ -653,8 +653,9 @@
653
653
  showMessage(`${name} — ${rankLabel(normalizeRank(builder.rank))} · ${fmtNum(shipped)} work${shipped === 1 ? '' : 's'} shipped`);
654
654
  renderHead(builder);
655
655
  renderNumbers(data.shipped_count, data.achievements);
656
- // The charts (task 1003489) read the activity route, so they need the same
657
- // session the ledgers do; on the viewer's own profile they add the pace.
656
+ // The charts (task 1003489) read the activity route and the credits ledger
657
+ // (task 1003491), so they need the same session the ledgers do; on the
658
+ // viewer's own profile they add the pace.
658
659
  if (window.OTBProfileCharts) window.OTBProfileCharts.render({ api, API, builderId: builder.id, selfId, signedIn: selfId != null });
659
660
  renderCrossProject(data, selfId);
660
661
  renderAchievements(data.achievements);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.489",
3
+ "version": "1.19.491",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.489",
9
+ "version": "1.19.491",
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.489",
3
+ "version": "1.19.491",
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",
@@ -17,8 +17,14 @@ const FIXTURES = path.join(__dirname, 'fixtures');
17
17
 
18
18
  // [api path (no /api prefix, no query), fixture name, optional trim fn]
19
19
  const trimList = (key, n) => (d) => (Array.isArray(d?.[key]) ? { ...d, [key]: d[key].slice(0, n) } : d);
20
+ // A paged ledger is one page of a longer record, and the harness ignores query
21
+ // strings — so a fixture whose page.total exceeds its rows would send the
22
+ // profile charts' pager back for the same page until "total". Pin total to
23
+ // what the snapshot holds.
24
+ const selfPaged = (d) => (Array.isArray(d?.rows) && d.page ? { ...d, page: { ...d.page, total: d.rows.length } } : d);
20
25
  const SNAPSHOTS = [
21
26
  ['me', 'me'],
27
+ ['me/credits?limit=200', 'me__credits', selfPaged],
22
28
  ['me/recent-ships?limit=30', 'me__recent-ships'],
23
29
  ['achievements', 'achievements'],
24
30
  ['versions', 'versions'],
@@ -111,6 +111,9 @@ const FIXTURE_FALLBACKS = [
111
111
  [/^analytics\/builder\/\d+$/, 'analytics__builder'],
112
112
  [/^builders\/\d+\/active-claims$/, 'builders__active-claims'],
113
113
  [/^builders\/\d+\/profile$/, 'builders__profile'],
114
+ // the profile's drachmae chart reads the credits ledger (task 1003491): one
115
+ // ledger fixture serves your own (/me/credits, exact match) and any other id.
116
+ [/^builders\/\d+\/credits$/, 'me__credits'],
114
117
  // the profile's per-craft activity log (task 1003281). One fixture per SECTION
115
118
  // rather than per id, because the section is what changes the row shape; the
116
119
  // craft and the page window are query string, which fixture matching ignores.
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.489'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.491'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -17,10 +17,16 @@
17
17
  // public latest-works strip and the cross-project record; the craft
18
18
  // ledgers are one key over, so the first screen is a screen.
19
19
  // * THE CHARTS ARE ARITHMETIC FIRST. cumulativeByDay folds two ships on one
20
- // day into one point and accumulates; craftMix folds the tail past seven
21
- // into "other" rather than minting an eighth hue; weeklyBuckets is a time
22
- // axis (every week present, empty or not); creditsOf falls back to the
23
- // estimate and SAYS so.
20
+ // day into one point and accumulates, over whichever timestamp the caller
21
+ // names; craftMix folds the tail past seven into "other" rather than
22
+ // minting an eighth hue; weeklyBuckets is a time axis (every week present,
23
+ // empty or not).
24
+ // * DRACHMAE COME FROM THE LEDGER (task 1003491). The earned line is the
25
+ // credits ledger — /me/credits on your own profile, /builders/:id/credits
26
+ // on another's — `delta` cumulated by `recorded_at`, so it ends at the
27
+ // balance. Per-task credits_awarded is 0 on a cost-plus instance and is no
28
+ // longer read at all (the owner's chart had flatlined since July on it).
29
+ // A viewer the ledger refuses gets the credits_reward estimate, labelled.
24
30
  // * NO NUMBER IS PICTURE-ONLY. Every time chart carries a table twin with a
25
31
  // row per plotted day; the readout is written with textContent.
26
32
  // * OWN-ONLY MEANS OWN-ONLY. /analytics/builder/:id is asked for only when
@@ -163,14 +169,29 @@ test('cumulativeByDay folds a day\'s ships into one point and accumulates, skipp
163
169
  { day: day('2026-08-10'), value: 3 },
164
170
  ]);
165
171
  assert.deepEqual(plain(C.cumulativeByDay([], () => 1)), []);
172
+ // the ledger's rows are stamped recorded_at, not shipped_at — the caller names the timestamp
173
+ const ledger = [
174
+ { delta: 12, recorded_at: '2026-08-02T22:17:01Z' },
175
+ { delta: 39, recorded_at: '2026-08-01T21:52:51Z' },
176
+ { delta: 19, recorded_at: '2026-08-01T22:05:00Z' },
177
+ ];
178
+ assert.deepEqual(plain(C.cumulativeByDay(ledger, (r) => r.delta, (r) => r.recorded_at)), [
179
+ { day: day('2026-08-01'), value: 58 },
180
+ { day: day('2026-08-02'), value: 70 },
181
+ ], 'newest-first ledger rows still accumulate oldest-first, by their own timestamp');
166
182
  });
167
183
 
168
- test('creditsOf pays the ledger, and falls back to the estimate while saying so', () => {
184
+ test('estimateOf is the credits_reward figure and nothing else — the per-task award column is not read anywhere', () => {
169
185
  const C = bootCharts();
170
- assert.deepEqual(plain(C.creditsOf({ credits_awarded: 58, credits_reward: 60 })), { value: 58, estimated: false });
171
- assert.deepEqual(plain(C.creditsOf({ credits_awarded: 0, credits_reward: 60 })), { value: 0, estimated: false });
172
- assert.deepEqual(plain(C.creditsOf({ credits_awarded: null, credits_reward: 60 })), { value: 60, estimated: true });
173
- assert.deepEqual(plain(C.creditsOf({})), { value: 0, estimated: true });
186
+ assert.equal(C.estimateOf({ credits_awarded: 58, credits_reward: 60 }), 60, 'the estimate ignores what was awarded');
187
+ assert.equal(C.estimateOf({ credits_awarded: 0, credits_reward: 45 }), 45, 'a cost-plus 0 award does not zero the estimate');
188
+ assert.equal(C.estimateOf({}), 0);
189
+ assert.equal(C.estimateOf(null), 0);
190
+ assert.equal(C.creditsOf, undefined, 'the old ledger-or-estimate picker is gone');
191
+ // The whole reason for task 1003491: on a cost-plus instance every task's
192
+ // credits_awarded is 0, so a drachmae series summed from it flatlines the day
193
+ // per-task payout stops. The column must not be read by the charts at all.
194
+ assert.ok(!chartsJs.includes('credits_awarded'), 'profile-charts.js never mentions credits_awarded');
174
195
  });
175
196
 
176
197
  test('craftMix sorts by count and folds the tail past seven into "other" — never an eighth hue', () => {
@@ -216,7 +237,10 @@ test('clipToWindow (the lookback) enters the window at the running height, not a
216
237
 
217
238
  // ---- the charts: what render asks for and draws ---------------------------------
218
239
 
219
- function bootRender({ rows, status = 200, analytics = null }) {
240
+ // `ledger` is what the credits routes answer: an array of rows (paged by the
241
+ // route's 200 in the real shape, { rows, page: { total } }), or 'denied' for the
242
+ // 403 a viewer without builder.roster.read gets on someone else's ledger.
243
+ function bootRender({ rows, status = 200, analytics = null, ledger = [] }) {
220
244
  const requests = [];
221
245
  const mount = { innerHTML: '', querySelector: () => null };
222
246
  const controls = { innerHTML: '', addEventListener() {}, querySelectorAll: () => [] };
@@ -229,6 +253,11 @@ function bootRender({ rows, status = 200, analytics = null }) {
229
253
  const offset = Number(new URL(`http://x${url}`).searchParams.get('offset')) || 0;
230
254
  return { status: 200, ok: true, data: { total: rows.length, rows: rows.slice(offset, offset + 100) } };
231
255
  }
256
+ if (url.includes('/credits')) {
257
+ if (ledger === 'denied') return { status: 403, ok: false, data: null };
258
+ const offset = Number(new URL(`http://x${url}`).searchParams.get('offset')) || 0;
259
+ return { status: 200, ok: true, data: { rows: ledger.slice(offset, offset + 200), page: { limit: 200, offset, total: ledger.length } } };
260
+ }
232
261
  if (url.includes('/analytics/')) return analytics ? { status: 200, ok: true, data: analytics } : { status: 403, ok: false, data: null };
233
262
  return { status: 404, ok: false, data: null };
234
263
  },
@@ -253,12 +282,18 @@ function bootRender({ rows, status = 200, analytics = null }) {
253
282
  }
254
283
 
255
284
  const ship = (d, extra = {}) => ({ shipped_at: `${d}T10:00:00Z`, discipline: 'engineer', credits_awarded: 10, credits_reward: 12, ...extra });
285
+ // a credits-ledger row: what the instance actually paid, when
286
+ const pay = (d, delta, extra = {}) => ({ delta, reason: 'session.token_reward', recorded_at: `${d}T22:00:00Z`, ...extra });
256
287
 
257
288
  test('render draws three public figures with a table twin per time chart, and no own-only one for another builder', async () => {
258
- const b = bootRender({ rows: [ship('2026-08-01'), ship('2026-08-01'), ship('2026-08-20', { discipline: 'ui' })] });
289
+ const b = bootRender({
290
+ rows: [ship('2026-08-01'), ship('2026-08-01'), ship('2026-08-20', { discipline: 'ui' })],
291
+ ledger: [pay('2026-08-01', 20), pay('2026-08-20', 30)],
292
+ });
259
293
  await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
260
294
  const out = b.mount.innerHTML;
261
295
  assert.ok(out.includes('id="chart-works"') && out.includes('id="chart-drachmae"') && out.includes('id="chart-mix"'), 'the three public charts');
296
+ assert.ok(out.includes('<td>2026-08-20</td><td class="ledger__num">50 drachmae</td>'), 'the drachmae twin reads the ledger, not the 30 of per-task awards');
262
297
  assert.ok(!out.includes('id="chart-rate"'), 'no pace chart on someone else\'s profile');
263
298
  assert.ok(!b.requests.some((u) => u.includes('/analytics/')), 'and /analytics was never asked for');
264
299
  assert.equal((out.match(/<details class="pchart__twin">/g) || []).length, 2, 'a table twin under each time chart');
@@ -272,6 +307,8 @@ test('render asks for the pace chart on the viewer\'s own profile only', async (
272
307
  const own = bootRender({ rows: [ship('2026-08-01')], analytics });
273
308
  await own.C.render({ api: own.api, API: '/api/bongos', builderId: 95, selfId: '95', signedIn: true, now: NOW });
274
309
  assert.ok(own.requests.some((u) => u.includes('/analytics/builder/95')), 'own profile: the analytics read');
310
+ assert.ok(own.requests.some((u) => u.includes('/me/credits?')), 'own profile: your own ledger');
311
+ assert.ok(!own.requests.some((u) => u.includes('/builders/95/credits')), 'never through the roster route for yourself');
275
312
  assert.ok(own.mount.innerHTML.includes('id="chart-rate"'), 'and the pace chart draws');
276
313
  assert.ok(own.mount.innerHTML.includes('only you see this one'), 'saying plainly that it is private');
277
314
  });
@@ -325,6 +362,64 @@ test('a 30-day window drops the older ships from the craft mix and clips the axi
325
362
  assert.equal(C.craftMix(within).length, 1, 'only the recent craft survives a 30-day window');
326
363
  });
327
364
 
365
+ test('the drachmae line is the credits ledger: /me/credits on your own profile, cumulated by recorded_at, ending at the balance', async () => {
366
+ const rows = [ship('2026-08-01'), ship('2026-08-20')]; // per-task awards would sum to 20
367
+ const ledger = [pay('2026-09-01', 12), pay('2026-08-20', 39), pay('2026-08-20', 19), pay('2026-07-05', 30)]; // balance 100
368
+ const b = bootRender({ rows, ledger });
369
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 95, signedIn: true, now: NOW });
370
+ assert.ok(b.requests.some((u) => u.includes('/me/credits?limit=200&offset=0')), 'own profile: /me/credits, paged');
371
+ assert.ok(!b.requests.some((u) => u.includes('/builders/95/credits')), 'not the roster route');
372
+ const out = b.mount.innerHTML;
373
+ const fig = out.slice(out.indexOf('id="chart-drachmae"'), out.indexOf('id="chart-mix"'));
374
+ assert.ok(fig.includes('>100 drachmae</text>'), 'the endpoint is the ledger balance, not the 20 of per-task awards');
375
+ assert.ok(fig.includes('<td>2026-07-05</td><td class="ledger__num">30 drachmae</td>'), 'a paid day with no ship is still a point');
376
+ assert.ok(fig.includes('<td>2026-08-20</td><td class="ledger__num">88 drachmae</td>'), 'two payments on one day fold into one point');
377
+ assert.match(fig, /100 paid, from the credits ledger/);
378
+ assert.doesNotMatch(fig, /estimated/);
379
+ });
380
+
381
+ test('another builder\'s ledger is read from /builders/:id/credits, paged by 200 to total', async () => {
382
+ const ledger = Array.from({ length: 450 }, (_, i) => pay(`2026-08-${String(1 + (i % 28)).padStart(2, '0')}`, 1));
383
+ const b = bootRender({ rows: [ship('2026-08-01')], ledger });
384
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
385
+ const pages = b.requests.filter((u) => u.includes('/builders/95/credits'));
386
+ assert.equal(pages.length, 3, 'three pages of 200 for 450 rows');
387
+ assert.ok(!b.requests.some((u) => u.includes('/me/credits')), 'never your own ledger for someone else\'s chart');
388
+ assert.ok(b.mount.innerHTML.includes('>450 drachmae</text>'), 'and every row counted once');
389
+ });
390
+
391
+ test('a viewer the ledger refuses gets the credits_reward estimate, cumulated and labelled estimated', async () => {
392
+ // the cost-plus shape: every award 0, the estimate is all there is to show
393
+ const rows = [ship('2026-08-01', { credits_awarded: 0, credits_reward: 45 }), ship('2026-08-20', { credits_awarded: 0, credits_reward: 55 })];
394
+ const b = bootRender({ rows, ledger: 'denied' });
395
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 7, signedIn: true, now: NOW });
396
+ const out = b.mount.innerHTML;
397
+ const fig = out.slice(out.indexOf('id="chart-drachmae"'), out.indexOf('id="chart-mix"'));
398
+ assert.ok(fig.includes('>100 drachmae</text>'), 'the estimate climbs where the 0 awards would have flatlined');
399
+ assert.match(fig, /estimated from shipped work, not the ledger/);
400
+ assert.ok(!out.includes('id="chart-rate"'), 'the pace chart stays own-only');
401
+ assert.ok(!out.includes('Could not load'), 'a 403 on the ledger is a fallback, not a failure');
402
+ });
403
+
404
+ test('a readable ledger with nothing in it draws one honest line, not a crash', async () => {
405
+ const b = bootRender({ rows: [ship('2026-08-01')], ledger: [] });
406
+ await b.C.render({ api: b.api, API: '/api/bongos', builderId: 95, selfId: 95, signedIn: true, now: NOW });
407
+ const out = b.mount.innerHTML;
408
+ const fig = out.slice(out.indexOf('id="chart-drachmae"'), out.indexOf('id="chart-mix"'));
409
+ assert.match(fig, /nothing recorded yet/);
410
+ assert.ok(out.includes('id="chart-works"') && out.includes('id="chart-mix"'), 'the other charts are untouched');
411
+ });
412
+
413
+ test('the harness carries a credits-ledger fixture the chart can climb on, self-paged, serving both routes', () => {
414
+ const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'me__credits.json'), 'utf8'));
415
+ assert.ok(Array.isArray(fx.rows) && fx.rows.length > 1, 'rows to draw');
416
+ assert.equal(fx.page.total, fx.rows.length, 'total pinned to the rows here, or the pager would loop on the same page');
417
+ assert.ok(fx.rows.every((r) => Number.isFinite(r.delta) && Number.isFinite(Date.parse(r.recorded_at))), 'every row is a delta at a time');
418
+ assert.ok(new Set(fx.rows.map((r) => r.recorded_at.slice(0, 10))).size > 1, 'across more than one day, so the line is not flat');
419
+ const server = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'server.js'), 'utf8');
420
+ assert.ok(server.includes("[/^builders\\/\\d+\\/credits$/, 'me__credits']"), 'the roster route falls back to the same ledger');
421
+ });
422
+
328
423
  test('the readout is written with textContent — the hover layer never builds markup from data', () => {
329
424
  const fn = chartsJs.slice(chartsJs.indexOf('function wireReadout'), chartsJs.indexOf('function twinHtml'));
330
425
  assert.match(fn, /read\.textContent = /);