diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index c9d59df..0e2f58e 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -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(null); const [held, setHeld] = useState(); const toggleHeard = () => { @@ -130,7 +130,7 @@ export function AtlasCard({ ); return ( - + {query.isPending &&

{t("common.loading")}

} {query.isError && isNotFound(query.error) && (

{t("nodeDetail.notFound")}

@@ -144,9 +144,10 @@ export function AtlasCard({

)} {node && !heardOpen && ( -
-
+
+
{t("atlas.meanHops")} {average(sample.hops)} - {t("nodeDetail.neighborsCount", { count: node.knownNeighborCount })} - + + {t("nodeDetail.neighborsCount", { count: node.knownNeighborCount })}{" "} + +
)} {node && heardOpen && ( -
+
{!latest ? (

{t("atlas.noReports")}

) : heard.isPending ? ( diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx index b6e6271..f6e152c 100644 --- a/src/features/atlas/MyAtlasPage.tsx +++ b/src/features/atlas/MyAtlasPage.tsx @@ -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(null); @@ -83,39 +86,45 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti return (
-
-
- {t("atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })} - - {t(storageFailed ? "atlas.storageFailed" : "atlas.localOnly")} - -
- update((value) => ({ ...value, range: range as AtlasRange }))} - ariaLabel={t("atlas.range")} +
+ + {!isMobile && } + + + + {t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })} + + + {storageFailed && {t("atlas.storageFailed")}} + {!isMobile && {t("atlas.range")}} + update((value) => ({ ...value, range: range as AtlasRange }))} + ariaLabel={t("atlas.range")} + /> +
- } - > - - {hint &&

{t(hint, { min: SEARCH_MIN })}

} - {ready && ( -
- {results.isPending ? ( + {typed.length > 0 && ( + +
+ {hint ? ( +

{t(hint, { min: SEARCH_MIN })}

+ ) : results.isPending ? (

{t("common.loading")}

) : results.isError ? (

@@ -134,7 +143,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti return (

  • {label} @@ -157,13 +166,13 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti })} )} - {results.data?.hasMore && ( + {results.data?.hasMore && !hint && (

    {t("atlas.refine", { limit: SEARCH_LIMIT })}

    )} - {full &&

    {t("atlas.limitReached", { limit: ATLAS_LIMIT })}

    } + {full && !hint &&

    {t("atlas.limitReached", { limit: ATLAS_LIMIT })}

    }
  • - )} -
    + + )} {saved.nodes.length === 0 ? ( ) : ( diff --git a/src/features/observers/ObserverSidebar.tsx b/src/features/observers/ObserverSidebar.tsx index a7de7e5..2bdd4d7 100644 --- a/src/features/observers/ObserverSidebar.tsx +++ b/src/features/observers/ObserverSidebar.tsx @@ -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 = ; + 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 (
    -
    -
    {t("tabs.Observers")}
    - onSortChange(v as ObserverDirectorySort)} - options={[{ value: "traffic", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} /> +
    +
    +
    {t("tabs.Observers")}
    + {windowEnd != null && } +
    +
    + {onRefresh && !unsupported && } + onSortChange(v as ObserverDirectorySort)} + options={[{ value: "traffic", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} /> +
    - {onRefresh && !unsupported && } - {coverage && coverage.status !== "complete" &&

    - {t(maxObservationCount != null ? "observerPage.countsPartial" - : effectiveSort === "name" ? "observerPage.countsUnavailableName" : "observerPage.countsUnavailable")} + {partial && maxObservationCount == null &&

    + {t(effectiveSort === "name" ? "observerPage.countsUnavailableName" : "observerPage.countsUnavailable")}

    } - {windowEnd != null &&
    -

    {t("observerPage.directoryPeriod")}

    -

    {t("observerPage.countsThrough", { time: formatUtc(windowEnd) })}

    -
    } {unsupported ?

    {t("observerPage.upgradeRequired")}

    : isPending ?
    {t("common.loading")}
    diff --git a/src/features/stats/cards.tsx b/src/features/stats/cards.tsx index 5655e36..7b14d1a 100644 --- a/src/features/stats/cards.tsx +++ b/src/features/stats/cards.tsx @@ -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 ( -
    +
    {label} {sublabel && {sublabel}}
    -
    {value}
    - {presence ? : spark ? : sparkSpacer} +
    {value}
    +
    + {presence ? : spark ? : sparkSpacer} +
    ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 84263a1..d9d97e9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 38a3152..0b03911 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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.", diff --git a/tests/features/atlas/AtlasCard.test.tsx b/tests/features/atlas/AtlasCard.test.tsx index 1b78c22..9711af7 100644 --- a/tests/features/atlas/AtlasCard.test.tsx +++ b/tests/features/atlas/AtlasCard.test.tsx @@ -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" })); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx index ff7a855..6496a9b 100644 --- a/tests/features/atlas/MyAtlasPage.test.tsx +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -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(); diff --git a/tests/features/observers/ObserverSidebar.test.tsx b/tests/features/observers/ObserverSidebar.test.tsx index a7ebe22..91ce445 100644 --- a/tests/features/observers/ObserverSidebar.test.tsx +++ b/tests/features/observers/ObserverSidebar.test.tsx @@ -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(); + 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"); }); diff --git a/tests/features/stats/cards.test.tsx b/tests/features/stats/cards.test.tsx index 831550c..cf5be95 100644 --- a/tests/features/stats/cards.test.tsx +++ b/tests/features/stats/cards.test.tsx @@ -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(); + 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(); const dot = container.querySelector("[data-peak]")!;