Bazén vizualizace + EV Discord notifikace po příjezdu (fáze A)
- R__097: vw_latest_pool_pump + vw_pool_pump_day_energy (denní kWh z delty čítače, minuty běhu) + ems_anon granty - PoolCard na Dashboardu: stav/W/dnešní kWh+hodiny/7denní mini sloupce - _notify_ev_arrival_plan: po příjezdu EV Discord souhrn (SoC auta → cíl, deadline, nabíjecí okna shlukovaná ze slotů aktivního plánu, ø cena) - docs/discord-ev-interaction.md: fáze B (bot s tlačítky přes gateway — žádný veřejný endpoint; čeká na DISCORD_BOT_TOKEN od uživatele) - docs: pool-shelly + ev-charging aktualizovány (pravidlo docs 1:1) První commit na dev větvi (nová kadence: deploy až s milníkovým merge). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
117
frontend/src/components/PoolCard.tsx
Normal file
117
frontend/src/components/PoolCard.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getJson } from '../api/postgrest'
|
||||
|
||||
type LatestRow = {
|
||||
site_id: number
|
||||
pump_id: number
|
||||
pump_code: string
|
||||
rated_power_w: number
|
||||
schedulable: boolean
|
||||
measured_at: string | null
|
||||
is_on: boolean | null
|
||||
power_w: number | null
|
||||
energy_wh_total: number | null
|
||||
}
|
||||
|
||||
type DayRow = { day: string; kwh: number; on_minutes: number }
|
||||
|
||||
const POLL_MS = 60_000
|
||||
|
||||
/** Karta bazénového čerpadla (Shelly): stav, příkon, dnešní kWh + týdenní mini přehled. */
|
||||
export function PoolCard({ siteId }: { siteId: number }) {
|
||||
const [latest, setLatest] = useState<LatestRow | null>(null)
|
||||
const [days, setDays] = useState<DayRow[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const load = async () => {
|
||||
try {
|
||||
const [l, d] = await Promise.all([
|
||||
getJson<LatestRow[]>('/vw_latest_pool_pump', { site_id: `eq.${siteId}` }),
|
||||
getJson<DayRow[]>('/vw_pool_pump_day_energy', {
|
||||
site_id: `eq.${siteId}`,
|
||||
order: 'day.desc',
|
||||
limit: '7',
|
||||
}),
|
||||
])
|
||||
if (!alive) return
|
||||
setLatest(l[0] ?? null)
|
||||
setDays(d)
|
||||
} catch {
|
||||
/* karta je doplňková — chybu nechováme */
|
||||
}
|
||||
}
|
||||
void load()
|
||||
const t = setInterval(load, POLL_MS)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(t)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
if (!latest) return null
|
||||
|
||||
const stale =
|
||||
latest.measured_at != null &&
|
||||
Date.now() - new Date(latest.measured_at).getTime() > 5 * 60_000
|
||||
const running = latest.is_on === true
|
||||
const today = days[0]
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🏊</span>
|
||||
<span className="text-sm font-medium text-slate-200">Bazénové čerpadlo</span>
|
||||
<span
|
||||
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
||||
stale ? 'bg-slate-600' : running ? 'bg-emerald-400' : 'bg-slate-500'
|
||||
}`}
|
||||
title={stale ? 'telemetrie >5 min stará' : running ? 'běží' : 'stojí'}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">
|
||||
{latest.schedulable ? 'řízeno plánem' : 'jen měření'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-slate-100">
|
||||
{stale ? '—' : running ? `${latest.power_w ?? 0} W` : '0 W'}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400">příkon</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-slate-100">
|
||||
{today ? `${today.kwh.toFixed(2)}` : '—'}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400">kWh dnes</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-slate-100">
|
||||
{today ? `${Math.round(today.on_minutes / 60 * 10) / 10} h` : '—'}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400">běh dnes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{days.length > 1 && (
|
||||
<div className="mt-3 flex items-end gap-1" title="kWh po dnech (7 dní)">
|
||||
{[...days].reverse().map((d) => {
|
||||
const max = Math.max(...days.map((x) => x.kwh), 0.01)
|
||||
return (
|
||||
<div key={d.day} className="flex-1">
|
||||
<div
|
||||
className="rounded-sm bg-sky-700/70"
|
||||
style={{ height: `${Math.max(3, (d.kwh / max) * 36)}px` }}
|
||||
title={`${d.day}: ${d.kwh.toFixed(2)} kWh`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { ControlPanel } from '../components/ControlPanel'
|
||||
import { ModeBar } from '../components/ModeBar'
|
||||
import { NotificationBar } from '../components/NotificationBar'
|
||||
import { StatePanel } from '../components/StatePanel'
|
||||
import { PoolCard } from '../components/PoolCard'
|
||||
import { useDashboardData } from '../hooks/useDashboardData'
|
||||
import { useFullStatus } from '../hooks/useFullStatus'
|
||||
import { useNotifications } from '../hooks/useNotifications'
|
||||
@@ -351,6 +352,11 @@ export function Dashboard() {
|
||||
{data.slots.length > 0 && data.slotsReady ? (
|
||||
<section>
|
||||
<StatePanel slots={data.slots} nowIndex={data.nowIndex} />
|
||||
{siteId != null && (
|
||||
<div className="mt-4">
|
||||
<PoolCard siteId={siteId} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user