231 lines
6.8 KiB
TypeScript
231 lines
6.8 KiB
TypeScript
/**
|
|
* 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<Period, string> = {
|
|
"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 (
|
|
<div {...stylex.props(styles.toolbar)}>
|
|
<div {...stylex.props(styles.control)}>
|
|
<Select
|
|
aria-label="Device"
|
|
variant="toolbar"
|
|
options={devices.options}
|
|
value={scope.client ?? ALL_DEVICES}
|
|
onChange={(value) => onChange({ ...scope, client: value === ALL_DEVICES ? undefined : value })}
|
|
/>
|
|
{devices.failed && (
|
|
<p role="alert" {...stylex.props(styles.devicesFailed)}>
|
|
Device list unavailable.{" "}
|
|
<button type="button" onClick={devices.retry} {...stylex.props(styles.retry, shared.focusRing)}>
|
|
Retry
|
|
</button>
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div {...stylex.props(styles.control)}>
|
|
<Select
|
|
aria-label="Period"
|
|
variant="toolbar"
|
|
options={PERIOD_OPTIONS}
|
|
value={scope.period}
|
|
onChange={(value) => onChange({ ...scope, period: value as Period })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function OverviewLoading() {
|
|
return (
|
|
<p role="status" {...stylex.props(styles.loading, shared.pulse)}>
|
|
Loading…
|
|
</p>
|
|
);
|
|
}
|
|
|
|
export function OverviewFrame({
|
|
scope,
|
|
onChange,
|
|
children,
|
|
}: {
|
|
scope: OverviewScope;
|
|
onChange: (next: OverviewScope) => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div {...stylex.props(styles.page)}>
|
|
<div {...stylex.props(styles.headingRow)}>
|
|
<h1 {...stylex.props(styles.heading)}>Overview</h1>
|
|
<OverviewToolbar scope={scope} onChange={onChange} />
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<OverviewFrame scope={scope} onChange={setScope}>
|
|
<OverviewLoading />
|
|
</OverviewFrame>
|
|
);
|
|
}
|