Frontend redesign chunk 6: Live view
New components/LiveView.tsx renders the default landing at "/": full-bleed MapView (rail + legend from chunk 5) plus a new TimeScrubber strip below it — real call-density bars over the selected 1h/6h/24h/7d window, tinted by the worst severity in each bucket, playhead pinned to NOW. The playhead doesn't scrub yet; that needs `resolved_at` on incidents, which doesn't exist server-side (blocked chunk 13, in DEFERRED.md) — the density data itself is live, not a fixture. Distinguishes the two empty states UI_REDESIGN.md §4 calls out: a configured-but-quiet org (nodes online, zero active incidents) now shows "Listening — last check-in Xm ago" instead of rendering nothing, separate from the zero-node case (chunk 10's Activation screen). app/page.tsx's HomePage now renders LiveView directly for a signed-in, provisioned user instead of the chunk-4 interim redirect to /incidents. Per UI_REDESIGN.md chunk 6.
This commit is contained in:
@@ -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 <LiveView />;
|
||||
return <MarketingHomePage />;
|
||||
}
|
||||
|
||||
@@ -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<Date | null>(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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{nodesLoading || callsLoading ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">
|
||||
Loading map…
|
||||
</div>
|
||||
) : (
|
||||
<MapView
|
||||
nodes={nodes}
|
||||
activeCalls={activeCalls}
|
||||
incidents={activeIncidents}
|
||||
calls={recentCalls}
|
||||
lastUpdated={lastUpdated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
||||
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
|
||||
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TimeScrubber calls={recentCalls} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="h-[84px] bg-surface/90 border-t border-line px-4 flex items-center gap-4">
|
||||
<div className="flex-1 h-12 flex items-end gap-[2px]">
|
||||
{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 (
|
||||
<div
|
||||
key={i}
|
||||
title={b.count > 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) */}
|
||||
<div className="relative w-0">
|
||||
<div className="absolute right-0 -top-14 bottom-0 w-[2px] bg-accent" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{RANGES.map((r, i) => (
|
||||
<button
|
||||
key={r.label}
|
||||
onClick={() => setRangeIdx(i)}
|
||||
className={`px-2 py-1 rounded text-xs font-mono transition-colors ${
|
||||
i === rangeIdx ? "bg-accent text-white" : "text-ink-muted hover:text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-2 text-xs text-ink-muted font-mono">NOW</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user