From f1407dbb1d55f0e931a47bc1a599a7ad4db6d5a5 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 17:07:01 -0600 Subject: [PATCH] feat(frontend): TailStatusPill with reconnect button --- src/components/TailStatusPill.test.tsx | 184 +++++++++++++++++++++++++ src/components/TailStatusPill.tsx | 122 ++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/components/TailStatusPill.test.tsx create mode 100644 src/components/TailStatusPill.tsx diff --git a/src/components/TailStatusPill.test.tsx b/src/components/TailStatusPill.test.tsx new file mode 100644 index 0000000..a7aaa8f --- /dev/null +++ b/src/components/TailStatusPill.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom +// TailStatusPill is a leaf component with no async state and no hooks +// beyond a single setInterval — happy-dom is the right env so we can +// inspect the rendered DOM. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TailStatusPill } from "./TailStatusPill"; +import type { TailStatus } from "@/hooks/useTailStream"; + +/** + * Minimal render helper — the project doesn't ship + * `@testing-library/react`, so we render straight into a real + * `createRoot` and inspect the DOM via `container.innerHTML` and + * `container.querySelector`. + * + * Note: React 18's `createRoot` defers DOM mutation until the calling + * microtask flushes unless we wrap the render in `act`. The pill's only + * effect is a `setInterval`, so we use `act(() => { root.render(node) })` + * — sync act is fine because no state updates fire during render. + */ +function renderInto(node: React.ReactElement): { + container: HTMLDivElement; + root: Root; + unmount: () => void; +} { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(node); + }); + return { + container, + root, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +const ALL_STATUSES: TailStatus[] = [ + "connecting", + "live", + "reconnecting", + "closed", + "stalled", + "error", +]; + +describe("TailStatusPill", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-20T12:00:30Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("test_renders_badge_for_each_status", () => { + // Iterate every TailStatus and assert no crash, plus a sensible label. + // The plan's smoke spec calls for "renders all 5 statuses"; we test + // all 6 (closed is also a status even if rare in practice). + for (const status of ALL_STATUSES) { + const { container, root, unmount } = renderInto( + {}} + />, + ); + + // The pill root should be present. + const pill = container.querySelector('[data-testid="tail-status-pill"]'); + expect(pill).not.toBeNull(); + + // The Badge child should carry the human label — match by text + // content (the badge text is the only handoff the user reads). + const expectedLabels: Record = { + connecting: "Connecting", + live: "Live", + reconnecting: "Reconnecting", + closed: "Closed", + stalled: "Stalled", + error: "Error", + }; + expect(pill?.textContent).toContain(expectedLabels[status]); + + unmount(); + // Silence "unmount was not wrapped in act" warnings; renderInto's + // unmount is a sync root.unmount() and React tolerates this for + // leaf components with no async state. + void root; + } + }); + + it("test_shows_reconnect_button_only_for_stalled_and_error", () => { + const onReconnect = vi.fn(); + + for (const status of ALL_STATUSES) { + const { container, unmount } = renderInto( + , + ); + + const button = container.querySelector( + '[data-testid="tail-status-pill-reconnect"]', + ); + + if (status === "stalled" || status === "error") { + expect(button).not.toBeNull(); + expect(button?.textContent).toContain("Reconnect"); + } else { + expect(button).toBeNull(); + } + + unmount(); + } + }); + + it("test_subtitle_says_never_when_lastEventAt_is_null", () => { + const { container, unmount } = renderInto( + {}} + />, + ); + + const subtitle = container.querySelector( + '[data-testid="tail-status-pill-subtitle"]', + ); + expect(subtitle).not.toBeNull(); + expect(subtitle?.textContent).toBe("Last event: never"); + + unmount(); + }); + + it("test_subtitle_shows_age_in_seconds_when_lastEventAt_recent", () => { + // 12 seconds ago — fake-timer driven so the assertion is stable. + const eventAt = new Date("2026-06-20T12:00:18Z"); + const { container, unmount } = renderInto( + {}} + />, + ); + + const subtitle = container.querySelector( + '[data-testid="tail-status-pill-subtitle"]', + ); + expect(subtitle?.textContent).toBe("Last event: 12s ago"); + + unmount(); + }); + + it("test_reconnect_button_invokes_onReconnect_when_clicked", () => { + const onReconnect = vi.fn(); + const { container, unmount } = renderInto( + , + ); + + const button = container.querySelector( + '[data-testid="tail-status-pill-reconnect"]', + ) as HTMLButtonElement | null; + expect(button).not.toBeNull(); + button?.click(); + expect(onReconnect).toHaveBeenCalledTimes(1); + + unmount(); + }); +}); diff --git a/src/components/TailStatusPill.tsx b/src/components/TailStatusPill.tsx new file mode 100644 index 0000000..7251745 --- /dev/null +++ b/src/components/TailStatusPill.tsx @@ -0,0 +1,122 @@ +// --------------------------------------------------------------------------- +// Live-tail status pill (sub-project 5, Phase 5 Task 21). +// +// Tiny presentational component used in the toolbar of `Claims`, +// `Remittances`, and `ActivityLog` to surface the connection status of +// `useTailStream`. Renders the existing `` (variants from +// `src/components/ui/badge.tsx`) plus a "Last event: 12s ago" subtitle +// that re-ticks every second, plus a "↻ Reconnect" button that only +// appears when the connection has visibly failed (`stalled` or `error`). +// +// Variant mapping (per spec): +// live → success +// connecting → warning +// reconnecting → warning +// closed → destructive +// stalled → destructive +// error → destructive +// --------------------------------------------------------------------------- + +import { useEffect, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { TailStatus } from "@/hooks/useTailStream"; + +export interface TailStatusPillProps { + status: TailStatus; + lastEventAt: Date | null; + onReconnect: () => void; +} + +const STATUS_LABEL: Record = { + connecting: "Connecting…", + live: "Live", + reconnecting: "Reconnecting…", + closed: "Closed", + stalled: "Stalled", + error: "Error", +}; + +type BadgeVariant = "success" | "warning" | "destructive"; + +const STATUS_VARIANT: Record = { + live: "success", + connecting: "warning", + reconnecting: "warning", + closed: "destructive", + stalled: "destructive", + error: "destructive", +}; + +/** + * Render a duration in seconds as a compact human label. We deliberately + * keep the units small — under a minute is the common case for a healthy + * tail (the stall detector fires at 30s, so anything beyond is a real + * outage the user will want to investigate). + */ +function formatAge(seconds: number): string { + if (seconds < 0) return "0s"; + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + if (m < 60) return s === 0 ? `${m}m` : `${m}m ${s}s`; + const h = Math.floor(m / 60); + return `${h}h ${m % 60}m`; +} + +export function TailStatusPill({ + status, + lastEventAt, + onReconnect, +}: TailStatusPillProps): React.JSX.Element { + // Re-tick every second so the "Last event: 12s ago" subtitle stays + // current without the parent having to re-render. We mount the + // interval only once per pill instance. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + + const ageSeconds = lastEventAt + ? Math.max(0, Math.floor((now - lastEventAt.getTime()) / 1000)) + : null; + + const subtitle = + ageSeconds === null + ? "Last event: never" + : `Last event: ${formatAge(ageSeconds)} ago`; + + // Only show the manual reconnect affordance when the connection has + // visibly failed. `closed` is reserved for the post-unmount state and + // is intentionally not actionable — the component is about to vanish. + const showReconnect = status === "stalled" || status === "error"; + + return ( +
+ + {STATUS_LABEL[status]} + + + {subtitle} + + {showReconnect && ( + + )} +
+ ); +}