second version

This commit is contained in:
Dusan Vojacek
2026-04-03 14:23:16 +02:00
parent 897b95f728
commit 9f4126946d
105 changed files with 9738 additions and 1470 deletions

View File

@@ -1,262 +1,159 @@
import { useState } from 'react'
import { Sun, Battery, Zap, Home, ChevronDown, ChevronUp } from 'lucide-react'
import type { ChartArea } from 'chart.js'
import { Activity, Battery, ChevronDown, ChevronUp, Sun, Zap } from 'lucide-react'
import { memo, useCallback, useEffect, useState } from 'react'
import { EnergyChart } from '../components/charts/EnergyChart'
import { ForecastPanel } from '../components/charts/ForecastPanel'
import { NegPricePanel } from '../components/charts/NegPricePanel'
import { RegimeBar } from '../components/charts/RegimeBar'
import { SocTuvChart } from '../components/charts/SocTuvChart'
import {
Area,
Bar,
CartesianGrid,
Cell,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { useAuditDailyToday } from '../hooks/useAuditDailyToday'
import { useCurrentPlan } from '../hooks/useCurrentPlan'
postImportSitePrices,
postRunPlan,
postSiteMode,
} from '../api/backend'
import { CHART_LAYOUT_PADDING } from '../components/charts/chartConstants'
import { ControlPanel } from '../components/ControlPanel'
import { ModeBar } from '../components/ModeBar'
import { NotificationBar } from '../components/NotificationBar'
import { StatePanel } from '../components/StatePanel'
import { useDashboardData } from '../hooks/useDashboardData'
import { useFullStatus } from '../hooks/useFullStatus'
import { useNotifications } from '../hooks/useNotifications'
import { useRollingReplanMinutes } from '../hooks/useRollingReplanMinutes'
import { useSiteStatus } from '../hooks/useSiteStatus'
import { useTelemetryToday, type TelemetryChartPoint } from '../hooks/useTelemetryToday'
import type { PlanningIntervalDto } from '../types/plan'
const BAT_PLAN_W = 80
const MemoEnergyChart = memo(EnergyChart, (prev, next) =>
prev.slots === next.slots && prev.nowIndex === next.nowIndex && prev.hidden === next.hidden,
)
const MemoRegimeBar = memo(RegimeBar, (prev, next) =>
prev.slots === next.slots &&
prev.nowIndex === next.nowIndex &&
prev.chartArea === next.chartArea,
)
const MemoSocTuvChart = memo(SocTuvChart, (prev, next) =>
prev.slots === next.slots && prev.nowIndex === next.nowIndex,
)
function fmtKw2(w: number | null | undefined): string {
if (w == null || Number.isNaN(w)) return '—'
return `${(w / 1000).toFixed(2)} kW`
}
function fmtEnergy(v: string | number | null | undefined): string {
const n = typeof v === 'number' ? v : v == null ? NaN : Number(v)
if (!Number.isFinite(n)) return '—'
return `${n.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh`
function fmtMoney3(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '—'
return `${v.toLocaleString('cs-CZ', { minimumFractionDigits: 3, maximumFractionDigits: 3 })} Kč/kWh`
}
function fmtMoney(v: string | number | null | undefined): string {
const n = typeof v === 'number' ? v : v == null ? NaN : Number(v)
if (!Number.isFinite(n)) return '—'
return `${n.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč`
}
function parseNum(v: string | number | null | undefined): number | null {
if (v == null) return null
if (typeof v === 'number' && !Number.isNaN(v)) return v
const n = Number(v)
return Number.isFinite(n) ? n : null
}
function modeBadgeClass(code: string | null): string {
const c = (code ?? '').toUpperCase()
if (c.includes('AUTO')) return 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/35'
if (c.includes('SELF')) return 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/35'
if (c.includes('MANUAL') || c.includes('FORCE')) return 'bg-amber-500/15 text-amber-200 ring-1 ring-amber-500/35'
if (c.includes('OFF') || c.includes('IDLE')) return 'bg-slate-600/40 text-slate-300 ring-1 ring-slate-500/30'
return 'bg-slate-700/60 text-slate-200 ring-1 ring-slate-600/50'
}
function formatTelemetryAgo(iso: string | null | undefined): string {
if (iso == null) return '—'
const diffMin = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000)
if (diffMin <= 0) return 'právě teď'
if (diffMin === 1) return 'před 1 minutou'
if (diffMin >= 2 && diffMin <= 4) return `před ${diffMin} minutami`
return `před ${diffMin} minutami`
}
function floorToSlotUtc(ms: number): number {
const slot = 15 * 60 * 1000
return Math.floor(ms / slot) * slot
}
function nextPlanSlots(intervals: PlanningIntervalDto[], count: number): PlanningIntervalDto[] {
if (!intervals.length) return []
const sorted = [...intervals].sort(
(a, b) => new Date(a.interval_start).getTime() - new Date(b.interval_start).getTime(),
)
const boundary = floorToSlotUtc(Date.now())
const upcoming = sorted.filter((iv) => new Date(iv.interval_start).getTime() >= boundary - 1)
return upcoming.slice(0, count)
}
function meanBuyPrice(slots: PlanningIntervalDto[]): number | null {
const vals = slots
.map((s) => s.effective_buy_price)
.filter((x): x is number => x != null && Number.isFinite(x))
if (!vals.length) return null
return vals.reduce((a, b) => a + b, 0) / vals.length
}
function slotBgClass(slot: PlanningIntervalDto, avgBuy: number | null): string {
const b = slot.battery_setpoint_w ?? 0
if (b > BAT_PLAN_W) return 'bg-emerald-500'
if (b < -BAT_PLAN_W) return 'bg-orange-500'
const buy = slot.effective_buy_price
if (buy != null && avgBuy != null && avgBuy > 0) {
if (buy > avgBuy * 1.15) return 'bg-red-500'
if (buy < avgBuy * 0.85) return 'bg-amber-400'
}
return 'bg-slate-600'
}
function formatSlotLabel(iso: string): string {
return new Date(iso).toLocaleTimeString('cs-CZ', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Prague',
})
}
type ChartTipPayload = { name?: string; value?: number; dataKey?: string | number }
function ChartTooltip({
active,
payload,
label,
}: {
active?: boolean
payload?: ChartTipPayload[]
label?: string
}) {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-slate-600 bg-slate-900/95 px-3 py-2 text-xs text-slate-100 shadow-xl">
<p className="mb-1 font-medium text-slate-200">{label}</p>
<ul className="space-y-0.5 tabular-nums">
{payload.map((p) => (
<li key={String(p.dataKey)} className="flex justify-between gap-6">
<span className="text-slate-400">{p.name}</span>
<span>{typeof p.value === 'number' ? `${p.value.toFixed(2)} kW` : '—'}</span>
</li>
))}
</ul>
</div>
)
}
function SemicircleSocGauge({ socPercent }: { socPercent: string | number | null | undefined }) {
const raw = parseNum(socPercent)
const pct = raw == null ? null : Math.max(0, Math.min(100, raw))
const r = 88
const halfLen = Math.PI * r
const stroke =
pct == null ? 'text-slate-600' : pct < 20 ? 'text-red-500' : pct > 80 ? 'text-blue-500' : 'text-emerald-500'
return (
<div className="flex flex-col items-center pt-2">
<div className="relative h-[120px] w-[220px]">
<svg viewBox="0 0 200 110" className="h-full w-full" aria-hidden>
<path
d="M 12 100 A 88 88 0 0 1 188 100"
fill="none"
stroke="currentColor"
strokeWidth="14"
className="text-slate-800"
/>
{pct != null && (
<path
d="M 12 100 A 88 88 0 0 1 188 100"
fill="none"
stroke="currentColor"
strokeWidth="14"
strokeLinecap="round"
strokeDasharray={halfLen}
strokeDashoffset={halfLen * (1 - pct / 100)}
className={stroke}
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
/>
)}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1">
<span className="text-3xl font-bold tabular-nums text-slate-50">
{pct == null ? '—' : `${pct.toFixed(0)}`}
</span>
<span className="text-xs text-slate-500">% SoC</span>
</div>
</div>
</div>
)
function fmtSoc(p: number | null | undefined): string {
if (p == null || Number.isNaN(p)) return '—'
return `${p.toFixed(0)} %`
}
function MetricSkeleton() {
return <div className="h-[104px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
function BlockSkeleton({ className = '' }: { className?: string }) {
return <div className={`animate-pulse rounded-xl border border-slate-800 bg-slate-900/40 ${className}`} />
return <div className="h-[92px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
}
export function Dashboard() {
const { site, ready: siteReady, error: siteError, hasLiveData, reload: reloadSite } = useSiteStatus()
const siteId = site?.site_id ?? null
const { site: siteRow, ready: siteReady, error: siteErr } = useSiteStatus()
const siteId = siteRow?.site_id ?? null
const data = useDashboardData(siteId)
const { notifications, reload: reloadNotifications } = useNotifications(siteId)
const { nextReplanIn, refreshRollingEta } = useRollingReplanMinutes()
const { fullStatus } = useFullStatus(siteId)
const [alertsOpen, setAlertsOpen] = useState(false)
const [inverterDiagOpen, setInverterDiagOpen] = useState(true)
const [hiddenSeries, setHiddenSeries] = useState<Set<string>>(() => new Set())
const {
points,
ready: chartReady,
error: chartError,
hasChartData,
reload: reloadChart,
} = useTelemetryToday(siteId)
const {
daily,
ready: auditReady,
error: auditError,
hasDaily,
reload: reloadAudit,
} = useAuditDailyToday(siteId)
const { plan, ready: planReady, error: planError, reload: reloadPlan } = useCurrentPlan(siteId)
useEffect(() => {
console.log('siteId:', siteId)
console.log('inverterDiagOpen:', inverterDiagOpen)
}, [siteId, inverterDiagOpen])
const [chartArea, setChartArea] = useState<ChartArea | null>(null)
const fetchError = siteError ?? chartError ?? auditError ?? planError
const retryAll = () => {
void reloadSite()
void reloadChart()
void reloadAudit()
void reloadPlan()
}
const toggleSeries = useCallback((key: string) => {
setHiddenSeries((prev) => {
const n = new Set(prev)
if (n.has(key)) n.delete(key)
else n.add(key)
return n
})
}, [])
const metricsLoading = !siteReady
const chartLoading = !chartReady
const summaryLoading = !auditReady
const planLoading = !planReady
const hbOnline = site?.ems_heartbeat_status === 'ok'
const onChartArea = useCallback((area: ChartArea) => {
setChartArea(area)
}, [])
const site = siteRow
const fetchError = data.error ?? siteErr
const metricsLoading = !data.ready || !siteReady
const monitoringAlerts = fullStatus?.alerts ?? []
const hasMonitoringAlerts = monitoringAlerts.length > 0
const monitoringHasError = monitoringAlerts.some((a) => a.level === 'error')
const hbOnline = site?.ems_heartbeat_status === 'ok'
const gridW = site?.grid_power_w ?? null
const gridLabel =
gridW == null || Number.isNaN(gridW)
? '—'
: gridW >= 0
? `+${(gridW / 1000).toFixed(2)} kW import`
: `${(gridW / 1000).toFixed(2)} kW export`
/** Horní karty (FVE, síť, SoC, cena): liveMetrics z useDashboardData (5s poll / WS), ne siteRow. */
const lm = data.liveMetrics
const batW = site?.battery_power_w ?? null
const batPct = parseNum(site?.battery_soc_percent)
const batSignedKw =
batW == null || Number.isNaN(batW) ? null : Math.abs(batW / 1000) * (batW >= 0 ? 1 : -1)
const modeName = site?.active_mode ?? fullStatus?.operating_mode.mode_code ?? 'AUTO'
const modeActivatedAt = site?.activated_at ?? fullStatus?.operating_mode.activated_at ?? null
const planSlots = nextPlanSlots(plan.intervals, 16)
const avgBuy = meanBuyPrice(planSlots)
const handleReplan = useCallback(() => {
if (siteId == null) return
void postRunPlan(siteId, 'rolling')
.then(() => {
void data.reload()
void refreshRollingEta()
void reloadNotifications()
})
.catch(() => {
/* ignore */
})
}, [siteId, data, refreshRollingEta, reloadNotifications])
const chartData: TelemetryChartPoint[] = points
const handleImportPrices = useCallback(() => {
if (siteId == null) return
void postImportSitePrices(siteId)
.then(() => {
void data.reload()
void reloadNotifications()
})
.catch(() => {
/* ignore */
})
}, [siteId, data, reloadNotifications])
const handleSwitchAuto = useCallback(() => {
if (siteId == null) return
void postSiteMode(siteId, {
mode: 'AUTO',
notes: 'Přepnuto z notifikace',
valid_until: null,
})
.then(() => {
void data.reload()
void reloadNotifications()
})
.catch(() => {
/* ignore */
})
}, [siteId, data, reloadNotifications])
return (
<div className="min-h-screen bg-slate-950 p-4 text-slate-100 md:p-8">
<div className="mx-auto max-w-7xl space-y-8">
<div className="min-h-screen bg-gray-950 p-4 text-slate-100 md:p-8">
<div className="mx-auto max-w-7xl space-y-6">
{fetchError ? (
<div
className="flex flex-col gap-3 rounded-xl border border-red-500/40 bg-red-950/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
role="alert"
>
<p className="text-sm font-medium text-red-200">Chyba načítání dat</p>
<p className="text-sm font-medium text-red-200">{fetchError}</p>
<button
type="button"
onClick={() => retryAll()}
onClick={() => void data.reload()}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-500"
>
Zkusit znovu
@@ -264,157 +161,99 @@ export function Dashboard() {
</div>
) : null}
<header className="border-b border-slate-800/80 pb-6">
<header className="border-b border-slate-800/80 pb-5">
<h1 className="text-2xl font-bold tracking-tight text-white">EMS Platform</h1>
<p className="mt-1 text-sm text-slate-400">Přehled lokality, auditu a plánu</p>
<p className="mt-1 text-sm text-slate-400">Přehled výkonů, režimů a cen</p>
</header>
{/* Horní metriky */}
{siteId != null ? (
<ModeBar
modeName={modeName}
activatedAt={modeActivatedAt}
nextReplanIn={nextReplanIn}
onReplan={handleReplan}
onModeChange={() => {}}
/>
) : null}
{notifications.length > 0 ? (
<NotificationBar
notifications={notifications}
onReplan={handleReplan}
onImportPrices={handleImportPrices}
onSwitchAuto={handleSwitchAuto}
/>
) : null}
<section>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
{metricsLoading ? (
<>
<MetricSkeleton />
<MetricSkeleton />
<MetricSkeleton />
<MetricSkeleton />
<MetricSkeleton />
</>
) : site == null ? (
<p className="col-span-full text-sm text-slate-500">Žádná lokalita ve vw_site_status.</p>
<p className="col-span-full text-sm text-slate-500">Žádná aktivní lokalita ve vw_site_status.</p>
) : (
<>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-amber-400 bg-slate-900/60 p-4 pl-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<Sun className="h-6 w-6 text-amber-400" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">FVE výroba</p>
<p className="text-xl font-semibold tabular-nums text-amber-300">
{hasLiveData ? fmtKw2(site.pv_power_w) : '—'}{' '}
<span className="text-lg" aria-hidden>
</span>
</p>
</div>
</div>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-amber-500/80 bg-slate-900/70 p-4 pl-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">FVE</p>
<p className="mt-1 text-xl font-semibold tabular-nums text-amber-300">{fmtKw2(lm?.pv_w)}</p>
<Sun className="mt-2 h-5 w-5 text-amber-500/80" aria-hidden />
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
<div className="flex items-center gap-3">
<div
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80 ${
batW != null && !Number.isNaN(batW)
? batW >= 0
? 'text-emerald-400'
: 'text-orange-400'
: 'text-slate-400'
}`}
>
<Battery className="h-6 w-6" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Baterie</p>
<p className="text-xl font-semibold tabular-nums text-slate-100">
{batPct == null ? '—' : `${batPct.toFixed(0)}%`}
{batSignedKw == null ? '' : ` / ${batSignedKw >= 0 ? '+' : ''}${batSignedKw.toFixed(2)} kW`}
</p>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-800">
<div
className={`h-full rounded-full transition-all ${
batW != null && !Number.isNaN(batW) && batW < 0 ? 'bg-orange-500' : 'bg-emerald-500'
}`}
style={{ width: `${batPct ?? 0}%` }}
/>
</div>
</div>
</div>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-blue-500/80 bg-slate-900/70 p-4 pl-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">Spotřeba</p>
<p className="mt-1 text-xl font-semibold tabular-nums text-blue-300">{fmtKw2(lm?.load_w)}</p>
<Activity className="mt-2 h-5 w-5 text-blue-500/80" aria-hidden />
</div>
<div
className={`rounded-xl border border-slate-800 bg-slate-900/60 p-4 pl-3 border-l-4 ${
gridW != null && !Number.isNaN(gridW)
? gridW >= 0
? 'border-l-red-500'
: 'border-l-emerald-500'
: 'border-l-slate-600'
}`}
>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<Zap
className={`h-6 w-6 ${
gridW != null && !Number.isNaN(gridW)
? gridW >= 0
? 'text-red-400'
: 'text-emerald-400'
: 'text-slate-400'
}`}
aria-hidden
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Síť</p>
<p className="text-xl font-semibold tabular-nums text-slate-100">{gridLabel}</p>
<p className="mt-0.5 text-xs text-slate-500">
{gridW != null && !Number.isNaN(gridW)
? gridW >= 0
? 'import'
: 'export'
: ''}
</p>
</div>
</div>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-red-500/80 bg-slate-900/70 p-4 pl-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">Síť</p>
<p className="mt-1 text-xl font-semibold tabular-nums text-slate-100">{fmtKw2(lm?.grid_w)}</p>
<p className="mt-0.5 text-[10px] text-slate-500">
{lm?.grid_w == null ? '' : lm.grid_w >= 0 ? 'import' : 'export'}
</p>
<Zap className="mt-1 h-5 w-5 text-red-400/80" aria-hidden />
</div>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-blue-500 bg-slate-900/60 p-4 pl-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
<Home className="h-6 w-6 text-blue-400" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">Spotřeba</p>
<p className="text-xl font-semibold tabular-nums text-blue-300">
{hasLiveData ? fmtKw2(site.load_power_w) : '—'}
</p>
</div>
</div>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-emerald-500/80 bg-slate-900/70 p-4 pl-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">SOC</p>
<p className="mt-1 text-xl font-semibold tabular-nums text-emerald-300">{fmtSoc(lm?.bat_soc)}</p>
<Battery className="mt-2 h-5 w-5 text-emerald-500/80" aria-hidden />
</div>
<div className="rounded-xl border border-slate-800 border-l-4 border-l-rose-500/80 bg-slate-900/70 p-4 pl-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">Cena nákup</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-rose-200">
{fmtMoney3(data.buyNow)}
</p>
<p className="mt-1 text-[10px] text-slate-500">Aktuální 15min slot</p>
</div>
</>
)}
</div>
{/* Status řádek */}
{!metricsLoading && site != null ? (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="text-slate-500">Aktivní režim:</span>
<span
className={`rounded-md px-2.5 py-1 text-xs font-semibold uppercase tracking-wide ${modeBadgeClass(site.active_mode)}`}
title={site.mode_description ?? undefined}
>
<div className="mt-4 space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-3">
<span className="text-slate-500">Režim:</span>
<span className="rounded-md bg-slate-800 px-2 py-1 text-xs font-semibold uppercase text-slate-200">
{site.active_mode ?? '—'}
{site.mode_name ? ` · ${site.mode_name}` : ''}
</span>
<span className="flex items-center gap-2 text-slate-400">
<span className="relative flex h-2.5 w-2.5">
<span
className={`inline-flex h-2.5 w-2.5 rounded-full ${hbOnline ? 'bg-emerald-500' : 'bg-red-500'}`}
/>
</span>
<span
className={`inline-flex h-2.5 w-2.5 rounded-full ${hbOnline ? 'bg-emerald-500' : 'bg-red-500'}`}
/>
EMS:{' '}
<span className={hbOnline ? 'text-emerald-400' : 'text-red-400'}>
{hbOnline ? 'online' : 'offline'}
</span>
</span>
<span className="text-slate-500">
Poslední telemetrie:{' '}
<span className="text-slate-300">{formatTelemetryAgo(site.telemetry_at)}</span>
</span>
</div>
{hasMonitoringAlerts ? (
<div className="w-full max-w-2xl">
<div className="max-w-2xl">
<button
type="button"
onClick={() => setAlertsOpen((o) => !o)}
@@ -428,7 +267,6 @@ export function Dashboard() {
<span>
{monitoringAlerts.length}{' '}
{monitoringAlerts.length === 1 ? 'alert' : 'alertů'}
{monitoringHasError ? ' · obsahuje chyby' : ''}
</span>
{alertsOpen ? (
<ChevronUp className="h-4 w-4 shrink-0 opacity-80" aria-hidden />
@@ -443,18 +281,10 @@ export function Dashboard() {
? 'border-red-500/30 bg-red-950/25 text-red-100'
: 'border-amber-500/25 bg-amber-950/20 text-amber-50'
}`}
role="list"
>
{monitoringAlerts.map((a, i) => (
<li
key={`${a.level}-${i}-${a.message}`}
className={
a.level === 'error'
? 'text-red-200'
: 'text-amber-200'
}
>
<span className="font-semibold uppercase tracking-wide text-[10px] opacity-80">
<li key={`${a.level}-${i}`} className={a.level === 'error' ? 'text-red-200' : 'text-amber-200'}>
<span className="text-[10px] font-semibold uppercase opacity-80">
{a.level === 'error' ? 'Chyba' : 'Varování'}
</span>
<span className="ml-2">{a.message}</span>
@@ -465,164 +295,72 @@ export function Dashboard() {
</div>
) : null}
</div>
) : metricsLoading ? (
<div className="mt-4 h-5 w-full max-w-md animate-pulse rounded bg-slate-800/80" />
) : null}
</section>
{/* Graf + denní souhrn */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
{chartLoading ? (
<BlockSkeleton className="h-[300px] w-full" />
) : !hasChartData ? (
<div className="flex h-[300px] items-center justify-center rounded-xl border border-slate-800 bg-slate-900/40 text-sm text-slate-500">
Zatím žádná data pro dnešní den
</div>
) : (
<div className="h-[300px] w-full rounded-xl border border-slate-800 bg-slate-900/40 p-2 pr-4 pb-4 pt-4">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="timeLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<YAxis tick={{ fill: '#94a3b8', fontSize: 11 }} width={40} />
<Tooltip content={<ChartTooltip />} />
<Area
type="monotone"
dataKey="pv_kw"
name="FVE"
stroke="#fbbf24"
fill="#fbbf24"
fillOpacity={0.25}
connectNulls
/>
<Line
type="monotone"
dataKey="load_kw"
name="Spotřeba"
stroke="#60a5fa"
strokeWidth={2}
dot={false}
connectNulls
/>
<Bar dataKey="battery_kw" name="Baterie" barSize={14}>
{chartData.map((e, i) => (
<Cell
key={`c-${i}`}
fill={
e.battery_kw == null || Number.isNaN(e.battery_kw)
? '#475569'
: e.battery_kw >= 0
? '#22c55e'
: '#f97316'
}
/>
))}
</Bar>
<Line
type="monotone"
dataKey="grid_kw"
name="Síť"
stroke="#94a3b8"
strokeWidth={2}
strokeDasharray="6 4"
dot={false}
connectNulls
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</div>
<div className="lg:col-span-1">
{summaryLoading ? (
<div className="space-y-3">
<BlockSkeleton className="h-10 w-full" />
<BlockSkeleton className="h-10 w-full" />
<BlockSkeleton className="h-10 w-full" />
<BlockSkeleton className="h-10 w-full" />
<BlockSkeleton className="h-40 w-full" />
</div>
) : (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-5">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500">Dnešní souhrn</h2>
<ul className="mt-4 space-y-3 text-sm">
<li className="flex justify-between gap-2">
<span className="text-slate-400">FVE výroba</span>
<span className="tabular-nums text-amber-200">{fmtEnergy(daily?.pv_kwh)}</span>
</li>
<li className="flex justify-between gap-2">
<span className="text-slate-400">Import ze sítě</span>
<span className="tabular-nums text-red-300">{fmtEnergy(daily?.import_kwh)}</span>
</li>
<li className="flex justify-between gap-2">
<span className="text-slate-400">Export do sítě</span>
<span className="tabular-nums text-emerald-300">{fmtEnergy(daily?.export_kwh)}</span>
</li>
<li className="flex justify-between gap-2">
<span className="text-slate-400">Náklady / příjem</span>
{(() => {
const c = parseNum(daily?.actual_cost_czk)
const cls =
c == null
? 'text-slate-200'
: c > 0
? 'text-red-400'
: c < 0
? 'text-emerald-400'
: 'text-slate-200'
return <span className={`tabular-nums font-medium ${cls}`}>{fmtMoney(daily?.actual_cost_czk)}</span>
})()}
</li>
</ul>
{!hasDaily ? (
<p className="mt-3 text-xs text-slate-500">Pro dnešek zatím nejsou uzavřené intervaly auditu.</p>
) : null}
<SemicircleSocGauge socPercent={site?.battery_soc_percent} />
</div>
)}
</div>
</section>
{/* Plán 4 h */}
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Nejbližší plán (4 hodiny)
</h2>
{planLoading ? (
<BlockSkeleton className="h-16 w-full" />
) : planSlots.length === 0 ? (
<p className="text-sm text-slate-500">Plán zatím není k dispozici</p>
{data.slots.length === 0 && data.ready ? (
<div className="rounded-xl border border-slate-800 bg-slate-900/40 px-4 py-8 text-center text-sm text-slate-500">
Nedostatek dat pro graf (zkontrolujte plán a telemetrii).
</div>
) : (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div className="flex gap-1">
{planSlots.map((slot, i) => (
<div key={`${slot.interval_start}-${i}`} className="min-w-0 flex-1 group relative">
<div
className={`h-10 w-full rounded-sm ${slotBgClass(slot, avgBuy)} opacity-90 transition group-hover:opacity-100`}
title=""
/>
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden w-max min-w-[140px] -translate-x-1/2 rounded-md border border-slate-600 bg-slate-900 px-2 py-1.5 text-[10px] text-slate-100 shadow-lg group-hover:block">
<p className="font-medium text-slate-200">{formatSlotLabel(slot.interval_start)}</p>
<p className="tabular-nums text-slate-400">
cena:{' '}
{slot.effective_buy_price == null
? '—'
: `${slot.effective_buy_price.toFixed(3)} Kč/kWh`}
</p>
<p className="tabular-nums text-slate-400">
baterie: {fmtKw2(slot.battery_setpoint_w ?? undefined)}
</p>
<p className="tabular-nums text-slate-400">síť: {fmtKw2(slot.grid_setpoint_w ?? undefined)}</p>
</div>
</div>
))}
<div className="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/40">
<div className="px-2 pb-1 pt-2">
<MemoEnergyChart
slots={data.slots}
nowIndex={data.nowIndex}
hidden={hiddenSeries}
onToggle={toggleSeries}
onChartArea={onChartArea}
/>
</div>
<MemoRegimeBar
slots={data.slots}
nowIndex={data.nowIndex}
chartPaddingLeft={CHART_LAYOUT_PADDING.left}
chartPaddingRight={CHART_LAYOUT_PADDING.right}
chartArea={chartArea}
/>
<div className="border-t border-slate-800 px-2 py-2">
<MemoSocTuvChart slots={data.slots} nowIndex={data.nowIndex} />
</div>
<p className="mt-2 text-center text-[10px] text-slate-600">16× 15 min · najet myší pro detail</p>
</div>
)}
</section>
{data.slots.length > 0 && data.ready ? (
<section>
<StatePanel slots={data.slots} nowIndex={data.nowIndex} />
</section>
) : null}
{siteId != null ? (
<section className="rounded-xl border border-slate-800 bg-slate-900/40">
<button
type="button"
onClick={() => setInverterDiagOpen((o) => !o)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm font-medium text-slate-200 transition hover:bg-slate-800/30"
aria-expanded={inverterDiagOpen}
>
<span>Diagnostika střídače (Deye · Modbus)</span>
{inverterDiagOpen ? (
<ChevronUp className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
) : (
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
)}
</button>
{inverterDiagOpen ? (
<div className="border-t border-slate-800 p-4">
<ControlPanel siteId={siteId} />
</div>
) : null}
</section>
) : null}
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
<ForecastPanel days={data.forecastWeek} />
<NegPricePanel items={data.negPrices} />
</section>
</div>
</div>
)

View File

@@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from 'react'
type LogRecord = {
ts?: string
level?: string
logger?: string
msg?: string
}
export function Logs() {
const [lines, setLines] = useState<LogRecord[]>([])
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${window.location.host}/ws/logs`)
ws.onmessage = (ev) => {
try {
const rec = JSON.parse(ev.data as string) as LogRecord
setLines((prev) => {
const next = [...prev, rec]
return next.length > 500 ? next.slice(-500) : next
})
} catch {
/* ignore */
}
}
return () => ws.close()
}, [])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [lines.length])
return (
<div className="min-h-screen bg-slate-950 p-4 text-slate-100 md:p-8">
<div className="mx-auto max-w-5xl">
<h1 className="text-xl font-bold text-white">Logy EMS</h1>
<p className="mt-1 text-sm text-slate-500">Stream z backendu (WebSocket)</p>
<pre className="mt-6 max-h-[calc(100vh-8rem)] overflow-auto rounded-xl border border-slate-800 bg-slate-900/80 p-4 font-mono text-xs leading-relaxed">
{lines.map((r, i) => (
<div
key={`${i}-${r.ts}-${r.msg}`}
className={
r.level === 'ERROR'
? 'text-red-300'
: r.level === 'WARNING'
? 'text-amber-200'
: 'text-slate-300'
}
>
<span className="text-slate-600">{r.ts ?? '—'} </span>
<span className="text-slate-500">[{r.level ?? '?'}] </span>
<span className="text-slate-500">{r.logger ?? ''}: </span>
{r.msg ?? ''}
</div>
))}
<div ref={bottomRef} />
</pre>
</div>
</div>
)
}

View File

@@ -24,6 +24,7 @@ import {
} from 'recharts'
import { getCurrentPlan, postImportSitePrices, postRunForecast, postRunPlan } from '../api/backend'
import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants'
import { useSiteStatus } from '../hooks/useSiteStatus'
import type { CurrentPlanResponse, PlanningIntervalDto } from '../types/plan'
@@ -48,10 +49,115 @@ function formatLocalTime(iso: string): string {
})
}
function pragueYmd(d: Date): string {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d)
}
function slotStartUtcMs(iso: string): number {
return new Date(iso).getTime()
}
const PREDICTED_LEAD_MS = 36 * 60 * 60 * 1000
const MAX_FUTURE_SLOTS = 384
function pragueDayKey(iso: string): string {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(iso))
}
function formatPragueDateLabel(iso: string): string {
return new Date(iso).toLocaleDateString('cs-CZ', {
timeZone: TZ,
weekday: 'short',
day: 'numeric',
month: 'numeric',
year: 'numeric',
})
}
function isPredictedPriceSlot(i: PlanningIntervalDto, nowMs: number): boolean {
if (i.is_predicted_price === true) return true
if (i.is_predicted_price === false) return false
return slotStartUtcMs(i.interval_start) > nowMs + PREDICTED_LEAD_MS
}
function groupByDay(slots: PlanningIntervalDto[]): Record<string, PlanningIntervalDto[]> {
return slots.reduce(
(acc, slot) => {
const day = pragueDayKey(slot.interval_start)
if (!acc[day]) acc[day] = []
acc[day].push(slot)
return acc
},
{} as Record<string, PlanningIntervalDto[]>,
)
}
function dayStats(slots: PlanningIntervalDto[]): {
fveKwh: number
exportKwh: number
avgBuy: number | null
} {
const slotHours = SLOT_MS / 3_600_000
let fveWh = 0
let expWh = 0
const buys: number[] = []
for (const s of slots) {
fveWh += (s.pv_forecast_total_w ?? 0) * slotHours
const gw = s.grid_setpoint_w ?? 0
if (gw < 0) expWh += -gw * slotHours
if (s.effective_buy_price != null) buys.push(s.effective_buy_price)
}
const avgBuy = buys.length ? buys.reduce((a, b) => a + b, 0) / buys.length : null
return { fveKwh: fveWh / 1000, exportKwh: expWh / 1000, avgBuy }
}
type HorizonHours = 24 | 48 | 96
type PlanTableRow =
| {
kind: 'summary'
dayKey: string
dateLabel: string
fveKwh: number
exportKwh: number
avgBuy: number | null
}
| { kind: 'slot'; i: PlanningIntervalDto }
function buildPlanTableRows(visibleSlots: PlanningIntervalDto[]): PlanTableRow[] {
const groups = groupByDay(visibleSlots)
const dayKeys = [...new Set(visibleSlots.map((s) => pragueDayKey(s.interval_start)))].sort()
const rows: PlanTableRow[] = []
for (const dk of dayKeys) {
const sl = groups[dk]
if (!sl?.length) continue
rows.push({
kind: 'summary',
dayKey: dk,
dateLabel: formatPragueDateLabel(sl[0]!.interval_start),
...dayStats(sl),
})
for (const i of sl) rows.push({ kind: 'slot', i })
}
return rows
}
function horizonToggleClass(active: boolean): string {
return active
? 'border-cyan-600 bg-cyan-950/50 text-cyan-100'
: 'border-slate-600 bg-slate-800/80 text-slate-300 hover:bg-slate-800'
}
/**
* Vizuál FVE: API posílá součet A+B (`pv_forecast_total_w`).
* Pokud je hodnota null (data chybí), použijeme jednoduchou proxy z ceny nákupu (W).
@@ -67,6 +173,53 @@ function pvAProxyW(i: PlanningIntervalDto): number {
return Math.max(0, Math.min(15000, w))
}
/** Budoucí slot (od začátku ještě nenastal): předpověď; proběhlý / probíhající: telemetrie z auditu. */
function slotFveDisplayW(i: PlanningIntervalDto, nowMs: number): number | null {
const start = slotStartUtcMs(i.interval_start)
const future = start >= nowMs
if (future) {
const f = i.pv_forecast_total_w
if (f != null) return Number(f)
return null
}
const a = i.pv_power_w
if (a != null) return Number(a)
const f = i.pv_forecast_total_w
return f != null ? Number(f) : null
}
/** Stejná idea jako výkonové buňky: velké hodnoty v kW, jinak W (bez suffixu u malých čísel jako Bat. W). */
function formatPlanPowerW(w: number | null): string {
if (w == null || Number.isNaN(w)) return '—'
const v = Math.round(Number(w))
if (Math.abs(v) >= 1000) {
const k = v / 1000
const s = k.toFixed(1).replace(/\.0$/, '')
return `${s} kW`
}
return String(v)
}
function FveWCell({ i, nowMs }: { i: PlanningIntervalDto; nowMs: number }) {
const w = slotFveDisplayW(i, nowMs)
const color =
w == null || Number.isNaN(w) ? 'text-slate-500' : w > 0 ? 'text-emerald-400' : 'text-slate-500'
return (
<td className={`pr-2 font-mono tabular-nums ${color}`}>{formatPlanPowerW(w)}</td>
)
}
function VynosKcCell({ v }: { v: number | null | undefined }) {
if (v == null || Number.isNaN(Number(v))) {
return <td className="pr-2 font-mono tabular-nums text-slate-500"></td>
}
const n = Number(v)
const color = n < 0 ? 'text-emerald-400' : n > 0 ? 'text-red-400' : 'text-slate-500'
return (
<td className={`pr-2 font-mono tabular-nums ${color}`}>{n.toFixed(4)}</td>
)
}
function runTypeBadgeClass(t: string): string {
const u = t.toLowerCase()
if (u === 'daily') return 'bg-sky-500/15 text-sky-300 ring-1 ring-sky-500/35'
@@ -90,6 +243,31 @@ function axiosDetail(e: unknown): string {
return e instanceof Error ? e.message : 'Neznámá chyba'
}
/** Zrcadlí logiku TOU řádků z `write_inverter_setpoints` (PASSIVE/SELL/CHARGE) pro jeden plánovací interval. */
function deyeSetpointLabel(i: PlanningIntervalDto): string {
const battery_w = i.battery_setpoint_w ?? 0
const grid_w = i.grid_setpoint_w ?? 0
const is_exporting = battery_w < -500 || grid_w < -500
const is_charging = battery_w > 500
const tgt = i.battery_soc_target_pct
const targetSoc = tgt != null ? Math.min(95, Math.round(Number(tgt))) : 80
const fmtKw = (w: number) => {
const k = Math.abs(w) / 1000
const s = k.toFixed(1).replace(/\.0$/, '')
return `${s}kW`
}
if (is_exporting) {
const tpPowerW = Math.abs(battery_w)
return `${fmtKw(tpPowerW)} | reg178 bit45=10 (grid PS off)`
}
if (is_charging) {
return `${fmtKw(battery_w)} | grid=yes | SOC→${targetSoc}%`
}
return '~ 2kW | hold'
}
function tableRowClass(
i: PlanningIntervalDto,
selected: boolean,
@@ -117,6 +295,8 @@ type ChartRow = {
type PlanPrepActionsProps = {
prepAction: null | 'import' | 'forecast' | 'init'
replanning: boolean
importDate: 'today' | 'tomorrow'
onImportDateChange: (v: 'today' | 'tomorrow') => void
onImport: () => void
onForecast: () => void
onInit: () => void
@@ -126,6 +306,8 @@ type PlanPrepActionsProps = {
function PlanPrepActions({
prepAction,
replanning,
importDate,
onImportDateChange,
onImport,
onForecast,
onInit,
@@ -148,6 +330,18 @@ function PlanPrepActions({
)}
Importovat ceny
</button>
<label className="inline-flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900/60 px-3 py-2 text-xs text-slate-300">
Den OTE
<select
value={importDate}
onChange={(e) => onImportDateChange(e.target.value === 'today' ? 'today' : 'tomorrow')}
disabled={dis}
className="rounded border border-slate-600 bg-slate-800 px-2 py-1 text-xs text-slate-100"
>
<option value="today">dnes</option>
<option value="tomorrow">zítra</option>
</select>
</label>
<button
type="button"
onClick={onForecast}
@@ -178,15 +372,27 @@ function PlanPrepActions({
)
}
function PlanTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: ChartRow }> }) {
function PlanTooltip({
active,
payload,
nowMs,
}: {
active?: boolean
payload?: Array<{ payload: ChartRow }>
nowMs: number
}) {
if (!active || !payload?.length) return null
const p = payload[0].payload
const i = p.raw
const buy = i.effective_buy_price
const sell = i.effective_sell_price
const pred = isPredictedPriceSlot(i, nowMs)
return (
<div className="rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-xs text-slate-200 shadow-xl">
<div className="mb-1 font-medium text-slate-100">{formatLocal(i.interval_start)}</div>
{pred && (
<div className="mb-1 text-[10px] uppercase tracking-wide text-slate-500">Cena: odhad (predikce)</div>
)}
<div className="space-y-0.5 font-mono tabular-nums">
<div>
Cena nákup: {buy != null ? `${buy.toFixed(3)} Kč/kWh` : '—'} · prodej:{' '}
@@ -203,6 +409,59 @@ function PlanTooltip({ active, payload }: { active?: boolean; payload?: Array<{
)
}
function CenaCell({ i, nowMs }: { i: PlanningIntervalDto; nowMs: number }) {
const pred = isPredictedPriceSlot(i, nowMs)
return (
<td className={`max-w-[200px] pr-2 font-mono text-xs tabular-nums ${pred ? 'text-slate-500' : 'text-slate-300'}`}>
<span className="inline-flex flex-wrap items-center gap-x-1.5 align-middle">
{pred && (
<span
className="shrink-0 rounded bg-slate-700/70 px-1 py-0.5 text-[10px] font-sans font-semibold uppercase tracking-wide text-slate-400"
title="Predikovaná cena (mimo přesné OTE)"
>
odhad
</span>
)}
<span>
{i.effective_buy_price != null ? i.effective_buy_price.toFixed(3) : '—'}
<span className="text-slate-600"> / </span>
{i.effective_sell_price != null ? i.effective_sell_price.toFixed(3) : '—'}
</span>
</span>
</td>
)
}
function HorizonToggle({
value,
onChange,
disabled,
}: {
value: HorizonHours
onChange: (h: HorizonHours) => void
disabled?: boolean
}) {
const opts: HorizonHours[] = [24, 48, 96]
return (
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-xs text-slate-500">Horizont:</span>
<div className="flex gap-1">
{opts.map((h) => (
<button
key={h}
type="button"
disabled={disabled}
onClick={() => onChange(h)}
className={`rounded-md border px-2.5 py-1 text-xs font-medium transition disabled:opacity-50 ${horizonToggleClass(value === h)}`}
>
{h}h
</button>
))}
</div>
</div>
)
}
export default function Planning() {
const { site, ready: siteReady } = useSiteStatus()
const siteId = site?.site_id ?? null
@@ -212,7 +471,10 @@ export default function Planning() {
const [error, setError] = useState<string | null>(null)
const [replanning, setReplanning] = useState(false)
const [prepAction, setPrepAction] = useState<null | 'import' | 'forecast' | 'init'>(null)
const [importDate, setImportDate] = useState<'today' | 'tomorrow'>('tomorrow')
const [selectedStart, setSelectedStart] = useState<string | null>(null)
const [tableHorizonH, setTableHorizonH] = useState<HorizonHours>(48)
const [chartHorizonH, setChartHorizonH] = useState<HorizonHours>(48)
const load = useCallback(async () => {
if (siteId == null) return
@@ -239,36 +501,46 @@ export default function Planning() {
}, [siteId, load])
const nowMs = Date.now()
const dayMs = 24 * 60 * 60 * 1000
const slotFloorMs = floorSlotUtcMs(nowMs)
const intervals24h = useMemo(() => {
const futureSlots = 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])
.filter((i) => slotStartUtcMs(i.interval_start) >= slotFloorMs)
.sort((a, b) => slotStartUtcMs(a.interval_start) - slotStartUtcMs(b.interval_start))
.slice(0, MAX_FUTURE_SLOTS)
}, [data?.intervals, slotFloorMs])
const visibleSlots = useMemo(() => {
const endMs = nowMs + tableHorizonH * 60 * 60 * 1000
return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs)
}, [futureSlots, nowMs, tableHorizonH])
const chartIntervals = useMemo(() => {
const endMs = nowMs + chartHorizonH * 60 * 60 * 1000
return futureSlots.filter((s) => slotStartUtcMs(s.interval_start) <= endMs)
}, [futureSlots, nowMs, chartHorizonH])
const planTableRows = useMemo(() => buildPlanTableRows(visibleSlots), [visibleSlots])
const xTicks = useMemo(() => {
if (!intervals24h.length) return undefined
const stepMs = 2 * 60 * 60 * 1000
const first = slotStartUtcMs(intervals24h[0].interval_start)
const last = slotStartUtcMs(intervals24h[intervals24h.length - 1].interval_start)
if (!chartIntervals.length) return undefined
const stepH = chartHorizonH <= 24 ? 2 : chartHorizonH <= 48 ? 4 : 6
const stepMs = stepH * 60 * 60 * 1000
const first = slotStartUtcMs(chartIntervals[0].interval_start)
const last = slotStartUtcMs(chartIntervals[chartIntervals.length - 1].interval_start)
const ticks: string[] = []
let t = Math.ceil(first / stepMs) * stepMs
while (t <= last) {
const hit = intervals24h.find((i) => Math.abs(slotStartUtcMs(i.interval_start) - t) < 30 * 60 * 1000)
const hit = chartIntervals.find((i) => Math.abs(slotStartUtcMs(i.interval_start) - t) < 30 * 60 * 1000)
if (hit) ticks.push(hit.interval_start)
t += stepMs
}
return ticks.length ? ticks.map((iso) => formatLocalTime(iso)) : undefined
}, [intervals24h])
}, [chartIntervals, chartHorizonH])
const chartRows: ChartRow[] = useMemo(() => {
return intervals24h.map((i) => ({
return chartIntervals.map((i) => ({
label: formatLocalTime(i.interval_start),
ts: slotStartUtcMs(i.interval_start),
pv_a_w: pvAProxyW(i),
@@ -277,7 +549,7 @@ export default function Planning() {
effective_buy_price: i.effective_buy_price,
raw: i,
}))
}, [intervals24h])
}, [chartIntervals])
async function onReplan() {
if (siteId == null) return
@@ -304,7 +576,11 @@ export default function Planning() {
setPrepAction('import')
setError(null)
try {
const r = await postImportSitePrices(siteId)
const selectedDate = new Date()
if (importDate === 'tomorrow') {
selectedDate.setDate(selectedDate.getDate() + 1)
}
const r = await postImportSitePrices(siteId, pragueYmd(selectedDate))
toast.success(
`Ceny: ${r.slots_imported} slotů (${r.date}), první ${r.first_price_czk_kwh.toFixed(3)} Kč/kWh`,
)
@@ -336,7 +612,11 @@ export default function Planning() {
setPrepAction('init')
setError(null)
try {
const imp = await postImportSitePrices(siteId)
const selectedDate = new Date()
if (importDate === 'tomorrow') {
selectedDate.setDate(selectedDate.getDate() + 1)
}
const imp = await postImportSitePrices(siteId, pragueYmd(selectedDate))
toast.success(
`Ceny: ${imp.slots_imported} slotů (${imp.date}), první ${imp.first_price_czk_kwh.toFixed(3)} Kč/kWh`,
)
@@ -370,9 +650,7 @@ export default function Planning() {
const run = data?.run
const summary = data?.summary
const planStale =
run != null && Date.now() - new Date(run.created_at).getTime() > 2 * 60 * 60 * 1000
const showPrepActions = !loading && (run == null || planStale)
const showPrepActions = !loading
const prepBusy = prepAction !== null
const correctionPct =
@@ -384,7 +662,8 @@ export default function Planning() {
<header className="space-y-1">
<h1 className="text-xl font-semibold tracking-tight text-white">Plánování</h1>
<p className="text-sm text-slate-400">
Aktuální LP plán a dalších 24 h od teď ({site?.site_name ?? 'lokalita'})
Aktuální LP plán až 96 h od aktuálního slotu ({site?.site_name ?? 'lokalita'}) tabulka a graf lze zúžit
horizontem 24 / 48 / 96 h.
</p>
</header>
@@ -410,6 +689,8 @@ export default function Planning() {
<PlanPrepActions
prepAction={prepAction}
replanning={replanning}
importDate={importDate}
onImportDateChange={setImportDate}
onImport={() => void handleImportPrices()}
onForecast={() => void handleRunForecast()}
onInit={() => void handleInitializePlan()}
@@ -462,6 +743,17 @@ export default function Planning() {
{run.solver_duration_ms != null ? `${run.solver_duration_ms} ms` : '—'}
</span>
</div>
{summary?.pv_scarcity_factor != null && (
<div className="text-sm">
<span className="text-slate-500">PV scarcity factor: </span>
<span className="font-mono text-slate-200">
{summary.pv_scarcity_factor.toFixed(3)}
</span>
<span className="ml-2 text-xs text-slate-500">
(nižší = méně očekávaného slunce, ekonomika víc toleruje precharge ze sítě)
</span>
</div>
)}
{summary && (
<div className="border-t border-slate-800 pt-3 text-sm">
<p className="mb-2 text-slate-500">Summary</p>
@@ -501,6 +793,8 @@ export default function Planning() {
<PlanPrepActions
prepAction={prepAction}
replanning={replanning}
importDate={importDate}
onImportDateChange={setImportDate}
onImport={() => void handleImportPrices()}
onForecast={() => void handleRunForecast()}
onInit={() => void handleInitializePlan()}
@@ -523,9 +817,12 @@ export default function Planning() {
{/* Sekce 2 */}
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">Graf plánu</h2>
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Graf plánu</h2>
<HorizonToggle value={chartHorizonH} onChange={setChartHorizonH} disabled={futureSlots.length === 0} />
{!chartRows.length ? (
<p className="text-sm text-slate-500">Žádná data pro graf (24 h od teď, max. 96 slotů).</p>
<p className="text-sm text-slate-500">
Žádná data pro graf (budoucí sloty aktivního plánu, horizont {chartHorizonH} h).
</p>
) : (
<div className="h-[350px] w-full">
<ResponsiveContainer width="100%" height="100%">
@@ -534,11 +831,11 @@ export default function Planning() {
<XAxis
dataKey="label"
ticks={xTicks}
tick={{ fill: '#94a3b8', fontSize: 10 }}
tick={{ fill: '#94a3b8', fontSize: 9 }}
interval={0}
angle={-35}
textAnchor="end"
height={48}
height={52}
/>
<YAxis
yAxisId="power"
@@ -568,7 +865,7 @@ export default function Planning() {
offset: 10,
}}
/>
<Tooltip content={<PlanTooltip />} />
<Tooltip content={<PlanTooltip nowMs={nowMs} />} />
<Area
yAxisId="power"
type="monotone"
@@ -616,25 +913,58 @@ export default function Planning() {
{/* Sekce 3 */}
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
<h2 className="mb-3 text-sm font-medium uppercase tracking-wide text-slate-400">Tabulka slotů</h2>
<div className="max-h-[400px] overflow-y-auto overflow-x-auto rounded-lg border border-slate-800/80">
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Tabulka slotů</h2>
<HorizonToggle value={tableHorizonH} onChange={setTableHorizonH} disabled={futureSlots.length === 0} />
<div className="max-h-[min(70vh,720px)] overflow-y-auto overflow-x-auto rounded-lg border border-slate-800/80">
<table className="w-full border-collapse text-left text-xs">
<thead className="sticky top-0 z-10 bg-slate-900 shadow-[0_1px_0_0_rgb(30_41_59)]">
<tr className="text-slate-500">
<th className="whitespace-nowrap py-2 pl-2 pr-2 font-medium">Čas</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Cena kup</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Cena prod</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">
<span className="block">Cena</span>
<span className="block text-[10px] font-normal normal-case text-slate-600">/kWh · kup / prod</span>
</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Bat. W</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Deye setpoint</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">SoC %</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">FVE W</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Síť W</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">EV1 W</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">EV2 W</th>
<th className="whitespace-nowrap py-2 pr-2 font-medium"></th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Náklady </th>
<th className="whitespace-nowrap py-2 pr-2 font-medium">Výnos </th>
</tr>
</thead>
<tbody>
{intervals24h.map((i) => {
{planTableRows.map((row) => {
if (row.kind === 'summary') {
return (
<tr
key={`sum-${row.dayKey}`}
className="border-b border-slate-700/90 bg-slate-800/70 text-slate-200"
>
<td colSpan={11} className="px-2 py-2 text-xs font-medium">
<span className="text-slate-100">{row.dateLabel}</span>
<span className="mx-2 text-slate-600">·</span>
<span className="text-slate-400">FVE celkem</span>{' '}
<span className="font-mono tabular-nums text-slate-200">
{row.fveKwh.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh
</span>
<span className="mx-2 text-slate-600">·</span>
<span className="text-slate-400">Export celkem</span>{' '}
<span className="font-mono tabular-nums text-slate-200">
{row.exportKwh.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh
</span>
<span className="mx-2 text-slate-600">·</span>
<span className="text-slate-400">Prům. cena nákup</span>{' '}
<span className="font-mono tabular-nums text-slate-200">
{row.avgBuy != null ? `${row.avgBuy.toFixed(3)} Kč/kWh` : '—'}
</span>
</td>
</tr>
)
}
const i = row.i
const sel = selectedStart === i.interval_start
return (
<tr
@@ -653,33 +983,32 @@ export default function Planning() {
<td className="whitespace-nowrap py-1.5 pl-2 pr-2 font-mono text-slate-300">
{formatLocalTime(i.interval_start)}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">
{i.effective_buy_price?.toFixed(3) ?? '—'}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">
{i.effective_sell_price?.toFixed(3) ?? '—'}
</td>
<CenaCell i={i} nowMs={nowMs} />
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.battery_setpoint_w ?? '—'}</td>
<td className="max-w-[200px] whitespace-normal break-words pr-2 text-slate-300">
{deyeSetpointLabel(i)}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">
{i.battery_soc_target_pct != null
? `${i.battery_soc_target_pct.toFixed(1)}`
: '—'}
</td>
<FveWCell i={i} nowMs={nowMs} />
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.grid_setpoint_w ?? '—'}</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.ev1_setpoint_w ?? '—'}</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">{i.ev2_setpoint_w ?? '—'}</td>
<td className="pr-2 text-slate-300">{i.heat_pump_enabled ? 'on' : 'off'}</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">
{i.expected_cost_czk?.toFixed(4) ?? '—'}
</td>
<VynosKcCell v={i.expected_cost_czk} />
</tr>
)
})}
</tbody>
</table>
</div>
{!intervals24h.length && !loading && (
<p className="mt-2 text-sm text-slate-500">Žádné řádky v 24h okně.</p>
{!visibleSlots.length && !loading && (
<p className="mt-2 text-sm text-slate-500">
Žádné budoucí sloty v horizontu {tableHorizonH} h (aktivní plán může být prázdný nebo starý).
</p>
)}
</section>
</div>