547 lines
19 KiB
TypeScript
547 lines
19 KiB
TypeScript
import axios from 'axios'
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
|
||
import {
|
||
getCurrentPlan,
|
||
getForecastPvSlotsRangeCorrected,
|
||
getSitePricesSlotsRange,
|
||
} 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,
|
||
Telemetry15m7dRow,
|
||
} from '../types/ems'
|
||
import type { PlanningIntervalDto } from '../types/plan'
|
||
|
||
const POLL_FULL_MS = 60_000
|
||
const POLL_LIVE_MS = 15_000
|
||
|
||
/** Limit řádků vw_telemetry_15m_7d: jen okno zpět (s rezervou), ne celých 7 dní. */
|
||
const TELEMETRY_15M_LIMIT = String(Math.ceil(SLOT_COUNT_BACK * 1.2))
|
||
|
||
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),
|
||
}
|
||
}
|
||
|
||
/** 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,
|
||
deye_physical_mode: null,
|
||
export_mode: null,
|
||
export_limit_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
|
||
if (p.deye_physical_mode != null) s.deye_physical_mode = p.deye_physical_mode
|
||
if (p.export_mode != null) s.export_mode = p.export_mode
|
||
if (p.export_limit_w != null) s.export_limit_w = p.export_limit_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 [slotsReady, setSlotsReady] = 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)
|
||
setSlotsReady(true)
|
||
return
|
||
}
|
||
|
||
const windowStart = floorSlotUtcMs(Date.now()) - SLOT_COUNT_BACK * SLOT_MS
|
||
const nIdx = currentSlotIndexInWindow(windowStart)
|
||
|
||
// Vlna 1 — kritická: vw_site_status (+ TČ) je rychlé, UI se odemkne hned.
|
||
let status: SiteStatusRow | null = null
|
||
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}` }),
|
||
])
|
||
status = Array.isArray(statusArr) && statusArr[0] ? statusArr[0]! : null
|
||
const hp = Array.isArray(hpArr) && hpArr[0] ? hpArr[0]! : null
|
||
setLiveMetrics(buildLiveMetrics(status, hp))
|
||
setError(null)
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : 'Chyba načítání dashboardu')
|
||
} finally {
|
||
setReady(true)
|
||
}
|
||
|
||
// Vlna 2 — extended: plán, telemetrie, audit, ceny. Při refetchi zůstávají
|
||
// zobrazená stale data (sloty se přepíšou až novými daty, žádné blikání).
|
||
try {
|
||
const todayPrague = pragueCalendarDay()
|
||
|
||
const [
|
||
planMaybe,
|
||
telemetry15m7d,
|
||
auditHourly,
|
||
modeLog,
|
||
priceRows,
|
||
] = 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<Telemetry15m7dRow[]>('/vw_telemetry_15m_7d', {
|
||
site_id: `eq.${siteId}`,
|
||
slot_start: `gte.${new Date(windowStart).toISOString()}`,
|
||
order: 'slot_start.asc',
|
||
limit: TELEMETRY_15M_LIMIT,
|
||
}),
|
||
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',
|
||
}),
|
||
// Ceny bereme přes FastAPI range endpoint (PostgREST /rest je u vás chráněné → 401).
|
||
getSitePricesSlotsRange(
|
||
siteId,
|
||
new Date(windowStart).toISOString(),
|
||
new Date(windowStart + TOTAL_SLOTS * SLOT_MS).toISOString(),
|
||
),
|
||
])
|
||
|
||
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 = Array.isArray(priceRows) ? priceRows : []
|
||
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 forecastDays: ForecastDayTotal[] = []
|
||
const weekDates = Array.from({ length: 7 }, (_, d) => pragueAddCalendarDays(todayPrague, d))
|
||
|
||
// Forecast pro dashboard bereme pouze z range endpointu (jedno volání místo N× /forecast/pv per den).
|
||
// Rozsah musí pokrýt (a) okno slotů pro tabulku a (b) 7denní sumáře.
|
||
const windowFromIso = new Date(windowStart).toISOString()
|
||
const windowToIso = new Date(windowStart + TOTAL_SLOTS * SLOT_MS).toISOString()
|
||
const weekToIso = new Date(floorSlotUtcMs(Date.now()) + 7 * 24 * 60 * 60 * 1000).toISOString()
|
||
const forecastToIso = weekToIso > windowToIso ? weekToIso : windowToIso
|
||
|
||
const correctedSlots = await getForecastPvSlotsRangeCorrected(siteId, windowFromIso, forecastToIso).catch(
|
||
() => [] as Awaited<ReturnType<typeof getForecastPvSlotsRangeCorrected>>,
|
||
)
|
||
const correctedBySlot = new Map<string, number>()
|
||
for (const r of correctedSlots) {
|
||
const t = new Date(r.interval_start).getTime()
|
||
if (!Number.isFinite(t)) continue
|
||
const v = r.pv_forecast_corrected_w
|
||
if (v == null) continue
|
||
correctedBySlot.set(slotTimeKey(t), Number(v))
|
||
}
|
||
for (const ymd of weekDates) {
|
||
let kwh = 0
|
||
// Křivka je po 15 min, takže energie = W * 0.25h
|
||
// Použijeme correctedBySlot v časovém okně daného prague dne.
|
||
const dayStart = new Date(ymd + 'T00:00:00').getTime()
|
||
const dayEnd = new Date(pragueAddCalendarDays(ymd, 1) + 'T00:00:00').getTime()
|
||
for (let t = dayStart; t < dayEnd; t += SLOT_MS) {
|
||
const w = correctedBySlot.get(slotTimeKey(t)) ?? 0
|
||
kwh += (w * 0.25) / 1000
|
||
}
|
||
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 telemetryBySlot = new Map<string, Telemetry15m7dRow>()
|
||
if (Array.isArray(telemetry15m7d)) {
|
||
for (const r of telemetry15m7d) {
|
||
telemetryBySlot.set(slotTimeKey(new Date(r.slot_start).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 = telemetryBySlot.get(k)
|
||
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 corr = correctedBySlot.get(k)
|
||
if (corr != null) {
|
||
base.pv_forecast_corrected_w = corr
|
||
// Dashboard neřeší přesné rozdělení po polích; pro UI rozpad použijeme stabilní poměr.
|
||
base.pv_a_forecast_w = Math.round(corr * 0.6)
|
||
base.pv_b_forecast_w = Math.round(corr * 0.4)
|
||
}
|
||
|
||
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 liveSoc = parseNum(status?.battery_soc_percent)
|
||
if (liveSoc != null && nIdx >= 0 && nIdx < built.length) {
|
||
const cur = built[nIdx]!
|
||
built[nIdx] = { ...cur, soc_actual_pct: liveSoc }
|
||
}
|
||
|
||
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')
|
||
// Sloty neměnit — během chyby refetche zůstávají zobrazená poslední data.
|
||
} finally {
|
||
setSlotsReady(true)
|
||
}
|
||
}, [siteId])
|
||
|
||
useEffect(() => {
|
||
// Změna lokality: data staré site nesmí zůstat zobrazená.
|
||
setSlots([])
|
||
setSlotsReady(false)
|
||
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
|
||
|
||
const sellNow =
|
||
slots.length && liveNowIndex >= 0 && liveNowIndex < slots.length
|
||
? slots[liveNowIndex]!.sell_price
|
||
: null
|
||
|
||
return {
|
||
slots,
|
||
nowIndex: liveNowIndex,
|
||
forecastWeek,
|
||
negPrices,
|
||
error,
|
||
ready,
|
||
slotsReady,
|
||
reload: load,
|
||
liveMetrics,
|
||
buyNow,
|
||
sellNow,
|
||
}
|
||
}
|