Atlas: one-row phone toolbar, uniform card heights; Observers notes in a tip

This commit is contained in:
MrAlders0n
2026-10-13 06:11:58 -04:00
parent a2fc5362cf
commit 7400d2abac
10 changed files with 148 additions and 60 deletions
+13 -7
View File
@@ -74,7 +74,7 @@ export function AtlasCard({
const hours = sample.counts.length;
const activeHours = sample.present.filter(Boolean).length;
// The list view holds the tiles' height so flipping never resizes the card.
// The list view is fixed to the tiles' height and scrolls inside it, so flipping never resizes the card.
const body = useRef<HTMLDivElement>(null);
const [held, setHeld] = useState<number>();
const toggleHeard = () => {
@@ -130,7 +130,7 @@ export function AtlasCard({
);
return (
<Card title={title} right={actions} wrap>
<Card title={title} right={actions} wrap className="flex h-full flex-col">
{query.isPending && <p role="status" className="text-sm text-text-muted">{t("common.loading")}</p>}
{query.isError && isNotFound(query.error) && (
<p role="alert" className="text-sm text-text-muted">{t("nodeDetail.notFound")}</p>
@@ -144,9 +144,10 @@ export function AtlasCard({
</p>
)}
{node && !heardOpen && (
<div ref={body} className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div ref={body} className="flex flex-1 flex-col gap-3">
<div className="grid flex-1 grid-cols-2 gap-3">
<StatCard
compact
label={t("signal.receptions")}
value={formatCount(sample.reports.length)}
accent={c.series[0]!}
@@ -155,18 +156,21 @@ export function AtlasCard({
sublabel={capped ? t("atlas.sampleCapped", { limit: REPORT_LIMIT }) : t(`atlas.ranges.${range}`)}
/>
<StatCard
compact
label={t("signal.meanSnr")}
value={average(sample.snr, "dB")}
accent={c.series[2]!}
spark={sample.snrHourly}
/>
<StatCard
compact
label={t("signal.meanRssi")}
value={average(sample.rssi, "dBm")}
accent={c.series[1]!}
spark={sample.rssiHourly}
/>
<StatCard
compact
label={t("signal.hoursWithRecords")}
value={`${activeHours}/${hours}`}
accent={c.series[3]!}
@@ -182,13 +186,15 @@ export function AtlasCard({
<span>
{t("atlas.meanHops")} {average(sample.hops)}
</span>
<span>{t("nodeDetail.neighborsCount", { count: node.knownNeighborCount })}</span>
<InfoTip text={[t("atlas.sampleHelp", { limit: REPORT_LIMIT }), capped && t("atlas.cappedHelp")]} />
<span className="whitespace-nowrap">
{t("nodeDetail.neighborsCount", { count: node.knownNeighborCount })}{" "}
<InfoTip text={[t("atlas.sampleHelp", { limit: REPORT_LIMIT }), capped && t("atlas.cappedHelp")]} />
</span>
</div>
</div>
)}
{node && heardOpen && (
<div className="flex flex-col font-mono text-xs" style={{ minHeight: held }}>
<div className="flex flex-col overflow-hidden font-mono text-xs" style={{ height: held }}>
{!latest ? (
<p className="text-text-muted">{t("atlas.noReports")}</p>
) : heard.isPending ? (
+43 -34
View File
@@ -8,6 +8,7 @@ import { EmptyState } from "../../components/EmptyState";
import { InfoTip } from "../../components/InfoTip";
import { SearchBar, type SearchFieldOption } from "../../components/SearchBar";
import { Tooltip } from "../../components/Tooltip";
import { useIsMobile } from "../../hooks/useMediaQuery";
import { formatHex } from "../../lib/formatters";
import { nodeSearchParams } from "../nodes/node-search";
import type { NodeSummary } from "../nodes/types";
@@ -26,6 +27,8 @@ const SEARCH_MIN = 2;
export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { active?: boolean }) {
const { t } = useTranslation();
const { saved, update, storageFailed } = useSavedAtlas();
// Same single-row bar the other tabs use on a phone: the search shrinks, nothing wraps.
const isMobile = useIsMobile();
const [search, setSearch] = useState("");
const [field, setField] = useState("name");
const [heardKey, setHeardKey] = useState<string | null>(null);
@@ -83,39 +86,45 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
return (
<section className="flex min-h-0 min-w-0 flex-1 flex-col" aria-label={t("tabs.MyAtlas")}>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border bg-bg-surface px-4 py-2.5">
<div className="flex min-w-0 flex-wrap items-center gap-3 font-mono text-[11px] text-text-muted">
<span>{t("atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })}</span>
<span role="status" className={storageFailed ? "text-warn" : undefined}>
{t(storageFailed ? "atlas.storageFailed" : "atlas.localOnly")}
</span>
</div>
<Segmented
className="shrink-0"
options={rangeOptions}
value={saved.range}
onChange={(range) => update((value) => ({ ...value, range: range as AtlasRange }))}
ariaLabel={t("atlas.range")}
<div
className={`flex shrink-0 items-center gap-1.5 gap-y-1.5 border-b border-border-subtle bg-bg-base px-4 py-2 ${isMobile ? "" : "flex-wrap"}`}
role="toolbar"
aria-label={t("atlas.addNodes")}
>
<SearchBar
value={search}
onChange={setSearch}
fields={fields}
field={field}
onFieldChange={changeField}
inputLabel={t("atlas.search")}
/>
{!isMobile && <InfoTip text={t("atlas.searchHelp", { min: SEARCH_MIN, limit: ATLAS_LIMIT })} />}
<span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[11px] text-text-muted">
<Tooltip label={t("atlas.localOnly")}>
<span className={`rounded-sm border px-2.5 py-1 ${full ? "border-warn/15 bg-warn/7 text-warn" : "border-border bg-bg-surface"}`}>
{t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })}
</span>
</Tooltip>
{storageFailed && <span role="status" className="text-warn">{t("atlas.storageFailed")}</span>}
{!isMobile && <span>{t("atlas.range")}</span>}
<Segmented
size="xs"
options={rangeOptions}
value={saved.range}
onChange={(range) => update((value) => ({ ...value, range: range as AtlasRange }))}
ariaLabel={t("atlas.range")}
/>
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto flex w-full min-w-0 max-w-[1600px] flex-col gap-3.5 p-4">
<Card
title={t("atlas.addNodes")}
right={<InfoTip text={t("atlas.searchHelp", { min: SEARCH_MIN, limit: ATLAS_LIMIT })} />}
>
<SearchBar
value={search}
onChange={setSearch}
fields={fields}
field={field}
onFieldChange={changeField}
inputLabel={t("atlas.search")}
/>
{hint && <p className="mt-2 font-mono text-xs text-text-muted">{t(hint, { min: SEARCH_MIN })}</p>}
{ready && (
<div className="mt-2 font-mono text-xs">
{results.isPending ? (
{typed.length > 0 && (
<Card title={t("atlas.matches")}>
<div className="font-mono text-xs">
{hint ? (
<p className="text-text-muted">{t(hint, { min: SEARCH_MIN })}</p>
) : results.isPending ? (
<p role="status" className="text-text-muted">{t("common.loading")}</p>
) : results.isError ? (
<p role="alert" className="text-danger">
@@ -134,7 +143,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
return (
<li
key={node.publicKey}
className="flex items-center justify-between gap-2 border-t border-border-subtle py-1.5"
className="flex items-center justify-between gap-2 border-t border-border-subtle py-1.5 first:border-t-0"
>
<span className="flex min-w-0 items-center gap-2">
<span className={`truncate ${node.name ? "text-text-bright" : "italic text-text-dim"}`}>{label}</span>
@@ -157,13 +166,13 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
})}
</ul>
)}
{results.data?.hasMore && (
{results.data?.hasMore && !hint && (
<p className="mt-1 text-text-muted">{t("atlas.refine", { limit: SEARCH_LIMIT })}</p>
)}
{full && <p className="mt-1 text-warn">{t("atlas.limitReached", { limit: ATLAS_LIMIT })}</p>}
{full && !hint && <p className="mt-1 text-warn">{t("atlas.limitReached", { limit: ATLAS_LIMIT })}</p>}
</div>
)}
</Card>
</Card>
)}
{saved.nodes.length === 0 ? (
<EmptyState title={t("atlas.emptyTitle")} subtitle={t("atlas.emptyDescription", { limit: ATLAS_LIMIT })} />
) : (
+20 -13
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { InfoTip } from "../../components/InfoTip";
import { formatCount, formatUtc } from "../../lib/formatters";
import { SCROLL_BOTTOM_THRESHOLD_PX } from "../../lib/constants";
import { Segmented } from "../stats/Segmented";
@@ -35,24 +36,30 @@ export function ObserverSidebar({ observers, filtered, isPending, isError, unsup
}, [loadNearEnd, observers.length]);
const messageClass = "px-3 py-3 text-center font-mono text-[11px] text-text-dim";
const retry = <button type="button" onClick={onRetry} className={`${messageClass} w-full text-danger`}>{t("common.loadFailed")} · {t("observerPage.retry")}</button>;
const partial = coverage != null && coverage.status !== "complete";
// Incomplete coverage is a footnote in the tip; counts missing altogether changes the list, so that stays visible.
return (
<div className="flex min-h-0 w-full flex-col bg-bg-base md:rounded-lg md:border md:border-border md:bg-bg-surface md:p-3.5">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border-subtle px-4 py-2 md:mb-2.5 md:border-0 md:p-0">
<div className="font-mono text-[11px] font-semibold uppercase tracking-wider text-text-normal">{t("tabs.Observers")}</div>
<Segmented size="sm" ariaLabel={t("observerPage.sort")} value={sort} onChange={v => onSortChange(v as ObserverDirectorySort)}
options={[{ value: "traffic", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} />
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border-subtle px-4 py-2 md:mb-2.5 md:border-0 md:p-0">
<div className="flex items-center gap-1.5">
<div className="hidden font-mono text-[11px] font-semibold uppercase tracking-wider text-text-normal md:block">{t("tabs.Observers")}</div>
{windowEnd != null && <InfoTip text={[
partial && t("observerPage.countsPartial"),
t("observerPage.directoryPeriod"),
t("observerPage.countsThrough", { time: formatUtc(windowEnd) }),
]} />}
</div>
<div className="ml-auto flex items-center gap-2">
{onRefresh && !unsupported && <button type="button" onClick={onRefresh} disabled={isPending || isFetchingNextPage}
className="font-mono text-[10px] text-primary disabled:opacity-50">{t("observerPage.refresh")}</button>}
<Segmented size="sm" ariaLabel={t("observerPage.sort")} value={sort} onChange={v => onSortChange(v as ObserverDirectorySort)}
options={[{ value: "traffic", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} />
</div>
</div>
{onRefresh && !unsupported && <button type="button" onClick={onRefresh} disabled={isPending || isFetchingNextPage}
className="mb-2 self-end px-3 font-mono text-[10px] text-primary disabled:opacity-50">{t("observerPage.refresh")}</button>}
{coverage && coverage.status !== "complete" && <p className={messageClass}>
{t(maxObservationCount != null ? "observerPage.countsPartial"
: effectiveSort === "name" ? "observerPage.countsUnavailableName" : "observerPage.countsUnavailable")}
{partial && maxObservationCount == null && <p className={messageClass}>
{t(effectiveSort === "name" ? "observerPage.countsUnavailableName" : "observerPage.countsUnavailable")}
</p>}
{windowEnd != null && <div className="shrink-0 px-3 pb-2 font-mono text-[10px] text-text-dim">
<p>{t("observerPage.directoryPeriod")}</p>
<p>{t("observerPage.countsThrough", { time: formatUtc(windowEnd) })}</p>
</div>}
{unsupported ? <div className={messageClass}><p>{t("observerPage.upgradeRequired")}</p>
<button type="button" onClick={onRetry} className="mt-2 text-primary">{t("observerPage.retry")}</button></div>
: isPending ? <div className={messageClass}>{t("common.loading")}</div>
+7 -3
View File
@@ -90,6 +90,7 @@ export function StatCard({
markPeak,
presence,
sublabel,
compact,
}: {
label: string;
value: ReactNode;
@@ -98,15 +99,18 @@ export function StatCard({
markPeak?: boolean;
presence?: boolean[];
sublabel?: ReactNode;
compact?: boolean; // narrow tiles: value stays text-xl so "-77.2 dBm" never wraps
}) {
return (
<div className="rounded-lg border border-border bg-bg-surface px-3.5 py-3">
<div className="flex flex-col rounded-lg border border-border bg-bg-surface px-3.5 py-3">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
<span className="min-w-0 font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted">{label}</span>
{sublabel && <span className="min-w-0 max-w-full truncate font-mono text-[9px] text-text-dim">{sublabel}</span>}
</div>
<div className="mt-0.5 font-mono text-xl font-bold tabular-nums text-text-bright sm:text-2xl">{value}</div>
{presence ? <PresenceStrip hours={presence} color={accent} /> : spark ? <Sparkline values={spark} color={accent} markPeak={markPeak} /> : sparkSpacer}
<div className={`mt-0.5 font-mono text-xl font-bold tabular-nums text-text-bright ${compact ? "" : "sm:text-2xl"}`}>{value}</div>
<div className="mt-auto">
{presence ? <PresenceStrip hours={presence} color={accent} /> : spark ? <Sparkline values={spark} color={accent} markPeak={markPeak} /> : sparkSpacer}
</div>
</div>
);
}
+2
View File
@@ -958,12 +958,14 @@
},
"atlas": {
"addNodes": "Add nodes",
"matches": "Matches",
"search": "Find a node",
"searchHelp": "Searches every region by name or public-key prefix (hex, at least {{min}} characters). Up to {{limit}} nodes.",
"searchTooShort": "Type at least {{min}} characters.",
"searchNotHex": "A public key prefix is hex only (0-9, a-f).",
"savedCount_one": "{{count}} / {{limit}} saved",
"savedCount_other": "{{count}} / {{limit}} saved",
"savedCountShort": "{{count}}/{{limit}}",
"localOnly": "Saved in this browser",
"storageFailed": "Browser storage is unavailable; changes last only for this visit.",
"limitReached": "All {{limit}} slots are in use. Remove a card to add another.",
+2
View File
@@ -959,6 +959,7 @@
},
"atlas": {
"addNodes": "Ajouter des nœuds",
"matches": "Résultats",
"search": "Trouver un nœud",
"searchHelp": "Recherche dans toutes les régions par nom ou préfixe de clé publique (hexadécimal, au moins {{min}} caractères). Jusqu’à {{limit}} nœuds.",
"searchTooShort": "Saisissez au moins {{min}} caractères.",
@@ -966,6 +967,7 @@
"savedCount_one": "{{count}} / {{limit}} enregistré",
"savedCount_many": "{{count}} / {{limit}} enregistrés",
"savedCount_other": "{{count}} / {{limit}} enregistrés",
"savedCountShort": "{{count}}/{{limit}}",
"localOnly": "Enregistré dans ce navigateur",
"storageFailed": "Le stockage du navigateur est indisponible ; les modifications ne durent que cette visite.",
"limitReached": "Les {{limit}} emplacements sont utilisés. Retirez une fiche pour en ajouter une autre.",
+12 -1
View File
@@ -98,6 +98,16 @@ describe("AtlasCard", () => {
expect(actions.parentElement).toHaveClass("flex-wrap");
});
it("fills its grid row so neighbouring cards stay the same height whatever their titles wrap to", async () => {
mount();
await screen.findByRole("button", { name: "Renamed repeater" });
const tiles = screen.getByText("Observations").closest(".grid")!;
expect(tiles).toHaveClass("flex-1");
expect(tiles.parentElement).toHaveClass("flex-1");
expect(tiles.closest(".rounded-lg.border.p-3\\.5")).toHaveClass("flex", "h-full", "flex-col");
expect(screen.getByText("-106.0 dBm")).toHaveClass("text-xl");
});
it("labels an unnamed node by its ID prefix, dimmed, as the Nodes page does", async () => {
vi.mocked(api.getNode).mockResolvedValue({ ...node, name: "" });
mount({ pin: { ...pin, name: "" } });
@@ -144,7 +154,8 @@ describe("AtlasCard", () => {
expect(summary).toHaveTextContent(/ago$/);
expect(screen.getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByText("Mean SNR")).not.toBeInTheDocument();
expect(summary.parentElement).toHaveStyle({ minHeight: "240px" });
expect(summary.parentElement).toHaveStyle({ height: "240px" });
expect(summary.nextElementSibling).toHaveClass("overflow-y-auto");
expect(screen.getAllByRole("button", { name: "Home listener" })).toHaveLength(1);
expect(screen.getAllByText("YKF")[0]).toHaveClass("bg-primary/6");
fireEvent.click(screen.getByRole("button", { name: "Home listener" }));
+27
View File
@@ -65,6 +65,33 @@ describe("My Atlas", () => {
expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal));
});
it("keeps the search in a toolbar with the saved count chip beside the range picker, and lists matches only while typing", async () => {
save();
mount();
await screen.findByRole("button", { name: "Renamed repeater" });
const bar = screen.getByRole("toolbar", { name: "Add nodes" });
expect(within(bar).getByRole("textbox", { name: "Find a node" })).toBeInTheDocument();
expect(within(bar).getByText("1 / 12 saved")).toHaveClass("rounded-sm");
expect(within(bar).getByRole("button", { name: "3d" })).toBeInTheDocument();
expect(screen.queryByText("Matches")).not.toBeInTheDocument();
typeSearch("repeater");
expect(await screen.findByRole("button", { name: "Renamed repeater is saved" })).toBeInTheDocument();
expect(screen.getByText("Matches")).toBeInTheDocument();
});
it("keeps the toolbar to one row on a phone: short count chip, picker, no info tip", async () => {
vi.stubGlobal("matchMedia", (query: string) => ({ matches: /max-width/.test(query), media: query, addEventListener: () => {}, removeEventListener: () => {} }));
save();
mount();
await screen.findByRole("button", { name: "Renamed repeater" });
const bar = screen.getByRole("toolbar", { name: "Add nodes" });
expect(bar).not.toHaveClass("flex-wrap");
expect(within(bar).getByText("1/12")).toHaveClass("rounded-sm");
expect(within(bar).queryByText("1 / 12 saved")).not.toBeInTheDocument();
expect(within(bar).getByRole("button", { name: "3d" })).toBeInTheDocument();
expect(within(bar).queryByText(/More information/)).not.toBeInTheDocument();
});
it("shows the empty state and queries nothing with no pins", () => {
mount();
expect(screen.getByText("No nodes saved")).toBeInTheDocument();
@@ -93,6 +93,17 @@ it.each(["partial", "unavailable"] as const)("renders available traffic and zero
expect(rows[2].querySelector('[style]')).toHaveStyle({ width: "0%" });
expect(screen.queryByText(/counts unavailable/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Name order/)).not.toBeInTheDocument();
expect(screen.getByText(/coverage is incomplete/)).toBeInTheDocument();
expect(screen.getByText(/Available history, up to 7 days/)).toBeInTheDocument();
expect(screen.getByText(/More information: Counts use available history; coverage is incomplete\. Available history, up to 7 days Counts through 2026-10-0\d \d\d:00 UTC/)).toBeInTheDocument();
expect(screen.queryByText("Available history, up to 7 days")).not.toBeInTheDocument();
});
it("keeps the history notes in the info tip and the panel title off the phone layout", () => {
render(<ObserverSidebar {...props} onRefresh={vi.fn()} windowEnd={1791162000000} />);
expect(screen.getByText(/^More information: Available history, up to 7 days Counts through/)).toBeInTheDocument();
expect(screen.queryByText(/coverage is incomplete/)).not.toBeInTheDocument();
expect(screen.getByText("Observers")).toHaveClass("hidden", "md:block");
const controls = screen.getByRole("button", { name: "Refresh" }).parentElement!;
expect(controls).toContainElement(screen.getByRole("button", { name: "Name" }));
expect(controls).toHaveClass("ml-auto");
expect(controls.parentElement).toHaveClass("flex-wrap");
});
+9
View File
@@ -11,6 +11,15 @@ it("keeps the header on one line unless a caller asks for the right slot to wrap
expect(screen.getByRole("button", { name: "Act" }).parentElement).toHaveClass("flex-wrap");
});
it("keeps a compact tile's value small and its chart at the foot so stretched tiles stay uniform", () => {
const { container } = render(<StatCard label="Mean RSSI" value="-77.2 dBm" accent="red" spark={[1, 2]} compact />);
const tile = container.firstElementChild!;
expect(tile).toHaveClass("flex", "flex-col");
expect(screen.getByText("-77.2 dBm")).toHaveClass("text-xl");
expect(screen.getByText("-77.2 dBm")).not.toHaveClass("sm:text-2xl");
expect(container.querySelector("svg")!.parentElement).toHaveClass("mt-auto");
});
it("marks the peak hour on a sparkline when asked", () => {
const { container } = render(<StatCard label="Busiest" value="9" accent="red" spark={[1, 9, null, 4]} markPeak />);
const dot = container.querySelector("[data-peak]")!;