@ai-matrx/kit 0.10.0 → 0.11.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.
- package/CHANGELOG.md +54 -0
- package/README.md +1 -0
- package/dist/format.cjs +31 -12
- package/dist/format.cjs.map +1 -1
- package/dist/format.d.cts +74 -6
- package/dist/format.d.ts +74 -6
- package/dist/format.js +31 -12
- package/dist/format.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0 — 2026-09-11
|
|
4
|
+
|
|
5
|
+
**`format` LEARNS THE TWO VOICES THE FLEET WAS MISSING**, plus the countdown
|
|
6
|
+
rounding rule. No new exports and no new layer — four new options and one new
|
|
7
|
+
tier on `formatDurationMs` / `formatRelativeTime`.
|
|
8
|
+
|
|
9
|
+
**Why.** THREE independent collapse lanes, which never spoke to each other, hit
|
|
10
|
+
the same wall in a single day while retiring hand-rolled formatters onto this
|
|
11
|
+
package. Six matrx-frontend files could not be collapsed at all and three live
|
|
12
|
+
screens regressed when they were. Every one of them broke on one of two holes.
|
|
13
|
+
|
|
14
|
+
- **`style: "long"` — THE PROSE VOICE.** `45 minutes`, `3 hours`, `1 day`,
|
|
15
|
+
`3 days`, `less than a second`. The voice that goes INSIDE a sentence a
|
|
16
|
+
person reads: "Nothing has been delivered for 3 days."; "8 save attempts have
|
|
17
|
+
failed over the last 5 minutes."; "Expires in 3 days." `coarse` renders those
|
|
18
|
+
as `72h` and `5 min`, which is wrong in prose — a collapse that degrades a
|
|
19
|
+
screen is not a win. Always floors, correctly singularises ("1 minute", never
|
|
20
|
+
"1 minutes"), one tier only, and stops at days (a duration has no calendar
|
|
21
|
+
anchor, so it cannot honestly speak weeks or months). The name is the same
|
|
22
|
+
word `RelativeTimeStyle` already uses for the same meaning.
|
|
23
|
+
- **`suffix: false` on `formatRelativeTime`** — the bare stamp (`2m`, `3d`,
|
|
24
|
+
`2 minutes`) for a dense cell whose column header already says "Age". An
|
|
25
|
+
option rather than a `"bare"` style because suffixed-vs-bare is a different
|
|
26
|
+
axis from short/long/intl; folding it into the enum would force
|
|
27
|
+
`"bare-short"` / `"bare-long"`. It also carries the dense-context long-ago
|
|
28
|
+
fallback: past a year the bare form prints the short numeric date
|
|
29
|
+
(`8/7/2025`) instead of `formatAbsoluteDate`'s `Aug 7, 2025, 11:14 PM`, which
|
|
30
|
+
is right in prose and ruinous in a narrow column. `"intl"` documents that it
|
|
31
|
+
ignores the option — there is no correct locale-independent way to amputate
|
|
32
|
+
an `Intl.RelativeTimeFormat` suffix.
|
|
33
|
+
- **`round: "nearest" | "down"`** — `"down"` is mandatory for a COUNTDOWN. The
|
|
34
|
+
sandbox time-remaining display had regressed to rounding: with 5 minutes
|
|
35
|
+
30 seconds left it said "6 min", handing the user half a minute they did not
|
|
36
|
+
have. `clock` and `long` already floor by construction and are unaffected.
|
|
37
|
+
|
|
38
|
+
**Behaviour changes (all of them fixes to sites that already read badly):**
|
|
39
|
+
|
|
40
|
+
- `coarse` **gains a day tier**: `3d`, `3d 4h`, `1d 5h`. It used to print `77h`
|
|
41
|
+
for a three-day container uptime. Only durations past 24 hours change, and
|
|
42
|
+
every one of those was already unreadable.
|
|
43
|
+
- `compact` **strips a trailing `.0`**: `5.0s` → `5s`, `1.0s` → `1s`. The
|
|
44
|
+
one-decimal-under-10s rule exists to show tenths WHEN THERE ARE TENTHS; `.0`
|
|
45
|
+
spends a character saying there are none, and a game countdown ticking
|
|
46
|
+
"9.0s, 8.0s, 7.0s" reads worse than "9s, 8s, 7s". The usual column-jitter
|
|
47
|
+
objection does not apply: `compact` already swings between `250ms`, `5.2s`,
|
|
48
|
+
`44s` and `1m 30s`, so it was never a fixed-width format. `clock` is.
|
|
49
|
+
|
|
50
|
+
**Refused on purpose: a third, abbreviated-spelled-out register**
|
|
51
|
+
(`45 sec` / `12 min` / `3 hr` / `2 days`), proposed for countdown copy. It is
|
|
52
|
+
`"long"` with three words truncated, gives the reader nothing `"long"` does
|
|
53
|
+
not, and its only distinguishing feature ("hr", "sec") is jargon in exactly the
|
|
54
|
+
sentences that wanted prose. Two registers — dense (`coarse`) and prose
|
|
55
|
+
(`"long"`) — cover every call site the fleet has.
|
|
56
|
+
|
|
3
57
|
## 0.10.0 — 2026-09-11
|
|
4
58
|
|
|
5
59
|
**THE OPENER PATTERN IS NOW A PRIMITIVE.** `@ai-matrx/kit/opener` +
|
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ npm install @ai-matrx/kit
|
|
|
44
44
|
| `@ai-matrx/kit/color-util` | `findNearestTailwindColor`, `getColorFromTailwind`, `formatTailwindColor`, `format*` normalizers, `createColorNormalizer`, `tailwindColors` | The bidirectional Tailwind-token color mapping with zero dependencies: token → hex lookups (including fuzzy input like `"skyblue598"`), and hex/`rgb()` → nearest token by perceptual CIEDE2000 distance (bit-identical to the colord lab plugin; a colord instance also plugs straight into the structural `{ delta(hex) }` seam). Plus the pure paste-anything input normalizers and the injected-validity `normalizeColorInput` waterfall. |
|
|
45
45
|
| `@ai-matrx/kit/react-tree` | `treeContainsComponent` | Safe React children-tree scan for optional a11y children (is a `DialogDescription` anywhere under these children?): walks elements, arrays, fragments, iterables, and portals by hand, and SKIPS a non-renderable child with a loud dev scream instead of crashing at the probe like `React.Children.toArray` would. |
|
|
46
46
|
| `@ai-matrx/kit/qr` | `decodeQrFromImageFile`, `decodeQrFromElement`, `decodeQrFromImageData`, `hasNativeQrDetector` | THE QR decoder, in memory and never uploaded: a pasted screenshot, a dropped file, or a live `<video>`/`<canvas>` frame in; the encoded text (or `null`) out. Native `BarcodeDetector` first; the pure-JS `jsqr` fallback is lazily imported only in browsers that lack it. |
|
|
47
|
+
| `@ai-matrx/kit/format` | `formatDurationMs` / `Seconds` / `Minutes`, `formatRelativeTime`, `formatAbsoluteDate`, `parseTimestamp`, `durationMsBetween`, `formatFileSize`, `getInitials`, `avatarPaletteIndex` | THE one home for the tiny display formatters nothing else owns, with the display decision made ONCE per voice. Durations speak `clock` (`9:04`), `compact` (`5.2s`, `1m 30s`), `coarse` (`45 min`, `3d 4h`) or `long` (`45 minutes`, `3 days` — the voice that goes inside a sentence), and `round: "down"` makes any of them safe for a countdown. Relative stamps speak `short`, `long` or locale-aware `intl`, with `suffix: false` for a dense column whose header already says "Age". The unit is always in the name — there is deliberately no `formatDuration`. |
|
|
47
48
|
|
|
48
49
|
More subpaths are on the roadmap: copy-for-ai.
|
|
49
50
|
|
package/dist/format.cjs
CHANGED
|
@@ -90,6 +90,7 @@ var INTL_UNITS = [
|
|
|
90
90
|
];
|
|
91
91
|
function formatRelativeTime(value, options = {}) {
|
|
92
92
|
const style = options.style ?? "short";
|
|
93
|
+
const bare = options.suffix === false;
|
|
93
94
|
const now = options.now ?? Date.now();
|
|
94
95
|
const parsed = parseTimestamp(value);
|
|
95
96
|
if (!parsed) {
|
|
@@ -108,13 +109,14 @@ function formatRelativeTime(value, options = {}) {
|
|
|
108
109
|
}
|
|
109
110
|
const elapsed = now - parsed.getTime();
|
|
110
111
|
if (elapsed < 0) return "just now";
|
|
112
|
+
const ago = bare ? "" : " ago";
|
|
111
113
|
for (const unit of RELATIVE_UNITS) {
|
|
112
114
|
if (elapsed < unit.limit) {
|
|
113
115
|
const count = Math.max(1, Math.floor(elapsed / unit.divisor));
|
|
114
|
-
return style === "long" ? `${count} ${unit.long}${count === 1 ? "" : "s"}
|
|
116
|
+
return style === "long" ? `${count} ${unit.long}${count === 1 ? "" : "s"}${ago}` : `${count}${unit.short}${ago}`;
|
|
115
117
|
}
|
|
116
118
|
}
|
|
117
|
-
return formatAbsoluteDate(parsed);
|
|
119
|
+
return bare ? parsed.toLocaleDateString() : formatAbsoluteDate(parsed);
|
|
118
120
|
}
|
|
119
121
|
var pad2 = (value) => value.toString().padStart(2, "0");
|
|
120
122
|
function clockBody(totalSeconds) {
|
|
@@ -123,25 +125,41 @@ function clockBody(totalSeconds) {
|
|
|
123
125
|
const hours = Math.floor(totalSeconds / 3600);
|
|
124
126
|
return hours > 0 ? `${hours}:${pad2(minutes)}:${pad2(seconds)}` : `${minutes}:${pad2(seconds)}`;
|
|
125
127
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
+
var reduce = (value, mode) => mode === "down" ? Math.floor(value) : Math.round(value);
|
|
129
|
+
function compactBody(ms, round) {
|
|
130
|
+
if (ms < 1e3) return `${reduce(ms, round)}ms`;
|
|
128
131
|
const totalSeconds = ms / 1e3;
|
|
129
132
|
if (totalSeconds < 60) {
|
|
130
|
-
|
|
133
|
+
if (totalSeconds >= 10) return `${reduce(totalSeconds, round)}s`;
|
|
134
|
+
const tenths = round === "down" ? Math.floor(totalSeconds * 10) / 10 : Math.round(totalSeconds * 10) / 10;
|
|
135
|
+
return `${Number.isInteger(tenths) ? tenths : tenths.toFixed(1)}s`;
|
|
131
136
|
}
|
|
132
|
-
const whole =
|
|
137
|
+
const whole = reduce(totalSeconds, round);
|
|
133
138
|
const minutes = Math.floor(whole / 60);
|
|
134
139
|
if (minutes < 60) return `${minutes}m ${pad2(whole % 60)}s`;
|
|
135
140
|
const hours = Math.floor(minutes / 60);
|
|
136
141
|
return `${hours}h ${pad2(minutes % 60)}m`;
|
|
137
142
|
}
|
|
138
|
-
function coarseBody(ms) {
|
|
139
|
-
const minutes =
|
|
143
|
+
function coarseBody(ms, round) {
|
|
144
|
+
const minutes = reduce(ms / 6e4, round);
|
|
140
145
|
if (minutes < 1) return "< 1 min";
|
|
141
146
|
if (minutes < 60) return `${minutes} min`;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
147
|
+
if (minutes < 1440) {
|
|
148
|
+
const hours = Math.floor(minutes / 60);
|
|
149
|
+
const rest = minutes % 60;
|
|
150
|
+
return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
|
|
151
|
+
}
|
|
152
|
+
const days = Math.floor(minutes / 1440);
|
|
153
|
+
const restHours = Math.floor(minutes % 1440 / 60);
|
|
154
|
+
return restHours > 0 ? `${days}d ${restHours}h` : `${days}d`;
|
|
155
|
+
}
|
|
156
|
+
var plural = (count, unit) => `${count} ${unit}${count === 1 ? "" : "s"}`;
|
|
157
|
+
function longBody(ms) {
|
|
158
|
+
if (ms < 1e3) return "less than a second";
|
|
159
|
+
if (ms < 6e4) return plural(Math.floor(ms / 1e3), "second");
|
|
160
|
+
if (ms < 36e5) return plural(Math.floor(ms / 6e4), "minute");
|
|
161
|
+
if (ms < 864e5) return plural(Math.floor(ms / 36e5), "hour");
|
|
162
|
+
return plural(Math.floor(ms / 864e5), "day");
|
|
145
163
|
}
|
|
146
164
|
function formatDurationMs(ms, options = {}) {
|
|
147
165
|
const style = options.style ?? "clock";
|
|
@@ -152,7 +170,8 @@ function formatDurationMs(ms, options = {}) {
|
|
|
152
170
|
return style === "clock" ? clockBody(0) : fallback;
|
|
153
171
|
}
|
|
154
172
|
const magnitude = Math.abs(ms);
|
|
155
|
-
const
|
|
173
|
+
const round = options.round ?? "nearest";
|
|
174
|
+
const body = style === "clock" ? clockBody(Math.floor(magnitude / 1e3)) : style === "compact" ? compactBody(magnitude, round) : style === "coarse" ? coarseBody(magnitude, round) : longBody(magnitude);
|
|
156
175
|
return negative ? `-${body}` : body;
|
|
157
176
|
}
|
|
158
177
|
function formatDurationSeconds(seconds, options = {}) {
|
package/dist/format.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/format.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/format — THE one home for the tiny display formatters that\n * every Matrx surface, in every repo, needs and that nothing owns.\n *\n * WHY THIS FILE EXISTS (the H1 finding, 2026-09-07 duplication census). These\n * formatters were duplicated *between packages*: `@ai-matrx/meet` and\n * `@ai-matrx/messaging` each defined `formatDuration` + `getInitials`,\n * `@ai-matrx/associations` and `@ai-matrx/diff` each defined\n * `formatRelativeTime`, and `@ai-matrx/media` hid `formatFileSize` inside a\n * React upload component. Because the fleet's packages disagreed about who\n * owned them, ~65 host twins across matrx-frontend / matrx-extend /\n * matrx-local / matrx-games / aidream's two apps had *no correct package to\n * point at* — a settings page cannot take a dependency on the video-calling\n * package to print a duration.\n *\n * WHY KIT. `@ai-matrx/kit` has ZERO sibling dependencies (verified with\n * `node scripts/check_ts_sibling_graph.mjs --order`), so it sits at the very\n * bottom of the sibling DAG: meet, messaging, associations, diff, media and\n * print can all depend on it without creating a cycle, and so can every host.\n * It is also, by charter, the \"little primitives every Matrx app speaks\"\n * package. This is not a new package — new packages need Arman's approval.\n *\n * THE UNIT LAW. A duration formatter that takes a bare `number` is a bug\n * waiting to happen: the fleet's twins variously took milliseconds, seconds\n * and minutes, and two of them were wrong by 1000×-worth of confusion. There\n * is deliberately NO `formatDuration` here. The unit is in the name:\n * `formatDurationMs`, `formatDurationSeconds`, `formatDurationMinutes`.\n *\n * Everything in this module is pure, synchronous, and free of React, DOM and\n * `Intl` implicit-locale surprises except where a style explicitly asks for\n * `Intl.RelativeTimeFormat`. Every time-dependent function takes an explicit\n * `now` so tests never depend on the clock.\n */\n\n// ───────────────────────── timestamps ─────────────────────────\n\nexport type TimestampInput = string | number | Date | null | undefined;\n\n/** True when a string already carries a timezone designator (Z or ±offset). */\nfunction hasTimezoneDesignator(value: string): boolean {\n if (/[zZ]$/.test(value)) return true;\n if (/[+-]\\d{2}(:?\\d{2})?$/.test(value)) return true;\n if (/\\b(GMT|UTC)\\b/i.test(value)) return true;\n return false;\n}\n\n/**\n * Normalise a raw timestamp string so `new Date()` parses it as an absolute\n * instant.\n *\n * THE CORRECTION THIS CARRIES (from `@ai-matrx/diff`, the richest of the\n * twins). Postgres serialises `timestamp with time zone` as\n * \"2026-06-13T16:32:26+00:00\" (parses correctly) and `timestamp without time\n * zone` as \"2026-06-13T16:32:26\" — which, per the ES spec, `new Date()` parses\n * as LOCAL time. Our backend writes those columns in UTC, so a bare\n * `new Date(...)` is wrong by the viewer's offset: the classic \"times are off\n * by N hours\" report. A zone-less string that HAS a time component is treated\n * as UTC; date-only strings are left untouched (a calendar day is not a\n * timestamp).\n */\nfunction normalizeTimestampString(raw: string): string {\n const value = raw.trim();\n if (!value) return value;\n if (!/\\d{1,2}:\\d{2}/.test(value)) return value;\n if (hasTimezoneDesignator(value)) return value;\n return `${value.replace(\" \", \"T\")}Z`;\n}\n\n/** Parse any backend timestamp into a Date, or `null` when unparseable. */\nexport function parseTimestamp(value: TimestampInput): Date | null {\n if (value === null || value === undefined) return null;\n if (value instanceof Date) {\n return Number.isNaN(value.getTime()) ? null : value;\n }\n if (typeof value === \"number\") {\n const fromNumber = new Date(value);\n return Number.isNaN(fromNumber.getTime()) ? null : fromNumber;\n }\n if (typeof value !== \"string\") return null;\n const normalized = normalizeTimestampString(value);\n if (!normalized) return null;\n const parsed = new Date(normalized);\n return Number.isNaN(parsed.getTime()) ? null : parsed;\n}\n\nconst DEFAULT_ABSOLUTE_OPTIONS: Intl.DateTimeFormatOptions = {\n year: \"numeric\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n};\n\n/** Absolute local date+time, e.g. \"Jun 13, 2026, 9:32 AM\". */\nexport function formatAbsoluteDate(\n value: TimestampInput,\n options: Intl.DateTimeFormatOptions = DEFAULT_ABSOLUTE_OPTIONS,\n fallback = \"—\",\n): string {\n const parsed = parseTimestamp(value);\n if (!parsed) return fallback;\n return parsed.toLocaleString(undefined, options);\n}\n\n// ───────────────────────── relative time ─────────────────────────\n\nconst RELATIVE_UNITS: readonly {\n limit: number;\n divisor: number;\n short: string;\n long: string;\n}[] = [\n { limit: 60_000, divisor: 1000, short: \"s\", long: \"second\" },\n { limit: 3_600_000, divisor: 60_000, short: \"m\", long: \"minute\" },\n { limit: 86_400_000, divisor: 3_600_000, short: \"h\", long: \"hour\" },\n { limit: 604_800_000, divisor: 86_400_000, short: \"d\", long: \"day\" },\n { limit: 2_592_000_000, divisor: 604_800_000, short: \"w\", long: \"week\" },\n { limit: 31_536_000_000, divisor: 2_592_000_000, short: \"mo\", long: \"month\" },\n];\n\nconst INTL_UNITS: readonly { unit: Intl.RelativeTimeFormatUnit; ms: number }[] = [\n { unit: \"year\", ms: 365 * 24 * 60 * 60 * 1000 },\n { unit: \"month\", ms: 30 * 24 * 60 * 60 * 1000 },\n { unit: \"week\", ms: 7 * 24 * 60 * 60 * 1000 },\n { unit: \"day\", ms: 24 * 60 * 60 * 1000 },\n { unit: \"hour\", ms: 60 * 60 * 1000 },\n { unit: \"minute\", ms: 60 * 1000 },\n];\n\n/**\n * The three relative-time voices the fleet actually speaks. They are display\n * decisions, not implementations — one body serves all three.\n *\n * - `\"short\"` — \"2m ago\", \"3d ago\". The dense default (from `@ai-matrx/diff`).\n * - `\"long\"` — \"2 minutes ago\", \"3 days ago\".\n * - `\"intl\"` — `Intl.RelativeTimeFormat` with `numeric: \"auto\"`, so the\n * viewer's locale conventions apply and \"yesterday\" reads as \"yesterday\"\n * (from `@ai-matrx/associations`). Sub-minute reads \"just now\".\n */\nexport type RelativeTimeStyle = \"short\" | \"long\" | \"intl\";\n\nexport interface RelativeTimeOptions {\n /** Default `\"short\"`. */\n style?: RelativeTimeStyle | undefined;\n /** Injected clock. Default `Date.now()` — pass it in tests. */\n now?: number | undefined;\n /** Returned for null / unparseable input. Default `\"—\"`. */\n fallback?: string | undefined;\n /**\n * Echo the raw input instead of `fallback` when it cannot be parsed —\n * degraded, never broken. What the comments face wants: an odd server string\n * is more useful on screen than an em-dash that hides it.\n */\n fallbackToInput?: boolean | undefined;\n}\n\n/**\n * \"2m ago\" / \"2 minutes ago\" / a locale-aware \"2 minutes ago\", falling back to\n * an absolute local date past a year. Timezone-agnostic by construction (a\n * pure epoch difference over `parseTimestamp`'s corrected instant).\n *\n * Future timestamps read \"just now\" in `\"short\"`/`\"long\"` (a clock skew of a\n * few seconds must not print \"in 3 seconds\"); `\"intl\"` formats them properly\n * (\"in 5 minutes\") because that is the whole point of asking for `Intl`.\n */\nexport function formatRelativeTime(\n value: TimestampInput,\n options: RelativeTimeOptions = {},\n): string {\n const style = options.style ?? \"short\";\n const now = options.now ?? Date.now();\n const parsed = parseTimestamp(value);\n if (!parsed) {\n if (options.fallbackToInput === true && typeof value === \"string\") return value;\n return options.fallback ?? \"—\";\n }\n\n if (style === \"intl\") {\n const delta = parsed.getTime() - now;\n const magnitude = Math.abs(delta);\n if (magnitude < 60_000) return \"just now\";\n const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: \"auto\" });\n for (const { unit, ms } of INTL_UNITS) {\n if (magnitude >= ms) return rtf.format(Math.trunc(delta / ms), unit);\n }\n return \"just now\";\n }\n\n const elapsed = now - parsed.getTime();\n if (elapsed < 0) return \"just now\";\n for (const unit of RELATIVE_UNITS) {\n if (elapsed < unit.limit) {\n const count = Math.max(1, Math.floor(elapsed / unit.divisor));\n return style === \"long\"\n ? `${count} ${unit.long}${count === 1 ? \"\" : \"s\"} ago`\n : `${count}${unit.short} ago`;\n }\n }\n return formatAbsoluteDate(parsed);\n}\n\n// ───────────────────────── durations ─────────────────────────\n\n/**\n * The three duration voices the fleet actually speaks. Picked once here so a\n * call duration reads the same in the meeting, the history list and the\n * notification.\n *\n * - `\"clock\"` (default) — `0:00`, `9:04`, `1:02:33`. For anything a human\n * watches tick: recordings, calls, media players, timers.\n * - `\"compact\"` — `250ms`, `5.2s`, `5m 30s`, `1h 02m`. For elapsed work:\n * job runs, request timings, step durations. Sub-second is honest about\n * being sub-second rather than collapsing to `0:00`.\n * - `\"coarse\"` — `45 min`, `1h 30m`. For things measured in minutes and read\n * at a glance: podcast episodes, lesson lengths, estimates. Never prints\n * seconds.\n */\nexport type DurationStyle = \"clock\" | \"compact\" | \"coarse\";\n\nexport interface DurationOptions {\n /** Default `\"clock\"`. */\n style?: DurationStyle | undefined;\n /**\n * Returned for `null` / `undefined` / non-finite input. Default `\"0:00\"` for\n * `\"clock\"` and `\"—\"` for `\"compact\"` / `\"coarse\"`.\n */\n fallback?: string | undefined;\n /**\n * Keep the sign on a negative duration (`-1:23`) instead of clamping to\n * zero. Default `false` — a negative elapsed time is nearly always a clock\n * bug, and `0:00` is the honest reading. Opt in where a signed offset is the\n * actual quantity (a field format for a stored `interval`, say).\n */\n signed?: boolean | undefined;\n}\n\nconst pad2 = (value: number): string => value.toString().padStart(2, \"0\");\n\nfunction clockBody(totalSeconds: number): string {\n const seconds = totalSeconds % 60;\n const minutes = Math.floor(totalSeconds / 60) % 60;\n const hours = Math.floor(totalSeconds / 3600);\n return hours > 0\n ? `${hours}:${pad2(minutes)}:${pad2(seconds)}`\n : `${minutes}:${pad2(seconds)}`;\n}\n\nfunction compactBody(ms: number): string {\n if (ms < 1000) return `${Math.round(ms)}ms`;\n const totalSeconds = ms / 1000;\n if (totalSeconds < 60) {\n // One decimal under 10s (0.1s is a visible difference at that scale),\n // whole seconds above it (nobody reads \"43.7s\" as more precise than \"44s\").\n return totalSeconds < 10\n ? `${totalSeconds.toFixed(1)}s`\n : `${Math.round(totalSeconds)}s`;\n }\n const whole = Math.round(totalSeconds);\n const minutes = Math.floor(whole / 60);\n if (minutes < 60) return `${minutes}m ${pad2(whole % 60)}s`;\n const hours = Math.floor(minutes / 60);\n return `${hours}h ${pad2(minutes % 60)}m`;\n}\n\nfunction coarseBody(ms: number): string {\n const minutes = Math.round(ms / 60_000);\n if (minutes < 1) return \"< 1 min\";\n if (minutes < 60) return `${minutes} min`;\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;\n}\n\n/**\n * THE canonical duration formatter. Milliseconds in, a chosen voice out.\n *\n * `null`, `undefined`, `NaN` and `Infinity` all take the fallback — never\n * `NaN:NaN`, which is what a null start time produces, and never a confident\n * `0:00` for \"we do not know\".\n */\nexport function formatDurationMs(\n ms: number | null | undefined,\n options: DurationOptions = {},\n): string {\n const style = options.style ?? \"clock\";\n const fallback = options.fallback ?? (style === \"clock\" ? \"0:00\" : \"—\");\n if (ms === null || ms === undefined || !Number.isFinite(ms)) return fallback;\n\n const negative = ms < 0;\n if (negative && options.signed !== true) {\n return style === \"clock\" ? clockBody(0) : fallback;\n }\n const magnitude = Math.abs(ms);\n const body =\n style === \"clock\"\n ? clockBody(Math.floor(magnitude / 1000))\n : style === \"compact\"\n ? compactBody(magnitude)\n : coarseBody(magnitude);\n return negative ? `-${body}` : body;\n}\n\n/** Seconds in. See {@link formatDurationMs}. */\nexport function formatDurationSeconds(\n seconds: number | null | undefined,\n options: DurationOptions = {},\n): string {\n if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) {\n return formatDurationMs(seconds as number | null | undefined, options);\n }\n return formatDurationMs(seconds * 1000, options);\n}\n\n/** Minutes in. See {@link formatDurationMs}. */\nexport function formatDurationMinutes(\n minutes: number | null | undefined,\n options: DurationOptions = {},\n): string {\n if (minutes === null || minutes === undefined || !Number.isFinite(minutes)) {\n return formatDurationMs(minutes as number | null | undefined, options);\n }\n return formatDurationMs(minutes * 60_000, options);\n}\n\n/**\n * Duration between two timestamps, in milliseconds — `null` when the start is\n * missing or either end is unparseable. An absent `to` means \"still running\",\n * so it measures to `now`. Pair with {@link formatDurationMs}.\n */\nexport function durationMsBetween(\n from: TimestampInput,\n to: TimestampInput,\n now: number = Date.now(),\n): number | null {\n const start = parseTimestamp(from);\n if (!start) return null;\n if (to === null || to === undefined) return Math.max(0, now - start.getTime());\n const end = parseTimestamp(to);\n if (!end) return null;\n return Math.max(0, end.getTime() - start.getTime());\n}\n\n// ───────────────────────── byte sizes ─────────────────────────\n\nconst SIZE_UNITS = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\"] as const;\n\nexport interface FileSizeOptions {\n /** Returned for `null` / `undefined` / non-finite / negative. Default `\"—\"`. */\n fallback?: string | undefined;\n}\n\n/**\n * `512` → `\"512 B\"`, `2048` → `\"2.0 KB\"`, `15360` → `\"15 KB\"`.\n *\n * THE DISPLAY DECISIONS, made once (unified 2026-09-07 from six host twins and\n * `@ai-matrx/media`'s copy, which was the richest):\n *\n * 1. Binary units (1024), labelled with the short SI-ish names the whole fleet\n * already used — `B`/`KB`/`MB`/`GB`/`TB`, never `\"Bytes\"`.\n * 2. One decimal below 10 in a unit (`2.0 KB`, `9.9 MB`), whole numbers at or\n * above it (`15 KB`, `340 MB`) — three significant figures is the most a\n * file size is ever worth, and `1.5 GB` versus `1536.0 MB` is the whole\n * point of the unit.\n * 3. Whole bytes below 1 KB — `\"512 B\"`, never `\"512.0 B\"`.\n * 4. `null` / `undefined` / `NaN` / `Infinity` / negative all collapse to an\n * em-dash, NOT to `\"0 B\"`. A corrupt or mid-upload `size` row rendering as\n * a confident, wrong `\"0 B\"` is a screen telling a lie; \"size unknown\" is\n * the honest reading. (`0` itself is a real size and prints `\"0 B\"`.)\n */\nexport function formatFileSize(\n bytes: number | null | undefined,\n options: FileSizeOptions = {},\n): string {\n const fallback = options.fallback ?? \"—\";\n if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) {\n return fallback;\n }\n if (bytes < 1024) return `${Math.round(bytes)} B`;\n let value = bytes;\n let unit = 0;\n while (value >= 1024 && unit < SIZE_UNITS.length - 1) {\n value /= 1024;\n unit += 1;\n }\n const rendered = value >= 10 ? String(Math.round(value)) : value.toFixed(1);\n return `${rendered} ${SIZE_UNITS[unit]}`;\n}\n\n// ───────────────────────── people ─────────────────────────\n\nexport interface InitialsOptions {\n /** Returned when nothing usable is left. Default `\"?\"`. */\n fallback?: string | undefined;\n}\n\n/**\n * `\"Ana Rivera\"` → `\"AR\"`, `\"Ana\"` → `\"A\"`, `\"Ana Maria Rivera\"` → `\"AR\"`,\n * `\"ana@example.com\"` → `\"A\"`, `\" \"` → `\"?\"`.\n *\n * THE DISPLAY DECISION, made once: a multi-part name takes FIRST + LAST, not\n * first + second. `\"Ana Maria Rivera\"` is `AR`, because the family name is the\n * half a reader recognises. (Six matrx-frontend twins took first + second and\n * printed `AM`; the two package copies both took first + last, and both were\n * tested. First + last wins.)\n *\n * Pass the email as the value when there is no name — a single token yields\n * its first character, which is exactly what the host twins did by hand.\n */\nexport function getInitials(\n value: string | null | undefined,\n options: InitialsOptions = {},\n): string {\n const fallback = options.fallback ?? \"?\";\n if (typeof value !== \"string\") return fallback;\n const parts = value.trim().split(/\\s+/).filter((part) => part.length > 0);\n if (parts.length === 0) return fallback;\n const first = parts[0]?.charAt(0) ?? \"\";\n const last = parts.length > 1 ? (parts[parts.length - 1]?.charAt(0) ?? \"\") : \"\";\n return `${first}${last}`.toUpperCase() || fallback;\n}\n\n/**\n * A stable palette index for an avatar with no image. Deterministic on the\n * seed, so the same person is the same colour on every device and every\n * reload — a random colour per render is a surprisingly loud bug.\n */\nexport function avatarPaletteIndex(seed: string, buckets = 8): number {\n let hash = 0;\n for (let index = 0; index < seed.length; index += 1) {\n hash = (hash * 31 + seed.charCodeAt(index)) | 0;\n }\n return Math.abs(hash) % buckets;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/format.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/format — THE one home for the tiny display formatters that\n * every Matrx surface, in every repo, needs and that nothing owns.\n *\n * WHY THIS FILE EXISTS (the H1 finding, 2026-09-07 duplication census). These\n * formatters were duplicated *between packages*: `@ai-matrx/meet` and\n * `@ai-matrx/messaging` each defined `formatDuration` + `getInitials`,\n * `@ai-matrx/associations` and `@ai-matrx/diff` each defined\n * `formatRelativeTime`, and `@ai-matrx/media` hid `formatFileSize` inside a\n * React upload component. Because the fleet's packages disagreed about who\n * owned them, ~65 host twins across matrx-frontend / matrx-extend /\n * matrx-local / matrx-games / aidream's two apps had *no correct package to\n * point at* — a settings page cannot take a dependency on the video-calling\n * package to print a duration.\n *\n * WHY KIT. `@ai-matrx/kit` has ZERO sibling dependencies (verified with\n * `node scripts/check_ts_sibling_graph.mjs --order`), so it sits at the very\n * bottom of the sibling DAG: meet, messaging, associations, diff, media and\n * print can all depend on it without creating a cycle, and so can every host.\n * It is also, by charter, the \"little primitives every Matrx app speaks\"\n * package. This is not a new package — new packages need Arman's approval.\n *\n * THE UNIT LAW. A duration formatter that takes a bare `number` is a bug\n * waiting to happen: the fleet's twins variously took milliseconds, seconds\n * and minutes, and two of them were wrong by 1000×-worth of confusion. There\n * is deliberately NO `formatDuration` here. The unit is in the name:\n * `formatDurationMs`, `formatDurationSeconds`, `formatDurationMinutes`.\n *\n * Everything in this module is pure, synchronous, and free of React, DOM and\n * `Intl` implicit-locale surprises except where a style explicitly asks for\n * `Intl.RelativeTimeFormat`. Every time-dependent function takes an explicit\n * `now` so tests never depend on the clock.\n */\n\n// ───────────────────────── timestamps ─────────────────────────\n\nexport type TimestampInput = string | number | Date | null | undefined;\n\n/** True when a string already carries a timezone designator (Z or ±offset). */\nfunction hasTimezoneDesignator(value: string): boolean {\n if (/[zZ]$/.test(value)) return true;\n if (/[+-]\\d{2}(:?\\d{2})?$/.test(value)) return true;\n if (/\\b(GMT|UTC)\\b/i.test(value)) return true;\n return false;\n}\n\n/**\n * Normalise a raw timestamp string so `new Date()` parses it as an absolute\n * instant.\n *\n * THE CORRECTION THIS CARRIES (from `@ai-matrx/diff`, the richest of the\n * twins). Postgres serialises `timestamp with time zone` as\n * \"2026-06-13T16:32:26+00:00\" (parses correctly) and `timestamp without time\n * zone` as \"2026-06-13T16:32:26\" — which, per the ES spec, `new Date()` parses\n * as LOCAL time. Our backend writes those columns in UTC, so a bare\n * `new Date(...)` is wrong by the viewer's offset: the classic \"times are off\n * by N hours\" report. A zone-less string that HAS a time component is treated\n * as UTC; date-only strings are left untouched (a calendar day is not a\n * timestamp).\n */\nfunction normalizeTimestampString(raw: string): string {\n const value = raw.trim();\n if (!value) return value;\n if (!/\\d{1,2}:\\d{2}/.test(value)) return value;\n if (hasTimezoneDesignator(value)) return value;\n return `${value.replace(\" \", \"T\")}Z`;\n}\n\n/** Parse any backend timestamp into a Date, or `null` when unparseable. */\nexport function parseTimestamp(value: TimestampInput): Date | null {\n if (value === null || value === undefined) return null;\n if (value instanceof Date) {\n return Number.isNaN(value.getTime()) ? null : value;\n }\n if (typeof value === \"number\") {\n const fromNumber = new Date(value);\n return Number.isNaN(fromNumber.getTime()) ? null : fromNumber;\n }\n if (typeof value !== \"string\") return null;\n const normalized = normalizeTimestampString(value);\n if (!normalized) return null;\n const parsed = new Date(normalized);\n return Number.isNaN(parsed.getTime()) ? null : parsed;\n}\n\nconst DEFAULT_ABSOLUTE_OPTIONS: Intl.DateTimeFormatOptions = {\n year: \"numeric\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n};\n\n/** Absolute local date+time, e.g. \"Jun 13, 2026, 9:32 AM\". */\nexport function formatAbsoluteDate(\n value: TimestampInput,\n options: Intl.DateTimeFormatOptions = DEFAULT_ABSOLUTE_OPTIONS,\n fallback = \"—\",\n): string {\n const parsed = parseTimestamp(value);\n if (!parsed) return fallback;\n return parsed.toLocaleString(undefined, options);\n}\n\n// ───────────────────────── relative time ─────────────────────────\n\nconst RELATIVE_UNITS: readonly {\n limit: number;\n divisor: number;\n short: string;\n long: string;\n}[] = [\n { limit: 60_000, divisor: 1000, short: \"s\", long: \"second\" },\n { limit: 3_600_000, divisor: 60_000, short: \"m\", long: \"minute\" },\n { limit: 86_400_000, divisor: 3_600_000, short: \"h\", long: \"hour\" },\n { limit: 604_800_000, divisor: 86_400_000, short: \"d\", long: \"day\" },\n { limit: 2_592_000_000, divisor: 604_800_000, short: \"w\", long: \"week\" },\n { limit: 31_536_000_000, divisor: 2_592_000_000, short: \"mo\", long: \"month\" },\n];\n\nconst INTL_UNITS: readonly { unit: Intl.RelativeTimeFormatUnit; ms: number }[] = [\n { unit: \"year\", ms: 365 * 24 * 60 * 60 * 1000 },\n { unit: \"month\", ms: 30 * 24 * 60 * 60 * 1000 },\n { unit: \"week\", ms: 7 * 24 * 60 * 60 * 1000 },\n { unit: \"day\", ms: 24 * 60 * 60 * 1000 },\n { unit: \"hour\", ms: 60 * 60 * 1000 },\n { unit: \"minute\", ms: 60 * 1000 },\n];\n\n/**\n * The three relative-time voices the fleet actually speaks. They are display\n * decisions, not implementations — one body serves all three.\n *\n * - `\"short\"` — \"2m ago\", \"3d ago\". The dense default (from `@ai-matrx/diff`).\n * - `\"long\"` — \"2 minutes ago\", \"3 days ago\".\n * - `\"intl\"` — `Intl.RelativeTimeFormat` with `numeric: \"auto\"`, so the\n * viewer's locale conventions apply and \"yesterday\" reads as \"yesterday\"\n * (from `@ai-matrx/associations`). Sub-minute reads \"just now\".\n */\nexport type RelativeTimeStyle = \"short\" | \"long\" | \"intl\";\n\nexport interface RelativeTimeOptions {\n /** Default `\"short\"`. */\n style?: RelativeTimeStyle | undefined;\n /**\n * THE BARE CONTRACT. `false` drops the trailing \" ago\" — \"2m\", \"3d\",\n * \"2 minutes\" — for a dense table cell whose COLUMN HEADER already says\n * \"Age\" or \"Last seen\". Default `true`.\n *\n * WHY AN OPTION AND NOT A `\"bare\"` STYLE (decided 2026-09-11, after three\n * surfaces — an exposure audit, a conversation sidebar and a batch Age\n * column — each lost their deliberate bare stamp to a collapse). `short` /\n * `long` / `intl` are three different VOICES; suffixed-vs-bare is a\n * different axis entirely, and you can want a bare `long` (\"2 minutes\") as\n * readily as a bare `short` (\"2m\"). Folding the axis into the style enum\n * would force `\"bare-short\"` / `\"bare-long\"` and double the enum every time\n * either axis grows. Orthogonal beats combinatorial.\n *\n * IT ALSO CARRIES THE DENSE-CONTEXT FALLBACK, on purpose. Past a year the\n * suffixed voices fall back to `formatAbsoluteDate` — \"Aug 7, 2025,\n * 11:14 PM\" — which is right in prose and ruinous in a narrow column. Asking\n * for the bare form IS the statement \"this is a dense cell\", so the bare\n * form's long-ago fallback is the short numeric date, \"8/7/2025\", which is\n * what every hand-rolled twin printed there. One option, one context, two\n * consistent consequences.\n *\n * `\"intl\"` IGNORES THIS, loudly documented rather than silently half-done:\n * `Intl.RelativeTimeFormat` composes the whole string in the viewer's\n * locale, and there is no correct locale-independent way to amputate its\n * suffix. Ask for `\"short\"` or `\"long\"` when you need bare.\n */\n suffix?: boolean | undefined;\n /** Injected clock. Default `Date.now()` — pass it in tests. */\n now?: number | undefined;\n /** Returned for null / unparseable input. Default `\"—\"`. */\n fallback?: string | undefined;\n /**\n * Echo the raw input instead of `fallback` when it cannot be parsed —\n * degraded, never broken. What the comments face wants: an odd server string\n * is more useful on screen than an em-dash that hides it.\n */\n fallbackToInput?: boolean | undefined;\n}\n\n/**\n * \"2m ago\" / \"2 minutes ago\" / a locale-aware \"2 minutes ago\", falling back to\n * an absolute local date past a year. Timezone-agnostic by construction (a\n * pure epoch difference over `parseTimestamp`'s corrected instant).\n *\n * Future timestamps read \"just now\" in `\"short\"`/`\"long\"` (a clock skew of a\n * few seconds must not print \"in 3 seconds\"); `\"intl\"` formats them properly\n * (\"in 5 minutes\") because that is the whole point of asking for `Intl`.\n */\nexport function formatRelativeTime(\n value: TimestampInput,\n options: RelativeTimeOptions = {},\n): string {\n const style = options.style ?? \"short\";\n const bare = options.suffix === false;\n const now = options.now ?? Date.now();\n const parsed = parseTimestamp(value);\n if (!parsed) {\n if (options.fallbackToInput === true && typeof value === \"string\") return value;\n return options.fallback ?? \"—\";\n }\n\n if (style === \"intl\") {\n const delta = parsed.getTime() - now;\n const magnitude = Math.abs(delta);\n if (magnitude < 60_000) return \"just now\";\n const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: \"auto\" });\n for (const { unit, ms } of INTL_UNITS) {\n if (magnitude >= ms) return rtf.format(Math.trunc(delta / ms), unit);\n }\n return \"just now\";\n }\n\n const elapsed = now - parsed.getTime();\n if (elapsed < 0) return \"just now\";\n const ago = bare ? \"\" : \" ago\";\n for (const unit of RELATIVE_UNITS) {\n if (elapsed < unit.limit) {\n const count = Math.max(1, Math.floor(elapsed / unit.divisor));\n return style === \"long\"\n ? `${count} ${unit.long}${count === 1 ? \"\" : \"s\"}${ago}`\n : `${count}${unit.short}${ago}`;\n }\n }\n // Past a year: prose gets the full local date+time, a bare/dense cell gets\n // the short numeric date. See `suffix` above for why one option decides both.\n return bare ? parsed.toLocaleDateString() : formatAbsoluteDate(parsed);\n}\n\n// ───────────────────────── durations ─────────────────────────\n\n/**\n * The four duration voices the fleet actually speaks. Picked once here so a\n * call duration reads the same in the meeting, the history list and the\n * notification.\n *\n * - `\"clock\"` (default) — `0:00`, `9:04`, `1:02:33`. For anything a human\n * watches tick: recordings, calls, media players, timers.\n * - `\"compact\"` — `250ms`, `5.2s`, `5m 30s`, `1h 02m`. For elapsed work:\n * job runs, request timings, step durations. Sub-second is honest about\n * being sub-second rather than collapsing to `0:00`.\n * - `\"coarse\"` — `45 min`, `1h 30m`, `3d 4h`. For durations read at a glance\n * in a dense cell: podcast episodes, lesson lengths, estimates, uptime,\n * sandbox time-remaining. Never prints seconds.\n * - `\"long\"` — `45 minutes`, `3 hours`, `1 day`, `3 days`. THE PROSE VOICE:\n * the one you put INSIDE a sentence a person reads. \"Nothing has been\n * delivered for 3 days.\" \"8 save attempts have failed over the last\n * 5 minutes.\" \"Expires in 3 days.\"\n *\n * WHY `\"long\"` EXISTS (added 2026-09-11). THREE independent collapse lanes,\n * which never spoke to each other, hit the same wall in one day: six files\n * across matrx-frontend render a duration into English prose, and the package\n * had no voice for it. `coarse` renders three days as `72h` and \"45 minutes\"\n * as \"45 min\", both of which are wrong in a sentence — and a collapse that\n * degrades a screen is not a win. The name is deliberately the SAME WORD as\n * {@link RelativeTimeStyle}'s `\"long\"`, which already means \"spell the unit\n * out in full, correctly pluralised\" (\"2 minutes ago\"). One module, one word,\n * one meaning.\n *\n * WHY THERE IS NO FIFTH, ABBREVIATED-SPELLED-OUT VOICE. A lane proposed a\n * third register — `45 sec` / `12 min` / `3 hr` / `2 days` — for countdown\n * copy. It was refused: it is `\"long\"` with three words truncated, it gives\n * the reader nothing `\"long\"` does not, and its only distinguishing feature\n * (\"hr\", \"sec\") is jargon in exactly the sentences that wanted prose. The one\n * caller that spoke it now speaks `\"long\"`. Two registers — dense (`coarse`)\n * and prose (`\"long\"`) — cover every site the fleet has.\n *\n * WHY `\"coarse\"` GREW A DAY TIER rather than `\"long\"` absorbing those callers:\n * `coarse` stopping at hours was simply a hole. It printed `77h` for a\n * three-day container uptime, which no dense cell wants either; every site\n * that changes is a site that was already reading badly.\n */\nexport type DurationStyle = \"clock\" | \"compact\" | \"coarse\" | \"long\";\n\nexport interface DurationOptions {\n /** Default `\"clock\"`. */\n style?: DurationStyle | undefined;\n /**\n * Returned for `null` / `undefined` / non-finite input. Default `\"0:00\"` for\n * `\"clock\"` and `\"—\"` for `\"compact\"` / `\"coarse\"` / `\"long\"`.\n */\n fallback?: string | undefined;\n /**\n * How a tier's leading number is derived from the remainder below it.\n * `\"nearest\"` (default) is right for a MEASURED or ESTIMATED span — a\n * 44-minute-31-second podcast reads \"45 min\". `\"down\"` is mandatory for a\n * COUNTDOWN: with 5 minutes 30 seconds left, \"6 min\" promises the user half\n * a minute they do not have. (Real regression, sandbox time-remaining,\n * 2026-09-11.)\n *\n * `\"clock\"` and `\"long\"` are ALREADY `\"down\"` by construction and ignore\n * this — a clock never rounds `0:59` up to `1:00`, and prose \"for 3 hours\"\n * asserts at-least-three-hours. It changes `\"coarse\"` and `\"compact\"`.\n */\n round?: \"nearest\" | \"down\" | undefined;\n /**\n * Keep the sign on a negative duration (`-1:23`) instead of clamping to\n * zero. Default `false` — a negative elapsed time is nearly always a clock\n * bug, and `0:00` is the honest reading. Opt in where a signed offset is the\n * actual quantity (a field format for a stored `interval`, say).\n */\n signed?: boolean | undefined;\n}\n\nconst pad2 = (value: number): string => value.toString().padStart(2, \"0\");\n\nfunction clockBody(totalSeconds: number): string {\n const seconds = totalSeconds % 60;\n const minutes = Math.floor(totalSeconds / 60) % 60;\n const hours = Math.floor(totalSeconds / 3600);\n return hours > 0\n ? `${hours}:${pad2(minutes)}:${pad2(seconds)}`\n : `${minutes}:${pad2(seconds)}`;\n}\n\ntype Rounding = \"nearest\" | \"down\";\n\nconst reduce = (value: number, mode: Rounding): number =>\n mode === \"down\" ? Math.floor(value) : Math.round(value);\n\nfunction compactBody(ms: number, round: Rounding): string {\n if (ms < 1000) return `${reduce(ms, round)}ms`;\n const totalSeconds = ms / 1000;\n if (totalSeconds < 60) {\n // One decimal under 10s (0.1s is a visible difference at that scale),\n // whole seconds above it (nobody reads \"43.7s\" as more precise than \"44s\").\n //\n // A TRAILING `.0` IS STRIPPED (2026-09-11). The rule above exists to show\n // TENTHS WHEN THERE ARE TENTHS; `5.0s` spends a character saying \"there\n // are none\", and a game countdown ticking \"9.0s, 8.0s, 7.0s\" reads worse\n // than \"9s, 8s, 7s\" for no gain. The usual objection — column jitter —\n // does not apply to this voice: `compact` already swings between `250ms`,\n // `5.2s`, `44s` and `1m 30s`, so it was never a fixed-width column format.\n // `\"clock\"` is the fixed-width one.\n if (totalSeconds >= 10) return `${reduce(totalSeconds, round)}s`;\n const tenths = round === \"down\"\n ? Math.floor(totalSeconds * 10) / 10\n : Math.round(totalSeconds * 10) / 10;\n return `${Number.isInteger(tenths) ? tenths : tenths.toFixed(1)}s`;\n }\n const whole = reduce(totalSeconds, round);\n const minutes = Math.floor(whole / 60);\n if (minutes < 60) return `${minutes}m ${pad2(whole % 60)}s`;\n const hours = Math.floor(minutes / 60);\n return `${hours}h ${pad2(minutes % 60)}m`;\n}\n\nfunction coarseBody(ms: number, round: Rounding): string {\n const minutes = reduce(ms / 60_000, round);\n if (minutes < 1) return \"< 1 min\";\n if (minutes < 60) return `${minutes} min`;\n if (minutes < 1440) {\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;\n }\n // Day tier, added 2026-09-11: this voice used to print `77h` for a\n // three-day container uptime. It keeps `coarse`'s own shape — abbreviated\n // units, at most two of them, largest first — so `3d 4h`, `3d`, never\n // `3d 4h 12m`.\n const days = Math.floor(minutes / 1440);\n const restHours = Math.floor((minutes % 1440) / 60);\n return restHours > 0 ? `${days}d ${restHours}h` : `${days}d`;\n}\n\nconst plural = (count: number, unit: string): string =>\n `${count} ${unit}${count === 1 ? \"\" : \"s\"}`;\n\n/**\n * The prose voice. Always FLOORS: \"for 3 hours\" and \"expires in 3 days\" both\n * assert at-least, which is the only reading that cannot mislead in either\n * direction (an elapsed time never overstates, a countdown never over-promises).\n *\n * ONE TIER ONLY — \"3 hours\", never \"3 hours 12 minutes\". This voice goes\n * inside a sentence, and a sentence carrying two magnitudes reads like a\n * stopwatch readout. A caller that genuinely needs both wants `coarse`.\n *\n * Sub-second says so in words rather than printing a floored \"0 seconds\",\n * which reads as \"nothing happened\" — the same reasoning as `coarse`'s\n * \"< 1 min\".\n *\n * It stops at days. Weeks and months are calendar units whose length depends\n * on WHICH week and WHICH month, so a duration — a pure span with no anchor —\n * cannot honestly speak them. \"45 days\" is exact; \"1.5 months\" is a guess.\n * {@link formatRelativeTime}, which HAS an anchor, is where weeks and months\n * belong.\n */\nfunction longBody(ms: number): string {\n if (ms < 1_000) return \"less than a second\";\n if (ms < 60_000) return plural(Math.floor(ms / 1_000), \"second\");\n if (ms < 3_600_000) return plural(Math.floor(ms / 60_000), \"minute\");\n if (ms < 86_400_000) return plural(Math.floor(ms / 3_600_000), \"hour\");\n return plural(Math.floor(ms / 86_400_000), \"day\");\n}\n\n/**\n * THE canonical duration formatter. Milliseconds in, a chosen voice out.\n *\n * `null`, `undefined`, `NaN` and `Infinity` all take the fallback — never\n * `NaN:NaN`, which is what a null start time produces, and never a confident\n * `0:00` for \"we do not know\".\n */\nexport function formatDurationMs(\n ms: number | null | undefined,\n options: DurationOptions = {},\n): string {\n const style = options.style ?? \"clock\";\n const fallback = options.fallback ?? (style === \"clock\" ? \"0:00\" : \"—\");\n if (ms === null || ms === undefined || !Number.isFinite(ms)) return fallback;\n\n const negative = ms < 0;\n if (negative && options.signed !== true) {\n return style === \"clock\" ? clockBody(0) : fallback;\n }\n const magnitude = Math.abs(ms);\n const round = options.round ?? \"nearest\";\n const body =\n style === \"clock\"\n ? clockBody(Math.floor(magnitude / 1000))\n : style === \"compact\"\n ? compactBody(magnitude, round)\n : style === \"coarse\"\n ? coarseBody(magnitude, round)\n : longBody(magnitude);\n return negative ? `-${body}` : body;\n}\n\n/** Seconds in. See {@link formatDurationMs}. */\nexport function formatDurationSeconds(\n seconds: number | null | undefined,\n options: DurationOptions = {},\n): string {\n if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) {\n return formatDurationMs(seconds as number | null | undefined, options);\n }\n return formatDurationMs(seconds * 1000, options);\n}\n\n/** Minutes in. See {@link formatDurationMs}. */\nexport function formatDurationMinutes(\n minutes: number | null | undefined,\n options: DurationOptions = {},\n): string {\n if (minutes === null || minutes === undefined || !Number.isFinite(minutes)) {\n return formatDurationMs(minutes as number | null | undefined, options);\n }\n return formatDurationMs(minutes * 60_000, options);\n}\n\n/**\n * Duration between two timestamps, in milliseconds — `null` when the start is\n * missing or either end is unparseable. An absent `to` means \"still running\",\n * so it measures to `now`. Pair with {@link formatDurationMs}.\n */\nexport function durationMsBetween(\n from: TimestampInput,\n to: TimestampInput,\n now: number = Date.now(),\n): number | null {\n const start = parseTimestamp(from);\n if (!start) return null;\n if (to === null || to === undefined) return Math.max(0, now - start.getTime());\n const end = parseTimestamp(to);\n if (!end) return null;\n return Math.max(0, end.getTime() - start.getTime());\n}\n\n// ───────────────────────── byte sizes ─────────────────────────\n\nconst SIZE_UNITS = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\"] as const;\n\nexport interface FileSizeOptions {\n /** Returned for `null` / `undefined` / non-finite / negative. Default `\"—\"`. */\n fallback?: string | undefined;\n}\n\n/**\n * `512` → `\"512 B\"`, `2048` → `\"2.0 KB\"`, `15360` → `\"15 KB\"`.\n *\n * THE DISPLAY DECISIONS, made once (unified 2026-09-07 from six host twins and\n * `@ai-matrx/media`'s copy, which was the richest):\n *\n * 1. Binary units (1024), labelled with the short SI-ish names the whole fleet\n * already used — `B`/`KB`/`MB`/`GB`/`TB`, never `\"Bytes\"`.\n * 2. One decimal below 10 in a unit (`2.0 KB`, `9.9 MB`), whole numbers at or\n * above it (`15 KB`, `340 MB`) — three significant figures is the most a\n * file size is ever worth, and `1.5 GB` versus `1536.0 MB` is the whole\n * point of the unit.\n * 3. Whole bytes below 1 KB — `\"512 B\"`, never `\"512.0 B\"`.\n * 4. `null` / `undefined` / `NaN` / `Infinity` / negative all collapse to an\n * em-dash, NOT to `\"0 B\"`. A corrupt or mid-upload `size` row rendering as\n * a confident, wrong `\"0 B\"` is a screen telling a lie; \"size unknown\" is\n * the honest reading. (`0` itself is a real size and prints `\"0 B\"`.)\n */\nexport function formatFileSize(\n bytes: number | null | undefined,\n options: FileSizeOptions = {},\n): string {\n const fallback = options.fallback ?? \"—\";\n if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) {\n return fallback;\n }\n if (bytes < 1024) return `${Math.round(bytes)} B`;\n let value = bytes;\n let unit = 0;\n while (value >= 1024 && unit < SIZE_UNITS.length - 1) {\n value /= 1024;\n unit += 1;\n }\n const rendered = value >= 10 ? String(Math.round(value)) : value.toFixed(1);\n return `${rendered} ${SIZE_UNITS[unit]}`;\n}\n\n// ───────────────────────── people ─────────────────────────\n\nexport interface InitialsOptions {\n /** Returned when nothing usable is left. Default `\"?\"`. */\n fallback?: string | undefined;\n}\n\n/**\n * `\"Ana Rivera\"` → `\"AR\"`, `\"Ana\"` → `\"A\"`, `\"Ana Maria Rivera\"` → `\"AR\"`,\n * `\"ana@example.com\"` → `\"A\"`, `\" \"` → `\"?\"`.\n *\n * THE DISPLAY DECISION, made once: a multi-part name takes FIRST + LAST, not\n * first + second. `\"Ana Maria Rivera\"` is `AR`, because the family name is the\n * half a reader recognises. (Six matrx-frontend twins took first + second and\n * printed `AM`; the two package copies both took first + last, and both were\n * tested. First + last wins.)\n *\n * Pass the email as the value when there is no name — a single token yields\n * its first character, which is exactly what the host twins did by hand.\n */\nexport function getInitials(\n value: string | null | undefined,\n options: InitialsOptions = {},\n): string {\n const fallback = options.fallback ?? \"?\";\n if (typeof value !== \"string\") return fallback;\n const parts = value.trim().split(/\\s+/).filter((part) => part.length > 0);\n if (parts.length === 0) return fallback;\n const first = parts[0]?.charAt(0) ?? \"\";\n const last = parts.length > 1 ? (parts[parts.length - 1]?.charAt(0) ?? \"\") : \"\";\n return `${first}${last}`.toUpperCase() || fallback;\n}\n\n/**\n * A stable palette index for an avatar with no image. Deterministic on the\n * seed, so the same person is the same colour on every device and every\n * reload — a random colour per render is a surprisingly loud bug.\n */\nexport function avatarPaletteIndex(seed: string, buckets = 8): number {\n let hash = 0;\n for (let index = 0; index < seed.length; index += 1) {\n hash = (hash * 31 + seed.charCodeAt(index)) | 0;\n }\n return Math.abs(hash) % buckets;\n}\n"],"mappings":"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","names":[]}
|
package/dist/format.d.cts
CHANGED
|
@@ -50,6 +50,34 @@ type RelativeTimeStyle = "short" | "long" | "intl";
|
|
|
50
50
|
interface RelativeTimeOptions {
|
|
51
51
|
/** Default `"short"`. */
|
|
52
52
|
style?: RelativeTimeStyle | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* THE BARE CONTRACT. `false` drops the trailing " ago" — "2m", "3d",
|
|
55
|
+
* "2 minutes" — for a dense table cell whose COLUMN HEADER already says
|
|
56
|
+
* "Age" or "Last seen". Default `true`.
|
|
57
|
+
*
|
|
58
|
+
* WHY AN OPTION AND NOT A `"bare"` STYLE (decided 2026-09-11, after three
|
|
59
|
+
* surfaces — an exposure audit, a conversation sidebar and a batch Age
|
|
60
|
+
* column — each lost their deliberate bare stamp to a collapse). `short` /
|
|
61
|
+
* `long` / `intl` are three different VOICES; suffixed-vs-bare is a
|
|
62
|
+
* different axis entirely, and you can want a bare `long` ("2 minutes") as
|
|
63
|
+
* readily as a bare `short` ("2m"). Folding the axis into the style enum
|
|
64
|
+
* would force `"bare-short"` / `"bare-long"` and double the enum every time
|
|
65
|
+
* either axis grows. Orthogonal beats combinatorial.
|
|
66
|
+
*
|
|
67
|
+
* IT ALSO CARRIES THE DENSE-CONTEXT FALLBACK, on purpose. Past a year the
|
|
68
|
+
* suffixed voices fall back to `formatAbsoluteDate` — "Aug 7, 2025,
|
|
69
|
+
* 11:14 PM" — which is right in prose and ruinous in a narrow column. Asking
|
|
70
|
+
* for the bare form IS the statement "this is a dense cell", so the bare
|
|
71
|
+
* form's long-ago fallback is the short numeric date, "8/7/2025", which is
|
|
72
|
+
* what every hand-rolled twin printed there. One option, one context, two
|
|
73
|
+
* consistent consequences.
|
|
74
|
+
*
|
|
75
|
+
* `"intl"` IGNORES THIS, loudly documented rather than silently half-done:
|
|
76
|
+
* `Intl.RelativeTimeFormat` composes the whole string in the viewer's
|
|
77
|
+
* locale, and there is no correct locale-independent way to amputate its
|
|
78
|
+
* suffix. Ask for `"short"` or `"long"` when you need bare.
|
|
79
|
+
*/
|
|
80
|
+
suffix?: boolean | undefined;
|
|
53
81
|
/** Injected clock. Default `Date.now()` — pass it in tests. */
|
|
54
82
|
now?: number | undefined;
|
|
55
83
|
/** Returned for null / unparseable input. Default `"—"`. */
|
|
@@ -72,7 +100,7 @@ interface RelativeTimeOptions {
|
|
|
72
100
|
*/
|
|
73
101
|
declare function formatRelativeTime(value: TimestampInput, options?: RelativeTimeOptions): string;
|
|
74
102
|
/**
|
|
75
|
-
* The
|
|
103
|
+
* The four duration voices the fleet actually speaks. Picked once here so a
|
|
76
104
|
* call duration reads the same in the meeting, the history list and the
|
|
77
105
|
* notification.
|
|
78
106
|
*
|
|
@@ -81,19 +109,59 @@ declare function formatRelativeTime(value: TimestampInput, options?: RelativeTim
|
|
|
81
109
|
* - `"compact"` — `250ms`, `5.2s`, `5m 30s`, `1h 02m`. For elapsed work:
|
|
82
110
|
* job runs, request timings, step durations. Sub-second is honest about
|
|
83
111
|
* being sub-second rather than collapsing to `0:00`.
|
|
84
|
-
* - `"coarse"` — `45 min`, `1h 30m`. For
|
|
85
|
-
*
|
|
86
|
-
* seconds.
|
|
112
|
+
* - `"coarse"` — `45 min`, `1h 30m`, `3d 4h`. For durations read at a glance
|
|
113
|
+
* in a dense cell: podcast episodes, lesson lengths, estimates, uptime,
|
|
114
|
+
* sandbox time-remaining. Never prints seconds.
|
|
115
|
+
* - `"long"` — `45 minutes`, `3 hours`, `1 day`, `3 days`. THE PROSE VOICE:
|
|
116
|
+
* the one you put INSIDE a sentence a person reads. "Nothing has been
|
|
117
|
+
* delivered for 3 days." "8 save attempts have failed over the last
|
|
118
|
+
* 5 minutes." "Expires in 3 days."
|
|
119
|
+
*
|
|
120
|
+
* WHY `"long"` EXISTS (added 2026-09-11). THREE independent collapse lanes,
|
|
121
|
+
* which never spoke to each other, hit the same wall in one day: six files
|
|
122
|
+
* across matrx-frontend render a duration into English prose, and the package
|
|
123
|
+
* had no voice for it. `coarse` renders three days as `72h` and "45 minutes"
|
|
124
|
+
* as "45 min", both of which are wrong in a sentence — and a collapse that
|
|
125
|
+
* degrades a screen is not a win. The name is deliberately the SAME WORD as
|
|
126
|
+
* {@link RelativeTimeStyle}'s `"long"`, which already means "spell the unit
|
|
127
|
+
* out in full, correctly pluralised" ("2 minutes ago"). One module, one word,
|
|
128
|
+
* one meaning.
|
|
129
|
+
*
|
|
130
|
+
* WHY THERE IS NO FIFTH, ABBREVIATED-SPELLED-OUT VOICE. A lane proposed a
|
|
131
|
+
* third register — `45 sec` / `12 min` / `3 hr` / `2 days` — for countdown
|
|
132
|
+
* copy. It was refused: it is `"long"` with three words truncated, it gives
|
|
133
|
+
* the reader nothing `"long"` does not, and its only distinguishing feature
|
|
134
|
+
* ("hr", "sec") is jargon in exactly the sentences that wanted prose. The one
|
|
135
|
+
* caller that spoke it now speaks `"long"`. Two registers — dense (`coarse`)
|
|
136
|
+
* and prose (`"long"`) — cover every site the fleet has.
|
|
137
|
+
*
|
|
138
|
+
* WHY `"coarse"` GREW A DAY TIER rather than `"long"` absorbing those callers:
|
|
139
|
+
* `coarse` stopping at hours was simply a hole. It printed `77h` for a
|
|
140
|
+
* three-day container uptime, which no dense cell wants either; every site
|
|
141
|
+
* that changes is a site that was already reading badly.
|
|
87
142
|
*/
|
|
88
|
-
type DurationStyle = "clock" | "compact" | "coarse";
|
|
143
|
+
type DurationStyle = "clock" | "compact" | "coarse" | "long";
|
|
89
144
|
interface DurationOptions {
|
|
90
145
|
/** Default `"clock"`. */
|
|
91
146
|
style?: DurationStyle | undefined;
|
|
92
147
|
/**
|
|
93
148
|
* Returned for `null` / `undefined` / non-finite input. Default `"0:00"` for
|
|
94
|
-
* `"clock"` and `"—"` for `"compact"` / `"coarse"`.
|
|
149
|
+
* `"clock"` and `"—"` for `"compact"` / `"coarse"` / `"long"`.
|
|
95
150
|
*/
|
|
96
151
|
fallback?: string | undefined;
|
|
152
|
+
/**
|
|
153
|
+
* How a tier's leading number is derived from the remainder below it.
|
|
154
|
+
* `"nearest"` (default) is right for a MEASURED or ESTIMATED span — a
|
|
155
|
+
* 44-minute-31-second podcast reads "45 min". `"down"` is mandatory for a
|
|
156
|
+
* COUNTDOWN: with 5 minutes 30 seconds left, "6 min" promises the user half
|
|
157
|
+
* a minute they do not have. (Real regression, sandbox time-remaining,
|
|
158
|
+
* 2026-09-11.)
|
|
159
|
+
*
|
|
160
|
+
* `"clock"` and `"long"` are ALREADY `"down"` by construction and ignore
|
|
161
|
+
* this — a clock never rounds `0:59` up to `1:00`, and prose "for 3 hours"
|
|
162
|
+
* asserts at-least-three-hours. It changes `"coarse"` and `"compact"`.
|
|
163
|
+
*/
|
|
164
|
+
round?: "nearest" | "down" | undefined;
|
|
97
165
|
/**
|
|
98
166
|
* Keep the sign on a negative duration (`-1:23`) instead of clamping to
|
|
99
167
|
* zero. Default `false` — a negative elapsed time is nearly always a clock
|
package/dist/format.d.ts
CHANGED
|
@@ -50,6 +50,34 @@ type RelativeTimeStyle = "short" | "long" | "intl";
|
|
|
50
50
|
interface RelativeTimeOptions {
|
|
51
51
|
/** Default `"short"`. */
|
|
52
52
|
style?: RelativeTimeStyle | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* THE BARE CONTRACT. `false` drops the trailing " ago" — "2m", "3d",
|
|
55
|
+
* "2 minutes" — for a dense table cell whose COLUMN HEADER already says
|
|
56
|
+
* "Age" or "Last seen". Default `true`.
|
|
57
|
+
*
|
|
58
|
+
* WHY AN OPTION AND NOT A `"bare"` STYLE (decided 2026-09-11, after three
|
|
59
|
+
* surfaces — an exposure audit, a conversation sidebar and a batch Age
|
|
60
|
+
* column — each lost their deliberate bare stamp to a collapse). `short` /
|
|
61
|
+
* `long` / `intl` are three different VOICES; suffixed-vs-bare is a
|
|
62
|
+
* different axis entirely, and you can want a bare `long` ("2 minutes") as
|
|
63
|
+
* readily as a bare `short` ("2m"). Folding the axis into the style enum
|
|
64
|
+
* would force `"bare-short"` / `"bare-long"` and double the enum every time
|
|
65
|
+
* either axis grows. Orthogonal beats combinatorial.
|
|
66
|
+
*
|
|
67
|
+
* IT ALSO CARRIES THE DENSE-CONTEXT FALLBACK, on purpose. Past a year the
|
|
68
|
+
* suffixed voices fall back to `formatAbsoluteDate` — "Aug 7, 2025,
|
|
69
|
+
* 11:14 PM" — which is right in prose and ruinous in a narrow column. Asking
|
|
70
|
+
* for the bare form IS the statement "this is a dense cell", so the bare
|
|
71
|
+
* form's long-ago fallback is the short numeric date, "8/7/2025", which is
|
|
72
|
+
* what every hand-rolled twin printed there. One option, one context, two
|
|
73
|
+
* consistent consequences.
|
|
74
|
+
*
|
|
75
|
+
* `"intl"` IGNORES THIS, loudly documented rather than silently half-done:
|
|
76
|
+
* `Intl.RelativeTimeFormat` composes the whole string in the viewer's
|
|
77
|
+
* locale, and there is no correct locale-independent way to amputate its
|
|
78
|
+
* suffix. Ask for `"short"` or `"long"` when you need bare.
|
|
79
|
+
*/
|
|
80
|
+
suffix?: boolean | undefined;
|
|
53
81
|
/** Injected clock. Default `Date.now()` — pass it in tests. */
|
|
54
82
|
now?: number | undefined;
|
|
55
83
|
/** Returned for null / unparseable input. Default `"—"`. */
|
|
@@ -72,7 +100,7 @@ interface RelativeTimeOptions {
|
|
|
72
100
|
*/
|
|
73
101
|
declare function formatRelativeTime(value: TimestampInput, options?: RelativeTimeOptions): string;
|
|
74
102
|
/**
|
|
75
|
-
* The
|
|
103
|
+
* The four duration voices the fleet actually speaks. Picked once here so a
|
|
76
104
|
* call duration reads the same in the meeting, the history list and the
|
|
77
105
|
* notification.
|
|
78
106
|
*
|
|
@@ -81,19 +109,59 @@ declare function formatRelativeTime(value: TimestampInput, options?: RelativeTim
|
|
|
81
109
|
* - `"compact"` — `250ms`, `5.2s`, `5m 30s`, `1h 02m`. For elapsed work:
|
|
82
110
|
* job runs, request timings, step durations. Sub-second is honest about
|
|
83
111
|
* being sub-second rather than collapsing to `0:00`.
|
|
84
|
-
* - `"coarse"` — `45 min`, `1h 30m`. For
|
|
85
|
-
*
|
|
86
|
-
* seconds.
|
|
112
|
+
* - `"coarse"` — `45 min`, `1h 30m`, `3d 4h`. For durations read at a glance
|
|
113
|
+
* in a dense cell: podcast episodes, lesson lengths, estimates, uptime,
|
|
114
|
+
* sandbox time-remaining. Never prints seconds.
|
|
115
|
+
* - `"long"` — `45 minutes`, `3 hours`, `1 day`, `3 days`. THE PROSE VOICE:
|
|
116
|
+
* the one you put INSIDE a sentence a person reads. "Nothing has been
|
|
117
|
+
* delivered for 3 days." "8 save attempts have failed over the last
|
|
118
|
+
* 5 minutes." "Expires in 3 days."
|
|
119
|
+
*
|
|
120
|
+
* WHY `"long"` EXISTS (added 2026-09-11). THREE independent collapse lanes,
|
|
121
|
+
* which never spoke to each other, hit the same wall in one day: six files
|
|
122
|
+
* across matrx-frontend render a duration into English prose, and the package
|
|
123
|
+
* had no voice for it. `coarse` renders three days as `72h` and "45 minutes"
|
|
124
|
+
* as "45 min", both of which are wrong in a sentence — and a collapse that
|
|
125
|
+
* degrades a screen is not a win. The name is deliberately the SAME WORD as
|
|
126
|
+
* {@link RelativeTimeStyle}'s `"long"`, which already means "spell the unit
|
|
127
|
+
* out in full, correctly pluralised" ("2 minutes ago"). One module, one word,
|
|
128
|
+
* one meaning.
|
|
129
|
+
*
|
|
130
|
+
* WHY THERE IS NO FIFTH, ABBREVIATED-SPELLED-OUT VOICE. A lane proposed a
|
|
131
|
+
* third register — `45 sec` / `12 min` / `3 hr` / `2 days` — for countdown
|
|
132
|
+
* copy. It was refused: it is `"long"` with three words truncated, it gives
|
|
133
|
+
* the reader nothing `"long"` does not, and its only distinguishing feature
|
|
134
|
+
* ("hr", "sec") is jargon in exactly the sentences that wanted prose. The one
|
|
135
|
+
* caller that spoke it now speaks `"long"`. Two registers — dense (`coarse`)
|
|
136
|
+
* and prose (`"long"`) — cover every site the fleet has.
|
|
137
|
+
*
|
|
138
|
+
* WHY `"coarse"` GREW A DAY TIER rather than `"long"` absorbing those callers:
|
|
139
|
+
* `coarse` stopping at hours was simply a hole. It printed `77h` for a
|
|
140
|
+
* three-day container uptime, which no dense cell wants either; every site
|
|
141
|
+
* that changes is a site that was already reading badly.
|
|
87
142
|
*/
|
|
88
|
-
type DurationStyle = "clock" | "compact" | "coarse";
|
|
143
|
+
type DurationStyle = "clock" | "compact" | "coarse" | "long";
|
|
89
144
|
interface DurationOptions {
|
|
90
145
|
/** Default `"clock"`. */
|
|
91
146
|
style?: DurationStyle | undefined;
|
|
92
147
|
/**
|
|
93
148
|
* Returned for `null` / `undefined` / non-finite input. Default `"0:00"` for
|
|
94
|
-
* `"clock"` and `"—"` for `"compact"` / `"coarse"`.
|
|
149
|
+
* `"clock"` and `"—"` for `"compact"` / `"coarse"` / `"long"`.
|
|
95
150
|
*/
|
|
96
151
|
fallback?: string | undefined;
|
|
152
|
+
/**
|
|
153
|
+
* How a tier's leading number is derived from the remainder below it.
|
|
154
|
+
* `"nearest"` (default) is right for a MEASURED or ESTIMATED span — a
|
|
155
|
+
* 44-minute-31-second podcast reads "45 min". `"down"` is mandatory for a
|
|
156
|
+
* COUNTDOWN: with 5 minutes 30 seconds left, "6 min" promises the user half
|
|
157
|
+
* a minute they do not have. (Real regression, sandbox time-remaining,
|
|
158
|
+
* 2026-09-11.)
|
|
159
|
+
*
|
|
160
|
+
* `"clock"` and `"long"` are ALREADY `"down"` by construction and ignore
|
|
161
|
+
* this — a clock never rounds `0:59` up to `1:00`, and prose "for 3 hours"
|
|
162
|
+
* asserts at-least-three-hours. It changes `"coarse"` and `"compact"`.
|
|
163
|
+
*/
|
|
164
|
+
round?: "nearest" | "down" | undefined;
|
|
97
165
|
/**
|
|
98
166
|
* Keep the sign on a negative duration (`-1:23`) instead of clamping to
|
|
99
167
|
* zero. Default `false` — a negative elapsed time is nearly always a clock
|
package/dist/format.js
CHANGED
|
@@ -57,6 +57,7 @@ var INTL_UNITS = [
|
|
|
57
57
|
];
|
|
58
58
|
function formatRelativeTime(value, options = {}) {
|
|
59
59
|
const style = options.style ?? "short";
|
|
60
|
+
const bare = options.suffix === false;
|
|
60
61
|
const now = options.now ?? Date.now();
|
|
61
62
|
const parsed = parseTimestamp(value);
|
|
62
63
|
if (!parsed) {
|
|
@@ -75,13 +76,14 @@ function formatRelativeTime(value, options = {}) {
|
|
|
75
76
|
}
|
|
76
77
|
const elapsed = now - parsed.getTime();
|
|
77
78
|
if (elapsed < 0) return "just now";
|
|
79
|
+
const ago = bare ? "" : " ago";
|
|
78
80
|
for (const unit of RELATIVE_UNITS) {
|
|
79
81
|
if (elapsed < unit.limit) {
|
|
80
82
|
const count = Math.max(1, Math.floor(elapsed / unit.divisor));
|
|
81
|
-
return style === "long" ? `${count} ${unit.long}${count === 1 ? "" : "s"}
|
|
83
|
+
return style === "long" ? `${count} ${unit.long}${count === 1 ? "" : "s"}${ago}` : `${count}${unit.short}${ago}`;
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
|
-
return formatAbsoluteDate(parsed);
|
|
86
|
+
return bare ? parsed.toLocaleDateString() : formatAbsoluteDate(parsed);
|
|
85
87
|
}
|
|
86
88
|
var pad2 = (value) => value.toString().padStart(2, "0");
|
|
87
89
|
function clockBody(totalSeconds) {
|
|
@@ -90,25 +92,41 @@ function clockBody(totalSeconds) {
|
|
|
90
92
|
const hours = Math.floor(totalSeconds / 3600);
|
|
91
93
|
return hours > 0 ? `${hours}:${pad2(minutes)}:${pad2(seconds)}` : `${minutes}:${pad2(seconds)}`;
|
|
92
94
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
var reduce = (value, mode) => mode === "down" ? Math.floor(value) : Math.round(value);
|
|
96
|
+
function compactBody(ms, round) {
|
|
97
|
+
if (ms < 1e3) return `${reduce(ms, round)}ms`;
|
|
95
98
|
const totalSeconds = ms / 1e3;
|
|
96
99
|
if (totalSeconds < 60) {
|
|
97
|
-
|
|
100
|
+
if (totalSeconds >= 10) return `${reduce(totalSeconds, round)}s`;
|
|
101
|
+
const tenths = round === "down" ? Math.floor(totalSeconds * 10) / 10 : Math.round(totalSeconds * 10) / 10;
|
|
102
|
+
return `${Number.isInteger(tenths) ? tenths : tenths.toFixed(1)}s`;
|
|
98
103
|
}
|
|
99
|
-
const whole =
|
|
104
|
+
const whole = reduce(totalSeconds, round);
|
|
100
105
|
const minutes = Math.floor(whole / 60);
|
|
101
106
|
if (minutes < 60) return `${minutes}m ${pad2(whole % 60)}s`;
|
|
102
107
|
const hours = Math.floor(minutes / 60);
|
|
103
108
|
return `${hours}h ${pad2(minutes % 60)}m`;
|
|
104
109
|
}
|
|
105
|
-
function coarseBody(ms) {
|
|
106
|
-
const minutes =
|
|
110
|
+
function coarseBody(ms, round) {
|
|
111
|
+
const minutes = reduce(ms / 6e4, round);
|
|
107
112
|
if (minutes < 1) return "< 1 min";
|
|
108
113
|
if (minutes < 60) return `${minutes} min`;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
if (minutes < 1440) {
|
|
115
|
+
const hours = Math.floor(minutes / 60);
|
|
116
|
+
const rest = minutes % 60;
|
|
117
|
+
return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
|
|
118
|
+
}
|
|
119
|
+
const days = Math.floor(minutes / 1440);
|
|
120
|
+
const restHours = Math.floor(minutes % 1440 / 60);
|
|
121
|
+
return restHours > 0 ? `${days}d ${restHours}h` : `${days}d`;
|
|
122
|
+
}
|
|
123
|
+
var plural = (count, unit) => `${count} ${unit}${count === 1 ? "" : "s"}`;
|
|
124
|
+
function longBody(ms) {
|
|
125
|
+
if (ms < 1e3) return "less than a second";
|
|
126
|
+
if (ms < 6e4) return plural(Math.floor(ms / 1e3), "second");
|
|
127
|
+
if (ms < 36e5) return plural(Math.floor(ms / 6e4), "minute");
|
|
128
|
+
if (ms < 864e5) return plural(Math.floor(ms / 36e5), "hour");
|
|
129
|
+
return plural(Math.floor(ms / 864e5), "day");
|
|
112
130
|
}
|
|
113
131
|
function formatDurationMs(ms, options = {}) {
|
|
114
132
|
const style = options.style ?? "clock";
|
|
@@ -119,7 +137,8 @@ function formatDurationMs(ms, options = {}) {
|
|
|
119
137
|
return style === "clock" ? clockBody(0) : fallback;
|
|
120
138
|
}
|
|
121
139
|
const magnitude = Math.abs(ms);
|
|
122
|
-
const
|
|
140
|
+
const round = options.round ?? "nearest";
|
|
141
|
+
const body = style === "clock" ? clockBody(Math.floor(magnitude / 1e3)) : style === "compact" ? compactBody(magnitude, round) : style === "coarse" ? coarseBody(magnitude, round) : longBody(magnitude);
|
|
123
142
|
return negative ? `-${body}` : body;
|
|
124
143
|
}
|
|
125
144
|
function formatDurationSeconds(seconds, options = {}) {
|
package/dist/format.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/format.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/format — THE one home for the tiny display formatters that\n * every Matrx surface, in every repo, needs and that nothing owns.\n *\n * WHY THIS FILE EXISTS (the H1 finding, 2026-09-07 duplication census). These\n * formatters were duplicated *between packages*: `@ai-matrx/meet` and\n * `@ai-matrx/messaging` each defined `formatDuration` + `getInitials`,\n * `@ai-matrx/associations` and `@ai-matrx/diff` each defined\n * `formatRelativeTime`, and `@ai-matrx/media` hid `formatFileSize` inside a\n * React upload component. Because the fleet's packages disagreed about who\n * owned them, ~65 host twins across matrx-frontend / matrx-extend /\n * matrx-local / matrx-games / aidream's two apps had *no correct package to\n * point at* — a settings page cannot take a dependency on the video-calling\n * package to print a duration.\n *\n * WHY KIT. `@ai-matrx/kit` has ZERO sibling dependencies (verified with\n * `node scripts/check_ts_sibling_graph.mjs --order`), so it sits at the very\n * bottom of the sibling DAG: meet, messaging, associations, diff, media and\n * print can all depend on it without creating a cycle, and so can every host.\n * It is also, by charter, the \"little primitives every Matrx app speaks\"\n * package. This is not a new package — new packages need Arman's approval.\n *\n * THE UNIT LAW. A duration formatter that takes a bare `number` is a bug\n * waiting to happen: the fleet's twins variously took milliseconds, seconds\n * and minutes, and two of them were wrong by 1000×-worth of confusion. There\n * is deliberately NO `formatDuration` here. The unit is in the name:\n * `formatDurationMs`, `formatDurationSeconds`, `formatDurationMinutes`.\n *\n * Everything in this module is pure, synchronous, and free of React, DOM and\n * `Intl` implicit-locale surprises except where a style explicitly asks for\n * `Intl.RelativeTimeFormat`. Every time-dependent function takes an explicit\n * `now` so tests never depend on the clock.\n */\n\n// ───────────────────────── timestamps ─────────────────────────\n\nexport type TimestampInput = string | number | Date | null | undefined;\n\n/** True when a string already carries a timezone designator (Z or ±offset). */\nfunction hasTimezoneDesignator(value: string): boolean {\n if (/[zZ]$/.test(value)) return true;\n if (/[+-]\\d{2}(:?\\d{2})?$/.test(value)) return true;\n if (/\\b(GMT|UTC)\\b/i.test(value)) return true;\n return false;\n}\n\n/**\n * Normalise a raw timestamp string so `new Date()` parses it as an absolute\n * instant.\n *\n * THE CORRECTION THIS CARRIES (from `@ai-matrx/diff`, the richest of the\n * twins). Postgres serialises `timestamp with time zone` as\n * \"2026-06-13T16:32:26+00:00\" (parses correctly) and `timestamp without time\n * zone` as \"2026-06-13T16:32:26\" — which, per the ES spec, `new Date()` parses\n * as LOCAL time. Our backend writes those columns in UTC, so a bare\n * `new Date(...)` is wrong by the viewer's offset: the classic \"times are off\n * by N hours\" report. A zone-less string that HAS a time component is treated\n * as UTC; date-only strings are left untouched (a calendar day is not a\n * timestamp).\n */\nfunction normalizeTimestampString(raw: string): string {\n const value = raw.trim();\n if (!value) return value;\n if (!/\\d{1,2}:\\d{2}/.test(value)) return value;\n if (hasTimezoneDesignator(value)) return value;\n return `${value.replace(\" \", \"T\")}Z`;\n}\n\n/** Parse any backend timestamp into a Date, or `null` when unparseable. */\nexport function parseTimestamp(value: TimestampInput): Date | null {\n if (value === null || value === undefined) return null;\n if (value instanceof Date) {\n return Number.isNaN(value.getTime()) ? null : value;\n }\n if (typeof value === \"number\") {\n const fromNumber = new Date(value);\n return Number.isNaN(fromNumber.getTime()) ? null : fromNumber;\n }\n if (typeof value !== \"string\") return null;\n const normalized = normalizeTimestampString(value);\n if (!normalized) return null;\n const parsed = new Date(normalized);\n return Number.isNaN(parsed.getTime()) ? null : parsed;\n}\n\nconst DEFAULT_ABSOLUTE_OPTIONS: Intl.DateTimeFormatOptions = {\n year: \"numeric\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n};\n\n/** Absolute local date+time, e.g. \"Jun 13, 2026, 9:32 AM\". */\nexport function formatAbsoluteDate(\n value: TimestampInput,\n options: Intl.DateTimeFormatOptions = DEFAULT_ABSOLUTE_OPTIONS,\n fallback = \"—\",\n): string {\n const parsed = parseTimestamp(value);\n if (!parsed) return fallback;\n return parsed.toLocaleString(undefined, options);\n}\n\n// ───────────────────────── relative time ─────────────────────────\n\nconst RELATIVE_UNITS: readonly {\n limit: number;\n divisor: number;\n short: string;\n long: string;\n}[] = [\n { limit: 60_000, divisor: 1000, short: \"s\", long: \"second\" },\n { limit: 3_600_000, divisor: 60_000, short: \"m\", long: \"minute\" },\n { limit: 86_400_000, divisor: 3_600_000, short: \"h\", long: \"hour\" },\n { limit: 604_800_000, divisor: 86_400_000, short: \"d\", long: \"day\" },\n { limit: 2_592_000_000, divisor: 604_800_000, short: \"w\", long: \"week\" },\n { limit: 31_536_000_000, divisor: 2_592_000_000, short: \"mo\", long: \"month\" },\n];\n\nconst INTL_UNITS: readonly { unit: Intl.RelativeTimeFormatUnit; ms: number }[] = [\n { unit: \"year\", ms: 365 * 24 * 60 * 60 * 1000 },\n { unit: \"month\", ms: 30 * 24 * 60 * 60 * 1000 },\n { unit: \"week\", ms: 7 * 24 * 60 * 60 * 1000 },\n { unit: \"day\", ms: 24 * 60 * 60 * 1000 },\n { unit: \"hour\", ms: 60 * 60 * 1000 },\n { unit: \"minute\", ms: 60 * 1000 },\n];\n\n/**\n * The three relative-time voices the fleet actually speaks. They are display\n * decisions, not implementations — one body serves all three.\n *\n * - `\"short\"` — \"2m ago\", \"3d ago\". The dense default (from `@ai-matrx/diff`).\n * - `\"long\"` — \"2 minutes ago\", \"3 days ago\".\n * - `\"intl\"` — `Intl.RelativeTimeFormat` with `numeric: \"auto\"`, so the\n * viewer's locale conventions apply and \"yesterday\" reads as \"yesterday\"\n * (from `@ai-matrx/associations`). Sub-minute reads \"just now\".\n */\nexport type RelativeTimeStyle = \"short\" | \"long\" | \"intl\";\n\nexport interface RelativeTimeOptions {\n /** Default `\"short\"`. */\n style?: RelativeTimeStyle | undefined;\n /** Injected clock. Default `Date.now()` — pass it in tests. */\n now?: number | undefined;\n /** Returned for null / unparseable input. Default `\"—\"`. */\n fallback?: string | undefined;\n /**\n * Echo the raw input instead of `fallback` when it cannot be parsed —\n * degraded, never broken. What the comments face wants: an odd server string\n * is more useful on screen than an em-dash that hides it.\n */\n fallbackToInput?: boolean | undefined;\n}\n\n/**\n * \"2m ago\" / \"2 minutes ago\" / a locale-aware \"2 minutes ago\", falling back to\n * an absolute local date past a year. Timezone-agnostic by construction (a\n * pure epoch difference over `parseTimestamp`'s corrected instant).\n *\n * Future timestamps read \"just now\" in `\"short\"`/`\"long\"` (a clock skew of a\n * few seconds must not print \"in 3 seconds\"); `\"intl\"` formats them properly\n * (\"in 5 minutes\") because that is the whole point of asking for `Intl`.\n */\nexport function formatRelativeTime(\n value: TimestampInput,\n options: RelativeTimeOptions = {},\n): string {\n const style = options.style ?? \"short\";\n const now = options.now ?? Date.now();\n const parsed = parseTimestamp(value);\n if (!parsed) {\n if (options.fallbackToInput === true && typeof value === \"string\") return value;\n return options.fallback ?? \"—\";\n }\n\n if (style === \"intl\") {\n const delta = parsed.getTime() - now;\n const magnitude = Math.abs(delta);\n if (magnitude < 60_000) return \"just now\";\n const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: \"auto\" });\n for (const { unit, ms } of INTL_UNITS) {\n if (magnitude >= ms) return rtf.format(Math.trunc(delta / ms), unit);\n }\n return \"just now\";\n }\n\n const elapsed = now - parsed.getTime();\n if (elapsed < 0) return \"just now\";\n for (const unit of RELATIVE_UNITS) {\n if (elapsed < unit.limit) {\n const count = Math.max(1, Math.floor(elapsed / unit.divisor));\n return style === \"long\"\n ? `${count} ${unit.long}${count === 1 ? \"\" : \"s\"} ago`\n : `${count}${unit.short} ago`;\n }\n }\n return formatAbsoluteDate(parsed);\n}\n\n// ───────────────────────── durations ─────────────────────────\n\n/**\n * The three duration voices the fleet actually speaks. Picked once here so a\n * call duration reads the same in the meeting, the history list and the\n * notification.\n *\n * - `\"clock\"` (default) — `0:00`, `9:04`, `1:02:33`. For anything a human\n * watches tick: recordings, calls, media players, timers.\n * - `\"compact\"` — `250ms`, `5.2s`, `5m 30s`, `1h 02m`. For elapsed work:\n * job runs, request timings, step durations. Sub-second is honest about\n * being sub-second rather than collapsing to `0:00`.\n * - `\"coarse\"` — `45 min`, `1h 30m`. For things measured in minutes and read\n * at a glance: podcast episodes, lesson lengths, estimates. Never prints\n * seconds.\n */\nexport type DurationStyle = \"clock\" | \"compact\" | \"coarse\";\n\nexport interface DurationOptions {\n /** Default `\"clock\"`. */\n style?: DurationStyle | undefined;\n /**\n * Returned for `null` / `undefined` / non-finite input. Default `\"0:00\"` for\n * `\"clock\"` and `\"—\"` for `\"compact\"` / `\"coarse\"`.\n */\n fallback?: string | undefined;\n /**\n * Keep the sign on a negative duration (`-1:23`) instead of clamping to\n * zero. Default `false` — a negative elapsed time is nearly always a clock\n * bug, and `0:00` is the honest reading. Opt in where a signed offset is the\n * actual quantity (a field format for a stored `interval`, say).\n */\n signed?: boolean | undefined;\n}\n\nconst pad2 = (value: number): string => value.toString().padStart(2, \"0\");\n\nfunction clockBody(totalSeconds: number): string {\n const seconds = totalSeconds % 60;\n const minutes = Math.floor(totalSeconds / 60) % 60;\n const hours = Math.floor(totalSeconds / 3600);\n return hours > 0\n ? `${hours}:${pad2(minutes)}:${pad2(seconds)}`\n : `${minutes}:${pad2(seconds)}`;\n}\n\nfunction compactBody(ms: number): string {\n if (ms < 1000) return `${Math.round(ms)}ms`;\n const totalSeconds = ms / 1000;\n if (totalSeconds < 60) {\n // One decimal under 10s (0.1s is a visible difference at that scale),\n // whole seconds above it (nobody reads \"43.7s\" as more precise than \"44s\").\n return totalSeconds < 10\n ? `${totalSeconds.toFixed(1)}s`\n : `${Math.round(totalSeconds)}s`;\n }\n const whole = Math.round(totalSeconds);\n const minutes = Math.floor(whole / 60);\n if (minutes < 60) return `${minutes}m ${pad2(whole % 60)}s`;\n const hours = Math.floor(minutes / 60);\n return `${hours}h ${pad2(minutes % 60)}m`;\n}\n\nfunction coarseBody(ms: number): string {\n const minutes = Math.round(ms / 60_000);\n if (minutes < 1) return \"< 1 min\";\n if (minutes < 60) return `${minutes} min`;\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;\n}\n\n/**\n * THE canonical duration formatter. Milliseconds in, a chosen voice out.\n *\n * `null`, `undefined`, `NaN` and `Infinity` all take the fallback — never\n * `NaN:NaN`, which is what a null start time produces, and never a confident\n * `0:00` for \"we do not know\".\n */\nexport function formatDurationMs(\n ms: number | null | undefined,\n options: DurationOptions = {},\n): string {\n const style = options.style ?? \"clock\";\n const fallback = options.fallback ?? (style === \"clock\" ? \"0:00\" : \"—\");\n if (ms === null || ms === undefined || !Number.isFinite(ms)) return fallback;\n\n const negative = ms < 0;\n if (negative && options.signed !== true) {\n return style === \"clock\" ? clockBody(0) : fallback;\n }\n const magnitude = Math.abs(ms);\n const body =\n style === \"clock\"\n ? clockBody(Math.floor(magnitude / 1000))\n : style === \"compact\"\n ? compactBody(magnitude)\n : coarseBody(magnitude);\n return negative ? `-${body}` : body;\n}\n\n/** Seconds in. See {@link formatDurationMs}. */\nexport function formatDurationSeconds(\n seconds: number | null | undefined,\n options: DurationOptions = {},\n): string {\n if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) {\n return formatDurationMs(seconds as number | null | undefined, options);\n }\n return formatDurationMs(seconds * 1000, options);\n}\n\n/** Minutes in. See {@link formatDurationMs}. */\nexport function formatDurationMinutes(\n minutes: number | null | undefined,\n options: DurationOptions = {},\n): string {\n if (minutes === null || minutes === undefined || !Number.isFinite(minutes)) {\n return formatDurationMs(minutes as number | null | undefined, options);\n }\n return formatDurationMs(minutes * 60_000, options);\n}\n\n/**\n * Duration between two timestamps, in milliseconds — `null` when the start is\n * missing or either end is unparseable. An absent `to` means \"still running\",\n * so it measures to `now`. Pair with {@link formatDurationMs}.\n */\nexport function durationMsBetween(\n from: TimestampInput,\n to: TimestampInput,\n now: number = Date.now(),\n): number | null {\n const start = parseTimestamp(from);\n if (!start) return null;\n if (to === null || to === undefined) return Math.max(0, now - start.getTime());\n const end = parseTimestamp(to);\n if (!end) return null;\n return Math.max(0, end.getTime() - start.getTime());\n}\n\n// ───────────────────────── byte sizes ─────────────────────────\n\nconst SIZE_UNITS = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\"] as const;\n\nexport interface FileSizeOptions {\n /** Returned for `null` / `undefined` / non-finite / negative. Default `\"—\"`. */\n fallback?: string | undefined;\n}\n\n/**\n * `512` → `\"512 B\"`, `2048` → `\"2.0 KB\"`, `15360` → `\"15 KB\"`.\n *\n * THE DISPLAY DECISIONS, made once (unified 2026-09-07 from six host twins and\n * `@ai-matrx/media`'s copy, which was the richest):\n *\n * 1. Binary units (1024), labelled with the short SI-ish names the whole fleet\n * already used — `B`/`KB`/`MB`/`GB`/`TB`, never `\"Bytes\"`.\n * 2. One decimal below 10 in a unit (`2.0 KB`, `9.9 MB`), whole numbers at or\n * above it (`15 KB`, `340 MB`) — three significant figures is the most a\n * file size is ever worth, and `1.5 GB` versus `1536.0 MB` is the whole\n * point of the unit.\n * 3. Whole bytes below 1 KB — `\"512 B\"`, never `\"512.0 B\"`.\n * 4. `null` / `undefined` / `NaN` / `Infinity` / negative all collapse to an\n * em-dash, NOT to `\"0 B\"`. A corrupt or mid-upload `size` row rendering as\n * a confident, wrong `\"0 B\"` is a screen telling a lie; \"size unknown\" is\n * the honest reading. (`0` itself is a real size and prints `\"0 B\"`.)\n */\nexport function formatFileSize(\n bytes: number | null | undefined,\n options: FileSizeOptions = {},\n): string {\n const fallback = options.fallback ?? \"—\";\n if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) {\n return fallback;\n }\n if (bytes < 1024) return `${Math.round(bytes)} B`;\n let value = bytes;\n let unit = 0;\n while (value >= 1024 && unit < SIZE_UNITS.length - 1) {\n value /= 1024;\n unit += 1;\n }\n const rendered = value >= 10 ? String(Math.round(value)) : value.toFixed(1);\n return `${rendered} ${SIZE_UNITS[unit]}`;\n}\n\n// ───────────────────────── people ─────────────────────────\n\nexport interface InitialsOptions {\n /** Returned when nothing usable is left. Default `\"?\"`. */\n fallback?: string | undefined;\n}\n\n/**\n * `\"Ana Rivera\"` → `\"AR\"`, `\"Ana\"` → `\"A\"`, `\"Ana Maria Rivera\"` → `\"AR\"`,\n * `\"ana@example.com\"` → `\"A\"`, `\" \"` → `\"?\"`.\n *\n * THE DISPLAY DECISION, made once: a multi-part name takes FIRST + LAST, not\n * first + second. `\"Ana Maria Rivera\"` is `AR`, because the family name is the\n * half a reader recognises. (Six matrx-frontend twins took first + second and\n * printed `AM`; the two package copies both took first + last, and both were\n * tested. First + last wins.)\n *\n * Pass the email as the value when there is no name — a single token yields\n * its first character, which is exactly what the host twins did by hand.\n */\nexport function getInitials(\n value: string | null | undefined,\n options: InitialsOptions = {},\n): string {\n const fallback = options.fallback ?? \"?\";\n if (typeof value !== \"string\") return fallback;\n const parts = value.trim().split(/\\s+/).filter((part) => part.length > 0);\n if (parts.length === 0) return fallback;\n const first = parts[0]?.charAt(0) ?? \"\";\n const last = parts.length > 1 ? (parts[parts.length - 1]?.charAt(0) ?? \"\") : \"\";\n return `${first}${last}`.toUpperCase() || fallback;\n}\n\n/**\n * A stable palette index for an avatar with no image. Deterministic on the\n * seed, so the same person is the same colour on every device and every\n * reload — a random colour per render is a surprisingly loud bug.\n */\nexport function avatarPaletteIndex(seed: string, buckets = 8): number {\n let hash = 0;\n for (let index = 0; index < seed.length; index += 1) {\n hash = (hash * 31 + seed.charCodeAt(index)) | 0;\n }\n return Math.abs(hash) % buckets;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/format.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/format — THE one home for the tiny display formatters that\n * every Matrx surface, in every repo, needs and that nothing owns.\n *\n * WHY THIS FILE EXISTS (the H1 finding, 2026-09-07 duplication census). These\n * formatters were duplicated *between packages*: `@ai-matrx/meet` and\n * `@ai-matrx/messaging` each defined `formatDuration` + `getInitials`,\n * `@ai-matrx/associations` and `@ai-matrx/diff` each defined\n * `formatRelativeTime`, and `@ai-matrx/media` hid `formatFileSize` inside a\n * React upload component. Because the fleet's packages disagreed about who\n * owned them, ~65 host twins across matrx-frontend / matrx-extend /\n * matrx-local / matrx-games / aidream's two apps had *no correct package to\n * point at* — a settings page cannot take a dependency on the video-calling\n * package to print a duration.\n *\n * WHY KIT. `@ai-matrx/kit` has ZERO sibling dependencies (verified with\n * `node scripts/check_ts_sibling_graph.mjs --order`), so it sits at the very\n * bottom of the sibling DAG: meet, messaging, associations, diff, media and\n * print can all depend on it without creating a cycle, and so can every host.\n * It is also, by charter, the \"little primitives every Matrx app speaks\"\n * package. This is not a new package — new packages need Arman's approval.\n *\n * THE UNIT LAW. A duration formatter that takes a bare `number` is a bug\n * waiting to happen: the fleet's twins variously took milliseconds, seconds\n * and minutes, and two of them were wrong by 1000×-worth of confusion. There\n * is deliberately NO `formatDuration` here. The unit is in the name:\n * `formatDurationMs`, `formatDurationSeconds`, `formatDurationMinutes`.\n *\n * Everything in this module is pure, synchronous, and free of React, DOM and\n * `Intl` implicit-locale surprises except where a style explicitly asks for\n * `Intl.RelativeTimeFormat`. Every time-dependent function takes an explicit\n * `now` so tests never depend on the clock.\n */\n\n// ───────────────────────── timestamps ─────────────────────────\n\nexport type TimestampInput = string | number | Date | null | undefined;\n\n/** True when a string already carries a timezone designator (Z or ±offset). */\nfunction hasTimezoneDesignator(value: string): boolean {\n if (/[zZ]$/.test(value)) return true;\n if (/[+-]\\d{2}(:?\\d{2})?$/.test(value)) return true;\n if (/\\b(GMT|UTC)\\b/i.test(value)) return true;\n return false;\n}\n\n/**\n * Normalise a raw timestamp string so `new Date()` parses it as an absolute\n * instant.\n *\n * THE CORRECTION THIS CARRIES (from `@ai-matrx/diff`, the richest of the\n * twins). Postgres serialises `timestamp with time zone` as\n * \"2026-06-13T16:32:26+00:00\" (parses correctly) and `timestamp without time\n * zone` as \"2026-06-13T16:32:26\" — which, per the ES spec, `new Date()` parses\n * as LOCAL time. Our backend writes those columns in UTC, so a bare\n * `new Date(...)` is wrong by the viewer's offset: the classic \"times are off\n * by N hours\" report. A zone-less string that HAS a time component is treated\n * as UTC; date-only strings are left untouched (a calendar day is not a\n * timestamp).\n */\nfunction normalizeTimestampString(raw: string): string {\n const value = raw.trim();\n if (!value) return value;\n if (!/\\d{1,2}:\\d{2}/.test(value)) return value;\n if (hasTimezoneDesignator(value)) return value;\n return `${value.replace(\" \", \"T\")}Z`;\n}\n\n/** Parse any backend timestamp into a Date, or `null` when unparseable. */\nexport function parseTimestamp(value: TimestampInput): Date | null {\n if (value === null || value === undefined) return null;\n if (value instanceof Date) {\n return Number.isNaN(value.getTime()) ? null : value;\n }\n if (typeof value === \"number\") {\n const fromNumber = new Date(value);\n return Number.isNaN(fromNumber.getTime()) ? null : fromNumber;\n }\n if (typeof value !== \"string\") return null;\n const normalized = normalizeTimestampString(value);\n if (!normalized) return null;\n const parsed = new Date(normalized);\n return Number.isNaN(parsed.getTime()) ? null : parsed;\n}\n\nconst DEFAULT_ABSOLUTE_OPTIONS: Intl.DateTimeFormatOptions = {\n year: \"numeric\",\n month: \"short\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n};\n\n/** Absolute local date+time, e.g. \"Jun 13, 2026, 9:32 AM\". */\nexport function formatAbsoluteDate(\n value: TimestampInput,\n options: Intl.DateTimeFormatOptions = DEFAULT_ABSOLUTE_OPTIONS,\n fallback = \"—\",\n): string {\n const parsed = parseTimestamp(value);\n if (!parsed) return fallback;\n return parsed.toLocaleString(undefined, options);\n}\n\n// ───────────────────────── relative time ─────────────────────────\n\nconst RELATIVE_UNITS: readonly {\n limit: number;\n divisor: number;\n short: string;\n long: string;\n}[] = [\n { limit: 60_000, divisor: 1000, short: \"s\", long: \"second\" },\n { limit: 3_600_000, divisor: 60_000, short: \"m\", long: \"minute\" },\n { limit: 86_400_000, divisor: 3_600_000, short: \"h\", long: \"hour\" },\n { limit: 604_800_000, divisor: 86_400_000, short: \"d\", long: \"day\" },\n { limit: 2_592_000_000, divisor: 604_800_000, short: \"w\", long: \"week\" },\n { limit: 31_536_000_000, divisor: 2_592_000_000, short: \"mo\", long: \"month\" },\n];\n\nconst INTL_UNITS: readonly { unit: Intl.RelativeTimeFormatUnit; ms: number }[] = [\n { unit: \"year\", ms: 365 * 24 * 60 * 60 * 1000 },\n { unit: \"month\", ms: 30 * 24 * 60 * 60 * 1000 },\n { unit: \"week\", ms: 7 * 24 * 60 * 60 * 1000 },\n { unit: \"day\", ms: 24 * 60 * 60 * 1000 },\n { unit: \"hour\", ms: 60 * 60 * 1000 },\n { unit: \"minute\", ms: 60 * 1000 },\n];\n\n/**\n * The three relative-time voices the fleet actually speaks. They are display\n * decisions, not implementations — one body serves all three.\n *\n * - `\"short\"` — \"2m ago\", \"3d ago\". The dense default (from `@ai-matrx/diff`).\n * - `\"long\"` — \"2 minutes ago\", \"3 days ago\".\n * - `\"intl\"` — `Intl.RelativeTimeFormat` with `numeric: \"auto\"`, so the\n * viewer's locale conventions apply and \"yesterday\" reads as \"yesterday\"\n * (from `@ai-matrx/associations`). Sub-minute reads \"just now\".\n */\nexport type RelativeTimeStyle = \"short\" | \"long\" | \"intl\";\n\nexport interface RelativeTimeOptions {\n /** Default `\"short\"`. */\n style?: RelativeTimeStyle | undefined;\n /**\n * THE BARE CONTRACT. `false` drops the trailing \" ago\" — \"2m\", \"3d\",\n * \"2 minutes\" — for a dense table cell whose COLUMN HEADER already says\n * \"Age\" or \"Last seen\". Default `true`.\n *\n * WHY AN OPTION AND NOT A `\"bare\"` STYLE (decided 2026-09-11, after three\n * surfaces — an exposure audit, a conversation sidebar and a batch Age\n * column — each lost their deliberate bare stamp to a collapse). `short` /\n * `long` / `intl` are three different VOICES; suffixed-vs-bare is a\n * different axis entirely, and you can want a bare `long` (\"2 minutes\") as\n * readily as a bare `short` (\"2m\"). Folding the axis into the style enum\n * would force `\"bare-short\"` / `\"bare-long\"` and double the enum every time\n * either axis grows. Orthogonal beats combinatorial.\n *\n * IT ALSO CARRIES THE DENSE-CONTEXT FALLBACK, on purpose. Past a year the\n * suffixed voices fall back to `formatAbsoluteDate` — \"Aug 7, 2025,\n * 11:14 PM\" — which is right in prose and ruinous in a narrow column. Asking\n * for the bare form IS the statement \"this is a dense cell\", so the bare\n * form's long-ago fallback is the short numeric date, \"8/7/2025\", which is\n * what every hand-rolled twin printed there. One option, one context, two\n * consistent consequences.\n *\n * `\"intl\"` IGNORES THIS, loudly documented rather than silently half-done:\n * `Intl.RelativeTimeFormat` composes the whole string in the viewer's\n * locale, and there is no correct locale-independent way to amputate its\n * suffix. Ask for `\"short\"` or `\"long\"` when you need bare.\n */\n suffix?: boolean | undefined;\n /** Injected clock. Default `Date.now()` — pass it in tests. */\n now?: number | undefined;\n /** Returned for null / unparseable input. Default `\"—\"`. */\n fallback?: string | undefined;\n /**\n * Echo the raw input instead of `fallback` when it cannot be parsed —\n * degraded, never broken. What the comments face wants: an odd server string\n * is more useful on screen than an em-dash that hides it.\n */\n fallbackToInput?: boolean | undefined;\n}\n\n/**\n * \"2m ago\" / \"2 minutes ago\" / a locale-aware \"2 minutes ago\", falling back to\n * an absolute local date past a year. Timezone-agnostic by construction (a\n * pure epoch difference over `parseTimestamp`'s corrected instant).\n *\n * Future timestamps read \"just now\" in `\"short\"`/`\"long\"` (a clock skew of a\n * few seconds must not print \"in 3 seconds\"); `\"intl\"` formats them properly\n * (\"in 5 minutes\") because that is the whole point of asking for `Intl`.\n */\nexport function formatRelativeTime(\n value: TimestampInput,\n options: RelativeTimeOptions = {},\n): string {\n const style = options.style ?? \"short\";\n const bare = options.suffix === false;\n const now = options.now ?? Date.now();\n const parsed = parseTimestamp(value);\n if (!parsed) {\n if (options.fallbackToInput === true && typeof value === \"string\") return value;\n return options.fallback ?? \"—\";\n }\n\n if (style === \"intl\") {\n const delta = parsed.getTime() - now;\n const magnitude = Math.abs(delta);\n if (magnitude < 60_000) return \"just now\";\n const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: \"auto\" });\n for (const { unit, ms } of INTL_UNITS) {\n if (magnitude >= ms) return rtf.format(Math.trunc(delta / ms), unit);\n }\n return \"just now\";\n }\n\n const elapsed = now - parsed.getTime();\n if (elapsed < 0) return \"just now\";\n const ago = bare ? \"\" : \" ago\";\n for (const unit of RELATIVE_UNITS) {\n if (elapsed < unit.limit) {\n const count = Math.max(1, Math.floor(elapsed / unit.divisor));\n return style === \"long\"\n ? `${count} ${unit.long}${count === 1 ? \"\" : \"s\"}${ago}`\n : `${count}${unit.short}${ago}`;\n }\n }\n // Past a year: prose gets the full local date+time, a bare/dense cell gets\n // the short numeric date. See `suffix` above for why one option decides both.\n return bare ? parsed.toLocaleDateString() : formatAbsoluteDate(parsed);\n}\n\n// ───────────────────────── durations ─────────────────────────\n\n/**\n * The four duration voices the fleet actually speaks. Picked once here so a\n * call duration reads the same in the meeting, the history list and the\n * notification.\n *\n * - `\"clock\"` (default) — `0:00`, `9:04`, `1:02:33`. For anything a human\n * watches tick: recordings, calls, media players, timers.\n * - `\"compact\"` — `250ms`, `5.2s`, `5m 30s`, `1h 02m`. For elapsed work:\n * job runs, request timings, step durations. Sub-second is honest about\n * being sub-second rather than collapsing to `0:00`.\n * - `\"coarse\"` — `45 min`, `1h 30m`, `3d 4h`. For durations read at a glance\n * in a dense cell: podcast episodes, lesson lengths, estimates, uptime,\n * sandbox time-remaining. Never prints seconds.\n * - `\"long\"` — `45 minutes`, `3 hours`, `1 day`, `3 days`. THE PROSE VOICE:\n * the one you put INSIDE a sentence a person reads. \"Nothing has been\n * delivered for 3 days.\" \"8 save attempts have failed over the last\n * 5 minutes.\" \"Expires in 3 days.\"\n *\n * WHY `\"long\"` EXISTS (added 2026-09-11). THREE independent collapse lanes,\n * which never spoke to each other, hit the same wall in one day: six files\n * across matrx-frontend render a duration into English prose, and the package\n * had no voice for it. `coarse` renders three days as `72h` and \"45 minutes\"\n * as \"45 min\", both of which are wrong in a sentence — and a collapse that\n * degrades a screen is not a win. The name is deliberately the SAME WORD as\n * {@link RelativeTimeStyle}'s `\"long\"`, which already means \"spell the unit\n * out in full, correctly pluralised\" (\"2 minutes ago\"). One module, one word,\n * one meaning.\n *\n * WHY THERE IS NO FIFTH, ABBREVIATED-SPELLED-OUT VOICE. A lane proposed a\n * third register — `45 sec` / `12 min` / `3 hr` / `2 days` — for countdown\n * copy. It was refused: it is `\"long\"` with three words truncated, it gives\n * the reader nothing `\"long\"` does not, and its only distinguishing feature\n * (\"hr\", \"sec\") is jargon in exactly the sentences that wanted prose. The one\n * caller that spoke it now speaks `\"long\"`. Two registers — dense (`coarse`)\n * and prose (`\"long\"`) — cover every site the fleet has.\n *\n * WHY `\"coarse\"` GREW A DAY TIER rather than `\"long\"` absorbing those callers:\n * `coarse` stopping at hours was simply a hole. It printed `77h` for a\n * three-day container uptime, which no dense cell wants either; every site\n * that changes is a site that was already reading badly.\n */\nexport type DurationStyle = \"clock\" | \"compact\" | \"coarse\" | \"long\";\n\nexport interface DurationOptions {\n /** Default `\"clock\"`. */\n style?: DurationStyle | undefined;\n /**\n * Returned for `null` / `undefined` / non-finite input. Default `\"0:00\"` for\n * `\"clock\"` and `\"—\"` for `\"compact\"` / `\"coarse\"` / `\"long\"`.\n */\n fallback?: string | undefined;\n /**\n * How a tier's leading number is derived from the remainder below it.\n * `\"nearest\"` (default) is right for a MEASURED or ESTIMATED span — a\n * 44-minute-31-second podcast reads \"45 min\". `\"down\"` is mandatory for a\n * COUNTDOWN: with 5 minutes 30 seconds left, \"6 min\" promises the user half\n * a minute they do not have. (Real regression, sandbox time-remaining,\n * 2026-09-11.)\n *\n * `\"clock\"` and `\"long\"` are ALREADY `\"down\"` by construction and ignore\n * this — a clock never rounds `0:59` up to `1:00`, and prose \"for 3 hours\"\n * asserts at-least-three-hours. It changes `\"coarse\"` and `\"compact\"`.\n */\n round?: \"nearest\" | \"down\" | undefined;\n /**\n * Keep the sign on a negative duration (`-1:23`) instead of clamping to\n * zero. Default `false` — a negative elapsed time is nearly always a clock\n * bug, and `0:00` is the honest reading. Opt in where a signed offset is the\n * actual quantity (a field format for a stored `interval`, say).\n */\n signed?: boolean | undefined;\n}\n\nconst pad2 = (value: number): string => value.toString().padStart(2, \"0\");\n\nfunction clockBody(totalSeconds: number): string {\n const seconds = totalSeconds % 60;\n const minutes = Math.floor(totalSeconds / 60) % 60;\n const hours = Math.floor(totalSeconds / 3600);\n return hours > 0\n ? `${hours}:${pad2(minutes)}:${pad2(seconds)}`\n : `${minutes}:${pad2(seconds)}`;\n}\n\ntype Rounding = \"nearest\" | \"down\";\n\nconst reduce = (value: number, mode: Rounding): number =>\n mode === \"down\" ? Math.floor(value) : Math.round(value);\n\nfunction compactBody(ms: number, round: Rounding): string {\n if (ms < 1000) return `${reduce(ms, round)}ms`;\n const totalSeconds = ms / 1000;\n if (totalSeconds < 60) {\n // One decimal under 10s (0.1s is a visible difference at that scale),\n // whole seconds above it (nobody reads \"43.7s\" as more precise than \"44s\").\n //\n // A TRAILING `.0` IS STRIPPED (2026-09-11). The rule above exists to show\n // TENTHS WHEN THERE ARE TENTHS; `5.0s` spends a character saying \"there\n // are none\", and a game countdown ticking \"9.0s, 8.0s, 7.0s\" reads worse\n // than \"9s, 8s, 7s\" for no gain. The usual objection — column jitter —\n // does not apply to this voice: `compact` already swings between `250ms`,\n // `5.2s`, `44s` and `1m 30s`, so it was never a fixed-width column format.\n // `\"clock\"` is the fixed-width one.\n if (totalSeconds >= 10) return `${reduce(totalSeconds, round)}s`;\n const tenths = round === \"down\"\n ? Math.floor(totalSeconds * 10) / 10\n : Math.round(totalSeconds * 10) / 10;\n return `${Number.isInteger(tenths) ? tenths : tenths.toFixed(1)}s`;\n }\n const whole = reduce(totalSeconds, round);\n const minutes = Math.floor(whole / 60);\n if (minutes < 60) return `${minutes}m ${pad2(whole % 60)}s`;\n const hours = Math.floor(minutes / 60);\n return `${hours}h ${pad2(minutes % 60)}m`;\n}\n\nfunction coarseBody(ms: number, round: Rounding): string {\n const minutes = reduce(ms / 60_000, round);\n if (minutes < 1) return \"< 1 min\";\n if (minutes < 60) return `${minutes} min`;\n if (minutes < 1440) {\n const hours = Math.floor(minutes / 60);\n const rest = minutes % 60;\n return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;\n }\n // Day tier, added 2026-09-11: this voice used to print `77h` for a\n // three-day container uptime. It keeps `coarse`'s own shape — abbreviated\n // units, at most two of them, largest first — so `3d 4h`, `3d`, never\n // `3d 4h 12m`.\n const days = Math.floor(minutes / 1440);\n const restHours = Math.floor((minutes % 1440) / 60);\n return restHours > 0 ? `${days}d ${restHours}h` : `${days}d`;\n}\n\nconst plural = (count: number, unit: string): string =>\n `${count} ${unit}${count === 1 ? \"\" : \"s\"}`;\n\n/**\n * The prose voice. Always FLOORS: \"for 3 hours\" and \"expires in 3 days\" both\n * assert at-least, which is the only reading that cannot mislead in either\n * direction (an elapsed time never overstates, a countdown never over-promises).\n *\n * ONE TIER ONLY — \"3 hours\", never \"3 hours 12 minutes\". This voice goes\n * inside a sentence, and a sentence carrying two magnitudes reads like a\n * stopwatch readout. A caller that genuinely needs both wants `coarse`.\n *\n * Sub-second says so in words rather than printing a floored \"0 seconds\",\n * which reads as \"nothing happened\" — the same reasoning as `coarse`'s\n * \"< 1 min\".\n *\n * It stops at days. Weeks and months are calendar units whose length depends\n * on WHICH week and WHICH month, so a duration — a pure span with no anchor —\n * cannot honestly speak them. \"45 days\" is exact; \"1.5 months\" is a guess.\n * {@link formatRelativeTime}, which HAS an anchor, is where weeks and months\n * belong.\n */\nfunction longBody(ms: number): string {\n if (ms < 1_000) return \"less than a second\";\n if (ms < 60_000) return plural(Math.floor(ms / 1_000), \"second\");\n if (ms < 3_600_000) return plural(Math.floor(ms / 60_000), \"minute\");\n if (ms < 86_400_000) return plural(Math.floor(ms / 3_600_000), \"hour\");\n return plural(Math.floor(ms / 86_400_000), \"day\");\n}\n\n/**\n * THE canonical duration formatter. Milliseconds in, a chosen voice out.\n *\n * `null`, `undefined`, `NaN` and `Infinity` all take the fallback — never\n * `NaN:NaN`, which is what a null start time produces, and never a confident\n * `0:00` for \"we do not know\".\n */\nexport function formatDurationMs(\n ms: number | null | undefined,\n options: DurationOptions = {},\n): string {\n const style = options.style ?? \"clock\";\n const fallback = options.fallback ?? (style === \"clock\" ? \"0:00\" : \"—\");\n if (ms === null || ms === undefined || !Number.isFinite(ms)) return fallback;\n\n const negative = ms < 0;\n if (negative && options.signed !== true) {\n return style === \"clock\" ? clockBody(0) : fallback;\n }\n const magnitude = Math.abs(ms);\n const round = options.round ?? \"nearest\";\n const body =\n style === \"clock\"\n ? clockBody(Math.floor(magnitude / 1000))\n : style === \"compact\"\n ? compactBody(magnitude, round)\n : style === \"coarse\"\n ? coarseBody(magnitude, round)\n : longBody(magnitude);\n return negative ? `-${body}` : body;\n}\n\n/** Seconds in. See {@link formatDurationMs}. */\nexport function formatDurationSeconds(\n seconds: number | null | undefined,\n options: DurationOptions = {},\n): string {\n if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) {\n return formatDurationMs(seconds as number | null | undefined, options);\n }\n return formatDurationMs(seconds * 1000, options);\n}\n\n/** Minutes in. See {@link formatDurationMs}. */\nexport function formatDurationMinutes(\n minutes: number | null | undefined,\n options: DurationOptions = {},\n): string {\n if (minutes === null || minutes === undefined || !Number.isFinite(minutes)) {\n return formatDurationMs(minutes as number | null | undefined, options);\n }\n return formatDurationMs(minutes * 60_000, options);\n}\n\n/**\n * Duration between two timestamps, in milliseconds — `null` when the start is\n * missing or either end is unparseable. An absent `to` means \"still running\",\n * so it measures to `now`. Pair with {@link formatDurationMs}.\n */\nexport function durationMsBetween(\n from: TimestampInput,\n to: TimestampInput,\n now: number = Date.now(),\n): number | null {\n const start = parseTimestamp(from);\n if (!start) return null;\n if (to === null || to === undefined) return Math.max(0, now - start.getTime());\n const end = parseTimestamp(to);\n if (!end) return null;\n return Math.max(0, end.getTime() - start.getTime());\n}\n\n// ───────────────────────── byte sizes ─────────────────────────\n\nconst SIZE_UNITS = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\"] as const;\n\nexport interface FileSizeOptions {\n /** Returned for `null` / `undefined` / non-finite / negative. Default `\"—\"`. */\n fallback?: string | undefined;\n}\n\n/**\n * `512` → `\"512 B\"`, `2048` → `\"2.0 KB\"`, `15360` → `\"15 KB\"`.\n *\n * THE DISPLAY DECISIONS, made once (unified 2026-09-07 from six host twins and\n * `@ai-matrx/media`'s copy, which was the richest):\n *\n * 1. Binary units (1024), labelled with the short SI-ish names the whole fleet\n * already used — `B`/`KB`/`MB`/`GB`/`TB`, never `\"Bytes\"`.\n * 2. One decimal below 10 in a unit (`2.0 KB`, `9.9 MB`), whole numbers at or\n * above it (`15 KB`, `340 MB`) — three significant figures is the most a\n * file size is ever worth, and `1.5 GB` versus `1536.0 MB` is the whole\n * point of the unit.\n * 3. Whole bytes below 1 KB — `\"512 B\"`, never `\"512.0 B\"`.\n * 4. `null` / `undefined` / `NaN` / `Infinity` / negative all collapse to an\n * em-dash, NOT to `\"0 B\"`. A corrupt or mid-upload `size` row rendering as\n * a confident, wrong `\"0 B\"` is a screen telling a lie; \"size unknown\" is\n * the honest reading. (`0` itself is a real size and prints `\"0 B\"`.)\n */\nexport function formatFileSize(\n bytes: number | null | undefined,\n options: FileSizeOptions = {},\n): string {\n const fallback = options.fallback ?? \"—\";\n if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) {\n return fallback;\n }\n if (bytes < 1024) return `${Math.round(bytes)} B`;\n let value = bytes;\n let unit = 0;\n while (value >= 1024 && unit < SIZE_UNITS.length - 1) {\n value /= 1024;\n unit += 1;\n }\n const rendered = value >= 10 ? String(Math.round(value)) : value.toFixed(1);\n return `${rendered} ${SIZE_UNITS[unit]}`;\n}\n\n// ───────────────────────── people ─────────────────────────\n\nexport interface InitialsOptions {\n /** Returned when nothing usable is left. Default `\"?\"`. */\n fallback?: string | undefined;\n}\n\n/**\n * `\"Ana Rivera\"` → `\"AR\"`, `\"Ana\"` → `\"A\"`, `\"Ana Maria Rivera\"` → `\"AR\"`,\n * `\"ana@example.com\"` → `\"A\"`, `\" \"` → `\"?\"`.\n *\n * THE DISPLAY DECISION, made once: a multi-part name takes FIRST + LAST, not\n * first + second. `\"Ana Maria Rivera\"` is `AR`, because the family name is the\n * half a reader recognises. (Six matrx-frontend twins took first + second and\n * printed `AM`; the two package copies both took first + last, and both were\n * tested. First + last wins.)\n *\n * Pass the email as the value when there is no name — a single token yields\n * its first character, which is exactly what the host twins did by hand.\n */\nexport function getInitials(\n value: string | null | undefined,\n options: InitialsOptions = {},\n): string {\n const fallback = options.fallback ?? \"?\";\n if (typeof value !== \"string\") return fallback;\n const parts = value.trim().split(/\\s+/).filter((part) => part.length > 0);\n if (parts.length === 0) return fallback;\n const first = parts[0]?.charAt(0) ?? \"\";\n const last = parts.length > 1 ? (parts[parts.length - 1]?.charAt(0) ?? \"\") : \"\";\n return `${first}${last}`.toUpperCase() || fallback;\n}\n\n/**\n * A stable palette index for an avatar with no image. Deterministic on the\n * seed, so the same person is the same colour on every device and every\n * reload — a random colour per render is a surprisingly loud bug.\n */\nexport function avatarPaletteIndex(seed: string, buckets = 8): number {\n let hash = 0;\n for (let index = 0; index < seed.length; index += 1) {\n hash = (hash * 31 + seed.charCodeAt(index)) | 0;\n }\n return Math.abs(hash) % buckets;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-matrx/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "The always-include AI Matrx kit: the little primitives every Matrx app speaks — autosave that never loses a keystroke, stale-response guards, clipboard with graceful fallbacks — one per subpath, tree-shaken to what you use.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|