603 lines
28 KiB
TypeScript
603 lines
28 KiB
TypeScript
"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<string, string> = { 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<number, Map<string, number[]>>();
|
||
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<string, number | string>[], 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<HTMLDivElement>(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<SVGSVGElement>(null);
|
||
|
||
const onMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
|
||
const rect = svgRef.current!.getBoundingClientRect();
|
||
setSelecting({ startPx: e.clientX - rect.left, endPx: e.clientX - rect.left });
|
||
};
|
||
const onMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||
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 (
|
||
<div ref={containerRef} className="relative w-full select-none" style={{ height: H }}>
|
||
<svg ref={svgRef} width={width} height={H}
|
||
onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={onMouseUp}
|
||
onDoubleClick={() => { onYDomainChange(null as any); onBrush(0, data.length - 1); }}
|
||
>
|
||
{/* Grid */}
|
||
{yTicks.map((v, i) => (
|
||
<g key={i}>
|
||
<line x1={margin.left} y1={yScale(v)} x2={margin.left + innerW} y2={yScale(v)} stroke="currentColor" strokeOpacity={0.08} strokeDasharray="3 3" />
|
||
<text x={margin.left - 6} y={yScale(v)} textAnchor="end" dominantBaseline="middle" fontSize={11} fill="currentColor" fillOpacity={0.4}>{Math.round(v)}W</text>
|
||
</g>
|
||
))}
|
||
{data.map((pt, i) => i % xStep !== 0 && i !== data.length - 1 ? null : (
|
||
<text key={i} x={xScale(i)} y={margin.top + innerH + 18} textAnchor="middle" fontSize={11} fill="currentColor" fillOpacity={0.4}>{pt.label}</text>
|
||
))}
|
||
|
||
{/* 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 (
|
||
<g key={name}
|
||
onMouseEnter={e => {
|
||
const r = containerRef.current?.getBoundingClientRect();
|
||
if (r) setTooltip({ x: e.clientX - r.left, y: e.clientY - r.top, bucket: pt });
|
||
}}
|
||
onMouseLeave={() => setTooltip(null)}
|
||
>
|
||
<line x1={x} y1={yScale(c.high)} x2={x} y2={yScale(c.low)} stroke={color} strokeWidth={1.5} />
|
||
<rect x={x - candleW / 2} y={bodyTop} width={candleW} height={bodyH} fill={color} fillOpacity={0.85} />
|
||
<rect x={cx - slotW / 2} y={margin.top} width={slotW} height={innerH} fill="transparent" />
|
||
</g>
|
||
);
|
||
});
|
||
})}
|
||
|
||
{/* Drag selection overlay */}
|
||
{selecting && selX2 - selX1 > 2 && (
|
||
<rect x={selX1} y={margin.top} width={selX2 - selX1} height={innerH}
|
||
fill="#428ce2" fillOpacity={0.15} stroke="#428ce2" strokeOpacity={0.5} strokeWidth={1} />
|
||
)}
|
||
</svg>
|
||
|
||
{/* Hover tooltip */}
|
||
{tooltip && !selecting && (
|
||
<div className="pointer-events-none absolute z-10 bg-primary border border-secondary rounded-xl px-3 py-2 text-xs shadow-lg"
|
||
style={{ left: Math.min(tooltip.x + 12, width - 160), top: Math.max(0, tooltip.y - 10) }}>
|
||
<p className="text-foreground-sec mb-1.5">{tooltip.bucket.label}</p>
|
||
{activeDevices.map(name => {
|
||
const c = tooltip.bucket[name] as Candle | undefined;
|
||
if (!c || typeof c !== "object") return null;
|
||
return (
|
||
<div key={name} className="flex flex-col gap-0.5 mb-1" style={{ color: DEVICE_COLORS[name] }}>
|
||
<span className="font-medium capitalize">{name}</span>
|
||
<span className="text-foreground-sec font-mono text-[10px]">
|
||
O {c.open.toFixed(1)} H {c.high.toFixed(1)}<br />
|
||
L {c.low.toFixed(1)} C {c.close.toFixed(1)} W
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Shared tooltip ────────────────────────────────────────────────────────────
|
||
|
||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||
if (!active || !payload?.length) return null;
|
||
return (
|
||
<div className="bg-primary border border-secondary rounded-xl px-3 py-2 text-xs shadow-lg">
|
||
<p className="text-foreground-sec mb-1">{label}</p>
|
||
{payload.map((p: any) => (
|
||
<p key={p.name} style={{ color: p.color }} className="font-medium">
|
||
{String(p.name).charAt(0).toUpperCase() + String(p.name).slice(1)}: {Number(p.value).toFixed(1)} W
|
||
</p>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
function SummaryCard({ label, value, sub, color }: { label: string; value: string; sub?: string; color?: string }) {
|
||
return (
|
||
<div className="bg-primary border border-secondary rounded-2xl p-5 flex flex-col gap-1">
|
||
<span className="text-xs font-medium text-foreground-sec">{label}</span>
|
||
<span className="text-2xl font-medium tracking-tight text-foreground leading-none mt-1" style={color ? { color } : undefined}>{value}</span>
|
||
{sub && <span className="text-[0.7rem] text-foreground-sec mt-0.5">{sub}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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<HistoryEntry[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
// Chart
|
||
const [chartType, setChartType] = useState<ChartType>("line");
|
||
const [activeDevices, setActiveDevices] = useState<Set<string>>(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<HTMLDivElement>(null);
|
||
const yDomainRef = useRef<[number, number] | null>(null);
|
||
const visibleRef = useRef<Record<string, number | string>[]>([]);
|
||
const activeListRef = useRef<string[]>([]);
|
||
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<string, number | string> = { 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 (
|
||
<div className="w-full h-full bg-primary text-foreground overflow-hidden flex flex-row">
|
||
<SideNav online={false} devConsoleOpen={false} onToggleDevConsole={() => {}} />
|
||
|
||
<div className="flex-1 overflow-y-auto pt-[52px] lg:pt-0 lg:m-[10px_10px_10px_0px] lg:rounded-2xl lg:border lg:border-blue/20 min-w-0">
|
||
<div className="max-w-5xl mx-auto px-3 pb-20 pt-8">
|
||
|
||
{/* Header */}
|
||
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Power Analytics</h1>
|
||
<p className="text-sm text-foreground-sec mt-1">
|
||
{readings.length} readings · LADWP ${COST_PER_KWH}/kWh
|
||
{appliedRange && <span> · {fmtDt(appliedRange.from)} – {fmtDt(appliedRange.to)}</span>}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Range picker */}
|
||
<div className="flex flex-col gap-2 items-end">
|
||
<div className="flex gap-1 bg-secondary/50 rounded-xl p-1">
|
||
{PRESETS.map(({ label, h }) => (
|
||
<button key={h} onClick={() => { setPresetH(h); setAppliedRange(null); setShowCustom(false); }}
|
||
className={"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer " +
|
||
(!appliedRange && presetH === h ? "bg-blue text-white" : "text-foreground-sec hover:text-foreground")}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
<button onClick={() => setShowCustom(s => !s)}
|
||
className={"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer " +
|
||
(showCustom || appliedRange ? "bg-blue text-white" : "text-foreground-sec hover:text-foreground")}>
|
||
Custom
|
||
</button>
|
||
</div>
|
||
|
||
{/* Custom date range picker */}
|
||
{showCustom && (
|
||
<div className="flex flex-wrap items-center gap-2 bg-secondary/40 border border-secondary rounded-xl p-2.5 text-xs">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-foreground-sec">From</span>
|
||
<input type="datetime-local" value={customFrom} onChange={e => 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" />
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-foreground-sec">To</span>
|
||
<input type="datetime-local" value={customTo} onChange={e => 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" />
|
||
</div>
|
||
<button
|
||
onClick={() => { if (customFrom && customTo) { setAppliedRange({ from: customFrom, to: customTo }); setShowCustom(false); } }}
|
||
className="px-3 py-1 bg-blue/10 border border-blue/30 text-blue rounded-lg font-medium hover:bg-blue/20 transition-colors cursor-pointer">
|
||
Apply
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Summary cards */}
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3.5 mb-8">
|
||
<SummaryCard label="Total energy" value={`${totalKWh.toFixed(3)} kWh`} sub={`≈ $${(totalKWh * COST_PER_KWH).toFixed(3)}`} />
|
||
{summaries.map(({ name, summary }) => (
|
||
<SummaryCard key={name} label={`${name[0].toUpperCase() + name.slice(1)} avg`}
|
||
value={summary ? `${summary.avgW.toFixed(1)} W` : "—"}
|
||
sub={summary ? `Peak ${summary.peakW.toFixed(1)} W` : undefined}
|
||
color={DEVICE_COLORS[name]} />
|
||
))}
|
||
{summaries.map(({ name, summary }) => (
|
||
<SummaryCard key={`${name}-u`} label={`${name[0].toUpperCase() + name.slice(1)} uptime`}
|
||
value={summary ? `${summary.uptimePct.toFixed(0)}%` : "—"}
|
||
sub={summary ? `${summary.kWh.toFixed(3)} kWh` : undefined} />
|
||
))}
|
||
</div>
|
||
|
||
{/* Chart card */}
|
||
<div className="bg-primary border border-secondary rounded-2xl p-5 mb-8">
|
||
{/* Controls row */}
|
||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<h2 className="text-sm font-medium text-foreground">Power over time</h2>
|
||
{deviceNames.map(name => {
|
||
const active = activeDevices.has(name);
|
||
const color = DEVICE_COLORS[name] ?? "#888";
|
||
return (
|
||
<button key={name} onClick={() => toggleDevice(name)}
|
||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer border"
|
||
style={{ borderColor: active ? color + "55" : "transparent", background: active ? color + "18" : "transparent", color: active ? color : "var(--color-foreground-sec)" }}>
|
||
<span className="w-2 h-2 rounded-full" style={{ background: active ? color : "var(--color-secondary)" }} />
|
||
<span className="capitalize">{name}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{isZoomed && (
|
||
<button onClick={resetZoom}
|
||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-foreground-sec hover:text-foreground border border-secondary hover:border-secondary/80 transition-colors cursor-pointer">
|
||
<IconRefresh size={11} />
|
||
Reset zoom
|
||
</button>
|
||
)}
|
||
<div className="flex gap-0.5 bg-secondary/50 rounded-lg p-0.5">
|
||
{(["line", "bar", "candle"] as ChartType[]).map(type => (
|
||
<button key={type} onClick={() => { setChartType(type); resetZoom(); }}
|
||
className={"px-3 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer capitalize " +
|
||
(chartType === type ? "bg-primary text-foreground shadow-sm" : "text-foreground-sec hover:text-foreground")}>
|
||
{type}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-[11px] text-foreground-sec/50 mb-3">
|
||
{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"}
|
||
</p>
|
||
|
||
{/* Chart body */}
|
||
{loading ? (
|
||
<div className="skeleton h-[300px]" />
|
||
) : readings.length === 0 ? (
|
||
<div className="flex items-center justify-center h-[300px] text-foreground-sec text-sm">
|
||
No data yet — readings are recorded every 5 minutes.
|
||
</div>
|
||
) : chartType === "candle" ? (
|
||
<CandleChart
|
||
data={visibleCandle}
|
||
activeDevices={activeList}
|
||
yDomain={yDomain}
|
||
onYDomainChange={d => setYDomain(d)}
|
||
onBrush={(s, e) => {
|
||
const base = candleBrushIdx ? candleBrushIdx[0] : 0;
|
||
setCandleBrushIdx([base + s, base + e]);
|
||
}}
|
||
/>
|
||
) : (
|
||
<div ref={lineBarRef} onDoubleClick={onChartDblClick}>
|
||
<ResponsiveContainer width="100%" height={300}>
|
||
{chartType === "bar" ? (
|
||
<BarChart data={flatData} margin={CHART_MARGIN}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" strokeOpacity={0.08} />
|
||
<XAxis dataKey="ts" {...axisProps} interval="preserveStartEnd" />
|
||
<YAxis {...axisProps} domain={effectiveYDomain ?? ["auto", "auto"]} tickFormatter={v => `${v}W`} width={50} />
|
||
<Tooltip content={<CustomTooltip />} />
|
||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} formatter={v => String(v)[0].toUpperCase() + String(v).slice(1)} />
|
||
{activeList.map(name => <Bar key={name} dataKey={name} fill={DEVICE_COLORS[name] ?? "#888"} fillOpacity={0.8} radius={[2, 2, 0, 0]} maxBarSize={16} />)}
|
||
<Brush dataKey="ts" height={22} stroke="var(--color-secondary)" fill="var(--color-primary)" travellerWidth={6}
|
||
onChange={({ startIndex, endIndex }) => {
|
||
setBrushIdx([startIndex as number, endIndex as number]);
|
||
setYDomain(null);
|
||
}} />
|
||
</BarChart>
|
||
) : (
|
||
<LineChart data={flatData} margin={CHART_MARGIN}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" strokeOpacity={0.08} />
|
||
<XAxis dataKey="ts" {...axisProps} interval="preserveStartEnd" />
|
||
<YAxis {...axisProps} domain={effectiveYDomain ?? ["auto", "auto"]} tickFormatter={v => `${v}W`} width={50} />
|
||
<Tooltip content={<CustomTooltip />} />
|
||
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} formatter={v => String(v)[0].toUpperCase() + String(v).slice(1)} />
|
||
{activeList.map(name => (
|
||
<Line key={name} type="monotone" dataKey={name} stroke={DEVICE_COLORS[name] ?? "#888"} strokeWidth={1.5} dot={false} activeDot={{ r: 3 }} />
|
||
))}
|
||
<Brush dataKey="ts" height={22} stroke="var(--color-secondary)" fill="var(--color-primary)" travellerWidth={6}
|
||
onChange={({ startIndex, endIndex }) => {
|
||
setBrushIdx([startIndex as number, endIndex as number]);
|
||
setYDomain(null);
|
||
}} />
|
||
</LineChart>
|
||
)}
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Device breakdown */}
|
||
{summaries.length > 0 && (
|
||
<>
|
||
<h2 className="text-lg font-medium tracking-tight text-foreground mb-5">Device breakdown</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5">
|
||
{summaries.map(({ name, summary }) => {
|
||
if (!summary) return null;
|
||
const color = DEVICE_COLORS[name] ?? "#888";
|
||
return (
|
||
<div key={name} className="bg-primary border border-secondary rounded-2xl p-5">
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<span className="w-2.5 h-2.5 rounded-full" style={{ background: color }} />
|
||
<h3 className="text-sm font-medium text-foreground capitalize">{name}</h3>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-4">
|
||
{[
|
||
{ 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 }) => (
|
||
<div key={label} className="flex flex-col gap-0.5">
|
||
<span className="text-xs text-foreground-sec">{label}</span>
|
||
<span className="text-sm font-medium text-foreground">{value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|