From c9ce05d95a8383454251920e08efb9e8ddfe59e3 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Thu, 27 Aug 2026 19:18:03 +0200 Subject: [PATCH] M8-R12: align and format thermal cost UI --- frontend/src/energy/CostView.test.tsx | 91 ++++++++++++++++++++++++++- frontend/src/energy/CostView.tsx | 76 +++++++++++++++++----- 2 files changed, 148 insertions(+), 19 deletions(-) diff --git a/frontend/src/energy/CostView.test.tsx b/frontend/src/energy/CostView.test.tsx index 2cede7a..bccae5b 100644 --- a/frontend/src/energy/CostView.test.tsx +++ b/frontend/src/energy/CostView.test.tsx @@ -79,6 +79,23 @@ const THERMAL_SUMMARY = { period_count: 4, degraded_count: 2, fixed_breakdown: { heating_network: '0.1', metering: '0.1', delivery_set: '0', hot_water_network: '0.1', other: '0.2' }, } +const ROUNDED_THERMAL_SUMMARY = { + ...THERMAL_SUMMARY, + heating: '20.12344', + hot_water_heating: '8.20005', + hot_water: '1.99995', + hot_water_tax: '0.40000', + variable_subtotal: '7.0000', + fixed_subtotal: '0.00004', + all_in: '9.99995', + fixed_breakdown: { + heating_network: '100.000000000000000001', + metering: '0.0000', + delivery_set: '20.20000', + hot_water_network: '30.30004', + other: '40.40005', + }, +} const THERMAL_VALUES = { variable: { heating: '20.123456789123456789', hot_water_heating: '8.200000000000000001', @@ -95,6 +112,12 @@ const THERMAL_ROW = { cost_breakdown: { heating: '0.123456789' }, pricing_snapshot: THERMAL_VALUES, quality: 'unverifiable', degraded: false, degraded_reason: null, } +const ROUNDED_THERMAL_ROW = { + ...THERMAL_ROW, + quantity: '1.23456', + cost: '0.12344', + cost_breakdown: { heating: '0.12345', hot_water: '0.10000' }, +} const THERMAL_ROW_OTHER_VERSION = { ...THERMAL_ROW, commodity: 'hot_water', period_start: '2026-06-22T10:15:00Z', period_end: '2026-06-22T10:30:00Z', contract_version_id: 100, quantity: '2.3', cost: '4.339506172839506170', @@ -233,6 +256,32 @@ describe('CostView', () => { }) }) + it('uses the same bottom-aligned toolbar structure in both scopes', async () => { + const user = userEvent.setup() + mockGet.mockImplementation((path: string) => { + if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY }) + if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY }) + return Promise.resolve({ data: { items: [], total: 0 } }) + }) + + renderWithProviders() + + const electricityToolbar = screen.getByTestId('cost-toolbar') + expect(electricityToolbar).toContainElement(screen.getByTestId('costs-scope-selector')) + expect(electricityToolbar).toContainElement(screen.getByTestId('cost-range-control')) + expect(electricityToolbar).toContainElement(screen.getByTestId('cost-recompute-button')) + expect(screen.getByTestId('cost-recompute-button')).toHaveStyle({ marginLeft: 'auto' }) + + await user.click(screen.getByTestId('costs-scope-selector')) + await user.click(screen.getByText('Thermal')) + await waitFor(() => expect(screen.getByTestId('thermal-cost-toolbar')).toBeInTheDocument()) + const thermalToolbar = screen.getByTestId('thermal-cost-toolbar') + expect(thermalToolbar).toContainElement(screen.getByTestId('costs-scope-selector')) + expect(thermalToolbar).toContainElement(screen.getByTestId('thermal-cost-range-control')) + expect(thermalToolbar).toContainElement(screen.getByTestId('thermal-recompute-button')) + expect(screen.getByTestId('thermal-recompute-button')).toHaveStyle({ marginLeft: 'auto' }) + }) + it('calls recompute mutation when confirmed', async () => { const user = userEvent.setup() @@ -349,8 +398,8 @@ describe('CostView', () => { it.each([ ['only heating', [ACTIVE_HEATING_METER], 'hot-water meter is not configured', 'Not configured'], ['only hot water', [ACTIVE_HOT_WATER_METER], 'heating meter is not configured', 'Not configured'], - ['both current meters', [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'], - ['a replaced heating meter plus its current epoch', [ENDED_HEATING_METER, ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'], + ['both current meters', [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.1'], + ['a replaced heating meter plus its current epoch', [ENDED_HEATING_METER, ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.1'], ])('uses active meter epochs for %s without treating zero amounts as missing', async (_name, meterItems, missingText, heatingValue) => { mockGet.mockImplementation((path: string) => { if (path === '/api/energy/meters') return Promise.resolve({ data: { items: meterItems, total: meterItems.length } }) @@ -373,6 +422,44 @@ describe('CostView', () => { } }) + it('rounds ordinary thermal Decimal strings without changing the audit snapshot', async () => { + const user = userEvent.setup() + mockGet.mockImplementation((path: string) => { + if (path === '/api/energy/meters') { + return Promise.resolve({ data: { items: [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], total: 2 } }) + } + if (path === '/api/energy/meter-costs') { + return Promise.resolve({ data: { items: [ROUNDED_THERMAL_ROW], total: 1 } }) + } + if (path === '/api/energy/meter-costs/summary') { + return Promise.resolve({ data: ROUNDED_THERMAL_SUMMARY }) + } + if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY }) + return Promise.resolve({ data: { items: [], total: 0 } }) + }) + + renderWithProviders() + await user.click(screen.getByTestId('costs-scope-selector')) + await user.click(screen.getByText('Thermal')) + await waitFor(() => expect(screen.getByTestId('thermal-cost-summary')).toBeInTheDocument()) + + expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Heating20.1234') + expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Hot-water heating8.2001') + expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Hot water2') + expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Hot-water tax0.4') + expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Variable subtotal7') + expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Fixed subtotal0') + expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('All-in total10') + expect(screen.getByTestId('thermal-fixed-breakdown')).toHaveTextContent('heating_network 100') + expect(screen.getByTestId('thermal-fixed-breakdown')).toHaveTextContent('other 40.4001') + expect(screen.getByTestId('thermal-cost-row-0')).toHaveTextContent('1.2346') + expect(screen.getByTestId('thermal-cost-row-0')).toHaveTextContent('0.1234 EUR') + expect(screen.getByTestId('thermal-cost-row-0')).toHaveTextContent('heating 0.1235, hot_water 0.1') + + await user.click(screen.getByTestId('thermal-cost-expand-0')) + expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('20.123456789123456789') + }) + it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => { const user = userEvent.setup() const lastRow = { ...THERMAL_ROW, period_start: '2026-06-22T12:00:00Z', quantity: '501' } diff --git a/frontend/src/energy/CostView.tsx b/frontend/src/energy/CostView.tsx index 1555139..b050a5c 100644 --- a/frontend/src/energy/CostView.tsx +++ b/frontend/src/energy/CostView.tsx @@ -110,6 +110,49 @@ function SummaryCard({ label, value, sub, testId }: SummaryCardProps) { type RangePreset = 'today' | 'month' | 'custom' +/** Format an API Decimal string without converting it through binary floating point. */ +function formatDecimal(value: string): string { + const negative = value.startsWith('-') + const unsigned = negative ? value.slice(1) : value + const [rawWhole = '0', rawFraction = ''] = unsigned.split('.') + let whole = rawWhole.replace(/^0+(?=\d)/, '') || '0' + let fraction = rawFraction.slice(0, 4) + + if (rawFraction.length > 4 && rawFraction[4] >= '5') { + const digits = '0123456789' + const fractionDigits = fraction.split('') + let carry = true + for (let index = fractionDigits.length - 1; index >= 0 && carry; index -= 1) { + const digit = fractionDigits[index] + if (digit === '9') { + fractionDigits[index] = '0' + } else { + fractionDigits[index] = digits[digits.indexOf(digit) + 1] + carry = false + } + } + fraction = fractionDigits.join('') + + if (carry) { + const wholeDigits = whole.split('') + for (let index = wholeDigits.length - 1; index >= 0 && carry; index -= 1) { + const digit = wholeDigits[index] + if (digit === '9') { + wholeDigits[index] = '0' + } else { + wholeDigits[index] = digits[digits.indexOf(digit) + 1] + carry = false + } + } + whole = `${carry ? '1' : ''}${wholeDigits.join('')}` + } + } + + fraction = fraction.replace(/0+$/, '') + const formatted = fraction ? `${whole}.${fraction}` : whole + return negative && formatted !== '0' ? `-${formatted}` : formatted +} + export function CostView() { const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity') if (scope === 'thermal') return @@ -155,7 +198,7 @@ function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electr return ( {/* Date range selector */} - + @@ -192,18 +235,17 @@ function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electr )} - - - + {/* Summary cards */} @@ -483,16 +525,16 @@ function ThermalCostView({ onScopeChange }: { onScopeChange: (scope: 'electricit 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" />} + 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" />} {(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(' · ')}}} + + {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} ${formatDecimal(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 && TimeCommodityQuantityCostBreakdownStatus{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'}, ...(expandedRows.has(index) ? [Contract version: {item.contract_version_id ?? 'none'}Pricing snapshot: {JSON.stringify(item.pricing_snapshot)}] : [])])}
}
} + {rows.data && Showing {shownStart}-{shownEnd} of {totalRows}{rows.data.items.length > 0 && TimeCommodityQuantityCostBreakdownStatus{rows.data.items.flatMap((item, index) => [{formatLocalTime(item.period_start)}{item.commodity}{formatDecimal(item.quantity)}{formatDecimal(item.cost)} {item.currency}{Object.entries(item.cost_breakdown).map(([key, value]) => `${key} ${formatDecimal(value)}`).join(', ')}{item.degraded ? {item.degraded_reason ?? 'degraded'} : 'normal'}, ...(expandedRows.has(index) ? [Contract version: {item.contract_version_id ?? 'none'}Pricing snapshot: {JSON.stringify(item.pricing_snapshot)}] : [])])}
}
} {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.}}
}