diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx index f739be6..84d11db 100644 --- a/app/analytics/page.tsx +++ b/app/analytics/page.tsx @@ -7,6 +7,7 @@ import { } from "recharts"; import { IconRefresh } from "@tabler/icons-react"; import SideNav from "../components/SideNav"; +import HelpTooltip from "../components/HelpTooltip"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -406,20 +407,25 @@ export default function AnalyticsPage() { {/* Range picker */} -
+
+ Time Range
{PRESETS.map(({ label, h }) => ( - + + + ))} - + + +
{/* Custom date range picker */} @@ -435,11 +441,13 @@ export default function AnalyticsPage() { 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" />
- + + +
)} @@ -464,38 +472,54 @@ export default function AnalyticsPage() { {/* Chart card */}
{/* Controls row */} -
-
+
+

Power over time

- {deviceNames.map(name => { - const active = activeDevices.has(name); - const color = DEVICE_COLORS[name] ?? "#888"; - return ( - - ); - })} -
-
- {isZoomed && ( - + {deviceNames.length > 0 && ( +
+ Devices +
+ {deviceNames.map(name => { + const active = activeDevices.has(name); + const color = DEVICE_COLORS[name] ?? "#888"; + return ( + + + + ); + })} +
+
)} -
- {(["line", "bar", "candle"] as ChartType[]).map(type => ( -
+
+ {isZoomed && ( + + - ))} + + )} +
+ Chart Type +
+ {(["line", "bar", "candle"] as ChartType[]).map(type => ( + + + + ))} +
diff --git a/app/auth/page.tsx b/app/auth/page.tsx index e4babb6..977b543 100644 --- a/app/auth/page.tsx +++ b/app/auth/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; +import HelpTooltip from "../components/HelpTooltip"; function b64uToBuf(b64u: string): ArrayBuffer { const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/"); @@ -190,20 +191,22 @@ export default function AuthPage() {

{error}

)} - +
diff --git a/app/components/DevConsole.tsx b/app/components/DevConsole.tsx index 743168f..d8168db 100644 --- a/app/components/DevConsole.tsx +++ b/app/components/DevConsole.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; import { LuX, LuTerminal, LuSend } from "react-icons/lu"; +import HelpTooltip from "./HelpTooltip"; export interface LogEntry { id: number; @@ -175,8 +176,8 @@ export default function DevConsole({
{(["logs", "request"] as const).map((tab) => ( + + ))} - + + +
@@ -273,28 +277,30 @@ export default function DevConsole({ minWidth: 0, }} /> - + + +
{["POST", "PUT", "PATCH"].includes(reqMethod) && ( diff --git a/app/components/HelpTooltip.tsx b/app/components/HelpTooltip.tsx new file mode 100644 index 0000000..9d277c5 --- /dev/null +++ b/app/components/HelpTooltip.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { useHelpMode } from "@/stores/helpModeStore"; + +interface HelpTooltipProps { + text: string; + children: React.ReactNode; + /** Use block=true for full-width elements so the ? sits to the right without breaking layout */ + block?: boolean; + /** Set hidden=true to suppress the ? badge (e.g. collapsed sidebar items) */ + hidden?: boolean; +} + +export default function HelpTooltip({ text, children, block = false, hidden = false }: HelpTooltipProps) { + const helpMode = useHelpMode(); + const [open, setOpen] = useState(false); + const [pos, setPos] = useState<{ x: number; y: number } | null>(null); + const btnRef = useRef(null); + const flyoutRef = useRef(null); + const touchHandled = useRef(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { setMounted(true); }, []); + + useEffect(() => { + if (!helpMode) setOpen(false); + }, [helpMode]); + + useEffect(() => { + if (!open) return; + const close = (e: MouseEvent | TouchEvent) => { + const target = e.target as Node; + if (!btnRef.current?.contains(target) && !flyoutRef.current?.contains(target)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", close); + document.addEventListener("touchstart", close, { passive: true }); + return () => { + document.removeEventListener("mousedown", close); + document.removeEventListener("touchstart", close); + }; + }, [open]); + + const calcPos = useCallback(() => { + if (!btnRef.current) return; + const rect = btnRef.current.getBoundingClientRect(); + const flyW = 210; + const x = rect.right + 8 + flyW > window.innerWidth + ? Math.max(4, rect.left - flyW - 8) + : rect.right + 8; + const y = Math.min(rect.top - 4, window.innerHeight - 130); + setPos({ x, y }); + }, []); + + const handleClick = useCallback((e: React.MouseEvent) => { + if (touchHandled.current) { touchHandled.current = false; return; } + e.stopPropagation(); + calcPos(); + setOpen((v) => !v); + }, [calcPos]); + + const handleTouchEnd = useCallback((e: React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + touchHandled.current = true; + calcPos(); + setOpen((v) => !v); + }, [calcPos]); + + if (!helpMode || hidden) return <>{children}; + + const qBtn = ( + + ); + + return ( + <> + {block ? ( +
+
{children}
+ {qBtn} +
+ ) : ( + + {children} + {qBtn} + + )} + {open && mounted && pos && createPortal( +
+ {text} +
, + document.body + )} + + ); +} diff --git a/app/components/PowerCard.tsx b/app/components/PowerCard.tsx index 6ff2759..94d28a7 100644 --- a/app/components/PowerCard.tsx +++ b/app/components/PowerCard.tsx @@ -1,6 +1,7 @@ "use client"; import { type TapoDevice } from "../lib/getPower"; +import HelpTooltip from "./HelpTooltip"; interface PowerCardProps { device: TapoDevice | null; @@ -45,17 +46,19 @@ export default function PowerCard({ device, label, delay = 0, toggling = false, {device.on ? "On" : "Off"} {onToggle && ( - + + + )}
) : null} @@ -70,7 +73,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false, W - {device.alias} · {device.model} + {device.model} · {device.ip}
diff --git a/app/components/PowerGrid.tsx b/app/components/PowerGrid.tsx index 176aebe..900bfc9 100644 --- a/app/components/PowerGrid.tsx +++ b/app/components/PowerGrid.tsx @@ -13,9 +13,6 @@ interface PowerGridProps { export default function PowerGrid({ power, onRefresh, showControls = true }: PowerGridProps) { const [toggling, setToggling] = useState(null); - const server = power?.devices.find((d) => d.name === "server") ?? null; - const desktop = power?.devices.find((d) => d.name === "desktop") ?? null; - const handleToggle = async (deviceName: string, on: boolean) => { setToggling(deviceName); try { @@ -28,22 +25,27 @@ export default function PowerGrid({ power, onRefresh, showControls = true }: Pow } }; + const devices = power?.devices ?? []; + return (
- handleToggle("server", on) : undefined} - /> - handleToggle("desktop", on) : undefined} - /> + {devices.length > 0 ? ( + devices.map((device, i) => ( + handleToggle(device.name, on) : undefined} + /> + )) + ) : ( + <> + + + + )}
); } diff --git a/app/components/SideNav.tsx b/app/components/SideNav.tsx index d6c4ae8..d243832 100644 --- a/app/components/SideNav.tsx +++ b/app/components/SideNav.tsx @@ -6,9 +6,11 @@ import { usePathname, useRouter } from "next/navigation"; import { IconHome2, IconMoon, IconSun, IconChevronsLeft, IconChevronsRight, IconMenu2, IconX, IconCode, IconKey, IconLogout, IconUsers, IconChartLine, - IconChevronDown, IconBolt, IconChartBar, IconChartCandle, + IconChevronDown, IconBolt, IconChartBar, IconChartCandle, IconHelpCircle, } from "@tabler/icons-react"; import { useSetTheme } from "@/stores/useThemeStore"; +import { useHelpMode, useToggleHelpMode } from "@/stores/helpModeStore"; +import HelpTooltip from "./HelpTooltip"; import { useFocusedWindowState, requestViewChange } from "@/stores/windowStore"; import { PANEL_SECTIONS, type PanelId } from "@/app/components/windows/types"; import { SideNavWidgets } from "./SideNavWidgets"; @@ -51,7 +53,6 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP if (collapsed) { return (
- {/* Dashboard icon */} - {/* Section items */} {PANEL_SECTIONS.flatMap((s) => s.items.map(({ panelId, label }) => { const Icon = ANALYTICS_ICONS[panelId]; @@ -92,39 +92,41 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP return (
- {/* Dashboard standalone */} - + + + - {/* Power Analytics section */} {PANEL_SECTIONS.map((section) => { const SectionIcon = SECTION_ICONS[section.id] ?? IconBolt; const isOpen = openSections.has(section.id); return (
- + + + {isOpen && (
@@ -132,19 +134,20 @@ function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedP const Icon = ANALYTICS_ICONS[panelId]; const active = focusedPanelId === panelId; return ( - + + + ); })}
@@ -162,6 +165,8 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN const pathname = usePathname(); const router = useRouter(); const setTheme = useSetTheme(); + const helpMode = useHelpMode(); + const toggleHelp = useToggleHelpMode(); const [collapsed, setCollapsed] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(220); const [menuOpen, setMenuOpen] = useState(false); @@ -282,51 +287,72 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN {/* Dev console */} {auth && (
- +
)} {/* Logout */} {auth && (
- +
)} {/* Theme toggle */}
- + +
+ + {/* Help mode toggle */} +
+
{/* Divider + collapse */}
- +
@@ -344,10 +370,12 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN setMenuOpen(false)}> logo - + + +
{/* Mobile dropdown menu */} @@ -383,13 +411,15 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN Views

{/* Dashboard */} - + + + {/* Sections */} {PANEL_SECTIONS.map((section) => (
@@ -400,13 +430,15 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN const Icon = ANALYTICS_ICONS[panelId]; const active = focusedPanelId === panelId; return ( - + + + ); })}
@@ -418,27 +450,41 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideN
{auth && ( - + + + )} {auth && ( - + + + )} - + + + + + +
)} diff --git a/app/components/SideNavWidgets.tsx b/app/components/SideNavWidgets.tsx index b667ab3..0a6107e 100644 --- a/app/components/SideNavWidgets.tsx +++ b/app/components/SideNavWidgets.tsx @@ -2,28 +2,24 @@ import { useState, useEffect, useRef } from "react"; import { IconLayoutGrid } from "@tabler/icons-react"; +import HelpTooltip from "./HelpTooltip"; import { type Stats, type NetworkInterface } from "../lib/getStats"; import { type PowerData } from "../lib/getPower"; import { formatBytes, statColor } from "../lib/utils"; -export type WidgetId = - | "cpu" | "memory" | "disk" | "temp" - | "power-server" | "power-desktop" - | "network" | "uptime" | "empty"; +export type WidgetId = string; -const WIDGET_OPTIONS: { id: WidgetId; label: string }[] = [ +const STATIC_WIDGET_OPTIONS: { id: WidgetId; label: string }[] = [ { id: "empty", label: "Empty" }, { id: "cpu", label: "CPU" }, { id: "memory", label: "Memory" }, { id: "disk", label: "Disk" }, { id: "temp", label: "Temp" }, - { id: "power-server", label: "Server Power" }, - { id: "power-desktop", label: "Desktop Power" }, { id: "network", label: "Network" }, { id: "uptime", label: "Uptime" }, ]; -const DEFAULT_WIDGETS: WidgetId[] = ["power-server", "power-desktop", "cpu", "memory"]; +const DEFAULT_WIDGETS: WidgetId[] = ["cpu", "memory", "network", "uptime"]; const STORAGE_KEY = "sidenav-widgets"; const card = "flex flex-col gap-2 p-3 bg-secondary/30 border border-secondary rounded-xl"; @@ -104,14 +100,20 @@ function WidgetSlot({ editing: boolean; onChange: (id: WidgetId) => void; }) { + const powerOptions = (power?.devices ?? []).map((d) => ({ + id: `power-${d.name}`, + label: `${d.name} Power`, + })); + const allOptions = [...STATIC_WIDGET_OPTIONS, ...powerOptions]; + if (editing) { return ( @@ -123,10 +125,15 @@ function WidgetSlot({ if (id === "memory") return ; if (id === "disk") return ; if (id === "temp") return ; - if (id === "power-server") return d.name === "server") ?? null} />; - if (id === "power-desktop") return d.name === "desktop") ?? null} />; if (id === "network") return ; if (id === "uptime") return ; + + if (id.startsWith("power-")) { + const deviceName = id.slice("power-".length); + const device = power?.devices.find((d) => d.name === deviceName) ?? null; + return ; + } + return null; } @@ -195,7 +202,7 @@ export function SideNavWidgets() { function updateWidget(index: number, id: WidgetId) { setWidgets((prev) => { - const next = [...prev] as WidgetId[]; + const next = [...prev]; next[index] = id; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {} return next; @@ -208,13 +215,15 @@ export function SideNavWidgets() {
Widgets - + + +
{widgets.map((id, i) => ( diff --git a/app/components/panels/AnalyticsPanel.tsx b/app/components/panels/AnalyticsPanel.tsx index 2d60fb4..f7acaf3 100644 --- a/app/components/panels/AnalyticsPanel.tsx +++ b/app/components/panels/AnalyticsPanel.tsx @@ -4,8 +4,14 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { createPortal } from "react-dom"; import { IconRefresh, IconBolt, IconClock, IconCalendarStats, - IconEye, IconChevronDown, IconChartBar, + IconEye, IconChevronDown, IconChartBar, IconArrowsHorizontal, } from "@tabler/icons-react"; +import HelpTooltip from "../HelpTooltip"; + +const ctrlLabelStyle: React.CSSProperties = { + fontSize: 10, fontWeight: 500, + color: "var(--color-foreground-sec)", paddingLeft: 2, +}; // ── Types ────────────────────────────────────────────────────────────────────── @@ -16,7 +22,9 @@ type ChartType = "line" | "bar" | "candle"; type Metric = "watts" | "energy" | "cost"; type GroupBy = "auto" | "hour" | "day" | "month" | "year"; type CandleInterval = "auto" | "1m" | "5m" | "15m" | "30m" | "1h" | "4h" | "1d" | "1w"; -type FlyoutId = "metric" | "range" | "groupby" | "devices" | "interval"; +type FlyoutId = "metric" | "range" | "groupby" | "devices" | "interval" | "xaxis"; +type BarXAxis = "device" | "hour-of-day" | "day-of-week" | "day-of-month" | "month-of-year" | "year" | "custom"; +type BarXAxisUnit = "minute" | "hour" | "day" | "week" | "month"; // ── Constants ───────────────────────────────────────────────────────────────── @@ -58,6 +66,26 @@ const CANDLE_INTERVALS: { id: CandleInterval; label: string; ms: number }[] = [ { id: "1w", label: "1w", ms: 604_800_000 }, ]; +const BAR_X_AXIS_OPTIONS: { id: BarXAxis; label: string }[] = [ + { id: "device", label: "Device" }, + { id: "hour-of-day", label: "Hour of day" }, + { id: "day-of-week", label: "Day of week" }, + { id: "day-of-month", label: "Day of month" }, + { id: "month-of-year", label: "Month of year" }, + { id: "year", label: "Year" }, + { id: "custom", label: "Custom" }, +]; + +const BAR_X_UNIT_MS: Record = { + minute: 60_000, hour: 3_600_000, day: 86_400_000, + week: 604_800_000, month: 30 * 86_400_000, +}; +const BAR_X_UNIT_SHORT: Record = { + minute: "min", hour: "hr", day: "d", week: "wk", month: "mo", +}; +const BAR_X_DOM_LABELS = Array.from({ length: 31 }, (_, i) => String(i + 1)); +const BAR_X_MONTH_LABELS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + const CHART_PALETTE = [ "#60a5fa", "#f87171", "#34d399", "#fbbf24", "#a78bfa", "#f472b6", "#22d3ee", "#a3e635", "#fb923c", "#818cf8", @@ -66,6 +94,17 @@ function chartColor(i: number) { return CHART_PALETTE[i % CHART_PALETTE.length]; // ── Helpers ─────────────────────────────────────────────────────────────────── +function getBarXMinHours(xAxis: BarXAxis, customUnit: BarXAxisUnit): number { + if (xAxis === "device" || xAxis === "hour-of-day") return 1; + if (xAxis === "day-of-week" || xAxis === "day-of-month") return 24; + if (xAxis === "month-of-year" || xAxis === "year") return 720; + // custom + if (customUnit === "minute" || customUnit === "hour") return 1; + if (customUnit === "day") return 24; + if (customUnit === "week") return 168; + return 720; // month +} + function getBucketKey(d: Date, groupBy: GroupBy): number { const n = new Date(d); if (groupBy === "hour") { n.setMinutes(0, 0, 0); } @@ -411,7 +450,7 @@ function LineChart({ readings, deviceNames, colors, visible, hours, metric, grou {visEntries.map(([n]) => { const c = colors.get(n) ?? "#888"; return ( - + @@ -451,7 +490,7 @@ function LineChart({ readings, deviceNames, colors, visible, hours, metric, grou const fillPath = `${linePath} L ${pts[pts.length - 1][0].toFixed(1)} ${iH.toFixed(1)} L ${pts[0][0].toFixed(1)} ${iH.toFixed(1)} Z`; return ( - + ); @@ -491,17 +530,21 @@ function LineChart({ readings, deviceNames, colors, visible, hours, metric, grou // ── Bar Chart ───────────────────────────────────────────────────────────────── const BM = { top: 16, right: 16, bottom: 56, left: 64 }; +const BAR_X_HOUR_LABELS = Array.from({ length: 24 }, (_, h) => `${h.toString().padStart(2, "0")}:00`); +const BAR_X_DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; -function BarChart({ readings, deviceNames, colors, visible, metric }: { +function BarChart({ readings, deviceNames, colors, visible, metric, xAxis, xAxisCustomN, xAxisCustomUnit }: { readings: HistoryEntry[]; deviceNames: string[]; colors: Map; - visible: Set; metric: Metric; + visible: Set; metric: Metric; xAxis: BarXAxis; + xAxisCustomN: number; xAxisCustomUnit: BarXAxisUnit; }) { const containerRef = useRef(null); const [uid] = useState(() => `bc${Math.random().toString(36).slice(2, 7)}`); const [size, setSize] = useState({ w: 0, h: 0 }); - const [hover, setHover] = useState(null); + const [hover, setHover] = useState<{ label: string; name: string } | null>(null); const [mouse, setMouse] = useState({ x: 0, y: 0 }); + // Always render the container div so ResizeObserver fires on mount useEffect(() => { const el = containerRef.current; if (!el) return; const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); @@ -509,44 +552,130 @@ function BarChart({ readings, deviceNames, colors, visible, metric }: { return () => ro.disconnect(); }, []); - const deviceVals = useMemo(() => { - const map = new Map(); - for (const name of deviceNames) { - const pts = readings - .map(r => { const d = r.devices.find(x => x.name === name); return d ? { ts: new Date(r.ts).getTime(), w: d.watts } : null; }) - .filter(Boolean).sort((a, b) => a!.ts - b!.ts) as { ts: number; w: number }[]; - let wh = 0; - for (let i = 1; i < pts.length; i++) { - wh += (pts[i].w + pts[i - 1].w) / 2 * (pts[i].ts - pts[i - 1].ts) / 3_600_000; + const groups = useMemo((): Array<{ label: string; bars: Array<{ name: string; val: number }> }> => { + const vis = deviceNames.filter(n => visible.has(n)); + + if (xAxis === "device") { + const map = new Map(); + for (const name of deviceNames) { + const pts = readings + .map(r => { const d = r.devices.find(x => x.name === name); return d ? { ts: new Date(r.ts).getTime(), w: d.watts } : null; }) + .filter(Boolean).sort((a, b) => a!.ts - b!.ts) as { ts: number; w: number }[]; + let wh = 0; + for (let i = 1; i < pts.length; i++) { + wh += (pts[i].w + pts[i - 1].w) / 2 * (pts[i].ts - pts[i - 1].ts) / 3_600_000; + } + const avgW = pts.length ? pts.reduce((s, p) => s + p.w, 0) / pts.length : 0; + map.set(name, metric === "watts" ? avgW : metric === "energy" ? wh : wh / 1000 * COST_PER_KWH); } - const avgW = pts.length ? pts.reduce((s, p) => s + p.w, 0) / pts.length : 0; - map.set(name, metric === "watts" ? avgW : metric === "energy" ? wh : wh / 1000 * COST_PER_KWH); + return vis.map(name => ({ label: name, bars: [{ name, val: map.get(name) ?? 0 }] })); } - return map; - }, [readings, deviceNames, metric]); + + // Cyclical grouping helper: group by a repeating key (hour 0-23, dow 0-6, etc.) + const makeCyclical = (keyFn: (ts: string) => number, allLabels: string[], keyOffset = 0) => { + const buckets = new Map>(); + for (const r of readings) { + const key = keyFn(r.ts); + if (!buckets.has(key)) buckets.set(key, new Map()); + for (const d of r.devices) { + if (!visible.has(d.name)) continue; + if (!buckets.get(key)!.has(d.name)) buckets.get(key)!.set(d.name, []); + buckets.get(key)!.get(d.name)!.push(d.watts); + } + } + return allLabels + .map((label, i) => ({ + label, + bars: vis.map(name => { + const vals = buckets.get(i + keyOffset)?.get(name) ?? []; + return { name, val: vals.length ? vals.reduce((s, v) => s + v, 0) / vals.length : 0 }; + }), + })) + .filter(g => g.bars.some(b => b.val > 0)); + }; + + // Absolute grouping helper: group by a computed key, sort by time + const makeAbsolute = (keyFn: (ts: string) => number, labelFn: (key: number) => string) => { + const buckets = new Map>(); + for (const r of readings) { + const key = keyFn(r.ts); + if (!buckets.has(key)) buckets.set(key, new Map()); + for (const d of r.devices) { + if (!visible.has(d.name)) continue; + if (!buckets.get(key)!.has(d.name)) buckets.get(key)!.set(d.name, []); + buckets.get(key)!.get(d.name)!.push(d.watts); + } + } + return [...buckets.entries()] + .sort(([a], [b]) => a - b) + .map(([key, devMap]) => ({ + label: labelFn(key), + bars: vis.map(name => { + const vals = devMap.get(name) ?? []; + return { name, val: vals.length ? vals.reduce((s, v) => s + v, 0) / vals.length : 0 }; + }), + })) + .filter(g => g.bars.some(b => b.val > 0)); + }; + + if (xAxis === "hour-of-day") return makeCyclical(ts => new Date(ts).getHours(), BAR_X_HOUR_LABELS); + if (xAxis === "day-of-week") return makeCyclical(ts => new Date(ts).getDay(), BAR_X_DOW_LABELS); + if (xAxis === "day-of-month") return makeCyclical(ts => new Date(ts).getDate(), BAR_X_DOM_LABELS, 1); + if (xAxis === "month-of-year") return makeCyclical(ts => new Date(ts).getMonth(), BAR_X_MONTH_LABELS); + if (xAxis === "year") return makeAbsolute(ts => new Date(ts).getFullYear(), key => String(key)); + + // Custom: absolute bucket of N units + const bucketMs = Math.max(60_000, xAxisCustomN * BAR_X_UNIT_MS[xAxisCustomUnit]); + return makeAbsolute( + ts => Math.floor(new Date(ts).getTime() / bucketMs) * bucketMs, + key => { + const d = new Date(key); + if (xAxisCustomUnit === "minute" || xAxisCustomUnit === "hour") + return d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + if (xAxisCustomUnit === "day" || xAxisCustomUnit === "week") + return d.toLocaleDateString([], { month: "short", day: "numeric" }); + return d.toLocaleDateString([], { month: "short", year: "2-digit" }); + } + ); + }, [readings, deviceNames, metric, xAxis, xAxisCustomN, xAxisCustomUnit, visible]); const visDev = deviceNames.filter(n => visible.has(n)); - if (visDev.length === 0 || readings.length === 0) return
No data
; + const noData = visDev.length === 0 || readings.length === 0; + const effectiveMetric: Metric = xAxis !== "device" ? "watts" : metric; const { w, h } = size; const iW = Math.max(0, w - BM.left - BM.right); const iH = Math.max(0, h - BM.top - BM.bottom); - const maxVal = Math.max(...visDev.map(n => deviceVals.get(n) ?? 0), 0.001); + + const allVals = groups.flatMap(g => g.bars.map(b => b.val)); + const maxVal = Math.max(...allVals, 0.001); const yTicks = computeYTicks(maxVal); const yMax = yTicks[yTicks.length - 1]; const yS = (v: number) => iH - (v / Math.max(yMax, 0.001)) * iH; - const gap = Math.max(6, iW * 0.1 / visDev.length); - const barW = Math.max(6, (iW - gap * (visDev.length + 1)) / visDev.length); + + const numGroups = Math.max(groups.length, 1); + const barsPerGroup = groups[0]?.bars.length ?? Math.max(visDev.length, 1); + const groupW = iW / numGroups; + const groupPad = Math.max(4, groupW * 0.15); + const barSlot = (groupW - groupPad) / Math.max(barsPerGroup, 1); + const barGap = Math.max(1, barSlot * 0.1); + const barW = Math.max(4, barSlot - barGap); + + const hoverVal = hover + ? groups.find(g => g.label === hover.label)?.bars.find(b => b.name === hover.name)?.val ?? 0 + : 0; return (
- {w > 0 && h > 0 && ( + {noData ? ( +
No data
+ ) : w > 0 && h > 0 ? ( {visDev.map(n => { const c = colors.get(n) ?? "#888"; return ( - + @@ -557,34 +686,49 @@ function BarChart({ readings, deviceNames, colors, visible, metric }: { {yTicks.map(v => ( - {fmtMetricTick(v, metric)} + {fmtMetricTick(v, effectiveMetric)} ))} - {visDev.map((n, i) => { - const val = deviceVals.get(n) ?? 0; - const x = gap + i * (barW + gap); + {groups.map((g, gi) => { + const gx = gi * groupW + groupPad / 2; + const groupCenter = gi * groupW + groupW / 2; return ( - { setHover(n); setMouse({ x: e.clientX, y: e.clientY }); }} - onMouseMove={e => setMouse({ x: e.clientX, y: e.clientY })} - onMouseLeave={() => setHover(null)} - style={{ cursor: "default" }} - > - - {n} + + {g.bars.map((bar, bi) => { + const bx = gx + bi * barSlot; + const bh = Math.max(0, iH - yS(bar.val)); + const isHovered = hover?.label === g.label && hover?.name === bar.name; + return ( + { setHover({ label: g.label, name: bar.name }); setMouse({ x: e.clientX, y: e.clientY }); }} + onMouseMove={e => setMouse({ x: e.clientX, y: e.clientY })} + onMouseLeave={() => setHover(null)} + style={{ cursor: "default" }} + > + + + ); + })} + {g.label} ); })} - )} + ) : null} {hover !== null && typeof document !== "undefined" && createPortal( - +
- - {hover} - {fmtMetricVal(deviceVals.get(hover) ?? 0, metric)} + + {hover.name} + {fmtMetricVal(hoverVal, effectiveMetric)}
, document.body @@ -750,11 +894,28 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou const [metric, setMetric] = useState("watts"); const [groupBy, setGroupBy] = useState("auto"); const [candleInterval, setCandleInterval] = useState("auto"); + const [barXAxis, setBarXAxis] = useState("device"); + const [barXAxisCustomN, setBarXAxisCustomN] = useState(1); + const [barXAxisCustomUnit, setBarXAxisCustomUnit] = useState("hour"); const [flyout, setFlyout] = useState<(FlyoutPos & { id: FlyoutId }) | null>(null); const hours = Math.max(1, parseInt(hoursInput, 10) || 24); const matchedPreset = PRESETS.find(p => p.h === hours); + const barXMinHours = chartType === "bar" ? getBarXMinHours(barXAxis, barXAxisCustomUnit) : 1; + const filteredPresets = PRESETS.filter(p => p.h >= barXMinHours); + + // Auto-bump time range when X axis changes to require more data + useEffect(() => { + if (chartType !== "bar") return; + const min = getBarXMinHours(barXAxis, barXAxisCustomUnit); + const currentH = Math.max(1, parseInt(hoursInput, 10) || 24); + if (currentH < min) { + const firstValid = PRESETS.find(p => p.h >= min); + if (firstValid) setHoursInput(String(firstValid.h)); + } + }, [barXAxis, barXAxisCustomUnit, chartType]); + const openFlyout = useCallback((id: FlyoutId, rect: DOMRect, el: HTMLButtonElement) => { if (flyout?.id === id) { setFlyout(null); return; } const alignRight = rect.left > window.innerWidth * 0.55; @@ -850,11 +1011,14 @@ export default function AnalyticsPanel({ chartType, readOnly = false, defaultHou }; // Derived labels for toolbar buttons - const metricLabel = METRICS.find(m => m.id === metric)?.label ?? "Power"; - const rangeLabel = matchedPreset ? matchedPreset.label : `${hours}h`; - const groupLabel = GROUP_BY_OPTIONS.find(g => g.id === groupBy)?.label ?? "Auto"; - const intervalLabel = CANDLE_INTERVALS.find(c => c.id === candleInterval)?.label ?? "Auto"; - const devLabel = visible.size === deviceNames.length ? "All" : `${visible.size}`; + const metricLabel = METRICS.find(m => m.id === metric)?.label ?? "Power"; + const rangeLabel = matchedPreset ? matchedPreset.label : `${hours}h`; + const groupLabel = GROUP_BY_OPTIONS.find(g => g.id === groupBy)?.label ?? "Auto"; + const intervalLabel = CANDLE_INTERVALS.find(c => c.id === candleInterval)?.label ?? "Auto"; + const devLabel = visible.size === deviceNames.length ? "All" : `${visible.size}`; + const barXAxisLabel = barXAxis === "custom" + ? `Every ${barXAxisCustomN}${BAR_X_UNIT_SHORT[barXAxisCustomUnit]}` + : BAR_X_AXIS_OPTIONS.find(o => o.id === barXAxis)?.label ?? "Device"; const refreshBtn = ( + + )} {activeFlyout?.id === "range" && (
- {PRESETS.map(({ label, h }) => ( + {filteredPresets.map(({ label, h }) => ( + + + ))}
+
{/* Summary cards */} diff --git a/app/components/serverMenu.tsx b/app/components/serverMenu.tsx index 6030b72..c070240 100644 --- a/app/components/serverMenu.tsx +++ b/app/components/serverMenu.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import HelpTooltip from "./HelpTooltip"; const SERVICES = [ "syncthing", @@ -140,12 +141,14 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) { Manage services
- + + +
{/* Services */} @@ -165,25 +168,28 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) {
{["start", "stop", "restart"].map((action) => ( - + + + ))} - + + +
))} @@ -202,12 +208,14 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) { Immediately restarts the machine

- + + +
@@ -219,12 +227,14 @@ export default function ControlPanel({ onClose }: { onClose: () => void }) { Powers off the machine

- + + + {/* Toast */} diff --git a/app/components/windows/WindowPane.tsx b/app/components/windows/WindowPane.tsx index 2bb57d6..ed40fc0 100644 --- a/app/components/windows/WindowPane.tsx +++ b/app/components/windows/WindowPane.tsx @@ -6,6 +6,7 @@ import { LeafNode, PanelId, PANEL_LABELS, PANEL_SECTIONS } from "./types"; import { IconX, IconLayoutColumns, IconLayoutRows, IconRefresh, } from "@tabler/icons-react"; +import HelpTooltip from "../HelpTooltip"; const DashboardPanel = lazy(() => import("../panels/DashboardPanel")); const AnalyticsPanel = lazy(() => import("../panels/AnalyticsPanel")); @@ -189,38 +190,46 @@ function WindowControls({ onMouseLeave={() => setPillHovered(false)} > {canClose && ( - + + + )} - - - + + + + + + + + + {menu && ( diff --git a/app/enroll/page.tsx b/app/enroll/page.tsx index 1f7f914..f3f6548 100644 --- a/app/enroll/page.tsx +++ b/app/enroll/page.tsx @@ -1,5 +1,6 @@ "use client"; import { useState } from "react"; +import HelpTooltip from "../components/HelpTooltip"; function b64uToBuf(b64u: string): ArrayBuffer { const b64 = b64u.replace(/-/g, "+").replace(/_/g, "/"); @@ -187,6 +188,7 @@ export default function EnrollPage() { )} {/* Submit */} + + + + )} @@ -390,6 +393,7 @@ export default function UsersPage() { )} + + ))} @@ -420,12 +425,14 @@ export default function UsersPage() { {enrollStatus === "done" ? (
{enrollMessage} - + + +
) : (
@@ -454,6 +461,7 @@ export default function UsersPage() {

{enrollMessage}

)} +