From cb8349837bbe734e6f0f5b5a821e83909f279b2c Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 8 May 2026 13:41:42 +1000 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20cockpit=20chrome=20=E2=80=94?= =?UTF-8?q?=20drop=20Graze=20layout,=20slim=20sticky=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The (operator) route group no longer wraps in DashboardLayout. The Graze admin sidebar (Home / Endpoints / API Manager / Providers / etc.) and the top bar (lang switcher, theme toggle, breadcrumbs) are irrelevant to running the operator's day and were stealing attention from the actual decision surface. Replaces all of that with one sticky 56px row: - status dot + 'gary cockpit' wordmark - tabs: Inbox / Calibration / Activity, with active background fill - Inbox tab badge showing combined gate + send count - match-rate pill (color-toned at 75% / 50% thresholds) - last-refresh indicator that ticks each second Status pulled from new /api/operator/status — one round-trip per 5s poll instead of three separate calls. Header tints amber and shows 'offline' label if the upstream gary-ui is unreachable. Drops the redundant page-level
+

on each operator route since the active tab already names the surface. Renames Approvals to Inbox in the cockpit (more cockpit-y; Calibration and Activity stay). Removes FrequencyStrip and OperatorNav components (replaced by the header). The frequency telemetry data lives entirely on /calibration where it's the page subject — the strip was duplicating the match-rate chip on every other surface. Effect: gate cards visible above the fold went from 5 to 9. Cockpit no longer reads as a guest page in someone else's app. --- src/app/(operator)/CockpitHeader.tsx | 162 ++++++++++++++++++++++++ src/app/(operator)/FrequencyStrip.tsx | 155 ----------------------- src/app/(operator)/OperatorNav.tsx | 46 ------- src/app/(operator)/activity/page.tsx | 12 +- src/app/(operator)/approvals/page.tsx | 11 +- src/app/(operator)/calibration/page.tsx | 15 +-- src/app/(operator)/layout.tsx | 33 ++--- src/app/api/operator/status/route.ts | 41 ++++++ 8 files changed, 223 insertions(+), 252 deletions(-) create mode 100644 src/app/(operator)/CockpitHeader.tsx delete mode 100644 src/app/(operator)/FrequencyStrip.tsx delete mode 100644 src/app/(operator)/OperatorNav.tsx create mode 100644 src/app/api/operator/status/route.ts diff --git a/src/app/(operator)/CockpitHeader.tsx b/src/app/(operator)/CockpitHeader.tsx new file mode 100644 index 00000000..21af1b98 --- /dev/null +++ b/src/app/(operator)/CockpitHeader.tsx @@ -0,0 +1,162 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; +import { cn } from "@/shared/utils/cn"; + +const TABS: Array<{ href: string; label: string }> = [ + { href: "/approvals", label: "Inbox" }, + { href: "/calibration", label: "Calibration" }, + { href: "/activity", label: "Activity" }, +]; + +interface StatusSnapshot { + gateCount: number; + sendCount: number; + matchPct: number | null; + ok: boolean; +} + +/** + * Slim cockpit chrome — replaces Graze's DashboardLayout for the (operator) + * route group. One sticky row: brand, tabs (with active fill), live status + * pill, theme toggle. No left sidebar, no breadcrumbs, no page H1 noise. + */ +export default function CockpitHeader() { + const pathname = usePathname() ?? ""; + const [status, setStatus] = useState({ + gateCount: 0, + sendCount: 0, + matchPct: null, + ok: true, + }); + const [refreshedAt, setRefreshedAt] = useState(null); + + // Lightweight 5s poll of /api/operator/status. Server-side aggregates the + // three counts we want without going all the way back to gary-ui via SSR. + useEffect(() => { + let alive = true; + async function tick() { + try { + const res = await fetch("/api/operator/status", { cache: "no-store" }); + if (!res.ok) { + if (alive) setStatus((s) => ({ ...s, ok: false })); + return; + } + const body = (await res.json()) as StatusSnapshot; + if (alive) { + setStatus({ ...body, ok: true }); + setRefreshedAt(new Date()); + } + } catch { + if (alive) setStatus((s) => ({ ...s, ok: false })); + } + } + void tick(); + const id = setInterval(tick, 5000); + return () => { + alive = false; + clearInterval(id); + }; + }, []); + + return ( +
+
+ +
+
+ ); +} + +function RefreshIndicator({ at, ok }: { at: Date | null; ok: boolean }) { + const [, force] = useState(0); + useEffect(() => { + const id = setInterval(() => force((n) => n + 1), 1000); + return () => clearInterval(id); + }, []); + + if (!ok) return offline; + if (!at) return ; + const sec = Math.max(0, Math.round((Date.now() - at.getTime()) / 1000)); + const label = sec < 5 ? "just now" : sec < 60 ? `${sec}s ago` : `${Math.round(sec / 60)}m ago`; + return ( + + {label} + + ); +} diff --git a/src/app/(operator)/FrequencyStrip.tsx b/src/app/(operator)/FrequencyStrip.tsx deleted file mode 100644 index 33f7efec..00000000 --- a/src/app/(operator)/FrequencyStrip.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { garyUi, type DecisionRecord, type MetricsByInstance } from "@/lib/gary-ui/client"; -import Sparkline, { buildDailyBuckets } from "./Sparkline"; - -/** - * FrequencyStrip — top-of-page strip on every operator surface. - * - * Server-renders two cards (gates-per-day sparkline and recommended-match - * ratio) plus a threshold-crossing alert when applicable. Both fetches fail - * silently — empty placeholder rather than crashing the layout — because - * the strip is chrome on every operator route. - */ -export default async function FrequencyStrip() { - let metrics: MetricsByInstance | null = null; - let decisions: DecisionRecord[] = []; - try { - metrics = await garyUi.metrics(); - } catch { - metrics = {}; - } - try { - decisions = await garyUi.decisions(); - } catch { - decisions = []; - } - - const aggregated = aggregateMetrics(metrics); - const daily = buildDailyBuckets(aggregated.gatesByDay, 30); - const total30 = daily.reduce((a, d) => a + d.count, 0); - const last7 = daily.slice(-7).reduce((a, d) => a + d.count, 0); - const prev7 = daily.slice(-14, -7).reduce((a, d) => a + d.count, 0); - const trendPct = prev7 > 0 ? ((last7 - prev7) / prev7) * 100 : null; - - const evaluable = decisions.filter( - (d) => d.recommended_match === true || d.recommended_match === false - ); - const matched = evaluable.filter((d) => d.recommended_match === true).length; - const matchPct = evaluable.length === 0 ? null : Math.round((matched / evaluable.length) * 100); - - const recentCrossings = aggregated.crossings.filter((c) => { - const t = new Date(c.crossed_at).getTime(); - return Number.isFinite(t) && Date.now() - t < 24 * 3600 * 1000; - }); - - return ( -
- {recentCrossings.length > 0 && ( -
- - Gate frequency above threshold — Gary is asking too often. - - - Review patterns → - -
- )} - -
-
-
-
- Gates per day · 30d -
-
- {total30 === 0 ? ( - no data yet - ) : ( - <> - {total30} total - {trendPct !== null && ( - <> - {" · "} - 0 - ? "text-amber-600 dark:text-amber-400" - : "text-text-muted" - } - > - {trendPct < 0 ? "↓" : trendPct > 0 ? "↑" : "·"}{" "} - {Math.abs(Math.round(trendPct))}% vs prior 7d - - - )} - - )} -
-
-
- -
-
- -
-
- Match rate -
-
- = 75 - ? "text-emerald-600 dark:text-emerald-400" - : matchPct < 50 - ? "text-amber-600 dark:text-amber-400" - : "text-text-main" - }`} - > - {matchPct === null ? "—" : `${matchPct}%`} - - - {matchPct === null - ? "no decisions yet" - : `${matched} of ${evaluable.length} aligned`} - -
-
-
-
- ); -} - -interface AggregatedMetrics { - gatesByDay: Record; - autoByDay: Record; - crossings: Array<{ instance_id: string; shape: string; gate_count: number; threshold: number; crossed_at: string }>; -} - -function aggregateMetrics(metrics: MetricsByInstance | null): AggregatedMetrics { - const gatesByDay: Record = {}; - const autoByDay: Record = {}; - const crossings: AggregatedMetrics["crossings"] = []; - if (!metrics) return { gatesByDay, autoByDay, crossings }; - for (const k of Object.keys(metrics)) { - const m = metrics[k]; - if (!m) continue; - for (const [d, c] of Object.entries(m.gates_by_day ?? {})) { - gatesByDay[d] = (gatesByDay[d] ?? 0) + Number(c); - } - for (const [d, c] of Object.entries(m.auto_approved_by_day ?? {})) { - autoByDay[d] = (autoByDay[d] ?? 0) + Number(c); - } - if (Array.isArray(m.threshold_crossings)) crossings.push(...m.threshold_crossings); - } - return { gatesByDay, autoByDay, crossings }; -} diff --git a/src/app/(operator)/OperatorNav.tsx b/src/app/(operator)/OperatorNav.tsx deleted file mode 100644 index ec352101..00000000 --- a/src/app/(operator)/OperatorNav.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { cn } from "@/shared/utils/cn"; - -const TABS: Array<{ href: string; label: string }> = [ - { href: "/approvals", label: "Approvals" }, - { href: "/calibration", label: "Calibration" }, - { href: "/activity", label: "Activity" }, -]; - -export default function OperatorNav() { - const pathname = usePathname() ?? ""; - - return ( - - ); -} diff --git a/src/app/(operator)/activity/page.tsx b/src/app/(operator)/activity/page.tsx index ff8841de..3d8b7dba 100644 --- a/src/app/(operator)/activity/page.tsx +++ b/src/app/(operator)/activity/page.tsx @@ -26,15 +26,7 @@ export default async function ActivityPage() { }); return ( -
-
-

Activity

-

- Decisions you've made and the work Gary did autonomously. Mark a decision as wrong to - feed the calibration loop. -

-
- +
{err && (
gary-ui unreachable: {err} @@ -42,6 +34,6 @@ export default async function ActivityPage() { )} -
+ ); } diff --git a/src/app/(operator)/approvals/page.tsx b/src/app/(operator)/approvals/page.tsx index b9efea56..dcdc920b 100644 --- a/src/app/(operator)/approvals/page.tsx +++ b/src/app/(operator)/approvals/page.tsx @@ -38,14 +38,7 @@ export default async function ApprovalsPage() { } return ( -
-
-

Approvals

-

- Unified queue: Sam-gates and pending sends from the running gary-ui backend. -

-
- +
{error && (
gary-ui unreachable: {error} @@ -53,6 +46,6 @@ export default async function ApprovalsPage() { )} {!error && } -
+ ); } diff --git a/src/app/(operator)/calibration/page.tsx b/src/app/(operator)/calibration/page.tsx index 2aef6acd..5cf1964c 100644 --- a/src/app/(operator)/calibration/page.tsx +++ b/src/app/(operator)/calibration/page.tsx @@ -82,17 +82,10 @@ export default async function CalibrationPage({ searchParams }: PageProps) { : null; return ( -
-
-
-

Calibration

-

- How often you go with Gary's recommendation, and the trend over time. Mark decisions - in Activity to seed the loop. -

-
+
+
-
+ {err && (
@@ -200,7 +193,7 @@ export default async function CalibrationPage({ searchParams }: PageProps) { climbs and the gates-per-day curve flattens.

-
+ ); } diff --git a/src/app/(operator)/layout.tsx b/src/app/(operator)/layout.tsx index 0c5e8703..ba69b466 100644 --- a/src/app/(operator)/layout.tsx +++ b/src/app/(operator)/layout.tsx @@ -1,31 +1,22 @@ -import { Suspense } from "react"; import { notFound } from "next/navigation"; -import { DashboardLayout } from "@/shared/components"; -import OperatorNav from "./OperatorNav"; -import FrequencyStrip from "./FrequencyStrip"; +import CockpitHeader from "./CockpitHeader"; +/** + * The operator route group does NOT wrap in Graze's DashboardLayout. + * The cockpit deserves its own chrome — slim sticky header with tabs + + * status pill, full-width content below. The Graze admin sidebar (Home / + * Endpoints / API Manager / Providers / etc.) is irrelevant to running + * the operator's day and was actively stealing attention from the + * actual decision surface. + */ export default function OperatorRootLayout({ children }: { children: React.ReactNode }) { if (process.env.OPERATOR_BUILD !== "true") { notFound(); } return ( - -
- - }> - - -
- {children} -
- ); -} - -function FrequencyStripFallback() { - return ( -
-
-
+
+ +
{children}
); } diff --git a/src/app/api/operator/status/route.ts b/src/app/api/operator/status/route.ts new file mode 100644 index 00000000..d65027ba --- /dev/null +++ b/src/app/api/operator/status/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { garyUi } from "@/lib/gary-ui/client"; + +export const dynamic = "force-dynamic"; + +/** + * Aggregated status pill for the cockpit header — one round-trip per + * 5-second poll instead of three. Failures collapse to zeros + ok=false + * so the header dot can tint amber without breaking the page. + */ +export async function GET() { + if (process.env.OPERATOR_BUILD !== "true") { + return NextResponse.json({ error: "not found" }, { status: 404 }); + } + + let gateCount = 0; + let sendCount = 0; + let matchPct: number | null = null; + let ok = true; + + try { + const [gates, sends, decisions] = await Promise.all([ + garyUi.gates(), + garyUi.sends(), + garyUi.decisions(), + ]); + gateCount = gates.length; + sendCount = sends.length; + const evaluable = decisions.filter( + (d) => d.recommended_match === true || d.recommended_match === false + ); + if (evaluable.length > 0) { + const matched = evaluable.filter((d) => d.recommended_match === true).length; + matchPct = Math.round((matched / evaluable.length) * 100); + } + } catch { + ok = false; + } + + return NextResponse.json({ gateCount, sendCount, matchPct, ok }); +}