/** * 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 { useCallback } from "react"; 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", // Without this the row's minimum is both labels at full length, and a // long device name pushes the period picker past the viewport edge. minWidth: 0, flexBasis: { default: null, [NARROW]: "100%" }, }, control: { minWidth: { default: "11rem", [NARROW]: 0 }, maxWidth: { default: "20rem", [NARROW]: "none" }, flex: { default: null, [NARROW]: 1 }, }, /** Under the Device control: the list behind it did not load, so the control offers the household only. */ devicesFailed: { margin: 0, marginTop: "0.25rem", fontSize: "0.75rem", lineHeight: "1rem", color: colors.textMuted, }, retry: { padding: 0, borderWidth: 0, backgroundColor: "transparent", font: "inherit", color: colors.primaryOnSurface, cursor: "pointer", }, /** * 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. A * list that failed to load is said so under the control, with a retry: the * household-only list is a failure, not the answer. */ function useDeviceOptions(client: string | undefined) { 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 }); } const { refetch } = clients; const retry = useCallback(() => void refetch(), [refetch]); return { options, failed: clients.isError, retry }; } 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 ( ); }