M5: Energy view — per-metric decimal formatting and chart auto-refresh toggle

This commit is contained in:
2026-06-22 19:18:29 +02:00
parent 4767a7af60
commit 935e68846c
7 changed files with 386 additions and 26 deletions
+9 -6
View File
@@ -41,7 +41,8 @@ import {
} from '@mantine/core'
import { useReadings, useMetrics } from './hooks'
import type { MetricInfo } from './hooks'
import type { MetricInfo, AutoRefreshOptions } from './hooks'
import { formatMetricValue } from './format'
// ---------------------------------------------------------------------------
// Constants
@@ -59,6 +60,8 @@ interface EnergyChartsProps {
uuid: string
/** Friendly name for the chart title. */
deviceName: string
/** Auto-refresh options forwarded to useReadings. */
autoRefresh?: AutoRefreshOptions
}
// ---------------------------------------------------------------------------
@@ -138,7 +141,7 @@ function safePayloadValue(
// EnergyCharts component
// ---------------------------------------------------------------------------
export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProps) {
const [activePreset, setActivePreset] = useState<string>(DEFAULT_PRESET)
const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0]
@@ -150,7 +153,7 @@ export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
[activePreset],
)
const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT })
const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT }, autoRefresh)
const metricsQuery = useMetrics(uuid)
// -------------------------------------------------------------------------
@@ -295,10 +298,10 @@ export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) {
/>
<RechartsTooltip
formatter={(value) => {
const n = value as number | null | undefined
if (n == null) return ['', metric.label] as [string, string]
const formatted = formatMetricValue(value, metric)
if (formatted === '—') return ['', metric.label] as [string, string]
return [
`${n}${metric.unit ? ' ' + metric.unit : ''}`,
`${formatted}${metric.unit ? ' ' + metric.unit : ''}`,
metric.label,
] as [string, string]
}}
+120
View File
@@ -0,0 +1,120 @@
/**
* Tests for energy/format.ts — formatMetricValue helper.
*
* Coverage:
* 1. energy device_class → 3 decimal places.
* 2. non-energy device_class → 2 decimal places.
* 3. null value → "—" placeholder.
* 4. undefined value → "—" placeholder.
* 5. NaN value → "—" placeholder.
* 6. Missing / null metric metadata → default 2 decimal places.
* 7. Integer value still rendered with correct decimal places.
* 8. String numeric value is coerced and formatted.
*/
import { describe, it, expect } from 'vitest'
import { formatMetricValue, VALUE_PLACEHOLDER } from './format'
import type { MetricInfo } from './hooks'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeMetric(overrides: Partial<MetricInfo> = {}): MetricInfo {
return {
key: 'voltage',
label: 'Voltage',
unit: 'V',
device_class: 'voltage',
...overrides,
}
}
const energyMetric = makeMetric({ key: 'import_energy', label: 'Import Energy', unit: 'kWh', device_class: 'energy' })
const voltageMetric = makeMetric()
const powerMetric = makeMetric({ key: 'active_power', label: 'Active Power', unit: 'W', device_class: 'power' })
const powerFactorMetric = makeMetric({ key: 'power_factor', label: 'Power Factor', unit: '', device_class: 'power_factor' })
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('formatMetricValue — energy device_class → 3 decimal places', () => {
it('formats kWh energy value to 3 decimal places', () => {
expect(formatMetricValue(123.4567, energyMetric)).toBe('123.457')
})
it('formats a whole-number energy value to 3 decimal places', () => {
expect(formatMetricValue(123, energyMetric)).toBe('123.000')
})
it('formats zero energy value to 3 decimal places', () => {
expect(formatMetricValue(0, energyMetric)).toBe('0.000')
})
})
describe('formatMetricValue — non-energy device_class → 2 decimal places', () => {
it('formats voltage to 2 decimal places', () => {
expect(formatMetricValue(230.2567, voltageMetric)).toBe('230.26')
})
it('formats power to 2 decimal places', () => {
expect(formatMetricValue(295.0, powerMetric)).toBe('295.00')
})
it('formats power factor to 2 decimal places', () => {
expect(formatMetricValue(0.98, powerFactorMetric)).toBe('0.98')
})
it('formats current to 2 decimal places', () => {
const currentMetric = makeMetric({ key: 'current', label: 'Current', unit: 'A', device_class: 'current' })
expect(formatMetricValue(1.3456, currentMetric)).toBe('1.35')
})
})
describe('formatMetricValue — null/undefined/NaN → placeholder', () => {
it('returns placeholder for null', () => {
expect(formatMetricValue(null, voltageMetric)).toBe(VALUE_PLACEHOLDER)
})
it('returns placeholder for undefined', () => {
expect(formatMetricValue(undefined, voltageMetric)).toBe(VALUE_PLACEHOLDER)
})
it('returns placeholder for NaN', () => {
expect(formatMetricValue(NaN, voltageMetric)).toBe(VALUE_PLACEHOLDER)
})
it('returns placeholder for non-numeric string', () => {
expect(formatMetricValue('not-a-number', voltageMetric)).toBe(VALUE_PLACEHOLDER)
})
it('returns placeholder for Infinity', () => {
expect(formatMetricValue(Infinity, voltageMetric)).toBe(VALUE_PLACEHOLDER)
})
})
describe('formatMetricValue — missing metric metadata → default 2 decimal places', () => {
it('falls back to 2 decimal places when metric is undefined', () => {
expect(formatMetricValue(230.2567)).toBe('230.26')
})
it('falls back to 2 decimal places when metric is null', () => {
expect(formatMetricValue(230.2567, null)).toBe('230.26')
})
it('returns placeholder for null value even without metric', () => {
expect(formatMetricValue(null)).toBe(VALUE_PLACEHOLDER)
})
})
describe('formatMetricValue — numeric string coercion', () => {
it('coerces a numeric string to number and formats it', () => {
// API could theoretically return strings; we handle them gracefully.
expect(formatMetricValue('230.2567', voltageMetric)).toBe('230.26')
})
it('coerces a numeric string for energy metric → 3 decimals', () => {
expect(formatMetricValue('123.4567', energyMetric)).toBe('123.457')
})
})
+40
View File
@@ -0,0 +1,40 @@
/**
* format.ts — shared metric value formatting helpers for the Energy view.
*
* Rules (from M5 polish requirements):
* - device_class === "energy" → 3 decimal places (kWh values need precision)
* - everything else → 2 decimal places
* - null / undefined / NaN → placeholder "—"
* - missing metric metadata → default 2 decimal places
*/
import type { MetricInfo } from './hooks'
/** Placeholder shown when a value is unavailable. */
export const VALUE_PLACEHOLDER = '—'
/**
* Format a numeric metric value for display.
*
* @param value The raw value from the API payload (any type; non-numeric → "—").
* @param metric Optional metric metadata from /metrics. When absent, falls back
* to 2 decimal places.
* @returns A formatted string ready for display (e.g. "230.25" or "—").
*/
export function formatMetricValue(
value: unknown,
metric?: MetricInfo | null,
): string {
// Guard: null / undefined
if (value === null || value === undefined) return VALUE_PLACEHOLDER
const n = Number(value)
// Guard: NaN or non-finite
if (!Number.isFinite(n)) return VALUE_PLACEHOLDER
// Determine decimal places from device_class.
const decimals = metric?.device_class === 'energy' ? 3 : 2
return n.toFixed(decimals)
}
+74
View File
@@ -274,6 +274,80 @@ describe('useMetrics', () => {
})
})
describe('useLatestReading — auto-refresh option', () => {
beforeEach(() => vi.clearAllMocks())
it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => {
mockGet.mockResolvedValue({
data: { found: true, recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } },
})
const { Wrapper } = makeWrapper()
const { useLatestReading } = await import('./hooks')
const { result } = renderHook(
() => useLatestReading('test-uuid-1', { refetchIntervalMs: 5_000 }),
{ wrapper: Wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Query succeeds — refetchIntervalMs is wired through without error.
expect(result.current.data?.found).toBe(true)
})
it('works without options (no auto-refresh)', async () => {
mockGet.mockResolvedValue({
data: { found: false, recorded_at: null, payload: null },
})
const { Wrapper } = makeWrapper()
const { useLatestReading } = await import('./hooks')
const { result } = renderHook(
() => useLatestReading('test-uuid-2'),
{ wrapper: Wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.found).toBe(false)
})
})
describe('useReadings — auto-refresh option', () => {
beforeEach(() => vi.clearAllMocks())
it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => {
mockGet.mockResolvedValue({
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] },
})
const { Wrapper } = makeWrapper()
const { useReadings } = await import('./hooks')
const { result } = renderHook(
() => useReadings('test-uuid-1', {}, { refetchIntervalMs: 5_000 }),
{ wrapper: Wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Query succeeds — refetchIntervalMs is wired through without error.
expect(result.current.data?.items).toHaveLength(1)
})
it('enforces minimum refetchInterval of 2000ms', async () => {
mockGet.mockResolvedValue({ data: { items: [] } })
const { Wrapper } = makeWrapper()
const { useReadings } = await import('./hooks')
// Pass a very small value — should be clamped to 2000ms internally.
const { result } = renderHook(
() => useReadings('test-uuid-1', {}, { refetchIntervalMs: 100 }),
{ wrapper: Wrapper },
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// No crash; the hook accepted the option and clamped it internally.
expect(result.current.isSuccess).toBe(true)
})
})
describe('useReadings', () => {
beforeEach(() => vi.clearAllMocks())
+29 -4
View File
@@ -132,11 +132,28 @@ export function useTestReadDevice() {
})
}
// ---------------------------------------------------------------------------
// Query options shared by auto-refreshcapable queries
// ---------------------------------------------------------------------------
export interface AutoRefreshOptions {
/**
* When provided, TanStack Query will automatically re-fetch this query at
* this interval (milliseconds). Pass `undefined` to disable auto-refresh.
* Minimum enforced value: 2 000 ms.
*/
refetchIntervalMs?: number
}
// ---------------------------------------------------------------------------
// Query: latest reading for a device
// ---------------------------------------------------------------------------
export function useLatestReading(uuid: string) {
export function useLatestReading(uuid: string, options?: AutoRefreshOptions) {
const refetchInterval = options?.refetchIntervalMs != null
? Math.max(2_000, options.refetchIntervalMs)
: undefined
return useQuery({
queryKey: ['modbus-latest', uuid],
queryFn: async () => {
@@ -145,8 +162,7 @@ export function useLatestReading(uuid: string) {
})
return res.data
},
// Refresh every 10 s to show up-to-date readings.
refetchInterval: 10_000,
refetchInterval,
})
}
@@ -175,10 +191,18 @@ export function useMetrics(uuid: string) {
/** Hard upper-bound on readings fetched; prevents accidental full-table pulls. */
const READINGS_MAX_LIMIT = 1000
export function useReadings(uuid: string, params: ReadingsQueryParams) {
export function useReadings(
uuid: string,
params: ReadingsQueryParams,
options?: AutoRefreshOptions,
) {
const { start, end, limit } = params
const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT)
const refetchInterval = options?.refetchIntervalMs != null
? Math.max(2_000, options.refetchIntervalMs)
: undefined
return useQuery({
queryKey: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }],
queryFn: async () => {
@@ -196,5 +220,6 @@ export function useReadings(uuid: string, params: ReadingsQueryParams) {
},
// Enabled only when we have valid uuid; start/end may be null (full window).
enabled: !!uuid,
refetchInterval,
})
}