/**
* CostView — cost trends + detail + summary.
*
* Features:
* - Date range selector: Today / This month / Custom (SegmentedControl + DateInput).
* - Recharts AreaChart showing import_cost, export_revenue, net_cost per period.
* - Table showing per-15min periods with time, kWh values, costs, degraded badge.
* - Summary cards: metered import/export, fixed costs, credits, total payable.
* - "Recompute" button with confirmation.
* - Loading/error/empty states, degraded period highlighting.
*
* Recharts imports are isolated to this file only.
*/
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Stack,
Text,
TextInput,
Loader,
Center,
Alert,
Table,
Group,
Badge,
ScrollArea,
SegmentedControl,
Button,
Paper,
SimpleGrid,
Modal,
Title,
} from '@mantine/core'
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { useEnergyCosts, useEnergyCostSummary, useRecomputeCosts } from './hooks'
import { formatLocalTime } from '../utils/datetime'
import apiClient from '../api/client'
// ---------------------------------------------------------------------------
// Cost limit — prevent accidental full-table pulls
// ---------------------------------------------------------------------------
const COSTS_MAX_LIMIT = 500
// ---------------------------------------------------------------------------
// Date range helpers
// ---------------------------------------------------------------------------
function getTodayRange(): { start: string; end: string } {
// Use local date boundary so "today" matches what the user sees in the display.
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
return { start: start.toISOString(), end: end.toISOString() }
}
function getThisMonthRange(): { start: string; end: string } {
// Use local date boundary so "this month" matches what the user sees in the display.
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), 1)
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1)
return { start: start.toISOString(), end: end.toISOString() }
}
// ---------------------------------------------------------------------------
// Summary cards
// ---------------------------------------------------------------------------
interface SummaryCardProps {
label: string
value: string
/** Optional secondary line, e.g. the monetary equivalent of an energy figure. */
sub?: string
testId?: string
}
function SummaryCard({ label, value, sub, testId }: SummaryCardProps) {
return (
{label}
{value}
{sub !== undefined && (
{sub}
)}
)
}
// ---------------------------------------------------------------------------
// CostView — main component
// ---------------------------------------------------------------------------
type RangePreset = 'today' | 'month' | 'custom'
export function CostView() {
const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity')
if (scope === 'thermal') return
return
}
function ScopeSelector({ scope, onScopeChange }: { scope: 'electricity' | 'thermal'; onScopeChange: (scope: 'electricity' | 'thermal') => void }) {
return onScopeChange(value as 'electricity' | 'thermal')} data={[{ label: 'Electricity', value: 'electricity' }, { label: 'Thermal', value: 'thermal' }]} data-testid="costs-scope-selector" />
}
function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electricity' | 'thermal') => void }) {
const [rangePreset, setRangePreset] = useState('today')
// Date strings in YYYY-MM-DD format for custom range
const [customStartStr, setCustomStartStr] = useState('')
const [customEndStr, setCustomEndStr] = useState('')
const [showRecomputeConfirm, setShowRecomputeConfirm] = useState(false)
// Compute effective date range
const { start, end } = (() => {
if (rangePreset === 'today') return getTodayRange()
if (rangePreset === 'month') return getThisMonthRange()
return {
start: customStartStr ? new Date(customStartStr).toISOString() : undefined,
end: customEndStr ? new Date(customEndStr).toISOString() : undefined,
}
})()
const costsQuery = useEnergyCosts(start, end, COSTS_MAX_LIMIT)
const summaryQuery = useEnergyCostSummary(start, end)
const recomputeMutation = useRecomputeCosts()
async function handleRecompute() {
setShowRecomputeConfirm(false)
await recomputeMutation.mutateAsync({ start, end })
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
const currency = costsQuery.data?.items[0]?.currency ?? summaryQuery.data?.currency ?? 'EUR'
return (
{/* Date range selector */}
Date range
setRangePreset(v as RangePreset)}
data={[
{ label: 'Today', value: 'today' },
{ label: 'This month', value: 'month' },
{ label: 'Custom', value: 'custom' },
]}
data-testid="cost-range-control"
/>
{rangePreset === 'custom' && (
setCustomStartStr(e.currentTarget.value)}
data-testid="cost-custom-start"
/>
setCustomEndStr(e.currentTarget.value)}
data-testid="cost-custom-end"
/>
)}
setShowRecomputeConfirm(true)}
loading={recomputeMutation.isPending}
data-testid="cost-recompute-button"
>
Recompute
{/* Summary cards */}
{summaryQuery.isLoading && (
)}
{summaryQuery.isError && (
Failed to load cost summary.
)}
{summaryQuery.data && (
Summary
)}
{/* Cost chart */}
{costsQuery.isLoading && (
)}
{costsQuery.isError && (
Failed to load cost periods. Please refresh.
)}
{costsQuery.data && costsQuery.data.items.length === 0 && (
No cost data available for the selected period.
)}
{costsQuery.data && costsQuery.data.items.length > 0 && (
<>
{/* Area chart */}
Cost trends ({currency})
({
time: formatLocalTime(item.period_start),
import_cost: item.import_cost,
export_revenue: item.export_revenue,
net_cost: item.net_cost,
}))}
margin={{ top: 4, right: 16, left: 0, bottom: 4 }}
>
v.toFixed(2)} />
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
}
/>
{/* Detail table */}
Period detail
Time
Import kWh
Export kWh
Import cost
Export rev.
Net cost
{costsQuery.data.items.map((item, idx) => (
{formatLocalTime(item.period_start)}
{(item.d1_kwh + item.d2_kwh).toFixed(3)}
{(item.r1_kwh + item.r2_kwh).toFixed(3)}
{item.import_cost.toFixed(4)}
{item.export_revenue.toFixed(4)}
{item.net_cost.toFixed(4)}
{item.degraded && (
degraded
)}
))}
>
)}
{/* Recompute confirmation modal */}
{showRecomputeConfirm && (
setShowRecomputeConfirm(false)}
title="Recompute costs?"
size="sm"
data-testid="recompute-confirm-modal"
>
This will recompute all cost periods for the selected date range. Continue?
setShowRecomputeConfirm(false)}
data-testid="recompute-cancel"
>
Cancel
Recompute
)}
)
}
function ThermalCostView({ onScopeChange }: { onScopeChange: (scope: 'electricity' | 'thermal') => void }) {
const [rangePreset, setRangePreset] = useState('today')
const [customStartStr, setCustomStartStr] = useState('')
const [customEndStr, setCustomEndStr] = useState('')
const [showConfirm, setShowConfirm] = useState(false)
const [recomputeError, setRecomputeError] = useState(null)
const [recomputeSuccess, setRecomputeSuccess] = useState(null)
const [expandedRows, setExpandedRows] = useState>(() => new Set())
const [ledgerOffset, setLedgerOffset] = useState(0)
const { start, end } = (() => {
if (rangePreset === 'today') return getTodayRange()
if (rangePreset === 'month') return getThisMonthRange()
return {
start: customStartStr ? new Date(customStartStr).toISOString() : undefined,
end: customEndStr ? new Date(customEndStr).toISOString() : undefined,
}
})()
// The server only accepts complete UTC quarters. Never send a future end.
const closedEnd = (() => {
const now = new Date()
now.setUTCMinutes(Math.floor(now.getUTCMinutes() / 15) * 15, 0, 0)
const selectedEnd = end ? new Date(end) : now
return new Date(Math.min(selectedEnd.getTime(), now.getTime())).toISOString()
})()
const recomputeStart = start
const recomputeAvailable = !!recomputeStart && new Date(recomputeStart) < new Date(closedEnd)
const qc = useQueryClient()
const resetLedgerPage = () => {
setLedgerOffset(0)
setExpandedRows(new Set())
}
const rows = useQuery({ queryKey: ['meter-costs', 'thermal', start, end, ledgerOffset], queryFn: async () => {
const result = await apiClient.GET('/api/energy/meter-costs', { params: { query: { scope: 'thermal', start, end, limit: COSTS_MAX_LIMIT, offset: ledgerOffset } } })
return result.data
} })
const meters = useQuery({ queryKey: ['energy-meters', 'thermal'], queryFn: async () => {
const result = await apiClient.GET('/api/energy/meters')
return result.data
} })
const summary = useQuery({ queryKey: ['meter-cost-summary', 'thermal', start, end], queryFn: async () => {
const result = await apiClient.GET('/api/energy/meter-costs/summary', { params: { query: { scope: 'thermal', start, end } } })
return result.data
} })
const recompute = useMutation({ mutationFn: () => apiClient.POST('/api/energy/meter-costs/recompute', { params: { query: { scope: 'thermal', start: recomputeStart!, end: closedEnd } } }), onSuccess: (result) => {
void qc.invalidateQueries({ queryKey: ['meter-costs', 'thermal'] }); void qc.invalidateQueries({ queryKey: ['meter-cost-summary', 'thermal'] })
setRecomputeSuccess(`Recomputed ${result.data?.processed ?? 0} closed periods.`)
} })
const currency = summary.data?.currency ?? rows.data?.items[0]?.currency ?? 'EUR'
const fixed = summary.data?.fixed_breakdown
const hasCurrentHeatingMeter = meters.data?.items.some((meter) => meter.commodity === 'heating' && meter.ended_at === null)
const hasCurrentHotWaterMeter = meters.data?.items.some((meter) => meter.commodity === 'hot_water' && meter.ended_at === null)
const missingCurrentMeters = [
...(hasCurrentHeatingMeter === false ? ['heating'] : []),
...(hasCurrentHotWaterMeter === false ? ['hot-water'] : []),
]
const totalRows = rows.data?.total ?? 0
const shownStart = totalRows === 0 ? 0 : ledgerOffset + 1
const shownEnd = Math.min(ledgerOffset + (rows.data?.items.length ?? 0), totalRows)
return
Date range { resetLedgerPage(); setRangePreset(value as RangePreset) }} data={[{ label: 'Today', value: 'today' }, { label: 'This month', value: 'month' }, { label: 'Custom', value: 'custom' }]} data-testid="thermal-cost-range-control" /> {rangePreset === 'custom' && { resetLedgerPage(); setCustomStartStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-start" /> { resetLedgerPage(); setCustomEndStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-end" /> } { setRecomputeError(null); setRecomputeSuccess(null); setShowConfirm(true) }} disabled={!recomputeAvailable} data-testid="thermal-recompute-button">Recompute
{(rows.isLoading || summary.isLoading) && }
{(rows.isError || summary.isError) && Failed to load thermal costs. }
{recomputeError && {recomputeError} }
{recomputeSuccess && {recomputeSuccess} }
{summary.data && {rangePreset === 'today' ? 'Today' : rangePreset === 'month' ? 'This month' : 'Custom range'} ({currency}) {start ?? 'Select a start date'} — {end ?? 'Select an end date'}
{missingCurrentMeters.length > 0 && Current {missingCurrentMeters.join(' and ')} meter{missingCurrentMeters.length > 1 ? 's are' : ' is'} not configured. Historical ledger rows do not establish a current meter. }{summary.data.period_count} periods; {summary.data.degraded_count} degraded {summary.data.degraded_count > 0 && Some totals include degraded periods. Expand a row to see its recorded reason. }{fixed && Fixed once per settled local day (summary only): {Object.entries(fixed).map(([key, value]) => `${key} ${value}`).join(' · ')} } }
{rows.data?.items.length === 0 && No thermal cost data for this range. Check that heating or hot-water meters are bound and have settled readings. }
{rows.data && Showing {shownStart}-{shownEnd} of {totalRows} {rows.data.items.length > 0 && Time Commodity Quantity Cost Breakdown Status {rows.data.items.flatMap((item, index) => [{formatLocalTime(item.period_start)} {item.commodity} {item.quantity} {item.cost} {item.currency} {Object.entries(item.cost_breakdown).map(([key, value]) => `${key} ${value}`).join(', ')} {item.degraded ? {item.degraded_reason ?? 'degraded'} : 'normal'} setExpandedRows((current) => { const next = new Set(current); if (next.has(index)) next.delete(index); else next.add(index); return next })} data-testid={`thermal-cost-expand-${index}`}>{expandedRows.has(index) ? 'Hide audit' : 'Audit'} , ...(expandedRows.has(index) ? [Contract version: {item.contract_version_id ?? 'none'} Pricing snapshot: {JSON.stringify(item.pricing_snapshot)} ] : [])])}
} { setExpandedRows(new Set()); setLedgerOffset((current) => Math.max(0, current - COSTS_MAX_LIMIT)) }} data-testid="thermal-ledger-prev">Previous = totalRows} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => current + COSTS_MAX_LIMIT) }} data-testid="thermal-ledger-next">Next }
{showConfirm && setShowConfirm(false)} title="Recompute thermal costs?" data-testid="thermal-recompute-confirm-modal">This explicitly overwrites closed 15-minute thermal ledger rows for {recomputeStart ?? 'the selected start'} — {closedEnd}. Continue? {!recomputeAvailable && Select a range containing at least one closed UTC quarter. } setShowConfirm(false)} data-testid="thermal-recompute-cancel">Cancel { try { await recompute.mutateAsync(); setShowConfirm(false) } catch { setRecomputeError('Failed to recompute thermal costs. Please try again.'); setShowConfirm(false) } }} data-testid="thermal-recompute-confirm">Recompute }
}