slice 1g: Next.js M1 overview panel + Primary-Rule lint (ADR-0007)

Symbol command bar (Zustand active-symbol) -> OverviewPanel (price + day-change
shape/color/label per P7 G3 + Recharts sparkline + sector) + signup/login + ADR-0007
footer. next.config rewrites proxy /api -> backend:3001 (same-origin cookies). Vanilla
fetch tRPC client (decoupled from server Node types). Primary-Rule lint 2/2 green.
Slice 1 tracer bullet COMPLETE: 38 tests green, live-verified end-to-end.
This commit is contained in:
Investor Flow Build
2026-06-29 17:51:54 -04:00
parent 82079b3e66
commit a8b95e36e3
10 changed files with 567 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useState } from "react";
import { useActiveSymbol } from "@/stores/active-symbol-store";
import { api, type Snapshot } from "@/lib/trpc";
import { UI_STRINGS } from "@/lib/strings";
import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
// M1 Symbol Overview (DESIGN.md §4). Reads market.snapshot from cache; polls so the
// stale-while-revalidate background refresh shows up once the cache fills. P7: the day-change
// uses shape (▲/▼) + color + a text label — never color alone (G3). No imperative verbs (ADR-0007).
export function OverviewPanel() {
const symbol = useActiveSymbol((s) => s.activeSymbol);
const [snap, setSnap] = useState<Snapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true); setError(null);
const load = () => api.market.snapshot(symbol)
.then((s) => { if (!cancelled) { setSnap(s); setLoading(false); } })
.catch((e) => { if (!cancelled) { setError(e instanceof Error ? e.message : "request failed"); setLoading(false); } });
load();
const t = setInterval(load, 5000); // poll for SWR refresh
return () => { cancelled = true; clearInterval(t); };
}, [symbol]);
const q = snap?.quote ?? null;
const sector = snap?.sector ?? null;
const candles = snap?.candles ?? null;
const isCrypto = sector?.tickerKind === "crypto";
const up = (q?.change ?? 0) >= 0;
const spark = (candles ?? []).map((c, i) => ({ i, c: c.c }));
return (
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
<header className="flex items-baseline justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-[#e6e7ec]">{symbol}</h2>
<p className="text-sm text-[#8a8b9a]">{sector?.name ?? sector?.sector ?? ""}</p>
</div>
{snap && (snap.stale.quote || snap.stale.candles || snap.stale.sector) && (
<span className="text-[10px] uppercase tracking-wider text-[#fcd34d] border border-[#78350f] rounded px-2 py-0.5">{UI_STRINGS.staleBadge}</span>
)}
</header>
{loading && !snap ? (
<p className="text-sm text-[#5a5b6a] py-8">{UI_STRINGS.loadingSnapshot}</p>
) : error ? (
<p className="text-sm text-[#ef4444] py-8">{error}</p>
) : !q ? (
<p className="text-sm text-[#5a5b6a] py-8">{UI_STRINGS.noData}</p>
) : (
<div className="mt-4 space-y-4">
<div className="flex items-end gap-6">
<div>
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a]">{UI_STRINGS.priceLabel}</p>
<p className="text-3xl font-mono font-bold text-[#e6e7ec]">${q.price?.toFixed(2)}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a]">{UI_STRINGS.dayChangeLabel}</p>
<p className={`text-lg font-mono font-semibold flex items-center gap-1 ${up ? "text-[#34d399]" : "text-[#fbbf24]"}`}>
<span aria-hidden>{up ? "▲" : "▼"}</span>
<span>{up ? "+" : ""}{q.change?.toFixed(2)} ({up ? "+" : ""}{(q.changePercent ?? 0).toFixed(2)}%)</span>
</p>
</div>
</div>
{spark.length > 1 && (
<figure>
<div className="h-24">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={spark} margin={{ top: 4, right: 4, bottom: 4, left: 4 }}>
<YAxis domain={["auto", "auto"]} hide />
<Line type="monotone" dataKey="c" stroke="#4f8cff" strokeWidth={1.5} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
<figcaption className="text-[11px] text-[#5a5b6a] mt-1">{UI_STRINGS.sparklineCaption}</figcaption>
</figure>
)}
{sector && (
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
<div className="flex justify-between"><dt className="text-[#5a5b6a]">{UI_STRINGS.sectorLabel}</dt><dd className="text-[#e6e7ec]">{sector.sector ?? "—"}</dd></div>
<div className="flex justify-between"><dt className="text-[#5a5b6a]">{UI_STRINGS.industryLabel}</dt><dd className="text-[#e6e7ec]">{sector.industry ?? "—"}</dd></div>
<div className="flex justify-between"><dt className="text-[#5a5b6a]">{UI_STRINGS.tickerKindLabel}</dt><dd className="text-[#e6e7ec]">{sector.tickerKind}</dd></div>
</dl>
)}
{isCrypto && <p className="text-[11px] text-[#fcd34d] border border-[#78350f] rounded p-2">{UI_STRINGS.cryptoNotice}</p>}
</div>
)}
</section>
);
}