mgv-backoffice 1.36.0 → 1.36.2
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/README.md +1794 -1719
- package/dist/components/BaseBadge.vue.d.ts +1 -11
- package/dist/components/BaseDropdown.vue.d.ts +1 -1
- package/dist/ui-lib.js +942 -925
- package/dist/ui-lib.umd.cjs +1 -1
- package/dist/utils/specForm.d.ts +1 -1
- package/package.json +54 -54
- package/src/components/BaseAlert.vue +104 -104
- package/src/components/BaseBadge.vue +12 -2
- package/src/components/BaseBarDistribution.test.ts +51 -51
- package/src/components/BaseBarDistribution.vue +73 -73
- package/src/components/BaseChipButton.vue +42 -42
- package/src/components/BaseCodeBlock.test.ts +70 -70
- package/src/components/BaseCodeBlock.vue +74 -74
- package/src/components/BaseCollapsibleSection.vue +113 -113
- package/src/components/BaseConfirmModal.vue +114 -114
- package/src/components/BaseCredentialsForm.test.ts +108 -108
- package/src/components/BaseCredentialsForm.vue +241 -241
- package/src/components/BaseDropdown.test.ts +87 -87
- package/src/components/BaseDropdown.vue +172 -172
- package/src/components/BaseEntityPickerModal.vue +330 -330
- package/src/components/BaseFileDropzone.vue +94 -94
- package/src/components/BaseFilterChip.test.ts +44 -44
- package/src/components/BaseFilterChip.vue +67 -67
- package/src/components/BaseModalShell.vue +209 -209
- package/src/components/BasePageHeader.vue +120 -120
- package/src/components/BasePillPickerModal.test.ts +95 -95
- package/src/components/BaseRemoveButton.vue +22 -22
- package/src/components/BaseSpecFields.test.ts +39 -0
- package/src/components/BaseSpecFields.vue +27 -3
- package/src/components/BaseStatBreakdown.test.ts +56 -56
- package/src/components/BaseStatBreakdown.vue +49 -49
- package/src/components/BaseStatusPill.vue +54 -54
- package/src/components/BaseTextInputModal.vue +140 -140
- package/src/components/BaseToolbarButton.test.ts +48 -48
- package/src/components/BaseToolbarButton.vue +94 -94
- package/src/composables/useFieldClasses.test.ts +55 -55
- package/src/composables/useFieldClasses.ts +56 -56
- package/src/composables/usePolling.test.ts +112 -112
- package/src/composables/usePolling.ts +102 -102
- package/src/composables/useQueryParamSync.test.ts +56 -56
- package/src/composables/useQueryParamSync.ts +42 -42
- package/src/composables/useThemeClasses.test.ts +34 -34
- package/src/composables/useThemeClasses.ts +137 -137
- package/src/index.ts +138 -138
- package/src/style.css +32 -32
- package/src/types/distribution.ts +7 -7
- package/src/types/pillPicker.ts +14 -14
- package/src/types/specField.ts +30 -30
- package/src/types/statBreakdown.ts +6 -6
- package/src/utils/format.test.ts +264 -264
- package/src/utils/format.ts +216 -216
- package/src/utils/httpColors.test.ts +100 -100
- package/src/utils/httpColors.ts +99 -99
- package/src/utils/kvRows.test.ts +47 -47
- package/src/utils/kvRows.ts +34 -34
- package/src/utils/specForm.test.ts +64 -53
- package/src/utils/specForm.ts +49 -48
- package/src/utils/validate.test.ts +82 -82
- package/src/utils/validate.ts +76 -76
package/src/utils/format.ts
CHANGED
|
@@ -1,216 +1,216 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Locale-aware display formatters shared across back-office tables, logs and
|
|
3
|
-
* charts. All are pure and dependency-free; they take the "missing value"
|
|
4
|
-
* branch seriously so callers can hand them raw API values (which may be
|
|
5
|
-
* null/strings) without pre-sanitising.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const EM_DASH = '—'
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Format a number with a fixed number of fraction digits, rendering an
|
|
12
|
-
* em-dash for null/undefined/non-finite input. Accepts numeric strings so
|
|
13
|
-
* BigDecimal-as-string API payloads pass through unchanged.
|
|
14
|
-
*/
|
|
15
|
-
export function fmtNumber(n: number | string | null | undefined, digits = 4): string {
|
|
16
|
-
if (n === null || n === undefined) return EM_DASH
|
|
17
|
-
if (typeof n === 'string' && n.trim() === '') return EM_DASH
|
|
18
|
-
const v = typeof n === 'string' ? Number(n) : n
|
|
19
|
-
if (!isFinite(v)) return EM_DASH
|
|
20
|
-
return v.toLocaleString(undefined, {
|
|
21
|
-
minimumFractionDigits: digits,
|
|
22
|
-
maximumFractionDigits: digits,
|
|
23
|
-
})
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Format an ISO date string or epoch value as a locale date-time, falling
|
|
28
|
-
* back to the raw value on parse failure and an em-dash on empty input.
|
|
29
|
-
* Accepts numbers so epoch-millis timestamps pass through unchanged.
|
|
30
|
-
*/
|
|
31
|
-
export function fmtDate(s: string | number | null | undefined): string {
|
|
32
|
-
if (s === null || s === undefined || s === '') return EM_DASH
|
|
33
|
-
const d = new Date(s)
|
|
34
|
-
if (isNaN(d.getTime())) return String(s)
|
|
35
|
-
return d.toLocaleString()
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Format an epoch-millis timestamp as a compact "Mon D, HH:MM" label —
|
|
40
|
-
* the form used for chart axes and tooltips.
|
|
41
|
-
*/
|
|
42
|
-
export function fmtDateTime(ms: number): string {
|
|
43
|
-
return new Date(ms).toLocaleString(undefined, {
|
|
44
|
-
month: 'short',
|
|
45
|
-
day: 'numeric',
|
|
46
|
-
hour: '2-digit',
|
|
47
|
-
minute: '2-digit',
|
|
48
|
-
})
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Format an epoch-millis timestamp as a short "Mon D" calendar label.
|
|
53
|
-
*/
|
|
54
|
-
export function fmtDateShort(ms: number): string {
|
|
55
|
-
return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Format an ISO string / epoch-millis as a full 24-hour locale date-time WITH
|
|
60
|
-
* the millisecond fraction — for dense feeds where several rows can land in
|
|
61
|
-
* the same second and must stay distinguishable. Falls back to the raw value
|
|
62
|
-
* when the input can't be parsed.
|
|
63
|
-
*/
|
|
64
|
-
export function fmtDateTimeMs(s: string | number): string {
|
|
65
|
-
try {
|
|
66
|
-
return new Date(s).toLocaleString(undefined, {
|
|
67
|
-
year: 'numeric',
|
|
68
|
-
month: '2-digit',
|
|
69
|
-
day: '2-digit',
|
|
70
|
-
hour: '2-digit',
|
|
71
|
-
minute: '2-digit',
|
|
72
|
-
second: '2-digit',
|
|
73
|
-
fractionalSecondDigits: 3,
|
|
74
|
-
hour12: false,
|
|
75
|
-
})
|
|
76
|
-
} catch {
|
|
77
|
-
return String(s)
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Format a price with precision that scales to magnitude: penny stocks and
|
|
83
|
-
* sub-cent crypto get more decimals so the value never collapses to "0.00".
|
|
84
|
-
*/
|
|
85
|
-
export function fmtPrice(n: number): string {
|
|
86
|
-
if (!isFinite(n) || n === 0) return n.toFixed(2)
|
|
87
|
-
const abs = Math.abs(n)
|
|
88
|
-
let digits = 2
|
|
89
|
-
if (abs < 0.0001) digits = 8
|
|
90
|
-
else if (abs < 0.01) digits = 6
|
|
91
|
-
else if (abs < 1) digits = 4
|
|
92
|
-
else if (abs < 100) digits = 3
|
|
93
|
-
return n.toLocaleString(undefined, {
|
|
94
|
-
minimumFractionDigits: digits,
|
|
95
|
-
maximumFractionDigits: digits,
|
|
96
|
-
})
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Format a percentage with an explicit sign (e.g. "+2.50%", "-1.00%").
|
|
101
|
-
*/
|
|
102
|
-
export function fmtPct(n: number, digits = 2): string {
|
|
103
|
-
return `${n >= 0 ? '+' : ''}${n.toFixed(digits)}%`
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Format a signed USD amount with an explicit leading sign (e.g. "+$5.00").
|
|
108
|
-
*/
|
|
109
|
-
export function fmtUsd(v: number): string {
|
|
110
|
-
const sign = v < 0 ? '-' : '+'
|
|
111
|
-
return `${sign}$${Math.abs(v).toFixed(2)}`
|
|
112
|
-
}
|
|
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
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Pretty-print a string that may contain JSON: parseable input is
|
|
173
|
-
* re-serialized with 2-space indentation, anything else is returned
|
|
174
|
-
* verbatim. Handy for request/response bodies of unknown content type.
|
|
175
|
-
*/
|
|
176
|
-
export function formatJson(content: string): string {
|
|
177
|
-
try {
|
|
178
|
-
return JSON.stringify(JSON.parse(content), null, 2)
|
|
179
|
-
} catch {
|
|
180
|
-
return content
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Render an unknown value as a display string: strings pass through,
|
|
186
|
-
* null/undefined become '', everything else is JSON-serialized (falling
|
|
187
|
-
* back to String() for values JSON can't handle, e.g. cycles or BigInt).
|
|
188
|
-
*/
|
|
189
|
-
export function stringifyValue(value: unknown): string {
|
|
190
|
-
if (value === null || value === undefined) return ''
|
|
191
|
-
if (typeof value === 'string') return value
|
|
192
|
-
try {
|
|
193
|
-
return JSON.stringify(value)
|
|
194
|
-
} catch {
|
|
195
|
-
return String(value)
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Format a millisecond duration as compact day/hour/minute units, e.g.
|
|
201
|
-
* "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
|
|
202
|
-
* `maxUnits` caps how many units render (2 → "3d 5h", 3 → "3d 5h 12m").
|
|
203
|
-
* Renders an em-dash for null/undefined/non-finite input.
|
|
204
|
-
*/
|
|
205
|
-
export function fmtDuration(ms: number | null | undefined, maxUnits = 2): string {
|
|
206
|
-
if (ms === null || ms === undefined || !isFinite(ms)) return EM_DASH
|
|
207
|
-
const totalMins = Math.floor(Math.max(0, ms) / 60_000)
|
|
208
|
-
const days = Math.floor(totalMins / 1440)
|
|
209
|
-
const hours = Math.floor((totalMins % 1440) / 60)
|
|
210
|
-
const mins = totalMins % 60
|
|
211
|
-
const parts: string[] = []
|
|
212
|
-
if (days > 0) parts.push(`${days}d`)
|
|
213
|
-
if (days > 0 || hours > 0) parts.push(`${hours}h`)
|
|
214
|
-
parts.push(`${mins}m`)
|
|
215
|
-
return parts.slice(0, maxUnits).join(' ')
|
|
216
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Locale-aware display formatters shared across back-office tables, logs and
|
|
3
|
+
* charts. All are pure and dependency-free; they take the "missing value"
|
|
4
|
+
* branch seriously so callers can hand them raw API values (which may be
|
|
5
|
+
* null/strings) without pre-sanitising.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EM_DASH = '—'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format a number with a fixed number of fraction digits, rendering an
|
|
12
|
+
* em-dash for null/undefined/non-finite input. Accepts numeric strings so
|
|
13
|
+
* BigDecimal-as-string API payloads pass through unchanged.
|
|
14
|
+
*/
|
|
15
|
+
export function fmtNumber(n: number | string | null | undefined, digits = 4): string {
|
|
16
|
+
if (n === null || n === undefined) return EM_DASH
|
|
17
|
+
if (typeof n === 'string' && n.trim() === '') return EM_DASH
|
|
18
|
+
const v = typeof n === 'string' ? Number(n) : n
|
|
19
|
+
if (!isFinite(v)) return EM_DASH
|
|
20
|
+
return v.toLocaleString(undefined, {
|
|
21
|
+
minimumFractionDigits: digits,
|
|
22
|
+
maximumFractionDigits: digits,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Format an ISO date string or epoch value as a locale date-time, falling
|
|
28
|
+
* back to the raw value on parse failure and an em-dash on empty input.
|
|
29
|
+
* Accepts numbers so epoch-millis timestamps pass through unchanged.
|
|
30
|
+
*/
|
|
31
|
+
export function fmtDate(s: string | number | null | undefined): string {
|
|
32
|
+
if (s === null || s === undefined || s === '') return EM_DASH
|
|
33
|
+
const d = new Date(s)
|
|
34
|
+
if (isNaN(d.getTime())) return String(s)
|
|
35
|
+
return d.toLocaleString()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Format an epoch-millis timestamp as a compact "Mon D, HH:MM" label —
|
|
40
|
+
* the form used for chart axes and tooltips.
|
|
41
|
+
*/
|
|
42
|
+
export function fmtDateTime(ms: number): string {
|
|
43
|
+
return new Date(ms).toLocaleString(undefined, {
|
|
44
|
+
month: 'short',
|
|
45
|
+
day: 'numeric',
|
|
46
|
+
hour: '2-digit',
|
|
47
|
+
minute: '2-digit',
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Format an epoch-millis timestamp as a short "Mon D" calendar label.
|
|
53
|
+
*/
|
|
54
|
+
export function fmtDateShort(ms: number): string {
|
|
55
|
+
return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Format an ISO string / epoch-millis as a full 24-hour locale date-time WITH
|
|
60
|
+
* the millisecond fraction — for dense feeds where several rows can land in
|
|
61
|
+
* the same second and must stay distinguishable. Falls back to the raw value
|
|
62
|
+
* when the input can't be parsed.
|
|
63
|
+
*/
|
|
64
|
+
export function fmtDateTimeMs(s: string | number): string {
|
|
65
|
+
try {
|
|
66
|
+
return new Date(s).toLocaleString(undefined, {
|
|
67
|
+
year: 'numeric',
|
|
68
|
+
month: '2-digit',
|
|
69
|
+
day: '2-digit',
|
|
70
|
+
hour: '2-digit',
|
|
71
|
+
minute: '2-digit',
|
|
72
|
+
second: '2-digit',
|
|
73
|
+
fractionalSecondDigits: 3,
|
|
74
|
+
hour12: false,
|
|
75
|
+
})
|
|
76
|
+
} catch {
|
|
77
|
+
return String(s)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Format a price with precision that scales to magnitude: penny stocks and
|
|
83
|
+
* sub-cent crypto get more decimals so the value never collapses to "0.00".
|
|
84
|
+
*/
|
|
85
|
+
export function fmtPrice(n: number): string {
|
|
86
|
+
if (!isFinite(n) || n === 0) return n.toFixed(2)
|
|
87
|
+
const abs = Math.abs(n)
|
|
88
|
+
let digits = 2
|
|
89
|
+
if (abs < 0.0001) digits = 8
|
|
90
|
+
else if (abs < 0.01) digits = 6
|
|
91
|
+
else if (abs < 1) digits = 4
|
|
92
|
+
else if (abs < 100) digits = 3
|
|
93
|
+
return n.toLocaleString(undefined, {
|
|
94
|
+
minimumFractionDigits: digits,
|
|
95
|
+
maximumFractionDigits: digits,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Format a percentage with an explicit sign (e.g. "+2.50%", "-1.00%").
|
|
101
|
+
*/
|
|
102
|
+
export function fmtPct(n: number, digits = 2): string {
|
|
103
|
+
return `${n >= 0 ? '+' : ''}${n.toFixed(digits)}%`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Format a signed USD amount with an explicit leading sign (e.g. "+$5.00").
|
|
108
|
+
*/
|
|
109
|
+
export function fmtUsd(v: number): string {
|
|
110
|
+
const sign = v < 0 ? '-' : '+'
|
|
111
|
+
return `${sign}$${Math.abs(v).toFixed(2)}`
|
|
112
|
+
}
|
|
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
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Pretty-print a string that may contain JSON: parseable input is
|
|
173
|
+
* re-serialized with 2-space indentation, anything else is returned
|
|
174
|
+
* verbatim. Handy for request/response bodies of unknown content type.
|
|
175
|
+
*/
|
|
176
|
+
export function formatJson(content: string): string {
|
|
177
|
+
try {
|
|
178
|
+
return JSON.stringify(JSON.parse(content), null, 2)
|
|
179
|
+
} catch {
|
|
180
|
+
return content
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Render an unknown value as a display string: strings pass through,
|
|
186
|
+
* null/undefined become '', everything else is JSON-serialized (falling
|
|
187
|
+
* back to String() for values JSON can't handle, e.g. cycles or BigInt).
|
|
188
|
+
*/
|
|
189
|
+
export function stringifyValue(value: unknown): string {
|
|
190
|
+
if (value === null || value === undefined) return ''
|
|
191
|
+
if (typeof value === 'string') return value
|
|
192
|
+
try {
|
|
193
|
+
return JSON.stringify(value)
|
|
194
|
+
} catch {
|
|
195
|
+
return String(value)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Format a millisecond duration as compact day/hour/minute units, e.g.
|
|
201
|
+
* "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
|
|
202
|
+
* `maxUnits` caps how many units render (2 → "3d 5h", 3 → "3d 5h 12m").
|
|
203
|
+
* Renders an em-dash for null/undefined/non-finite input.
|
|
204
|
+
*/
|
|
205
|
+
export function fmtDuration(ms: number | null | undefined, maxUnits = 2): string {
|
|
206
|
+
if (ms === null || ms === undefined || !isFinite(ms)) return EM_DASH
|
|
207
|
+
const totalMins = Math.floor(Math.max(0, ms) / 60_000)
|
|
208
|
+
const days = Math.floor(totalMins / 1440)
|
|
209
|
+
const hours = Math.floor((totalMins % 1440) / 60)
|
|
210
|
+
const mins = totalMins % 60
|
|
211
|
+
const parts: string[] = []
|
|
212
|
+
if (days > 0) parts.push(`${days}d`)
|
|
213
|
+
if (days > 0 || hours > 0) parts.push(`${hours}h`)
|
|
214
|
+
parts.push(`${mins}m`)
|
|
215
|
+
return parts.slice(0, maxUnits).join(' ')
|
|
216
|
+
}
|
|
@@ -1,100 +1,100 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
methodBadgeSolid,
|
|
4
|
-
methodBadgeBright,
|
|
5
|
-
statusBadgeSolid,
|
|
6
|
-
statusBadgeTinted,
|
|
7
|
-
} from './httpColors'
|
|
8
|
-
|
|
9
|
-
describe('methodBadgeSolid', () => {
|
|
10
|
-
it('maps known methods (case-insensitive)', () => {
|
|
11
|
-
expect(methodBadgeSolid('GET')).toBe('bg-blue-600')
|
|
12
|
-
expect(methodBadgeSolid('get')).toBe('bg-blue-600')
|
|
13
|
-
expect(methodBadgeSolid('DELETE')).toBe('bg-red-600')
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('falls back to gray for missing/unknown methods', () => {
|
|
17
|
-
expect(methodBadgeSolid()).toBe('bg-gray-600')
|
|
18
|
-
expect(methodBadgeSolid('TRACE')).toBe('bg-gray-600')
|
|
19
|
-
})
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
describe('methodBadgeBright', () => {
|
|
23
|
-
it('maps known methods', () => {
|
|
24
|
-
expect(methodBadgeBright('POST')).toBe('bg-green-500')
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
it('falls back to gray', () => {
|
|
28
|
-
expect(methodBadgeBright()).toBe('bg-gray-500')
|
|
29
|
-
expect(methodBadgeBright('WAT')).toBe('bg-gray-500')
|
|
30
|
-
})
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
describe('statusBadgeSolid', () => {
|
|
34
|
-
it('buckets by status class', () => {
|
|
35
|
-
expect(statusBadgeSolid(204)).toBe('bg-emerald-600')
|
|
36
|
-
expect(statusBadgeSolid(301)).toBe('bg-blue-600')
|
|
37
|
-
expect(statusBadgeSolid(404)).toBe('bg-amber-600')
|
|
38
|
-
expect(statusBadgeSolid(500)).toBe('bg-red-600')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('falls back to gray for missing/unknown codes', () => {
|
|
42
|
-
expect(statusBadgeSolid()).toBe('bg-gray-600')
|
|
43
|
-
expect(statusBadgeSolid(100)).toBe('bg-gray-600')
|
|
44
|
-
})
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
describe('statusBadgeTinted', () => {
|
|
48
|
-
it('returns dark-theme classes', () => {
|
|
49
|
-
expect(statusBadgeTinted(200, true)).toBe('bg-green-500/15 text-green-400')
|
|
50
|
-
expect(statusBadgeTinted(404, true)).toBe('bg-yellow-500/15 text-yellow-400')
|
|
51
|
-
expect(statusBadgeTinted(500, true)).toBe('bg-red-500/15 text-red-400')
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('returns light-theme classes', () => {
|
|
55
|
-
expect(statusBadgeTinted(200, false)).toBe('bg-green-100 text-green-800')
|
|
56
|
-
expect(statusBadgeTinted(404, false)).toBe('bg-yellow-100 text-yellow-800')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('defaults a missing status to 2xx', () => {
|
|
60
|
-
expect(statusBadgeTinted(undefined, false)).toBe('bg-green-100 text-green-800')
|
|
61
|
-
})
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
import { methodBadgeTinted, statusBadgeSoft } from './httpColors'
|
|
65
|
-
|
|
66
|
-
describe('methodBadgeTinted', () => {
|
|
67
|
-
it('gives each method its own hue (dark)', () => {
|
|
68
|
-
expect(methodBadgeTinted('GET', true)).toBe('bg-blue-500/15 text-blue-400')
|
|
69
|
-
expect(methodBadgeTinted('POST', true)).toBe('bg-emerald-500/15 text-emerald-400')
|
|
70
|
-
expect(methodBadgeTinted('PUT', true)).toBe('bg-amber-500/15 text-amber-400')
|
|
71
|
-
expect(methodBadgeTinted('DELETE', true)).toBe('bg-red-500/15 text-red-400')
|
|
72
|
-
expect(methodBadgeTinted('PATCH', true)).toBe('bg-purple-500/15 text-purple-400')
|
|
73
|
-
expect(methodBadgeTinted('HEAD', true)).toBe('bg-sky-500/15 text-sky-400')
|
|
74
|
-
})
|
|
75
|
-
it('uses the light palette when isDark is false', () => {
|
|
76
|
-
expect(methodBadgeTinted('GET', false)).toBe('bg-blue-100 text-blue-700')
|
|
77
|
-
})
|
|
78
|
-
it('is case-insensitive and falls back to gray for unknown/ANY/missing', () => {
|
|
79
|
-
expect(methodBadgeTinted('get', true)).toBe('bg-blue-500/15 text-blue-400')
|
|
80
|
-
expect(methodBadgeTinted(undefined, false)).toBe('bg-gray-100 text-gray-600')
|
|
81
|
-
expect(methodBadgeTinted('OPTIONS', false)).toBe('bg-gray-100 text-gray-600')
|
|
82
|
-
})
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
describe('statusBadgeSoft', () => {
|
|
86
|
-
it('buckets by status class (light)', () => {
|
|
87
|
-
expect(statusBadgeSoft(201, false)).toBe('bg-emerald-100 text-emerald-700')
|
|
88
|
-
expect(statusBadgeSoft(302, false)).toBe('bg-sky-100 text-sky-700')
|
|
89
|
-
expect(statusBadgeSoft(404, false)).toBe('bg-amber-100 text-amber-700')
|
|
90
|
-
expect(statusBadgeSoft(503, false)).toBe('bg-red-100 text-red-700')
|
|
91
|
-
})
|
|
92
|
-
it('uses tinted surfaces in dark mode', () => {
|
|
93
|
-
expect(statusBadgeSoft(200, true)).toBe('bg-emerald-500/15 text-emerald-300')
|
|
94
|
-
})
|
|
95
|
-
it('defaults undefined to 200 and gray for out-of-range codes', () => {
|
|
96
|
-
expect(statusBadgeSoft(undefined, false)).toBe('bg-emerald-100 text-emerald-700')
|
|
97
|
-
expect(statusBadgeSoft(999, false)).toBe('bg-gray-100 text-gray-600')
|
|
98
|
-
expect(statusBadgeSoft(100, true)).toBe('bg-gray-800 text-gray-400')
|
|
99
|
-
})
|
|
100
|
-
})
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
methodBadgeSolid,
|
|
4
|
+
methodBadgeBright,
|
|
5
|
+
statusBadgeSolid,
|
|
6
|
+
statusBadgeTinted,
|
|
7
|
+
} from './httpColors'
|
|
8
|
+
|
|
9
|
+
describe('methodBadgeSolid', () => {
|
|
10
|
+
it('maps known methods (case-insensitive)', () => {
|
|
11
|
+
expect(methodBadgeSolid('GET')).toBe('bg-blue-600')
|
|
12
|
+
expect(methodBadgeSolid('get')).toBe('bg-blue-600')
|
|
13
|
+
expect(methodBadgeSolid('DELETE')).toBe('bg-red-600')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('falls back to gray for missing/unknown methods', () => {
|
|
17
|
+
expect(methodBadgeSolid()).toBe('bg-gray-600')
|
|
18
|
+
expect(methodBadgeSolid('TRACE')).toBe('bg-gray-600')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('methodBadgeBright', () => {
|
|
23
|
+
it('maps known methods', () => {
|
|
24
|
+
expect(methodBadgeBright('POST')).toBe('bg-green-500')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('falls back to gray', () => {
|
|
28
|
+
expect(methodBadgeBright()).toBe('bg-gray-500')
|
|
29
|
+
expect(methodBadgeBright('WAT')).toBe('bg-gray-500')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('statusBadgeSolid', () => {
|
|
34
|
+
it('buckets by status class', () => {
|
|
35
|
+
expect(statusBadgeSolid(204)).toBe('bg-emerald-600')
|
|
36
|
+
expect(statusBadgeSolid(301)).toBe('bg-blue-600')
|
|
37
|
+
expect(statusBadgeSolid(404)).toBe('bg-amber-600')
|
|
38
|
+
expect(statusBadgeSolid(500)).toBe('bg-red-600')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('falls back to gray for missing/unknown codes', () => {
|
|
42
|
+
expect(statusBadgeSolid()).toBe('bg-gray-600')
|
|
43
|
+
expect(statusBadgeSolid(100)).toBe('bg-gray-600')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('statusBadgeTinted', () => {
|
|
48
|
+
it('returns dark-theme classes', () => {
|
|
49
|
+
expect(statusBadgeTinted(200, true)).toBe('bg-green-500/15 text-green-400')
|
|
50
|
+
expect(statusBadgeTinted(404, true)).toBe('bg-yellow-500/15 text-yellow-400')
|
|
51
|
+
expect(statusBadgeTinted(500, true)).toBe('bg-red-500/15 text-red-400')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns light-theme classes', () => {
|
|
55
|
+
expect(statusBadgeTinted(200, false)).toBe('bg-green-100 text-green-800')
|
|
56
|
+
expect(statusBadgeTinted(404, false)).toBe('bg-yellow-100 text-yellow-800')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('defaults a missing status to 2xx', () => {
|
|
60
|
+
expect(statusBadgeTinted(undefined, false)).toBe('bg-green-100 text-green-800')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
import { methodBadgeTinted, statusBadgeSoft } from './httpColors'
|
|
65
|
+
|
|
66
|
+
describe('methodBadgeTinted', () => {
|
|
67
|
+
it('gives each method its own hue (dark)', () => {
|
|
68
|
+
expect(methodBadgeTinted('GET', true)).toBe('bg-blue-500/15 text-blue-400')
|
|
69
|
+
expect(methodBadgeTinted('POST', true)).toBe('bg-emerald-500/15 text-emerald-400')
|
|
70
|
+
expect(methodBadgeTinted('PUT', true)).toBe('bg-amber-500/15 text-amber-400')
|
|
71
|
+
expect(methodBadgeTinted('DELETE', true)).toBe('bg-red-500/15 text-red-400')
|
|
72
|
+
expect(methodBadgeTinted('PATCH', true)).toBe('bg-purple-500/15 text-purple-400')
|
|
73
|
+
expect(methodBadgeTinted('HEAD', true)).toBe('bg-sky-500/15 text-sky-400')
|
|
74
|
+
})
|
|
75
|
+
it('uses the light palette when isDark is false', () => {
|
|
76
|
+
expect(methodBadgeTinted('GET', false)).toBe('bg-blue-100 text-blue-700')
|
|
77
|
+
})
|
|
78
|
+
it('is case-insensitive and falls back to gray for unknown/ANY/missing', () => {
|
|
79
|
+
expect(methodBadgeTinted('get', true)).toBe('bg-blue-500/15 text-blue-400')
|
|
80
|
+
expect(methodBadgeTinted(undefined, false)).toBe('bg-gray-100 text-gray-600')
|
|
81
|
+
expect(methodBadgeTinted('OPTIONS', false)).toBe('bg-gray-100 text-gray-600')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('statusBadgeSoft', () => {
|
|
86
|
+
it('buckets by status class (light)', () => {
|
|
87
|
+
expect(statusBadgeSoft(201, false)).toBe('bg-emerald-100 text-emerald-700')
|
|
88
|
+
expect(statusBadgeSoft(302, false)).toBe('bg-sky-100 text-sky-700')
|
|
89
|
+
expect(statusBadgeSoft(404, false)).toBe('bg-amber-100 text-amber-700')
|
|
90
|
+
expect(statusBadgeSoft(503, false)).toBe('bg-red-100 text-red-700')
|
|
91
|
+
})
|
|
92
|
+
it('uses tinted surfaces in dark mode', () => {
|
|
93
|
+
expect(statusBadgeSoft(200, true)).toBe('bg-emerald-500/15 text-emerald-300')
|
|
94
|
+
})
|
|
95
|
+
it('defaults undefined to 200 and gray for out-of-range codes', () => {
|
|
96
|
+
expect(statusBadgeSoft(undefined, false)).toBe('bg-emerald-100 text-emerald-700')
|
|
97
|
+
expect(statusBadgeSoft(999, false)).toBe('bg-gray-100 text-gray-600')
|
|
98
|
+
expect(statusBadgeSoft(100, true)).toBe('bg-gray-800 text-gray-400')
|
|
99
|
+
})
|
|
100
|
+
})
|