diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx index dddcf4c..55574ef 100644 --- a/src/features/stats/CompareObserversTab.tsx +++ b/src/features/stats/CompareObserversTab.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { InfoTip } from "../../components/InfoTip"; +import { SectionInfo } from "./SectionInfo"; import { getObserver, getObserverComparison } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; import { OBSERVER_UUID } from "../observers/observer-id"; @@ -125,7 +126,7 @@ export function CompareObserversTab() { ] : []; return (
-

{t("observerCompare.title")}

+ {supplied && !valid &&

{t("observerCompare.invalidLink")}

} params.get(key)).join("|")} initial={selection} onCompare={compare} /> diff --git a/src/features/stats/PathsTab.tsx b/src/features/stats/PathsTab.tsx index aaf0282..f8df110 100644 --- a/src/features/stats/PathsTab.tsx +++ b/src/features/stats/PathsTab.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { InfoTip } from "../../components/InfoTip"; +import { SectionInfo } from "./SectionInfo"; import { formatCount, formatUtc } from "../../lib/formatters"; import { Card, ChartCard, StatCard } from "./cards"; import { donutOption } from "./chartOptions"; @@ -35,10 +36,7 @@ export function PathsTab({ range }: { range: StatsRange }) { const largest = data?.pathLengths.at(-1)?.entries; return
-
-

{t("stats.tabs.paths")}

- -
+ {query.isError &&

{t("paths.error")}

}
diff --git a/src/features/stats/ScopesTab.tsx b/src/features/stats/ScopesTab.tsx index d150342..1a0b83f 100644 --- a/src/features/stats/ScopesTab.tsx +++ b/src/features/stats/ScopesTab.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { InfoTip } from "../../components/InfoTip"; +import { SectionInfo } from "./SectionInfo"; import { useScopes } from "./useStats"; import { useChartColors } from "./chartTheme"; import { Card, ChartCard, StatCard } from "./cards"; @@ -25,7 +26,7 @@ export function ScopesTab() { return (
-

{t("scopes.title")}

+ diff --git a/src/features/stats/SectionInfo.tsx b/src/features/stats/SectionInfo.tsx new file mode 100644 index 0000000..f65a951 --- /dev/null +++ b/src/features/stats/SectionInfo.tsx @@ -0,0 +1,10 @@ +import { useContext, type ComponentProps } from "react"; +import { createPortal } from "react-dom"; +import { InfoTip } from "../../components/InfoTip"; +import { SectionInfoSlot } from "./section-info-slot"; + +// Sits beside the section chooser in the analytics bar; a tab rendered outside it keeps the tip in place. +export function SectionInfo({ text }: ComponentProps) { + const slot = useContext(SectionInfoSlot); + return slot ? createPortal(, slot) : ; +} diff --git a/src/features/stats/SignalTab.tsx b/src/features/stats/SignalTab.tsx index 9118dfc..25477fe 100644 --- a/src/features/stats/SignalTab.tsx +++ b/src/features/stats/SignalTab.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { InfoTip } from "../../components/InfoTip"; +import { SectionInfo } from "./SectionInfo"; import { formatCount, formatUtc } from "../../lib/formatters"; import { Card, ChartCard, StatCard } from "./cards"; import { useChartColors } from "./chartTheme"; @@ -25,10 +26,7 @@ export function SignalTab({ range }: { range: StatsRange }) { return (
-
-

{t("signal.title")}

- -
+ {query.isError &&

{t("signal.error")}

}
diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 50871e3..9eff5c5 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -1,7 +1,8 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import type { WsManager } from "../../api/ws-manager"; import { StatsSubHeader } from "./StatsSubHeader"; +import { SectionInfoSlot } from "./section-info-slot"; import { MeshTab } from "./MeshTab"; import { TrafficTab } from "./TrafficTab"; import { SignalTab } from "./SignalTab"; @@ -33,6 +34,7 @@ export function StatsOverview({ wsManager, onViewNode }: StatsOverviewProps) { const tab = asTab(params.get("statsTab")); const range = asRange(params.get("range")); const legacyObserver = params.get("statsTab") === "observer"; + const [infoSlot, setInfoSlot] = useState(null); const patch = useCallback( (updates: Record) => { @@ -62,7 +64,8 @@ export function StatsOverview({ wsManager, onViewNode }: StatsOverviewProps) { return (
- + +
{tab === "mesh" && } {tab === "traffic" && } @@ -74,6 +77,7 @@ export function StatsOverview({ wsManager, onViewNode }: StatsOverviewProps) { {tab === "graph" && } {tab === "compare" && }
+
); } diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 083bfe3..ffef33a 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -97,15 +97,17 @@ interface Props { onTabChange: (tab: StatsTab) => void; range: StatsRange; onRangeChange: (range: StatsRange) => void; + infoSlot?: (element: HTMLElement | null) => void; } -export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props) { +export function StatsSubHeader({ tab, onTabChange, range, onRangeChange, infoSlot }: Props) { const { t } = useTranslation(); const isMobile = useIsMobile(); const tabOptions = TAB_OPTIONS.map((option) => ({ ...option, label: t(`stats.tabs.${option.value}`) })); const rangeOptions = RANGES.map((value) => ({ value, label: t(`stats.ranges.${value}`) })); return (
+
{/* pills don't scale on a phone as sections grow — swap to a compact dropdown there */} {isMobile ? (
)} + +
{/* Comparison has explicit dates; graph and clock drift have no rolling window. */} {tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && tab !== "scopes" && ( -
-

{t("stats.tabs.traffic")}

- -
+ {query.isError &&

{t("traffic.error")}

}
diff --git a/src/features/stats/section-info-slot.ts b/src/features/stats/section-info-slot.ts new file mode 100644 index 0000000..cd5a344 --- /dev/null +++ b/src/features/stats/section-info-slot.ts @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const SectionInfoSlot = createContext(null); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 40a002c..bbb0bcd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -110,9 +110,7 @@ } }, "signal": { - "title": "RF / Signal", "subtitle": "Signal strength of packets heard by observers.", - "refresh": "Refresh signal", "error": "Could not load signal data. Try refreshing or choosing a shorter time period.", "receptions": "Observations", "meanSnr": "Mean SNR", @@ -150,7 +148,6 @@ }, "paths": { "subtitle": "How received packets carry route hashes.", - "refresh": "Refresh paths", "error": "Could not load path data. Try refreshing or choosing a shorter time period.", "receptions": "Observations", "withHashPaths": "With hash paths", @@ -196,7 +193,6 @@ }, "traffic": { "subtitle": "When and where observers hear packets.", - "refresh": "Refresh traffic", "error": "Could not load traffic. Try refreshing.", "receptions": "Observations", "reportingIatas": "Active areas", @@ -226,7 +222,6 @@ "heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} observations" }, "scopes": { - "title": "Transport scopes", "subtitle": "Scope activity for the selected region.", "search": "Find a scope", "error": "Could not load scopes. Try refreshing.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2569a74..2f0e26e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -112,9 +112,7 @@ } }, "signal": { - "title": "RF / Signal", "subtitle": "Force du signal des paquets reçus par les observateurs.", - "refresh": "Actualiser le signal", "error": "Impossible de charger les données du signal. Réessayez ou choisissez une période plus courte.", "receptions": "Observations", "meanSnr": "SNR moyen", @@ -152,7 +150,6 @@ }, "paths": { "subtitle": "Les hachages de route portés par les paquets reçus.", - "refresh": "Actualiser les chemins", "error": "Impossible de charger les données des chemins. Réessayez ou choisissez une période plus courte.", "receptions": "Observations", "withHashPaths": "Avec chemins hachés", @@ -199,7 +196,6 @@ }, "traffic": { "subtitle": "Quand et où les observateurs reçoivent des paquets.", - "refresh": "Actualiser le trafic", "error": "Impossible de charger le trafic. Réessayez.", "receptions": "Observations", "reportingIatas": "Zones actives", @@ -230,7 +226,6 @@ "heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} observations" }, "scopes": { - "title": "Scopes de transport", "subtitle": "Activité des scopes pour la région sélectionnée.", "search": "Rechercher un scope", "error": "Impossible de charger les scopes. Réessayez.", diff --git a/tests/features/stats/CompareObserversTab.test.tsx b/tests/features/stats/CompareObserversTab.test.tsx index 013ba37..c86fdf5 100644 --- a/tests/features/stats/CompareObserversTab.test.tsx +++ b/tests/features/stats/CompareObserversTab.test.tsx @@ -143,7 +143,7 @@ describe("observer comparison", () => { mount(query); const table = await screen.findByRole("table", { name: "Comparaison des paquets flood" }); expect(within(table).getByRole("row", { name: /A seulement.*1.*25.0%/ })).toBeInTheDocument(); - expect(screen.getByText("Comparer les observateurs")).toBeInTheDocument(); + expect(screen.queryByText("Comparer les observateurs")).not.toBeInTheDocument(); expect(screen.getByText("A : Rooftop · B : Hilltop")).toBeInTheDocument(); expect(screen.getByText(/4 paquets flood/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Comparer" })).toBeInTheDocument(); diff --git a/tests/features/stats/PathsTab.test.tsx b/tests/features/stats/PathsTab.test.tsx index 5ce5d17..65f967a 100644 --- a/tests/features/stats/PathsTab.test.tsx +++ b/tests/features/stats/PathsTab.test.tsx @@ -16,7 +16,7 @@ beforeEach(() => { query.data = originalData; query.isPending = false; query.isP it("translates classification and exact tables while preserving counts and the requested window", async () => { await act(() => i18n.changeLanguage("fr")); render(); - expect(screen.getByRole("heading", { name: "Chemins et hachages" })).toBeInTheDocument(); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); expect(usePathStats).toHaveBeenCalledWith("7d"); expect(screen.getByText("7 j")).toBeInTheDocument(); expect(screen.getByText("66.7%")).toBeInTheDocument(); @@ -29,8 +29,6 @@ it("translates classification and exact tables while preserving counts and the r expect(screen.getByRole("table", { name: "Effectifs par largeur de hachage" })).toHaveTextContent("Octets par hachage"); expect(within(screen.getByRole("table", { name: "Effectifs par nombre d’entrées" })).getByRole("row", { name: "0 20" })).toBeInTheDocument(); expect(within(screen.getByRole("table", { name: "Effectifs horaires des chemins" })).getByRole("row", { name: "1970-01-01 00:00 100 20 30 10 20 10 10" })).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Actualiser les chemins" })); - expect(query.refetch).toHaveBeenCalledOnce(); }); it("redraws all chart labels on language change and keeps exact details open", async () => { @@ -58,8 +56,7 @@ it("uses only nonempty hash paths for multi-byte share, exposes categories and e const table = screen.getByRole("table", { name: "Path classification counts" }); expect(within(table).getByRole("row", { name: /Hash paths.*60.*60.0%/ })).toBeInTheDocument(); expect(within(table).getByRole("row", { name: /Empty.*20.*20.0%/ })).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Refresh paths" })); - expect(query.refetch).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: /Refresh/ })).not.toBeInTheDocument(); }); it.each(["isPending", "isPlaceholderData", "isError"] as const)("hides old values when %s", (state) => { query[state] = true; render(); diff --git a/tests/features/stats/ScopesTab.test.tsx b/tests/features/stats/ScopesTab.test.tsx index 8cb8636..b75bcc4 100644 --- a/tests/features/stats/ScopesTab.test.tsx +++ b/tests/features/stats/ScopesTab.test.tsx @@ -15,7 +15,7 @@ beforeEach(() => { vi.clearAllMocks(); query.data = originalData; query.isError it("translates scope counts and membership guidance without changing scope names or values", async () => { await act(() => i18n.changeLanguage("fr")); render(); - expect(screen.getByRole("heading", { name: "Scopes de transport" })).toBeInTheDocument(); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); const table = screen.getByRole("table", { name: "Effectifs des scopes" }); expect(within(table).getByRole("row", { name: /#west.*3.*2.*2/ })).toBeInTheDocument(); expect(within(table).getByRole("row", { name: /#east.*2.*2.*3/ })).toBeInTheDocument(); diff --git a/tests/features/stats/SectionInfo.test.tsx b/tests/features/stats/SectionInfo.test.tsx new file mode 100644 index 0000000..917e50f --- /dev/null +++ b/tests/features/stats/SectionInfo.test.tsx @@ -0,0 +1,23 @@ +import { expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { StatsOverview } from "../../../src/features/stats/StatsOverview"; +import { SectionInfo } from "../../../src/features/stats/SectionInfo"; +import type { WsManager } from "../../../src/api/ws-manager"; + +vi.mock("../../../src/features/stats/ScopesTab", async () => { + const { SectionInfo } = await import("../../../src/features/stats/SectionInfo"); + return { ScopesTab: () =>
}; +}); + +it("shows a section's info beside the section chooser", () => { + render(); + const tip = screen.getByRole("button", { name: /About scopes/ }); + expect(tip.closest("[data-section-info]")).toBeInTheDocument(); + expect(screen.getByRole("main")).not.toContainElement(tip); +}); + +it("keeps the info in place outside the analytics bar", () => { + render(
); + expect(screen.getByRole("main")).toContainElement(screen.getByRole("button", { name: /Standalone/ })); +}); diff --git a/tests/features/stats/SignalTab.test.tsx b/tests/features/stats/SignalTab.test.tsx index 16cb220..bf74588 100644 --- a/tests/features/stats/SignalTab.test.tsx +++ b/tests/features/stats/SignalTab.test.tsx @@ -15,17 +15,15 @@ beforeEach(() => { query.data = originalData; query.isPending = false; query.isP it("translates the signal view while retaining the requested window, true zero and exact counts", async () => { await act(() => i18n.changeLanguage("fr")); render(); - expect(screen.getByRole("button", { name: "Actualiser le signal" })).toBeInTheDocument(); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); expect(useSignalStats).toHaveBeenCalledWith("7d"); expect(screen.getByText("0.0 dB")).toBeInTheDocument(); const table = screen.getByRole("table", { name: "Disponibilité des échantillons du signal" }); expect(within(table).getByRole("row", { name: /SNR.*80.*20.*80.0%/ })).toBeInTheDocument(); expect(screen.getByText(/1970-01-01 00:00.*1970-01-01 01:00.*UTC/)).toHaveTextContent("heure en cours exclue"); - fireEvent.click(screen.getByRole("button", { name: "Actualiser le signal" })); - expect(query.refetch).toHaveBeenCalledOnce(); }); -it("shows measured units, exact sample coverage and refresh without treating zero SNR as missing", () => { +it("shows measured units and exact sample coverage without treating zero SNR as missing", () => { render(); expect(useSignalStats).toHaveBeenCalledWith("24h"); expect(screen.getByText("0.0 dB")).toBeInTheDocument(); @@ -34,8 +32,7 @@ it("shows measured units, exact sample coverage and refresh without treating zer const table = screen.getByRole("table", { name: "Signal sample availability" }); expect(within(table).getByRole("row", { name: /SNR.*80.*20.*80.0%/ })).toBeInTheDocument(); expect(within(table).getByRole("row", { name: /RSSI.*90.*10.*90.0%/ })).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Refresh signal" })); - expect(query.refetch).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: /Refresh/ })).not.toBeInTheDocument(); }); it.each(["isPending", "isPlaceholderData", "isError"] as const)("hides previous filter values when %s", (state) => { query[state] = true; diff --git a/tests/features/stats/TrafficTab.test.tsx b/tests/features/stats/TrafficTab.test.tsx index 2d77d8a..b2bfd91 100644 --- a/tests/features/stats/TrafficTab.test.tsx +++ b/tests/features/stats/TrafficTab.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { act, render, screen, within } from "@testing-library/react"; import { TrafficTab } from "../../../src/features/stats/TrafficTab"; import { useStatsObservations } from "../../../src/features/stats/useStats"; import { EChart } from "../../../src/features/stats/EChart"; @@ -19,7 +19,7 @@ describe("Traffic page", () => { it("translates the page and exact table without changing reception counts, IATA codes or ranges", async () => { await act(() => i18n.changeLanguage("fr")); render(); - expect(screen.getByRole("heading", { name: "Trafic" })).toBeInTheDocument(); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); expect(useStatsObservations).toHaveBeenCalledWith("7d"); expect(screen.getByText("Activité horaire · 7 j")).toBeInTheDocument(); const table = screen.getByRole("table", { name: "Observations par zone" }); @@ -29,8 +29,6 @@ describe("Traffic page", () => { expect(screen.getByText(/pas une panne/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /pas une panne/ })).toBeInTheDocument(); expect(screen.queryByText(/pas une panne/, { selector: "p" })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Actualiser le trafic" })); - expect(query.refetch).toHaveBeenCalledOnce(); }); it("redraws grouped and unassigned chart labels on language change without altering data", async () => { @@ -79,15 +77,14 @@ describe("Traffic page", () => { expect(screen.queryByRole("table")).not.toBeInTheDocument(); }); - it("shows exact reception counts, UTC/missing-history guidance and a refresh action", () => { + it("shows exact reception counts and UTC/missing-history guidance without a refresh action", () => { render(); expect(useStatsObservations).toHaveBeenCalledWith("24h"); const table = screen.getByRole("table", { name: "Observations by area" }); expect(within(table).getByRole("row", { name: /YOW.*1,200.*100.0%/ })).toBeInTheDocument(); expect(screen.queryByText("9,999")).not.toBeInTheDocument(); expect(screen.getByText(/not unique packets/)).toHaveTextContent(/UTC.*outage/); - fireEvent.click(screen.getByRole("button", { name: "Refresh traffic" })); - expect(query.refetch).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: /Refresh/ })).not.toBeInTheDocument(); }); it("does not display previous-region data while a new query is loading", () => { @@ -98,12 +95,10 @@ describe("Traffic page", () => { expect(screen.queryAllByTestId("chart")).toHaveLength(0); }); - it("provides a visible error state and retry without stale chart values", () => { + it("provides a visible error state without stale chart values", () => { query.isError = true; render(); expect(screen.getByRole("alert")).toHaveTextContent("Could not load traffic"); expect(screen.queryByText("YOW")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Refresh traffic" })); - expect(query.refetch).toHaveBeenCalledOnce(); }); });