diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx index 30862f7..98fea58 100644 --- a/src/features/observers/ObserverSummary.tsx +++ b/src/features/observers/ObserverSummary.tsx @@ -1,48 +1,69 @@ import { useTranslation } from "react-i18next"; import { Timestamp } from "../../components/Timestamp"; import { CopyButton } from "../../components/CopyButton"; -import { formatBattery, formatRadioParts, formatUptime, formatUtc } from "../../lib/formatters"; +import { formatAbsolute, formatBattery, formatRadioParts, formatUptime, formatUtc, timeAgoParts } from "../../lib/formatters"; import { useTick } from "../../hooks/useTick"; import { observerNoiseFloor } from "./observer-stats"; -import { Card, StatCard } from "../stats/cards"; +import { Card } from "../stats/cards"; +import { Tooltip } from "../../components/Tooltip"; import { Field } from "../../components/DetailPanel"; import { IataChip } from "../../components/IataChip"; import type { Observer } from "./types"; import type { ObserverActivity, TelemetryPoint } from "../stats/types"; +// Numbers at full size, units and words smaller, so "30d 16h 52m" and "-119 dBm" scan as values. +function Measure({ text }: { text: string }) { + if (!/\d/.test(text)) return <>{text}; + return <>{text.split(/(-?[\d.,]+)/).filter(Boolean).map((part, i) => + /\d/.test(part) ? {part} : {part})}; +} + export function ObserverSummary({ observer, activity, points, pending = false }: { observer: Observer; activity?: ObserverActivity; points: TelemetryPoint[]; pending?: boolean }) { const { t, i18n } = useTranslation(); const now = useTick(); const summary = activity?.summary; const statusFresh = observer.lastStatusAt != null && now - observer.lastStatusAt < 300_000; const reportedNoise = observerNoiseFloor(observer); const noise = reportedNoise ?? points.at(-1)?.noiseFloorDb; - const cards = [ - ["records", summary?.recordedPackets.toLocaleString(i18n.resolvedLanguage) ?? "—"], - ["lastHour", summary?.lastCompleteHour.toLocaleString(i18n.resolvedLanguage) ?? "—"], - ["lastPacket", summary?.latestRecordedAt != null ? : "—"], - ["battery", observer.batteryLevel != null ? formatBattery(observer.batteryLevel) : "—"], - ["uptime", observer.uptimeSeconds != null ? formatUptime(observer.uptimeSeconds) : "—"], - ["noise", noise != null && Number.isFinite(noise) ? `${noise.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} dBm` : "—"], - ] as const; + const ago = (at: number) => { + const { count, unit } = timeAgoParts(at); + return t("timestamp.ago", { duration: t(`timestamp.unit.${unit}`, { count }) }); + }; + const hour = (at: number) => formatUtc(at, { timeOnly: true }).slice(0, 2); + const cards: { key: string; label: string; value: string; title?: string }[] = [ + { key: "records", label: t("observerPage.records"), value: summary?.recordedPackets.toLocaleString(i18n.resolvedLanguage) ?? "—" }, + { + key: "lastHour", + label: summary ? t("observerPage.hourWindow", { start: hour(summary.lastCompleteHourStart), end: hour(summary.lastCompleteHourEnd) }) : t("observerPage.lastHour"), + value: summary?.lastCompleteHour.toLocaleString(i18n.resolvedLanguage) ?? "—", + }, + { + key: "lastPacket", + label: t("observerPage.lastPacket"), + value: summary?.latestRecordedAt != null ? ago(summary.latestRecordedAt) : "—", + title: summary?.latestRecordedAt != null ? formatAbsolute(summary.latestRecordedAt) : undefined, + }, + { key: "battery", label: t("observerPage.battery"), value: observer.batteryLevel != null ? formatBattery(observer.batteryLevel) : "—" }, + { key: "uptime", label: t("observerPage.uptime"), value: observer.uptimeSeconds != null ? formatUptime(observer.uptimeSeconds) : "—" }, + { + key: "noise", + label: t("observerPage.lastNoise"), + value: noise != null && Number.isFinite(noise) ? `${noise.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} dBm` : "—", + }, + ]; return <> -
-

{observer.displayName ?? observer.id.slice(0, 8)}

-
- - - - {observer.iata} -
-
+
+

{observer.displayName ?? observer.id.slice(0, 8)}

+ + + + {observer.iata}
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) => {formatClockDrift(e.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") })}, + cell: (e) => {formatClockDrift(e.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") })}, 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) => ( +
+
+ {e.nodeName ?? e.nodeId.slice(0, 8)} + {e.nodeTypeName} +
+
+ {drift(e)} + +
+ {(e.iatas ?? []).length > 0 && ( +
{e.iatas!.map((i) => {i.iata})}
+ )} +
+ ); return (
@@ -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", () => {