diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index dca6fd2..04b66b1 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -108,8 +108,12 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) { [scopesData], ); - const obsSpark = useMemo(() => spark(ov, (v) => v.observations), [ov]); - const observerSpark = useMemo(() => spark(ov, (v) => v.activeObservers), [ov]); + const sparks = useMemo(() => ({ + packets: spark(ov, (v) => v.uniquePackets), + observations: spark(ov, (v) => v.observations), + observers: spark(ov, (v) => v.activeObservers), + iatas: spark(ov, (v) => v.activeIatas), + }), [ov]); // top-row KPIs are the last 24 rollable hours, so they lag the clock by 35–95 min; range only drives the charts below const ovWindow = ov @@ -119,10 +123,10 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) { return (
- - - - + + + +
{ + const counts = hours.map(({ oneByte, twoByte, threeByte }) => + oneByte === null || twoByte === null || threeByte === null ? null : { hashed: oneByte + twoByte + threeByte, multi: twoByte + threeByte }); + return { + receptions: hours.map((h) => h.receptions), + hashed: counts.map((n) => n?.hashed ?? null), + multiShare: counts.map((n) => n?.hashed ? n.multi / n.hashed : null), + }; + }, [hours]); const multi = data?.hashWidths.filter((bin) => bin.bytes > 1).reduce((n, bin) => n + bin.receptions, 0) ?? 0; const largest = data?.pathLengths.at(-1)?.entries; @@ -39,9 +48,9 @@ export function PathsTab({ range }: { range: StatsRange }) { {query.isError &&

{t("paths.error")}

}
- - - + + +
diff --git a/src/features/stats/SignalTab.tsx b/src/features/stats/SignalTab.tsx index 25477fe..326113e 100644 --- a/src/features/stats/SignalTab.tsx +++ b/src/features/stats/SignalTab.tsx @@ -23,16 +23,22 @@ export function SignalTab({ range }: { range: StatsRange }) { snrTrend: signalTrendOption(hours, "snr", c, t), rssiTrend: signalTrendOption(hours, "rssi", c, t), coverage: signalCoverageOption(data, c, t), }), [data, hours, c, t]); const state = { isLoading: loading, isError: query.isError }; + const sparks = useMemo(() => data && { + receptions: hours.map((h) => h.receptions), + snr: hours.map((h) => h.snrAverage), + rssi: hours.map((h) => h.rssiAverage), + reported: hours.map((h) => h.receptions !== null), + }, [data, hours]); return (
{query.isError &&

{t("signal.error")}

}
- - - - + + + +
diff --git a/src/features/stats/TrafficTab.tsx b/src/features/stats/TrafficTab.tsx index 877590b..f14ee51 100644 --- a/src/features/stats/TrafficTab.tsx +++ b/src/features/stats/TrafficTab.tsx @@ -24,16 +24,21 @@ export function TrafficTab({ range }: { range: StatsRange }) { return { ...option, tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) }, aria: { enabled: true, label: { description: t("traffic.shareDescription") } } }; }, [model, colors, t]); const value = (number: number) => unavailable ? "—" : formatCount(number); + const sparks = useMemo(() => unavailable ? null : { + totals: model.hours.map((h) => h.total), + areas: model.hours.map((h) => h.areas), + reported: model.hours.map((h) => h.total !== null), + }, [model, unavailable]); return (
{query.isError &&

{t("traffic.error")}

}
- - area.total > 0).length)} accent={colors.green} /> - - + + area.total > 0).length)} accent={colors.green} spark={sparks?.areas} /> + +
{t("traffic.hourlyRecords")}} option={trend} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} /> diff --git a/src/features/stats/cards.tsx b/src/features/stats/cards.tsx index 12b9f88..d10e237 100644 --- a/src/features/stats/cards.tsx +++ b/src/features/stats/cards.tsx @@ -26,41 +26,75 @@ export function Card({ ); } -function Sparkline({ values, color }: { values: (number | null)[]; color: string }) { +const SPARK_W = 120; +const SPARK_H = 20; +const sparkSpacer =
; + +function Sparkline({ values, color, markPeak }: { values: (number | null)[]; color: string; markPeak?: boolean }) { const known = values.filter((v): v is number => v !== null); - if (values.length < 2 || known.length < 2) return
; - const w = 120; - const h = 20; + if (values.length < 2 || known.length < 2) return sparkSpacer; const max = Math.max(...known); const min = Math.min(...known); const range = max - min || 1; + const x = (i: number) => (i / (values.length - 1)) * SPARK_W; + const y = (v: number) => SPARK_H - 1 - ((v - min) / range) * (SPARK_H - 2); // A null hour breaks the line, so an outage shows as a gap rather than a straight join. const runs: string[][] = [[]]; values.forEach((v, i) => { if (v === null) runs.push([]); - else runs[runs.length - 1]!.push(`${(i / (values.length - 1)) * w},${h - 1 - ((v - min) / range) * (h - 2)}`); + else runs[runs.length - 1]!.push(`${x(i)},${y(v)}`); }); + const peak = values.indexOf(max); return ( - + {runs.filter((run) => run.length > 1).map((run) => ( ))} + {markPeak && ( + // zero-length round-capped stroke: stays a circle under preserveAspectRatio="none" + + )} + + ); +} + +// One bar per run of hours that reported, so gaps read at a glance. +function PresenceStrip({ hours, color }: { hours: boolean[]; color: string }) { + if (hours.length === 0) return sparkSpacer; + const slot = SPARK_W / hours.length; + const bars: { start: number; length: number }[] = []; + hours.forEach((on, i) => { + const last = bars.at(-1); + if (!on) return; + if (last && last.start + last.length === i) last.length++; + else bars.push({ start: i, length: 1 }); + }); + return ( + + + {bars.map((bar) => ( + + ))} ); } -// KPI tile: label, big mono value, optional sparkline + sub-label. +// KPI tile: label, big mono value, optional sparkline (or hourly presence strip) + sub-label. export function StatCard({ label, value, accent, spark, + markPeak, + presence, sublabel, }: { label: string; value: ReactNode; accent: string; // CSS color for the sparkline, e.g. "var(--color-primary)" spark?: (number | null)[]; + markPeak?: boolean; + presence?: boolean[]; sublabel?: ReactNode; }) { return ( @@ -70,7 +104,7 @@ export function StatCard({ {sublabel && {sublabel}}
{value}
- {spark ? :
} + {presence ? : spark ? : sparkSpacer}
); } diff --git a/src/features/stats/paths.ts b/src/features/stats/paths.ts index 9e8924a..c0319cc 100644 --- a/src/features/stats/paths.ts +++ b/src/features/stats/paths.ts @@ -10,7 +10,7 @@ export function pathHours(data: PathStats | undefined) { const rows = new Map(data.hourly.map((row) => [row.hour, row])); return Array.from({ length: Math.min(721, Math.ceil((data.until - first) / HOUR)) }, (_, i) => { const hour = first + i * HOUR, row = rows.get(hour); - return { hour, oneByte: row?.oneByte ?? null, twoByte: row?.twoByte ?? null, threeByte: row?.threeByte ?? null }; + return { hour, receptions: row?.receptions ?? null, oneByte: row?.oneByte ?? null, twoByte: row?.twoByte ?? null, threeByte: row?.threeByte ?? null }; }); } diff --git a/src/features/stats/traffic.ts b/src/features/stats/traffic.ts index c32448b..3b24501 100644 --- a/src/features/stats/traffic.ts +++ b/src/features/stats/traffic.ts @@ -26,7 +26,7 @@ export function trafficModel(points: ObservationPoint[], range: StatsRange, asOf const hours = Array.from({ length }, (_, i) => { const hour = start + i * HOUR; const bucket = buckets.get(hour); - return { hour, total: bucket ? [...bucket.values()].reduce((a, b) => a + b, 0) : null }; + return { hour, total: bucket ? [...bucket.values()].reduce((a, b) => a + b, 0) : null, areas: bucket ? bucket.size : null }; }); const series = names.map((name) => ({ name, total: totals.get(name) ?? 0, diff --git a/tests/features/stats/MeshTab.test.tsx b/tests/features/stats/MeshTab.test.tsx index f7cf3db..3628267 100644 --- a/tests/features/stats/MeshTab.test.tsx +++ b/tests/features/stats/MeshTab.test.tsx @@ -91,7 +91,7 @@ it("hides pending range charts while retaining fixed-24h KPIs, sparklines and po expect(screen.queryByText("17 obs")).not.toBeInTheDocument(); expect(screen.getAllByTestId("chart")).toHaveLength(2); expect(within(card("Total packets")).getByText("111")).toBeInTheDocument(); - expect(container.querySelectorAll("polyline")).toHaveLength(2); + expect(container.querySelectorAll("polyline")).toHaveLength(4); expect(getStatsSeries).toHaveBeenCalledTimes(2); expect(getTopNodes).toHaveBeenCalledTimes(2); }); @@ -114,7 +114,7 @@ it("hides failed series values and sparklines without discarding the cache or he it("draws sparklines from complete hours only", async () => { const { container } = mount(); await loaded(); const lines = [...container.querySelectorAll("polyline")].map((l) => l.getAttribute("points")!.split(" ").length); - expect(lines).toEqual([2, 2]); + expect(lines).toEqual([2, 2, 2, 2]); }); it("breaks sparklines at missing hours instead of joining across them", async () => { @@ -122,7 +122,7 @@ it("breaks sparklines at missing hours instead of joining across them", async () vi.mocked(getStatsSeries).mockResolvedValue({ ...series, hours: [ok(0), ok(1), { hour: 2 * H, status: "missing", values: null }, ok(3), ok(4)] }); const { container } = mount(); await loaded(); const lines = [...container.querySelectorAll("polyline")].map((l) => l.getAttribute("points")!.split(" ")); - expect(lines.map((pts) => pts.length)).toEqual([2, 2, 2, 2]); + expect(lines.map((pts) => pts.length)).toEqual([2, 2, 2, 2, 2, 2, 2, 2]); expect(lines[1]![0]!.split(",")[0]).toBe("90"); }); @@ -161,7 +161,7 @@ it("retains valid values, series, sparklines and observer navigation during same await act(async () => { refresh = client.refetchQueries({ queryKey: ["stats-series"] }); }); expect(within(card("Total packets")).getByText("111")).toBeInTheDocument(); expect(screen.getAllByTestId("chart")).toHaveLength(6); - expect(container.querySelectorAll("polyline")).toHaveLength(2); + expect(container.querySelectorAll("polyline")).toHaveLength(4); fireEvent.click(within(card("Top observers · 24h")).getByTestId("chart")); expect(onSelectObserver).toHaveBeenCalledWith("observer-id"); await act(async () => { next.resolve(withSummary({ uniquePackets: 555 })); await refresh; }); diff --git a/tests/features/stats/PathsTab.test.tsx b/tests/features/stats/PathsTab.test.tsx index 65f967a..e8c16b1 100644 --- a/tests/features/stats/PathsTab.test.tsx +++ b/tests/features/stats/PathsTab.test.tsx @@ -87,3 +87,10 @@ it("translates empty coverage without inventing hash-width votes or hourly recor expect(screen.queryAllByTestId("chart")).toHaveLength(0); expect(screen.queryByRole("table")).not.toBeInTheDocument(); }); + +it("draws hourly sparks for observations, hash paths and multi-byte share", () => { + const hour = (h: number) => ({ hour: h * 3600000, receptions: 50 + h, oneByte: 10, twoByte: 5 + h, threeByte: 1, empty: 5, trace: 1, unclassified: 1 }); + query.data = { ...originalData, until: 3 * 3600000, hourly: [hour(0), hour(1), hour(2)] }; + const { container } = render(); + expect(container.querySelectorAll("polyline")).toHaveLength(3); +}); diff --git a/tests/features/stats/SignalTab.test.tsx b/tests/features/stats/SignalTab.test.tsx index bf74588..d27615d 100644 --- a/tests/features/stats/SignalTab.test.tsx +++ b/tests/features/stats/SignalTab.test.tsx @@ -82,3 +82,11 @@ it("shows a translated empty state without implying an outage or displaying samp expect(screen.getAllByText("Aucune donnée").length).toBeGreaterThan(0); expect(screen.queryAllByTestId("chart")).toHaveLength(0); }); + +it("draws hourly sparks for observations, SNR and RSSI, and a presence strip for hours with records", () => { + const hour = (h: number) => ({ hour: h * 3600000, receptions: 10 + h, snrSamples: 5, snrAverage: h, rssiSamples: 5, rssiAverage: -100 - h }); + query.data = { ...originalData, until: 3 * 3600000, hourly: [hour(0), hour(1), hour(2)] }; + const { container } = render(); + expect(container.querySelectorAll("polyline")).toHaveLength(3); + expect(container.querySelectorAll("rect")).toHaveLength(1); +}); diff --git a/tests/features/stats/TrafficTab.test.tsx b/tests/features/stats/TrafficTab.test.tsx index b2bfd91..f32a10b 100644 --- a/tests/features/stats/TrafficTab.test.tsx +++ b/tests/features/stats/TrafficTab.test.tsx @@ -102,3 +102,12 @@ describe("Traffic page", () => { expect(screen.queryByText("YOW")).not.toBeInTheDocument(); }); }); + +it("draws hourly sparks for observations, areas and the busiest hour, and a presence strip for active hours", () => { + const at = (h: number, iata: string, n: number) => ({ hour: Date.UTC(2026, 8, 19, h), iata, observationCount: n, uniquePackets: 0, activeObservers: 0 }); + query.data = [at(10, "YOW", 5), at(11, "YOW", 9), at(11, "YUL", 2), at(12, "YOW", 3)]; + const { container } = render(); + expect(container.querySelectorAll("polyline")).toHaveLength(3); + expect(container.querySelectorAll("[data-peak]")).toHaveLength(1); + expect(container.querySelectorAll("rect")).toHaveLength(1); +}); diff --git a/tests/features/stats/cards.test.tsx b/tests/features/stats/cards.test.tsx new file mode 100644 index 0000000..6255e2f --- /dev/null +++ b/tests/features/stats/cards.test.tsx @@ -0,0 +1,15 @@ +import { expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import { StatCard } from "../../../src/features/stats/cards"; + +it("marks the peak hour on a sparkline when asked", () => { + const { container } = render(); + const dot = container.querySelector("[data-peak]")!; + expect(dot.getAttribute("x1")).toBe("40"); +}); + +it("draws a presence strip with one bar per run of reporting hours", () => { + const { container } = render(); + const bars = [...container.querySelectorAll("rect")]; + expect(bars.map((b) => [b.getAttribute("x"), b.getAttribute("width")])).toEqual([["0", "48"], ["72", "24"]]); +}); diff --git a/tests/features/stats/paths.test.ts b/tests/features/stats/paths.test.ts index e0f5c7a..840559d 100644 --- a/tests/features/stats/paths.test.ts +++ b/tests/features/stats/paths.test.ts @@ -16,6 +16,7 @@ describe("path analytics charts", () => { expect(hours.map((h) => h.hour)).toEqual([0, hour, 2 * hour]); expect(hours.map((h) => h.oneByte)).toEqual([2, null, 0]); expect(hours.map((h) => h.twoByte)).toEqual([3, null, 0]); + expect(hours.map((h) => h.receptions)).toEqual([8, null, 2]); expect(JSON.stringify(fixture)).toBe(before); expect(pathHours(undefined)).toEqual([]); expect(pathHours({ ...fixture, until: 30 * 24 * hour + hour / 2 })).toHaveLength(721); diff --git a/tests/features/stats/traffic.test.ts b/tests/features/stats/traffic.test.ts index 3a9d2a3..be2553d 100644 --- a/tests/features/stats/traffic.test.ts +++ b/tests/features/stats/traffic.test.ts @@ -17,6 +17,7 @@ describe("traffic exploration", () => { expect(model.hours).toHaveLength(24); expect(model.hours.at(-2)?.total).toBeNull(); expect(model.hours.at(-3)?.total).toBe(5); + expect(model.hours.slice(-3).map((h) => h.areas)).toEqual([2, null, 1]); expect(model.total).toBe(12); expect(model.reportedHours).toBe(2); expect(model.areas.map((area) => [area.name, area.total])).toEqual([["YVR", 9], ["YOW", 3]]);