/** * The part of Overview that does not wait for anything: the heading, the * toolbar with the device and period selectors, and the pulsing body the page * shows while the window is in flight. * * It lives apart from `OverviewPage` so the route's pending component can render * the identical surface while the page chunk loads. Importing the page itself * would pull the charts into the main bundle, and a second hand-written copy of * the frame would drift. Nothing here imports a chart. */ import * as stylex from "@stylexjs/stylex"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { clientLabel, useClientNames } from "@/features/clients/clientNames"; import { clientsQuery } from "@/lib/queries"; import type { Period } from "@/lib/types"; import Select, { type SelectOption } from "@/ui/Select"; import { styles as shared } from "@/ui/styles"; import { colors } from "@/ui/tokens.stylex"; import { DEFAULT_PERIOD, PERIODS } from "./period"; const NARROW = "@media (max-width: 800px)"; const PERIOD_LABELS: Record = { "1h": "Last hour", "24h": "Last 24 hours", "7d": "Last 7 days", "30d": "Last 30 days", }; const PERIOD_OPTIONS: SelectOption[] = PERIODS.map((period) => ({ value: period, label: PERIOD_LABELS[period] })); /** The Select's key for the whole household; a client scope is the address itself. */ const ALL_DEVICES = ""; const styles = stylex.create({ page: { display: "flex", flexDirection: "column", gap: "1.25rem", }, headingRow: { display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: "0.75rem", }, heading: { margin: 0, fontSize: "1.5rem", lineHeight: "2rem", fontWeight: 650, letterSpacing: "-0.015em", textWrap: "balance", }, /** Device on the left, period on the right; on a phone the pair takes the whole row. */ toolbar: { display: "flex", gap: "0.5rem", flexBasis: { default: null, [NARROW]: "100%" }, }, control: { minWidth: { default: "11rem", [NARROW]: 0 }, flex: { default: null, [NARROW]: 1 }, }, /** * The height approximates the filled overview — stat tiles, two 240px charts * and the row of cards under them — so that the page does not jump when the * window lands. That is where the number comes from. */ loading: { minHeight: "48rem", fontSize: "0.875rem", lineHeight: "1.25rem", color: colors.textMuted, }, }); export interface OverviewScope { period: Period; client: string | undefined; } /** * The devices the reader can scope to: the whole household first, then every * registered client under its name. A scope the URL carries that the list has * never seen is still offered, as its address, so the control shows the scope * the page is actually under rather than silently claiming the household. */ function useDeviceOptions(client: string | undefined): SelectOption[] { const clients = useQuery(clientsQuery()); const names = useClientNames(); const known = (clients.data ?? []).map((one) => ({ value: one.ip, label: clientLabel(one.ip, names)?.text ?? one.ip, })); const options = [{ value: ALL_DEVICES, label: "All devices" }, ...known]; if (client !== undefined && !known.some((option) => option.value === client)) { options.push({ value: client, label: client }); } return options; } export function OverviewToolbar({ scope, onChange, }: { scope: OverviewScope; onChange: (next: OverviewScope) => void; }) { const devices = useDeviceOptions(scope.client); return (
onChange({ ...scope, period: value as Period })} />
); } export function OverviewLoading() { return (

Loading…

); } export function OverviewFrame({ scope, onChange, children, }: { scope: OverviewScope; onChange: (next: OverviewScope) => void; children: React.ReactNode; }) { return (

Overview

{children}
); } /** * The scope the URL names, and the navigation that rewrites it. The default * period and the household scope are the absence of a parameter, so a link to * the plain page stays `/overview` rather than growing `?period=24h`. */ export function useOverviewScope(): [OverviewScope, (next: OverviewScope) => void] { const search = useSearch({ from: "/shell/overview" }); const navigate = useNavigate({ from: "/overview" }); const scope = { period: search.period ?? DEFAULT_PERIOD, client: search.client }; const setScope = (next: OverviewScope) => void navigate({ search: (prev) => ({ ...prev, period: next.period === DEFAULT_PERIOD ? undefined : next.period, client: next.client, }), }); return [scope, setScope]; } /** * The route's pending surface. The toolbar stays live because it only writes * the search parameters, which the route already re-reads on its own. */ export function OverviewPending() { const [scope, setScope] = useOverviewScope(); return ( ); }