Files
home-automation/frontend/src/energy/TibberPrices.tsx
T

245 lines
6.6 KiB
TypeScript
Raw Normal View History

/**
* TibberPrices — price curve visualization.
*
* - Fetches today + tomorrow price range using useEnergyPrices.
* - For tibber kind: Recharts LineChart showing buy/sell prices over time.
* - For manual kind: shows tariff table (buy_dal, buy_normal, sell_dal, sell_normal).
* - Handles: no active contract, empty data, loading, error.
*
* Recharts imports are isolated to this file only.
*/
import {
Stack,
Text,
Loader,
Center,
Alert,
Table,
Title,
Badge,
Group,
Paper,
} from '@mantine/core'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { useEnergyPrices } from './hooks'
import { formatLocalTime } from '../utils/datetime'
// ---------------------------------------------------------------------------
// Time range helpers
// ---------------------------------------------------------------------------
function getTodayStart(): string {
const d = new Date()
d.setUTCHours(0, 0, 0, 0)
return d.toISOString()
}
function getTomorrowEnd(): string {
const d = new Date()
d.setUTCHours(0, 0, 0, 0)
d.setUTCDate(d.getUTCDate() + 2)
return d.toISOString()
}
// ---------------------------------------------------------------------------
// Tibber chart
// ---------------------------------------------------------------------------
interface TibberChartProps {
points: Array<{ starts_at: string; buy: number; sell: number; level?: string | null }>
currency: string
}
function TibberChart({ points, currency }: TibberChartProps) {
const data = points.map((p) => ({
time: formatLocalTime(p.starts_at),
buy: p.buy,
sell: p.sell,
}))
return (
<Stack gap="xs" data-testid="tibber-chart">
<Title order={6} c="dimmed">
Price curve ({currency})
</Title>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="time"
tick={{ fontSize: 10 }}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10 }}
tickFormatter={(v: number) => v.toFixed(3)}
/>
<Tooltip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(val: any) =>
[`${typeof val === 'number' ? val.toFixed(4) : String(val)} ${currency}`, undefined]
}
/>
<Legend />
<Line
type="monotone"
dataKey="buy"
stroke="#2196f3"
dot={false}
strokeWidth={2}
name="Buy"
/>
<Line
type="monotone"
dataKey="sell"
stroke="#4caf50"
dot={false}
strokeWidth={2}
name="Sell"
/>
</LineChart>
</ResponsiveContainer>
</Stack>
)
}
// ---------------------------------------------------------------------------
// Manual tariff table
// ---------------------------------------------------------------------------
interface ManualTariffTableProps {
tariff: {
buy_dal: number
buy_normal: number
sell_dal: number
sell_normal: number
}
currency: string
}
function ManualTariffTable({ tariff, currency }: ManualTariffTableProps) {
return (
<Stack gap="xs" data-testid="manual-tariff-table">
<Title order={6} c="dimmed">
Fixed tariff ({currency}/kWh)
</Title>
<Table withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Tariff</Table.Th>
<Table.Th>Buy</Table.Th>
<Table.Th>Sell</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
<Table.Td>Normal (peak)</Table.Td>
<Table.Td data-testid="tariff-buy-normal">{tariff.buy_normal.toFixed(4)}</Table.Td>
<Table.Td data-testid="tariff-sell-normal">{tariff.sell_normal.toFixed(4)}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>Dal (off-peak)</Table.Td>
<Table.Td data-testid="tariff-buy-dal">{tariff.buy_dal.toFixed(4)}</Table.Td>
<Table.Td data-testid="tariff-sell-dal">{tariff.sell_dal.toFixed(4)}</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Stack>
)
}
// ---------------------------------------------------------------------------
// TibberPrices — main component
// ---------------------------------------------------------------------------
export function TibberPrices() {
const start = getTodayStart()
const end = getTomorrowEnd()
const { data, isLoading, isError } = useEnergyPrices(start, end)
if (isLoading) {
return (
<Center py="xl" data-testid="prices-loading">
<Loader />
</Center>
)
}
if (isError) {
return (
<Alert color="red" data-testid="prices-error">
Failed to load energy prices. Please refresh.
</Alert>
)
}
if (!data) {
return (
<Alert color="gray" data-testid="prices-no-data">
No pricing data available.
</Alert>
)
}
// No active contract
if (!data.kind) {
return (
<Paper withBorder p="md" data-testid="prices-no-contract">
<Stack gap="xs">
<Text fw={500}>No active contract</Text>
<Text size="sm" c="dimmed">
Activate an energy contract on the Contracts tab to see pricing data.
</Text>
</Stack>
</Paper>
)
}
const currency = data.currency
return (
<Stack gap="lg" data-testid="tibber-prices">
<Group gap="sm" align="center">
<Text fw={500}>Energy Prices</Text>
<Badge variant="outline" size="sm">
{data.kind}
</Badge>
<Text size="xs" c="dimmed">
{currency}
</Text>
</Group>
{data.kind === 'tibber' && data.points && data.points.length > 0 && (
<TibberChart points={data.points} currency={currency} />
)}
{data.kind === 'tibber' && (!data.points || data.points.length === 0) && (
<Alert color="yellow" data-testid="tibber-no-prices">
No Tibber price points available for the selected time range. Check Tibber configuration.
</Alert>
)}
{data.kind === 'manual' && data.tariff && (
<ManualTariffTable tariff={data.tariff} currency={currency} />
)}
{data.kind === 'manual' && !data.tariff && (
<Alert color="yellow" data-testid="manual-no-tariff">
Manual tariff data not available.
</Alert>
)}
</Stack>
)
}