mgv-backoffice 1.25.0 → 1.28.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/package.json CHANGED
@@ -1,54 +1,54 @@
1
- {
2
- "name": "mgv-backoffice",
3
- "version": "1.25.0",
4
- "description": "Shared Vue 3 UI component library",
5
- "type": "module",
6
- "main": "./dist/ui-lib.umd.cjs",
7
- "module": "./dist/ui-lib.js",
8
- "types": "./dist/index.d.ts",
9
- "exports": {
10
- ".": {
11
- "types": "./dist/index.d.ts",
12
- "import": "./dist/ui-lib.js",
13
- "require": "./dist/ui-lib.umd.cjs"
14
- },
15
- "./dist/style.css": "./dist/ui-lib.css",
16
- "./tailwind.safelist": "./tailwind.safelist.js",
17
- "./tailwind.safelist.css": "./tailwind.safelist.css"
18
- },
19
- "files": [
20
- "dist",
21
- "src",
22
- "tailwind.safelist.js",
23
- "tailwind.safelist.css"
24
- ],
25
- "scripts": {
26
- "safelist": "node scripts/generate-safelist.mjs",
27
- "prebuild": "npm run safelist",
28
- "build": "vite build",
29
- "dev": "vite build --watch",
30
- "test": "vitest run",
31
- "test:watch": "vitest"
32
- },
33
- "peerDependencies": {
34
- "@heroicons/vue": "^2.0.0",
35
- "vue": "^3.5.0",
36
- "vue-router": "^5.0.0"
37
- },
38
- "devDependencies": {
39
- "@heroicons/vue": "^2.2.0",
40
- "@tailwindcss/vite": "^4.3.1",
41
- "@vitejs/plugin-vue": "^6.0.7",
42
- "@vue/language-core": "^3.3.5",
43
- "@vue/test-utils": "^2.4.11",
44
- "jsdom": "^29.1.1",
45
- "sass": "^1.101.0",
46
- "tailwindcss": "^4.3.1",
47
- "typescript": "^6.0.3",
48
- "vite": "^8.1.0",
49
- "vite-plugin-dts": "^5.0.3",
50
- "vitest": "^4.1.9",
51
- "vue": "^3.5.38",
52
- "vue-router": "^5.1.0"
53
- }
54
- }
1
+ {
2
+ "name": "mgv-backoffice",
3
+ "version": "1.28.0",
4
+ "description": "Shared Vue 3 UI component library",
5
+ "type": "module",
6
+ "main": "./dist/ui-lib.umd.cjs",
7
+ "module": "./dist/ui-lib.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/ui-lib.js",
13
+ "require": "./dist/ui-lib.umd.cjs"
14
+ },
15
+ "./dist/style.css": "./dist/ui-lib.css",
16
+ "./tailwind.safelist": "./tailwind.safelist.js",
17
+ "./tailwind.safelist.css": "./tailwind.safelist.css"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "tailwind.safelist.js",
23
+ "tailwind.safelist.css"
24
+ ],
25
+ "scripts": {
26
+ "safelist": "node scripts/generate-safelist.mjs",
27
+ "prebuild": "npm run safelist",
28
+ "build": "vite build",
29
+ "dev": "vite build --watch",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest"
32
+ },
33
+ "peerDependencies": {
34
+ "@heroicons/vue": "^2.0.0",
35
+ "vue": "^3.5.0",
36
+ "vue-router": "^5.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@heroicons/vue": "^2.2.0",
40
+ "@tailwindcss/vite": "^4.3.1",
41
+ "@vitejs/plugin-vue": "^6.0.7",
42
+ "@vue/language-core": "^3.3.5",
43
+ "@vue/test-utils": "^2.4.11",
44
+ "jsdom": "^29.1.1",
45
+ "sass": "^1.101.0",
46
+ "tailwindcss": "^4.3.1",
47
+ "typescript": "^6.0.3",
48
+ "vite": "^8.1.0",
49
+ "vite-plugin-dts": "^5.0.3",
50
+ "vitest": "^4.1.9",
51
+ "vue": "^3.5.38",
52
+ "vue-router": "^5.1.0"
53
+ }
54
+ }
@@ -1,8 +1,31 @@
1
1
  <template>
2
- <div :class="computeCss()" role="alert">
3
- <component :is="iconComponent" class="w-6 h-6 flex-shrink-0" :class="iconColorClass"/>
4
- <div>
5
- <span class="font-medium pl-1">{{ title }}</span>
2
+ <!--
3
+ Inline notice panel (error / warning / success / info).
4
+
5
+ v2 (1.27.0): rewritten from the legacy dynamic-class version —
6
+ • theme-aware via the shared `isDark` ref (same pattern as the other
7
+ extracted components; no runtime-composed class names, so every
8
+ class survives Tailwind's static scan)
9
+ • optional default slot for body content below the title
10
+ • `compact` variant for slim in-form warnings (text-xs, small icon)
11
+ Backward compatible: `<BaseAlert title="..." :color="AlertEnum.X" />`
12
+ renders the same single-line alert as before, mb-4 included.
13
+ -->
14
+ <div
15
+ role="alert"
16
+ class="flex gap-3 mb-4 rounded-lg border text-sm transition-colors"
17
+ :class="[compact ? 'items-start px-3 py-2 text-xs' : 'items-start p-4', palette]"
18
+ >
19
+ <component
20
+ :is="iconComponent"
21
+ class="shrink-0"
22
+ :class="[compact ? 'w-4 h-4 mt-px' : 'w-5 h-5 mt-0.5', iconColorClass]"
23
+ aria-hidden="true"
24
+ />
25
+ <div class="min-w-0 flex-1">
26
+ <p v-if="title" class="font-medium">{{ title }}</p>
27
+ <!-- Body — caller-styled free-form content under the title. -->
28
+ <slot />
6
29
  </div>
7
30
  </div>
8
31
  </template>
@@ -14,45 +37,68 @@ import {
14
37
  ExclamationTriangleIcon,
15
38
  InformationCircleIcon,
16
39
  } from '@heroicons/vue/24/outline'
17
- import { AlertEnum } from "../enums/AlertEnum";
18
- import { computed, PropType } from "vue";
19
- import { getBaseColor } from "../utils/util";
20
-
21
- const props = defineProps({
22
- title: {
23
- type: String,
24
- required: false,
25
- default: ''
26
- },
27
- color: {
28
- type: String as PropType<AlertEnum>,
29
- required: false,
30
- default: AlertEnum.ERROR
31
- }
40
+ import { AlertEnum } from '../enums/AlertEnum'
41
+ import { computed } from 'vue'
42
+ import { useTheme } from '../composables/useTheme'
43
+
44
+ interface Props {
45
+ /** Bold headline. Optional when the slot carries the whole message. */
46
+ title?: string
47
+ /** Alert mode — drives palette and icon. Defaults to ERROR. */
48
+ color?: AlertEnum
49
+ /** Slim variant for in-form warnings: text-xs, smaller icon/padding. */
50
+ compact?: boolean
51
+ }
52
+
53
+ const props = withDefaults(defineProps<Props>(), {
54
+ title: '',
55
+ color: AlertEnum.ERROR,
56
+ compact: false,
32
57
  })
33
58
 
59
+ const { isDark } = useTheme()
60
+
61
+ // Literal palette table so Tailwind's scanner sees every class.
62
+ const PALETTES: Record<AlertEnum, { light: string; dark: string }> = {
63
+ [AlertEnum.ERROR]: {
64
+ light: 'bg-red-50 border-red-200 text-red-700',
65
+ dark: 'bg-red-500/10 border-red-500/30 text-red-300',
66
+ },
67
+ [AlertEnum.WARNING]: {
68
+ light: 'bg-amber-50 border-amber-200 text-amber-700',
69
+ dark: 'bg-amber-500/10 border-amber-500/30 text-amber-300',
70
+ },
71
+ [AlertEnum.SUCCESS]: {
72
+ light: 'bg-emerald-50 border-emerald-200 text-emerald-700',
73
+ dark: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300',
74
+ },
75
+ [AlertEnum.INFO]: {
76
+ light: 'bg-sky-50 border-sky-200 text-sky-700',
77
+ dark: 'bg-sky-500/10 border-sky-500/30 text-sky-300',
78
+ },
79
+ }
80
+
81
+ const palette = computed(() =>
82
+ isDark.value ? PALETTES[props.color].dark : PALETTES[props.color].light,
83
+ )
84
+
34
85
  // Icon + tint follow the alert mode so an error doesn't render with a
35
86
  // neutral info glyph.
36
87
  const iconComponent = computed(() => {
37
88
  switch (props.color) {
38
- case AlertEnum.SUCCESS: return CheckCircleIcon;
39
- case AlertEnum.WARNING: return ExclamationTriangleIcon;
40
- case AlertEnum.INFO: return InformationCircleIcon;
41
- default: return ExclamationCircleIcon;
89
+ case AlertEnum.SUCCESS: return CheckCircleIcon
90
+ case AlertEnum.WARNING: return ExclamationTriangleIcon
91
+ case AlertEnum.INFO: return InformationCircleIcon
92
+ default: return ExclamationCircleIcon
42
93
  }
43
94
  })
44
95
 
45
96
  const iconColorClass = computed(() => {
46
97
  switch (props.color) {
47
- case AlertEnum.SUCCESS: return 'text-green-500';
48
- case AlertEnum.WARNING: return 'text-yellow-500';
49
- case AlertEnum.INFO: return 'text-gray-500';
50
- default: return 'text-red-500';
98
+ case AlertEnum.SUCCESS: return 'text-emerald-500'
99
+ case AlertEnum.WARNING: return 'text-amber-500'
100
+ case AlertEnum.INFO: return 'text-sky-500'
101
+ default: return 'text-red-500'
51
102
  }
52
103
  })
53
-
54
- function computeCss() {
55
- let baseColor = getBaseColor(props.color);
56
- return "flex items-center p-4 mb-4 text-sm text-" + baseColor + "-800 border border-" + baseColor + "-300 rounded-lg bg-" + baseColor + "-50 dark:bg-gray-800 dark:text-" + baseColor + "-400 dark:border-" + baseColor + "-800"
57
- }
58
104
  </script>
@@ -0,0 +1,42 @@
1
+ <template>
2
+ <!--
3
+ Small tinted "chip" action button — the compact emerald "+ Add" pill
4
+ used above repeatable form rows (extracted from WireMate's CreateMock,
5
+ where the identical class string appeared seven times).
6
+
7
+ Label comes from the default slot so callers can mix text and icons.
8
+ -->
9
+ <button
10
+ type="button"
11
+ :disabled="disabled"
12
+ class="inline-flex items-center gap-1 font-medium rounded-md border transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
13
+ :class="[
14
+ size === 'xs' ? 'text-xs px-2 py-0.5' : 'text-xs px-2.5 py-1',
15
+ isDark
16
+ ? 'text-emerald-300 border-emerald-700 bg-emerald-900/30 hover:bg-emerald-800/50'
17
+ : 'text-emerald-700 border-emerald-200 bg-emerald-50 hover:bg-emerald-100',
18
+ ]"
19
+ @click="emit('click')"
20
+ >
21
+ <slot />
22
+ </button>
23
+ </template>
24
+
25
+ <script lang="ts" setup>
26
+ import { useTheme } from '../composables/useTheme'
27
+
28
+ interface Props {
29
+ /** 'sm' = px-2.5 py-1 (default); 'xs' = px-2 py-0.5 for tight corners. */
30
+ size?: 'xs' | 'sm'
31
+ disabled?: boolean
32
+ }
33
+
34
+ withDefaults(defineProps<Props>(), {
35
+ size: 'sm',
36
+ disabled: false,
37
+ })
38
+
39
+ const emit = defineEmits<{ click: [] }>()
40
+
41
+ const { isDark } = useTheme()
42
+ </script>
@@ -18,11 +18,17 @@
18
18
  • toggle — fired when the user clicks the header. The parent
19
19
  flips its own collapsed state in response.
20
20
  -->
21
- <section class="rounded-xl border overflow-hidden transition-colors" :class="t.cardAlt">
21
+ <!--
22
+ NOTE: no `overflow-hidden` on the section — it would clip floating
23
+ menus (BaseDropdown) rendered near the bottom of the body. The child
24
+ corners are rounded explicitly instead so the card silhouette is
25
+ unchanged.
26
+ -->
27
+ <section class="rounded-xl border transition-colors" :class="t.cardAlt">
22
28
  <button
23
29
  type="button"
24
30
  class="w-full flex items-center justify-between p-4 text-left transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500"
25
- :class="headerClasses"
31
+ :class="[headerClasses, collapsed ? 'rounded-xl' : 'rounded-t-xl']"
26
32
  :aria-expanded="!collapsed"
27
33
  @click="emit('toggle')"
28
34
  >
@@ -50,7 +56,7 @@
50
56
  </button>
51
57
  <div
52
58
  v-show="!collapsed"
53
- class="p-6 border-t"
59
+ class="p-6 border-t rounded-b-xl"
54
60
  :class="[t.border, resolvedBodyClass]"
55
61
  >
56
62
  <slot />
@@ -25,7 +25,9 @@
25
25
  :aria-expanded="open"
26
26
  @click="toggle"
27
27
  >
28
- <span :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
28
+ <!-- min-w-0 + truncate so long labels ellipsize inside narrow
29
+ (fixed-column) triggers instead of wrapping or widening them. -->
30
+ <span class="min-w-0 truncate" :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
29
31
  {{ selectedOption ? selectedOption.label : placeholder }}
30
32
  </span>
31
33
  <ChevronDownIcon
@@ -39,7 +41,7 @@
39
41
  v-if="open"
40
42
  role="listbox"
41
43
  :aria-label="ariaLabel || placeholder"
42
- class="absolute z-20 mt-1 w-full max-h-60 overflow-auto rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 py-1 shadow-lg focus:outline-none"
44
+ class="absolute z-20 mt-1 w-full min-w-max max-w-xs max-h-60 overflow-auto rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 py-1 shadow-lg focus:outline-none"
43
45
  >
44
46
  <li
45
47
  v-for="opt in options"
@@ -0,0 +1,22 @@
1
+ <template>
2
+ <!--
3
+ The "×" remove-row affordance used beside repeatable form rows
4
+ (extracted from WireMate's CreateMock, where the identical red ×
5
+ button appeared on every key/value, matcher, and cookie row).
6
+
7
+ Renders a multiplication sign; name it for screen readers via
8
+ `aria-label` (falls through as an attr, as do `title`, `disabled`,
9
+ and any extra classes like `pt-1` / `self-start`).
10
+ -->
11
+ <button
12
+ type="button"
13
+ class="px-2 text-lg leading-none text-red-400 hover:text-red-600 transition-colors cursor-pointer rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:text-gray-400 disabled:hover:text-gray-400"
14
+ @click="emit('click')"
15
+ >
16
+ &times;
17
+ </button>
18
+ </template>
19
+
20
+ <script lang="ts" setup>
21
+ const emit = defineEmits<{ click: [] }>()
22
+ </script>
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import BaseStatBreakdown from './BaseStatBreakdown.vue'
4
+ import type { StatBreakdownItem } from '../types/statBreakdown'
5
+
6
+ const items: StatBreakdownItem[] = [
7
+ { label: 'Alpaca', value: 12.5 },
8
+ { label: 'Kraken', value: -3 },
9
+ { label: 'Binance', value: null },
10
+ ]
11
+
12
+ describe('BaseStatBreakdown', () => {
13
+ it('renders one row per item with label and formatted value', () => {
14
+ const wrapper = mount(BaseStatBreakdown, {
15
+ props: { items, currency: '$' },
16
+ })
17
+ const rows = wrapper.findAll('.flex')
18
+ expect(rows).toHaveLength(3)
19
+ expect(rows[0].text()).toContain('Alpaca')
20
+ expect(rows[0].text()).toContain('$12.50')
21
+ })
22
+
23
+ it('renders an em-dash for null values instead of a misleading 0', () => {
24
+ const wrapper = mount(BaseStatBreakdown, { props: { items } })
25
+ const rows = wrapper.findAll('.flex')
26
+ expect(rows[2].text()).toContain('—')
27
+ expect(rows[2].text()).not.toContain('0.00')
28
+ })
29
+
30
+ it('honours the decimals prop', () => {
31
+ const wrapper = mount(BaseStatBreakdown, {
32
+ props: { items: [{ label: 'BTC', value: 0.5 }], decimals: 8 },
33
+ })
34
+ expect(wrapper.text()).toContain('0.50000000')
35
+ })
36
+
37
+ it('prefixes a plus and colours by sign when signed', () => {
38
+ const wrapper = mount(BaseStatBreakdown, {
39
+ props: { items, currency: '$', signed: true },
40
+ })
41
+ const values = wrapper.findAll('.font-mono')
42
+ expect(values[0].text()).toBe('+$12.50')
43
+ expect(values[0].classes().join(' ')).toContain('text-emerald-600')
44
+ expect(values[1].text()).toBe('-$3.00')
45
+ expect(values[1].classes().join(' ')).toContain('text-red-600')
46
+ // Null rows stay neutral even in signed mode.
47
+ expect(values[2].classes().join(' ')).toContain('text-slate-500')
48
+ })
49
+
50
+ it('stays neutral-coloured when signed is off', () => {
51
+ const wrapper = mount(BaseStatBreakdown, {
52
+ props: { items: [{ label: 'Alpaca', value: 12.5 }] },
53
+ })
54
+ expect(wrapper.find('.font-mono').classes().join(' ')).toContain('text-slate-500')
55
+ })
56
+ })
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ // Compact per-item breakdown meant to sit under a summary/stat card (pairs
3
+ // with EarningsCard). Each item renders on its own line — label left, value
4
+ // right in monospace. A null/undefined value (a source that is unconfigured,
5
+ // unreachable, or has no matching rows) shows a dash rather than a
6
+ // misleading 0. With `signed`, values gain an explicit "+" and are coloured
7
+ // green/red by sign.
8
+ import type { StatBreakdownItem } from '../types/statBreakdown'
9
+
10
+ const props = withDefaults(
11
+ defineProps<{
12
+ items: StatBreakdownItem[]
13
+ currency?: string
14
+ decimals?: number
15
+ signed?: boolean
16
+ }>(),
17
+ { currency: '', decimals: 2, signed: false },
18
+ )
19
+
20
+ function fmt(v: number | null | undefined): string {
21
+ if (v === null || v === undefined) return '—'
22
+ // Sign sits before the currency symbol ("-$3.00", "+$5.00" — same shape
23
+ // as fmtUsd); the plus only appears in signed mode.
24
+ const sign = v < 0 ? '-' : props.signed ? '+' : ''
25
+ return `${sign}${props.currency}${Math.abs(v).toFixed(props.decimals)}`
26
+ }
27
+
28
+ function color(v: number | null | undefined): string {
29
+ if (!props.signed || v === null || v === undefined) {
30
+ return 'text-slate-500 dark:text-slate-400'
31
+ }
32
+ return v >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <div class="mt-1.5 space-y-0.5 px-1">
38
+ <div
39
+ v-for="item in items"
40
+ :key="item.label"
41
+ class="flex items-center justify-between text-[11px]"
42
+ >
43
+ <span class="text-slate-400 dark:text-slate-500">{{ item.label }}</span>
44
+ <span class="font-mono tabular-nums font-medium" :class="color(item.value)">
45
+ {{ fmt(item.value) }}
46
+ </span>
47
+ </div>
48
+ </div>
49
+ </template>
@@ -0,0 +1,54 @@
1
+ <template>
2
+ <!--
3
+ Connection/health status pill: a colored dot (pulsing while ok) next
4
+ to a short label, on a tinted rounded background. Extracted from
5
+ WireMate's sidebar WireMock health indicator.
6
+
7
+ status: 'ok' → emerald + pulse, 'error' → red, 'unknown' → gray.
8
+ -->
9
+ <div class="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs" :class="pillClass">
10
+ <span class="relative flex h-2 w-2">
11
+ <span
12
+ v-if="status === 'ok'"
13
+ class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"
14
+ aria-hidden="true"
15
+ ></span>
16
+ <span class="relative inline-flex rounded-full h-2 w-2" :class="dotClass"></span>
17
+ </span>
18
+ {{ label }}
19
+ </div>
20
+ </template>
21
+
22
+ <script lang="ts" setup>
23
+ import { computed } from 'vue'
24
+ import { useTheme } from '../composables/useTheme'
25
+
26
+ interface Props {
27
+ status: 'ok' | 'error' | 'unknown'
28
+ /** Short text next to the dot, e.g. "WireMock Connected". */
29
+ label: string
30
+ }
31
+
32
+ const props = defineProps<Props>()
33
+
34
+ const { isDark } = useTheme()
35
+
36
+ const pillClass = computed(() => {
37
+ switch (props.status) {
38
+ case 'ok':
39
+ return isDark.value ? 'bg-emerald-500/10 text-emerald-400' : 'bg-emerald-50 text-emerald-700'
40
+ case 'error':
41
+ return isDark.value ? 'bg-red-500/10 text-red-400' : 'bg-red-50 text-red-700'
42
+ default:
43
+ return isDark.value ? 'bg-gray-700 text-gray-400' : 'bg-gray-100 text-gray-500'
44
+ }
45
+ })
46
+
47
+ const dotClass = computed(() => {
48
+ switch (props.status) {
49
+ case 'ok': return 'bg-emerald-500'
50
+ case 'error': return 'bg-red-500'
51
+ default: return 'bg-gray-400'
52
+ }
53
+ })
54
+ </script>
package/src/index.ts CHANGED
@@ -33,6 +33,9 @@ export { default as BaseCopyButton } from './components/BaseCopyButton.vue'
33
33
  export { default as BaseGoogleSignInButton } from './components/BaseGoogleSignInButton.vue'
34
34
  export { default as BaseLoginForm } from './components/BaseLoginForm.vue'
35
35
  export { default as BaseNotificationPanel } from './components/BaseNotificationPanel.vue'
36
+ export { default as BaseChipButton } from './components/BaseChipButton.vue'
37
+ export { default as BaseRemoveButton } from './components/BaseRemoveButton.vue'
38
+ export { default as BaseStatusPill } from './components/BaseStatusPill.vue'
36
39
 
37
40
  // Components — extracted from TradeAutomation
38
41
  export { default as BaseInput } from './components/BaseInput.vue'
@@ -41,6 +44,7 @@ export { default as BaseDropdown } from './components/BaseDropdown.vue'
41
44
  export { default as BaseSegmentedControl } from './components/BaseSegmentedControl.vue'
42
45
  export { default as BaseTable } from './components/BaseTable.vue'
43
46
  export { default as BaseSpecFields } from './components/BaseSpecFields.vue'
47
+ export { default as BaseStatBreakdown } from './components/BaseStatBreakdown.vue'
44
48
 
45
49
  // Composables
46
50
  export { useTheme, initTheme } from './composables/useTheme'
@@ -77,6 +81,7 @@ export type { SegmentedOption } from './types/segmented'
77
81
  export type { DropdownOption } from './types/dropdown'
78
82
  export type { TableColumn } from './types/table'
79
83
  export type { SpecField, SpecFieldType, SpecFieldValue } from './types/specField'
84
+ export type { StatBreakdownItem } from './types/statBreakdown'
80
85
 
81
86
  // Utils
82
87
  export { getBaseColor, getBaseColorOf } from './utils/util'
@@ -95,6 +100,7 @@ export {
95
100
  fmtPrice,
96
101
  fmtPct,
97
102
  fmtUsd,
103
+ fmtDuration,
98
104
  } from './utils/format'
99
105
  export { computePnL } from './utils/pnl'
100
106
  export type { PnL, PnLInputs } from './utils/pnl'
package/src/style.css CHANGED
@@ -1 +1,10 @@
1
1
  @import "tailwindcss";
2
+
3
+ /* Compile `dark:` variants against the `.dark` class that useTheme()/initTheme()
4
+ toggle on <html> — NOT the OS-level prefers-color-scheme media query
5
+ (Tailwind v4's default). Both consumers (TradeAutomation, WireMate) switch
6
+ themes with the class toggle, so media-gated dark rules leak the dark skin
7
+ into the light theme for users whose OS prefers dark.
8
+ Use a specificity-bearing selector (:is, not :where) so dark: utilities win
9
+ over plain base utilities like bg-slate-50 on a cascade tie. */
10
+ @custom-variant dark (&:is(.dark, .dark *));
@@ -0,0 +1,6 @@
1
+ export interface StatBreakdownItem {
2
+ /** Row label rendered on the left (e.g. a venue or category name). */
3
+ label: string
4
+ /** Numeric value; null/undefined renders as an em-dash rather than 0. */
5
+ value: number | null | undefined
6
+ }
@@ -7,6 +7,7 @@ import {
7
7
  fmtPrice,
8
8
  fmtPct,
9
9
  fmtUsd,
10
+ fmtDuration,
10
11
  } from './format'
11
12
 
12
13
  const EM_DASH = '—'
@@ -127,3 +128,37 @@ describe('fmtUsd', () => {
127
128
  expect(fmtUsd(0)).toBe('+$0.00')
128
129
  })
129
130
  })
131
+
132
+ describe('fmtDuration', () => {
133
+ it('renders an em-dash for null/undefined/non-finite input', () => {
134
+ expect(fmtDuration(null)).toBe(EM_DASH)
135
+ expect(fmtDuration(undefined)).toBe(EM_DASH)
136
+ expect(fmtDuration(NaN)).toBe(EM_DASH)
137
+ expect(fmtDuration(Infinity)).toBe(EM_DASH)
138
+ })
139
+
140
+ it('drops zero-value leading units', () => {
141
+ expect(fmtDuration(12 * 60_000)).toBe('12m')
142
+ expect(fmtDuration((5 * 60 + 12) * 60_000)).toBe('5h 12m')
143
+ expect(fmtDuration((3 * 1440 + 5 * 60 + 12) * 60_000)).toBe('3d 5h')
144
+ })
145
+
146
+ it('caps the rendered units at maxUnits', () => {
147
+ const ms = (2 * 1440 + 5 * 60 + 13) * 60_000
148
+ expect(fmtDuration(ms, 3)).toBe('2d 5h 13m')
149
+ expect(fmtDuration(ms, 2)).toBe('2d 5h')
150
+ expect(fmtDuration(ms, 1)).toBe('2d')
151
+ })
152
+
153
+ it('keeps the hours unit when days are present even if hours are zero', () => {
154
+ expect(fmtDuration(2 * 1440 * 60_000, 3)).toBe('2d 0h 0m')
155
+ })
156
+
157
+ it('clamps negative durations to zero', () => {
158
+ expect(fmtDuration(-5000)).toBe('0m')
159
+ })
160
+
161
+ it('renders sub-minute durations as 0m', () => {
162
+ expect(fmtDuration(59_000)).toBe('0m')
163
+ })
164
+ })
@@ -87,3 +87,22 @@ export function fmtUsd(v: number): string {
87
87
  const sign = v < 0 ? '-' : '+'
88
88
  return `${sign}$${Math.abs(v).toFixed(2)}`
89
89
  }
90
+
91
+ /**
92
+ * Format a millisecond duration as compact day/hour/minute units, e.g.
93
+ * "3d 5h", "5h 12m" or "12m". Zero-value leading units are dropped;
94
+ * `maxUnits` caps how many units render (2 → "3d 5h", 3 → "3d 5h 12m").
95
+ * Renders an em-dash for null/undefined/non-finite input.
96
+ */
97
+ export function fmtDuration(ms: number | null | undefined, maxUnits = 2): string {
98
+ if (ms === null || ms === undefined || !isFinite(ms)) return EM_DASH
99
+ const totalMins = Math.floor(Math.max(0, ms) / 60_000)
100
+ const days = Math.floor(totalMins / 1440)
101
+ const hours = Math.floor((totalMins % 1440) / 60)
102
+ const mins = totalMins % 60
103
+ const parts: string[] = []
104
+ if (days > 0) parts.push(`${days}d`)
105
+ if (days > 0 || hours > 0) parts.push(`${hours}h`)
106
+ parts.push(`${mins}m`)
107
+ return parts.slice(0, maxUnits).join(' ')
108
+ }