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 (
-
{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]]);