Polish observer cards and clock drift on phones (#106)

This commit is contained in:
MrAlders0n authored and GitHub committed 2026-09-30 13:47:10 -04:00
1 parent 8d1f30d553
commit 63344af365
8 files changed
+112 -55

No files matched your search

+47 -26
View File
@@ -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) ? <span key={i}>{part}</span> : <span key={i} className="text-sm font-semibold text-text-muted">{part}</span>)}</>;
}
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 ? <Timestamp value={summary.latestRecordedAt} /> : "—"],
["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 <>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0"><h1 className="break-words text-lg font-semibold text-text-bright">{observer.displayName ?? observer.id.slice(0, 8)}</h1>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span role="img" aria-label={t(`observerPage.${observer.lastStatusAt == null ? "statusMissing" : statusFresh ? "statusRecent" : "statusStale"}`)} title={t(`observerPage.${observer.lastStatusAt == null ? "statusMissing" : statusFresh ? "statusRecent" : "statusStale"}`)} className={`inline-flex items-center ${observer.lastStatusAt == null ? "text-text-muted" : statusFresh ? "text-green" : "text-warn"}`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="12" cy="12" r="9" />{statusFresh ? <path d="m7 12 3 3 7-7" /> : <><path d="M12 7v6" /><circle cx="12" cy="17" r=".5" fill="currentColor" /></>}</svg>
</span>
<IataChip>{observer.iata}</IataChip>
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1">
<h1 className="min-w-0 break-words text-lg font-semibold text-text-bright">{observer.displayName ?? observer.id.slice(0, 8)}</h1>
<span role="img" aria-label={t(`observerPage.${observer.lastStatusAt == null ? "statusMissing" : statusFresh ? "statusRecent" : "statusStale"}`)} title={t(`observerPage.${observer.lastStatusAt == null ? "statusMissing" : statusFresh ? "statusRecent" : "statusStale"}`)} className={`inline-flex items-center ${observer.lastStatusAt == null ? "text-text-muted" : statusFresh ? "text-green" : "text-warn"}`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="12" cy="12" r="9" />{statusFresh ? <path d="m7 12 3 3 7-7" /> : <><path d="M12 7v6" /><circle cx="12" cy="17" r=".5" fill="currentColor" /></>}</svg>
</span>
<IataChip>{observer.iata}</IataChip>
</div>
<ul role="list" aria-label={t("observerPage.metrics")} className="grid grid-cols-2 gap-3 md:grid-cols-3 2xl:grid-cols-6">
{cards.map(([key, value]) => {
const note = key === "lastHour" && summary ? `${formatUtc(summary.lastCompleteHourStart, { timeOnly: true })}–${formatUtc(summary.lastCompleteHourEnd, { timeOnly: true })} UTC`
: key === "noise" && noise != null ? t(reportedNoise != null ? "observerPage.latestStatus" : "observerPage.latestTelemetry")
: undefined;
return <li key={key} className="min-w-0">
<StatCard label={t(`observerPage.${key}`)} value={value} accent="var(--color-primary)" />
<p className="mt-1 min-h-4 text-xs text-text-muted">{note}</p>
{cards.map(({ key, label, value, title }) => {
const shown = <span className="font-mono text-xl font-bold tabular-nums text-text-bright sm:text-2xl"><Measure text={value} /></span>;
return <li key={key} className="flex min-w-0 flex-col items-center justify-center gap-1 rounded-lg border border-border bg-bg-surface px-2 py-3 text-center">
<span className="font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted">{label}</span>
{title ? <Tooltip label={title}>{shown}</Tooltip> : shown}
</li>;
})}
</ul>
+19 -1
View File
@@ -37,7 +37,7 @@ export function ClockDriftTab() {
id: "drift",
header: t("clockDrift.drift"),
className: "tabular-nums",
cell: (e) => <span className={driftClass(e.clockDriftSeconds)}>{formatClockDrift(e.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") })}</span>,
cell: (e) => <span className={`whitespace-nowrap ${driftClass(e.clockDriftSeconds)}`}>{formatClockDrift(e.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") })}</span>,
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) => (
<div className="flex flex-col gap-1.5 font-mono text-xs">
<div className="flex items-center justify-between gap-2">
<span className={`min-w-0 flex-1 truncate ${e.nodeName ? "text-text-normal" : "italic text-text-dim"}`}>{e.nodeName ?? e.nodeId.slice(0, 8)}</span>
<span className="shrink-0"><Badge variant="default">{e.nodeTypeName}</Badge></span>
</div>
<div className="flex items-center justify-between gap-2">
<span className={`tabular-nums ${driftClass(e.clockDriftSeconds)}`}>{drift(e)}</span>
<span className="shrink-0 text-text-muted"><Timestamp value={e.clockCheckedAt} /></span>
</div>
{(e.iatas ?? []).length > 0 && (
<div className="flex flex-wrap gap-1">{e.iatas!.map((i) => <IataChip key={i.iata}>{i.iata}</IataChip>)}</div>
)}
</div>
);
return (
<div className="flex h-full flex-col">
@@ -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}
/>
</div>
);
+1 -4
View File
@@ -25,10 +25,7 @@ export function ScopesTab() {
return (
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2"><h2 className="text-lg font-semibold text-text-bright">{t("scopes.title")}</h2><InfoTip text={[t("scopes.subtitle"), t("scopes.measurement")]} /></div>
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching || query.isPending} className="rounded border border-border px-3 py-1.5 text-xs text-text-normal hover:bg-bg-raised disabled:opacity-50">{t("scopes.refresh")}</button>
</div>
<div className="flex items-center gap-2"><h2 className="text-lg font-semibold text-text-bright">{t("scopes.title")}</h2><InfoTip text={[t("scopes.subtitle"), t("scopes.measurement")]} /></div>
<label className="flex max-w-sm flex-col gap-1 text-xs text-text-muted">{t("scopes.search")}
<input type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="#bc, #east…" className="rounded border border-border bg-bg-raised px-3 py-2 text-base text-text-bright outline-none focus:border-primary sm:text-sm" />
</label>
+5 -6
View File
@@ -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…",
+4 -5
View File
@@ -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…",
@@ -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(<ObserverSummary observer={observer} points={[]} />);
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(<ObserverSummary observer={observer} activity={activity} points={[]} />);
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(<ObserverSummary observer={observer} points={points} />);
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");
});
});
@@ -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(<ClockDriftTab />);
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();
}
});
+2 -4
View File
@@ -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", () => {