import { Loader2, RefreshCw } from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts' import { getCurrentPlan, postRunPlan } from './api/backend' import { useSiteStatus } from './hooks/useSiteStatus' import type { CurrentPlanResponse, PlanningIntervalDto } from './types/plan' const TZ = 'Europe/Prague' function formatLocal(iso: string): string { const d = new Date(iso) return d.toLocaleString('cs-CZ', { timeZone: TZ, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', }) } function formatLocalTime(iso: string): string { return new Date(iso).toLocaleTimeString('cs-CZ', { timeZone: TZ, hour: '2-digit', minute: '2-digit', }) } function slotStartUtcMs(iso: string): number { return new Date(iso).getTime() } function negPrice(i: PlanningIntervalDto): boolean { const b = i.effective_buy_price const s = i.effective_sell_price return (b != null && b < 0) || (s != null && s < 0) } function rowHighlight(i: PlanningIntervalDto): string { if (negPrice(i)) return 'bg-red-950/45' if ((i.pv_a_curtailed_w ?? 0) > 0) return 'bg-amber-950/35' return '' } type ChartRow = { label: string ts: number pv_kw: number baseline_kw: number bat_charge_kw: number bat_discharge_kw: number price: number raw: PlanningIntervalDto } export default function Planning() { const { site, ready: siteReady } = useSiteStatus() const siteId = site?.site_id ?? null const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [replanning, setReplanning] = useState(false) const [slotDetail, setSlotDetail] = useState(null) const load = useCallback(async () => { if (siteId == null) return setLoading(true) setError(null) try { const res = await getCurrentPlan(siteId) setData(res) } catch (e) { setError(e instanceof Error ? e.message : 'Chyba načtení plánu') setData(null) } finally { setLoading(false) } }, [siteId]) useEffect(() => { if (siteId != null) void load() }, [siteId, load]) const nowMs = Date.now() const dayMs = 24 * 60 * 60 * 1000 const intervals24h = useMemo(() => { if (!data?.intervals?.length) return [] const end = nowMs + dayMs return data.intervals .filter((i) => { const t = slotStartUtcMs(i.interval_start) return t >= nowMs && t < end }) .slice(0, 96) }, [data?.intervals, nowMs, dayMs]) const chartRows: ChartRow[] = useMemo(() => { return intervals24h.map((i) => { const bat = i.battery_setpoint_w ?? 0 const pv = i.pv_forecast_total_w ?? 0 const base = i.load_baseline_w ?? 0 const price = i.effective_buy_price ?? 0 return { label: formatLocalTime(i.interval_start), ts: slotStartUtcMs(i.interval_start), pv_kw: pv / 1000, baseline_kw: base / 1000, bat_charge_kw: Math.max(0, bat) / 1000, bat_discharge_kw: Math.max(0, -bat) / 1000, price, raw: i, } }) }, [intervals24h]) async function onReplan() { if (siteId == null) return setReplanning(true) setError(null) try { await postRunPlan(siteId, 'rolling') await load() } catch (e) { setError(e instanceof Error ? e.message : 'Přepočet selhal') } finally { setReplanning(false) } } if (!siteReady) { return (
Načítám lokalitu…
) } if (siteId == null) { return (
V PostgREST nebyla nalezena lokalita (vw_site_status). Nelze načíst plán.
) } const run = data?.run const summary = data?.summary return (

Plánování

Aktuální LP plán a přehled dalších 24 hodin ({site?.site_name ?? 'lokalita'})

{error && (
{error}
)} {/* Sekce 1 */}

Aktuální plán

{loading && !run ? (
Načítám…
) : !run ? (

Žádný aktivní plán v databázi.

) : (
Vytvořen
{formatLocal(run.created_at)}
Typ
{run.run_type}
Korekce FVE
{run.forecast_correction_factor != null ? run.forecast_correction_factor.toFixed(4) : '—'}
Čas solveru
{run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
)} {summary && run && (
Očekávané náklady (celkem)
{summary.total_expected_cost_czk.toFixed(2)} Kč
Curtailment A
{summary.total_pv_curtailed_kwh.toFixed(3)} kWh
Sloty nabíjení
{summary.charge_slots}
Sloty vybíjení
{summary.discharge_slots}
Sloty exportu
{summary.export_slots}
)}
{/* Sekce 2 */}

Graf (24 h)

{!chartRows.length ? (

Žádná data pro graf v horizontu 24 h.

) : (
{ const p = state?.activePayload?.[0]?.payload as ChartRow | undefined if (p?.raw) setSlotDetail(p.raw) }} > { if (name === 'Cena nákup') return [`${value.toFixed(3)} Kč/kWh`, name] return [`${value.toFixed(2)} kW`, name] }} />
)} {slotDetail && (
Slot {formatLocal(slotDetail.interval_start)}
Nákup / prodej
{slotDetail.effective_buy_price?.toFixed(4) ?? '—'} /{' '} {slotDetail.effective_sell_price?.toFixed(4) ?? '—'}
FVE (A+B)
{slotDetail.pv_forecast_total_w ?? '—'} W
Baseline
{slotDetail.load_baseline_w ?? '—'} W
Baterie
{slotDetail.battery_setpoint_w ?? '—'} W
SoC cíl
{slotDetail.battery_soc_target_pct != null ? `${slotDetail.battery_soc_target_pct}%` : '—'}
Síť
{slotDetail.grid_setpoint_w ?? '—'} W
EV1 / EV2
{slotDetail.ev1_setpoint_w ?? '—'} / {slotDetail.ev2_setpoint_w ?? '—'} W
{slotDetail.heat_pump_enabled ? 'Zapnuto' : 'Vypnuto'}
Curtailment A
{slotDetail.pv_a_curtailed_w ?? 0} W
Náklady slotu
{slotDetail.expected_cost_czk?.toFixed(4) ?? '—'} Kč
)}
{/* Sekce 3 */}

Tabulka (96 slotů / 24 h)

{intervals24h.map((i) => ( ))}
Čas Nákup Prodej FVE Bat Síť EV1 EV2 Náklady
{formatLocalTime(i.interval_start)} {i.effective_buy_price?.toFixed(2) ?? '—'} {i.effective_sell_price?.toFixed(2) ?? '—'} {i.pv_forecast_total_w != null ? Math.round(i.pv_forecast_total_w) : '—'} {i.battery_setpoint_w ?? '—'} {i.grid_setpoint_w ?? '—'} {i.ev1_setpoint_w ?? '—'} {i.ev2_setpoint_w ?? '—'} {i.heat_pump_enabled ? 'Ano' : 'Ne'} {i.expected_cost_czk?.toFixed(2) ?? '—'}
{!intervals24h.length && !loading && (

Žádné řádky v 24h okně.

)}
) }