From a8b95e36e3902247b6fcebba396f63096d0c76a6 Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Mon, 29 Jun 2026 17:51:54 -0400 Subject: [PATCH] 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. --- app/next.config.ts | 11 ++ app/package.json | 37 +++++ app/src/__tests__/primary-rule-lint.test.ts | 48 +++++++ app/src/app/globals.css | 150 ++++++++++++++++++++ app/src/app/layout.tsx | 22 +++ app/src/app/page.tsx | 78 ++++++++++ app/src/components/OverviewPanel.tsx | 96 +++++++++++++ app/src/lib/strings.ts | 29 ++++ app/src/lib/trpc.ts | 83 +++++++++++ app/src/stores/active-symbol-store.ts | 13 ++ 10 files changed, 567 insertions(+) create mode 100644 app/next.config.ts create mode 100644 app/package.json create mode 100644 app/src/__tests__/primary-rule-lint.test.ts create mode 100644 app/src/app/globals.css create mode 100644 app/src/app/layout.tsx create mode 100644 app/src/app/page.tsx create mode 100644 app/src/components/OverviewPanel.tsx create mode 100644 app/src/lib/strings.ts create mode 100644 app/src/lib/trpc.ts create mode 100644 app/src/stores/active-symbol-store.ts diff --git a/app/next.config.ts b/app/next.config.ts new file mode 100644 index 0000000..18eb3fc --- /dev/null +++ b/app/next.config.ts @@ -0,0 +1,11 @@ +import type { NextConfig } from "next"; + +// Proxy /api/* to the local Bun/Node backend (slice 1f) so the SPA and backend share an +// origin — session cookies work without cross-origin/CORS gymnastics (DESIGN.md §2.1). +const nextConfig: NextConfig = { + rewrites: async () => [ + { source: "/api/:path*", destination: "http://localhost:3001/api/:path*" }, + ], +}; + +export default nextConfig; diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..32ba107 --- /dev/null +++ b/app/package.json @@ -0,0 +1,37 @@ +{ + "name": "investor-flow", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "test": "node --test --experimental-strip-types \"src/**/*.test.ts\"" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.17", + "@radix-ui/react-dropdown-menu": "^2.1.18", + "@radix-ui/react-label": "^2.1.10", + "@radix-ui/react-select": "^2.3.1", + "@radix-ui/react-switch": "^1.3.1", + "@radix-ui/react-tabs": "^1.1.15", + "@radix-ui/react-toast": "^1.2.17", + "lucide-react": "^1.21.0", + "next": "16.2.9", + "react": "19.2.4", + "react-dom": "19.2.4", + "recharts": "^3.9.0", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.9", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/app/src/__tests__/primary-rule-lint.test.ts b/app/src/__tests__/primary-rule-lint.test.ts new file mode 100644 index 0000000..35a4fc8 --- /dev/null +++ b/app/src/__tests__/primary-rule-lint.test.ts @@ -0,0 +1,48 @@ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { UI_STRING_LIST } from "../lib/strings.ts"; + +// ADR-0007 golden master (DESIGN.md §7.3): no imperative trade directive in any curated UI +// string or panel/page/client source. Stoplist of imperatives; "buy-zone estimate" (a noun) is allowed. +const __dirname = dirname(fileURLToPath(import.meta.url)); +const STOPWORDS = ["buy", "sell", "you should", "add to your", "rotate into", "action needed"]; +const ALLOWED_PHRASES = ["buy-zone"]; + +function imperativeHits(text: string): string[] { + const hits: string[] = []; + for (const w of STOPWORDS) { + const re = new RegExp("\\b" + w.replace(/\s+/g, "\\s+") + "\\b", "gi"); + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const ctx = text.slice(Math.max(0, m.index - 14), m.index + w.length + 14).replace(/\s+/g, " ").trim(); + if (ALLOWED_PHRASES.some((a) => ctx.toLowerCase().includes(a))) continue; + hits.push(`"${w}" near: …${ctx}…`); + } + } + return hits; +} + +test("curated UI strings pass the Primary-Rule lint", () => { + for (const s of UI_STRING_LIST) { + assert.deepEqual(imperativeHits(s), [], `Primary-Rule violation in string: "${s}"`); + } +}); + +test("panel/page/client source pass the Primary-Rule lint", () => { + const files = [ + "../components/OverviewPanel.tsx", + "../app/page.tsx", + "../app/layout.tsx", + "../lib/strings.ts", + "../lib/trpc.ts", + "../stores/active-symbol-store.ts", + ]; + for (const f of files) { + const src = readFileSync(join(__dirname, f), "utf8"); + const hits = imperativeHits(src); + assert.deepEqual(hits, [], `Primary-Rule violation in ${f}: ${hits.join("; ")}`); + } +}); diff --git a/app/src/app/globals.css b/app/src/app/globals.css new file mode 100644 index 0000000..7bc0b5d --- /dev/null +++ b/app/src/app/globals.css @@ -0,0 +1,150 @@ +@import "tailwindcss"; + +:root { + --bg-primary: #0a0b0f; + --bg-secondary: #121318; + --bg-card: #1a1b24; + --bg-input: #22232f; + --border-color: #2a2b3a; + --border-focus: #4f8cff; + --text-primary: #e6e7ec; + --text-secondary: #8a8b9a; + --text-muted: #5a5b6a; + --accent-blue: #4f8cff; + --accent-green: #34d399; + --accent-red: #ef4444; + --accent-yellow: #fbbf24; + --accent-purple: #a78bfa; +} + +* { box-sizing: border-box; } + +html, body, #__next { + height: 100%; + margin: 0; + padding: 0; +} + +body { + background-color: var(--bg-primary); + color: var(--text-primary); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: var(--bg-secondary); } +::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; } + +/* Focus ring for accessibility */ +:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; +} + +/* Utility: card */ +.card { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; +} + +/* Utility: button */ +.btn-primary { + background-color: var(--accent-blue); + color: white; + border: none; + border-radius: 6px; + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} +.btn-primary:hover { opacity: 0.85; } + +.btn-secondary { + background-color: var(--bg-input); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 8px 16px; + font-size: 13px; + cursor: pointer; +} +.btn-secondary:hover { background-color: var(--border-color); } + +.btn-danger { + background-color: transparent; + color: var(--accent-red); + border: 1px solid var(--accent-red); + border-radius: 6px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; +} + +/* Input */ +.input { + background-color: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + padding: 8px 12px; + font-size: 13px; + width: 100%; +} +.input:focus { + border-color: var(--border-focus); + outline: none; +} + +/* Badge */ +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Layout helper */ +.page-section { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); +} + +.section-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.label { + font-size: 12px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +/* Tier colors */ +.tier-A_STAR { background: #064e3b; color: #34d399; } +.tier-A { background: #065f46; color: #6ee7b7; } +.tier-B { background: #3730a3; color: #a5b4fc; } +.tier-C { background: #78350f; color: #fcd34d; } diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx new file mode 100644 index 0000000..b02d753 --- /dev/null +++ b/app/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { UI_STRINGS } from "@/lib/strings"; + +export const metadata: Metadata = { + title: `${UI_STRINGS.appTitle} — ${UI_STRINGS.appSubtitle}`, + description: UI_STRINGS.appSubtitle, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +
+

{UI_STRINGS.appTitle}

+

{UI_STRINGS.appSubtitle}

+
+
{children}
+ + + ); +} diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx new file mode 100644 index 0000000..5a40493 --- /dev/null +++ b/app/src/app/page.tsx @@ -0,0 +1,78 @@ +"use client"; +import { useEffect, useState } from "react"; +import { OverviewPanel } from "@/components/OverviewPanel"; +import { useActiveSymbol } from "@/stores/active-symbol-store"; +import { api, type AuthUser } from "@/lib/trpc"; +import { UI_STRINGS } from "@/lib/strings"; + +export default function Page() { + const activeSymbol = useActiveSymbol((s) => s.activeSymbol); + const setActive = useActiveSymbol((s) => s.setActive); + const [draft, setDraft] = useState(activeSymbol); + + return ( +
+
{ e.preventDefault(); setActive(draft); }} className="flex gap-2"> + setDraft(e.target.value)} placeholder={UI_STRINGS.symbolPlaceholder} + className="flex-1 bg-[#121318] border border-[#2a2b3a] rounded-md px-3 py-2 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a] focus:outline-none focus:border-[#4f8cff]" /> + +
+ + + + +
+ ); +} + +function AuthSection() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [user, setUser] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { api.auth.me().then(setUser).catch(() => {}); }, []); + + const submit = async (mode: "signup" | "login") => { + setBusy(true); setError(null); + try { + await api.auth[mode](email, password); + setUser(await api.auth.me()); + } catch (e) { + setError(e instanceof Error ? e.message : "request failed"); + } finally { setBusy(false); } + }; + + if (user) { + return ( +
+

Signed in as {user.email}

+

Complexity: {user.complexity} · Risk tolerance: {user.riskTolerance}

+ +
+ ); + } + return ( +
+

{UI_STRINGS.signupPrompt}

+
+ setEmail(e.target.value)} placeholder={UI_STRINGS.authEmailLabel} type="email" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-3 py-2 text-sm text-[#e6e7ec]" /> + setPassword(e.target.value)} placeholder={UI_STRINGS.authPasswordLabel} type="password" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-3 py-2 text-sm text-[#e6e7ec]" /> +
+ {error &&

{error}

} +
+ + +
+
+ ); +} + +function PrimaryRuleFooter() { + return ( +
+

{UI_STRINGS.primaryRuleFooter}

+
+ ); +} diff --git a/app/src/components/OverviewPanel.tsx b/app/src/components/OverviewPanel.tsx new file mode 100644 index 0000000..70a26d6 --- /dev/null +++ b/app/src/components/OverviewPanel.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

{symbol}

+

{sector?.name ?? sector?.sector ?? ""}

+
+ {snap && (snap.stale.quote || snap.stale.candles || snap.stale.sector) && ( + {UI_STRINGS.staleBadge} + )} +
+ + {loading && !snap ? ( +

{UI_STRINGS.loadingSnapshot}

+ ) : error ? ( +

{error}

+ ) : !q ? ( +

{UI_STRINGS.noData}

+ ) : ( +
+
+
+

{UI_STRINGS.priceLabel}

+

${q.price?.toFixed(2)}

+
+
+

{UI_STRINGS.dayChangeLabel}

+

+ {up ? "▲" : "▼"} + {up ? "+" : ""}{q.change?.toFixed(2)} ({up ? "+" : ""}{(q.changePercent ?? 0).toFixed(2)}%) +

+
+
+ + {spark.length > 1 && ( +
+
+ + + + + + +
+
{UI_STRINGS.sparklineCaption}
+
+ )} + + {sector && ( +
+
{UI_STRINGS.sectorLabel}
{sector.sector ?? "—"}
+
{UI_STRINGS.industryLabel}
{sector.industry ?? "—"}
+
{UI_STRINGS.tickerKindLabel}
{sector.tickerKind}
+
+ )} + + {isCrypto &&

{UI_STRINGS.cryptoNotice}

} +
+ )} +
+ ); +} diff --git a/app/src/lib/strings.ts b/app/src/lib/strings.ts new file mode 100644 index 0000000..5233c30 --- /dev/null +++ b/app/src/lib/strings.ts @@ -0,0 +1,29 @@ +// Curated user-facing strings (P6 plain English; ADR-0007 Primary-Rule compliant). +// The Primary-Rule lint test (src/__tests__/primary-rule-lint.test.ts) scans these and the +// panel/page source for imperative trade verbs (a stoplist of directives). Approved noun +// phrases such as the buy-zone estimate are allowed. +export const UI_STRINGS = { + appTitle: "Investor Flow", + appSubtitle: "Educational investment research terminal", + symbolPlaceholder: "Enter a symbol (e.g. NVDA)", + loadingSnapshot: "Loading cached snapshot…", + noData: "No cached data yet — the backend is fetching this in the background.", + priceLabel: "Last price", + dayChangeLabel: "Day change", + sectorLabel: "Sector", + industryLabel: "Industry", + tickerKindLabel: "Type", + sparklineCaption: "Recent daily close — context for where price has been, not a signal to act on.", + cryptoNotice: "Crypto — limited research module (SEC-derived ownership and filings do not apply).", + staleBadge: "cached — refreshing", + primaryRuleFooter: + "Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions.", + authEmailLabel: "Email", + authPasswordLabel: "Password", + signupButton: "Create account", + loginButton: "Sign in", + logoutButton: "Sign out", + signupPrompt: "Create an account to track symbols and keep your research in one place.", +} as const; + +export const UI_STRING_LIST: string[] = Object.values(UI_STRINGS); diff --git a/app/src/lib/trpc.ts b/app/src/lib/trpc.ts new file mode 100644 index 0000000..3ac190a --- /dev/null +++ b/app/src/lib/trpc.ts @@ -0,0 +1,83 @@ +// Thin typed client over the tRPC backend (DESIGN.md §2). Slice 1 uses a fetch wrapper +// rather than @trpc/client to keep the Next app decoupled from the server's Node-native +// type graph; the server is a real tRPC server and full end-to-end client types can be +// layered later without changing this surface. +export interface Quote { + symbol: string; + price: number; + bid?: number | null; + ask?: number | null; + change?: number | null; + changePercent?: number | null; + iv?: number | null; +} +export interface PriceCandle { + ts: string; + o: number; + h: number; + l: number; + c: number; + v: number; + adjClose?: number | null; +} +export interface SymbolMeta { + symbol: string; + name?: string | null; + sector?: string | null; + industry?: string | null; + exchange?: string | null; + tickerKind: string; + peers?: string[] | null; +} +export interface Snapshot { + symbol: string; + quote: Quote | null; + candles: PriceCandle[] | null; + sector: SymbolMeta | null; + stale: { quote: boolean; candles: boolean; sector: boolean }; +} +export interface AuthUser { + userId: string; + email?: string; + complexity?: string; + riskTolerance?: string; + convexityPosture?: string; +} + +async function parse(res: Response): Promise { + const json = await res.json(); + if (json?.error) { + const msg = json.error.message ?? `tRPC error ${json.error.data?.code ?? ""}`; + throw new Error(typeof msg === "string" ? msg : "tRPC error"); + } + return json.result.data as T; +} + +async function trpcQuery(procedure: string, input?: unknown): Promise { + const search = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : ""; + const res = await fetch(`/api/trpc/${procedure}${search}`, { + headers: { "content-type": "application/json" }, + credentials: "include", + }); + return parse(res); +} + +async function trpcMutate(procedure: string, input: unknown): Promise { + const res = await fetch(`/api/trpc/${procedure}`, { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "include", + body: JSON.stringify(input), + }); + return parse(res); +} + +export const api = { + market: { snapshot: (symbol: string) => trpcQuery("market.snapshot", { symbol }) }, + auth: { + signup: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.signup", { email, password }), + login: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.login", { email, password }), + logout: () => trpcMutate<{ ok: boolean }>("auth.logout", {}), + me: () => trpcQuery("auth.me"), + }, +}; diff --git a/app/src/stores/active-symbol-store.ts b/app/src/stores/active-symbol-store.ts new file mode 100644 index 0000000..f2c48b1 --- /dev/null +++ b/app/src/stores/active-symbol-store.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +// Single-page active-symbol signal (DESIGN.md §4): the shell holds the active symbol in +// Zustand; switching it rehydrates panels from cache with no route change. +interface ActiveSymbolState { + activeSymbol: string; // slice 1 default hardcoded to NVDA + setActive: (s: string) => void; +} + +export const useActiveSymbol = create((set) => ({ + activeSymbol: "NVDA", + setActive: (s) => set({ activeSymbol: s.toUpperCase().trim() || "NVDA" }), +}));