diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx
new file mode 100644
index 0000000..ee196a6
--- /dev/null
+++ b/src/features/packets/PacketExpansion.tsx
@@ -0,0 +1,76 @@
+import type { PacketSummary } from "../../types/api";
+import { formatPropagation } from "../../lib/formatters";
+import { Timestamp } from "../../components/Timestamp";
+import { usePacketDetail } from "./usePacketDetail";
+import { ObservationTable } from "./ObservationTable";
+
+// Roughly what fits the scroll cap; observations are unbounded server-side.
+const SKELETON_ROW_CAP = 12;
+
+const ACTION_BUTTON_CLASS =
+ "border border-border rounded-sm px-2 py-0.5 bg-bg-raised text-text-normal hover:bg-text-normal/3 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors";
+
+interface Props {
+ packet: PacketSummary;
+ onOpenAnalyzer: () => void;
+ onViewPath: () => void;
+ selectedObservationId: number | null;
+ onSelectObservation: (id: number) => void;
+}
+
+// Expanded region under a packet row: a summary-driven timing strip (instant, no fetch wait) plus
+// the per-observer table, which does wait on usePacketDetail.
+export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: Props) {
+ const { data, isLoading, isError, refetch } = usePacketDetail(packet.packetHash);
+ // firstHeardAt/lastHeardAt are epoch ms (same unit Timestamp expects), so the difference is
+ // already in ms for formatPropagation -- no *1000 here.
+ const spread = packet.lastHeardAt - packet.firstHeardAt;
+ const ready = !isLoading && !isError;
+ // The summary already knows the count is zero, so skip the fetch-driven states entirely rather
+ // than showing a blank (0-row) skeleton while it loads.
+ const noObservations = packet.observationCount === 0;
+ const emptyState =
No observations
;
+
+ return (
+
+
+ first
+ last
+ spread {formatPropagation(spread)}
+
+
+
+
+
+ {noObservations ? (
+ emptyState
+ ) : isLoading ? (
+
+ {Array.from({ length: Math.min(packet.observationCount, SKELETON_ROW_CAP) }).map((_, i) => (
+
+ ))}
+
+ ) : isError ? (
+
+ Failed to load observations
+
+
+ ) : data && data.observations.length === 0 ? (
+ emptyState
+ ) : data ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/tests/features/packets/PacketExpansion.test.tsx b/tests/features/packets/PacketExpansion.test.tsx
new file mode 100644
index 0000000..b1b9074
--- /dev/null
+++ b/tests/features/packets/PacketExpansion.test.tsx
@@ -0,0 +1,131 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { PacketExpansion } from "../../../src/features/packets/PacketExpansion";
+import type { PacketSummary, Observation } from "../../../src/types/api";
+
+const usePacketDetail = vi.fn();
+vi.mock("../../../src/features/packets/usePacketDetail", () => ({
+ usePacketDetail: (h: string | null) => usePacketDetail(h),
+}));
+
+const pkt = (over: Partial = {}): PacketSummary => ({
+ packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT",
+ routeType: 1, routeTypeName: "FLOOD",
+ firstHeardAt: 1700000000, lastHeardAt: 1700000002, observationCount: 3, ...over,
+});
+
+const obs = (id: number, over: Partial = {}): Observation => ({
+ id, observerId: `o${id}`, observerName: `Observer ${id}`, iata: "YVR",
+ heardAt: 1700000000 + id, pathLength: { raw: "41", hashSize: 1, hopCount: 1 },
+ sourceBroker: "b1", resolvedPath: [], ...over,
+});
+
+const props = {
+ packet: pkt(), onOpenAnalyzer: () => {}, onViewPath: () => {},
+ selectedObservationId: null, onSelectObservation: () => {},
+};
+
+beforeEach(() => usePacketDetail.mockReset());
+
+describe("PacketExpansion", () => {
+ it("shows one skeleton row per expected observation while loading", () => {
+ usePacketDetail.mockReturnValue({ isLoading: true });
+ render();
+ expect(screen.getAllByTestId("observation-skeleton")).toHaveLength(3);
+ });
+
+ it("caps skeleton rows at the scroll cap for a large observation count", () => {
+ usePacketDetail.mockReturnValue({ isLoading: true });
+ render();
+ expect(screen.getAllByTestId("observation-skeleton")).toHaveLength(12);
+ });
+
+ it("shows an error line with retry on failure", () => {
+ usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() });
+ render();
+ expect(screen.getByText("Failed to load observations")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
+ });
+
+ it("calls refetch when retry is clicked", () => {
+ const refetch = vi.fn();
+ usePacketDetail.mockReturnValue({ isError: true, refetch });
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /retry/i }));
+ expect(refetch).toHaveBeenCalled();
+ });
+
+ it("shows an empty state when the packet has no observations", () => {
+ usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } });
+ render();
+ expect(screen.getByText("No observations")).toBeInTheDocument();
+ });
+
+ it("shows the empty state immediately when the summary count is zero, without a skeleton", () => {
+ usePacketDetail.mockReturnValue({ isLoading: true });
+ render();
+ expect(screen.getByText("No observations")).toBeInTheDocument();
+ expect(screen.queryByTestId("observation-skeleton")).not.toBeInTheDocument();
+ });
+
+ it("renders the timing strip from the summary without waiting for the fetch", () => {
+ usePacketDetail.mockReturnValue({ isLoading: true });
+ render();
+ expect(screen.getByText(/spread/i)).toBeInTheDocument();
+ });
+
+ it("formats the spread as first/last converted from milliseconds, not re-scaled", () => {
+ usePacketDetail.mockReturnValue({ isLoading: true });
+ render();
+ expect(screen.getByText("spread 2.500s")).toBeInTheDocument();
+ });
+
+ it("renders the observation table once data resolves", () => {
+ usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [obs(1), obs(2)] } });
+ render();
+ expect(screen.getByText("Observer 1")).toBeInTheDocument();
+ expect(screen.getByText("Observer 2")).toBeInTheDocument();
+ });
+
+ it("wires selectedObservationId and onSelectObservation through to the observation table", () => {
+ const onSelectObservation = vi.fn();
+ usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [obs(1), obs(2)] } });
+ render();
+ const rows = screen.getAllByRole("row").slice(1);
+ expect(rows[1]).toHaveAttribute("aria-selected", "true");
+ fireEvent.click(screen.getByText("Observer 1"));
+ expect(onSelectObservation).toHaveBeenCalledWith(1);
+ });
+
+ it("disables both action buttons while loading", () => {
+ usePacketDetail.mockReturnValue({ isLoading: true });
+ render();
+ expect(screen.getByRole("button", { name: "Open analyzer" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled();
+ });
+
+ it("disables both action buttons on error", () => {
+ usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() });
+ render();
+ expect(screen.getByRole("button", { name: "Open analyzer" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled();
+ });
+
+ it("enables both action buttons once the fetch resolves", () => {
+ usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } });
+ render();
+ expect(screen.getByRole("button", { name: "Open analyzer" })).not.toBeDisabled();
+ expect(screen.getByRole("button", { name: "View path on map" })).not.toBeDisabled();
+ });
+
+ it("calls onOpenAnalyzer and onViewPath when their buttons are clicked", () => {
+ const onOpenAnalyzer = vi.fn();
+ const onViewPath = vi.fn();
+ usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } });
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Open analyzer" }));
+ fireEvent.click(screen.getByRole("button", { name: "View path on map" }));
+ expect(onOpenAnalyzer).toHaveBeenCalledTimes(1);
+ expect(onViewPath).toHaveBeenCalledTimes(1);
+ });
+});