diff --git a/drb-frontend/app/page.tsx b/drb-frontend/app/page.tsx index cfe35b8..4fb7b79 100644 --- a/drb-frontend/app/page.tsx +++ b/drb-frontend/app/page.tsx @@ -1,13 +1,12 @@ "use client"; -import { useEffect } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { LinkButton } from "@/components/ui/Button"; import { Card } from "@/components/ui/Card"; import { Badge } from "@/components/ui/Badge"; import { PLANS } from "@/lib/billing"; import { useAuth } from "@/components/AuthProvider"; +import { LiveView } from "@/components/LiveView"; const CAPABILITIES = [ { @@ -147,21 +146,15 @@ function MarketingHomePage() { /** * "/" is marketing for a signed-out visitor and Live (the map) for anyone - * signed in — see UI_REDESIGN.md §3, "the map is the home screen". The Live - * screen itself lands in chunk 6; until then a signed-in, provisioned user - * is sent to /incidents rather than shown stale marketing copy. A user with - * no org_id yet still sees marketing (unchanged — that's the pre-redesign - * behaviour for an unprovisioned account, out of scope here). + * signed in — see UI_REDESIGN.md §3, "the map is the home screen". A user + * with no org_id yet still sees marketing (unchanged — that's the + * pre-redesign behaviour for an unprovisioned account, out of scope here; + * ChromeSwitcher's own no-claim guard only fires off marketing paths). */ export default function HomePage() { const { user, loading, orgId } = useAuth(); - const router = useRouter(); - useEffect(() => { - if (loading || !user || !orgId) return; - router.replace("/incidents"); - }, [loading, user, orgId, router]); - - if (loading || (user && orgId)) return null; + if (loading) return null; + if (user && orgId) return ; return ; } diff --git a/drb-frontend/components/LiveView.tsx b/drb-frontend/components/LiveView.tsx new file mode 100644 index 0000000..47bd450 --- /dev/null +++ b/drb-frontend/components/LiveView.tsx @@ -0,0 +1,79 @@ +"use client"; + +/** + * Live — the default landing for every authenticated user (UI_REDESIGN.md + * §3, §5.1). Full-bleed map with the incident rail/legend MapView already + * renders (chunk 5), plus a time-density scrubber strip below it. + */ +import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; +import { useNodes } from "@/lib/useNodes"; +import { useActiveCalls, useCalls } from "@/lib/useCalls"; +import { useActiveIncidents } from "@/lib/useIncidents"; +import { TimeScrubber } from "@/components/TimeScrubber"; + +const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); + +function timeAgo(date: Date): string { + const s = Math.floor((Date.now() - date.getTime()) / 1000); + if (s < 60) return `${s}s ago`; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + return `${Math.floor(s / 3600)}h ago`; +} + +export function LiveView() { + const { nodes, loading: nodesLoading } = useNodes(); + const activeCalls = useActiveCalls(); + const activeIncidents = useActiveIncidents(); + // Wide-ish recent window: feeds both the incident-path polyline (chunk 5) + // and the scrubber's density bars. Not a fixture — real recent calls. + const { calls: recentCalls, loading: callsLoading } = useCalls(500); + const [lastUpdated, setLastUpdated] = useState(null); + + useEffect(() => { + if (!nodesLoading) setLastUpdated(new Date()); + }, [nodes, activeCalls, activeIncidents, nodesLoading]); + + const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0; + const mostRecentSeen = configuredButQuiet + ? nodes + .map((n) => n.last_seen) + .filter((s): s is string => !!s) + .sort() + .at(-1) + : null; + + return ( +
+
+ {nodesLoading || callsLoading ? ( +
+ Loading map… +
+ ) : ( + + )} + + {/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's + Activation screen handles the zero-node case). A node that's + online but has heard nothing is NOT the same empty state as an + org with no equipment. */} + {configuredButQuiet && ( +
+ + ● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""} + +
+ )} +
+ + +
+ ); +} diff --git a/drb-frontend/components/TimeScrubber.tsx b/drb-frontend/components/TimeScrubber.tsx new file mode 100644 index 0000000..e103181 --- /dev/null +++ b/drb-frontend/components/TimeScrubber.tsx @@ -0,0 +1,91 @@ +"use client"; + +/** + * Live's time scrubber — call-density bars over the selected window, tinted + * by the worst severity in each bucket, playhead pinned to NOW. Range + * presets change which window is densitized; the playhead itself does not + * move in this build — history scrub needs `resolved_at` on incidents, + * which doesn't exist yet (UI_REDESIGN.md chunk 13, blocked on backend, + * tracked in DEFERRED.md). This is real call density from live data, not a + * fixture — only the scrub interaction is deferred. + */ +import { useMemo, useState } from "react"; +import type { CallRecord } from "@/lib/types"; +import { SEVERITY_COLORS, severityRank } from "@/lib/severity"; + +const RANGES = [ + { label: "1h", ms: 60 * 60 * 1000 }, + { label: "6h", ms: 6 * 60 * 60 * 1000 }, + { label: "24h", ms: 24 * 60 * 60 * 1000 }, + { label: "7d", ms: 7 * 24 * 60 * 60 * 1000 }, +] as const; + +const BUCKETS = 48; + +export function TimeScrubber({ calls }: { calls: CallRecord[] }) { + const [rangeIdx, setRangeIdx] = useState(2); // default 24h + + const range = RANGES[rangeIdx]; + + const buckets = useMemo(() => { + const now = Date.now(); + const start = now - range.ms; + const bucketMs = range.ms / BUCKETS; + const counts = Array.from({ length: BUCKETS }, () => ({ count: 0, worstRank: -1 })); + for (const c of calls) { + const t = new Date(c.started_at).getTime(); + if (Number.isNaN(t) || t < start || t > now) continue; + const idx = Math.min(BUCKETS - 1, Math.floor((t - start) / bucketMs)); + counts[idx].count += 1; + const rank = severityRank(c.severity); + if (rank > counts[idx].worstRank) counts[idx].worstRank = rank; + } + return counts; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [calls, rangeIdx]); + + const maxCount = Math.max(1, ...buckets.map((b) => b.count)); + + return ( +
+
+ {buckets.map((b, i) => { + const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100); + const color = + b.worstRank === 3 + ? SEVERITY_COLORS.major + : b.worstRank === 2 + ? SEVERITY_COLORS.moderate + : b.worstRank >= 0 + ? "var(--ink-2)" + : "var(--line-strong)"; + return ( +
0 ? `${b.count} call${b.count !== 1 ? "s" : ""}` : undefined} + style={{ height: `${heightPct}%`, background: color, flex: 1, borderRadius: 1, minWidth: 2 }} + /> + ); + })} + {/* Playhead — pinned to NOW; doesn't move yet (see file header) */} +
+
+
+
+
+ {RANGES.map((r, i) => ( + + ))} + NOW +
+
+ ); +}