mgv-backoffice 1.29.0 → 1.30.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.
@@ -55,6 +55,29 @@ export function fmtDateShort(ms: number): string {
55
55
  return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
56
56
  }
57
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
+
58
81
  /**
59
82
  * Format a price with precision that scales to magnitude: penny stocks and
60
83
  * sub-cent crypto get more decimals so the value never collapses to "0.00".
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { SpecField } from '../types/specField'
3
+ import { buildSpecParams, firstInvalidNumericSpec } from './specForm'
4
+
5
+ // Mirrors a typical backend catalogue shape: required numerics carry a
6
+ // default, the optional field carries `default: null` (the optionality
7
+ // convention — blank means "knob disabled").
8
+ const specs: SpecField[] = [
9
+ { key: 'amountPerSymbol', label: 'Amount per symbol ($)', type: 'decimal', default: 4, min: 1, step: 0.01, options: null, help: '' },
10
+ { key: 'stopLossPercent', label: 'Stop loss (%)', type: 'decimal', default: null, min: 0.1, step: 0.1, options: null, help: '' },
11
+ ]
12
+
13
+ describe('firstInvalidNumericSpec', () => {
14
+ it('flags a blank required numeric field', () => {
15
+ expect(firstInvalidNumericSpec(specs, { amountPerSymbol: '', stopLossPercent: 1 }))
16
+ .toBe('Amount per symbol ($)')
17
+ })
18
+
19
+ it.each([['' as const], [null as never], [undefined as never]])(
20
+ 'accepts a blank optional field (cleared → %j)',
21
+ (blank) => {
22
+ expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: blank }))
23
+ .toBeNull()
24
+ },
25
+ )
26
+
27
+ it('still validates a non-blank value on an optional field', () => {
28
+ expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: NaN }))
29
+ .toBe('Stop loss (%)')
30
+ expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: 2.5 }))
31
+ .toBeNull()
32
+ })
33
+
34
+ it('accepts a fully valid value set and tolerates missing specs', () => {
35
+ expect(firstInvalidNumericSpec(specs, { amountPerSymbol: 10, stopLossPercent: 1 })).toBeNull()
36
+ expect(firstInvalidNumericSpec(undefined, {})).toBeNull()
37
+ })
38
+ })
39
+
40
+ describe('buildSpecParams', () => {
41
+ it('seeds from defaults, keeping caller-supplied overlaps', () => {
42
+ expect(buildSpecParams(specs, { amountPerSymbol: 10 }).amountPerSymbol).toBe(10)
43
+ expect(buildSpecParams(specs, {}).amountPerSymbol).toBe(4)
44
+ })
45
+
46
+ it('seeds an optional field with null (renders as an empty input)', () => {
47
+ expect(buildSpecParams(specs, {}).stopLossPercent).toBeNull()
48
+ })
49
+
50
+ it('returns an empty map when there are no specs', () => {
51
+ expect(buildSpecParams(undefined, { anything: 1 })).toEqual({})
52
+ })
53
+ })
@@ -0,0 +1,48 @@
1
+ import type { SpecField, SpecFieldValue } from '../types/specField'
2
+
3
+ /**
4
+ * Helpers for spec-driven forms (the value maps behind BaseSpecFields), so
5
+ * param seeding and numeric validation stay single-sourced across consumers.
6
+ */
7
+
8
+ /**
9
+ * Build the value map for a spec set: keep any overlapping values the caller
10
+ * already has (e.g. a shared amount field carries across spec sets), otherwise
11
+ * fall back to the spec's default. No specs → empty map. A `default: null`
12
+ * seeds the field with null so it renders as an empty (optional) input.
13
+ */
14
+ export function buildSpecParams(
15
+ specs: readonly SpecField[] | undefined,
16
+ existing: Record<string, SpecFieldValue>,
17
+ ): Record<string, SpecFieldValue> {
18
+ const out: Record<string, SpecFieldValue> = {}
19
+ if (!specs) return out
20
+ for (const spec of specs) {
21
+ const prev = existing[spec.key]
22
+ out[spec.key] = prev !== undefined && prev !== null ? prev : (spec.default as SpecFieldValue)
23
+ }
24
+ return out
25
+ }
26
+
27
+ /**
28
+ * Label of the first blank / NaN numeric field (a cleared number input becomes
29
+ * '' under number coercion), or null when every numeric field holds a number.
30
+ * A spec whose `default` is null is OPTIONAL: blank means "knob disabled" and
31
+ * must pass; a non-blank value on an optional field is still validated as a
32
+ * number.
33
+ */
34
+ export function firstInvalidNumericSpec(
35
+ specs: readonly SpecField[] | undefined,
36
+ params: Record<string, SpecFieldValue>,
37
+ ): string | null {
38
+ if (!specs) return null
39
+ for (const spec of specs) {
40
+ if (spec.type === 'decimal' || spec.type === 'integer') {
41
+ const v = params[spec.key]
42
+ const blank = v === '' || v === null || v === undefined
43
+ if (spec.default === null && blank) continue
44
+ if (typeof v !== 'number' || Number.isNaN(v)) return spec.label
45
+ }
46
+ }
47
+ return null
48
+ }