diff --git a/package-lock.json b/package-lock.json index b323e1b..cad2d8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.4", + "version": "2.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.4", + "version": "2.0.5", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index 13e195c..c983950 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.4", + "version": "2.0.5", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 1505510..fe4092c 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from "react"; import { VARIANT_CLASSES, type BadgeVariant } from "./badge-utils"; -export function Badge({ variant, children }: { variant: BadgeVariant; children: ReactNode }) { +export function Badge({ variant, children, normalCase = false }: { variant: BadgeVariant; children: ReactNode; normalCase?: boolean }) { return ( {children} diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index 267f38b..c64f94d 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -1,11 +1,13 @@ -import { useState, useRef, useEffect, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; +import { useState, useRef, useEffect, useId, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useHasHover } from "../hooks/useMediaQuery"; // Portals to with fixed positioning so overflow parents (the data tables) can't clip it. // Hover reveals with a mouse; on touch it toggles on tap and dismisses on an outside tap. -export function Tooltip({ label, children, className = "", wrap = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean }) { +// `focusable` makes a non-interactive trigger a tab stop whose label is exposed to screen readers. +export function Tooltip({ label, children, className = "", wrap = false, focusable = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean; focusable?: boolean }) { const hasHover = useHasHover(); + const descId = useId(); const ref = useRef(null); const tipRef = useRef(null); const [anchor, setAnchor] = useState(null); @@ -62,9 +64,12 @@ export function Tooltip({ label, children, className = "", wrap = false }: { lab onClick={hasHover ? undefined : toggle} onFocus={hasHover ? show : undefined} onBlur={hasHover ? hide : undefined} + tabIndex={focusable ? 0 : undefined} + aria-describedby={focusable ? descId : undefined} className={`inline-flex ${className}`} > {children} + {focusable && } {anchor && createPortal( + {t("nodes.possiblyForeign")} ); diff --git a/src/features/nodes/MeshMapperSection.tsx b/src/features/nodes/MeshMapperSection.tsx new file mode 100644 index 0000000..fa030b0 --- /dev/null +++ b/src/features/nodes/MeshMapperSection.tsx @@ -0,0 +1,91 @@ +import { useTranslation } from "react-i18next"; +import { Section, Field } from "../../components/DetailPanel"; +import { IataChip } from "../../components/IataChip"; +import { Timestamp } from "../../components/Timestamp"; +import type { MeshMapperRepeater, MeshMapperScope } from "./types"; + +function ScopeRow({ scope }: { scope: MeshMapperScope }) { + const { t } = useTranslation(); + const how = [ + scope.observedPackets != null ? t("nodeDetail.observedPackets", { count: scope.observedPackets }) : scope.observedAt != null ? t("nodeDetail.observed") : null, + scope.reportedAt != null ? t("nodeDetail.reported") : null, + scope.uploadedAt != null ? t("nodeDetail.uploaded") : null, + ].filter(Boolean).join(" · "); + const latest = Math.max(scope.observedAt ?? 0, scope.reportedAt ?? 0, scope.uploadedAt ?? 0); + return ( +
+ {scope.scope} + {how && {how}} + {latest > 0 && } +
+ ); +} + +export function MeshMapperSection({ mm }: { mm: MeshMapperRepeater }) { + const { t, i18n } = useTranslation(); + const { site, defaultScope } = mm; + const hasSite = Boolean(site.hardware || site.power || site.powerSource || site.antenna || site.heightM != null || site.notes); + const hasScopes = mm.scopes.length > 0 || mm.scopesCheckedAt != null; + // carpeater alone is covered by the badge next to the node name + if (!hasSite && !hasScopes && mm.admins.length === 0 && !defaultScope) return null; + + const num = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }); + const height = site.heightM != null + ? [t("nodeDetail.heightAgl", { value: num.format(site.heightM) }), site.heightAslM != null ? t("nodeDetail.heightAsl", { value: num.format(site.heightAslM) }) : null].filter(Boolean).join(" · ") + : null; + + return ( +
+
+ {(site.hardware || site.power || site.powerSource) && ( +
+ {site.hardware && } + {site.power && } + {site.powerSource && } +
+ )} + {site.antenna && } + {height && } + {mm.admins.length > 0 && } + {defaultScope && ( + + {defaultScope.state === "named" && defaultScope.name ? defaultScope.name : t(`nodeDetail.scopeState.${defaultScope.state === "none" ? "none" : "unknown"}`)} + {(defaultScope.source || defaultScope.updatedAt != null) && ( + + {" "}({defaultScope.source && t(`nodeDetail.scopeSource.${defaultScope.source}`, { defaultValue: defaultScope.source })} + {defaultScope.source && defaultScope.updatedAt != null && ", "} + {defaultScope.updatedAt != null && }) + + )} + + } + /> + )} + {hasScopes && ( +
+ {t("nodeDetail.confirmedScopes")} +
+ {mm.scopes.length > 0 ? ( + mm.scopes.map((s, i) => ) + ) : ( +
+ {t("nodeDetail.noConfirmedScopes")} + +
+ )} +
+
+ )} + {site.notes &&

{site.notes}

} +
+
+ {t("nodeDetail.fromMeshMapper")} + {mm.iata && {mm.iata}} + {t("nodeDetail.updated")} +
+
+ ); +} diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index dedc183..7941dd2 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -11,6 +11,8 @@ import { Timestamp } from "../../components/Timestamp"; import { hasMapLocation } from "../map/location"; import type { NodeObservation, NodeNeighbor } from "./types"; import { ForeignNodeBadge } from "./ForeignNodeBadge"; +import { MeshMapperSection } from "./MeshMapperSection"; +import { Tooltip } from "../../components/Tooltip"; function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClick?: () => void }) { const { t } = useTranslation(); @@ -126,6 +128,11 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
{node.nodeTypeName} + {node.meshmapper?.carpeater && ( + + {t("nodeDetail.carpeater")} + + )}
@@ -163,6 +170,8 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o )} + {node.meshmapper && } +
{node.minFirmwareVersion && } diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index 40886a6..b01ca2a 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -39,6 +39,46 @@ export interface Node extends NodeSummary { clockDriftSeconds?: number; clockOutOfSync?: boolean; clockCheckedAt?: number; // epoch ms + // MeshMapper's record for this repeater; node detail only, absent when the import is off or unmatched. + meshmapper?: MeshMapperRepeater; +} + +// Imported from MeshMapper. Times are epoch ms; every string is admin-entered free text. +export interface MeshMapperRepeater { + iata?: string; + fetchedAt: number; + site: MeshMapperSite; + carpeater: boolean; + admins: string[]; + scopes: MeshMapperScope[]; + scopesCheckedAt?: number; // set with empty scopes = it answered with none; absent = never asked + defaultScope?: MeshMapperDefaultScope; // separate from Node.defaultScope (Beacon's own match) +} + +export interface MeshMapperSite { + power?: string; + hardware?: string; + antenna?: string; + heightM?: number; // above ground + heightAslM?: number; + powerSource?: "mains" | "solar" | "battery" | "poe"; + notes?: string; +} + +export interface MeshMapperScope { + scope: string; + observedAt?: number; + observedPackets?: number; + proof?: "multibyte" | "floor"; + reportedAt?: number; + uploadedAt?: number; +} + +export interface MeshMapperDefaultScope { + name?: string; // only when state is "named" + state: "named" | "unknown" | "none"; + updatedAt?: number; + source?: "advert" | "reported"; } // First-hop neighbor of a node, from GET /nodes/{id}/neighbors (bare array, no pagination). diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a945368..2a858a8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -282,7 +282,42 @@ "noRecentPackets": "No recent packets", "observations_one": "{{formatted}} obs", "observations_other": "{{formatted}} obs", - "hops": "Hops" + "hops": "Hops", + "hardware": "Hardware", + "power": "Power", + "antenna": "Antenna", + "powerSource": "Power source", + "powerSources": { + "mains": "Mains", + "solar": "Solar", + "battery": "Battery", + "poe": "PoE" + }, + "height": "Height", + "heightAgl": "{{value}} m AGL", + "heightAsl": "{{value}} m ASL", + "admins": "Admins", + "mmDefaultScope": "Default scope", + "scopeState": { + "unknown": "Unknown", + "none": "None" + }, + "scopeSource": { + "advert": "advert", + "reported": "reported" + }, + "confirmedScopes": "Confirmed scopes", + "noConfirmedScopes": "None confirmed", + "observedPackets_one": "observed {{count}} packet", + "observedPackets_other": "observed {{count}} packets", + "observed": "observed", + "reported": "reported", + "uploaded": "uploaded", + "meshmapper": "MeshMapper", + "fromMeshMapper": "From MeshMapper", + "updated": "updated", + "carpeater": "CARpeater", + "carpeaterHint": "Mobile repeater in a vehicle: its position moves." }, "nodes": { "lastHeard": "last heard {{ago}}", diff --git a/tests/components/Tooltip.test.tsx b/tests/components/Tooltip.test.tsx index e9b26d3..8951990 100644 --- a/tests/components/Tooltip.test.tsx +++ b/tests/components/Tooltip.test.tsx @@ -72,3 +72,19 @@ describe("Tooltip (mobile)", () => { expect(onParentClick).not.toHaveBeenCalled(); }); }); + +describe("Tooltip focusable", () => { + it("is a tab stop described by its label for keyboard and screen-reader users", () => { + setMobile(false); + render(target); + const trigger = screen.getByText("target").parentElement!; + expect(trigger).toHaveAttribute("tabindex", "0"); + expect(trigger).toHaveAccessibleDescription("tip text"); + }); + + it("is not a tab stop by default", () => { + setMobile(false); + render(target); + expect(screen.getByText("target").parentElement).not.toHaveAttribute("tabindex"); + }); +}); diff --git a/tests/features/nodes/NodeDetailPanel.test.tsx b/tests/features/nodes/NodeDetailPanel.test.tsx index 6c9424a..664e773 100644 --- a/tests/features/nodes/NodeDetailPanel.test.tsx +++ b/tests/features/nodes/NodeDetailPanel.test.tsx @@ -5,7 +5,7 @@ import type { ReactNode } from "react"; import { NodeDetailPanel } from "../../../src/features/nodes/NodeDetailPanel"; import i18n from "../../../src/i18n"; import { getNode, getNodeObservations, getNodeNeighbors } from "../../../src/api/client"; -import type { Node, NodeNeighbor } from "../../../src/features/nodes/types"; +import type { MeshMapperRepeater, Node, NodeNeighbor } from "../../../src/features/nodes/types"; vi.mock("../../../src/api/client", () => ({ getNode: vi.fn(), @@ -45,8 +45,8 @@ function renderPanel(onViewNode = vi.fn()) { const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); - render(, { wrapper }); - return { onViewNode }; + const result = render(, { wrapper }); + return { ...result, onViewNode }; } beforeEach(() => { @@ -227,3 +227,117 @@ describe("NodeDetailPanel in French", () => { expect(await screen.findByText("Possiblement étranger")).toBeInTheDocument(); }); }); + +describe("NodeDetailPanel MeshMapper", () => { + const meshmapper: MeshMapperRepeater = { + iata: "YOW", + fetchedAt: Date.now() - 60_000, + site: { + power: "1W", hardware: "RAK4631", antenna: "5.8 dBi fibreglass", + heightM: 12, heightAslM: 341.6, powerSource: "solar", + notes: "On the fire tower, east face", + }, + carpeater: false, + admins: ["MrAlders0n"], + scopes: [ + { scope: "yow", observedAt: Date.now() - 3_600_000, observedPackets: 37, proof: "multibyte", reportedAt: Date.now() - 7_200_000 }, + ], + scopesCheckedAt: Date.now() - 7_200_000, + defaultScope: { name: "#yow", state: "named", updatedAt: Date.now() - 180_000, source: "advert" }, + }; + const empty: MeshMapperRepeater = { fetchedAt: 1, site: {}, carpeater: false, admins: [], scopes: [] }; + + it("shows MeshMapper's site details, admins and scopes", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper }); + renderPanel(); + + expect(await screen.findByText("MeshMapper")).toBeInTheDocument(); + expect(screen.getByText("RAK4631")).toBeInTheDocument(); + expect(screen.getByText("1W")).toBeInTheDocument(); + expect(screen.getByText("5.8 dBi fibreglass")).toBeInTheDocument(); + expect(screen.getByText("Solar")).toBeInTheDocument(); + expect(screen.getByText("12 m AGL · 341.6 m ASL")).toBeInTheDocument(); + expect(screen.getByText("MrAlders0n")).toBeInTheDocument(); + expect(screen.getByText("#yow")).toBeInTheDocument(); + expect(screen.getByText("yow")).toBeInTheDocument(); + expect(screen.getByText("observed 37 packets · reported")).toBeInTheDocument(); + expect(screen.getByText("On the fire tower, east face")).toBeInTheDocument(); + expect(screen.getByText(/From MeshMapper/)).toBeInTheDocument(); + expect(screen.queryByText("CARpeater")).not.toBeInTheDocument(); + }); + + it("never shows MeshMapper's proof type", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopes: [{ scope: "yow", observedPackets: 3, proof: "floor" }] } }); + renderPanel(); + expect(await screen.findByText("observed 3 packets")).toBeInTheDocument(); + expect(screen.queryByText(/floor/)).not.toBeInTheDocument(); + }); + + it("describes a scope confirmed only by report or upload", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopes: [{ scope: "yow", reportedAt: 1, uploadedAt: 2 }] } }); + renderPanel(); + expect(await screen.findByText("reported · uploaded")).toBeInTheDocument(); + }); + + it("shows height above ground alone when sea level is missing", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, site: { heightM: 12 } } }); + renderPanel(); + expect(await screen.findByText("12 m AGL")).toBeInTheDocument(); + }); + + it("formats heights in the reader's locale", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, site: { heightM: 12.25, heightAslM: 341.6 } } }); + await act(() => i18n.changeLanguage("fr")); + renderPanel(); + expect(await screen.findByText("12,3 m AGL · 341,6 m ASL")).toBeInTheDocument(); + await act(() => i18n.changeLanguage("en")); + }); + + it("is hidden when the node has no MeshMapper record", async () => { + renderPanel(); + await screen.findByText("Self Node"); + expect(screen.queryByText("MeshMapper")).not.toBeInTheDocument(); + expect(screen.queryByText(/From MeshMapper/)).not.toBeInTheDocument(); + }); + + it.each([ + ["nothing set", empty], + ["only a CARpeater flag", { ...empty, carpeater: true }], + ["only a sea-level height", { ...empty, site: { heightAslM: 341.6 } }], + ])("is hidden when the record has %s", async (_, mm) => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: mm }); + renderPanel(); + await screen.findByText("Self Node"); + expect(screen.queryByText(/From MeshMapper/)).not.toBeInTheDocument(); + }); + + it("shows an unknown default scope and an answered-but-empty scope list", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopesCheckedAt: 1, defaultScope: { state: "unknown" } } }); + renderPanel(); + expect(await screen.findByText("Unknown")).toBeInTheDocument(); + expect(screen.getByText("None confirmed")).toBeInTheDocument(); + }); + + it("shows a default scope of none", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, defaultScope: { state: "none" } } }); + renderPanel(); + expect(await screen.findByText("None")).toBeInTheDocument(); + }); + + it("badges a CARpeater next to the name, reachable by keyboard", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...meshmapper, carpeater: true } }); + renderPanel(); + const badge = await screen.findByText("CARpeater"); + expect(badge.className).not.toContain("uppercase"); + const trigger = badge.parentElement!; + expect(trigger).toHaveAttribute("tabindex", "0"); + expect(trigger).toHaveAccessibleDescription("Mobile repeater in a vehicle: its position moves."); + }); + + it("renders admin-entered markup as plain text", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...meshmapper, site: { notes: "bold" } } }); + const { container } = renderPanel(); + expect(await screen.findByText("bold")).toBeInTheDocument(); + expect(container.querySelector("b")).toBeNull(); + }); +});