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();
});
});