/** * MeterManager — electricity meter timeline UI. * * Features: * - Table of meter epochs: label / interval (started_at → ended_at or "active") / * active badge / reason. * - "Declare New Meter" button: form with label + date (started_at) + reason + * optional note. Sends local-midnight naive datetime per FU10 convention. * - Edit modal: update label, note, or correct started_at (retroactive). * - Retroactive feedback: if started_at is changed, a success notice mentions * that affected billing periods have been recomputed. * - Loading / error / empty states. */ import { useState } from 'react' import { Table, Button, Group, Text, Loader, Center, Alert, Stack, Badge, ScrollArea, Modal, TextInput, Textarea, Select, Notification, } from '@mantine/core' import { useMeters, useDeclareMeter, useUpdateMeter, useSources, useSourceChannels, useCreateBinding, useCloseBinding, useCloseMeter, useTransferBinding, type MeterResponse, type MeterReason, } from './hooks' import { ApiError } from '../api/client' import { formatLocalDate, formatLocalDateTime, parseBackendTimestamp } from '../utils/datetime' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const REASON_OPTIONS: { value: MeterReason; label: string }[] = [ { value: 'initial', label: 'Initial installation' }, { value: 'meter_swap', label: 'Meter swap (same address)' }, { value: 'home_move', label: 'Home / address move' }, { value: 'other', label: 'Other' }, ] /** * Convert a local date string "YYYY-MM-DD" to a naive local-midnight datetime * string (no Z suffix) following the FU10 / ContractForm convention. * The backend interprets naive datetimes as server local wall-clock time. */ function toLocalMidnightNaive(dateStr: string): string { return `${dateStr}T00:00:00` } /** * Format a Date object as a "YYYY-MM-DD" string using the browser's local timezone. * Used to populate fields. * Returns '' if the Date is invalid. */ function toLocalDateInputString(d: Date): string { if (isNaN(d.getTime())) return '' const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') return `${y}-${m}-${day}` } function toLocalDateTimeInputString(d = new Date()): string { const pad = (value: number) => String(value).padStart(2, '0') return `${toLocalDateInputString(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}` } function expectedUnit(commodity: string): string { return ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record)[commodity] ?? '' } function apiErrorMessage(err: unknown, fallback: string): string { if (err instanceof ApiError) { const body = err.body if (typeof body === 'string') return body if (body && typeof body === 'object' && 'detail' in body) { const detail = (body as { detail?: unknown }).detail if (typeof detail === 'string') return detail if (Array.isArray(detail)) { const messages = detail.map((item) => item && typeof item === 'object' && typeof item.msg === 'string' ? item.msg : String(item)).filter(Boolean) if (messages.length) return messages.join('; ') } if (detail != null) return typeof detail === 'object' ? JSON.stringify(detail) : String(detail) } return `${fallback} (error ${err.status}).` } return fallback } function isValidLocalDateTime(value: string): boolean { return value.trim() !== '' && !Number.isNaN(new Date(value).getTime()) } type Eligibility = { eligible: boolean; reason?: string } function localInstant(value: string): number | null { const instant = new Date(value).getTime() return Number.isNaN(instant) ? null : instant } function intervalsOverlap(start: number, end: number | null, otherStart: number, otherEnd: number | null): boolean { return (end === null || otherStart < end) && (otherEnd === null || start < otherEnd) } function channelIntervalEligibility( meters: MeterResponse[], channelUuid: string, startedAt: string, excludeBindingUuids: string[] = [], ): Eligibility { const start = localInstant(startedAt) if (start === null) return { eligible: false, reason: 'choose a valid start time first' } const conflicts = meters.flatMap((meter) => (meter.bindings ?? []).filter((binding) => binding.source_channel_uuid === channelUuid && !excludeBindingUuids.includes(binding.uuid) && intervalsOverlap(start, null, parseBackendTimestamp(binding.started_at).getTime(), binding.ended_at ? parseBackendTimestamp(binding.ended_at).getTime() : null), )) if (!conflicts.length) return { eligible: true } if (conflicts.length > 1) return { eligible: false, reason: 'ambiguous: channel has overlapping binding history; resolve it first' } return conflicts[0].ended_at === null ? { eligible: false, reason: 'occupied by an open binding; close or transfer it first' } : { eligible: false, reason: 'overlaps closed binding history; choose a time at or after it ends' } } function meterContainsInstant(meter: MeterResponse, instant: number): boolean { const start = parseBackendTimestamp(meter.started_at).getTime() const end = meter.ended_at ? parseBackendTimestamp(meter.ended_at).getTime() : null return instant >= start && (end === null || instant < end) } function recoveryTargetFor(meter: MeterResponse, meters: MeterResponse[]): MeterResponse | null { if (meter.ended_at === null) return null const active = meters.filter((candidate) => candidate.commodity === meter.commodity && candidate.ended_at === null) if (active.length !== 1) return null const target = active[0] const targetStart = parseBackendTimestamp(target.started_at).getTime() const predecessors = meters.filter((candidate) => candidate.commodity === meter.commodity && candidate.ended_at !== null && parseBackendTimestamp(candidate.ended_at).getTime() <= targetStart) const latestEnd = Math.max(...predecessors.map((candidate) => parseBackendTimestamp(candidate.ended_at!).getTime())) const immediate = predecessors.filter((candidate) => parseBackendTimestamp(candidate.ended_at!).getTime() === latestEnd) if (immediate.length !== 1 || immediate[0].id !== meter.id) return null const sourceStart = parseBackendTimestamp(meter.started_at).getTime() const sourceEnd = parseBackendTimestamp(meter.ended_at).getTime() for (const candidate of meters) { if (candidate.id === meter.id || candidate.id === target.id || candidate.commodity !== meter.commodity) continue const candidateStart = parseBackendTimestamp(candidate.started_at).getTime() const candidateEnd = candidate.ended_at ? parseBackendTimestamp(candidate.ended_at).getTime() : null if (intervalsOverlap(sourceStart, sourceEnd, candidateStart, candidateEnd) || intervalsOverlap(targetStart, null, candidateStart, candidateEnd)) return null } return target } function BindingTimeline({ binding, sources }: { binding: NonNullable[number] sources: ReturnType }) { const source = sources.data?.items.find((item) => item.uuid === binding.source_uuid) const channels = useSourceChannels(source?.uuid ?? null) const channel = channels.data?.items.find((item) => item.uuid === binding.source_channel_uuid) let endpoint = 'Source details unavailable' if (sources.isLoading) endpoint = 'Loading source details…' else if (!sources.isError && source) endpoint = source.name else if (!sources.isError) endpoint = 'Source unavailable' let channelName = 'Channel details unavailable' if (source && channels.isLoading) channelName = 'Loading channel details…' else if (source && !channels.isError && channel) channelName = channel.label else if (source && !channels.isError) channelName = 'Channel unavailable' return ( {endpoint} → {channelName} [{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'}) {' '}({binding.ended_at ? 'closed' : 'active'}) ) } // --------------------------------------------------------------------------- // Declare meter form (modal) // --------------------------------------------------------------------------- interface DeclareMeterFormProps { meters: MeterResponse[] onClose: () => void onSaved: () => void } function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) { const [label, setLabel] = useState('') const [dateStr, setDateStr] = useState('') const [reason, setReason] = useState(null) const [note, setNote] = useState('') const [error, setError] = useState(null) const [commodity, setCommodity] = useState('electricity') const [sourceUuid, setSourceUuid] = useState(null) const [channelUuid, setChannelUuid] = useState(null) const sources = useSources() const channels = useSourceChannels(sourceUuid) const declareMutation = useDeclareMeter() const unit = expectedUnit(commodity ?? 'electricity') const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null) const channelEligibility = (uuid: string, startedAt: string): Eligibility => { const channel = channels.data?.items.find((item) => item.uuid === uuid) if (!channel) return { eligible: false, reason: 'channel is unavailable; reload the source' } if (channel.unit !== unit) return { eligible: false, reason: `unit mismatch: ${channel.unit}; this meter needs ${unit}` } if (!startedAt) return { eligible: false, reason: 'choose a start date first' } const start = localInstant(toLocalMidnightNaive(startedAt)) if (start === null) return { eligible: false, reason: 'choose a valid start date first' } if (startedAt > toLocalDateInputString(new Date())) return { eligible: false, reason: 'future start dates cannot bind a channel' } // Channel aggregates include closed binding history. For a meter swap, only // currently open bindings determine whether this channel can be handed off. const openBindings = meters.flatMap((meter) => (meter.bindings ?? []) .filter((binding) => binding.source_channel_uuid === channel.uuid && binding.ended_at === null) .map((binding) => ({ meter, binding })), ) const oldBinding = openBindings[0] const isSingleOldBinding = reason === 'meter_swap' && oldMeter !== undefined && openBindings.length === 1 && oldBinding !== undefined && oldBinding.meter.id === oldMeter.id && oldBinding.meter.commodity === commodity // A handoff must leave a non-empty interval on the old Meter. The backend // rejects equality too, so surface it here instead of offering a request // that is guaranteed to fail. if (isSingleOldBinding && start <= parseBackendTimestamp(oldBinding.binding.started_at).getTime()) { return { eligible: false, reason: 'handoff boundary must be strictly after the current binding start' } } const canHandoff = isSingleOldBinding const interval = channelIntervalEligibility( meters, channel.uuid, toLocalMidnightNaive(startedAt), canHandoff ? [oldBinding.binding.uuid] : [], ) if (!interval.eligible) return interval if (openBindings.length && !canHandoff) { return openBindings.length > 1 ? { eligible: false, reason: 'ambiguous: multiple open bindings; resolve them first' } : { eligible: false, reason: 'occupied by an open binding; choose a different channel or close/transfer it first' } } return { eligible: true } } const channelOptions = channels.data?.items.map((channel) => { const result = channelEligibility(channel.uuid, dateStr) const canHandoff = !isUnboundChannel(channel.uuid) && result.eligible return { value: channel.uuid, label: `${channel.label} (${channel.unit})${canHandoff ? ' — hand off from current meter' : result.reason ? ` — ${result.reason}` : ''}`, disabled: !result.eligible, } }) ?? [] const selectedChannelUuid = channelUuid && channelEligibility(channelUuid, dateStr).eligible ? channelUuid : null function isUnboundChannel(uuid: string): boolean { return !meters.some((meter) => meter.bindings?.some( (binding) => binding.source_channel_uuid === uuid && binding.ended_at === null, )) } async function handleSubmit(e: React.FormEvent) { e.preventDefault() setError(null) if (!label.trim()) { setError('Label is required.') return } if (!dateStr) { setError('Start date is required.') return } if (!reason) { setError('Reason is required.') return } if (sourceUuid && !selectedChannelUuid) { const unavailable = channelOptions.length === 1 && channelOptions[0].disabled ? channelEligibility(channelOptions[0].value, dateStr).reason : undefined setError(unavailable ? `${unavailable[0].toUpperCase()}${unavailable.slice(1)}` : 'Select an eligible source channel or clear the optional source.') return } // Omitting a channel for meter_swap asks the backend to auto-handoff the // sole open binding. Keep that implicit path subject to the same strict // boundary rule as an explicitly selected channel. const start = localInstant(toLocalMidnightNaive(dateStr)) const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null) // The implicit backend handoff only considers bindings on the current // commodity's old meter. Bindings are unit-compatible with their meter // by the binding contract, so an unrelated heating/hot-water binding must // neither make this ambiguous nor bypass this strict boundary check. const autoHandoffCandidates = (oldMeter?.bindings ?? []).filter((binding) => binding.ended_at === null) const oldBinding = autoHandoffCandidates[0] if (reason === 'meter_swap' && start !== null && oldMeter !== undefined && autoHandoffCandidates.length === 1 && oldBinding !== undefined && start <= parseBackendTimestamp(oldBinding.started_at).getTime()) { setError('Handoff boundary must be strictly after the current binding start.') return } try { await declareMutation.mutateAsync({ label: label.trim(), started_at: toLocalMidnightNaive(dateStr), reason: reason as MeterReason, note: note.trim() || undefined, commodity: commodity ?? 'electricity', ...(selectedChannelUuid ? { source_channel_uuid: selectedChannelUuid } : {}), }) onSaved() onClose() } catch (err) { setError(apiErrorMessage(err, 'Failed to declare meter. Please try again.')) } } return (
setLabel(e.currentTarget.value)} data-testid="meter-label" /> { const nextDateStr = e.currentTarget.value setDateStr(nextDateStr) setChannelUuid((uuid) => uuid && !channelEligibility(uuid, nextDateStr).eligible ? null : uuid) }} data-testid="meter-started-at" /> { setCommodity(value); setChannelUuid(null) }} data={[ { value: 'electricity', label: 'Electricity' }, { value: 'heating', label: 'Heating' }, { value: 'hot_water', label: 'Hot water' }, ]} /> }