diff --git a/src/features/stats/ClockDriftTab.tsx b/src/features/stats/ClockDriftTab.tsx
index 9d022f9..4d1235d 100644
--- a/src/features/stats/ClockDriftTab.tsx
+++ b/src/features/stats/ClockDriftTab.tsx
@@ -37,7 +37,7 @@ export function ClockDriftTab() {
id: "drift",
header: t("clockDrift.drift"),
className: "tabular-nums",
- cell: (e) =>
,
sortValue: (e) => Math.abs(e.clockDriftSeconds),
},
{
@@ -58,6 +58,23 @@ export function ClockDriftTab() {
),
},
], [t]);
+ const drift = (e: ClockDriftEntry) =>
+ formatClockDrift(e.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") });
+ const renderCard = (e: ClockDriftEntry) => (
+
@@ -73,6 +90,7 @@ export function ClockDriftTab() {
isLoading={loading}
emptyLabel={clockDrift.isError ? t("common.loadFailed") : t("clockDrift.empty")}
defaultSort={{ id: "drift", direction: "desc" }}
+ renderCard={renderCard}
/>
);
diff --git a/src/features/stats/ScopesTab.tsx b/src/features/stats/ScopesTab.tsx
index 49a3ea8..d150342 100644
--- a/src/features/stats/ScopesTab.tsx
+++ b/src/features/stats/ScopesTab.tsx
@@ -25,10 +25,7 @@ export function ScopesTab() {
return (
-
-
{t("scopes.title")}
-
-
+
{t("scopes.title")}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index ce83cad..899406f 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -206,7 +206,6 @@
"scopes": {
"title": "Transport scopes",
"subtitle": "Retained data and memberships for the selected region.",
- "refresh": "Refresh scopes",
"search": "Find a scope",
"error": "Could not load scopes. Try refreshing.",
"active": "Scopes with data",
@@ -266,10 +265,10 @@
"metrics": "Observer metrics",
"records": "Recorded packets",
"lastHour": "Last complete hour",
- "lastPacket": "Last recorded packet",
+ "lastPacket": "Last packet",
"battery": "Battery",
"batteryV": "Battery V",
- "uptime": "Device uptime",
+ "uptime": "Uptime",
"noise": "Noise floor",
"noiseDbm": "Noise dBm",
"packets": "Packets",
@@ -310,8 +309,6 @@
"mean": "Average",
"minimum": "Minimum",
"unknown": "Unknown",
- "latestStatus": "Latest status",
- "latestTelemetry": "Latest telemetry interval",
"recording": "Loading observer…",
"hour": "hour",
"day": "day",
@@ -319,7 +316,9 @@
"directory": "All observers",
"sidebarSearch": "Search observers",
"noMatches": "No matches",
- "none": "No observers"
+ "none": "No observers",
+ "hourWindow": "Packets {{start}}–{{end}} UTC",
+ "lastNoise": "Last noise floor"
},
"observerCompare": {
"open": "Compare with…",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index c78e573..1664513 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -209,7 +209,6 @@
"scopes": {
"title": "Scopes de transport",
"subtitle": "Données conservées et appartenances pour la région sélectionnée.",
- "refresh": "Actualiser les scopes",
"search": "Rechercher un scope",
"error": "Impossible de charger les scopes. Réessayez.",
"active": "Scopes avec données",
@@ -270,7 +269,7 @@
"metrics": "Mesures de l’observateur",
"records": "Paquets enregistrés",
"lastHour": "Dernière heure complète",
- "lastPacket": "Dernier paquet enregistré",
+ "lastPacket": "Dernier paquet",
"battery": "Batterie",
"batteryV": "Batterie V",
"uptime": "Durée de fonctionnement",
@@ -314,8 +313,6 @@
"mean": "Moyenne",
"minimum": "Minimum",
"unknown": "Inconnu",
- "latestStatus": "Dernier état",
- "latestTelemetry": "Dernière période de télémétrie",
"recording": "Chargement de l’observateur…",
"hour": "heure",
"day": "jour",
@@ -323,7 +320,9 @@
"directory": "Tous les observateurs",
"sidebarSearch": "Rechercher les observateurs",
"noMatches": "Aucun résultat",
- "none": "Aucun observateur"
+ "none": "Aucun observateur",
+ "hourWindow": "Paquets {{start}}–{{end}} h UTC",
+ "lastNoise": "Dernier bruit de fond"
},
"observerCompare": {
"open": "Comparer avec…",
diff --git a/tests/features/observers/ObserverSummary.compact.test.tsx b/tests/features/observers/ObserverSummary.compact.test.tsx
index 254a8c7..94ffca2 100644
--- a/tests/features/observers/ObserverSummary.compact.test.tsx
+++ b/tests/features/observers/ObserverSummary.compact.test.tsx
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { render, screen, within } from "@testing-library/react";
import { ObserverSummary } from "../../../src/features/observers/ObserverSummary";
import type { Observer } from "../../../src/features/observers/types";
import type { ObserverActivity, TelemetryPoint } from "../../../src/features/stats/types";
@@ -22,21 +22,30 @@ describe("compact observer header", () => {
});
describe("observer summary card notes", () => {
- it("wraps the last-complete-hour window under its card instead of a non-wrapping sublabel", () => {
+ it("keeps the status icon and IATA chip on the name row", () => {
+ render(
);
+ const row = screen.getByRole("heading", { level: 1 }).parentElement!;
+ expect(screen.getByRole("img", { name: "Status is stale" }).parentElement).toBe(row);
+ expect(screen.getByText("YKF").parentElement).toBe(row);
+ });
+
+ it("names the last complete hour by its UTC window instead of a note under the card", () => {
const activity = { summary: {
recordedPackets: 5, lastCompleteHour: 3, lastCompleteHourStart: 0, lastCompleteHourEnd: 3_600_000, latestRecordedAt: null,
} } as unknown as ObserverActivity;
render(
);
- const note = screen.getByText("00:00–01:00 UTC");
- expect(note).not.toHaveClass("whitespace-nowrap");
- expect(note).toHaveClass("mt-1");
+ const tile = screen.getByText("Packets 00–01 UTC").closest("li")!;
+ expect(tile).toHaveTextContent("3");
+ expect(tile).toHaveClass("text-center");
+ expect(screen.queryByText(/00:00–01:00/)).not.toBeInTheDocument();
});
- it("wraps the noise-floor telemetry note under its card the same way", () => {
+ it("labels the noise tile as the last reading and sets the unit apart from the number", () => {
const points = [{ noiseFloorDb: -91 } as unknown as TelemetryPoint];
render(
);
- const note = screen.getByText("Latest telemetry interval");
- expect(note).not.toHaveClass("whitespace-nowrap");
- expect(note).toHaveClass("mt-1");
+ const tile = screen.getByText("Last noise floor").closest("li")!;
+ expect(screen.queryByText(/Latest telemetry interval|Latest status/)).not.toBeInTheDocument();
+ expect(within(tile).getByText("-91")).not.toHaveClass("text-sm");
+ expect(within(tile).getByText("dBm")).toHaveClass("text-sm");
});
});
diff --git a/tests/features/stats/ClockDriftTab.test.tsx b/tests/features/stats/ClockDriftTab.test.tsx
index 0823f63..58c636d 100644
--- a/tests/features/stats/ClockDriftTab.test.tsx
+++ b/tests/features/stats/ClockDriftTab.test.tsx
@@ -66,3 +66,19 @@ it("retains valid values during a healthy background refresh", () => {
expect(screen.getByText("+59m 59s ahead")).toHaveClass("text-warn");
expect(screen.getByText("-1h 0m behind")).toHaveClass("text-danger");
});
+
+it("shows compact cards instead of a wide table on a phone, worst first", async () => {
+ await act(() => i18n.changeLanguage("en"));
+ const media = window.matchMedia("(max-width: 767px)");
+ const spy = vi.spyOn(window, "matchMedia").mockImplementation((q) => ({ ...media, media: q, matches: q === "(max-width: 767px)" }));
+ try {
+ const { container } = render(
);
+ expect(container.querySelector("table")).toBeNull();
+ const cards = screen.getAllByRole("button", { name: /Beta|Alpha/ });
+ expect(cards.map((c) => c.textContent)).toEqual([expect.stringContaining("Beta"), expect.stringContaining("Alpha")]);
+ expect(cards[0]).toHaveTextContent("-1h 0m behind");
+ expect(cards[0]).toHaveTextContent("YOW");
+ } finally {
+ spy.mockRestore();
+ }
+});
diff --git a/tests/features/stats/ScopesTab.test.tsx b/tests/features/stats/ScopesTab.test.tsx
index f0c7277..f5e7e2e 100644
--- a/tests/features/stats/ScopesTab.test.tsx
+++ b/tests/features/stats/ScopesTab.test.tsx
@@ -22,8 +22,7 @@ it("translates scope counts and membership guidance without changing scope names
expect(screen.getByText("Appartenances d’observateurs")).toBeInTheDocument();
expect(screen.getByText(/plusieurs scopes/)).toHaveTextContent("sans période sélectionnée");
expect(screen.getByText("2 sur 2 scopes")).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "Actualiser les scopes" }));
- expect(query.refetch).toHaveBeenCalledOnce();
+ expect(screen.queryByRole("button", { name: /Actualiser/ })).not.toBeInTheDocument();
});
it("keeps the current search and chart values when changing language", async () => {
@@ -84,8 +83,7 @@ it("shows exact scope counts, keeps membership semantics explicit and filters th
fireEvent.change(screen.getByRole("searchbox", { name: "Find a scope" }), { target: { value: "WEST" } });
expect(screen.queryByText("#east")).not.toBeInTheDocument();
expect(screen.getByText(/1 of 2 scopes/)).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "Refresh scopes" }));
- expect(query.refetch).toHaveBeenCalledOnce();
+ expect(screen.queryByRole("button", { name: /Refresh/ })).not.toBeInTheDocument();
});
it("hides stale values while a region change is pending and on errors", () => {