cursedbelt-core 1.0.0

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.
Files changed (307) hide show
  1. package/LICENSE +15 -0
  2. package/dist/client/wsWire.d.ts +49 -0
  3. package/dist/client/wsWire.js +57 -0
  4. package/dist/config/appConfig.d.ts +361 -0
  5. package/dist/config/appConfig.js +308 -0
  6. package/dist/core/activity/index.d.ts +8 -0
  7. package/dist/core/activity/index.js +7 -0
  8. package/dist/core/activity/model.d.ts +115 -0
  9. package/dist/core/activity/model.js +63 -0
  10. package/dist/core/analytics/index.d.ts +18 -0
  11. package/dist/core/analytics/index.js +18 -0
  12. package/dist/core/analytics/range.d.ts +186 -0
  13. package/dist/core/analytics/range.js +271 -0
  14. package/dist/core/analytics/schedule.d.ts +301 -0
  15. package/dist/core/analytics/schedule.js +372 -0
  16. package/dist/core/category/index.d.ts +3 -0
  17. package/dist/core/category/index.js +6 -0
  18. package/dist/core/category/memoryStore.d.ts +21 -0
  19. package/dist/core/category/memoryStore.js +97 -0
  20. package/dist/core/category/tree.d.ts +26 -0
  21. package/dist/core/category/tree.js +113 -0
  22. package/dist/core/category/types.d.ts +48 -0
  23. package/dist/core/category/types.js +8 -0
  24. package/dist/core/color.d.ts +14 -0
  25. package/dist/core/color.js +32 -0
  26. package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
  27. package/dist/core/colorScheme/colorSchemeStore.js +123 -0
  28. package/dist/core/colorScheme/index.d.ts +7 -0
  29. package/dist/core/colorScheme/index.js +7 -0
  30. package/dist/core/domainOwners.d.ts +19 -0
  31. package/dist/core/domainOwners.js +15 -0
  32. package/dist/core/download.d.ts +14 -0
  33. package/dist/core/download.js +37 -0
  34. package/dist/core/events/ccEventBus.d.ts +44 -0
  35. package/dist/core/events/ccEventBus.js +70 -0
  36. package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
  37. package/dist/core/file-tree/fileTreeModel.js +312 -0
  38. package/dist/core/folder-tree/actions.d.ts +84 -0
  39. package/dist/core/folder-tree/actions.js +119 -0
  40. package/dist/core/folder-tree/index.d.ts +3 -0
  41. package/dist/core/folder-tree/index.js +20 -0
  42. package/dist/core/folder-tree/path.d.ts +76 -0
  43. package/dist/core/folder-tree/path.js +146 -0
  44. package/dist/core/folder-tree/tree.d.ts +86 -0
  45. package/dist/core/folder-tree/tree.js +184 -0
  46. package/dist/core/layout/index.d.ts +1 -0
  47. package/dist/core/layout/index.js +7 -0
  48. package/dist/core/master-lock/index.d.ts +14 -0
  49. package/dist/core/master-lock/index.js +14 -0
  50. package/dist/core/master-lock/kdf.d.ts +63 -0
  51. package/dist/core/master-lock/kdf.js +117 -0
  52. package/dist/core/master-lock/policy.d.ts +32 -0
  53. package/dist/core/master-lock/policy.js +52 -0
  54. package/dist/core/master-lock/presence.d.ts +52 -0
  55. package/dist/core/master-lock/presence.js +61 -0
  56. package/dist/core/master-lock/wire.d.ts +139 -0
  57. package/dist/core/master-lock/wire.js +81 -0
  58. package/dist/core/media/bgRemoval.d.ts +27 -0
  59. package/dist/core/media/bgRemoval.js +101 -0
  60. package/dist/core/media/ffmpeg.d.ts +57 -0
  61. package/dist/core/media/ffmpeg.js +126 -0
  62. package/dist/core/media/format.d.ts +20 -0
  63. package/dist/core/media/format.js +54 -0
  64. package/dist/core/media/index.d.ts +12 -0
  65. package/dist/core/media/index.js +18 -0
  66. package/dist/core/media/mediaItem.d.ts +15 -0
  67. package/dist/core/media/mediaItem.js +34 -0
  68. package/dist/core/media/renderPlan.d.ts +63 -0
  69. package/dist/core/media/renderPlan.js +232 -0
  70. package/dist/core/media/segment.d.ts +79 -0
  71. package/dist/core/media/segment.js +247 -0
  72. package/dist/core/media/size.d.ts +1 -0
  73. package/dist/core/media/size.js +3 -0
  74. package/dist/core/media/trim.d.ts +21 -0
  75. package/dist/core/media/trim.js +40 -0
  76. package/dist/core/media/types.d.ts +148 -0
  77. package/dist/core/media/types.js +11 -0
  78. package/dist/core/media/uploadLimits.d.ts +7 -0
  79. package/dist/core/media/uploadLimits.js +16 -0
  80. package/dist/core/media/validate.d.ts +9 -0
  81. package/dist/core/media/validate.js +121 -0
  82. package/dist/core/media/videoCuts.d.ts +152 -0
  83. package/dist/core/media/videoCuts.js +284 -0
  84. package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
  85. package/dist/core/navigation/breadcrumbCompat.js +21 -0
  86. package/dist/core/navigation/coverageTest.d.ts +24 -0
  87. package/dist/core/navigation/coverageTest.js +48 -0
  88. package/dist/core/navigation/index.d.ts +6 -0
  89. package/dist/core/navigation/index.js +10 -0
  90. package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
  91. package/dist/core/navigation/migrateNavPreferences.js +63 -0
  92. package/dist/core/navigation/navigationModel.d.ts +165 -0
  93. package/dist/core/navigation/navigationModel.js +14 -0
  94. package/dist/core/navigation/navigationSelectors.d.ts +63 -0
  95. package/dist/core/navigation/navigationSelectors.js +167 -0
  96. package/dist/core/navigation/searchCatalogue.d.ts +23 -0
  97. package/dist/core/navigation/searchCatalogue.js +76 -0
  98. package/dist/core/net/reconnectSchedule.d.ts +46 -0
  99. package/dist/core/net/reconnectSchedule.js +38 -0
  100. package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
  101. package/dist/core/palette/ShortcutRegistry.js +52 -0
  102. package/dist/core/palette/fuzzyRank.d.ts +18 -0
  103. package/dist/core/palette/fuzzyRank.js +73 -0
  104. package/dist/core/palette/hotkeys.d.ts +28 -0
  105. package/dist/core/palette/hotkeys.js +118 -0
  106. package/dist/core/palette/index.d.ts +3 -0
  107. package/dist/core/palette/index.js +8 -0
  108. package/dist/core/schema/projection.d.ts +84 -0
  109. package/dist/core/schema/projection.js +184 -0
  110. package/dist/core/session/claims.d.ts +145 -0
  111. package/dist/core/session/claims.js +157 -0
  112. package/dist/core/sharing/index.d.ts +5 -0
  113. package/dist/core/sharing/index.js +4 -0
  114. package/dist/core/sharing/model.d.ts +195 -0
  115. package/dist/core/sharing/model.js +150 -0
  116. package/dist/core/site/siteCopy.d.ts +92 -0
  117. package/dist/core/site/siteCopy.js +91 -0
  118. package/dist/core/slots.d.ts +22 -0
  119. package/dist/core/slots.js +31 -0
  120. package/dist/core/styles/constructableSheet.d.ts +51 -0
  121. package/dist/core/styles/constructableSheet.js +80 -0
  122. package/dist/core/tokens/registry.d.ts +119 -0
  123. package/dist/core/tokens/registry.js +447 -0
  124. package/dist/core/uiScale/index.d.ts +8 -0
  125. package/dist/core/uiScale/index.js +8 -0
  126. package/dist/core/uiScale/scale.d.ts +36 -0
  127. package/dist/core/uiScale/scale.js +47 -0
  128. package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
  129. package/dist/core/uiScale/uiScaleStore.js +63 -0
  130. package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
  131. package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
  132. package/dist/core/wire/index.d.ts +17 -0
  133. package/dist/core/wire/index.js +17 -0
  134. package/dist/core/wire/ingress.d.ts +44 -0
  135. package/dist/core/wire/ingress.js +134 -0
  136. package/dist/core/wire/marshalWorker.d.ts +70 -0
  137. package/dist/core/wire/marshalWorker.js +273 -0
  138. package/dist/core/wire/marshalling.d.ts +122 -0
  139. package/dist/core/wire/marshalling.js +126 -0
  140. package/dist/core/wire/wireHub.d.ts +80 -0
  141. package/dist/core/wire/wireHub.js +116 -0
  142. package/dist/ctgr/base64url.d.ts +8 -0
  143. package/dist/ctgr/base64url.js +39 -0
  144. package/dist/ctgr/chunkAccumulator.d.ts +214 -0
  145. package/dist/ctgr/chunkAccumulator.js +307 -0
  146. package/dist/ctgr/chunkStore.d.ts +24 -0
  147. package/dist/ctgr/chunkStore.js +125 -0
  148. package/dist/ctgr/codec.d.ts +44 -0
  149. package/dist/ctgr/codec.js +153 -0
  150. package/dist/ctgr/compress.d.ts +2 -0
  151. package/dist/ctgr/compress.js +20 -0
  152. package/dist/ctgr/crc32.d.ts +2 -0
  153. package/dist/ctgr/crc32.js +23 -0
  154. package/dist/ctgr/digest.d.ts +1 -0
  155. package/dist/ctgr/digest.js +15 -0
  156. package/dist/ctgr/index.d.ts +9 -0
  157. package/dist/ctgr/index.js +17 -0
  158. package/dist/ctgr/types.d.ts +64 -0
  159. package/dist/ctgr/types.js +21 -0
  160. package/dist/ctgr/v0compat.d.ts +27 -0
  161. package/dist/ctgr/v0compat.js +128 -0
  162. package/dist/index.d.ts +15 -0
  163. package/dist/index.js +17 -0
  164. package/dist/shared/errors.d.ts +101 -0
  165. package/dist/shared/errors.js +99 -0
  166. package/dist/testing/bytes.d.ts +32 -0
  167. package/dist/testing/bytes.js +33 -0
  168. package/dist/testing/domInspect.d.ts +59 -0
  169. package/dist/testing/domInspect.js +115 -0
  170. package/dist/testing/fakeFetch.d.ts +23 -0
  171. package/dist/testing/fakeFetch.js +21 -0
  172. package/dist/testing/index.d.ts +3 -0
  173. package/dist/testing/index.js +11 -0
  174. package/package.json +373 -0
  175. package/src/client/wsWire.ts +83 -0
  176. package/src/config/appConfig.spec.ts +158 -0
  177. package/src/config/appConfig.ts +372 -0
  178. package/src/core/activity/index.ts +21 -0
  179. package/src/core/activity/model.ts +160 -0
  180. package/src/core/analytics/index.ts +18 -0
  181. package/src/core/analytics/range.spec.ts +280 -0
  182. package/src/core/analytics/range.ts +383 -0
  183. package/src/core/analytics/schedule.spec.ts +311 -0
  184. package/src/core/analytics/schedule.ts +624 -0
  185. package/src/core/category/category.spec.ts +144 -0
  186. package/src/core/category/index.ts +19 -0
  187. package/src/core/category/memoryStore.ts +104 -0
  188. package/src/core/category/tree.ts +118 -0
  189. package/src/core/category/types.ts +59 -0
  190. package/src/core/color.spec.ts +35 -0
  191. package/src/core/color.ts +35 -0
  192. package/src/core/colorScheme/colorScheme.spec.ts +127 -0
  193. package/src/core/colorScheme/colorSchemeStore.ts +184 -0
  194. package/src/core/colorScheme/index.ts +23 -0
  195. package/src/core/domainOwners.ts +32 -0
  196. package/src/core/download.spec.ts +38 -0
  197. package/src/core/download.ts +35 -0
  198. package/src/core/events/ccEventBus.spec.ts +90 -0
  199. package/src/core/events/ccEventBus.ts +120 -0
  200. package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
  201. package/src/core/file-tree/fileTreeModel.ts +511 -0
  202. package/src/core/folder-tree/actions.spec.ts +103 -0
  203. package/src/core/folder-tree/actions.ts +153 -0
  204. package/src/core/folder-tree/index.ts +46 -0
  205. package/src/core/folder-tree/path.spec.ts +113 -0
  206. package/src/core/folder-tree/path.ts +148 -0
  207. package/src/core/folder-tree/tree.spec.ts +157 -0
  208. package/src/core/folder-tree/tree.ts +244 -0
  209. package/src/core/layout/index.ts +7 -0
  210. package/src/core/master-lock/index.ts +50 -0
  211. package/src/core/master-lock/kdf.spec.ts +95 -0
  212. package/src/core/master-lock/kdf.ts +145 -0
  213. package/src/core/master-lock/policy.ts +55 -0
  214. package/src/core/master-lock/presence.spec.ts +73 -0
  215. package/src/core/master-lock/presence.ts +71 -0
  216. package/src/core/master-lock/wire.ts +155 -0
  217. package/src/core/media/bgRemoval.spec.ts +120 -0
  218. package/src/core/media/bgRemoval.ts +123 -0
  219. package/src/core/media/ffmpeg.spec.ts +135 -0
  220. package/src/core/media/ffmpeg.ts +162 -0
  221. package/src/core/media/format.spec.ts +41 -0
  222. package/src/core/media/format.ts +58 -0
  223. package/src/core/media/index.ts +105 -0
  224. package/src/core/media/mediaItem.spec.ts +46 -0
  225. package/src/core/media/mediaItem.ts +40 -0
  226. package/src/core/media/renderPlan.spec.ts +140 -0
  227. package/src/core/media/renderPlan.ts +318 -0
  228. package/src/core/media/segment.spec.ts +159 -0
  229. package/src/core/media/segment.ts +352 -0
  230. package/src/core/media/size.spec.ts +19 -0
  231. package/src/core/media/size.ts +3 -0
  232. package/src/core/media/trim.spec.ts +75 -0
  233. package/src/core/media/trim.ts +60 -0
  234. package/src/core/media/types.ts +147 -0
  235. package/src/core/media/uploadLimits.ts +18 -0
  236. package/src/core/media/validate.spec.ts +106 -0
  237. package/src/core/media/validate.ts +131 -0
  238. package/src/core/media/videoCuts.spec.ts +327 -0
  239. package/src/core/media/videoCuts.ts +355 -0
  240. package/src/core/navigation/breadcrumbCompat.ts +44 -0
  241. package/src/core/navigation/coverageTest.ts +54 -0
  242. package/src/core/navigation/index.ts +50 -0
  243. package/src/core/navigation/migrateNavPreferences.ts +119 -0
  244. package/src/core/navigation/navigation.spec.ts +388 -0
  245. package/src/core/navigation/navigationModel.ts +185 -0
  246. package/src/core/navigation/navigationSelectors.ts +206 -0
  247. package/src/core/navigation/searchCatalogue.ts +89 -0
  248. package/src/core/net/reconnectSchedule.spec.ts +49 -0
  249. package/src/core/net/reconnectSchedule.ts +78 -0
  250. package/src/core/palette/ShortcutRegistry.ts +69 -0
  251. package/src/core/palette/fuzzyRank.ts +78 -0
  252. package/src/core/palette/hotkeys.ts +132 -0
  253. package/src/core/palette/index.ts +22 -0
  254. package/src/core/palette/palette.spec.ts +160 -0
  255. package/src/core/schema/projection.spec.ts +122 -0
  256. package/src/core/schema/projection.ts +225 -0
  257. package/src/core/session/claims.ts +181 -0
  258. package/src/core/sharing/index.ts +33 -0
  259. package/src/core/sharing/model.spec.ts +104 -0
  260. package/src/core/sharing/model.ts +296 -0
  261. package/src/core/site/siteCopy.ts +115 -0
  262. package/src/core/slots.ts +38 -0
  263. package/src/core/styles/constructableSheet.ts +86 -0
  264. package/src/core/tokens/registry.spec.ts +160 -0
  265. package/src/core/tokens/registry.ts +536 -0
  266. package/src/core/uiScale/index.ts +32 -0
  267. package/src/core/uiScale/scale.ts +63 -0
  268. package/src/core/uiScale/uiScale.spec.ts +77 -0
  269. package/src/core/uiScale/uiScaleStore.ts +100 -0
  270. package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
  271. package/src/core/wire/index.ts +56 -0
  272. package/src/core/wire/ingress.spec.ts +276 -0
  273. package/src/core/wire/ingress.ts +182 -0
  274. package/src/core/wire/marshalWorker.spec.ts +196 -0
  275. package/src/core/wire/marshalWorker.ts +361 -0
  276. package/src/core/wire/marshalling.spec.ts +139 -0
  277. package/src/core/wire/marshalling.ts +238 -0
  278. package/src/core/wire/wireHub.spec.ts +137 -0
  279. package/src/core/wire/wireHub.ts +213 -0
  280. package/src/ctgr/base64url.ts +44 -0
  281. package/src/ctgr/chunkAccumulator.ts +403 -0
  282. package/src/ctgr/chunkStore.spec.ts +101 -0
  283. package/src/ctgr/chunkStore.ts +168 -0
  284. package/src/ctgr/codec.spec.ts +208 -0
  285. package/src/ctgr/codec.ts +202 -0
  286. package/src/ctgr/compress.ts +21 -0
  287. package/src/ctgr/crc32.ts +23 -0
  288. package/src/ctgr/digest.ts +14 -0
  289. package/src/ctgr/index.ts +39 -0
  290. package/src/ctgr/types.ts +80 -0
  291. package/src/ctgr/v0compat.spec.ts +73 -0
  292. package/src/ctgr/v0compat.ts +156 -0
  293. package/src/declaredDepsAreImported.spec.ts +87 -0
  294. package/src/index.ts +25 -0
  295. package/src/leafSubpathsImportNothing.spec.ts +287 -0
  296. package/src/lockfileIsTracked.spec.ts +50 -0
  297. package/src/namedSubpathsResolve.spec.ts +122 -0
  298. package/src/noPathDeps.spec.ts +24 -0
  299. package/src/publishShape.spec.ts +89 -0
  300. package/src/shared/errors.ts +153 -0
  301. package/src/skippedTestsAreAnswered.spec.ts +221 -0
  302. package/src/testTmpRoot.spec.ts +102 -0
  303. package/src/testing/bytes.ts +38 -0
  304. package/src/testing/domInspect.spec.ts +79 -0
  305. package/src/testing/domInspect.ts +127 -0
  306. package/src/testing/fakeFetch.ts +26 -0
  307. package/src/testing/index.ts +11 -0
@@ -0,0 +1,280 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ addDays,
4
+ bucketize,
5
+ DAY_MS,
6
+ dayKey,
7
+ durationExcluding,
8
+ HOUR_MS,
9
+ median,
10
+ overlapMs,
11
+ percentile,
12
+ RANGE_PRESETS,
13
+ rangeLabel,
14
+ rangePreset,
15
+ resolveRange,
16
+ startOfDay,
17
+ startOfHour,
18
+ unitForSpan,
19
+ } from './range';
20
+
21
+ /** 2026-08-13 14:30 LOCAL — the tests are about local calendar behaviour. */
22
+ const NOW = new Date(2026, 7, 13, 14, 30, 0).getTime();
23
+
24
+ describe('the presets', () => {
25
+ test('every preset is reachable by key, and an unknown key does not throw', () => {
26
+ for (const p of RANGE_PRESETS) expect(rangePreset(p.key).key).toBe(p.key);
27
+ expect(rangePreset('nonsense' as never).key).toBe('week');
28
+ });
29
+
30
+ test('only the one-day range buckets by hour — 90 hourly buckets is unreadable', () => {
31
+ const hourly = RANGE_PRESETS.filter((p) => p.unit === 'hour');
32
+ expect(hourly.map((p) => p.key)).toEqual(['today']);
33
+ });
34
+
35
+ test('the owner\'s whole vocabulary is offered — including a year and a custom range', () => {
36
+ expect(RANGE_PRESETS.map((p) => p.key)).toEqual([
37
+ 'today',
38
+ 'week',
39
+ 'month',
40
+ 'quarter',
41
+ 'year',
42
+ 'all',
43
+ 'custom',
44
+ ]);
45
+ });
46
+ });
47
+
48
+ describe('resolveRange — the custom range', () => {
49
+ const from = new Date(2026, 7, 1).getTime();
50
+ const to = new Date(2026, 7, 4).getTime(); // exclusive: through the end of Aug 3
51
+
52
+ test('takes the caller\'s bounds rather than deriving them from now', () => {
53
+ const r = resolveRange('custom', NOW, { custom: { from, to } });
54
+ expect(r.key).toBe('custom');
55
+ expect(r.from).toBe(from);
56
+ expect(r.to).toBe(to);
57
+ expect(r.buckets.map(dayKey)).toEqual(['2026-08-01', '2026-08-02', '2026-08-03', '2026-08-04']);
58
+ });
59
+
60
+ test('picks its bucket unit from the SPAN — hourly for a short window, daily beyond two days', () => {
61
+ const oneDay = resolveRange('custom', NOW, {
62
+ custom: { from, to: from + DAY_MS },
63
+ });
64
+ expect(oneDay.unit).toBe('hour');
65
+ expect(resolveRange('custom', NOW, { custom: { from, to } }).unit).toBe('day');
66
+ expect(unitForSpan(DAY_MS)).toBe('hour');
67
+ expect(unitForSpan(5 * DAY_MS)).toBe('day');
68
+ });
69
+
70
+ test('carries a comparable previous window of the SAME width', () => {
71
+ const r = resolveRange('custom', NOW, { custom: { from, to } });
72
+ expect(r.previous).not.toBeNull();
73
+ expect((r.previous as { to: number }).to).toBe(r.from);
74
+ expect((r.previous as { from: number; to: number }).to - (r.previous as { from: number }).from).toBe(
75
+ r.to - r.from,
76
+ );
77
+ });
78
+
79
+ // The failure path: a switcher can sit on Custom before two dates are picked.
80
+ // Throwing (or returning an empty axis) would make an ordinary interaction
81
+ // look like a broken dashboard.
82
+ test('degrades to a real window rather than throwing when the bounds are missing or backwards', () => {
83
+ for (const bad of [undefined, null, { from: to, to: from }, { from: Number.NaN, to }]) {
84
+ const r = resolveRange('custom', NOW, { custom: bad as never });
85
+ expect(r.key).toBe('month');
86
+ expect(r.buckets.length).toBeGreaterThan(0);
87
+ }
88
+ });
89
+
90
+ test('the legacy positional `earliest` argument still works', () => {
91
+ const earliest = NOW - 3 * DAY_MS;
92
+ expect(resolveRange('all', NOW, earliest).from).toBe(startOfDay(earliest));
93
+ expect(resolveRange('all', NOW, { earliest }).from).toBe(startOfDay(earliest));
94
+ });
95
+ });
96
+
97
+ describe('rangeLabel — a panel must be able to STATE its window', () => {
98
+ test('names the window in the reader\'s own calendar terms', () => {
99
+ expect(rangeLabel(resolveRange('today', NOW))).toBe('Today, since midnight');
100
+ expect(rangeLabel(resolveRange('week', NOW))).toBe('2026-08-07 → 2026-08-13');
101
+ expect(rangeLabel(resolveRange('all', NOW, NOW - 2 * DAY_MS))).toBe('All time — since 2026-08-11');
102
+ expect(
103
+ rangeLabel(
104
+ resolveRange('custom', NOW, {
105
+ custom: { from: new Date(2026, 7, 1).getTime(), to: new Date(2026, 7, 4).getTime() },
106
+ }),
107
+ ),
108
+ ).toBe('2026-08-01 → 2026-08-04');
109
+ });
110
+ });
111
+
112
+ describe('resolveRange — "today" means since midnight, not the last 24 hours', () => {
113
+ test('today starts at local midnight, not now-24h', () => {
114
+ const r = resolveRange('today', NOW);
115
+ expect(r.from).toBe(startOfDay(NOW));
116
+ expect(r.from).not.toBe(NOW - DAY_MS);
117
+ });
118
+
119
+ test('today has one bucket per elapsed hour, inclusive of the current partial hour', () => {
120
+ const r = resolveRange('today', NOW);
121
+ expect(r.unit).toBe('hour');
122
+ expect(r.buckets).toHaveLength(15); // 00:00 … 14:00
123
+ expect(r.buckets[0]).toBe(startOfDay(NOW));
124
+ expect(r.buckets.at(-1)).toBe(startOfHour(NOW));
125
+ });
126
+
127
+ test('a 7-day range covers 7 calendar days INCLUDING today', () => {
128
+ const r = resolveRange('week', NOW);
129
+ expect(r.buckets).toHaveLength(7);
130
+ expect(r.buckets.at(-1)).toBe(startOfDay(NOW));
131
+ expect(r.buckets[0]).toBe(addDays(startOfDay(NOW), -6));
132
+ });
133
+
134
+ test('the baseline window is the SAME LENGTH and sits immediately before', () => {
135
+ const r = resolveRange('week', NOW);
136
+ expect(r.previous).not.toBeNull();
137
+ expect(r.previous?.to).toBe(r.from);
138
+ expect((r.previous as { from: number }).from).toBe(r.from - (r.to - r.from));
139
+ });
140
+
141
+ test('"all" has NO baseline — nothing precedes everything, and zero would be a lie', () => {
142
+ expect(resolveRange('all', NOW, NOW - 3 * DAY_MS).previous).toBeNull();
143
+ });
144
+
145
+ test('"all" starts at the earliest datum', () => {
146
+ const earliest = new Date(2026, 6, 26, 9, 15).getTime();
147
+ expect(resolveRange('all', NOW, earliest).from).toBe(startOfDay(earliest));
148
+ });
149
+
150
+ test('"all" with no earliest datum falls back to 90 days, never to the epoch', () => {
151
+ const r = resolveRange('all', NOW);
152
+ expect(r.buckets.length).toBeLessThanOrEqual(91);
153
+ expect(r.from).toBeGreaterThan(NOW - 100 * DAY_MS);
154
+ });
155
+
156
+ test('a clock skewed into the future still yields an axis rather than an empty chart', () => {
157
+ const r = resolveRange('all', NOW, NOW + 5 * DAY_MS);
158
+ expect(r.buckets.length).toBeGreaterThan(0);
159
+ });
160
+ });
161
+
162
+ describe('addDays — DST, the bug nobody reproduces', () => {
163
+ test('crossing the US spring-forward boundary lands on the right calendar day', () => {
164
+ // 2026-03-08 is the US DST start. t + 86400000 lands at 01:00 on the 9th in
165
+ // a -06:00→-05:00 zone; addDays must still say "the 9th, same wall clock".
166
+ const before = new Date(2026, 2, 7, 12, 0).getTime();
167
+ const after = new Date(addDays(before, 2));
168
+ expect(after.getDate()).toBe(9);
169
+ expect(after.getHours()).toBe(12);
170
+ });
171
+ });
172
+
173
+ describe('bucketize', () => {
174
+ const range = resolveRange('week', NOW);
175
+ const at = (x: { t: number }) => x.t;
176
+
177
+ test('counts land in the right day and the output is axis-aligned', () => {
178
+ const today = { t: NOW - HOUR_MS };
179
+ const twoDaysAgo = { t: addDays(NOW, -2) };
180
+ const out = bucketize([today, today, twoDaysAgo], range, at);
181
+ expect(out).toHaveLength(range.buckets.length);
182
+ expect(out.at(-1)).toBe(2);
183
+ expect(out.at(-3)).toBe(1);
184
+ });
185
+
186
+ test('items outside the range are dropped, not clamped onto the edge buckets', () => {
187
+ const out = bucketize([{ t: addDays(NOW, -40) }, { t: NOW + DAY_MS }], range, at);
188
+ expect(out.reduce((a, b) => a + b, 0)).toBe(0);
189
+ });
190
+
191
+ test('a weight function sums magnitudes instead of counting', () => {
192
+ const out = bucketize([{ t: NOW }, { t: NOW }], range, at, () => 2.5);
193
+ expect(out.at(-1)).toBe(5);
194
+ });
195
+
196
+ test('a NaN timestamp is dropped rather than poisoning a bucket', () => {
197
+ const out = bucketize([{ t: Number.NaN }], range, at);
198
+ expect(out.every((v) => v === 0)).toBe(true);
199
+ });
200
+ });
201
+
202
+ describe('overlapMs / durationExcluding — the pause-adjusted duration', () => {
203
+ test('non-overlapping intervals contribute nothing', () => {
204
+ expect(overlapMs(0, 10, 20, 30)).toBe(0);
205
+ });
206
+
207
+ test('partial overlap is the intersection', () => {
208
+ expect(overlapMs(0, 20, 10, 30)).toBe(10);
209
+ });
210
+
211
+ test('a task that ran through a pause is charged only for the moving time', () => {
212
+ expect(durationExcluding(0, 100, [{ from: 20, to: 50 }])).toBe(70);
213
+ });
214
+
215
+ test('OVERLAPPING pause windows are not subtracted twice', () => {
216
+ // An engine pause nested inside a usage pause is the real case. A naive sum
217
+ // would subtract 60 from 100 and report 40.
218
+ expect(
219
+ durationExcluding(0, 100, [
220
+ { from: 10, to: 50 },
221
+ { from: 30, to: 40 },
222
+ ]),
223
+ ).toBe(60);
224
+ });
225
+
226
+ test('unsorted windows give the same answer as sorted ones', () => {
227
+ const windows = [
228
+ { from: 70, to: 80 },
229
+ { from: 10, to: 20 },
230
+ ];
231
+ expect(durationExcluding(0, 100, windows)).toBe(
232
+ durationExcluding(0, 100, [...windows].reverse()),
233
+ );
234
+ });
235
+
236
+ test('pauses reaching outside the interval are clipped to it', () => {
237
+ expect(durationExcluding(50, 100, [{ from: 0, to: 1000 }])).toBe(0);
238
+ });
239
+
240
+ test('a zero-length interval is zero, not negative', () => {
241
+ expect(durationExcluding(100, 100, [{ from: 0, to: 200 }])).toBe(0);
242
+ expect(durationExcluding(100, 50, [])).toBe(0);
243
+ });
244
+ });
245
+
246
+ describe('median / percentile', () => {
247
+ test('empty is null, NEVER zero — zero is a real measurement', () => {
248
+ expect(median([])).toBeNull();
249
+ expect(percentile([], 0.9)).toBeNull();
250
+ });
251
+
252
+ test('odd and even lengths', () => {
253
+ expect(median([3, 1, 2])).toBe(2);
254
+ expect(median([4, 1, 3, 2])).toBe(2.5);
255
+ });
256
+
257
+ test('p90 by nearest rank, and the extremes are addressable', () => {
258
+ const v = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
259
+ expect(percentile(v, 0.9)).toBe(9);
260
+ expect(percentile(v, 1)).toBe(10);
261
+ expect(percentile(v, 0)).toBe(1);
262
+ });
263
+
264
+ test('the input is not mutated', () => {
265
+ const v = [3, 1, 2];
266
+ median(v);
267
+ expect(v).toEqual([3, 1, 2]);
268
+ });
269
+ });
270
+
271
+ describe('dayKey', () => {
272
+ test('is LOCAL, so an evening does not get filed under tomorrow', () => {
273
+ // 22:00 local on the 12th is the 13th in UTC for America/Chicago.
274
+ expect(dayKey(new Date(2026, 7, 12, 22, 0).getTime())).toBe('2026-08-12');
275
+ });
276
+
277
+ test('zero-pads', () => {
278
+ expect(dayKey(new Date(2026, 0, 5, 12, 0).getTime())).toBe('2026-01-05');
279
+ });
280
+ });
@@ -0,0 +1,383 @@
1
+ /**
2
+ * The time-range vocabulary every analytics surface shares — the presets, the
3
+ * bucket size each one implies, and the bucketing itself.
4
+ *
5
+ * ── Why this is a shared primitive and not per-dashboard code ────────────────
6
+ * Every dashboard needs "today / this week / this month", and every one that
7
+ * hand-rolls it gets the same three things subtly wrong:
8
+ *
9
+ * 1. **"Today" is not "the last 24 hours."** The owner asking *"has the
10
+ * orchestrator done well today?"* means since midnight in THEIR timezone,
11
+ * against comparable earlier days. A rolling 24h window silently mixes last
12
+ * night into this morning and makes every day look average.
13
+ * 2. **The bucket must follow the range.** Hourly buckets over 90 days is 2,160
14
+ * points nobody can read; daily buckets over one day is a single bar. The
15
+ * preset owns both, so a range change cannot leave a mismatched bucket
16
+ * behind.
17
+ * 3. **A comparison needs a baseline of the same shape.** "42 tasks" answers
18
+ * nothing. "42, against a 7-day median of 26" answers the question that was
19
+ * actually asked — and the baseline has to be the same length as the range
20
+ * or the comparison is meaningless.
21
+ *
22
+ * Framework-free and DOM-free on purpose: the ENGINE computes with it and the
23
+ * BROWSER renders with it, and they must bucket identically or a chart's x-axis
24
+ * disagrees with its own tooltip.
25
+ *
26
+ * ── Local days, not UTC days ────────────────────────────────────────────────
27
+ * Day boundaries come from the host's own calendar (`new Date(y, m, d)`), so a
28
+ * day is the day the reader lived through. UTC bucketing would move "today" by
29
+ * 5–6 hours in America/Chicago and put a normal evening's work on tomorrow.
30
+ * 🔴 It follows that the ENGINE must be the one to bucket (it shares the
31
+ * owner's timezone) — a prod box on UTC bucketing for a Chicago reader
32
+ * reintroduces exactly this bug.
33
+ */
34
+
35
+ /**
36
+ * The preset keys. `all` is unbounded-below — it starts at the first datum.
37
+ *
38
+ * `custom` is the one key with no fixed width: it takes explicit bounds from the
39
+ * caller (`resolveRange(key, now, { custom })`) and picks its own bucket unit
40
+ * from how wide those bounds are. It exists because the owner asked for it by
41
+ * name — *"I want to be able to view all types of times (all time, this year,
42
+ * this month, weeks days, custom times, etc)"* (2026-08-13) — and because a
43
+ * dashboard whose narrowest question is "the last 7 days" cannot answer "what
44
+ * happened during that incident on the 14th".
45
+ */
46
+ export type RangeKey = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'all' | 'custom';
47
+
48
+ /** How wide one bucket is. `day` is calendar-aligned; `hour` is clock-aligned. */
49
+ export type BucketUnit = 'hour' | 'day';
50
+
51
+ export interface RangePreset {
52
+ key: RangeKey;
53
+ /** Control label — short, because it sits in a segmented control. */
54
+ label: string;
55
+ /** What the range covers, for a tooltip or a subtitle. */
56
+ description: string;
57
+ unit: BucketUnit;
58
+ /**
59
+ * Whole days back from today, inclusive of today. `null` for `all`.
60
+ * `today` is 1 — itself.
61
+ */
62
+ days: number | null;
63
+ }
64
+
65
+ export const RANGE_PRESETS: readonly RangePreset[] = [
66
+ {
67
+ key: 'today',
68
+ label: 'Today',
69
+ description: 'Since midnight, by hour',
70
+ unit: 'hour',
71
+ days: 1,
72
+ },
73
+ { key: 'week', label: '7 days', description: 'The last 7 days, by day', unit: 'day', days: 7 },
74
+ {
75
+ key: 'month',
76
+ label: '30 days',
77
+ description: 'The last 30 days, by day',
78
+ unit: 'day',
79
+ days: 30,
80
+ },
81
+ {
82
+ key: 'quarter',
83
+ label: '90 days',
84
+ description: 'The last 90 days, by day',
85
+ unit: 'day',
86
+ days: 90,
87
+ },
88
+ {
89
+ key: 'year',
90
+ label: '1 year',
91
+ description: 'The last 365 days, by day',
92
+ unit: 'day',
93
+ days: 365,
94
+ },
95
+ { key: 'all', label: 'All', description: 'Everything on record, by day', unit: 'day', days: null },
96
+ {
97
+ key: 'custom',
98
+ label: 'Custom',
99
+ description: 'A range you pick',
100
+ unit: 'day',
101
+ // Not a width. `custom` is resolved from the caller's own bounds; `days` is
102
+ // null for the same reason `all`'s is — neither has one until it is asked.
103
+ days: null,
104
+ },
105
+ ] as const;
106
+
107
+ /** The presets with a fixed width, in order — the ordinary switcher's options. */
108
+ export const FIXED_RANGE_KEYS: readonly RangeKey[] = RANGE_PRESETS.filter(
109
+ (p) => p.key !== 'custom',
110
+ ).map((p) => p.key);
111
+
112
+ /** Explicit bounds for the `custom` preset. `to` is exclusive, like `ResolvedRange.to`. */
113
+ export interface CustomRangeBounds {
114
+ from: number;
115
+ to: number;
116
+ }
117
+
118
+ export interface ResolveRangeOptions {
119
+ /** The first datum's timestamp — bounds the `all` preset. */
120
+ earliest?: number;
121
+ /** Required by `custom`; ignored by every other key. */
122
+ custom?: CustomRangeBounds | null;
123
+ }
124
+
125
+ /**
126
+ * Pick the bucket unit for an arbitrary span. Hourly up to two days (a bucket
127
+ * per hour over 48h is 48 points — readable); daily past that, because 90 days
128
+ * of hourly buckets is 2,160 points nobody can read, which is the same argument
129
+ * the fixed presets make.
130
+ */
131
+ export const unitForSpan = (spanMs: number): BucketUnit => (spanMs <= 2 * DAY_MS ? 'hour' : 'day');
132
+
133
+ export const rangePreset = (key: RangeKey): RangePreset =>
134
+ RANGE_PRESETS.find((p) => p.key === key) ?? RANGE_PRESETS[1];
135
+
136
+ export const HOUR_MS = 3_600_000;
137
+ export const DAY_MS = 86_400_000;
138
+
139
+ /** Local midnight at the start of `t`'s day. */
140
+ export const startOfDay = (t: number): number => {
141
+ const d = new Date(t);
142
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
143
+ };
144
+
145
+ /** Local start of `t`'s hour. */
146
+ export const startOfHour = (t: number): number => {
147
+ const d = new Date(t);
148
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime();
149
+ };
150
+
151
+ /** Start of the bucket `t` falls in, for either unit. */
152
+ export const startOfBucket = (t: number, unit: BucketUnit): number =>
153
+ unit === 'hour' ? startOfHour(t) : startOfDay(t);
154
+
155
+ /**
156
+ * Add `n` calendar days, keeping the wall clock. `t + n * DAY_MS` drifts by an
157
+ * hour twice a year and lands on the wrong calendar day, which shows up as a
158
+ * duplicated or missing bar exactly once every spring and autumn — the kind of
159
+ * bug nobody reproduces.
160
+ *
161
+ * The time-of-day is carried through rather than normalised to midnight: every
162
+ * caller here passes an already-aligned value, so normalising would be
163
+ * invisible in this file and a trap for the first caller that does not.
164
+ */
165
+ export const addDays = (t: number, n: number): number => {
166
+ const d = new Date(t);
167
+ return new Date(
168
+ d.getFullYear(),
169
+ d.getMonth(),
170
+ d.getDate() + n,
171
+ d.getHours(),
172
+ d.getMinutes(),
173
+ d.getSeconds(),
174
+ d.getMilliseconds(),
175
+ ).getTime();
176
+ };
177
+
178
+ export interface ResolvedRange {
179
+ key: RangeKey;
180
+ /** Inclusive start (epoch ms), bucket-aligned. */
181
+ from: number;
182
+ /** Exclusive end (epoch ms) — `now`, so the newest bucket is partial. */
183
+ to: number;
184
+ unit: BucketUnit;
185
+ /** Every bucket start in the range, oldest → newest. */
186
+ buckets: number[];
187
+ /**
188
+ * The comparable previous window, for a baseline. `null` for `all` (nothing
189
+ * precedes everything) — a caller must then say "no baseline", never zero.
190
+ */
191
+ previous: { from: number; to: number } | null;
192
+ }
193
+
194
+ /**
195
+ * Turn a preset into concrete bounds and bucket starts.
196
+ *
197
+ * The third argument is either the `earliest` timestamp (the original,
198
+ * still-supported form — `all` is bounded by the first datum, and absent one it
199
+ * falls back to 90 days so a caller can never ask for a bucket array reaching
200
+ * back to the epoch) or an options object, which is how `custom` receives its
201
+ * bounds.
202
+ *
203
+ * 🔴 `custom` with no bounds is NOT an error and must not throw — a switcher can
204
+ * legitimately be on Custom before the reader has picked two dates. It degrades
205
+ * to the `month` preset, which is a visible, ordinary window rather than an
206
+ * empty chart the reader has to diagnose.
207
+ */
208
+ export function resolveRange(
209
+ key: RangeKey,
210
+ now: number,
211
+ options?: number | ResolveRangeOptions,
212
+ ): ResolvedRange {
213
+ const opts: ResolveRangeOptions =
214
+ typeof options === 'number' ? { earliest: options } : (options ?? {});
215
+
216
+ if (key === 'custom') {
217
+ const bounds = opts.custom;
218
+ if (!bounds || !Number.isFinite(bounds.from) || !Number.isFinite(bounds.to) || bounds.to <= bounds.from) {
219
+ return resolveRange('month', now, { earliest: opts.earliest });
220
+ }
221
+ const unit = unitForSpan(bounds.to - bounds.from);
222
+ const from = startOfBucket(bounds.from, unit);
223
+ const to = bounds.to;
224
+ const buckets = bucketStarts(from, to, unit);
225
+ const span = to - from;
226
+ return { key, from, to, unit, buckets, previous: { from: from - span, to: from } };
227
+ }
228
+
229
+ const preset = rangePreset(key);
230
+ const from =
231
+ preset.days === null
232
+ ? startOfDay(opts.earliest ?? addDays(now, -90))
233
+ : preset.unit === 'hour'
234
+ ? startOfDay(now)
235
+ : addDays(startOfDay(now), -(preset.days - 1));
236
+
237
+ const buckets = bucketStarts(from, now, preset.unit);
238
+
239
+ const span = now - from;
240
+ return {
241
+ key,
242
+ from,
243
+ to: now,
244
+ unit: preset.unit,
245
+ buckets,
246
+ previous: preset.days === null ? null : { from: from - span, to: from },
247
+ };
248
+ }
249
+
250
+ /** Every bucket start in `[from, to]`, oldest → newest. Never empty. */
251
+ function bucketStarts(from: number, to: number, unit: BucketUnit): number[] {
252
+ const buckets: number[] = [];
253
+ if (unit === 'hour') {
254
+ for (let t = from; t <= to; t += HOUR_MS) buckets.push(t);
255
+ } else {
256
+ for (let t = from; t <= to; t = addDays(t, 1)) buckets.push(t);
257
+ }
258
+ // A guard, not a formality: a corrupt clock (or an `earliest` in the future)
259
+ // could otherwise produce an empty axis with no explanation.
260
+ if (buckets.length === 0) buckets.push(from);
261
+ return buckets;
262
+ }
263
+
264
+ /**
265
+ * The three fields it takes to STATE a window — deliberately less than a
266
+ * `ResolvedRange`, so a server can put the window on the wire beside an
267
+ * aggregate without also shipping its bucket array. Every `ResolvedRange`
268
+ * satisfies it.
269
+ */
270
+ export type RangeWindow = Pick<ResolvedRange, 'key' | 'from' | 'to'>;
271
+
272
+ /**
273
+ * The active window, stated in words — what a panel prints so a reader never has
274
+ * to infer which period a number covers.
275
+ *
276
+ * This exists because of one owner report, 2026-08-13: *"what time period are we
277
+ * showing. For instance 'Spend & tokens by model' doesn't say if its per this
278
+ * month or week"*. A figure whose window is unstated is not a smaller version of
279
+ * the same figure — it is unreadable, because every reasonable guess gives a
280
+ * different answer.
281
+ */
282
+ export function rangeLabel(range: RangeWindow): string {
283
+ if (range.key === 'today') return 'Today, since midnight';
284
+ if (range.key === 'all') return `All time — since ${dayKey(range.from)}`;
285
+ const first = dayKey(range.from);
286
+ const last = dayKey(range.to);
287
+ return first === last ? first : `${first} → ${last}`;
288
+ }
289
+
290
+ /**
291
+ * Sum `weight(item)` into buckets by `at(item)`. Items outside the range are
292
+ * dropped — silently, because a caller passing "all events" and asking for
293
+ * today is the normal case, not an error.
294
+ *
295
+ * Returns one number per bucket, index-aligned with `range.buckets`, so a
296
+ * caller can zip it against any other series from the same range without
297
+ * re-deriving the axis.
298
+ */
299
+ export function bucketize<T>(
300
+ items: readonly T[],
301
+ range: ResolvedRange,
302
+ at: (item: T) => number,
303
+ weight: (item: T) => number = () => 1,
304
+ ): number[] {
305
+ const out = new Array<number>(range.buckets.length).fill(0);
306
+ const index = new Map(range.buckets.map((t, i) => [t, i]));
307
+ for (const item of items) {
308
+ const t = at(item);
309
+ if (!Number.isFinite(t) || t < range.from || t > range.to) continue;
310
+ const i = index.get(startOfBucket(t, range.unit));
311
+ if (i !== undefined) out[i] += weight(item);
312
+ }
313
+ return out;
314
+ }
315
+
316
+ /**
317
+ * How much of `[from, to)` overlaps `[windowFrom, windowTo)`. The primitive
318
+ * behind every "…excluding the time it was paused" number: a duration is only
319
+ * honest once the intervals it should not be charged for are subtracted.
320
+ */
321
+ export const overlapMs = (
322
+ from: number,
323
+ to: number,
324
+ windowFrom: number,
325
+ windowTo: number,
326
+ ): number => Math.max(0, Math.min(to, windowTo) - Math.max(from, windowFrom));
327
+
328
+ /**
329
+ * `[from, to)` minus every interval in `exclude`. Used for pause-adjusted
330
+ * durations and for the denominator of a rate that must not count downtime.
331
+ *
332
+ * `exclude` may overlap itself and need not be sorted — overlapping pause
333
+ * windows (an engine pause inside a usage pause) must not subtract twice, which
334
+ * is the bug a naive sum produces.
335
+ */
336
+ export function durationExcluding(
337
+ from: number,
338
+ to: number,
339
+ exclude: readonly { from: number; to: number }[],
340
+ ): number {
341
+ const total = Math.max(0, to - from);
342
+ if (total === 0 || exclude.length === 0) return total;
343
+
344
+ const clipped = exclude
345
+ .map((w) => ({ from: Math.max(w.from, from), to: Math.min(w.to, to) }))
346
+ .filter((w) => w.to > w.from)
347
+ .sort((a, b) => a.from - b.from);
348
+
349
+ let excluded = 0;
350
+ let cursor = -Infinity;
351
+ for (const w of clipped) {
352
+ const start = Math.max(w.from, cursor);
353
+ if (w.to > start) {
354
+ excluded += w.to - start;
355
+ cursor = w.to;
356
+ }
357
+ }
358
+ return Math.max(0, total - excluded);
359
+ }
360
+
361
+ /** Median of a numeric list. `null` for empty — never 0, which is a real value. */
362
+ export function median(values: readonly number[]): number | null {
363
+ if (values.length === 0) return null;
364
+ const s = [...values].sort((a, b) => a - b);
365
+ const mid = s.length >> 1;
366
+ return s.length % 2 === 0 ? (s[mid - 1] + s[mid]) / 2 : s[mid];
367
+ }
368
+
369
+ /** The `p`-th percentile (0–1), nearest-rank. `null` for empty. */
370
+ export function percentile(values: readonly number[], p: number): number | null {
371
+ if (values.length === 0) return null;
372
+ const s = [...values].sort((a, b) => a - b);
373
+ const rank = Math.ceil(Math.min(1, Math.max(0, p)) * s.length);
374
+ return s[Math.max(0, rank - 1)];
375
+ }
376
+
377
+ /** A day key (`2026-08-13`) in LOCAL time — never `toISOString`, which is UTC. */
378
+ export function dayKey(t: number): string {
379
+ const d = new Date(t);
380
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
381
+ const dd = String(d.getDate()).padStart(2, '0');
382
+ return `${d.getFullYear()}-${mm}-${dd}`;
383
+ }