@ai-matrx/kit 0.10.1 → 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 CHANGED
@@ -1,10 +1,58 @@
1
1
  # Changelog
2
2
 
3
- ## 0.10.1
4
-
5
- Automatic changed-only republish (docs/metadata drift since the last tag see
6
- `git diff npm/kit/v0.10.0..npm/kit/v0.10.1 -- apps/shared/kit`).
7
- No source changes intended and no consumer action required.
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.
8
56
 
9
57
  ## 0.10.0 — 2026-09-11
10
58
 
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"} ago` : `${count}${unit.short} ago`;
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
- function compactBody(ms) {
127
- if (ms < 1e3) return `${Math.round(ms)}ms`;
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
- return totalSeconds < 10 ? `${totalSeconds.toFixed(1)}s` : `${Math.round(totalSeconds)}s`;
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 = Math.round(totalSeconds);
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 = Math.round(ms / 6e4);
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
- const hours = Math.floor(minutes / 60);
143
- const rest = minutes % 60;
144
- return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
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 body = style === "clock" ? clockBody(Math.floor(magnitude / 1e3)) : style === "compact" ? compactBody(magnitude) : coarseBody(magnitude);
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 = {}) {
@@ -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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAuCA,SAAS,sBAAsB,OAAwB;AACrD,MAAI,QAAQ,KAAK,KAAK,EAAG,QAAO;AAChC,MAAI,uBAAuB,KAAK,KAAK,EAAG,QAAO;AAC/C,MAAI,iBAAiB,KAAK,KAAK,EAAG,QAAO;AACzC,SAAO;AACT;AAgBA,SAAS,yBAAyB,KAAqB;AACrD,QAAM,QAAQ,IAAI,KAAK;AACvB,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI,CAAC,gBAAgB,KAAK,KAAK,EAAG,QAAO;AACzC,MAAI,sBAAsB,KAAK,EAAG,QAAO;AACzC,SAAO,GAAG,MAAM,QAAQ,KAAK,GAAG,CAAC;AACnC;AAGO,SAAS,eAAe,OAAoC;AACjE,MAAI,UAAU,QAAQ,UAAU,OAAW,QAAO;AAClD,MAAI,iBAAiB,MAAM;AACzB,WAAO,OAAO,MAAM,MAAM,QAAQ,CAAC,IAAI,OAAO;AAAA,EAChD;AACA,MAAI,OAAO,UAAU,UAAU;AAC7B,UAAM,aAAa,IAAI,KAAK,KAAK;AACjC,WAAO,OAAO,MAAM,WAAW,QAAQ,CAAC,IAAI,OAAO;AAAA,EACrD;AACA,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,aAAa,yBAAyB,KAAK;AACjD,MAAI,CAAC,WAAY,QAAO;AACxB,QAAM,SAAS,IAAI,KAAK,UAAU;AAClC,SAAO,OAAO,MAAM,OAAO,QAAQ,CAAC,IAAI,OAAO;AACjD;AAEA,IAAM,2BAAuD;AAAA,EAC3D,MAAM;AAAA,EACN,OAAO;AAAA,EACP,KAAK;AAAA,EACL,MAAM;AAAA,EACN,QAAQ;AACV;AAGO,SAAS,mBACd,OACA,UAAsC,0BACtC,WAAW,UACH;AACR,QAAM,SAAS,eAAe,KAAK;AACnC,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,OAAO,eAAe,QAAW,OAAO;AACjD;AAIA,IAAM,iBAKA;AAAA,EACJ,EAAE,OAAO,KAAQ,SAAS,KAAM,OAAO,KAAK,MAAM,SAAS;AAAA,EAC3D,EAAE,OAAO,MAAW,SAAS,KAAQ,OAAO,KAAK,MAAM,SAAS;AAAA,EAChE,EAAE,OAAO,OAAY,SAAS,MAAW,OAAO,KAAK,MAAM,OAAO;AAAA,EAClE,EAAE,OAAO,QAAa,SAAS,OAAY,OAAO,KAAK,MAAM,MAAM;AAAA,EACnE,EAAE,OAAO,QAAe,SAAS,QAAa,OAAO,KAAK,MAAM,OAAO;AAAA,EACvE,EAAE,OAAO,SAAgB,SAAS,QAAe,OAAO,MAAM,MAAM,QAAQ;AAC9E;AAEA,IAAM,aAA2E;AAAA,EAC/E,EAAE,MAAM,QAAQ,IAAI,MAAM,KAAK,KAAK,KAAK,IAAK;AAAA,EAC9C,EAAE,MAAM,SAAS,IAAI,KAAK,KAAK,KAAK,KAAK,IAAK;AAAA,EAC9C,EAAE,MAAM,QAAQ,IAAI,IAAI,KAAK,KAAK,KAAK,IAAK;AAAA,EAC5C,EAAE,MAAM,OAAO,IAAI,KAAK,KAAK,KAAK,IAAK;AAAA,EACvC,EAAE,MAAM,QAAQ,IAAI,KAAK,KAAK,IAAK;AAAA,EACnC,EAAE,MAAM,UAAU,IAAI,KAAK,IAAK;AAClC;AAsCO,SAAS,mBACd,OACA,UAA+B,CAAC,GACxB;AACR,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,MAAM,QAAQ,OAAO,KAAK,IAAI;AACpC,QAAM,SAAS,eAAe,KAAK;AACnC,MAAI,CAAC,QAAQ;AACX,QAAI,QAAQ,oBAAoB,QAAQ,OAAO,UAAU,SAAU,QAAO;AAC1E,WAAO,QAAQ,YAAY;AAAA,EAC7B;AAEA,MAAI,UAAU,QAAQ;AACpB,UAAM,QAAQ,OAAO,QAAQ,IAAI;AACjC,UAAM,YAAY,KAAK,IAAI,KAAK;AAChC,QAAI,YAAY,IAAQ,QAAO;AAC/B,UAAM,MAAM,IAAI,KAAK,mBAAmB,QAAW,EAAE,SAAS,OAAO,CAAC;AACtE,eAAW,EAAE,MAAM,GAAG,KAAK,YAAY;AACrC,UAAI,aAAa,GAAI,QAAO,IAAI,OAAO,KAAK,MAAM,QAAQ,EAAE,GAAG,IAAI;AAAA,IACrE;AACA,WAAO;AAAA,EACT;AAEA,QAAM,UAAU,MAAM,OAAO,QAAQ;AACrC,MAAI,UAAU,EAAG,QAAO;AACxB,aAAW,QAAQ,gBAAgB;AACjC,QAAI,UAAU,KAAK,OAAO;AACxB,YAAM,QAAQ,KAAK,IAAI,GAAG,KAAK,MAAM,UAAU,KAAK,OAAO,CAAC;AAC5D,aAAO,UAAU,SACb,GAAG,KAAK,IAAI,KAAK,IAAI,GAAG,UAAU,IAAI,KAAK,GAAG,SAC9C,GAAG,KAAK,GAAG,KAAK,KAAK;AAAA,IAC3B;AAAA,EACF;AACA,SAAO,mBAAmB,MAAM;AAClC;AAqCA,IAAM,OAAO,CAAC,UAA0B,MAAM,SAAS,EAAE,SAAS,GAAG,GAAG;AAExE,SAAS,UAAU,cAA8B;AAC/C,QAAM,UAAU,eAAe;AAC/B,QAAM,UAAU,KAAK,MAAM,eAAe,EAAE,IAAI;AAChD,QAAM,QAAQ,KAAK,MAAM,eAAe,IAAI;AAC5C,SAAO,QAAQ,IACX,GAAG,KAAK,IAAI,KAAK,OAAO,CAAC,IAAI,KAAK,OAAO,CAAC,KAC1C,GAAG,OAAO,IAAI,KAAK,OAAO,CAAC;AACjC;AAEA,SAAS,YAAY,IAAoB;AACvC,MAAI,KAAK,IAAM,QAAO,GAAG,KAAK,MAAM,EAAE,CAAC;AACvC,QAAM,eAAe,KAAK;AAC1B,MAAI,eAAe,IAAI;AAGrB,WAAO,eAAe,KAClB,GAAG,aAAa,QAAQ,CAAC,CAAC,MAC1B,GAAG,KAAK,MAAM,YAAY,CAAC;AAAA,EACjC;AACA,QAAM,QAAQ,KAAK,MAAM,YAAY;AACrC,QAAM,UAAU,KAAK,MAAM,QAAQ,EAAE;AACrC,MAAI,UAAU,GAAI,QAAO,GAAG,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;AACxD,QAAM,QAAQ,KAAK,MAAM,UAAU,EAAE;AACrC,SAAO,GAAG,KAAK,KAAK,KAAK,UAAU,EAAE,CAAC;AACxC;AAEA,SAAS,WAAW,IAAoB;AACtC,QAAM,UAAU,KAAK,MAAM,KAAK,GAAM;AACtC,MAAI,UAAU,EAAG,QAAO;AACxB,MAAI,UAAU,GAAI,QAAO,GAAG,OAAO;AACnC,QAAM,QAAQ,KAAK,MAAM,UAAU,EAAE;AACrC,QAAM,OAAO,UAAU;AACvB,SAAO,OAAO,IAAI,GAAG,KAAK,KAAK,IAAI,MAAM,GAAG,KAAK;AACnD;AASO,SAAS,iBACd,IACA,UAA2B,CAAC,GACpB;AACR,QAAM,QAAQ,QAAQ,SAAS;AAC/B,QAAM,WAAW,QAAQ,aAAa,UAAU,UAAU,SAAS;AACnE,MAAI,OAAO,QAAQ,OAAO,UAAa,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO;AAEpE,QAAM,WAAW,KAAK;AACtB,MAAI,YAAY,QAAQ,WAAW,MAAM;AACvC,WAAO,UAAU,UAAU,UAAU,CAAC,IAAI;AAAA,EAC5C;AACA,QAAM,YAAY,KAAK,IAAI,EAAE;AAC7B,QAAM,OACJ,UAAU,UACN,UAAU,KAAK,MAAM,YAAY,GAAI,CAAC,IACtC,UAAU,YACR,YAAY,SAAS,IACrB,WAAW,SAAS;AAC5B,SAAO,WAAW,IAAI,IAAI,KAAK;AACjC;AAGO,SAAS,sBACd,SACA,UAA2B,CAAC,GACpB;AACR,MAAI,YAAY,QAAQ,YAAY,UAAa,CAAC,OAAO,SAAS,OAAO,GAAG;AAC1E,WAAO,iBAAiB,SAAsC,OAAO;AAAA,EACvE;AACA,SAAO,iBAAiB,UAAU,KAAM,OAAO;AACjD;AAGO,SAAS,sBACd,SACA,UAA2B,CAAC,GACpB;AACR,MAAI,YAAY,QAAQ,YAAY,UAAa,CAAC,OAAO,SAAS,OAAO,GAAG;AAC1E,WAAO,iBAAiB,SAAsC,OAAO;AAAA,EACvE;AACA,SAAO,iBAAiB,UAAU,KAAQ,OAAO;AACnD;AAOO,SAAS,kBACd,MACA,IACA,MAAc,KAAK,IAAI,GACR;AACf,QAAM,QAAQ,eAAe,IAAI;AACjC,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI,OAAO,QAAQ,OAAO,OAAW,QAAO,KAAK,IAAI,GAAG,MAAM,MAAM,QAAQ,CAAC;AAC7E,QAAM,MAAM,eAAe,EAAE;AAC7B,MAAI,CAAC,IAAK,QAAO;AACjB,SAAO,KAAK,IAAI,GAAG,IAAI,QAAQ,IAAI,MAAM,QAAQ,CAAC;AACpD;AAIA,IAAM,aAAa,CAAC,KAAK,MAAM,MAAM,MAAM,MAAM,IAAI;AAyB9C,SAAS,eACd,OACA,UAA2B,CAAC,GACpB;AACR,QAAM,WAAW,QAAQ,YAAY;AACrC,MAAI,UAAU,QAAQ,UAAU,UAAa,CAAC,OAAO,SAAS,KAAK,KAAK,QAAQ,GAAG;AACjF,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,KAAM,QAAO,GAAG,KAAK,MAAM,KAAK,CAAC;AAC7C,MAAI,QAAQ;AACZ,MAAI,OAAO;AACX,SAAO,SAAS,QAAQ,OAAO,WAAW,SAAS,GAAG;AACpD,aAAS;AACT,YAAQ;AAAA,EACV;AACA,QAAM,WAAW,SAAS,KAAK,OAAO,KAAK,MAAM,KAAK,CAAC,IAAI,MAAM,QAAQ,CAAC;AAC1E,SAAO,GAAG,QAAQ,IAAI,WAAW,IAAI,CAAC;AACxC;AAsBO,SAAS,YACd,OACA,UAA2B,CAAC,GACpB;AACR,QAAM,WAAW,QAAQ,YAAY;AACrC,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,QAAQ,MAAM,KAAK,EAAE,MAAM,KAAK,EAAE,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC;AACxE,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,QAAQ,MAAM,CAAC,GAAG,OAAO,CAAC,KAAK;AACrC,QAAM,OAAO,MAAM,SAAS,IAAK,MAAM,MAAM,SAAS,CAAC,GAAG,OAAO,CAAC,KAAK,KAAM;AAC7E,SAAO,GAAG,KAAK,GAAG,IAAI,GAAG,YAAY,KAAK;AAC5C;AAOO,SAAS,mBAAmB,MAAc,UAAU,GAAW;AACpE,MAAI,OAAO;AACX,WAAS,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS,GAAG;AACnD,WAAQ,OAAO,KAAK,KAAK,WAAW,KAAK,IAAK;AAAA,EAChD;AACA,SAAO,KAAK,IAAI,IAAI,IAAI;AAC1B;","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 three duration voices the fleet actually speaks. Picked once here so a
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 things measured in minutes and read
85
- * at a glance: podcast episodes, lesson lengths, estimates. Never prints
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 three duration voices the fleet actually speaks. Picked once here so a
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 things measured in minutes and read
85
- * at a glance: podcast episodes, lesson lengths, estimates. Never prints
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"} ago` : `${count}${unit.short} ago`;
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
- function compactBody(ms) {
94
- if (ms < 1e3) return `${Math.round(ms)}ms`;
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
- return totalSeconds < 10 ? `${totalSeconds.toFixed(1)}s` : `${Math.round(totalSeconds)}s`;
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 = Math.round(totalSeconds);
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 = Math.round(ms / 6e4);
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
- const hours = Math.floor(minutes / 60);
110
- const rest = minutes % 60;
111
- return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
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 body = style === "clock" ? clockBody(Math.floor(magnitude / 1e3)) : style === "compact" ? compactBody(magnitude) : coarseBody(magnitude);
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 = {}) {
@@ -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.10.1",
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",