Files
ems/frontend/src/components/PoolCard.tsx
Dusan Vojacek 29d854f23d
Some checks failed
CI and deploy / deploy (push) Has been cancelled
CI and deploy / migration-check (push) Has been cancelled
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>
2026-06-12 10:59:09 +02:00

118 lines
3.7 KiB
TypeScript

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>
)
}