second version
This commit is contained in:
@@ -16,6 +16,15 @@ server {
|
||||
application/xml+rss
|
||||
text/plain;
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8000/ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
111
frontend/package-lock.json
generated
111
frontend/package-lock.json
generated
@@ -7,9 +7,11 @@
|
||||
"name": "ems-frontend",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"chart.js": "^4.4.8",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"recharts": "^2.15.0",
|
||||
"sonner": "^1.4.0"
|
||||
},
|
||||
@@ -733,6 +735,11 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1664,6 +1671,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -1689,6 +1707,18 @@
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2649,6 +2679,42 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||
"dependencies": {
|
||||
"react-router": "7.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
@@ -2770,6 +2836,11 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||
@@ -3345,6 +3416,11 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||
},
|
||||
"@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -3907,6 +3983,14 @@
|
||||
"integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==",
|
||||
"dev": true
|
||||
},
|
||||
"chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"requires": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -3926,6 +4010,11 @@
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="
|
||||
},
|
||||
"csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -4507,6 +4596,23 @@
|
||||
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
||||
"dev": true
|
||||
},
|
||||
"react-router": {
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||
"requires": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
}
|
||||
},
|
||||
"react-router-dom": {
|
||||
"version": "7.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||
"requires": {
|
||||
"react-router": "7.13.1"
|
||||
}
|
||||
},
|
||||
"react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
@@ -4600,6 +4706,11 @@
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true
|
||||
},
|
||||
"set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
},
|
||||
"sonner": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.8",
|
||||
"axios": "^1.7.9",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"recharts": "^2.15.0",
|
||||
"sonner": "^1.4.0"
|
||||
},
|
||||
|
||||
@@ -1,49 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
import { Toaster } from 'sonner'
|
||||
import Planning from './pages/Planning'
|
||||
import { NavLink, Outlet, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { useWsLogErrorCount } from './hooks/useWsLogErrorCount'
|
||||
import { Dashboard } from './pages/Dashboard'
|
||||
import { Logs } from './pages/Logs'
|
||||
import Planning from './pages/Planning'
|
||||
import { Settings } from './pages/Settings'
|
||||
|
||||
type Page = 'dashboard' | 'planning' | 'settings'
|
||||
function AppLayout() {
|
||||
const logErrors = useWsLogErrorCount(true)
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<Page>('dashboard')
|
||||
const tabClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`rounded-lg px-3 py-2 text-sm font-medium transition ${
|
||||
isActive ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950">
|
||||
<nav className="sticky top-0 z-40 border-b border-slate-800/80 bg-slate-950/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-1 px-4 py-2 md:px-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage('dashboard')}
|
||||
className={`rounded-lg px-3 py-2 text-sm font-medium transition ${
|
||||
page === 'dashboard' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-1 px-4 py-2 md:px-8">
|
||||
<NavLink to="/" end className={tabClass}>
|
||||
Přehled
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage('planning')}
|
||||
className={`rounded-lg px-3 py-2 text-sm font-medium transition ${
|
||||
page === 'planning' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
</NavLink>
|
||||
<NavLink to="/planning" className={tabClass}>
|
||||
Plánování
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage('settings')}
|
||||
className={`rounded-lg px-3 py-2 text-sm font-medium transition ${
|
||||
page === 'settings' ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
</NavLink>
|
||||
<NavLink to="/settings" className={tabClass}>
|
||||
Nastavení
|
||||
</button>
|
||||
</NavLink>
|
||||
<a
|
||||
href="/logs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative rounded-lg px-3 py-2 text-sm font-medium text-slate-400 transition hover:bg-slate-900 hover:text-slate-200"
|
||||
>
|
||||
Logy
|
||||
{logErrors > 0 ? (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold text-white">
|
||||
{logErrors > 99 ? '99+' : logErrors}
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
{page === 'dashboard' ? <Dashboard /> : page === 'planning' ? <Planning /> : <Settings />}
|
||||
<Outlet />
|
||||
<Toaster richColors position="top-right" theme="dark" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="planning" element={<Planning />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route path="logs" element={<Logs />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
|
||||
import type { FullStatusResponse } from '../types/fullStatus'
|
||||
import type { Notification } from '../types/dashboard'
|
||||
import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan'
|
||||
|
||||
const client: AxiosInstance = axios.create({
|
||||
@@ -34,6 +35,19 @@ export async function getSiteStatusFull(siteId: number): Promise<FullStatusRespo
|
||||
return data
|
||||
}
|
||||
|
||||
export type SiteNotificationsResponse = {
|
||||
notifications: Notification[]
|
||||
}
|
||||
|
||||
export async function getSiteNotifications(siteId: number): Promise<SiteNotificationsResponse> {
|
||||
const { data } = await client.get<SiteNotificationsResponse>(`/sites/${siteId}/notifications`, {
|
||||
timeout: 30_000,
|
||||
})
|
||||
return {
|
||||
notifications: Array.isArray(data?.notifications) ? data.notifications : [],
|
||||
}
|
||||
}
|
||||
|
||||
export type SetSiteModePayload = {
|
||||
mode: string
|
||||
notes: string | null
|
||||
@@ -61,6 +75,72 @@ export async function getCurrentPlan(siteId: number): Promise<CurrentPlanRespons
|
||||
return data
|
||||
}
|
||||
|
||||
/** GET /api/v1/sites/{id}/prices?date=YYYY-MM-DD */
|
||||
export type SiteEffectivePriceRowDto = {
|
||||
site_id: number
|
||||
interval_start: string
|
||||
interval_end?: string
|
||||
effective_buy_price_czk_kwh?: number | string | null
|
||||
effective_sell_price_czk_kwh?: number | string | null
|
||||
}
|
||||
|
||||
export async function getSitePrices(siteId: number, date: string): Promise<SiteEffectivePriceRowDto[]> {
|
||||
const { data } = await client.get<SiteEffectivePriceRowDto[]>(`/sites/${siteId}/prices`, {
|
||||
params: { date },
|
||||
timeout: 60_000,
|
||||
})
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
export type ForecastPvIntervalRow = {
|
||||
interval_start: string
|
||||
power_w?: number | string | null
|
||||
pv_array_id?: number
|
||||
}
|
||||
|
||||
export type ForecastPvDayResponse = {
|
||||
pv_a: ForecastPvIntervalRow[]
|
||||
pv_b: ForecastPvIntervalRow[]
|
||||
}
|
||||
|
||||
export async function getSiteForecastPv(siteId: number, date: string): Promise<ForecastPvDayResponse> {
|
||||
const { data } = await client.get<ForecastPvDayResponse>(`/sites/${siteId}/forecast/pv`, {
|
||||
params: { date },
|
||||
timeout: 60_000,
|
||||
})
|
||||
return {
|
||||
pv_a: Array.isArray(data?.pv_a) ? data.pv_a : [],
|
||||
pv_b: Array.isArray(data?.pv_b) ? data.pv_b : [],
|
||||
}
|
||||
}
|
||||
|
||||
export type NegPricePredictionDto = {
|
||||
predicted_date: string
|
||||
window_start_hour: number
|
||||
window_end_hour: number
|
||||
probability_pct: number
|
||||
expected_min_price: number | null
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type NegativePredictionsResponseDto = {
|
||||
predictions: NegPricePredictionDto[]
|
||||
insufficient_history: boolean
|
||||
}
|
||||
|
||||
export async function getNegativePricePredictions(
|
||||
siteId: number,
|
||||
): Promise<NegativePredictionsResponseDto> {
|
||||
const { data } = await client.get<NegativePredictionsResponseDto>(
|
||||
`/sites/${siteId}/prices/negative-predictions`,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
return {
|
||||
predictions: Array.isArray(data?.predictions) ? data.predictions : [],
|
||||
insufficient_history: Boolean(data?.insufficient_history),
|
||||
}
|
||||
}
|
||||
|
||||
export async function postRunPlan(
|
||||
siteId: number,
|
||||
planType: 'daily' | 'rolling',
|
||||
@@ -155,4 +235,52 @@ export async function patchEvSession(
|
||||
return data
|
||||
}
|
||||
|
||||
/** Živé hodnoty registrů Deye (GET …/control/registers). */
|
||||
export type DeyeRegistersLive = {
|
||||
reg108_charge_a: number
|
||||
reg109_discharge_a: number
|
||||
reg141_energy_mode: number
|
||||
reg142_limit_control: number
|
||||
reg143_export_limit_w: number
|
||||
reg178_peak_shaving_switch: number
|
||||
reg191_peak_shaving_w: number
|
||||
read_at: string
|
||||
}
|
||||
|
||||
export async function getDeyeRegisters(siteId: number): Promise<DeyeRegistersLive> {
|
||||
const { data } = await client.get<DeyeRegistersLive>(`/sites/${siteId}/control/registers`, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export type ModbusJournalCommandDto = {
|
||||
id: number
|
||||
register: number
|
||||
register_name: string | null
|
||||
value_to_write: number
|
||||
value_written: number | null
|
||||
value_verified: number | null
|
||||
status: string
|
||||
attempt_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type ModbusJournalResponse = {
|
||||
commands: ModbusJournalCommandDto[]
|
||||
}
|
||||
|
||||
export async function getCommandJournal(
|
||||
siteId: number,
|
||||
limit = 50,
|
||||
): Promise<ModbusJournalResponse> {
|
||||
const { data } = await client.get<ModbusJournalResponse>(
|
||||
`/sites/${siteId}/control/journal`,
|
||||
{ params: { limit }, timeout: 15_000 },
|
||||
)
|
||||
return {
|
||||
commands: Array.isArray(data?.commands) ? data.commands : [],
|
||||
}
|
||||
}
|
||||
|
||||
export { client as backendClient }
|
||||
|
||||
307
frontend/src/components/ControlPanel.tsx
Normal file
307
frontend/src/components/ControlPanel.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import axios from 'axios'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { getCommandJournal, getDeyeRegisters, type DeyeRegistersLive, type ModbusJournalCommandDto } from '../api/backend'
|
||||
|
||||
const BATT_VOLTAGE_V = 51.2
|
||||
const POLL_REGISTERS_MS = 30_000
|
||||
const POLL_JOURNAL_MS = 60_000
|
||||
|
||||
const TZ = 'Europe/Prague'
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('cs-CZ', {
|
||||
timeZone: TZ,
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function ampsToKw(a: number | null | undefined): string {
|
||||
if (a == null || Number.isNaN(a)) return '—'
|
||||
return `${((a * BATT_VOLTAGE_V) / 1000).toFixed(2)} kW`
|
||||
}
|
||||
|
||||
function fmtW(w: number | null | undefined): string {
|
||||
if (w == null || Number.isNaN(w)) return '—'
|
||||
return `${w} W`
|
||||
}
|
||||
|
||||
function journalSignature(cmds: ModbusJournalCommandDto[]): string {
|
||||
return cmds
|
||||
.map(
|
||||
(c) =>
|
||||
`${c.id}:${c.status}:${c.attempt_count}:${c.value_written ?? ''}:${c.value_verified ?? ''}`,
|
||||
)
|
||||
.join('|')
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
const u = status.toLowerCase()
|
||||
if (u === 'verified') return 'bg-emerald-600/25 text-emerald-200 ring-1 ring-emerald-500/40'
|
||||
if (u === 'written') return 'bg-sky-600/25 text-sky-200 ring-1 ring-sky-500/40'
|
||||
if (u === 'pending' || u === 'retrying') return 'bg-slate-600/30 text-slate-300 ring-1 ring-slate-500/35'
|
||||
if (u === 'failed' || u === 'mismatch')
|
||||
return u === 'mismatch'
|
||||
? 'bg-red-600/30 text-red-100 font-bold ring-1 ring-red-500/50'
|
||||
: 'bg-red-600/25 text-red-200 ring-1 ring-red-500/40'
|
||||
return 'bg-slate-600/30 text-slate-300 ring-1 ring-slate-500/35'
|
||||
}
|
||||
|
||||
type LiveSectionProps = {
|
||||
live: DeyeRegistersLive | null
|
||||
liveLoading: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const LiveRegistersSection = memo(
|
||||
function LiveRegistersSection({ live, liveLoading, onRefresh }: LiveSectionProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">Živé registry</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRefresh()}
|
||||
disabled={liveLoading}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${liveLoading ? 'animate-spin' : ''}`} aria-hidden />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Metric label="Max nabíjení" reg={108} unitA={live?.reg108_charge_a} kwHint />
|
||||
<Metric label="Max vybíjení" reg={109} unitA={live?.reg109_discharge_a} kwHint />
|
||||
<Metric
|
||||
label="Limit control"
|
||||
reg={142}
|
||||
sub="0 = selling first, 1 = zero export"
|
||||
valueText={live?.reg142_limit_control != null ? String(live.reg142_limit_control) : undefined}
|
||||
/>
|
||||
<Metric label="Energy mode" reg={141} valueText={live?.reg141_energy_mode != null ? String(live.reg141_energy_mode) : undefined} />
|
||||
<Metric
|
||||
label="Peak shaving switch"
|
||||
reg={178}
|
||||
sub="Bit4–5: 10 = disable při exportu, 11 = enable při IDLE/CHARGE"
|
||||
valueText={live?.reg178_peak_shaving_switch != null ? String(live.reg178_peak_shaving_switch) : undefined}
|
||||
/>
|
||||
<Metric
|
||||
label="Grid peak shaving W"
|
||||
reg={191}
|
||||
sub="EMS nezapisuje – nastavit v SolarmanApp (výkon peak shavingu v W)"
|
||||
valueText={fmtW(live?.reg191_peak_shaving_w)}
|
||||
/>
|
||||
</div>
|
||||
{live?.read_at ? (
|
||||
<p className="mt-3 text-[10px] text-slate-500">Načteno: {fmtTime(live.read_at)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
(a, b) =>
|
||||
a.liveLoading === b.liveLoading &&
|
||||
a.live?.read_at === b.live?.read_at &&
|
||||
a.live?.reg108_charge_a === b.live?.reg108_charge_a &&
|
||||
a.live?.reg109_discharge_a === b.live?.reg109_discharge_a &&
|
||||
a.live?.reg141_energy_mode === b.live?.reg141_energy_mode &&
|
||||
a.live?.reg142_limit_control === b.live?.reg142_limit_control &&
|
||||
a.live?.reg143_export_limit_w === b.live?.reg143_export_limit_w &&
|
||||
a.live?.reg178_peak_shaving_switch === b.live?.reg178_peak_shaving_switch &&
|
||||
a.live?.reg191_peak_shaving_w === b.live?.reg191_peak_shaving_w,
|
||||
)
|
||||
|
||||
type MetricProps = {
|
||||
label: string
|
||||
reg: number
|
||||
unitA?: number | null
|
||||
kwHint?: boolean
|
||||
valueText?: string
|
||||
sub?: string
|
||||
}
|
||||
|
||||
function Metric({ label, reg, unitA, kwHint, valueText, sub }: MetricProps) {
|
||||
const main =
|
||||
valueText ??
|
||||
(unitA != null && !Number.isNaN(unitA) ? `${unitA} A` : '—')
|
||||
const extra = kwHint ? ampsToKw(unitA ?? null) : null
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-800/80 bg-slate-950/40 px-3 py-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">{label}</p>
|
||||
<p className="mt-0.5 font-mono text-sm text-slate-100">
|
||||
reg {reg}: {main}
|
||||
{extra && extra !== '—' ? <span className="text-slate-400"> · {extra}</span> : null}
|
||||
</p>
|
||||
{sub ? <p className="mt-0.5 text-[10px] text-slate-500">{sub}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type JournalSectionProps = {
|
||||
commands: ModbusJournalCommandDto[]
|
||||
}
|
||||
|
||||
const JournalSection = memo(
|
||||
function JournalSection({ commands }: JournalSectionProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-slate-200">Posledních 50 zápisů</h3>
|
||||
<div
|
||||
className="overflow-x-auto"
|
||||
style={{
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
borderRadius: 'var(--border-radius-md)',
|
||||
border: '0.5px solid var(--color-border-tertiary)',
|
||||
}}
|
||||
>
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<thead>
|
||||
<tr className="text-slate-500">
|
||||
<th className="py-2 pr-2 font-medium">Čas</th>
|
||||
<th className="py-2 pr-2 font-medium">Reg</th>
|
||||
<th className="py-2 pr-2 font-medium">Popis</th>
|
||||
<th className="py-2 pr-2 font-medium">Hodnota</th>
|
||||
<th className="py-2 pr-2 font-medium">Pokus</th>
|
||||
<th className="py-2 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{commands.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-4 text-slate-500">
|
||||
Žádné záznamy v journalu.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
commands.map((c) => (
|
||||
<tr key={c.id} className="border-t border-slate-800/80">
|
||||
<td className="whitespace-nowrap py-1.5 pr-2 font-mono text-slate-400">
|
||||
{fmtTime(c.created_at)}
|
||||
</td>
|
||||
<td className="pr-2 font-mono text-slate-300">{c.register}</td>
|
||||
<td className="max-w-[140px] truncate pr-2 text-slate-400" title={c.register_name ?? ''}>
|
||||
{c.register_name ?? '—'}
|
||||
</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-200">
|
||||
{c.value_to_write}
|
||||
{c.value_verified != null ? (
|
||||
<span className="text-slate-500"> → {c.value_verified}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="pr-2 font-mono tabular-nums text-slate-300">{c.attempt_count}</td>
|
||||
<td className="py-1.5">
|
||||
<span
|
||||
className={`inline-block rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase ${statusBadgeClass(c.status)}`}
|
||||
>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
(a, b) => journalSignature(a.commands) === journalSignature(b.commands),
|
||||
)
|
||||
|
||||
function ControlPanelImpl({ siteId }: { siteId: number }) {
|
||||
const [live, setLive] = useState<DeyeRegistersLive | null>(null)
|
||||
const [liveError, setLiveError] = useState<string | null>(null)
|
||||
const [liveLoading, setLiveLoading] = useState(false)
|
||||
|
||||
const [commands, setCommands] = useState<ModbusJournalCommandDto[]>([])
|
||||
const [journalError, setJournalError] = useState<string | null>(null)
|
||||
|
||||
const fetchRegisters = useCallback(async () => {
|
||||
setLiveLoading(true)
|
||||
setLiveError(null)
|
||||
try {
|
||||
const data = await getDeyeRegisters(siteId)
|
||||
setLive(data)
|
||||
} catch (e: unknown) {
|
||||
let msg = 'Chyba čtení registrů'
|
||||
if (axios.isAxiosError(e)) {
|
||||
const d = e.response?.data as { detail?: string } | undefined
|
||||
if (typeof d?.detail === 'string') msg = d.detail
|
||||
} else if (e instanceof Error) {
|
||||
msg = e.message
|
||||
}
|
||||
setLiveError(msg)
|
||||
setLive(null)
|
||||
} finally {
|
||||
setLiveLoading(false)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
const fetchJournal = useCallback(async () => {
|
||||
setJournalError(null)
|
||||
try {
|
||||
const res = await getCommandJournal(siteId, 50)
|
||||
setCommands(res.commands)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Chyba načtení journalu'
|
||||
setJournalError(msg)
|
||||
setCommands([])
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRegisters()
|
||||
}, [fetchRegisters])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchJournal()
|
||||
}, [fetchJournal])
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setInterval(() => void fetchRegisters(), POLL_REGISTERS_MS)
|
||||
return () => window.clearInterval(t)
|
||||
}, [fetchRegisters])
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setInterval(() => void fetchJournal(), POLL_JOURNAL_MS)
|
||||
return () => window.clearInterval(t)
|
||||
}, [fetchJournal])
|
||||
|
||||
const apiError = liveError ?? journalError
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{apiError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-red-500/45 bg-red-950/50 px-4 py-3 text-sm text-red-100"
|
||||
>
|
||||
<p className="font-semibold text-red-50">Chyba API řízení / Modbus</p>
|
||||
{liveError ? (
|
||||
<p className="mt-1.5">
|
||||
<span className="text-red-200/90">GET …/control/registers: </span>
|
||||
{liveError}
|
||||
</p>
|
||||
) : null}
|
||||
{journalError ? (
|
||||
<p className={liveError ? 'mt-2' : 'mt-1.5'}>
|
||||
<span className="text-red-200/90">Journal: </span>
|
||||
{journalError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
|
||||
<LiveRegistersSection live={live} liveLoading={liveLoading} onRefresh={fetchRegisters} />
|
||||
<JournalSection commands={commands} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ControlPanel = memo(ControlPanelImpl)
|
||||
77
frontend/src/components/ModeBar.tsx
Normal file
77
frontend/src/components/ModeBar.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
type Props = {
|
||||
modeName: string
|
||||
activatedAt: string | null
|
||||
nextReplanIn: number | null
|
||||
onReplan: () => void
|
||||
onModeChange: () => void
|
||||
}
|
||||
|
||||
const MODE_DOT: Record<string, string> = {
|
||||
AUTO: '#1D9E75',
|
||||
SELF_SUSTAIN: '#E24B4A',
|
||||
CHARGE_CHEAP: '#EF9F27',
|
||||
PRESERVE: '#378ADD',
|
||||
MANUAL: '#888780',
|
||||
}
|
||||
|
||||
function fmtActivatedPrague(iso: string | null): string | null {
|
||||
if (!iso) return null
|
||||
return new Intl.DateTimeFormat('cs-CZ', {
|
||||
timeZone: 'Europe/Prague',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(iso))
|
||||
}
|
||||
|
||||
export function ModeBar({ modeName, activatedAt, nextReplanIn, onReplan, onModeChange }: Props) {
|
||||
const code = (modeName || 'AUTO').toUpperCase().replace(/-/g, '_')
|
||||
const dot = MODE_DOT[code] ?? MODE_DOT.MANUAL!
|
||||
const tAct = fmtActivatedPrague(activatedAt)
|
||||
const subParts: string[] = []
|
||||
if (tAct) subParts.push(`aktivní od ${tAct}`)
|
||||
if (nextReplanIn != null) subParts.push(`příští replan za ${nextReplanIn} min`)
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes ems-mode-auto-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.ems-mode-auto-pulse {
|
||||
animation: ems-mode-auto-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-slate-700/90 bg-slate-950/95 px-3 py-2 text-sm text-slate-200">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2.5 w-2.5 shrink-0 rounded-full ${code === 'AUTO' ? 'ems-mode-auto-pulse' : ''}`}
|
||||
style={{ backgroundColor: dot }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="font-semibold tracking-wide text-slate-100">{code}</span>
|
||||
{subParts.length > 0 ? (
|
||||
<span className="truncate text-xs text-slate-400 sm:text-sm">{subParts.join(' · ')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReplan}
|
||||
className="rounded-md border border-slate-600 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
Přeplánovat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onModeChange}
|
||||
className="rounded-md border border-slate-600 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
Změnit režim
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
64
frontend/src/components/NegPricePanel.tsx
Normal file
64
frontend/src/components/NegPricePanel.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
export interface NegPricePrediction {
|
||||
predicted_date: string
|
||||
window_start_hour: number
|
||||
window_end_hour: number
|
||||
probability_pct: number
|
||||
expected_min_price: number | null
|
||||
reason: string
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n.toString().padStart(2, '0')
|
||||
}
|
||||
|
||||
function borderClass(pct: number): string {
|
||||
if (pct >= 70) return 'border-l-emerald-500'
|
||||
if (pct >= 50) return 'border-l-amber-400'
|
||||
return 'border-l-slate-500'
|
||||
}
|
||||
|
||||
export function NegPricePanel({
|
||||
predictions,
|
||||
insufficientHistory = false,
|
||||
}: {
|
||||
predictions: NegPricePrediction[]
|
||||
insufficientHistory?: boolean
|
||||
}) {
|
||||
if (insufficientHistory) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4 text-sm text-slate-400">
|
||||
Predikce bude dostupná po 4 týdnech provozu.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!predictions.length) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4 text-sm text-slate-400">
|
||||
Žádné záporné ceny v příštích 7 dnech.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{predictions.map((p, i) => (
|
||||
<article
|
||||
key={`${p.predicted_date}-${p.window_start_hour}-${i}`}
|
||||
className={`rounded-lg border border-slate-800 border-l-4 bg-slate-900/60 py-3 pl-3 pr-4 ${borderClass(p.probability_pct)}`}
|
||||
>
|
||||
<p className="text-xs font-medium text-slate-300">
|
||||
{p.predicted_date} · {pad2(p.window_start_hour)}:00–{pad2(p.window_end_hour)}:00
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-400">{p.reason}</p>
|
||||
<p className="mt-2 text-xs tabular-nums text-slate-500">
|
||||
{p.probability_pct.toFixed(0)}% jistota
|
||||
{p.expected_min_price != null
|
||||
? ` · očekávané min. ${p.expected_min_price.toFixed(2)} Kč/kWh`
|
||||
: ''}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
frontend/src/components/NotificationBar.tsx
Normal file
140
frontend/src/components/NotificationBar.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { Notification, NotificationAction, NotificationLevel } from '../types/dashboard'
|
||||
|
||||
type Props = {
|
||||
notifications: Notification[]
|
||||
onReplan?: () => void
|
||||
onImportPrices?: () => void
|
||||
onSwitchAuto?: () => void
|
||||
}
|
||||
|
||||
const LEVEL_STYLES: Record<
|
||||
NotificationLevel,
|
||||
{ bg: string; border: string; icon: string; iconClass: string }
|
||||
> = {
|
||||
success: {
|
||||
bg: '#1D9E7508',
|
||||
border: '#1D9E7544',
|
||||
icon: '⚡',
|
||||
iconClass: 'text-emerald-500',
|
||||
},
|
||||
info: {
|
||||
bg: '#E6F1FB08',
|
||||
border: '#378ADD44',
|
||||
icon: 'ℹ',
|
||||
iconClass: 'text-blue-400',
|
||||
},
|
||||
warning: {
|
||||
bg: '#EF9F2708',
|
||||
border: '#EF9F2744',
|
||||
icon: '⚠',
|
||||
iconClass: 'text-amber-400',
|
||||
},
|
||||
error: {
|
||||
bg: '#E24B4A08',
|
||||
border: '#E24B4A44',
|
||||
icon: '✕',
|
||||
iconClass: 'text-red-400',
|
||||
},
|
||||
}
|
||||
|
||||
function fmtEtaMinutes(mins: number): string {
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
if (h > 0) return `za ${h}h ${m}min`
|
||||
return `za ${m}min`
|
||||
}
|
||||
|
||||
function ActionControls({
|
||||
action,
|
||||
onReplan,
|
||||
onImportPrices,
|
||||
onSwitchAuto,
|
||||
}: {
|
||||
action: NotificationAction | null | undefined
|
||||
onReplan?: () => void
|
||||
onImportPrices?: () => void
|
||||
onSwitchAuto?: () => void
|
||||
}) {
|
||||
if (action === 'connect_ev') {
|
||||
return (
|
||||
<span className="rounded-md bg-slate-800 px-2 py-0.5 text-[10px] font-semibold uppercase text-slate-300">
|
||||
Připoj auto
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (action === 'replan') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReplan}
|
||||
className="rounded-md border border-slate-600 bg-slate-900/60 px-2 py-1 text-xs font-medium text-slate-100 hover:bg-slate-800"
|
||||
>
|
||||
Přeplánovat nyní
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (action === 'import_prices') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportPrices}
|
||||
className="rounded-md border border-slate-600 bg-slate-900/60 px-2 py-1 text-xs font-medium text-slate-100 hover:bg-slate-800"
|
||||
>
|
||||
Importovat ceny
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (action === 'switch_auto') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchAuto}
|
||||
className="rounded-md border border-emerald-700/60 bg-emerald-950/40 px-2 py-1 text-xs font-medium text-emerald-100 hover:bg-emerald-900/50"
|
||||
>
|
||||
Přepnout na AUTO
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function NotificationBar({ notifications, onReplan, onImportPrices, onSwitchAuto }: Props) {
|
||||
const shown = notifications.slice(0, 2)
|
||||
if (shown.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{shown.map((n) => {
|
||||
const st = LEVEL_STYLES[n.level] ?? LEVEL_STYLES.info!
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
className="flex gap-3 rounded-xl border px-3 py-2.5 text-sm"
|
||||
style={{ backgroundColor: st.bg, borderColor: st.border }}
|
||||
>
|
||||
<span className={`shrink-0 text-lg leading-none ${st.iconClass}`} aria-hidden>
|
||||
{st.icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-bold text-slate-100">{n.title}</p>
|
||||
{n.eta_minutes != null && n.eta_minutes >= 0 ? (
|
||||
<span className="text-xs text-slate-400">{fmtEtaMinutes(n.eta_minutes)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 text-slate-300">{n.body}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ActionControls
|
||||
action={n.action}
|
||||
onReplan={onReplan}
|
||||
onImportPrices={onImportPrices}
|
||||
onSwitchAuto={onSwitchAuto}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
496
frontend/src/components/StatePanel.tsx
Normal file
496
frontend/src/components/StatePanel.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
|
||||
import { SLOT_MS, TOTAL_SLOTS } from './charts/chartConstants'
|
||||
import type { SlotData } from '../types/dashboard'
|
||||
|
||||
export type StatePanelProps = {
|
||||
slots: SlotData[]
|
||||
nowIndex: number
|
||||
}
|
||||
|
||||
/** Stav segmentu pro jeden track */
|
||||
export type TrackSegment = {
|
||||
widthPct: number
|
||||
label: string
|
||||
color: string
|
||||
textColor: string
|
||||
isFuture: boolean
|
||||
tooltip?: string
|
||||
/** Zákaz exportu (záporná prodejní cena) – overlay „0!“ */
|
||||
exportBanOverlay?: boolean
|
||||
}
|
||||
|
||||
const TIME_PRAGUE = new Intl.DateTimeFormat('cs-CZ', {
|
||||
timeZone: 'Europe/Prague',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
function slotRangeLabel(slots: SlotData[], i0: number, i1: number): string {
|
||||
const t0 = new Date(slots[i0]!.interval_start).getTime()
|
||||
const t1 = new Date(slots[i1]!.interval_start).getTime() + SLOT_MS
|
||||
return `${TIME_PRAGUE.format(t0)}–${TIME_PRAGUE.format(t1)}`
|
||||
}
|
||||
|
||||
function fmtMoney(v: number | null | undefined): string | null {
|
||||
if (v == null || Number.isNaN(v)) return null
|
||||
return `${v.toFixed(2)} Kč/kWh`
|
||||
}
|
||||
|
||||
function avg(nums: number[]): number {
|
||||
if (nums.length === 0) return 0
|
||||
return nums.reduce((a, b) => a + b, 0) / nums.length
|
||||
}
|
||||
|
||||
type GridKind = 'import' | 'export' | 'idle'
|
||||
|
||||
function gridKind(s: SlotData): GridKind {
|
||||
const sp = s.grid_setpoint_w
|
||||
const pw = s.grid_power_w
|
||||
const imp = (sp != null && sp > 500) || (pw != null && pw > 500)
|
||||
const exp = (sp != null && sp < -500) || (pw != null && pw < -500)
|
||||
if (imp) return 'import'
|
||||
if (exp) return 'export'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function gridFlowW(s: SlotData): number {
|
||||
return s.grid_setpoint_w ?? s.grid_power_w ?? 0
|
||||
}
|
||||
|
||||
export function buildGridSegments(slots: SlotData[], nowIndex: number): TrackSegment[] {
|
||||
const n = slots.length
|
||||
if (n === 0) return []
|
||||
const out: TrackSegment[] = []
|
||||
let i = 0
|
||||
while (i < n) {
|
||||
const g = gridKind(slots[i]!)
|
||||
const neg = slots[i]!.sell_price != null && slots[i]!.sell_price! < 0
|
||||
const fut = i > nowIndex
|
||||
const start = i
|
||||
const gw: number[] = []
|
||||
const buys: number[] = []
|
||||
const sells: number[] = []
|
||||
while (i < n) {
|
||||
const s = slots[i]!
|
||||
if (gridKind(s) !== g) break
|
||||
if ((s.sell_price != null && s.sell_price < 0) !== neg) break
|
||||
if ((i > nowIndex) !== fut) break
|
||||
gw.push(gridFlowW(s))
|
||||
if (s.buy_price != null) buys.push(s.buy_price)
|
||||
if (s.sell_price != null) sells.push(s.sell_price)
|
||||
i++
|
||||
}
|
||||
const count = i - start
|
||||
const widthPct = (count / n) * 100
|
||||
const avgW = avg(gw)
|
||||
const avgBuy = buys.length ? avg(buys) : null
|
||||
const avgSell = sells.length ? avg(sells) : null
|
||||
|
||||
let color = '#88878012'
|
||||
let textColor = '#5F5E5A'
|
||||
let label = '–'
|
||||
if (g === 'import') {
|
||||
color = '#E24B4A1A'
|
||||
textColor = '#993C1D'
|
||||
label = `↓ ${(avgW / 1000).toFixed(1)} kW`
|
||||
} else if (g === 'export') {
|
||||
color = '#1D9E751A'
|
||||
textColor = '#0F6E56'
|
||||
label = `↑ ${(Math.abs(avgW) / 1000).toFixed(1)} kW`
|
||||
}
|
||||
|
||||
const range = slotRangeLabel(slots, start, i - 1)
|
||||
let tooltip = range
|
||||
if (g === 'import') {
|
||||
tooltip += ` · import ${(avgW / 1000).toFixed(1)} kW`
|
||||
const p = fmtMoney(avgBuy)
|
||||
if (p) tooltip += ` · cena nákup ${p}`
|
||||
} else if (g === 'export') {
|
||||
tooltip += ` · export ${(Math.abs(avgW) / 1000).toFixed(1)} kW`
|
||||
const p = fmtMoney(avgSell)
|
||||
if (p) tooltip += ` · cena prodej ${p}`
|
||||
} else {
|
||||
tooltip += ' · síť v klidu'
|
||||
const p = fmtMoney(avgSell ?? avgBuy)
|
||||
if (p) tooltip += ` · cena ${p}`
|
||||
}
|
||||
|
||||
out.push({
|
||||
widthPct,
|
||||
label,
|
||||
color,
|
||||
textColor,
|
||||
isFuture: fut,
|
||||
tooltip,
|
||||
exportBanOverlay: neg,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type BatKind = 'fve' | 'grid' | 'dis' | 'idle'
|
||||
|
||||
function batKind(s: SlotData): BatKind {
|
||||
const bsp = s.battery_setpoint_w
|
||||
const bpw = s.battery_power_w
|
||||
const gsp = s.grid_setpoint_w
|
||||
const gpw = s.grid_power_w
|
||||
|
||||
if ((bsp != null && bsp < -500) || (bpw != null && bpw < -500)) return 'dis'
|
||||
|
||||
const gridHeavy = (gsp != null && gsp > 500) || (gpw != null && gpw > 500)
|
||||
|
||||
if (bsp != null && bsp > 500) {
|
||||
if (gsp != null && gsp > 500) return 'grid'
|
||||
if (gridHeavy) return 'grid'
|
||||
return 'fve'
|
||||
}
|
||||
|
||||
if (bpw != null && bpw > 500) {
|
||||
if (gridHeavy) return 'grid'
|
||||
return 'fve'
|
||||
}
|
||||
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
export function buildBatterySegments(slots: SlotData[], nowIndex: number): TrackSegment[] {
|
||||
const n = slots.length
|
||||
if (n === 0) return []
|
||||
const out: TrackSegment[] = []
|
||||
let i = 0
|
||||
while (i < n) {
|
||||
const k = batKind(slots[i]!)
|
||||
const fut = i > nowIndex
|
||||
const start = i
|
||||
while (i < n) {
|
||||
if (batKind(slots[i]!) !== k) break
|
||||
if ((i > nowIndex) !== fut) break
|
||||
i++
|
||||
}
|
||||
const count = i - start
|
||||
const widthPct = (count / n) * 100
|
||||
let color = '#88878012'
|
||||
let textColor = '#5F5E5A'
|
||||
let label = '–'
|
||||
if (k === 'fve') {
|
||||
color = '#1D9E751A'
|
||||
textColor = '#0F6E56'
|
||||
label = 'nabíjení FVE'
|
||||
} else if (k === 'grid') {
|
||||
color = '#378ADD1A'
|
||||
textColor = '#185FA5'
|
||||
label = 'nabíjení sítě'
|
||||
} else if (k === 'dis') {
|
||||
color = '#EF9F271A'
|
||||
textColor = '#854F0B'
|
||||
label = 'vybíjení'
|
||||
}
|
||||
const range = slotRangeLabel(slots, start, i - 1)
|
||||
out.push({
|
||||
widthPct,
|
||||
label,
|
||||
color,
|
||||
textColor,
|
||||
isFuture: fut,
|
||||
tooltip: `${range} · ${label}`,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export type DeviceKind = 'ev1' | 'ev2' | 'tc'
|
||||
|
||||
function evSegmentKind(sp: number | null): 'charge' | 'idle' | 'off' {
|
||||
if (sp === null) return 'off'
|
||||
if (sp > 0) return 'charge'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function tcRunning(s: SlotData): boolean {
|
||||
if (s.heat_pump_enabled === true) return true
|
||||
if (s.heat_pump_enabled === false) return false
|
||||
return (s.heat_pump_setpoint_w ?? 0) > 0
|
||||
}
|
||||
|
||||
export function buildDeviceSegments(
|
||||
slots: SlotData[],
|
||||
nowIndex: number,
|
||||
device: DeviceKind,
|
||||
): TrackSegment[] {
|
||||
const n = slots.length
|
||||
if (n === 0) return []
|
||||
const out: TrackSegment[] = []
|
||||
|
||||
if (device === 'tc') {
|
||||
let i = 0
|
||||
while (i < n) {
|
||||
const on = tcRunning(slots[i]!)
|
||||
const fut = i > nowIndex
|
||||
const start = i
|
||||
while (i < n) {
|
||||
if (tcRunning(slots[i]!) !== on) break
|
||||
if ((i > nowIndex) !== fut) break
|
||||
i++
|
||||
}
|
||||
const count = i - start
|
||||
const widthPct = (count / n) * 100
|
||||
out.push({
|
||||
widthPct,
|
||||
label: on ? '6kW' : '–',
|
||||
color: on ? '#D4537E1A' : '#88878012',
|
||||
textColor: on ? '#8B3055' : '#5F5E5A',
|
||||
isFuture: fut,
|
||||
tooltip: `${slotRangeLabel(slots, start, i - 1)} · ${on ? 'TČ běží' : 'TČ odstaveno'}`,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const pick = (s: SlotData) => (device === 'ev1' ? s.ev1_setpoint_w : s.ev2_setpoint_w)
|
||||
|
||||
let i = 0
|
||||
while (i < n) {
|
||||
const ek = evSegmentKind(pick(slots[i]!))
|
||||
const fut = i > nowIndex
|
||||
const start = i
|
||||
const pws: number[] = []
|
||||
while (i < n) {
|
||||
const s = slots[i]!
|
||||
if (evSegmentKind(pick(s)) !== ek) break
|
||||
if ((i > nowIndex) !== fut) break
|
||||
const sp = pick(s)
|
||||
if (sp != null && sp > 0) pws.push(sp)
|
||||
i++
|
||||
}
|
||||
const count = i - start
|
||||
const widthPct = (count / n) * 100
|
||||
if (ek === 'off') {
|
||||
out.push({
|
||||
widthPct,
|
||||
label: 'nepřipojeno',
|
||||
color: '#88878008',
|
||||
textColor: '#5F5E5A',
|
||||
isFuture: fut,
|
||||
tooltip: `${slotRangeLabel(slots, start, i - 1)} · vozidlo nepřipojeno`,
|
||||
})
|
||||
} else if (ek === 'charge') {
|
||||
const avgW = avg(pws.length ? pws : [0])
|
||||
out.push({
|
||||
widthPct,
|
||||
label: `${(avgW / 1000).toFixed(1)} kW`,
|
||||
color: '#534AB71A',
|
||||
textColor: '#3D3480',
|
||||
isFuture: fut,
|
||||
tooltip: `${slotRangeLabel(slots, start, i - 1)} · nabíjení ${(avgW / 1000).toFixed(1)} kW`,
|
||||
})
|
||||
} else {
|
||||
out.push({
|
||||
widthPct,
|
||||
label: '–',
|
||||
color: '#88878012',
|
||||
textColor: '#5F5E5A',
|
||||
isFuture: fut,
|
||||
tooltip: `${slotRangeLabel(slots, start, i - 1)} · klid`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function isFourHourTick(iso: string): boolean {
|
||||
const d = new Date(iso)
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Europe/Prague',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(d)
|
||||
const hi = parts.find((p) => p.type === 'hour')
|
||||
const mi = parts.find((p) => p.type === 'minute')
|
||||
if (!hi || !mi) return false
|
||||
const h = parseInt(hi.value, 10)
|
||||
const m = parseInt(mi.value, 10)
|
||||
return m === 0 && h % 4 === 0
|
||||
}
|
||||
|
||||
function TickRow({ slots }: { slots: SlotData[] }) {
|
||||
const n = slots.length
|
||||
if (n === 0) return null
|
||||
return (
|
||||
<div className="relative mt-0.5 h-4 w-full">
|
||||
{slots.map((s, i) =>
|
||||
isFourHourTick(s.interval_start) ? (
|
||||
<span
|
||||
key={`${s.interval_start}-${i}`}
|
||||
className="absolute top-0 -translate-x-1/2 text-[9px] tabular-nums text-slate-500"
|
||||
style={{ left: `${((i + 0.5) / n) * 100}%` }}
|
||||
>
|
||||
{TIME_PRAGUE.format(new Date(s.interval_start))}
|
||||
</span>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentBar({
|
||||
segments,
|
||||
nowIndex,
|
||||
showNowLabel,
|
||||
}: {
|
||||
segments: TrackSegment[]
|
||||
nowIndex: number
|
||||
showNowLabel?: boolean
|
||||
}) {
|
||||
const n = TOTAL_SLOTS
|
||||
const leftPct = (nowIndex / n) * 100
|
||||
return (
|
||||
<div className="relative min-h-[28px]">
|
||||
<div className="flex h-[28px] w-full overflow-hidden rounded-sm border border-slate-800/80">
|
||||
{segments.map((seg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
title={seg.tooltip}
|
||||
className="relative flex min-w-0 shrink-0 items-center justify-center overflow-hidden px-0.5 text-center font-medium leading-tight"
|
||||
style={{
|
||||
width: `${seg.widthPct}%`,
|
||||
background: seg.color,
|
||||
opacity: seg.isFuture ? 0.6 : 1,
|
||||
borderLeft: seg.isFuture ? '1px dashed rgba(148,163,184,0.45)' : 'none',
|
||||
color: seg.textColor,
|
||||
fontSize: 9,
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{seg.label}</span>
|
||||
{seg.exportBanOverlay ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center text-[9px] font-bold"
|
||||
style={{ background: '#E24B4A28', color: '#993C1D' }}
|
||||
>
|
||||
0!
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10"
|
||||
aria-hidden
|
||||
>
|
||||
{showNowLabel ? (
|
||||
<span
|
||||
className="absolute -top-5 z-20 whitespace-nowrap text-[9px] font-semibold text-[#378ADD]"
|
||||
style={{ left: `${leftPct}%`, transform: 'translateX(-50%)' }}
|
||||
>
|
||||
teď
|
||||
</span>
|
||||
) : null}
|
||||
<div
|
||||
className="absolute bottom-0 top-0 w-[1.5px] bg-[#378ADD]"
|
||||
style={{ left: `${leftPct}%`, transform: 'translateX(-50%)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrackRow({
|
||||
label,
|
||||
segments,
|
||||
nowIndex,
|
||||
showNowLabel,
|
||||
}: {
|
||||
label: string
|
||||
segments: TrackSegment[]
|
||||
nowIndex: number
|
||||
showNowLabel?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[52px_1fr] items-center gap-x-1 gap-y-0">
|
||||
<div className="pr-1 text-right text-[10px] font-medium text-slate-400">{label}</div>
|
||||
<SegmentBar segments={segments} nowIndex={nowIndex} showNowLabel={showNowLabel} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatePanelRaw({ slots, nowIndex }: StatePanelProps) {
|
||||
const gridSegs = useMemo(() => buildGridSegments(slots, nowIndex), [slots, nowIndex])
|
||||
const batSegs = useMemo(() => buildBatterySegments(slots, nowIndex), [slots, nowIndex])
|
||||
const ev1Segs = useMemo(() => buildDeviceSegments(slots, nowIndex, 'ev1'), [slots, nowIndex])
|
||||
const ev2Segs = useMemo(() => buildDeviceSegments(slots, nowIndex, 'ev2'), [slots, nowIndex])
|
||||
const tcSegs = useMemo(() => buildDeviceSegments(slots, nowIndex, 'tc'), [slots, nowIndex])
|
||||
|
||||
if (slots.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-5 rounded-xl border border-slate-800 bg-slate-900/40 p-3">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Energetický tok
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<TrackRow label="Síť" segments={gridSegs} nowIndex={nowIndex} showNowLabel />
|
||||
<TrackRow label="Baterie" segments={batSegs} nowIndex={nowIndex} />
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-[52px_1fr] gap-x-1">
|
||||
<div />
|
||||
<TickRow slots={slots} />
|
||||
</div>
|
||||
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-slate-500">
|
||||
<li className="flex items-center gap-1">
|
||||
<span className="h-2 w-3 rounded-sm" style={{ background: '#E24B4A1A' }} />
|
||||
Import
|
||||
</li>
|
||||
<li className="flex items-center gap-1">
|
||||
<span className="h-2 w-3 rounded-sm" style={{ background: '#1D9E751A' }} />
|
||||
Export
|
||||
</li>
|
||||
<li className="flex items-center gap-1">
|
||||
<span className="h-2 w-3 rounded-sm" style={{ background: '#88878012' }} />
|
||||
Klid
|
||||
</li>
|
||||
<li className="flex items-center gap-1">
|
||||
<span className="h-2 w-3 rounded-sm" style={{ background: '#E24B4A28' }} />
|
||||
Zákaz exportu (0!)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-800 pt-4">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
Variabilní zátěže
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<TrackRow label="Tesla" segments={ev1Segs} nowIndex={nowIndex} />
|
||||
<TrackRow label="Zoe" segments={ev2Segs} nowIndex={nowIndex} />
|
||||
<TrackRow label="TČ" segments={tcSegs} nowIndex={nowIndex} />
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-[52px_1fr] gap-x-1">
|
||||
<div />
|
||||
<TickRow slots={slots} />
|
||||
</div>
|
||||
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-slate-500">
|
||||
<li className="flex items-center gap-1">
|
||||
<span className="h-2 w-3 rounded-sm" style={{ background: '#534AB71A' }} />
|
||||
EV nabíjení
|
||||
</li>
|
||||
<li className="flex items-center gap-1">
|
||||
<span className="h-2 w-3 rounded-sm" style={{ background: '#88878008' }} />
|
||||
Nepřipojeno
|
||||
</li>
|
||||
<li className="flex items-center gap-1">
|
||||
<span className="h-2 w-3 rounded-sm" style={{ background: '#D4537E1A' }} />
|
||||
TČ běh
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StatePanel = memo(StatePanelRaw, (prev, next) => {
|
||||
return prev.slots === next.slots && prev.nowIndex === next.nowIndex
|
||||
})
|
||||
339
frontend/src/components/charts/EnergyChart.tsx
Normal file
339
frontend/src/components/charts/EnergyChart.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import type { ChartArea, TooltipItem } from 'chart.js'
|
||||
|
||||
import type { SlotData } from '../../types/dashboard'
|
||||
import { CHART_LAYOUT_PADDING } from './chartConstants'
|
||||
import {
|
||||
computeNegWeekendRanges,
|
||||
createNowLinePluginRef,
|
||||
createSlotBackgroundPluginRefs,
|
||||
} from './chartPlugins'
|
||||
|
||||
const COL = {
|
||||
fve: '#EF9F27',
|
||||
baz: '#378ADD',
|
||||
ev: '#534AB7',
|
||||
tc: '#D4537E',
|
||||
bat: '#1D9E75',
|
||||
sit: '#E24B4A',
|
||||
buy: '#E24B4A',
|
||||
sell: '#1D9E75',
|
||||
} as const
|
||||
|
||||
function kwFromW(w: number | null | undefined): number | null {
|
||||
if (w == null || Number.isNaN(Number(w))) return null
|
||||
return Number(w) / 1000
|
||||
}
|
||||
|
||||
function sumW(a: number | null, b: number | null): number | null {
|
||||
if (a == null && b == null) return null
|
||||
return (a ?? 0) + (b ?? 0)
|
||||
}
|
||||
|
||||
export type EnergyLegendItem = { key: string; label: string; color: string; dashed?: boolean }
|
||||
|
||||
export const ENERGY_LEGEND: EnergyLegendItem[] = [
|
||||
{ key: 'fve_real', label: 'FVE skutečnost', color: COL.fve },
|
||||
{ key: 'fve_pred', label: 'FVE předpověď', color: COL.fve, dashed: true },
|
||||
{ key: 'baz_real', label: 'Spotřeba skutečnost', color: COL.baz },
|
||||
{ key: 'baz_pred', label: 'Spotřeba předpověď', color: COL.baz, dashed: true },
|
||||
{ key: 'ev', label: 'EV plán', color: COL.ev },
|
||||
{ key: 'tc', label: 'TČ plán', color: COL.tc },
|
||||
{ key: 'bat', label: 'Baterie', color: COL.bat },
|
||||
{ key: 'sit', label: 'Síť', color: COL.sit },
|
||||
{ key: 'buy_price', label: 'Cena nákup', color: COL.buy, dashed: true },
|
||||
{ key: 'sell_price', label: 'Cena prodej', color: COL.sell, dashed: true },
|
||||
]
|
||||
|
||||
type Props = {
|
||||
slots: SlotData[]
|
||||
nowIndex: number
|
||||
hidden: Set<string>
|
||||
onToggle: (key: string) => void
|
||||
onChartArea?: (area: ChartArea) => void
|
||||
}
|
||||
|
||||
export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const chartRef = useRef<Chart | null>(null)
|
||||
const onChartAreaRef = useRef(onChartArea)
|
||||
onChartAreaRef.current = onChartArea
|
||||
|
||||
const slotsRef = useRef<SlotData[]>([])
|
||||
const negRangesRef = useRef<ReturnType<typeof computeNegWeekendRanges>>([])
|
||||
const nowIndexRef = useRef(0)
|
||||
const labelsRef = useRef<string[]>([])
|
||||
|
||||
slotsRef.current = slots
|
||||
nowIndexRef.current = nowIndex
|
||||
|
||||
const labels = useMemo(
|
||||
() =>
|
||||
slots.map((s) => {
|
||||
const d = new Date(s.interval_start)
|
||||
return d.toLocaleTimeString('cs-CZ', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Prague',
|
||||
})
|
||||
}),
|
||||
[slots],
|
||||
)
|
||||
labelsRef.current = labels
|
||||
|
||||
const negRanges = useMemo(() => computeNegWeekendRanges(slots, nowIndex), [slots, nowIndex])
|
||||
negRangesRef.current = negRanges
|
||||
|
||||
const windowKey = useMemo(
|
||||
() => (slots.length ? `${slots[0]!.interval_start}|${slots.length}` : ''),
|
||||
[slots],
|
||||
)
|
||||
|
||||
const series = useMemo(() => {
|
||||
const fveReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.pv_power_w) : null))
|
||||
const fvePred = slots.map((s) => kwFromW(sumW(s.pv_a_forecast_w, s.pv_b_forecast_w)))
|
||||
const bazReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.load_power_w) : null))
|
||||
const bazPred = slots.map((s) => kwFromW(s.load_baseline_w))
|
||||
const ev = slots.map((s) => kwFromW(sumW(s.ev1_setpoint_w, s.ev2_setpoint_w)))
|
||||
const tc = slots.map((s) => kwFromW(s.heat_pump_setpoint_w))
|
||||
const bat = slots.map((s, i) =>
|
||||
i <= nowIndex ? kwFromW(s.battery_power_w) : kwFromW(s.battery_setpoint_w),
|
||||
)
|
||||
const sit = slots.map((s, i) =>
|
||||
i <= nowIndex ? kwFromW(s.grid_power_w) : kwFromW(s.grid_setpoint_w),
|
||||
)
|
||||
const buy = slots.map((s) => (s.buy_price == null ? null : s.buy_price))
|
||||
const sell = slots.map((s) => (s.sell_price == null ? null : s.sell_price))
|
||||
return { fveReal, fvePred, bazReal, bazPred, ev, tc, bat, sit, buy, sell }
|
||||
}, [slots, nowIndex])
|
||||
|
||||
const bgPlugin = useMemo(
|
||||
() => createSlotBackgroundPluginRefs(slotsRef, negRangesRef),
|
||||
[],
|
||||
)
|
||||
const nowPlugin = useMemo(() => createNowLinePluginRef(nowIndexRef, 'teď'), [])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !windowKey) return
|
||||
|
||||
const mkDs = (
|
||||
key: string,
|
||||
label: string,
|
||||
d: (number | null)[],
|
||||
color: string,
|
||||
opts: {
|
||||
fill?: boolean | 'origin'
|
||||
dashed?: boolean
|
||||
yAxisID?: string
|
||||
order: number
|
||||
borderWidth?: number
|
||||
},
|
||||
) => ({
|
||||
label,
|
||||
data: d,
|
||||
borderColor: color,
|
||||
backgroundColor:
|
||||
opts.fill === true ? `${color}33` : opts.fill === 'origin' ? `${color}40` : undefined,
|
||||
fill: opts.fill ?? false,
|
||||
borderDash: opts.dashed ? [5, 4] : undefined,
|
||||
borderWidth: opts.borderWidth ?? (opts.dashed ? 1 : 1.2),
|
||||
pointRadius: 0,
|
||||
hitRadius: 6,
|
||||
tension: 0.15,
|
||||
yAxisID: opts.yAxisID ?? 'y',
|
||||
order: opts.order,
|
||||
hidden: hidden.has(key),
|
||||
})
|
||||
|
||||
const chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
plugins: [bgPlugin, nowPlugin],
|
||||
data: {
|
||||
labels: [...labels],
|
||||
datasets: [
|
||||
mkDs('sit', 'Síť', series.sit, COL.sit, { fill: 'origin', order: 2 }),
|
||||
mkDs('bat', 'Baterie', series.bat, COL.bat, { fill: 'origin', order: 3 }),
|
||||
mkDs('ev', 'EV plán', series.ev, COL.ev, { fill: true, order: 4 }),
|
||||
mkDs('tc', 'TČ plán', series.tc, COL.tc, { fill: true, order: 5 }),
|
||||
mkDs('baz_real', 'Spotřeba ■', series.bazReal, COL.baz, { fill: true, order: 6 }),
|
||||
mkDs('fve_real', 'FVE ■', series.fveReal, COL.fve, { fill: true, order: 7 }),
|
||||
mkDs('baz_pred', 'Spotřeba ···', series.bazPred, COL.baz, { dashed: true, order: 8 }),
|
||||
mkDs('fve_pred', 'FVE ···', series.fvePred, COL.fve, { dashed: true, order: 9 }),
|
||||
mkDs('buy_price', 'Nákup', series.buy, COL.buy, {
|
||||
dashed: true,
|
||||
yAxisID: 'y1',
|
||||
order: 10,
|
||||
borderWidth: 1,
|
||||
}),
|
||||
mkDs('sell_price', 'Prodej', series.sell, COL.sell, {
|
||||
dashed: true,
|
||||
yAxisID: 'y1',
|
||||
order: 11,
|
||||
borderWidth: 1,
|
||||
}),
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
layout: { padding: { ...CHART_LAYOUT_PADDING } },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title(items: TooltipItem<'line'>[]) {
|
||||
const i = items[0]?.dataIndex ?? 0
|
||||
return labelsRef.current[i] ?? ''
|
||||
},
|
||||
label(ctx: TooltipItem<'line'>) {
|
||||
const label = ctx.dataset.label ?? ''
|
||||
const v = ctx.parsed.y as number | null
|
||||
if (v == null || Number.isNaN(v)) return `${label}: —`
|
||||
if (ctx.dataset.yAxisID === 'y1') return `${label}: ${v.toFixed(3)} Kč/kWh`
|
||||
return `${label}: ${v.toFixed(2)} kW`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
offset: false,
|
||||
grid: { color: 'rgba(148,163,184,0.12)' },
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
maxRotation: 0,
|
||||
autoSkip: false,
|
||||
callback(_val: string | number, i: number) {
|
||||
return i % 8 === 0 ? labelsRef.current[i] ?? '' : ''
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
position: 'left',
|
||||
grid: { color: 'rgba(148,163,184,0.12)' },
|
||||
ticks: { color: '#94a3b8', font: { size: 10 } },
|
||||
title: { display: true, text: 'kW', color: '#64748b', font: { size: 10 } },
|
||||
},
|
||||
y1: {
|
||||
position: 'right',
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { color: '#94a3b8', font: { size: 9 } },
|
||||
title: { display: true, text: 'Kč/kWh', color: '#64748b', font: { size: 10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
chartRef.current = chart
|
||||
requestAnimationFrame(() => {
|
||||
const a = chart.chartArea
|
||||
if (a) onChartAreaRef.current?.(a)
|
||||
})
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
chart.resize()
|
||||
requestAnimationFrame(() => {
|
||||
const a = chart.chartArea
|
||||
if (a) onChartAreaRef.current?.(a)
|
||||
})
|
||||
})
|
||||
ro.observe(canvas.parentElement ?? canvas)
|
||||
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
chart.destroy()
|
||||
chartRef.current = null
|
||||
}
|
||||
// Jen při změně okna (první slot / počet); data dorovnává druhý effect.
|
||||
}, [windowKey, bgPlugin, nowPlugin])
|
||||
|
||||
useEffect(() => {
|
||||
const ch = chartRef.current
|
||||
if (!ch || !slots.length) return
|
||||
ch.data.labels = [...labels]
|
||||
const dss = ch.data.datasets
|
||||
if (!dss?.length) return
|
||||
const s = series
|
||||
const rows: (number | null)[][] = [
|
||||
s.sit,
|
||||
s.bat,
|
||||
s.ev,
|
||||
s.tc,
|
||||
s.bazReal,
|
||||
s.fveReal,
|
||||
s.bazPred,
|
||||
s.fvePred,
|
||||
s.buy,
|
||||
s.sell,
|
||||
]
|
||||
rows.forEach((data, i) => {
|
||||
const ds = dss[i]
|
||||
if (ds) ds.data = data
|
||||
})
|
||||
ch.update('none')
|
||||
requestAnimationFrame(() => {
|
||||
const a = ch.chartArea
|
||||
if (a) onChartAreaRef.current?.(a)
|
||||
})
|
||||
}, [labels, series, slots.length])
|
||||
|
||||
const keys = [
|
||||
'sit',
|
||||
'bat',
|
||||
'ev',
|
||||
'tc',
|
||||
'baz_real',
|
||||
'fve_real',
|
||||
'baz_pred',
|
||||
'fve_pred',
|
||||
'buy_price',
|
||||
'sell_price',
|
||||
] as const
|
||||
|
||||
useEffect(() => {
|
||||
const ch = chartRef.current
|
||||
const dss = ch?.data.datasets
|
||||
if (!ch || !dss?.length) return
|
||||
keys.forEach((k, i) => {
|
||||
const ds = dss[i]
|
||||
if (ds) ds.hidden = hidden.has(k)
|
||||
})
|
||||
ch.update('none')
|
||||
}, [hidden])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-[260px] w-full">
|
||||
<canvas ref={canvasRef} className="max-h-[260px] w-full" role="img" aria-label="Graf výkonů a cen" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5 px-1">
|
||||
{ENERGY_LEGEND.map((item) => {
|
||||
const off = hidden.has(item.key)
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => onToggle(item.key)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] transition hover:bg-white/5 ${
|
||||
off ? 'text-slate-500 line-through opacity-60' : 'text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-4 shrink-0 rounded-sm border border-white/10"
|
||||
style={{
|
||||
backgroundColor: off ? 'transparent' : item.color,
|
||||
borderStyle: item.dashed ? 'dashed' : 'solid',
|
||||
}}
|
||||
/>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
frontend/src/components/charts/ForecastPanel.tsx
Normal file
37
frontend/src/components/charts/ForecastPanel.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ForecastDayTotal } from '../../types/dashboard'
|
||||
|
||||
type Props = {
|
||||
days: ForecastDayTotal[]
|
||||
}
|
||||
|
||||
export function ForecastPanel({ days }: Props) {
|
||||
const max = Math.max(1, ...days.map((d) => d.kwh))
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800/90 bg-slate-900/50 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Předpověď výroby FVE (7 dní)
|
||||
</h3>
|
||||
{days.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-500">Žádná data forecastu.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2.5">
|
||||
{days.map((d) => (
|
||||
<li key={d.date} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-24 shrink-0 text-slate-400">{d.label}</span>
|
||||
<div className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-slate-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-500/80 transition-all"
|
||||
style={{ width: `${Math.min(100, (d.kwh / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-16 shrink-0 text-right tabular-nums text-amber-200/90">
|
||||
{d.kwh.toFixed(1)} kWh
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/charts/NegPricePanel.tsx
Normal file
51
frontend/src/components/charts/NegPricePanel.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { NegPriceItem } from '../../types/dashboard'
|
||||
|
||||
type Props = {
|
||||
items: NegPriceItem[]
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('cs-CZ', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Prague',
|
||||
})
|
||||
}
|
||||
|
||||
export function NegPricePanel({ items }: Props) {
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800/90 bg-slate-900/50 p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Záporné ceny (nadcházející)
|
||||
</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-500">V dostupných datech nejsou záporné ceny.</p>
|
||||
) : (
|
||||
<ul className="mt-3 max-h-48 space-y-2 overflow-y-auto text-sm">
|
||||
{items.map((it) => (
|
||||
<li
|
||||
key={it.interval_start}
|
||||
className="flex flex-col gap-0.5 rounded-lg border border-slate-700/60 bg-slate-950/40 px-2 py-1.5"
|
||||
>
|
||||
<span className="text-slate-300">{fmtTime(it.interval_start)}</span>
|
||||
<span className="tabular-nums text-xs text-slate-400">
|
||||
nákup:{' '}
|
||||
<span className={it.buy != null && it.buy < 0 ? 'text-emerald-400' : ''}>
|
||||
{it.buy == null ? '—' : `${it.buy.toFixed(3)} Kč/kWh`}
|
||||
</span>
|
||||
{' · '}
|
||||
prodej:{' '}
|
||||
<span className={it.sell != null && it.sell < 0 ? 'text-red-300' : ''}>
|
||||
{it.sell == null ? '—' : `${it.sell.toFixed(3)} Kč/kWh`}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
90
frontend/src/components/charts/RegimeBar.tsx
Normal file
90
frontend/src/components/charts/RegimeBar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import type { SlotData } from '../../types/dashboard'
|
||||
|
||||
type Props = {
|
||||
slots: SlotData[]
|
||||
nowIndex: number
|
||||
chartPaddingLeft: number
|
||||
chartPaddingRight: number
|
||||
/** Pixely plot oblasti z EnergyChart (chartArea), pokud známe – přesnější zarovnání. */
|
||||
chartArea: { left: number; right: number } | null
|
||||
}
|
||||
|
||||
const REGIME_STYLES: Record<
|
||||
string,
|
||||
{ bg: string; fg: string; bgPlan: string; fgPlan: string }
|
||||
> = {
|
||||
AUTO: { bg: '#1D9E7518', fg: '#0F6E56', bgPlan: '#1D9E7510', fgPlan: '#0F6E5699' },
|
||||
SELF_SUSTAIN: { bg: '#E24B4A18', fg: '#993C1D', bgPlan: '#E24B4A10', fgPlan: '#993C1D99' },
|
||||
CHARGE_CHEAP: { bg: '#EF9F2718', fg: '#854F0B', bgPlan: '#EF9F2710', fgPlan: '#854F0B99' },
|
||||
PRESERVE: { bg: '#53B0AA18', fg: '#185FA5', bgPlan: '#53B0AA10', fgPlan: '#185FA599' },
|
||||
MANUAL: { bg: '#88878018', fg: '#5F5E5A', bgPlan: '#88878010', fgPlan: '#5F5E5A99' },
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE = { bg: '#88878018', fg: '#5F5E5A', bgPlan: '#88878010', fgPlan: '#5F5E5A99' }
|
||||
|
||||
function normCode(code: string | null): string {
|
||||
return (code ?? 'AUTO').toUpperCase().replace(/-/g, '_')
|
||||
}
|
||||
|
||||
export function RegimeBar({ slots, nowIndex, chartPaddingLeft, chartPaddingRight, chartArea }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !slots.length) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const h = 28
|
||||
const w = canvas.clientWidth || canvas.parentElement?.clientWidth || 300
|
||||
canvas.width = Math.floor(w * dpr)
|
||||
canvas.height = Math.floor(h * dpr)
|
||||
canvas.style.height = `${h}px`
|
||||
canvas.style.width = `${w}px`
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
|
||||
const plotLeft = chartArea?.left ?? chartPaddingLeft
|
||||
const plotRight = chartArea?.right ?? w - chartPaddingRight
|
||||
const plotW = Math.max(1, plotRight - plotLeft)
|
||||
const n = slots.length
|
||||
const segW = plotW / n
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = slots[i]!
|
||||
const code = normCode(s.regime_code)
|
||||
const st = REGIME_STYLES[code] ?? DEFAULT_STYLE
|
||||
const planned = s.regime_is_planned
|
||||
ctx.fillStyle = planned ? st.bgPlan : st.bg
|
||||
const x0 = plotLeft + i * segW
|
||||
ctx.fillRect(x0, 0, segW + 0.5, h)
|
||||
}
|
||||
|
||||
if (nowIndex >= 0 && nowIndex < n) {
|
||||
const x = plotLeft + nowIndex * segW
|
||||
ctx.save()
|
||||
ctx.strokeStyle = '#378ADD'
|
||||
ctx.setLineDash([4, 3])
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, 0)
|
||||
ctx.lineTo(x, h)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
}, [slots, nowIndex, chartPaddingLeft, chartPaddingRight, chartArea])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="block w-full"
|
||||
style={{ height: 28 }}
|
||||
aria-hidden
|
||||
height={28}
|
||||
/>
|
||||
)
|
||||
}
|
||||
230
frontend/src/components/charts/SocTuvChart.tsx
Normal file
230
frontend/src/components/charts/SocTuvChart.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { Chart } from 'chart.js/auto'
|
||||
import type { TooltipItem } from 'chart.js'
|
||||
|
||||
import type { SlotData } from '../../types/dashboard'
|
||||
import { CHART_LAYOUT_PADDING } from './chartConstants'
|
||||
import {
|
||||
computeNegWeekendRanges,
|
||||
createNowLinePluginRef,
|
||||
createSlotBackgroundPluginRefs,
|
||||
} from './chartPlugins'
|
||||
|
||||
type Props = {
|
||||
slots: SlotData[]
|
||||
nowIndex: number
|
||||
}
|
||||
|
||||
export function SocTuvChart({ slots, nowIndex }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const chartRef = useRef<Chart | null>(null)
|
||||
|
||||
const slotsRef = useRef<SlotData[]>([])
|
||||
const negRangesRef = useRef<ReturnType<typeof computeNegWeekendRanges>>([])
|
||||
const nowIndexRef = useRef(0)
|
||||
const labelsRef = useRef<string[]>([])
|
||||
|
||||
slotsRef.current = slots
|
||||
nowIndexRef.current = nowIndex
|
||||
|
||||
const labels = useMemo(
|
||||
() =>
|
||||
slots.map((s) => {
|
||||
const d = new Date(s.interval_start)
|
||||
return d.toLocaleTimeString('cs-CZ', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Prague',
|
||||
})
|
||||
}),
|
||||
[slots],
|
||||
)
|
||||
labelsRef.current = labels
|
||||
|
||||
const negRanges = useMemo(() => computeNegWeekendRanges(slots, nowIndex), [slots, nowIndex])
|
||||
negRangesRef.current = negRanges
|
||||
|
||||
const windowKey = useMemo(
|
||||
() => (slots.length ? `${slots[0]!.interval_start}|${slots.length}` : ''),
|
||||
[slots],
|
||||
)
|
||||
|
||||
const series = useMemo(() => {
|
||||
const socReal = slots.map((s, i) => (i <= nowIndex ? s.soc_actual_pct : null))
|
||||
const socPlan = slots.map((s) => s.soc_plan_pct)
|
||||
const tuvReal = slots.map((s, i) => (i <= nowIndex ? s.tuv_actual_c : null))
|
||||
const tuvPlan = slots.map((s) => s.tuv_plan_c)
|
||||
return { socReal, socPlan, tuvReal, tuvPlan }
|
||||
}, [slots, nowIndex])
|
||||
|
||||
const bgPlugin = useMemo(
|
||||
() => createSlotBackgroundPluginRefs(slotsRef, negRangesRef),
|
||||
[],
|
||||
)
|
||||
const nowPlugin = useMemo(() => createNowLinePluginRef(nowIndexRef, 'teď'), [])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !windowKey) return
|
||||
|
||||
const chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
plugins: [bgPlugin, nowPlugin],
|
||||
data: {
|
||||
labels: [...labels],
|
||||
datasets: [
|
||||
{
|
||||
label: 'SoC ■',
|
||||
data: series.socReal,
|
||||
borderColor: '#1D9E75',
|
||||
backgroundColor: '#1D9E7526',
|
||||
fill: true,
|
||||
borderWidth: 1.2,
|
||||
pointRadius: 0,
|
||||
tension: 0.2,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: 'SoC plán',
|
||||
data: series.socPlan,
|
||||
borderColor: '#1D9E75',
|
||||
borderDash: [5, 4],
|
||||
fill: false,
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0.2,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: 'TUV ■',
|
||||
data: series.tuvReal,
|
||||
borderColor: '#EF9F27',
|
||||
backgroundColor: '#EF9F2726',
|
||||
fill: true,
|
||||
borderWidth: 1.2,
|
||||
pointRadius: 0,
|
||||
tension: 0.2,
|
||||
yAxisID: 'y1',
|
||||
},
|
||||
{
|
||||
label: 'TUV cíl',
|
||||
data: series.tuvPlan,
|
||||
borderColor: '#EF9F27',
|
||||
borderDash: [5, 4],
|
||||
fill: false,
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0.2,
|
||||
yAxisID: 'y1',
|
||||
},
|
||||
{
|
||||
label: '_layout',
|
||||
data: slots.map(() => 0),
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
pointRadius: 0,
|
||||
yAxisID: 'y2',
|
||||
order: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
layout: { padding: { ...CHART_LAYOUT_PADDING } },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title(items: TooltipItem<'line'>[]) {
|
||||
const i = items[0]?.dataIndex ?? 0
|
||||
return labelsRef.current[i] ?? ''
|
||||
},
|
||||
label(ctx: TooltipItem<'line'>) {
|
||||
if (ctx.dataset.label === '_layout') return ''
|
||||
if (!ctx.dataset.label) return ''
|
||||
const v = ctx.parsed.y as number | null
|
||||
if (v == null || Number.isNaN(v)) return `${ctx.dataset.label}: —`
|
||||
if (ctx.dataset.yAxisID === 'y') return `${ctx.dataset.label}: ${v.toFixed(1)} %`
|
||||
return `${ctx.dataset.label}: ${v.toFixed(1)} °C`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
offset: false,
|
||||
grid: { color: 'rgba(148,163,184,0.1)' },
|
||||
ticks: {
|
||||
color: '#94a3b8',
|
||||
maxRotation: 0,
|
||||
autoSkip: false,
|
||||
callback(_v: string | number, i: number) {
|
||||
return i % 8 === 0 ? labelsRef.current[i] ?? '' : ''
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
position: 'left',
|
||||
min: 0,
|
||||
max: 100,
|
||||
grid: { color: 'rgba(148,163,184,0.12)' },
|
||||
ticks: { color: '#1D9E75', font: { size: 9 } },
|
||||
title: { display: true, text: '% SoC', color: '#1D9E75', font: { size: 10 } },
|
||||
},
|
||||
y1: {
|
||||
position: 'right',
|
||||
min: 30,
|
||||
max: 75,
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: { color: '#EF9F27', font: { size: 9 } },
|
||||
title: { display: true, text: 'TUV °C', color: '#EF9F27', font: { size: 10 } },
|
||||
},
|
||||
y2: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
display: false,
|
||||
min: 0,
|
||||
max: 1,
|
||||
grid: { display: false },
|
||||
ticks: { display: false },
|
||||
weight: 0.35,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
chartRef.current = chart
|
||||
const ro = new ResizeObserver(() => chart.resize())
|
||||
ro.observe(canvas.parentElement ?? canvas)
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
chart.destroy()
|
||||
chartRef.current = null
|
||||
}
|
||||
}, [windowKey, bgPlugin, nowPlugin])
|
||||
|
||||
useEffect(() => {
|
||||
const ch = chartRef.current
|
||||
if (!ch || !slots.length) return
|
||||
ch.data.labels = [...labels]
|
||||
const dss = ch.data.datasets
|
||||
if (!dss?.length) return
|
||||
const s = series
|
||||
if (dss[0]) dss[0].data = s.socReal
|
||||
if (dss[1]) dss[1].data = s.socPlan
|
||||
if (dss[2]) dss[2].data = s.tuvReal
|
||||
if (dss[3]) dss[3].data = s.tuvPlan
|
||||
if (dss[4]) dss[4].data = slots.map(() => 0)
|
||||
ch.update('none')
|
||||
}, [labels, series, slots, slots.length])
|
||||
|
||||
return (
|
||||
<div className="h-[100px] w-full">
|
||||
<canvas ref={canvasRef} className="max-h-[100px] w-full" role="img" aria-label="SoC a TUV" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/charts/chartConstants.ts
Normal file
16
frontend/src/components/charts/chartConstants.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const CHART_LAYOUT_PADDING = { left: 8, right: 45 } as const
|
||||
|
||||
export const SLOT_MS = 15 * 60 * 1000
|
||||
export const SLOT_COUNT_BACK = 60
|
||||
export const SLOT_COUNT_FWD = 144
|
||||
export const TOTAL_SLOTS = SLOT_COUNT_BACK + SLOT_COUNT_FWD
|
||||
|
||||
export function floorSlotUtcMs(ms: number): number {
|
||||
return Math.floor(ms / SLOT_MS) * SLOT_MS
|
||||
}
|
||||
|
||||
/** Index aktuálního 15min slotu v okně [0, TOTAL_SLOTS). */
|
||||
export function currentSlotIndexInWindow(windowStartMs: number): number {
|
||||
const cur = floorSlotUtcMs(Date.now())
|
||||
return Math.round((cur - windowStartMs) / SLOT_MS)
|
||||
}
|
||||
200
frontend/src/components/charts/chartPlugins.ts
Normal file
200
frontend/src/components/charts/chartPlugins.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type { Chart, Plugin } from 'chart.js'
|
||||
|
||||
import type { SlotData } from '../../types/dashboard'
|
||||
|
||||
export type NegWeekendRange = { start: number; end: number }
|
||||
|
||||
const SELL_NEG = 'rgba(226,75,74,0.07)'
|
||||
const BUY_NEG = 'rgba(29,158,117,0.07)'
|
||||
const WEEKEND_NEG = 'rgba(239,159,39,0.07)'
|
||||
|
||||
function isWeekendPrague(iso: string): boolean {
|
||||
const w = new Date(iso).toLocaleDateString('en-US', { timeZone: 'Europe/Prague', weekday: 'short' })
|
||||
return w === 'Sat' || w === 'Sun'
|
||||
}
|
||||
|
||||
export function computeNegWeekendRanges(slots: SlotData[], nowIndex: number): NegWeekendRange[] {
|
||||
const ranges: NegWeekendRange[] = []
|
||||
let i = 0
|
||||
const n = slots.length
|
||||
while (i < n) {
|
||||
if (i <= nowIndex) {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
const s = slots[i]!
|
||||
const buy = s.buy_price
|
||||
if (!(buy != null && buy < 0 && isWeekendPrague(s.interval_start))) {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
const start = i
|
||||
while (
|
||||
i < n &&
|
||||
slots[i]!.buy_price != null &&
|
||||
slots[i]!.buy_price! < 0 &&
|
||||
isWeekendPrague(slots[i]!.interval_start)
|
||||
) {
|
||||
i += 1
|
||||
}
|
||||
ranges.push({ start, end: i })
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
export function createSlotBackgroundPlugin(
|
||||
slots: SlotData[],
|
||||
_nowIndex: number,
|
||||
negWeekendRanges: NegWeekendRange[],
|
||||
): Plugin {
|
||||
return {
|
||||
id: 'emsSlotBg',
|
||||
beforeDatasetsDraw(chart: Chart) {
|
||||
const { ctx, chartArea } = chart
|
||||
if (!chartArea || !slots.length) return
|
||||
const n = slots.length
|
||||
const w = chartArea.width / n
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = slots[i]!
|
||||
const x0 = chartArea.left + i * w
|
||||
let fill: string | null = null
|
||||
if (s.sell_price != null && s.sell_price < 0) fill = SELL_NEG
|
||||
else if (s.buy_price != null && s.buy_price < 0) fill = BUY_NEG
|
||||
if (fill) {
|
||||
ctx.save()
|
||||
ctx.fillStyle = fill
|
||||
ctx.fillRect(x0, chartArea.top, w, chartArea.bottom - chartArea.top)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of negWeekendRanges) {
|
||||
if (r.start >= n || r.end <= r.start) continue
|
||||
const x0 = chartArea.left + r.start * w
|
||||
const x1 = chartArea.left + r.end * w
|
||||
ctx.save()
|
||||
ctx.fillStyle = WEEKEND_NEG
|
||||
ctx.fillRect(x0, chartArea.top, x1 - x0, chartArea.bottom - chartArea.top)
|
||||
ctx.strokeStyle = 'rgba(239,159,39,0.45)'
|
||||
ctx.setLineDash([4, 3])
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(x0 + 0.5, chartArea.top + 0.5, x1 - x0 - 1, chartArea.bottom - chartArea.top - 1)
|
||||
ctx.restore()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Pozadí slotů – čte aktuální slots z ref (bez přepínání instance grafu). */
|
||||
export function createSlotBackgroundPluginRefs(
|
||||
slotsRef: MutableRefObject<SlotData[]>,
|
||||
negRangesRef: MutableRefObject<NegWeekendRange[]>,
|
||||
): Plugin {
|
||||
return {
|
||||
id: 'emsSlotBgRef',
|
||||
beforeDatasetsDraw(chart: Chart) {
|
||||
const { ctx, chartArea } = chart
|
||||
const slots = slotsRef.current
|
||||
const negWeekendRanges = negRangesRef.current
|
||||
if (!chartArea || !slots.length) return
|
||||
const n = slots.length
|
||||
const w = chartArea.width / n
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = slots[i]!
|
||||
const x0 = chartArea.left + i * w
|
||||
let fill: string | null = null
|
||||
if (s.sell_price != null && s.sell_price < 0) fill = SELL_NEG
|
||||
else if (s.buy_price != null && s.buy_price < 0) fill = BUY_NEG
|
||||
if (fill) {
|
||||
ctx.save()
|
||||
ctx.fillStyle = fill
|
||||
ctx.fillRect(x0, chartArea.top, w, chartArea.bottom - chartArea.top)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of negWeekendRanges) {
|
||||
if (r.start >= n || r.end <= r.start) continue
|
||||
const x0 = chartArea.left + r.start * w
|
||||
const x1 = chartArea.left + r.end * w
|
||||
ctx.save()
|
||||
ctx.fillStyle = WEEKEND_NEG
|
||||
ctx.fillRect(x0, chartArea.top, x1 - x0, chartArea.bottom - chartArea.top)
|
||||
ctx.strokeStyle = 'rgba(239,159,39,0.45)'
|
||||
ctx.setLineDash([4, 3])
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(x0 + 0.5, chartArea.top + 0.5, x1 - x0 - 1, chartArea.bottom - chartArea.top - 1)
|
||||
ctx.restore()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Čára „teď“ – index z ref. */
|
||||
export function createNowLinePluginRef(nowIndexRef: MutableRefObject<number>, label: string): Plugin {
|
||||
return {
|
||||
id: 'emsNowLineRef',
|
||||
afterDatasetsDraw(chart: Chart) {
|
||||
const nowIndex = nowIndexRef.current
|
||||
const { ctx, chartArea } = chart
|
||||
const labels = chart.data.labels
|
||||
if (!chartArea || !labels?.length) return
|
||||
const n = labels.length
|
||||
if (nowIndex < 0 || nowIndex >= n) return
|
||||
const w = chartArea.width / n
|
||||
const x = chartArea.left + nowIndex * w
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = '#378ADD'
|
||||
ctx.setLineDash([5, 4])
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.moveTo(x, chartArea.top)
|
||||
ctx.lineTo(x, chartArea.bottom)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.setLineDash([])
|
||||
ctx.fillStyle = '#378ADD'
|
||||
ctx.font = '600 10px system-ui, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'top'
|
||||
ctx.fillText(label, Math.min(x + 3, chartArea.right - 28), chartArea.top + 2)
|
||||
ctx.restore()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createNowLinePlugin(nowIndex: number, label: string): Plugin {
|
||||
return {
|
||||
id: 'emsNowLine',
|
||||
afterDatasetsDraw(chart: Chart) {
|
||||
const { ctx, chartArea } = chart
|
||||
const labels = chart.data.labels
|
||||
if (!chartArea || !labels?.length) return
|
||||
const n = labels.length
|
||||
if (nowIndex < 0 || nowIndex >= n) return
|
||||
const w = chartArea.width / n
|
||||
const x = chartArea.left + nowIndex * w
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = '#378ADD'
|
||||
ctx.setLineDash([5, 4])
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.moveTo(x, chartArea.top)
|
||||
ctx.lineTo(x, chartArea.bottom)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.setLineDash([])
|
||||
ctx.fillStyle = '#378ADD'
|
||||
ctx.font = '600 10px system-ui, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'top'
|
||||
ctx.fillText(label, Math.min(x + 3, chartArea.right - 28), chartArea.top + 2)
|
||||
ctx.restore()
|
||||
},
|
||||
}
|
||||
}
|
||||
520
frontend/src/hooks/useDashboardData.ts
Normal file
520
frontend/src/hooks/useDashboardData.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
import axios from 'axios'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
getCurrentPlan,
|
||||
getSiteForecastPv,
|
||||
getSitePrices,
|
||||
type SiteEffectivePriceRowDto,
|
||||
} from '../api/backend'
|
||||
import { getJson } from '../api/postgrest'
|
||||
import {
|
||||
currentSlotIndexInWindow,
|
||||
SLOT_COUNT_BACK,
|
||||
SLOT_MS,
|
||||
TOTAL_SLOTS,
|
||||
floorSlotUtcMs,
|
||||
} from '../components/charts/chartConstants'
|
||||
import { pragueAddCalendarDays, pragueCalendarDay } from '../lib/pragueDate'
|
||||
import type { ForecastDayTotal, LiveMetrics, NegPriceItem, SlotData } from '../types/dashboard'
|
||||
import type {
|
||||
AuditTodayHourlyRow,
|
||||
HeatPumpLatestRow,
|
||||
ModeLogRecentRow,
|
||||
SiteStatusRow,
|
||||
TelemetryHourly7dRow,
|
||||
} from '../types/ems'
|
||||
import type { PlanningIntervalDto } from '../types/plan'
|
||||
|
||||
const POLL_FULL_MS = 30_000
|
||||
const POLL_LIVE_MS = 5_000
|
||||
|
||||
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 numFromWs(v: unknown): number | null {
|
||||
if (v == null) return null
|
||||
const n = typeof v === 'number' ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function buildLiveMetrics(
|
||||
status: SiteStatusRow | null,
|
||||
_hp: HeatPumpLatestRow | null,
|
||||
): LiveMetrics | null {
|
||||
if (status == null) return null
|
||||
return {
|
||||
pv_w: parseNum(status.pv_power_w),
|
||||
load_w: parseNum(status.load_power_w),
|
||||
grid_w: parseNum(status.grid_power_w),
|
||||
bat_soc: parseNum(status.battery_soc_percent),
|
||||
bat_w: parseNum(status.battery_power_w),
|
||||
}
|
||||
}
|
||||
|
||||
function hourFloorUtcMs(ms: number): number {
|
||||
const d = new Date(ms)
|
||||
d.setUTCMinutes(0, 0, 0)
|
||||
d.setUTCSeconds(0, 0)
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
/** Klíč hodiny v Europe/Prague (pro shodu s vw_audit_today_hourly.hour_local). */
|
||||
function pragueHourKey(ms: number): string {
|
||||
return new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'Europe/Prague',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(ms))
|
||||
}
|
||||
|
||||
function slotTimeKey(ms: number): string {
|
||||
return String(floorSlotUtcMs(ms))
|
||||
}
|
||||
|
||||
function modeAt(logs: ModeLogRecentRow[], tMs: number): string | null {
|
||||
let best: ModeLogRecentRow | null = null
|
||||
let bestA = -Infinity
|
||||
for (const l of logs) {
|
||||
const a = new Date(l.activated_at).getTime()
|
||||
const d = l.deactivated_at ? new Date(l.deactivated_at).getTime() : Number.POSITIVE_INFINITY
|
||||
if (a <= tMs && tMs < d && a >= bestA) {
|
||||
bestA = a
|
||||
best = l
|
||||
}
|
||||
}
|
||||
return best?.mode_code ?? null
|
||||
}
|
||||
|
||||
function emptySlot(iso: string): SlotData {
|
||||
return {
|
||||
interval_start: iso,
|
||||
pv_power_w: null,
|
||||
battery_power_w: null,
|
||||
battery_setpoint_w: null,
|
||||
grid_power_w: null,
|
||||
grid_setpoint_w: null,
|
||||
load_power_w: null,
|
||||
gen_port_power_w: null,
|
||||
pv_a_forecast_w: null,
|
||||
pv_b_forecast_w: null,
|
||||
load_baseline_w: null,
|
||||
ev1_setpoint_w: null,
|
||||
ev2_setpoint_w: null,
|
||||
heat_pump_setpoint_w: null,
|
||||
heat_pump_enabled: null,
|
||||
battery_soc_target_pct: null,
|
||||
buy_price: null,
|
||||
sell_price: null,
|
||||
regime_code: null,
|
||||
regime_is_planned: false,
|
||||
soc_actual_pct: null,
|
||||
soc_plan_pct: null,
|
||||
tuv_actual_c: null,
|
||||
tuv_plan_c: null,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeInterval(s: SlotData, p: PlanningIntervalDto): void {
|
||||
s.battery_setpoint_w = p.battery_setpoint_w ?? s.battery_setpoint_w
|
||||
s.grid_setpoint_w = p.grid_setpoint_w ?? s.grid_setpoint_w
|
||||
s.ev1_setpoint_w = p.ev1_setpoint_w ?? s.ev1_setpoint_w
|
||||
s.ev2_setpoint_w = p.ev2_setpoint_w ?? s.ev2_setpoint_w
|
||||
if (s.ev1_setpoint_w == null && s.ev2_setpoint_w == null && p.ev_charge_power_w != null) {
|
||||
s.ev1_setpoint_w = p.ev_charge_power_w
|
||||
}
|
||||
if (p.heat_pump_enabled === true) {
|
||||
s.heat_pump_enabled = true
|
||||
} else if (p.heat_pump_enabled === false) {
|
||||
s.heat_pump_enabled = false
|
||||
}
|
||||
if (p.heat_pump_setpoint_w != null) {
|
||||
s.heat_pump_setpoint_w = p.heat_pump_setpoint_w
|
||||
if (s.heat_pump_enabled == null) {
|
||||
s.heat_pump_enabled = p.heat_pump_setpoint_w > 0
|
||||
}
|
||||
} else if (p.heat_pump_enabled === false) {
|
||||
s.heat_pump_setpoint_w = 0
|
||||
s.heat_pump_enabled = false
|
||||
}
|
||||
s.load_baseline_w = p.load_baseline_w ?? s.load_baseline_w
|
||||
s.buy_price = parseNum(p.effective_buy_price) ?? s.buy_price
|
||||
s.sell_price = parseNum(p.effective_sell_price) ?? s.sell_price
|
||||
const tgtSoc = parseNum(p.battery_soc_target_pct)
|
||||
if (tgtSoc != null) {
|
||||
s.battery_soc_target_pct = tgtSoc
|
||||
s.soc_plan_pct = tgtSoc
|
||||
}
|
||||
const pva = p.pv_forecast_total_w != null ? Math.round(Number(p.pv_forecast_total_w) * 0.6) : null
|
||||
const pvb = p.pv_forecast_total_w != null ? Math.round(Number(p.pv_forecast_total_w) * 0.4) : null
|
||||
if (s.pv_a_forecast_w == null && pva != null) s.pv_a_forecast_w = pva
|
||||
if (s.pv_b_forecast_w == null && pvb != null) s.pv_b_forecast_w = pvb
|
||||
if (p.heat_pump_setpoint_w != null && p.heat_pump_setpoint_w > 0) {
|
||||
s.tuv_plan_c = 52
|
||||
}
|
||||
}
|
||||
|
||||
export function useDashboardData(siteId: number | null) {
|
||||
const [slots, setSlots] = useState<SlotData[]>([])
|
||||
const [liveMetrics, setLiveMetrics] = useState<LiveMetrics | null>(null)
|
||||
const [forecastWeek, setForecastWeek] = useState<ForecastDayTotal[]>([])
|
||||
const [negPrices, setNegPrices] = useState<NegPriceItem[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const siteIdRef = useRef(siteId)
|
||||
siteIdRef.current = siteId
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null) {
|
||||
setSlots([])
|
||||
setForecastWeek([])
|
||||
setNegPrices([])
|
||||
setLiveMetrics(null)
|
||||
setError(null)
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
|
||||
const windowStart = floorSlotUtcMs(Date.now()) - SLOT_COUNT_BACK * SLOT_MS
|
||||
const nIdx = currentSlotIndexInWindow(windowStart)
|
||||
|
||||
try {
|
||||
const todayPrague = pragueCalendarDay()
|
||||
const dates = new Set<string>()
|
||||
for (let i = 0; i < TOTAL_SLOTS; i++) {
|
||||
const ms = windowStart + i * SLOT_MS
|
||||
dates.add(pragueCalendarDay(new Date(ms)))
|
||||
}
|
||||
|
||||
const [
|
||||
planMaybe,
|
||||
statusArr,
|
||||
hourly7d,
|
||||
auditHourly,
|
||||
modeLog,
|
||||
hpArr,
|
||||
...priceLists
|
||||
] = await Promise.all([
|
||||
getCurrentPlan(siteId).catch((e: unknown) => {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 404) {
|
||||
return { run: null, intervals: [] as PlanningIntervalDto[], summary: null }
|
||||
}
|
||||
throw e
|
||||
}),
|
||||
getJson<SiteStatusRow[]>('/vw_site_status', { site_id: `eq.${siteId}` }),
|
||||
getJson<TelemetryHourly7dRow[]>('/vw_telemetry_hourly_7d', {
|
||||
site_id: `eq.${siteId}`,
|
||||
order: 'hour.asc',
|
||||
limit: '500',
|
||||
}),
|
||||
getJson<AuditTodayHourlyRow[]>('/vw_audit_today_hourly', {
|
||||
site_id: `eq.${siteId}`,
|
||||
order: 'hour_local.asc',
|
||||
}),
|
||||
getJson<ModeLogRecentRow[]>('/vw_mode_log_recent', {
|
||||
site_id: `eq.${siteId}`,
|
||||
order: 'activated_at.asc',
|
||||
limit: '200',
|
||||
}),
|
||||
getJson<HeatPumpLatestRow[]>('/vw_latest_heat_pump', { site_id: `eq.${siteId}` }),
|
||||
...[...dates].map((d) => getSitePrices(siteId, d)),
|
||||
])
|
||||
|
||||
const status = Array.isArray(statusArr) && statusArr[0] ? statusArr[0]! : null
|
||||
const hp = Array.isArray(hpArr) && hpArr[0] ? hpArr[0]! : null
|
||||
setLiveMetrics(buildLiveMetrics(status, hp))
|
||||
|
||||
const plan = planMaybe as { intervals: PlanningIntervalDto[] }
|
||||
const planBySlot = new Map<string, PlanningIntervalDto>()
|
||||
for (const iv of plan.intervals) {
|
||||
planBySlot.set(slotTimeKey(new Date(iv.interval_start).getTime()), iv)
|
||||
}
|
||||
|
||||
const priceBySlot = new Map<string, { buy: number | null; sell: number | null }>()
|
||||
const flatPrices: SiteEffectivePriceRowDto[] = priceLists.flat() as SiteEffectivePriceRowDto[]
|
||||
for (const r of flatPrices) {
|
||||
const k = slotTimeKey(new Date(r.interval_start).getTime())
|
||||
priceBySlot.set(k, {
|
||||
buy: parseNum(r.effective_buy_price_czk_kwh),
|
||||
sell: parseNum(r.effective_sell_price_czk_kwh),
|
||||
})
|
||||
}
|
||||
|
||||
const forecastBySlot = new Map<string, { a: number; b: number }>()
|
||||
const forecastDays: ForecastDayTotal[] = []
|
||||
const today = todayPrague
|
||||
const forecastResults = await Promise.all(
|
||||
Array.from({ length: 7 }, (_, d) => {
|
||||
const ymd = pragueAddCalendarDays(today, d)
|
||||
return getSiteForecastPv(siteId, ymd)
|
||||
.then((fc) => ({ ymd, fc }))
|
||||
.catch(() => ({ ymd, fc: null as Awaited<ReturnType<typeof getSiteForecastPv>> | null }))
|
||||
}),
|
||||
)
|
||||
for (const { ymd, fc } of forecastResults) {
|
||||
if (!fc) {
|
||||
forecastDays.push({ date: ymd, label: ymd, kwh: 0 })
|
||||
continue
|
||||
}
|
||||
let kwh = 0
|
||||
const byStart = new Map<string, { a: number; b: number }>()
|
||||
for (const x of fc.pv_a ?? []) {
|
||||
const t = new Date(x.interval_start).getTime()
|
||||
const p = Number(x.power_w ?? 0)
|
||||
const cur = byStart.get(slotTimeKey(t)) ?? { a: 0, b: 0 }
|
||||
cur.a += p
|
||||
byStart.set(slotTimeKey(t), cur)
|
||||
}
|
||||
for (const x of fc.pv_b ?? []) {
|
||||
const t = new Date(x.interval_start).getTime()
|
||||
const p = Number(x.power_w ?? 0)
|
||||
const cur = byStart.get(slotTimeKey(t)) ?? { a: 0, b: 0 }
|
||||
cur.b += p
|
||||
byStart.set(slotTimeKey(t), cur)
|
||||
}
|
||||
for (const [, v] of byStart) {
|
||||
kwh += ((v.a + v.b) * 0.25) / 1000
|
||||
}
|
||||
for (const [k, v] of byStart) {
|
||||
forecastBySlot.set(k, v)
|
||||
}
|
||||
const label = new Date(ymd + 'T12:00:00Z').toLocaleDateString('cs-CZ', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
timeZone: 'Europe/Prague',
|
||||
})
|
||||
forecastDays.push({ date: ymd, label, kwh: Math.round(kwh * 10) / 10 })
|
||||
}
|
||||
setForecastWeek(forecastDays)
|
||||
|
||||
const hourlyMap = new Map<number, TelemetryHourly7dRow>()
|
||||
if (Array.isArray(hourly7d)) {
|
||||
for (const r of hourly7d) {
|
||||
hourlyMap.set(new Date(r.hour).getTime(), r)
|
||||
}
|
||||
}
|
||||
|
||||
const auditMap = new Map<string, AuditTodayHourlyRow>()
|
||||
if (Array.isArray(auditHourly)) {
|
||||
for (const r of auditHourly) {
|
||||
auditMap.set(pragueHourKey(new Date(r.hour_local).getTime()), r)
|
||||
}
|
||||
}
|
||||
|
||||
const logs = Array.isArray(modeLog) ? modeLog : []
|
||||
const activeMode = status?.active_mode ?? 'AUTO'
|
||||
|
||||
const built: SlotData[] = []
|
||||
for (let i = 0; i < TOTAL_SLOTS; i++) {
|
||||
const startMs = windowStart + i * SLOT_MS
|
||||
const iso = new Date(startMs).toISOString()
|
||||
const base = emptySlot(iso)
|
||||
const k = slotTimeKey(startMs)
|
||||
|
||||
const tel = hourlyMap.get(hourFloorUtcMs(startMs))
|
||||
if (tel) {
|
||||
base.pv_power_w = tel.avg_pv_w ?? base.pv_power_w
|
||||
base.battery_power_w = tel.avg_battery_w ?? base.battery_power_w
|
||||
base.grid_power_w = tel.avg_grid_w ?? base.grid_power_w
|
||||
base.load_power_w = tel.avg_load_w ?? base.load_power_w
|
||||
base.soc_actual_pct = parseNum(tel.last_soc_pct) ?? base.soc_actual_pct
|
||||
}
|
||||
|
||||
const aud = auditMap.get(pragueHourKey(startMs))
|
||||
if (aud) {
|
||||
if (base.pv_power_w == null && aud.avg_pv_kw != null) {
|
||||
base.pv_power_w = Math.round((parseNum(aud.avg_pv_kw) ?? 0) * 1000)
|
||||
}
|
||||
if (base.load_power_w == null && aud.avg_load_kw != null) {
|
||||
base.load_power_w = Math.round((parseNum(aud.avg_load_kw) ?? 0) * 1000)
|
||||
}
|
||||
if (base.battery_power_w == null && aud.avg_battery_kw != null) {
|
||||
base.battery_power_w = Math.round((parseNum(aud.avg_battery_kw) ?? 0) * 1000)
|
||||
}
|
||||
if (base.grid_power_w == null && aud.avg_grid_kw != null) {
|
||||
base.grid_power_w = Math.round((parseNum(aud.avg_grid_kw) ?? 0) * 1000)
|
||||
}
|
||||
if (base.soc_actual_pct == null) {
|
||||
base.soc_actual_pct = parseNum(aud.avg_soc_pct) ?? base.soc_actual_pct
|
||||
}
|
||||
}
|
||||
|
||||
const pr = priceBySlot.get(k)
|
||||
if (pr) {
|
||||
base.buy_price = pr.buy
|
||||
base.sell_price = pr.sell
|
||||
}
|
||||
|
||||
const fc = forecastBySlot.get(k)
|
||||
if (fc) {
|
||||
base.pv_a_forecast_w = fc.a
|
||||
base.pv_b_forecast_w = fc.b
|
||||
}
|
||||
|
||||
const pi = planBySlot.get(k)
|
||||
if (pi) mergeInterval(base, pi)
|
||||
|
||||
const past = i <= nIdx
|
||||
const regimePast = modeAt(logs, startMs) ?? activeMode
|
||||
built.push({
|
||||
...base,
|
||||
regime_code: past ? regimePast : activeMode,
|
||||
regime_is_planned: !past,
|
||||
})
|
||||
}
|
||||
|
||||
const neg: NegPriceItem[] = []
|
||||
const nowMs = Date.now()
|
||||
for (const r of flatPrices) {
|
||||
const t = new Date(r.interval_start).getTime()
|
||||
if (t < nowMs) continue
|
||||
const buy = parseNum(r.effective_buy_price_czk_kwh)
|
||||
const sell = parseNum(r.effective_sell_price_czk_kwh)
|
||||
if ((buy != null && buy < 0) || (sell != null && sell < 0)) {
|
||||
neg.push({
|
||||
interval_start: r.interval_start,
|
||||
buy,
|
||||
sell,
|
||||
})
|
||||
}
|
||||
}
|
||||
neg.sort((a, b) => new Date(a.interval_start).getTime() - new Date(b.interval_start).getTime())
|
||||
setNegPrices(neg.slice(0, 32))
|
||||
|
||||
setSlots(built)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Chyba načítání dashboardu')
|
||||
setSlots([])
|
||||
} finally {
|
||||
setReady(true)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const id = window.setInterval(() => void load(), POLL_FULL_MS)
|
||||
return () => window.clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId == null) {
|
||||
setLiveMetrics(null)
|
||||
return
|
||||
}
|
||||
const fetchLive = async () => {
|
||||
try {
|
||||
const [statusArr, hpArr] = await Promise.all([
|
||||
getJson<SiteStatusRow[]>('/vw_site_status', { site_id: `eq.${siteId}` }),
|
||||
getJson<HeatPumpLatestRow[]>('/vw_latest_heat_pump', { site_id: `eq.${siteId}` }),
|
||||
])
|
||||
const status = Array.isArray(statusArr) && statusArr[0] ? statusArr[0]! : null
|
||||
const hp = Array.isArray(hpArr) && hpArr[0] ? hpArr[0]! : null
|
||||
setLiveMetrics(buildLiveMetrics(status, hp))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
void fetchLive()
|
||||
const id = window.setInterval(() => void fetchLive(), POLL_LIVE_MS)
|
||||
return () => window.clearInterval(id)
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId == null) {
|
||||
wsRef.current?.close()
|
||||
wsRef.current = null
|
||||
return
|
||||
}
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/ws/telemetry`)
|
||||
wsRef.current = ws
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data as string) as Record<string, unknown>
|
||||
if (msg.type !== 'telemetry' || Number(msg.site_id) !== siteIdRef.current) return
|
||||
|
||||
const pv = numFromWs(msg.pv_power_w)
|
||||
const batW = numFromWs(msg.battery_power_w)
|
||||
const gridW = numFromWs(msg.grid_power_w)
|
||||
const loadW = numFromWs(msg.load_power_w)
|
||||
const genW = numFromWs(msg.gen_port_power_w)
|
||||
const soc = numFromWs(msg.battery_soc_pct)
|
||||
|
||||
setLiveMetrics((prev) => ({
|
||||
pv_w: pv ?? prev?.pv_w ?? null,
|
||||
load_w: loadW ?? prev?.load_w ?? null,
|
||||
grid_w: gridW ?? prev?.grid_w ?? null,
|
||||
bat_soc: soc ?? prev?.bat_soc ?? null,
|
||||
bat_w: batW ?? prev?.bat_w ?? null,
|
||||
}))
|
||||
|
||||
const tsStr = typeof msg.ts === 'string' ? msg.ts : null
|
||||
if (!tsStr) return
|
||||
const tsMs = new Date(tsStr).getTime()
|
||||
if (!Number.isFinite(tsMs)) return
|
||||
|
||||
setSlots((prev) => {
|
||||
const idx = prev.findIndex((s) => {
|
||||
const start = new Date(s.interval_start).getTime()
|
||||
return start <= tsMs && tsMs < start + SLOT_MS
|
||||
})
|
||||
if (idx === -1) return prev
|
||||
const cur = prev[idx]!
|
||||
const updated = [...prev]
|
||||
updated[idx] = {
|
||||
...cur,
|
||||
pv_power_w: pv ?? cur.pv_power_w,
|
||||
battery_power_w: batW ?? cur.battery_power_w,
|
||||
grid_power_w: gridW ?? cur.grid_power_w,
|
||||
load_power_w: loadW ?? cur.load_power_w,
|
||||
gen_port_power_w: genW ?? cur.gen_port_power_w,
|
||||
soc_actual_pct: soc ?? cur.soc_actual_pct,
|
||||
}
|
||||
return updated
|
||||
})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (wsRef.current === ws) wsRef.current = null
|
||||
}
|
||||
return () => {
|
||||
ws.close()
|
||||
if (wsRef.current === ws) wsRef.current = null
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
const liveNowIndex = useMemo(
|
||||
() => currentSlotIndexInWindow(floorSlotUtcMs(Date.now()) - SLOT_COUNT_BACK * SLOT_MS),
|
||||
[slots],
|
||||
)
|
||||
|
||||
const buyNow =
|
||||
slots.length && liveNowIndex >= 0 && liveNowIndex < slots.length
|
||||
? slots[liveNowIndex]!.buy_price
|
||||
: null
|
||||
|
||||
return {
|
||||
slots,
|
||||
nowIndex: liveNowIndex,
|
||||
forecastWeek,
|
||||
negPrices,
|
||||
error,
|
||||
ready,
|
||||
reload: load,
|
||||
liveMetrics,
|
||||
buyNow,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { getSiteStatusFull } from '../api/backend'
|
||||
import type { FullStatusResponse } from '../types/fullStatus'
|
||||
|
||||
const POLL_MS = 30_000
|
||||
const POLL_MS = 60_000
|
||||
|
||||
export function useFullStatus(siteId: number | null) {
|
||||
const [data, setData] = useState<FullStatusResponse | null>(null)
|
||||
|
||||
51
frontend/src/hooks/useLogSeverityBadge.ts
Normal file
51
frontend/src/hooks/useLogSeverityBadge.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const SEVERE = new Set(['ERROR', 'CRITICAL'])
|
||||
|
||||
function wsLogsUrl(): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${window.location.host}/ws/logs`
|
||||
}
|
||||
|
||||
/** Počítá ERROR/CRITICAL z /ws/logs jen když stránka Logy není aktivní (aby nebyly 2 sockety). */
|
||||
export function useLogSeverityBadge(logsPageActive: boolean): number {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (logsPageActive) {
|
||||
setCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let disposed = false
|
||||
|
||||
const connect = () => {
|
||||
if (disposed) return
|
||||
ws = new WebSocket(wsLogsUrl())
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const o = JSON.parse(e.data) as { level?: string }
|
||||
if (o.level && SEVERE.has(o.level)) setCount((c) => c + 1)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (disposed) return
|
||||
reconnectTimer = setTimeout(connect, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (reconnectTimer != null) clearTimeout(reconnectTimer)
|
||||
ws?.close()
|
||||
}
|
||||
}, [logsPageActive])
|
||||
|
||||
return count
|
||||
}
|
||||
45
frontend/src/hooks/useNotifications.ts
Normal file
45
frontend/src/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { getSiteNotifications } from '../api/backend'
|
||||
import type { Notification } from '../types/dashboard'
|
||||
|
||||
const POLL_MS = 60_000
|
||||
|
||||
export function useNotifications(siteId: number | null) {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async (silent?: boolean) => {
|
||||
if (siteId == null) {
|
||||
setNotifications([])
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (!silent) setLoading(true)
|
||||
try {
|
||||
const res = await getSiteNotifications(siteId)
|
||||
setNotifications(res.notifications)
|
||||
setError(null)
|
||||
} catch {
|
||||
setNotifications([])
|
||||
setError('Notifikace se nepodařilo načíst')
|
||||
} finally {
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
void load(false)
|
||||
const id = window.setInterval(() => void load(true), POLL_MS)
|
||||
return () => window.clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
return {
|
||||
notifications,
|
||||
loading,
|
||||
error,
|
||||
reload: () => void load(false),
|
||||
}
|
||||
}
|
||||
31
frontend/src/hooks/useRollingReplanMinutes.ts
Normal file
31
frontend/src/hooks/useRollingReplanMinutes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { getBackendHealthDetailed } from '../api/backend'
|
||||
|
||||
/** Minuty do dalšího naplánovaného `rolling_replan` jobu (globální scheduler). */
|
||||
export function useRollingReplanMinutes() {
|
||||
const [nextReplanIn, setNextReplanIn] = useState<number | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const h = await getBackendHealthDetailed()
|
||||
const job = h.active_jobs.find((j) => j.id === 'rolling_replan')
|
||||
if (job?.next_run_time == null) {
|
||||
setNextReplanIn(null)
|
||||
return
|
||||
}
|
||||
const diffMin = (new Date(job.next_run_time).getTime() - Date.now()) / 60_000
|
||||
setNextReplanIn(Math.max(0, Math.round(diffMin)))
|
||||
} catch {
|
||||
setNextReplanIn(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
const id = window.setInterval(() => void refresh(), 60_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [refresh])
|
||||
|
||||
return { nextReplanIn, refreshRollingEta: refresh }
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { getJson } from '../api/postgrest'
|
||||
import type { SiteStatusRow } from '../types/ems'
|
||||
|
||||
const POLL_MS = 5_000
|
||||
const POLL_MS = 30_000
|
||||
|
||||
export function useSiteStatus() {
|
||||
const [row, setRow] = useState<SiteStatusRow | null>(null)
|
||||
|
||||
49
frontend/src/hooks/useWsLogErrorCount.ts
Normal file
49
frontend/src/hooks/useWsLogErrorCount.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const WINDOW_MS = 5 * 60_000
|
||||
const PRUNE_MS = 10_000
|
||||
|
||||
/** Počet ERROR logů z /ws/logs za posledních 5 minut (podle času přijetí zprávy). */
|
||||
export function useWsLogErrorCount(enabled: boolean): number {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
const timestamps: number[] = []
|
||||
const prune = () => {
|
||||
const cutoff = Date.now() - WINDOW_MS
|
||||
while (timestamps.length > 0 && timestamps[0]! < cutoff) {
|
||||
timestamps.shift()
|
||||
}
|
||||
setCount(timestamps.length)
|
||||
}
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${proto}//${window.location.host}/ws/logs`)
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let rec: { level?: string }
|
||||
try {
|
||||
rec = JSON.parse(ev.data as string) as { level?: string }
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (rec.level === 'ERROR') {
|
||||
timestamps.push(Date.now())
|
||||
prune()
|
||||
}
|
||||
}
|
||||
|
||||
const id = window.setInterval(prune, PRUNE_MS)
|
||||
return () => {
|
||||
window.clearInterval(id)
|
||||
ws.close()
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return count
|
||||
}
|
||||
@@ -2,7 +2,28 @@
|
||||
@config "../tailwind.config.ts";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--border-radius-md: 0.5rem;
|
||||
--color-border-tertiary: rgb(51 65 85 / 0.6);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-screen bg-slate-950 text-slate-100 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes critical-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-critical-pulse {
|
||||
animation: critical-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,3 +16,16 @@ export function instantPragueDay(iso: string): string {
|
||||
day: '2-digit',
|
||||
}).format(new Date(iso))
|
||||
}
|
||||
|
||||
/** Kalendářní den v Praze posunutý o N dní (od ref YYYY-MM-DD v Praze). */
|
||||
export function pragueAddCalendarDays(ymd: string, deltaDays: number): string {
|
||||
const [y, m, d] = ymd.split('-').map(Number)
|
||||
const utc = new Date(Date.UTC(y, m - 1, d, 12, 0, 0))
|
||||
utc.setUTCDate(utc.getUTCDate() + deltaDays)
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Prague',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(utc)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
63
frontend/src/pages/Logs.tsx
Normal file
63
frontend/src/pages/Logs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 bit4–5=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">Kč/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">TČ</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Náklady Kč</th>
|
||||
<th className="whitespace-nowrap py-2 pr-2 font-medium">Výnos Kč</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>
|
||||
|
||||
49
frontend/src/types/chart-js-ambient.d.ts
vendored
Normal file
49
frontend/src/types/chart-js-ambient.d.ts
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
/** Minimalní deklarace pro build bez nainstalovaných typů / modulů chart.js. */
|
||||
declare module 'chart.js' {
|
||||
export type ChartArea = {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type TooltipItem<_T = unknown> = {
|
||||
dataIndex: number
|
||||
dataset: { label?: string; yAxisID?: string }
|
||||
parsed: { y: number }
|
||||
}
|
||||
|
||||
export interface Chart {
|
||||
chartArea: ChartArea
|
||||
ctx: CanvasRenderingContext2D
|
||||
data: {
|
||||
labels?: unknown[]
|
||||
datasets?: Array<{ hidden?: boolean; label?: string; yAxisID?: string; [key: string]: unknown }>
|
||||
}
|
||||
destroy(): void
|
||||
resize(): void
|
||||
update(mode?: string): void
|
||||
}
|
||||
|
||||
export type Plugin<_T = Chart> = {
|
||||
id: string
|
||||
beforeDatasetsDraw?(chart: Chart): void
|
||||
afterDatasetsDraw?(chart: Chart): void
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'chart.js/auto' {
|
||||
import type { Chart } from 'chart.js'
|
||||
|
||||
export class Chart {
|
||||
constructor(ctx: unknown, config: unknown)
|
||||
chartArea: Chart['chartArea']
|
||||
data: Chart['data']
|
||||
ctx: Chart['ctx']
|
||||
destroy(): void
|
||||
resize(): void
|
||||
update(mode?: string): void
|
||||
}
|
||||
}
|
||||
91
frontend/src/types/dashboard.ts
Normal file
91
frontend/src/types/dashboard.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/** Jedna 15min buňka pro dashboardové grafy (minulost + plán). */
|
||||
export type SlotData = {
|
||||
interval_start: string
|
||||
pv_power_w: number | null
|
||||
battery_power_w: number | null
|
||||
/** Plánovaný výkon baterie (W) – budoucí sloty. */
|
||||
battery_setpoint_w: number | null
|
||||
grid_power_w: number | null
|
||||
/** Plánovaný výkon sítě (W) – budoucí sloty. */
|
||||
grid_setpoint_w: number | null
|
||||
load_power_w: number | null
|
||||
gen_port_power_w: number | null
|
||||
pv_a_forecast_w: number | null
|
||||
pv_b_forecast_w: number | null
|
||||
load_baseline_w: number | null
|
||||
ev1_setpoint_w: number | null
|
||||
ev2_setpoint_w: number | null
|
||||
heat_pump_setpoint_w: number | null
|
||||
/** Z plánu (`heat_pump_enabled`); pro StatePanel TČ. */
|
||||
heat_pump_enabled: boolean | null
|
||||
battery_soc_target_pct: number | null
|
||||
buy_price: number | null
|
||||
sell_price: number | null
|
||||
/** Provozní režim pro RegimeBar (kód z operating_mode_def). */
|
||||
regime_code: string | null
|
||||
/** Budoucí segment – zobrazit jako plán (světlejší). */
|
||||
regime_is_planned: boolean
|
||||
/** SoC % skutečnost (telemetrie / audit). */
|
||||
soc_actual_pct: number | null
|
||||
/** SoC % z plánu. */
|
||||
soc_plan_pct: number | null
|
||||
/** TUV °C skutečnost. */
|
||||
tuv_actual_c: number | null
|
||||
/** TUV °C cíl z plánu (zjednodušeně při běhu TČ). */
|
||||
tuv_plan_c: number | null
|
||||
}
|
||||
|
||||
export type ChartAreaLike = {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
export type ForecastDayTotal = {
|
||||
date: string
|
||||
label: string
|
||||
kwh: number
|
||||
}
|
||||
|
||||
export type NegPriceItem = {
|
||||
interval_start: string
|
||||
buy: number | null
|
||||
sell: number | null
|
||||
}
|
||||
|
||||
/** Živé metriky pro horní karty (5s poll / WebSocket; nezávislé na slots[]). */
|
||||
export type LiveMetrics = {
|
||||
pv_w: number | null
|
||||
load_w: number | null
|
||||
grid_w: number | null
|
||||
bat_soc: number | null
|
||||
bat_w: number | null
|
||||
}
|
||||
|
||||
export type NotificationLevel = 'success' | 'info' | 'warning' | 'error'
|
||||
|
||||
export type NotificationAction = 'connect_ev' | 'replan' | 'import_prices' | 'switch_auto'
|
||||
|
||||
/** Odpověď GET /api/v1/sites/{id}/notifications */
|
||||
export type Notification = {
|
||||
id: string
|
||||
level: NotificationLevel
|
||||
title: string
|
||||
body: string
|
||||
/** Minuty do události (zobrazí se „za Xh Ymin“). */
|
||||
eta_minutes?: number | null
|
||||
action?: NotificationAction | null
|
||||
}
|
||||
|
||||
export type TelemetryWsPayload = {
|
||||
type: string
|
||||
site_id: number
|
||||
ts?: string
|
||||
pv_power_w?: number | null
|
||||
battery_soc_pct?: number | null
|
||||
battery_power_w?: number | null
|
||||
grid_power_w?: number | null
|
||||
load_power_w?: number | null
|
||||
gen_port_power_w?: number | null
|
||||
}
|
||||
@@ -36,6 +36,28 @@ export type AuditTodayHourlyRow = {
|
||||
cost_czk: string | number | null
|
||||
}
|
||||
|
||||
/** ems.vw_telemetry_hourly_7d (řádky z telemetry_inverter_hourly) */
|
||||
export type TelemetryHourly7dRow = {
|
||||
hour: string
|
||||
site_id: number
|
||||
avg_pv_w: number | null
|
||||
avg_battery_w: number | null
|
||||
avg_grid_w: number | null
|
||||
avg_load_w: number | null
|
||||
last_soc_pct: string | number | null
|
||||
sample_count: number | null
|
||||
}
|
||||
|
||||
/** ems.vw_latest_heat_pump */
|
||||
export type HeatPumpLatestRow = {
|
||||
site_id: number
|
||||
heat_pump_id: number
|
||||
heat_pump_code: string | null
|
||||
measured_at: string
|
||||
tuv_tank_temp_c: number | string | null
|
||||
power_w: number | null
|
||||
}
|
||||
|
||||
/** ems.vw_audit_daily */
|
||||
export type AuditDailyRow = {
|
||||
site_id: number
|
||||
|
||||
@@ -17,12 +17,18 @@ export type PlanningIntervalDto = {
|
||||
grid_setpoint_w: number | null
|
||||
ev1_setpoint_w: number | null
|
||||
ev2_setpoint_w: number | null
|
||||
ev_charge_power_w?: number | null
|
||||
heat_pump_enabled: boolean | null
|
||||
heat_pump_setpoint_w?: number | null
|
||||
pv_a_curtailed_w: number | null
|
||||
expected_cost_czk: number | null
|
||||
effective_buy_price: number | null
|
||||
effective_sell_price: number | null
|
||||
/** True pokud cena pro slot byla při plánování predikovaná (DB sloupec `is_predicted_price`). */
|
||||
is_predicted_price: boolean
|
||||
pv_forecast_total_w: number | null
|
||||
/** Průměrná skutečná FVE výkon za slot z audit_interval (GET /plan/current JOIN). */
|
||||
pv_power_w?: number | null
|
||||
load_baseline_w: number | null
|
||||
}
|
||||
|
||||
@@ -32,6 +38,8 @@ export type PlanningSummaryDto = {
|
||||
charge_slots: number
|
||||
discharge_slots: number
|
||||
export_slots: number
|
||||
/** 0.65..1.0; nižší znamená očekávaně méně slunce -> větší ochota precharge ze sítě */
|
||||
pv_scarcity_factor?: number
|
||||
}
|
||||
|
||||
export type CurrentPlanResponse = {
|
||||
|
||||
9
frontend/src/types/react-router-dom-ambient.d.ts
vendored
Normal file
9
frontend/src/types/react-router-dom-ambient.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare module 'react-router-dom' {
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function BrowserRouter(props: { children?: ReactNode }): JSX.Element
|
||||
export function Routes(props: { children?: ReactNode }): JSX.Element
|
||||
export function Route(props: Record<string, unknown>): JSX.Element | null
|
||||
export function Outlet(): JSX.Element | null
|
||||
export function NavLink(props: Record<string, unknown>): JSX.Element
|
||||
}
|
||||
@@ -27,6 +27,11 @@ export default defineConfig(async () => {
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/ws': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user