"use client"; import { useEffect, useState, useCallback, useRef } from "react"; import { ResponsiveContainer, LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Legend, Brush, ReferenceArea, } from "recharts"; import { IconRefresh } from "@tabler/icons-react"; import SideNav from "../components/SideNav"; // ── Types ──────────────────────────────────────────────────────────────────── interface DeviceReading { name: string; watts: number; on: boolean; today_wh: number; month_wh: number; } interface HistoryEntry { ts: string; devices: DeviceReading[]; } interface Candle { open: number; close: number; high: number; low: number; } interface CandleBucket { ts: number; label: string; [device: string]: Candle | number | string; } type ChartType = "line" | "bar" | "candle"; const COST_PER_KWH = 0.24; // LADWP Northridge CA 2025 const DEVICE_COLORS: Record = { server: "#428ce2", desktop: "#a78bfa" }; const CHART_MARGIN = { top: 4, right: 16, bottom: 4, left: 0 }; // ── Helpers ────────────────────────────────────────────────────────────────── function fmtTs(ts: string, spanH: number): string { const d = new Date(ts); if (spanH <= 24) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); return d.toLocaleDateString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } function fmtDt(iso: string) { // "2025-01-15T14:30" → "Jan 15, 2:30 PM" try { return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } catch { return iso; } } function bucketCandles(readings: HistoryEntry[], spanH: number): CandleBucket[] { const bucketMs = (spanH <= 6 ? 30 : spanH <= 24 ? 60 : spanH <= 168 ? 360 : 1440) * 60_000; const map = new Map>(); for (const r of readings) { const key = Math.floor(new Date(r.ts).getTime() / bucketMs) * bucketMs; if (!map.has(key)) map.set(key, new Map()); for (const d of r.devices) { if (!map.get(key)!.has(d.name)) map.get(key)!.set(d.name, []); map.get(key)!.get(d.name)!.push(d.watts); } } return Array.from(map.entries()).sort(([a], [b]) => a - b).map(([ts, devices]) => { const label = fmtTs(new Date(ts).toISOString(), spanH); const pt: CandleBucket = { ts, label }; for (const [name, watts] of devices) { const s = [...watts].sort((a, b) => a - b); pt[name] = { open: watts[0], close: watts[watts.length - 1], high: s[s.length - 1], low: s[0] }; } return pt; }); } function computeSummary(readings: HistoryEntry[], deviceName: string) { const pts = readings.map(r => { const d = r.devices.find(x => x.name === deviceName); return d ? { ts: new Date(r.ts).getTime(), watts: d.watts, on: d.on } : null; }).filter(Boolean).sort((a, b) => a!.ts - b!.ts) as { ts: number; watts: number; on: boolean }[]; if (!pts.length) return null; const avgW = pts.reduce((s, p) => s + p.watts, 0) / pts.length; const peakW = Math.max(...pts.map(p => p.watts)); const uptimePct = pts.filter(p => p.on).length / pts.length * 100; let kWh = pts.length >= 2 ? pts.slice(1).reduce((s, p, i) => s + (p.watts + pts[i].watts) / 2 * (p.ts - pts[i].ts) / 3_600_000_000, 0) : pts[0].watts / 1000 * (5 / 60); return { avgW, peakW, kWh, cost: kWh * COST_PER_KWH, uptimePct }; } function autoYDomain(data: Record[], names: string[]): [number, number] | null { const vals = data.flatMap(pt => names.map(n => pt[n]).filter(v => typeof v === "number")) as number[]; if (!vals.length) return null; const mn = Math.min(...vals), mx = Math.max(...vals); const pad = Math.max((mx - mn) * 0.08, 5); return [Math.max(0, mn - pad), mx + pad]; } // ── Candle chart ───────────────────────────────────────────────────────────── function CandleChart({ data, activeDevices, yDomain, onYDomainChange, onBrush }: { data: CandleBucket[]; activeDevices: string[]; yDomain: [number, number] | null; onYDomainChange: (d: [number, number]) => void; onBrush: (start: number, end: number) => void; }) { const containerRef = useRef(null); const [width, setWidth] = useState(600); const [tooltip, setTooltip] = useState<{ x: number; y: number; bucket: CandleBucket } | null>(null); const [selecting, setSelecting] = useState<{ startPx: number; endPx: number } | null>(null); useEffect(() => { const el = containerRef.current; if (!el) return; const ro = new ResizeObserver(e => setWidth(e[0].contentRect.width)); ro.observe(el); setWidth(el.getBoundingClientRect().width); return () => ro.disconnect(); }, []); const H = 300, margin = { top: 10, right: 20, bottom: 28, left: 54 }; const innerW = width - margin.left - margin.right; const innerH = H - margin.top - margin.bottom; // Y domain let yMin = Infinity, yMax = -Infinity; for (const pt of data) for (const n of activeDevices) { const c = pt[n] as Candle | undefined; if (c && typeof c === "object") { yMin = Math.min(yMin, c.low); yMax = Math.max(yMax, c.high); } } if (yMin === Infinity) { yMin = 0; yMax = 500; } const pad = Math.max((yMax - yMin) * 0.08, 5); const [effMin, effMax] = yDomain ?? [Math.max(0, yMin - pad), yMax + pad]; const yScale = (v: number) => margin.top + innerH - ((v - effMin) / (effMax - effMin)) * innerH; const yFromPx = (py: number) => effMin + (1 - (py - margin.top) / innerH) * (effMax - effMin); const slotW = innerW / Math.max(data.length, 1); const candleW = Math.max(3, Math.min(18, slotW / activeDevices.length - 3)); const xScale = (i: number) => margin.left + (i + 0.5) * slotW; const xToIdx = (px: number) => Math.max(0, Math.min(data.length - 1, Math.floor((px - margin.left) / slotW))); const yTicks = Array.from({ length: 5 }, (_, i) => effMin + (effMax - effMin) * i / 4); const xStep = Math.max(1, Math.floor(data.length / 6)); // Scroll = Y zoom useEffect(() => { const el = containerRef.current; if (!el) return; const handler = (e: WheelEvent) => { e.preventDefault(); const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15; const py = e.clientY - el.getBoundingClientRect().top; const center = yFromPx(py); const half = (effMax - effMin) / 2 * factor; onYDomainChange([Math.max(0, center - half), center + half]); }; el.addEventListener("wheel", handler, { passive: false }); return () => el.removeEventListener("wheel", handler); }, [effMin, effMax]); // Mouse drag = X brush selection const svgRef = useRef(null); const onMouseDown = (e: React.MouseEvent) => { const rect = svgRef.current!.getBoundingClientRect(); setSelecting({ startPx: e.clientX - rect.left, endPx: e.clientX - rect.left }); }; const onMouseMove = (e: React.MouseEvent) => { if (!selecting) return; const rect = svgRef.current!.getBoundingClientRect(); setSelecting(s => s ? { ...s, endPx: e.clientX - rect.left } : null); setTooltip(null); }; const onMouseUp = () => { if (!selecting) return; const s = xToIdx(selecting.startPx), e = xToIdx(selecting.endPx); if (Math.abs(s - e) > 0) onBrush(Math.min(s, e), Math.max(s, e)); setSelecting(null); }; const selX1 = selecting ? Math.min(selecting.startPx, selecting.endPx) : 0; const selX2 = selecting ? Math.max(selecting.startPx, selecting.endPx) : 0; return (
{ onYDomainChange(null as any); onBrush(0, data.length - 1); }} > {/* Grid */} {yTicks.map((v, i) => ( {Math.round(v)}W ))} {data.map((pt, i) => i % xStep !== 0 && i !== data.length - 1 ? null : ( {pt.label} ))} {/* Candles */} {data.map((pt, i) => { const cx = xScale(i); return activeDevices.map((name, di) => { const c = pt[name] as Candle | undefined; if (!c || typeof c !== "object") return null; const off = activeDevices.length > 1 ? (di - (activeDevices.length - 1) / 2) * (candleW + 3) : 0; const x = cx + off; const isUp = c.close >= c.open; const color = isUp ? "#5dd776" : "#ef4444"; const bodyTop = Math.min(yScale(c.open), yScale(c.close)); const bodyH = Math.max(1, Math.abs(yScale(c.close) - yScale(c.open))); return ( { const r = containerRef.current?.getBoundingClientRect(); if (r) setTooltip({ x: e.clientX - r.left, y: e.clientY - r.top, bucket: pt }); }} onMouseLeave={() => setTooltip(null)} > ); }); })} {/* Drag selection overlay */} {selecting && selX2 - selX1 > 2 && ( )} {/* Hover tooltip */} {tooltip && !selecting && (

{tooltip.bucket.label}

{activeDevices.map(name => { const c = tooltip.bucket[name] as Candle | undefined; if (!c || typeof c !== "object") return null; return (
{name} O {c.open.toFixed(1)} H {c.high.toFixed(1)}
L {c.low.toFixed(1)} C {c.close.toFixed(1)} W
); })}
)}
); } // ── Shared tooltip ──────────────────────────────────────────────────────────── const CustomTooltip = ({ active, payload, label }: any) => { if (!active || !payload?.length) return null; return (

{label}

{payload.map((p: any) => (

{String(p.name).charAt(0).toUpperCase() + String(p.name).slice(1)}: {Number(p.value).toFixed(1)} W

))}
); }; function SummaryCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) { return (
{label} {value} {sub && {sub}}
); } // ── Page ────────────────────────────────────────────────────────────────────── const PRESETS = [{ label: "6h", h: 6 }, { label: "24h", h: 24 }, { label: "7d", h: 168 }, { label: "30d", h: 720 }]; export default function AnalyticsPage() { // Time range const [presetH, setPresetH] = useState(24); const [showCustom, setShowCustom] = useState(false); const [customFrom, setCustomFrom] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 1); return d.toISOString().slice(0, 16); }); const [customTo, setCustomTo] = useState(() => new Date().toISOString().slice(0, 16)); const [appliedRange, setAppliedRange] = useState<{ from: string; to: string } | null>(null); // Data const [readings, setReadings] = useState([]); const [loading, setLoading] = useState(true); // Chart const [chartType, setChartType] = useState("line"); const [activeDevices, setActiveDevices] = useState>(new Set()); // Zoom const [yDomain, setYDomain] = useState<[number, number] | null>(null); const [brushIdx, setBrushIdx] = useState<[number, number] | null>(null); // [start, end] indices into flatData const [candleBrushIdx, setCandleBrushIdx] = useState<[number, number] | null>(null); const lineBarRef = useRef(null); const yDomainRef = useRef<[number, number] | null>(null); const visibleRef = useRef[]>([]); const activeListRef = useRef([]); yDomainRef.current = yDomain; const effectiveSpanH = appliedRange ? (new Date(appliedRange.to).getTime() - new Date(appliedRange.from).getTime()) / 3_600_000 : presetH; const fetchHistory = useCallback(async () => { setLoading(true); try { let hoursToFetch = presetH; if (appliedRange) { hoursToFetch = Math.ceil((Date.now() - new Date(appliedRange.from).getTime()) / 3_600_000) + 1; } const res = await fetch(`/api/power/history?hours=${Math.min(hoursToFetch, 24 * 60)}`); if (!res.ok) return; let r: HistoryEntry[] = (await res.json()).readings ?? []; if (appliedRange) { const from = new Date(appliedRange.from).getTime(); const to = new Date(appliedRange.to).getTime(); r = r.filter(e => { const t = new Date(e.ts).getTime(); return t >= from && t <= to; }); } setReadings(r); setBrushIdx(null); setCandleBrushIdx(null); setYDomain(null); } finally { setLoading(false); } }, [presetH, appliedRange]); useEffect(() => { fetchHistory(); }, [fetchHistory]); const deviceNames = Array.from(new Set(readings.flatMap(r => r.devices.map(d => d.name)))).sort(); useEffect(() => { setActiveDevices(prev => prev.size > 0 ? prev : new Set(deviceNames)); }, [deviceNames.join(",")]); const activeList = deviceNames.filter(n => activeDevices.has(n)); activeListRef.current = activeList; // Flat data for line/bar const flatData = readings.map(r => { const pt: Record = { ts: fmtTs(r.ts, effectiveSpanH) }; for (const d of r.devices) if (activeDevices.has(d.name)) pt[d.name] = Number(d.watts.toFixed(1)); return pt; }); const visibleFlat = brushIdx ? flatData.slice(brushIdx[0], brushIdx[1] + 1) : flatData; visibleRef.current = visibleFlat; // Candle data const candleData = bucketCandles(readings, effectiveSpanH); const visibleCandle = candleBrushIdx ? candleData.slice(candleBrushIdx[0], candleBrushIdx[1] + 1) : candleData; // Y domain: scroll-zoomed > auto from visible data > recharts auto const effectiveYDomain = yDomain ?? autoYDomain(chartType === "candle" ? [] : visibleFlat, activeList); // Scroll Y zoom for line/bar useEffect(() => { const el = lineBarRef.current; if (!el) return; const handler = (e: WheelEvent) => { e.preventDefault(); const [curMin, curMax] = yDomainRef.current ?? autoYDomain(visibleRef.current, activeListRef.current) ?? [0, 500]; const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15; const rect = el.getBoundingClientRect(); const pct = 1 - (e.clientY - rect.top) / rect.height; const center = curMin + pct * (curMax - curMin); const half = (curMax - curMin) / 2 * factor; setYDomain([Math.max(0, center - half), center + half]); }; el.addEventListener("wheel", handler, { passive: false }); return () => el.removeEventListener("wheel", handler); }, []); const isZoomed = !!(yDomain || brushIdx || candleBrushIdx); const resetZoom = () => { setYDomain(null); setBrushIdx(null); setCandleBrushIdx(null); }; const toggleDevice = (name: string) => { setActiveDevices(prev => { const next = new Set(prev); if (next.has(name)) { if (next.size > 1) next.delete(name); } else next.add(name); return next; }); }; const summaries = deviceNames.map(name => ({ name, summary: computeSummary(readings, name) })); const totalKWh = summaries.reduce((s, { summary }) => s + (summary?.kWh ?? 0), 0); const axisProps = { tick: { fontSize: 11, fill: "currentColor", fillOpacity: 0.4 } as any, tickLine: false, axisLine: false }; // Double-click resets all zoom const onChartDblClick = () => resetZoom(); return (
{}} />
{/* Header */}

Power Analytics

{readings.length} readings · LADWP ${COST_PER_KWH}/kWh {appliedRange && · {fmtDt(appliedRange.from)} – {fmtDt(appliedRange.to)}}

{/* Range picker */}
{PRESETS.map(({ label, h }) => ( ))}
{/* Custom date range picker */} {showCustom && (
From setCustomFrom(e.target.value)} className="bg-secondary/60 border border-secondary rounded-lg px-2 py-1 text-foreground text-xs outline-none focus:border-blue/60" />
To setCustomTo(e.target.value)} className="bg-secondary/60 border border-secondary rounded-lg px-2 py-1 text-foreground text-xs outline-none focus:border-blue/60" />
)}
{/* Summary cards */}
{summaries.map(({ name, summary }) => ( ))} {summaries.map(({ name, summary }) => ( ))}
{/* Chart card */}
{/* Controls row */}

Power over time

{deviceNames.map(name => { const active = activeDevices.has(name); const color = DEVICE_COLORS[name] ?? "#888"; return ( ); })}
{isZoomed && ( )}
{(["line", "bar", "candle"] as ChartType[]).map(type => ( ))}

{chartType === "candle" ? "Drag to zoom X · scroll to zoom Y · double-click to reset" : "Drag brush below chart to zoom X · scroll to zoom Y · double-click to reset"}

{/* Chart body */} {loading ? (
) : readings.length === 0 ? (
No data yet — readings are recorded every 5 minutes.
) : chartType === "candle" ? ( setYDomain(d)} onBrush={(s, e) => { const base = candleBrushIdx ? candleBrushIdx[0] : 0; setCandleBrushIdx([base + s, base + e]); }} /> ) : (
{chartType === "bar" ? ( `${v}W`} width={50} /> } /> String(v)[0].toUpperCase() + String(v).slice(1)} /> {activeList.map(name => )} { setBrushIdx([startIndex as number, endIndex as number]); setYDomain(null); }} /> ) : ( `${v}W`} width={50} /> } /> String(v)[0].toUpperCase() + String(v).slice(1)} /> {activeList.map(name => ( ))} { setBrushIdx([startIndex as number, endIndex as number]); setYDomain(null); }} /> )}
)}
{/* Device breakdown */} {summaries.length > 0 && ( <>

Device breakdown

{summaries.map(({ name, summary }) => { if (!summary) return null; const color = DEVICE_COLORS[name] ?? "#888"; return (

{name}

{[ { label: "Average", value: `${summary.avgW.toFixed(1)} W` }, { label: "Peak", value: `${summary.peakW.toFixed(1)} W` }, { label: "Uptime", value: `${summary.uptimePct.toFixed(1)}%` }, { label: "Energy", value: `${summary.kWh.toFixed(3)} kWh` }, { label: "Est. cost", value: `$${summary.cost.toFixed(3)}` }, { label: "Readings", value: String(readings.filter(r => r.devices.some(d => d.name === name)).length) }, ].map(({ label, value }) => (
{label} {value}
))}
); })}
)}
); }