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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user