mgv-backoffice 1.30.0 → 1.32.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.
@@ -45,6 +45,29 @@ export declare function fmtPct(n: number, digits?: number): string;
45
45
  * Format a signed USD amount with an explicit leading sign (e.g. "+$5.00").
46
46
  */
47
47
  export declare function fmtUsd(v: number): string;
48
+ /**
49
+ * Format an ISO date string / epoch as a "Mon D, YYYY" calendar label
50
+ * (en-US, e.g. "Jan 5, 2026"). Falls back to the raw value on parse
51
+ * failure and an em-dash on empty input.
52
+ */
53
+ export declare function fmtCalendarDate(s: string | number | null | undefined): string;
54
+ /**
55
+ * Format an ISO date string / epoch as a "Mon D, YYYY, HH:MM" calendar
56
+ * label with the time of day (en-US). Same fallbacks as fmtCalendarDate.
57
+ */
58
+ export declare function fmtCalendarDateTime(s: string | number | null | undefined): string;
59
+ /**
60
+ * Render a millisecond count as a "= 1.50 s" hint so users can
61
+ * sanity-check magnitudes without mental math. Sub-second values get 3
62
+ * decimals ("= 0.250 s"), everything else 2. Returns '' for
63
+ * non-positive / non-finite input so callers can `v-if` it away.
64
+ */
65
+ export declare function fmtMsAsSeconds(ms: number | null | undefined): string;
66
+ /**
67
+ * Pretty-print a byte count with KB/MB granularity ("512 B", "1.5 KB",
68
+ * "2.0 MB"). Returns '' for zero/falsy input.
69
+ */
70
+ export declare function fmtBytes(bytes: number | null | undefined): string;
48
71
  /**
49
72
  * Format a millisecond duration as compact day/hour/minute units, e.g.
50
73
  * "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
@@ -15,5 +15,18 @@ export declare function methodBadgeSolid(method?: string): string;
15
15
  export declare function methodBadgeBright(method?: string): string;
16
16
  /** Solid 600-weight status badge with white text. */
17
17
  export declare function statusBadgeSolid(status?: number): string;
18
+ /**
19
+ * Dark/light-aware tinted method badge — per-method hue on a soft
20
+ * `*-500/15` (dark) / `*-100` (light) surface, for card-surface chips.
21
+ * (Extracted from WireMate's mock/stub cards; GET is blue here, unlike
22
+ * the green-leaning bright family above.)
23
+ */
24
+ export declare function methodBadgeTinted(method: string | undefined, isDark: boolean): string;
25
+ /**
26
+ * Dark/light-aware soft status badge keyed by status class — emerald 2xx,
27
+ * sky 3xx, amber 4xx, red 5xx, gray otherwise. Softer companion to
28
+ * `statusBadgeTinted` (which uses the green/blue/yellow palette).
29
+ */
30
+ export declare function statusBadgeSoft(status: number | undefined, isDark: boolean): string;
18
31
  /** Dark/light-aware tinted status badge. */
19
32
  export declare function statusBadgeTinted(status: number | undefined, isDark: boolean): string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Validators for dynamic key/value row grids (header lists, query-param
3
+ * lists, metadata grids, …). A row is inconsistent when one side is
4
+ * filled in and the other is blank:
5
+ *
6
+ * • key present, value blank → rowValueMissing
7
+ * • value present, key blank → rowKeyMissing
8
+ *
9
+ * Rows whose `matcherType` is `'absent'` are exempt — an absent matcher
10
+ * intentionally carries no value (it asserts the key is NOT present).
11
+ * Rows without a matcherType (plain key/value pairs) validate normally.
12
+ */
13
+ export interface KeyValueRowLike {
14
+ key?: string;
15
+ value?: string;
16
+ matcherType?: string;
17
+ }
18
+ /** True when the row has a (non-absent) key but no value. */
19
+ export declare function rowValueMissing(row: KeyValueRowLike): boolean;
20
+ /** True when the row has a value but no key to bind it to. */
21
+ export declare function rowKeyMissing(row: KeyValueRowLike): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mgv-backoffice",
3
- "version": "1.30.0",
3
+ "version": "1.32.0",
4
4
  "description": "Shared Vue 3 UI component library",
5
5
  "type": "module",
6
6
  "main": "./dist/ui-lib.umd.cjs",
@@ -1,26 +1,14 @@
1
1
  <template>
2
2
  <BaseModalShell
3
3
  :title="title"
4
+ :icon="ExclamationTriangleIcon"
5
+ :icon-bg-class="variant === 'danger'
6
+ ? isDark ? 'bg-red-900/30' : 'bg-red-50'
7
+ : isDark ? 'bg-amber-900/30' : 'bg-amber-50'"
8
+ :icon-class="variant === 'danger' ? 'text-red-600' : 'text-amber-600'"
4
9
  :manual-close="submitting"
5
10
  @cancel="handleCancel"
6
11
  >
7
- <template #icon>
8
- <div
9
- class="flex-shrink-0 flex h-12 w-12 items-center justify-center rounded-full"
10
- :class="[
11
- variant === 'danger'
12
- ? isDark ? 'bg-red-900/30' : 'bg-red-50'
13
- : isDark ? 'bg-amber-900/30' : 'bg-amber-50'
14
- ]"
15
- >
16
- <ExclamationTriangleIcon
17
- class="h-6 w-6"
18
- :class="variant === 'danger' ? 'text-red-600' : 'text-amber-600'"
19
- aria-hidden="true"
20
- />
21
- </div>
22
- </template>
23
-
24
12
  <!--
25
13
  Default renders the plain `message` string. Callers that need rich
26
14
  text (bold names, inline code, etc.) can override the `message` slot
@@ -1,18 +1,16 @@
1
1
  <template>
2
2
  <BaseModalShell
3
3
  :title="title"
4
+ :icon="FolderIcon"
5
+ :icon-bg-class="palette.iconBg"
6
+ :icon-class="palette.iconText"
4
7
  :manual-close="submitting"
5
8
  @cancel="handleCancel"
6
9
  >
7
- <template #icon>
8
- <slot name="icon">
9
- <div
10
- class="flex-shrink-0 flex h-12 w-12 items-center justify-center rounded-full"
11
- :class="palette.iconBg"
12
- >
13
- <FolderIcon class="h-6 w-6" :class="palette.iconText" aria-hidden="true" />
14
- </div>
15
- </slot>
10
+ <!-- Forward a caller-supplied icon override; otherwise the shell
11
+ renders the default folder icon in the palette-tinted chip. -->
12
+ <template v-if="$slots.icon" #icon>
13
+ <slot name="icon" />
16
14
  </template>
17
15
 
18
16
  <p v-if="message" class="text-sm" :class="isDark ? 'text-gray-300' : 'text-gray-600'">
@@ -0,0 +1,94 @@
1
+ <template>
2
+ <!--
3
+ Dashed "click to select a file" upload zone — extracted from WireMate's
4
+ ImportPostmanModal file picker. Also accepts drag-and-drop.
5
+
6
+ The hidden input is reset after every selection so choosing the same
7
+ file twice still fires `files` — callers don't need their own reset.
8
+ -->
9
+ <label
10
+ class="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-6 py-10 text-center transition-colors"
11
+ :class="[
12
+ disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
13
+ dragging
14
+ ? (isDark ? 'border-emerald-500 text-emerald-400' : 'border-emerald-500 text-emerald-600')
15
+ : (isDark
16
+ ? 'border-gray-700 hover:border-emerald-500 text-gray-400 hover:text-emerald-400'
17
+ : 'border-gray-300 hover:border-emerald-500 text-gray-500 hover:text-emerald-600'),
18
+ ]"
19
+ @dragover.prevent="onDragOver"
20
+ @dragleave.prevent="dragging = false"
21
+ @drop.prevent="onDrop"
22
+ >
23
+ <slot name="icon">
24
+ <svg class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
25
+ <path
26
+ stroke-linecap="round"
27
+ stroke-linejoin="round"
28
+ d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m6.75 12-3-3m0 0-3 3m3-3v6m-1.5-15H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
29
+ />
30
+ </svg>
31
+ </slot>
32
+ <p class="text-sm font-medium">{{ label }}</p>
33
+ <p v-if="hint" class="text-xs" :class="isDark ? 'text-gray-500' : 'text-gray-400'">
34
+ {{ hint }}
35
+ </p>
36
+ <input
37
+ type="file"
38
+ class="hidden"
39
+ :accept="accept"
40
+ :multiple="multiple"
41
+ :disabled="disabled"
42
+ @change="onChange"
43
+ />
44
+ </label>
45
+ </template>
46
+
47
+ <script lang="ts" setup>
48
+ import { ref } from 'vue'
49
+ import { useTheme } from '../composables/useTheme'
50
+
51
+ interface Props {
52
+ /** Main line, e.g. "Click to select a Postman collection (.json)". */
53
+ label: string
54
+ /** Optional dimmed helper line below the label. */
55
+ hint?: string
56
+ /** Forwarded to the hidden input's `accept`. Dropped files are NOT filtered by it. */
57
+ accept?: string
58
+ multiple?: boolean
59
+ disabled?: boolean
60
+ }
61
+
62
+ const props = withDefaults(defineProps<Props>(), {
63
+ hint: '',
64
+ accept: '',
65
+ multiple: false,
66
+ disabled: false,
67
+ })
68
+
69
+ const emit = defineEmits<{ files: [files: File[]] }>()
70
+
71
+ const { isDark } = useTheme()
72
+
73
+ const dragging = ref(false)
74
+
75
+ function onChange(e: Event) {
76
+ const input = e.target as HTMLInputElement
77
+ const files = Array.from(input.files ?? [])
78
+ // Reset so re-selecting the same file re-triggers change.
79
+ input.value = ''
80
+ if (files.length) emit('files', files)
81
+ }
82
+
83
+ function onDragOver() {
84
+ if (!props.disabled) dragging.value = true
85
+ }
86
+
87
+ function onDrop(e: DragEvent) {
88
+ dragging.value = false
89
+ if (props.disabled) return
90
+ const dropped = Array.from(e.dataTransfer?.files ?? [])
91
+ if (!dropped.length) return
92
+ emit('files', props.multiple ? dropped : dropped.slice(0, 1))
93
+ }
94
+ </script>
@@ -4,8 +4,14 @@
4
4
  backdrop, dark-mode card, escape key, aria-modal). Concrete modals
5
5
  should compose this rather than re-implementing the chrome.
6
6
 
7
+ Every modal shows an icon left of the title: the REQUIRED `icon`
8
+ prop (a component, e.g. a heroicon) renders inside a tinted
9
+ circular chip. Tint via `icon-bg-class` / `icon-class`; the `icon`
10
+ slot remains as a full-markup override for callers that need
11
+ something richer than the standard chip.
12
+
7
13
  Slots:
8
- • icon — small circular icon shown next to the title
14
+ • icon — override the standard icon chip next to the title
9
15
  • default — body content
10
16
  • footer — action buttons (cancel / confirm / etc.)
11
17
 
@@ -37,7 +43,14 @@
37
43
  >
38
44
  <div class="flex items-start justify-between gap-4 p-6 border-b shrink-0" :class="t.border">
39
45
  <div class="flex items-start gap-4 min-w-0">
40
- <slot name="icon" />
46
+ <slot name="icon">
47
+ <div
48
+ class="flex-shrink-0 flex h-12 w-12 items-center justify-center rounded-full"
49
+ :class="iconChipBg"
50
+ >
51
+ <component :is="icon" class="h-6 w-6" :class="iconClass" aria-hidden="true" />
52
+ </div>
53
+ </slot>
41
54
  <div class="flex-1 min-w-0">
42
55
  <h3 :id="titleId" class="text-lg font-medium" :class="t.primaryText">
43
56
  {{ title }}
@@ -69,7 +82,14 @@
69
82
  <div class="p-6">
70
83
  <!-- Title row -->
71
84
  <div class="flex items-start gap-4">
72
- <slot name="icon" />
85
+ <slot name="icon">
86
+ <div
87
+ class="flex-shrink-0 flex h-12 w-12 items-center justify-center rounded-full"
88
+ :class="iconChipBg"
89
+ >
90
+ <component :is="icon" class="h-6 w-6" :class="iconClass" aria-hidden="true" />
91
+ </div>
92
+ </slot>
73
93
  <div class="flex-1">
74
94
  <h3
75
95
  :id="titleId"
@@ -98,12 +118,26 @@
98
118
 
99
119
  <script setup lang="ts">
100
120
  import { computed } from 'vue'
121
+ import type { Component } from 'vue'
101
122
  import { useTheme } from '../composables/useTheme'
102
123
  import { useThemeClasses } from '../composables/useThemeClasses'
103
124
  import { useEscapeKey } from '../composables/useEscapeKey'
104
125
 
105
126
  interface Props {
106
127
  title: string
128
+ /**
129
+ * Icon component (e.g. a heroicon) rendered in a tinted circular
130
+ * chip left of the title. REQUIRED — every modal must name itself
131
+ * with an icon. The `icon` slot can override the whole chip.
132
+ */
133
+ icon: Component
134
+ /**
135
+ * Background classes of the icon chip. Defaults to the emerald
136
+ * tint (dark-mode aware).
137
+ */
138
+ iconBgClass?: string
139
+ /** Classes applied to the icon itself. Defaults to emerald. */
140
+ iconClass?: string
107
141
  /**
108
142
  * Max-width of the dialog card. Defaults to `max-w-md`. Pass any
109
143
  * Tailwind max-w utility, or empty string to opt out.
@@ -135,6 +169,8 @@ const props = withDefaults(defineProps<Props>(), {
135
169
  manualClose: false,
136
170
  scrollable: false,
137
171
  subtitle: '',
172
+ iconBgClass: '',
173
+ iconClass: 'text-emerald-600',
138
174
  })
139
175
 
140
176
  const emit = defineEmits<{
@@ -156,6 +192,12 @@ const shellClass = computed(() =>
156
192
  : 'bg-white border border-gray-200',
157
193
  )
158
194
 
195
+ // The icon chip's background: an explicit `icon-bg-class` wins, else the
196
+ // standard emerald tint in the current theme.
197
+ const iconChipBg = computed(() =>
198
+ props.iconBgClass || (isDark.value ? 'bg-emerald-900/30' : 'bg-emerald-50'),
199
+ )
200
+
159
201
  function onBackdrop() {
160
202
  emit('backdrop')
161
203
  if (!props.manualClose) emit('cancel')
@@ -12,18 +12,14 @@
12
12
  -->
13
13
  <BaseModalShell
14
14
  :title="title"
15
+ :icon="DocumentDuplicateIcon"
15
16
  manual-close
16
17
  @backdrop="handleBackdropClick"
17
18
  >
18
- <template #icon>
19
- <slot name="icon">
20
- <div
21
- class="flex-shrink-0 flex h-12 w-12 items-center justify-center rounded-full"
22
- :class="isDark ? 'bg-emerald-900/30' : 'bg-emerald-50'"
23
- >
24
- <DocumentDuplicateIcon class="h-6 w-6 text-emerald-600" aria-hidden="true" />
25
- </div>
26
- </slot>
19
+ <!-- Forward a caller-supplied icon override; otherwise the shell
20
+ renders the default document icon in the standard emerald chip. -->
21
+ <template v-if="$slots.icon" #icon>
22
+ <slot name="icon" />
27
23
  </template>
28
24
 
29
25
  <p class="text-sm" :class="isDark ? 'text-gray-300' : 'text-gray-600'">
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { useFieldClasses } from './useFieldClasses'
3
+ import { useTheme } from './useTheme'
4
+
5
+ // useTheme is a module-level singleton — grab its ref once and flip it
6
+ // per test. localStorage is cleared so a previous test's persisted theme
7
+ // can't leak into the initial value.
8
+ beforeEach(() => {
9
+ localStorage.clear()
10
+ })
11
+
12
+ describe('useFieldClasses', () => {
13
+ it('returns the light-mode strings when isDark is false', () => {
14
+ const { isDark } = useTheme()
15
+ isDark.value = false
16
+ const f = useFieldClasses()
17
+ expect(f.label.value).toBe('text-gray-600')
18
+ expect(f.input.value).toContain('bg-white')
19
+ expect(f.input.value).toContain('focus:ring-emerald-500')
20
+ })
21
+
22
+ it('returns the dark-mode strings when isDark is true', () => {
23
+ const { isDark } = useTheme()
24
+ isDark.value = true
25
+ const f = useFieldClasses()
26
+ expect(f.label.value).toBe('text-gray-300')
27
+ expect(f.input.value).toContain('bg-gray-800')
28
+ })
29
+
30
+ it('requiredInput flags empty values with the red skin', () => {
31
+ const { isDark } = useTheme()
32
+ isDark.value = false
33
+ const f = useFieldClasses()
34
+ expect(f.requiredInput('')).toContain('border-red-400')
35
+ expect(f.requiredInput(' ')).toContain('border-red-400')
36
+ expect(f.requiredInput(undefined)).toContain('border-red-400')
37
+ expect(f.requiredInput(null)).toContain('border-red-400')
38
+ })
39
+
40
+ it('requiredInput falls back to the standard skin for filled values', () => {
41
+ const { isDark } = useTheme()
42
+ isDark.value = false
43
+ const f = useFieldClasses()
44
+ expect(f.requiredInput('hello')).toBe(f.input.value)
45
+ expect(f.requiredInput(0)).toBe(f.input.value) // 0 is a real value, not empty
46
+ })
47
+
48
+ it('requiredInput follows the active theme', () => {
49
+ const { isDark } = useTheme()
50
+ isDark.value = true
51
+ const f = useFieldClasses()
52
+ expect(f.requiredInput('')).toContain('bg-gray-800')
53
+ expect(f.requiredInput('')).toContain('border-red-500')
54
+ })
55
+ })
@@ -0,0 +1,56 @@
1
+ import { computed, type ComputedRef } from 'vue'
2
+ import { useTheme } from './useTheme'
3
+
4
+ /**
5
+ * Shared form-field class strings for the gray/emerald form skin
6
+ * (the WireMate mock-form family). Extracted because the exact same
7
+ * `labelClasses` / `inputClasses` / `requiredInputClasses` trio was
8
+ * copy-pasted across every form section component.
9
+ *
10
+ * Usage:
11
+ *
12
+ * const f = useFieldClasses()
13
+ * <label :class="f.label">Name</label>
14
+ * <input :class="f.input" />
15
+ * <input :class="f.requiredInput(value)" /> // red skin while empty
16
+ *
17
+ * Vue auto-unwraps the computed refs in template bindings; `requiredInput`
18
+ * is a plain function and is called with the current field value.
19
+ */
20
+ export interface FieldClasses {
21
+ /** Form label: gray-300 / gray-600. */
22
+ label: ComputedRef<string>
23
+ /** Standard input skin: gray-800 dark surface / white light surface, emerald focus. */
24
+ input: ComputedRef<string>
25
+ /**
26
+ * Input skin for required fields: red border + ring while the value is
27
+ * empty (undefined / null / blank after trim), the standard skin otherwise.
28
+ */
29
+ requiredInput: (value: string | number | undefined | null) => string
30
+ }
31
+
32
+ export function useFieldClasses(): FieldClasses {
33
+ const { isDark } = useTheme()
34
+
35
+ const label = computed(() =>
36
+ isDark.value ? 'text-gray-300' : 'text-gray-600',
37
+ )
38
+
39
+ const input = computed(() =>
40
+ isDark.value
41
+ ? 'bg-gray-800 border-gray-600 text-gray-100 placeholder-gray-500 focus:ring-emerald-500 focus:border-emerald-500'
42
+ : 'bg-white border-gray-300 text-gray-800 focus:ring-emerald-500 focus:border-emerald-500',
43
+ )
44
+
45
+ function requiredInput(value: string | number | undefined | null): string {
46
+ const isEmpty = value === undefined || value === null || String(value).trim() === ''
47
+ if (isEmpty) {
48
+ return isDark.value
49
+ ? 'bg-gray-800 border-red-500 text-gray-100 placeholder-gray-500 focus:ring-red-500 focus:border-red-500 ring-1 ring-red-500/30'
50
+ : 'bg-white border-red-400 text-gray-800 focus:ring-red-500 focus:border-red-500 ring-1 ring-red-400/30'
51
+ }
52
+ return input.value
53
+ }
54
+
55
+ return { label, input, requiredInput }
56
+ }
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ export { default as BaseNotificationPanel } from './components/BaseNotificationP
36
36
  export { default as BaseChipButton } from './components/BaseChipButton.vue'
37
37
  export { default as BaseRemoveButton } from './components/BaseRemoveButton.vue'
38
38
  export { default as BaseStatusPill } from './components/BaseStatusPill.vue'
39
+ export { default as BaseFileDropzone } from './components/BaseFileDropzone.vue'
39
40
 
40
41
  // Components — extracted from TradeAutomation
41
42
  export { default as BaseInput } from './components/BaseInput.vue'
@@ -62,6 +63,8 @@ export { useSidebarCollapse } from './composables/useSidebarCollapse'
62
63
  export type { UseSidebarCollapseOptions } from './composables/useSidebarCollapse'
63
64
  export { useNotifications } from './composables/useNotifications'
64
65
  export { useQueryParamSync } from './composables/useQueryParamSync'
66
+ export { useFieldClasses } from './composables/useFieldClasses'
67
+ export type { FieldClasses } from './composables/useFieldClasses'
65
68
 
66
69
  // Enums
67
70
  export { AlertEnum } from './enums/AlertEnum'
@@ -92,9 +95,13 @@ export { getBaseColor, getBaseColorOf } from './utils/util'
92
95
  export {
93
96
  methodBadgeSolid,
94
97
  methodBadgeBright,
98
+ methodBadgeTinted,
95
99
  statusBadgeSolid,
96
100
  statusBadgeTinted,
101
+ statusBadgeSoft,
97
102
  } from './utils/httpColors'
103
+ export { rowKeyMissing, rowValueMissing } from './utils/kvRows'
104
+ export type { KeyValueRowLike } from './utils/kvRows'
98
105
  export { sanitizeHtml, isSafeHref } from './utils/sanitizeHtml'
99
106
  export {
100
107
  fmtNumber,
@@ -102,6 +109,10 @@ export {
102
109
  fmtDateTime,
103
110
  fmtDateTimeMs,
104
111
  fmtDateShort,
112
+ fmtCalendarDate,
113
+ fmtCalendarDateTime,
114
+ fmtMsAsSeconds,
115
+ fmtBytes,
105
116
  fmtPrice,
106
117
  fmtPct,
107
118
  fmtUsd,
@@ -178,3 +178,58 @@ describe('fmtDuration', () => {
178
178
  expect(fmtDuration(59_000)).toBe('0m')
179
179
  })
180
180
  })
181
+
182
+ import { fmtCalendarDate, fmtCalendarDateTime, fmtMsAsSeconds, fmtBytes } from './format'
183
+
184
+ describe('fmtCalendarDate', () => {
185
+ it('renders a "Mon D, YYYY" en-US label', () => {
186
+ expect(fmtCalendarDate('2026-01-05T10:30:00Z')).toMatch(/^Jan \d{1,2}, 2026$/)
187
+ })
188
+ it('renders an em-dash for empty input', () => {
189
+ expect(fmtCalendarDate(null)).toBe(EM_DASH)
190
+ expect(fmtCalendarDate('')).toBe(EM_DASH)
191
+ })
192
+ it('falls back to the raw value for unparseable input', () => {
193
+ expect(fmtCalendarDate('not-a-date')).toBe('not-a-date')
194
+ })
195
+ })
196
+
197
+ describe('fmtCalendarDateTime', () => {
198
+ it('includes the time of day', () => {
199
+ expect(fmtCalendarDateTime('2026-01-05T10:30:00Z')).toMatch(/2026.*\d{1,2}:\d{2}/)
200
+ })
201
+ it('renders an em-dash for empty input', () => {
202
+ expect(fmtCalendarDateTime(undefined)).toBe(EM_DASH)
203
+ })
204
+ })
205
+
206
+ describe('fmtMsAsSeconds', () => {
207
+ it('renders 2 decimals at >= 1s', () => {
208
+ expect(fmtMsAsSeconds(1500)).toBe('= 1.50 s')
209
+ })
210
+ it('renders 3 decimals below 1s', () => {
211
+ expect(fmtMsAsSeconds(250)).toBe('= 0.250 s')
212
+ })
213
+ it('returns empty for zero / negative / non-finite input', () => {
214
+ expect(fmtMsAsSeconds(0)).toBe('')
215
+ expect(fmtMsAsSeconds(-5)).toBe('')
216
+ expect(fmtMsAsSeconds(NaN)).toBe('')
217
+ expect(fmtMsAsSeconds(null)).toBe('')
218
+ })
219
+ })
220
+
221
+ describe('fmtBytes', () => {
222
+ it('renders bytes below 1 KB', () => {
223
+ expect(fmtBytes(512)).toBe('512 B')
224
+ })
225
+ it('renders KB with one decimal', () => {
226
+ expect(fmtBytes(1536)).toBe('1.5 KB')
227
+ })
228
+ it('renders MB with one decimal', () => {
229
+ expect(fmtBytes(2 * 1024 * 1024)).toBe('2.0 MB')
230
+ })
231
+ it('returns empty for zero / falsy input', () => {
232
+ expect(fmtBytes(0)).toBe('')
233
+ expect(fmtBytes(null)).toBe('')
234
+ })
235
+ })
@@ -111,6 +111,63 @@ export function fmtUsd(v: number): string {
111
111
  return `${sign}$${Math.abs(v).toFixed(2)}`
112
112
  }
113
113
 
114
+ /**
115
+ * Format an ISO date string / epoch as a "Mon D, YYYY" calendar label
116
+ * (en-US, e.g. "Jan 5, 2026"). Falls back to the raw value on parse
117
+ * failure and an em-dash on empty input.
118
+ */
119
+ export function fmtCalendarDate(s: string | number | null | undefined): string {
120
+ if (s === null || s === undefined || s === '') return EM_DASH
121
+ const d = new Date(s)
122
+ if (isNaN(d.getTime())) return String(s)
123
+ return d.toLocaleDateString('en-US', {
124
+ year: 'numeric',
125
+ month: 'short',
126
+ day: 'numeric',
127
+ })
128
+ }
129
+
130
+ /**
131
+ * Format an ISO date string / epoch as a "Mon D, YYYY, HH:MM" calendar
132
+ * label with the time of day (en-US). Same fallbacks as fmtCalendarDate.
133
+ */
134
+ export function fmtCalendarDateTime(s: string | number | null | undefined): string {
135
+ if (s === null || s === undefined || s === '') return EM_DASH
136
+ const d = new Date(s)
137
+ if (isNaN(d.getTime())) return String(s)
138
+ return d.toLocaleDateString('en-US', {
139
+ year: 'numeric',
140
+ month: 'short',
141
+ day: 'numeric',
142
+ hour: '2-digit',
143
+ minute: '2-digit',
144
+ })
145
+ }
146
+
147
+ /**
148
+ * Render a millisecond count as a "= 1.50 s" hint so users can
149
+ * sanity-check magnitudes without mental math. Sub-second values get 3
150
+ * decimals ("= 0.250 s"), everything else 2. Returns '' for
151
+ * non-positive / non-finite input so callers can `v-if` it away.
152
+ */
153
+ export function fmtMsAsSeconds(ms: number | null | undefined): string {
154
+ if (typeof ms !== 'number' || !isFinite(ms) || ms <= 0) return ''
155
+ const seconds = ms / 1000
156
+ const decimals = seconds < 1 ? 3 : 2
157
+ return `= ${seconds.toFixed(decimals)} s`
158
+ }
159
+
160
+ /**
161
+ * Pretty-print a byte count with KB/MB granularity ("512 B", "1.5 KB",
162
+ * "2.0 MB"). Returns '' for zero/falsy input.
163
+ */
164
+ export function fmtBytes(bytes: number | null | undefined): string {
165
+ if (!bytes) return ''
166
+ if (bytes < 1024) return `${bytes} B`
167
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
168
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
169
+ }
170
+
114
171
  /**
115
172
  * Format a millisecond duration as compact day/hour/minute units, e.g.
116
173
  * "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;