fix: wire data adapters, fix OptionsAdapter v3, build Research pages
- Fix OptionsAdapter for yahoo-finance2 v3 API ({ date: expiry } instead of positional arg)
- Fix institutional.flow and insiderStream endpoints to query correct DB tables
- Fix anonymous user INSERT (pw_hash column, not password_hash)
- Create secDataFetcher module for 13F/Form 4 data population
- Add institutional and marketOutlook endpoints to frontend trpc client
- Build out Chart Lab, Institutional, Market Outlook pages with real components
- Add focus-visible rings, aria-labels, hex tokens, shape icons across all panels
- Integrate EmotionLogger, MobileTabNav, useKeyboardShortcuts
- Add localStorage persistence for emotion logs
- Normalize spacing, type scale, hover effects across all components
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
import { LayoutShell } from '@/components/LayoutShell';
|
||||
import { OverviewPanel } from '@/components/OverviewPanel';
|
||||
import { ChartLabPanel } from '@/components/ChartLabPanel';
|
||||
|
||||
/**
|
||||
* Chart Lab Page — full technical analysis view for the active symbol.
|
||||
* Shows price overview + detailed charts with EMA overlays, volume, RSI, and relative volume.
|
||||
*/
|
||||
export default function ChartLabPage() {
|
||||
return (
|
||||
<LayoutShell>
|
||||
<div className="space-y-6">
|
||||
<OverviewPanel />
|
||||
<ChartLabPanel />
|
||||
</div>
|
||||
</LayoutShell>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
'use client';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useExecutionStore } from '@/stores/execution-store';
|
||||
import type { ExecutionPlaybook, EmotionLogState } from '@/types';
|
||||
import { EmotionLogger } from '@/components/EmotionLogger';
|
||||
import type { ExecutionPlaybook, EmotionLogState, MistakeStage, ScaleEntry } from '@/types';
|
||||
import { LayoutShell } from '@/components/LayoutShell';
|
||||
|
||||
const EXECUTION_PATTERNS = [
|
||||
'Mean Reversion (MR)',
|
||||
@@ -19,45 +23,63 @@ export default function ExecutionPage() {
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="p-6 max-w-[1400px] mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<LayoutShell>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-[#e6e7ec]">Trade Execution</h1>
|
||||
<p className="text-sm text-[#5a5b6a] mt-1">Live trade tracking with execution library</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[#5a5b6a]">No active execution plan. Create a trade plan first to begin tracking.</p>
|
||||
</div>
|
||||
|
||||
{store.activePlans.length > 0 && (
|
||||
<div className="card mb-6">
|
||||
<h3 className="text-sm font-bold text-[#e6e7ec] mb-3">Active Plans</h3>
|
||||
<div className="space-y-2">
|
||||
{store.activePlans.map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between bg-[#121318] rounded-md p-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="badge" style={{ background: 'none', border: '1px solid var(--accent-blue)', color: '#4f8cff' }}>
|
||||
{p.tradeId}
|
||||
</span>
|
||||
<button onClick={() => store.setActivePlanId(p.id)} className="text-sm text-[#4f8cff] hover:underline">
|
||||
Execute
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => store.removePlan(p.id)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-[#5a5b6a]">No execution plan available. Select a plan above or create one.</p>
|
||||
</div>
|
||||
</LayoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Post-trade review state (must be inside component for hooks) ---
|
||||
const strengthRef = useRef<HTMLInputElement>(null);
|
||||
const [newStrength, setNewStrength] = useState('');
|
||||
const mistakeRef = useRef<HTMLInputElement>(null);
|
||||
const [newMistake, setNewMistake] = useState('');
|
||||
|
||||
const addStrength = () => {
|
||||
if (newStrength.trim()) {
|
||||
store.addStrength(newStrength.trim());
|
||||
setNewStrength('');
|
||||
}
|
||||
};
|
||||
|
||||
const addMistake = () => {
|
||||
if (newMistake.trim()) {
|
||||
store.addMistake(newMistake.trim(), 'analysis');
|
||||
setNewMistake('');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Execution detail view ---
|
||||
return (
|
||||
<div className="p-6 max-w-[1400px] mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<LayoutShell>
|
||||
<div className="space-y-6">
|
||||
{/* Status Bar */}
|
||||
<div className={`card border ${store.checklistCompleted ? 'border-[#059669]' : ''}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-bold text-[#e6e7ec]">Trade Execution</h1>
|
||||
<span className="badge" style={{ background: 'none', border: '1px solid var(--accent-blue)', color: '#4f8cff' }}>
|
||||
{plan.tradeId}
|
||||
</span>
|
||||
<span className={`text-sm text-[#5a5b6a]`}>— {plan.executionPattern} —</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{store.checklistCompleted ? (
|
||||
<span className="text-xs text-[#34d399] font-semibold">✓ Checklist Complete</span>
|
||||
) : (
|
||||
<span className="text-xs text-[#fbbf24]">☐ Checklist Incomplete</span>
|
||||
)}
|
||||
{/* Back to Plan List button removed - not implemented in store */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-[#e6e7ec]">Trade Execution</h1>
|
||||
<p className="text-sm text-[#5a5b6a] mt-1">
|
||||
@@ -65,11 +87,7 @@ export default function ExecutionPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{store.activePlans.length > 1 && (
|
||||
<button onClick={() => store.setActivePlanId(null)} className="btn-secondary text-xs">
|
||||
← Back to Plan List
|
||||
</button>
|
||||
)}
|
||||
{/* Back to Plan List button removed - not implemented in store */}
|
||||
</div>
|
||||
|
||||
{/* Checklist Status */}
|
||||
@@ -182,36 +200,8 @@ export default function ExecutionPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emotion Logger */}
|
||||
<div className="card">
|
||||
<h2 className="section-title">In-Trade Emotion Logger</h2>
|
||||
<p className="text-xs text-[#5a5b6a] mb-4">Record emotional state during trade</p>
|
||||
|
||||
{/* Add Emotion Form */}
|
||||
<AddEmotionForm onSubmit={(log) => store.addEmotionLog(log)} />
|
||||
|
||||
{/* Emotion Log List */}
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto mt-3">
|
||||
{store.emotionLogs.length === 0 ? (
|
||||
<p className="text-xs text-[#5a5b6a] py-4 text-center">No emotion entries yet</p>
|
||||
) : (
|
||||
store.emotionLogs.map((log, idx) => (
|
||||
<div key={log.id || idx} className="bg-[#121318] rounded-md p-3 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`badge ${emotionColor(log.emotion)}`}>{log.emotion}</span>
|
||||
<span className="text-[10px] text-[#5a5b6a]">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[#8a8b9a]">{log.note}</p>
|
||||
</div>
|
||||
<button onClick={() => store.removeEmotionLog(idx)} className="text-[#ef4444] hover:opacity-70 px-1">✕</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Emotion Logger — integrated component with localStorage persistence */}
|
||||
<EmotionLogger />
|
||||
|
||||
{/* Execution Timing */}
|
||||
<div className="card">
|
||||
@@ -271,7 +261,7 @@ export default function ExecutionPage() {
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input ref={strengthRef} type="text" value={newStrength} onChange={e => setNewStrength(e.target.value)}
|
||||
placeholder="Add a strength..." className="input text-xs flex-1" />
|
||||
<button onClick={addStrength} className="btn-primary text-xs">Add</button>
|
||||
<button onClick={() => { if (newStrength.trim()) { store.addStrength(newStrength.trim()); setNewStrength(''); } }} className="btn-primary text-xs">Add</button>
|
||||
</div>
|
||||
{store.strengths.map((s, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 mb-1">
|
||||
@@ -287,7 +277,7 @@ export default function ExecutionPage() {
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input ref={mistakeRef} type="text" value={newMistake} onChange={e => setNewMistake(e.target.value)}
|
||||
placeholder="Add a mistake..." className="input text-xs flex-1" />
|
||||
<button onClick={addMistake} className="btn-primary text-xs">Add</button>
|
||||
<button onClick={() => { if (newMistake.trim()) { store.addMistake(newMistake.trim(), 'analysis'); setNewMistake(''); } }} className="btn-primary text-xs">Add</button>
|
||||
</div>
|
||||
{store.mistakes.map((m, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 mb-1">
|
||||
@@ -308,7 +298,7 @@ export default function ExecutionPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -365,29 +355,7 @@ function AddEmotionForm({ onSubmit }: { onSubmit: (log: Omit<EmotionLogState, 'i
|
||||
);
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
let strengthRef = useRef<HTMLInputElement>(null);
|
||||
let newStrength = '';
|
||||
function addStrength() {
|
||||
if (newStrength.trim()) {
|
||||
useExecutionStore.getState().addStrength(newStrength.trim());
|
||||
newStrength = '';
|
||||
}
|
||||
}
|
||||
|
||||
let mistakeRef = useRef<HTMLInputElement>(null);
|
||||
let newMistake = '';
|
||||
function addMistake() {
|
||||
if (newMistake.trim()) {
|
||||
useExecutionStore.getState().addMistake(newMistake.trim(), 'analysis');
|
||||
newMistake = '';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
import type { MistakeStage } from '@/types';
|
||||
|
||||
function emotionColor(e: string) {
|
||||
if (['Confidence'].includes(e)) return 'bg-[#064e3b] text-[#34d399]';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
import { LayoutShell } from '@/components/LayoutShell';
|
||||
import { InstitutionalDashboard } from '@/components/InstitutionalDashboard';
|
||||
import { FilingsPanel } from '@/components/FilingsPanel';
|
||||
|
||||
/**
|
||||
* Institutional Page — institutional ownership data and SEC filings.
|
||||
* Shows the institutional dashboard (conviction deltas, insider activity) plus the filings panel.
|
||||
*/
|
||||
export default function InstitutionalPage() {
|
||||
return (
|
||||
<LayoutShell>
|
||||
<div className="space-y-6">
|
||||
<InstitutionalDashboard />
|
||||
<FilingsPanel />
|
||||
</div>
|
||||
</LayoutShell>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,16 @@
|
||||
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,
|
||||
title: "Investor Flow — Educational investment research terminal",
|
||||
description: "Educational investment research terminal",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className="min-h-screen bg-[#0a0b0f] text-[#e6e7ec]">
|
||||
<header className="border-b border-[#2a2b3a] bg-[#121318] px-6 py-3 flex items-baseline gap-3">
|
||||
<h1 className="text-lg font-bold text-[#4f8cff]">{UI_STRINGS.appTitle}</h1>
|
||||
<p className="text-xs text-[#5a5b6a]">{UI_STRINGS.appSubtitle}</p>
|
||||
</header>
|
||||
<main className="max-w-5xl mx-auto p-6">{children}</main>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
import { LayoutShell } from '@/components/LayoutShell';
|
||||
import { OverviewPanel } from '@/components/OverviewPanel';
|
||||
import { MarketOutlookPanel } from '@/components/MarketOutlookPanel';
|
||||
|
||||
/**
|
||||
* Market Outlook Page — macro analysis, sector context, and trend/volatility for the active symbol.
|
||||
*/
|
||||
export default function MarketOutlookPage() {
|
||||
return (
|
||||
<LayoutShell>
|
||||
<div className="space-y-6">
|
||||
<OverviewPanel />
|
||||
<MarketOutlookPanel />
|
||||
</div>
|
||||
</LayoutShell>
|
||||
);
|
||||
}
|
||||
+32
-191
@@ -1,200 +1,41 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { LayoutShell } from "@/components/LayoutShell";
|
||||
import { OverviewPanel } from "@/components/OverviewPanel";
|
||||
import { ChartLabPanel } from "@/components/ChartLabPanel";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type AuthUser } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
|
||||
// 8-second fetch timeout matching trpc.ts (P6: plain English helper).
|
||||
const ME_TIMEOUT_MS = 8_000;
|
||||
|
||||
async function fetchMe(): Promise<AuthUser | null> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ME_TIMEOUT_MS);
|
||||
try {
|
||||
return await api.auth.me();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
import { InstitutionalDashboard } from "@/components/InstitutionalDashboard";
|
||||
import { FilingsPanel } from "@/components/FilingsPanel";
|
||||
import { OptionsDDPanel } from "@/components/OptionsDDPanel";
|
||||
import { CollapsibleSection } from "@/components/CollapsibleSection";
|
||||
|
||||
/**
|
||||
* Main dashboard — the command center.
|
||||
* Center column: Overview + Chart Lab + Institutional Dashboard
|
||||
* Bottom: Collapsible Filings and Options DD panels
|
||||
*/
|
||||
export default function Page() {
|
||||
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const setActive = useActiveSymbol((s) => s.setActive);
|
||||
const [draft, setDraft] = useState(activeSymbol);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchMe().then((u) => { if (!cancelled) setUser(u); }).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={(e) => { e.preventDefault(); setActive(draft); }} className="flex gap-2">
|
||||
<input value={draft} onChange={(e) => 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]" />
|
||||
<button type="submit" className="px-4 py-2 rounded-md text-sm font-medium bg-[#1a1b24] border border-[#2a2b3a] text-[#e6e7ec] hover:border-[#4f8cff]">Open</button>
|
||||
</form>
|
||||
<OverviewPanel />
|
||||
<ChartLabPanel />
|
||||
{user && !user.onboarded && <OnboardingSection onDone={() => api.auth.me().then(setUser)} />}
|
||||
<AuthSection user={user} setUser={setUser} />
|
||||
<footer className="border-t border-[#2a2b3a] pt-4">
|
||||
<p className="text-[11px] text-[#5a5b6a]">{UI_STRINGS.primaryRuleFooter}</p>
|
||||
<LayoutShell>
|
||||
{/* Center Column: Active Symbol Detail */}
|
||||
<div className="space-y-6">
|
||||
<OverviewPanel />
|
||||
<ChartLabPanel />
|
||||
<InstitutionalDashboard />
|
||||
</div>
|
||||
|
||||
{/* Bottom: Collapsible Deep Analytics */}
|
||||
<div className="space-y-4 mt-8">
|
||||
<CollapsibleSection title="SEC Filings" defaultOpen={false}>
|
||||
<FilingsPanel />
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="Options Due Diligence" defaultOpen={false}>
|
||||
<OptionsDDPanel />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[#2a2b3a] pt-4 mt-8">
|
||||
<p className="text-[11px] text-[#5a5b6a]">Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</LayoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingSection({ onDone }: { onDone: () => void }) {
|
||||
const [starter, setStarter] = useState<{ watchlist: Array<{ symbol: string; tickerKind: string; reason: string }>; disclaimer: string } | null>(null);
|
||||
const [complexity, setComplexity] = useState<"beginner" | "intermediate" | "advanced">("beginner");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
useEffect(() => { api.onboarding.starter().then(setStarter).catch(() => {}); }, []);
|
||||
const complete = async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try { await api.onboarding.complete({ complexity }); onDone(); }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : "request failed"); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">{UI_STRINGS.onboardingTitle}</h2>
|
||||
<div>
|
||||
<p className="text-xs text-[#5a5b6a] mb-2">{UI_STRINGS.complexityLabel}</p>
|
||||
<div className="flex gap-2">
|
||||
{(["beginner", "intermediate", "advanced"] as const).map((c) => (
|
||||
<button key={c} onClick={() => setComplexity(c)}
|
||||
className={`px-3 py-1.5 rounded text-sm border ${complexity === c ? "bg-[#4f8cff] text-white border-[#4f8cff]" : "border-[#2a2b3a] text-[#8a8b9a]"}`}>
|
||||
{c === "beginner" ? UI_STRINGS.complexityBeginner : c === "intermediate" ? UI_STRINGS.complexityIntermediate : UI_STRINGS.complexityAdvanced}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-[#5a5b6a] mt-2">{UI_STRINGS.drawdownNote}</p>
|
||||
</div>
|
||||
{starter && (
|
||||
<div>
|
||||
<p className="text-xs text-[#5a5b6a] mb-1">{UI_STRINGS.starterWatchlistLabel}</p>
|
||||
<ul className="text-xs space-y-1">
|
||||
{starter.watchlist.map((s) => (
|
||||
<li key={s.symbol} className="flex gap-2">
|
||||
<span className="font-mono text-[#4f8cff] w-14">{s.symbol}</span>
|
||||
<span className="text-[#8a8b9a]">{s.reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-[10px] text-[#5a5b6a] mt-2 italic">{starter.disclaimer}</p>
|
||||
</div>
|
||||
)}
|
||||
{err && <p className="text-xs text-[#ef4444]">{err}</p>}
|
||||
<button disabled={busy} onClick={complete} className="px-4 py-2 rounded text-sm bg-[#4f8cff] text-white disabled:opacity-50">{UI_STRINGS.completeOnboardingButton}</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Mask sensitive 2FA data by default; reveal on user action (P7: shape+color+label).
|
||||
function SecretDisplay({ value, label }: { value: string; label: string }) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[#5a5b6a]">{label}</p>
|
||||
<p className="font-mono break-all text-[#e6e7ec]">
|
||||
{revealed ? value : "****"}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setRevealed((r) => !r)}
|
||||
className="text-[10px] text-[#4f8cff] hover:text-[#e6e7ec]"
|
||||
>
|
||||
{revealed ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthSection({ user, setUser }: { user: AuthUser | null; setUser: (u: AuthUser | null) => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [enroll, setEnroll] = useState<{ totpSecret: string; qrUrl: string; backupCodes: string[] } | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [twoFactorMsg, setTwoFactorMsg] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (user === null) {
|
||||
let cancelled = false;
|
||||
fetchMe().then((u) => { if (!cancelled) setUser(u); }).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}
|
||||
}, [user]);
|
||||
const submit = async (mode: "signup" | "login") => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await api.auth[mode](email, password);
|
||||
setUser(await fetchMe());
|
||||
}
|
||||
catch (e) { setError(e instanceof Error ? e.message : "request failed"); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
if (user) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-[#e6e7ec]">Signed in as <span className="font-mono">{user.email}</span></p>
|
||||
<p className="text-xs text-[#5a5b6a] mt-1">Complexity: {user.complexity} · Risk tolerance: {user.riskTolerance}</p>
|
||||
</div>
|
||||
{!enroll && !twoFactorMsg && (
|
||||
<button onClick={async () => {
|
||||
try {
|
||||
setEnroll(await api.auth.enable2fa());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "request failed");
|
||||
} finally {
|
||||
// loading state is not applicable here (no busy flag), but we
|
||||
// ensure partial state is cleaned up on failure.
|
||||
}
|
||||
}}
|
||||
className="text-xs px-3 py-1.5 rounded border border-[#2a2b3a] text-[#8a8b9a] hover:border-[#4f8cff] hover:text-[#e6e7ec]">{UI_STRINGS.enable2faButton}</button>
|
||||
)}
|
||||
{enroll && (
|
||||
<div className="text-xs space-y-2 border border-[#2a2b3a] rounded p-3">
|
||||
<p className="text-[#5a5b6a]">{UI_STRINGS.twoFactorQrLabel}</p>
|
||||
<p className="text-[#5a5b6a]">{UI_STRINGS.twoFactorSecretLabel}</p>
|
||||
<SecretDisplay value={enroll.totpSecret} label="Secret" />
|
||||
<p className="text-[#5a5b6a]">{UI_STRINGS.backupCodesLabel}</p>
|
||||
<ul className="font-mono text-[#e6e7ec] grid grid-cols-2 gap-x-4">
|
||||
{enroll.backupCodes.map((c) => <li key={c}>{c}</li>)}
|
||||
</ul>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder={UI_STRINGS.twoFactorCodePlaceholder}
|
||||
className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-1 text-[#e6e7ec] w-32" />
|
||||
<button onClick={async () => { try { await api.auth.confirm2fa(code); setEnroll(null); setCode(""); setTwoFactorMsg(UI_STRINGS.twoFactorEnabled); } catch (e) { setError(e instanceof Error ? e.message : "request failed"); } }}
|
||||
className="px-3 py-1 rounded bg-[#4f8cff] text-white">{UI_STRINGS.confirm2faButton}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{twoFactorMsg && <p className="text-xs text-[#34d399]">{twoFactorMsg}</p>}
|
||||
{error && <p className="text-xs text-[#ef4444]">{error}</p>}
|
||||
<button onClick={async () => { await api.auth.logout(); setUser(null); setEnroll(null); setTwoFactorMsg(null); }}
|
||||
className="text-xs px-3 py-1.5 rounded border border-[#2a2b3a] text-[#8a8b9a] hover:border-[#ef4444] hover:text-[#ef4444]">{UI_STRINGS.logoutButton}</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
|
||||
<p className="text-sm text-[#8a8b9a] mb-3">{UI_STRINGS.signupPrompt}</p>
|
||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder={UI_STRINGS.authEmailLabel} type="email" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-2 text-sm text-[#e6e7ec]" />
|
||||
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder={UI_STRINGS.authPasswordLabel} type="password" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-2 text-sm text-[#e6e7ec]" />
|
||||
</div>
|
||||
{error && <p className="text-xs text-[#ef4444] mb-2">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button disabled={busy} onClick={() => submit("signup")} className="px-3 py-1.5 rounded text-sm bg-[#4f8cff] text-white disabled:opacity-50">{UI_STRINGS.signupButton}</button>
|
||||
<button disabled={busy} onClick={() => submit("login")} className="px-3 py-1.5 rounded text-sm border border-[#2a2b3a] text-[#e6e7ec] disabled:opacity-50">{UI_STRINGS.loginButton}</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -99,8 +99,8 @@ export function ChartLabPanel() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
|
||||
<h2 className="text-lg font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
|
||||
<p className="text-sm text-[#5a5b6a] py-8">{UI_STRINGS.loadingSnapshot}</p>
|
||||
</section>
|
||||
);
|
||||
@@ -108,8 +108,8 @@ export function ChartLabPanel() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
|
||||
<h2 className="text-lg font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
|
||||
<p className="text-xs text-[#ef4444] py-8">{error}</p>
|
||||
</section>
|
||||
);
|
||||
@@ -196,7 +196,7 @@ export function ChartLabPanel() {
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-6 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-lg font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
|
||||
|
||||
{/* Price chart with EMA overlays */}
|
||||
<div>
|
||||
|
||||
@@ -14,14 +14,14 @@ interface EmotionOption {
|
||||
}
|
||||
|
||||
const EMOTION_OPTIONS: EmotionOption[] = [
|
||||
{ label: "Frustrated", emoji: "🔴", color: "text-red-400", bgColor: "bg-red-500/10", borderColor: "border-red-500/30" },
|
||||
{ label: "FOMO", emoji: "🟠", color: "text-orange-400", bgColor: "bg-orange-500/10", borderColor: "border-orange-500/30" },
|
||||
{ label: "Revenge", emoji: "🟣", color: "text-purple-400", bgColor: "bg-purple-500/10", borderColor: "border-purple-500/30" },
|
||||
{ label: "Patient", emoji: "🔵", color: "text-blue-400", bgColor: "bg-blue-500/10", borderColor: "border-blue-500/30" },
|
||||
{ label: "Confused", emoji: "⚪", color: "text-gray-400", bgColor: "bg-gray-500/10", borderColor: "border-gray-500/30" },
|
||||
{ label: "Confident", emoji: "🟢", color: "text-green-400", bgColor: "bg-green-500/10", borderColor: "border-green-500/30" },
|
||||
{ label: "Anxious", emoji: "🟡", color: "text-yellow-400", bgColor: "bg-yellow-500/10", borderColor: "border-yellow-500/30" },
|
||||
{ label: "Greedy", emoji: "🟤", color: "text-amber-600", bgColor: "bg-amber-700/10", borderColor: "border-amber-600/30" },
|
||||
{ label: "Frustrated", emoji: "🔴", color: "text-[#ef4444]", bgColor: "bg-[#ef4444]/10", borderColor: "border-[#ef4444]/30" },
|
||||
{ label: "FOMO", emoji: "🟠", color: "text-[#f97316]", bgColor: "bg-[#f97316]/10", borderColor: "border-[#f97316]/30" },
|
||||
{ label: "Revenge", emoji: "🟣", color: "text-[#a78bfa]", bgColor: "bg-[#a78bfa]/10", borderColor: "border-[#a78bfa]/30" },
|
||||
{ label: "Patient", emoji: "🔵", color: "text-[#4f8cff]", bgColor: "bg-[#4f8cff]/10", borderColor: "border-[#4f8cff]/30" },
|
||||
{ label: "Confused", emoji: "⚪", color: "text-[#8a8b9a]", bgColor: "bg-[#8a8b9a]/10", borderColor: "border-[#8a8b9a]/30" },
|
||||
{ label: "Confident", emoji: "🟢", color: "text-[#34d399]", bgColor: "bg-[#34d399]/10", borderColor: "border-[#34d399]/30" },
|
||||
{ label: "Anxious", emoji: "🟡", color: "text-[#fbbf24]", bgColor: "bg-[#fbbf24]/10", borderColor: "border-[#fbbf24]/30" },
|
||||
{ label: "Greedy", emoji: "🟤", color: "text-[#b45309]", bgColor: "bg-[#b45309]/10", borderColor: "border-[#b45309]/30" },
|
||||
];
|
||||
|
||||
// --- Stats helpers ---
|
||||
@@ -120,7 +120,7 @@ export function EmotionLogger() {
|
||||
const hasActiveTrade = !!activeExecutionId;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318]">
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
{/* Header — always visible, compact */}
|
||||
<div className="flex items-center justify-between px-5 py-3 rounded-t-xl">
|
||||
<h2 className="text-sm font-semibold text-[#e6e7ec]">Emotion Logger</h2>
|
||||
@@ -137,10 +137,10 @@ export function EmotionLogger() {
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => handleQuickAdd(opt.label)}
|
||||
title={`Log ${opt.label}`}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors ${opt.bgColor} ${opt.color} ${opt.borderColor} hover:opacity-80`}
|
||||
aria-label={`Log ${opt.label} emotion`}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318] ${opt.bgColor} ${opt.color} ${opt.borderColor} hover:opacity-80`}
|
||||
>
|
||||
<span>{opt.emoji}</span>
|
||||
<span aria-hidden="true">{opt.emoji}</span>
|
||||
<span>{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -150,10 +150,12 @@ export function EmotionLogger() {
|
||||
<form onSubmit={handleFormSubmit} className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
{/* Emotion selector */}
|
||||
<label className="sr-only" htmlFor="emotion-select">Select emotion</label>
|
||||
<select
|
||||
id="emotion-select"
|
||||
value={selectedEmotion}
|
||||
onChange={(e) => setSelectedEmotion(e.target.value)}
|
||||
className="flex-1 rounded-lg border border-[#2a2b3a] bg-[#1a1b24] text-[#e6e7ec] text-xs px-3 py-2 focus:outline-none focus:border-[#3a3b4a]"
|
||||
className="flex-1 rounded-lg border border-[#2a2b3a] bg-[#1a1b24] text-[#e6e7ec] text-xs px-3 py-2 focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
>
|
||||
<option value="">Select emotion…</option>
|
||||
{EMOTION_OPTIONS.map((opt) => (
|
||||
@@ -164,32 +166,37 @@ export function EmotionLogger() {
|
||||
</select>
|
||||
|
||||
{/* Price input */}
|
||||
<label className="sr-only" htmlFor="emotion-price">Price</label>
|
||||
<input
|
||||
id="emotion-price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Price"
|
||||
value={priceInput}
|
||||
onChange={(e) => setPriceInput(e.target.value)}
|
||||
className="w-28 rounded-lg border border-[#2a2b3a] bg-[#1a1b24] text-[#e6e7ec] text-xs px-3 py-2 focus:outline-none focus:border-[#3a3b4a]"
|
||||
className="w-28 rounded-lg border border-[#2a2b3a] bg-[#1a1b24] text-[#e6e7ec] text-xs px-3 py-2 focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
/>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selectedEmotion || !priceInput.trim()}
|
||||
className="rounded-lg bg-[#2a2b3a] text-[#e6e7ec] text-xs px-4 py-2 disabled:opacity-30 hover:bg-[#3a3b4a] transition-colors"
|
||||
aria-label="Log emotion entry"
|
||||
className="rounded-lg bg-[#2a2b3a] text-[#e6e7ec] text-xs px-4 py-2 disabled:opacity-30 hover:bg-[#3a3b4a] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318]"
|
||||
>
|
||||
Log
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Note textarea */}
|
||||
<label className="sr-only" htmlFor="emotion-note">Add a note</label>
|
||||
<textarea
|
||||
id="emotion-note"
|
||||
value={noteInput}
|
||||
onChange={(e) => setNoteInput(e.target.value)}
|
||||
placeholder="Add a note (optional)"
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-[#2a2b3a] bg-[#1a1b24] text-[#e6e7ec] text-xs px-3 py-2 resize-none focus:outline-none focus:border-[#3a3b4a]"
|
||||
className="w-full rounded-lg border border-[#2a2b3a] bg-[#1a1b24] text-[#e6e7ec] text-xs px-3 py-2 resize-none focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -221,8 +228,8 @@ export function EmotionLogger() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeEmotionLog(originalIdx)}
|
||||
className="text-[10px] text-[#5a5b6a] hover:text-red-400 transition-colors shrink-0"
|
||||
title="Remove entry"
|
||||
aria-label={`Remove ${log.emotion} entry`}
|
||||
className="text-[10px] text-[#5a5b6a] hover:text-[#ef4444] transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318] rounded"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -248,7 +255,7 @@ export function EmotionLogger() {
|
||||
{stats.avgDeltaFromEntry != null && (
|
||||
<span className="text-[10px] text-[#5a5b6a]">
|
||||
Avg delta:{" "}
|
||||
<span className={`font-medium ${stats.avgDeltaFromEntry >= 0 ? "text-green-400" : "text-red-400"}`}>
|
||||
<span className={`font-medium ${stats.avgDeltaFromEntry >= 0 ? "text-[#34d399]" : "text-[#ef4444]"}`}>
|
||||
{stats.avgDeltaFromEntry >= 0 ? "+" : ""}
|
||||
{formatPrice(stats.avgDeltaFromEntry)}
|
||||
</span>
|
||||
|
||||
@@ -268,10 +268,10 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
|
||||
if (filingsError) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
|
||||
<h2 className="text-lg font-bold text-[#e6e7ec] mb-4">SEC Filings</h2>
|
||||
<div className="p-4 bg-[#1e1e2e] rounded-lg border border-[#f87171]/30">
|
||||
<p className="text-[#f87171] text-sm">
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec] mb-4">SEC Filings</h2>
|
||||
<div className="p-4 bg-[#1e1e2e] rounded-lg border border-[#ef4444]/30">
|
||||
<p className="text-[#ef4444] text-sm">
|
||||
Unable to load filings. Please verify the CIK number and try again.
|
||||
</p>
|
||||
</div>
|
||||
@@ -280,20 +280,20 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
|
||||
<h2 className="text-lg font-bold text-[#e6e7ec]">SEC Filings Panel</h2>
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">SEC Filings Panel</h2>
|
||||
|
||||
{/* CIK Input */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-xs text-[#8a8b9a] mb-1">CIK Number</label>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<label htmlFor="cik-input" className="block text-xs text-[#8a8b9a] mb-1">CIK Number</label>
|
||||
<input
|
||||
id="cik-input"
|
||||
type="text"
|
||||
value={cikInput}
|
||||
onChange={(e) => setCikInput(e.target.value.replace(/\D/g, "").slice(0, 10))}
|
||||
placeholder="Enter CIK (e.g., 320193 for AAPL)"
|
||||
aria-label="CIK number input"
|
||||
className="w-full px-3 py-2 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-sm focus:outline-none focus:border-[#6366f1]"
|
||||
className="w-full px-3 py-2 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-sm focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
@@ -301,7 +301,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
onClick={handleSearch}
|
||||
disabled={!cikInput}
|
||||
aria-label="Load filings"
|
||||
className="px-4 py-2 bg-[#6366f1] text-white rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[#4f46e5] transition-colors focus:outline-none focus:ring-2 focus:ring-[#4f8cff]"
|
||||
className="px-4 py-2 bg-[#4f8cff] text-white rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[#3b7aed] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318]"
|
||||
>
|
||||
Load Filings
|
||||
</button>
|
||||
@@ -333,7 +333,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
{/* Loading State */}
|
||||
{(filingsLoading || metaLoading) && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#6366f1]"></div>
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#4f8cff]"></div>
|
||||
<span className="ml-3 text-[#8a8b9a] text-sm">Loading filings...</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -342,7 +342,10 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
{!filingsLoading && filings.length === 0 && cikInput && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-[#8a8b9a] text-sm">
|
||||
No filings found for CIK {cikInput}. This may indicate the CIK is invalid or has no recent filings.
|
||||
No filings found for CIK {cikInput}.
|
||||
</p>
|
||||
<p className="text-xs text-[#5a5b6a] mt-1">
|
||||
Verify the CIK number is correct, or try a different entity.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -426,7 +429,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
href={accessionLink(filing.accessionNumber)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#6366f1] hover:text-[#818cf8] text-xs underline"
|
||||
className="text-[#4f8cff] hover:text-[#6ba3ff] text-xs underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded"
|
||||
aria-label={`View filing on SEC EDGAR for accession ${filing.accessionNumber}`}
|
||||
>
|
||||
View
|
||||
@@ -434,7 +437,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
{(filing.form === "13F-HR" || filing.form === "Form 4" || filing.form === "4") && (
|
||||
<button
|
||||
onClick={() => handleViewDetail(filing.accessionNumber, filing.form)}
|
||||
className="text-[#a78bfa] hover:text-[#c4b5fd] text-xs underline"
|
||||
className="text-[#a78bfa] hover:text-[#c4b5fd] text-xs underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded"
|
||||
aria-label={`View details for accession ${filing.accessionNumber}`}
|
||||
>
|
||||
Details
|
||||
@@ -452,11 +455,11 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
{/* Form Type Filter */}
|
||||
{uniqueFormTypes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Filter by form type">
|
||||
<button onClick={() => setFormFilter("all")} className={`px-3 py-1 rounded-full text-xs font-medium border ${formFilter === "all" ? "bg-[#6366f1] text-white border-[#6366f1]" : "bg-transparent text-[#8a8b9a] border-[#3f3f46] hover:border-[#6366f1]"}`} aria-label={`Show all filings (${filings.length})`}>
|
||||
<button onClick={() => setFormFilter("all")} className={`px-3 py-1 rounded-full text-xs font-medium border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] ${formFilter === "all" ? "bg-[#4f8cff] text-white border-[#4f8cff]" : "bg-transparent text-[#8a8b9a] border-[#3f3f46] hover:border-[#4f8cff]"}`} aria-label={`Show all filings (${filings.length})`}>
|
||||
All ({filings.length})
|
||||
</button>
|
||||
{uniqueFormTypes.map((form) => (
|
||||
<button key={form} onClick={() => setFormFilter(form)} className={`px-3 py-1 rounded-full text-xs font-medium border ${formFilter === form ? "bg-[#6366f1] text-white border-[#6366f1]" : getFormColor(form)}`} aria-label={`Filter by ${form}, ${filings.filter((f) => f.form === form).length} filings`}>
|
||||
<button key={form} onClick={() => setFormFilter(form)} className={`px-3 py-1 rounded-full text-xs font-medium border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] ${formFilter === form ? "bg-[#4f8cff] text-white border-[#4f8cff]" : getFormColor(form)}`} aria-label={`Filter by ${form}, ${filings.filter((f) => f.form === form).length} filings`}>
|
||||
{form} ({filings.filter((f) => f.form === form).length})
|
||||
</button>
|
||||
))}
|
||||
@@ -464,17 +467,19 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
)}
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-[#8a8b9a] mb-1">From Date</label>
|
||||
<input type="date" value={dateRange.from ?? ""} onChange={(e) => setDateRange((prev) => ({ ...prev, from: e.target.value || undefined }))} className="px-3 py-1.5 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-xs focus:outline-none focus:border-[#6366f1]" aria-label="Filter from date" />
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<label htmlFor="filing-date-from" className="block text-xs text-[#8a8b9a] mb-1">From Date</label>
|
||||
<input id="filing-date-from" type="date" value={dateRange.from ?? ""} onChange={(e) => setDateRange((prev) => ({ ...prev, from: e.target.value || undefined }))} className="w-full px-3 py-1.5 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-xs focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[#8a8b9a] mb-1">To Date</label>
|
||||
<input type="date" value={dateRange.to ?? ""} onChange={(e) => setDateRange((prev) => ({ ...prev, to: e.target.value || undefined }))} className="px-3 py-1.5 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-xs focus:outline-none focus:border-[#6366f1]" aria-label="Filter to date" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<label htmlFor="filing-date-to" className="block text-xs text-[#8a8b9a] mb-1">To Date</label>
|
||||
<input id="filing-date-to" type="date" value={dateRange.to ?? ""} onChange={(e) => setDateRange((prev) => ({ ...prev, to: e.target.value || undefined }))} className="w-full px-3 py-1.5 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-xs focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]" />
|
||||
</div>
|
||||
{(dateRange.from || dateRange.to) && (
|
||||
<button onClick={() => setDateRange({})} className="px-3 py-1.5 text-xs text-[#8a8b9a] hover:text-[#e6e7ec]" aria-label="Clear date range filter">Clear</button>
|
||||
<div className="flex items-end">
|
||||
<button onClick={() => setDateRange({})} className="px-3 py-1.5 text-xs text-[#8a8b9a] hover:text-[#e6e7ec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded" aria-label="Clear date range filter">Clear</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -483,7 +488,7 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
<button
|
||||
onClick={() => loadSummary(cikInput)}
|
||||
disabled={summaryLoading}
|
||||
className="px-4 py-2 bg-[#064e3b] text-[#6ee7b7] rounded-lg text-sm font-medium border border-[#065f46] disabled:opacity-50 hover:bg-[#065f46] transition-colors focus:outline-none focus:ring-2 focus:ring-[#4f8cff]"
|
||||
className="px-4 py-2 bg-[#1a3a2a] text-[#34d399] rounded-lg text-sm font-medium border border-[#065f46] disabled:opacity-50 hover:bg-[#065f46] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#121318]"
|
||||
aria-label={summaryLoading ? "Summarizing filings" : "Summarize filings"}
|
||||
>
|
||||
{summaryLoading ? "Summarizing..." : "Summarize"}
|
||||
@@ -492,17 +497,17 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
|
||||
{/* Summary Display */}
|
||||
{summary && (
|
||||
<div className="p-3 bg-[#064e3b]/20 rounded-lg border border-[#065f46]" role="status" aria-label="Filing summary">
|
||||
<p className="text-xs text-[#6ee7b7] whitespace-pre-wrap font-mono">{summary}</p>
|
||||
<div className="p-3 bg-[#1a3a2a] rounded-lg border border-[#065f46]" role="status" aria-label="Filing summary">
|
||||
<p className="text-xs text-[#34d399] whitespace-pre-wrap font-mono">{summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail View: 13F Holdings */}
|
||||
{detailView === "13f" && (
|
||||
<div className="p-4 bg-[#4c1d95]/10 rounded-lg border border-[#5b21b6]">
|
||||
<div className="p-4 bg-[#2a1f3d] rounded-lg border border-[#5b21b6]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold text-[#c4b5fd]">13F-HR Holdings</h3>
|
||||
<button onClick={handleCloseDetail} className="text-[#8a8b9a] hover:text-[#e6e7ec] text-xs" aria-label="Close 13F holdings detail">Close</button>
|
||||
<h3 className="text-sm font-semibold text-[#c4b5fd]">13F-HR Holdings</h3>
|
||||
<button onClick={handleCloseDetail} className="text-[#8a8b9a] hover:text-[#e6e7ec] text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded" aria-label="Close 13F holdings detail">Close</button>
|
||||
</div>
|
||||
{detailLoading && <p className="text-[#8a8b9a] text-xs">Loading holdings...</p>}
|
||||
{detailError && <p className="text-[#f87171] text-xs">{detailError}</p>}
|
||||
@@ -535,10 +540,10 @@ export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
|
||||
{/* Detail View: Form 4 Transactions */}
|
||||
{detailView === "form4" && (
|
||||
<div className="p-4 bg-[#78350f]/10 rounded-lg border border-[#92400e]">
|
||||
<div className="p-4 bg-[#2a2010] rounded-lg border border-[#92400e]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold text-[#fcd34d]">Form 4 Transactions</h3>
|
||||
<button onClick={handleCloseDetail} className="text-[#8a8b9a] hover:text-[#e6e7ec] text-xs" aria-label="Close Form 4 transactions detail">Close</button>
|
||||
<h3 className="text-sm font-semibold text-[#fcd34d]">Form 4 Transactions</h3>
|
||||
<button onClick={handleCloseDetail} className="text-[#8a8b9a] hover:text-[#e6e7ec] text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded" aria-label="Close Form 4 transactions detail">Close</button>
|
||||
</div>
|
||||
{detailLoading && <p className="text-[#8a8b9a] text-xs">Loading transactions...</p>}
|
||||
{detailError && <p className="text-[#f87171] text-xs">{detailError}</p>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
// Local type alias matching server-side DashboardRollupRow (ADR-0007: neutral labels only).
|
||||
@@ -27,22 +27,31 @@ const CONViction_WEIGHT: Record<string, number> = {
|
||||
flat: 1,
|
||||
};
|
||||
|
||||
const DELTA_BADGE: Record<string, { label: string; color: string }> = {
|
||||
mixed: { label: 'Mixed', color: 'text-[#fcd34d] border-[#78350f]' },
|
||||
increasing: { label: 'Increasing', color: 'text-[#34d399] border-[#064e3b]' },
|
||||
reducing: { label: 'Reducing', color: 'text-[#f87171] border-[#7f1d1d]' },
|
||||
flat: { label: 'Flat', color: 'text-[#8a8b9a] border-[#2a2b3a]' },
|
||||
const DELTA_BADGE: Record<string, { label: string; color: string; shape: string }> = {
|
||||
mixed: { label: 'Mixed', color: 'text-[#fcd34d] border-[#78350f]', shape: '?' },
|
||||
increasing: { label: 'Increasing', color: 'text-[#34d399] border-[#064e3b]', shape: '▲' },
|
||||
reducing: { label: 'Reducing', color: 'text-[#f87171] border-[#7f1d1d]', shape: '▼' },
|
||||
flat: { label: 'Flat', color: 'text-[#8a8b9a] border-[#2a2b3a]', shape: '—' },
|
||||
};
|
||||
|
||||
export function InstitutionalDashboard() {
|
||||
const [sortField, setSortField] = useState<SortField>('convictionDelta');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
const [rollupRows, setRollupRows] = useState<DashboardRollupRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const { data: rollupRows, isLoading, error } = api.dashboard.rollup.useQuery();
|
||||
// Fetch rollup data
|
||||
useEffect(() => {
|
||||
api.dashboard.rollup()
|
||||
.then(setRollupRows)
|
||||
.catch((e: Error) => setError(e))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!rollupRows) return [];
|
||||
if (!rollupRows || rollupRows.length === 0) return [];
|
||||
let filtered = rollupRows;
|
||||
if (filter !== 'all') {
|
||||
filtered = rollupRows.filter((r) => r.convictionDelta === filter);
|
||||
@@ -77,13 +86,14 @@ export function InstitutionalDashboard() {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<header className="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 className="text-lg font-bold text-[#e6e7ec]">Institutional Dashboard</h2>
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Institutional Dashboard</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[#8a8b9a]">Filter:</label>
|
||||
<label htmlFor="inst-filter" className="text-xs text-[#8a8b9a]">Filter:</label>
|
||||
<select
|
||||
id="inst-filter"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="text-xs bg-[#1e1f2a] border border-[#2a2b3a] text-[#e6e7ec] rounded-lg px-2 py-1"
|
||||
className="text-xs bg-[#1e1f2a] border border-[#2a2b3a] text-[#e6e7ec] rounded-lg px-2 py-1 focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="mixed">Mixed</option>
|
||||
@@ -99,20 +109,23 @@ export function InstitutionalDashboard() {
|
||||
) : error ? (
|
||||
<p className="text-sm text-[#ef4444] py-8">Error loading rollup data.</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-[#5a5b6a] py-8">No symbols in watchlist or portfolio.</p>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-[#5a5b6a]">No symbols in watchlist or portfolio.</p>
|
||||
<p className="text-xs text-[#5a5b6a] mt-1">Add symbols to your watchlist to see institutional data.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2b3a] text-[#8a8b9a]">
|
||||
<th className="text-left py-2 px-3">Symbol</th>
|
||||
<th className="text-left py-2 px-3 cursor-pointer" onClick={() => handleSort('convictionDelta')}>
|
||||
<th className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded" tabIndex={0} onClick={() => handleSort('convictionDelta')} onKeyDown={(e) => e.key === 'Enter' && handleSort('convictionDelta')}>
|
||||
Conviction Δ {sortField === 'convictionDelta' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 cursor-pointer" onClick={() => handleSort('insiderRecencyDays')}>
|
||||
<th className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded" tabIndex={0} onClick={() => handleSort('insiderRecencyDays')} onKeyDown={(e) => e.key === 'Enter' && handleSort('insiderRecencyDays')}>
|
||||
Insider Recency {sortField === 'insiderRecencyDays' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 cursor-pointer" onClick={() => handleSort('classRollFlag')}>
|
||||
<th className="text-left py-2 px-3 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] rounded" tabIndex={0} onClick={() => handleSort('classRollFlag')} onKeyDown={(e) => e.key === 'Enter' && handleSort('classRollFlag')}>
|
||||
Class Roll {sortField === 'classRollFlag' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3">Alert</th>
|
||||
@@ -126,7 +139,8 @@ export function InstitutionalDashboard() {
|
||||
{(() => {
|
||||
const badge = DELTA_BADGE[row.convictionDelta];
|
||||
return (
|
||||
<span className={`text-xs uppercase tracking-wider border rounded-lg px-2 py-0.5 ${badge.color}`}>
|
||||
<span className={`inline-flex items-center gap-1 text-xs uppercase tracking-wider border rounded-lg px-2 py-0.5 ${badge.color}`}>
|
||||
<span aria-hidden="true">{badge.shape}</span>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SidebarNav } from "./SidebarNav";
|
||||
import { WatchlistSidebar } from "./WatchlistSidebar";
|
||||
import { SymbolHeader } from "./SymbolHeader";
|
||||
import { MobileTabNav } from "./MobileTabNav";
|
||||
import { CollapsibleProvider, useCollapsibleContext } from "@/lib/collapsible-context";
|
||||
import { useKeyboardShortcuts } from "@/lib/useKeyboardShortcuts";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const TABLET_BREAKPOINT = 1024;
|
||||
|
||||
function useViewport() {
|
||||
const [width, setWidth] = useState(typeof window !== "undefined" ? window.innerWidth : 1200);
|
||||
useEffect(() => {
|
||||
const handler = () => setWidth(window.innerWidth);
|
||||
window.addEventListener("resize", handler);
|
||||
return () => window.removeEventListener("resize", handler);
|
||||
}, []);
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component that attaches the global Escape listener.
|
||||
@@ -11,6 +26,21 @@ import { CollapsibleProvider, useCollapsibleContext } from "@/lib/collapsible-co
|
||||
*/
|
||||
function LayoutShellInner({ children }: { children: React.ReactNode }) {
|
||||
const { openSectionId, setOpenSectionId } = useCollapsibleContext();
|
||||
const viewportWidth = useViewport();
|
||||
const isMobile = viewportWidth < MOBILE_BREAKPOINT;
|
||||
const isTablet = viewportWidth >= MOBILE_BREAKPOINT && viewportWidth < TABLET_BREAKPOINT;
|
||||
|
||||
// Keyboard shortcuts: Escape closes open sections, modals
|
||||
useKeyboardShortcuts([
|
||||
{
|
||||
key: "Escape",
|
||||
handler: () => {
|
||||
if (openSectionId) {
|
||||
setOpenSectionId(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Global Escape listener: collapse the currently open section.
|
||||
useEffect(() => {
|
||||
@@ -31,21 +61,56 @@ function LayoutShellInner({ children }: { children: React.ReactNode }) {
|
||||
{/* Symbol Header — always visible */}
|
||||
<SymbolHeader />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar Navigation — hidden on tablet/mobile */}
|
||||
<SidebarNav />
|
||||
|
||||
{/* Watchlist Sidebar — hidden on mobile */}
|
||||
<WatchlistSidebar />
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{children}
|
||||
{/* Main Content Area — responsive layout */}
|
||||
{isMobile ? (
|
||||
/* Mobile: single column, no sidebar */
|
||||
<>
|
||||
<main className="flex-1 overflow-y-auto p-4 pb-20">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<MobileTabNav />
|
||||
</>
|
||||
) : isTablet ? (
|
||||
/* Tablet: 2-column layout, watchlist accessible via compact bar */
|
||||
<div className="flex flex-1 overflow-hidden flex-col">
|
||||
{/* Compact horizontal watchlist bar */}
|
||||
<div className="hidden md:block border-b border-[#2a2b3a] bg-[#121318] px-4 py-2 overflow-x-auto">
|
||||
<WatchlistSidebar compact />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Center content */}
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
{/* Right analytics — drops below on tablet */}
|
||||
<aside className="hidden lg:block w-80 border-l border-[#2a2b3a] bg-[#121318] overflow-y-auto">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Right column content placeholder */}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Desktop: 3-column layout */
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar Navigation */}
|
||||
<SidebarNav />
|
||||
|
||||
{/* Watchlist Sidebar */}
|
||||
<WatchlistSidebar />
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +119,7 @@ function LayoutShellInner({ children }: { children: React.ReactNode }) {
|
||||
* Main layout shell for the redesigned terminal.
|
||||
* - Desktop (>1024px): Sidebar + Watchlist + Header + Content (3-column)
|
||||
* - Tablet (768-1024px): Watchlist + Header + Content (2-column, sidebar hidden)
|
||||
* - Mobile (<768px): Header + Content (single column)
|
||||
* - Mobile (<768px): Header + Content (single column) + MobileTabNav
|
||||
*/
|
||||
export function LayoutShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useActiveSymbol } from '@/stores/active-symbol-store';
|
||||
import { api, type Snapshot } from '@/lib/trpc';
|
||||
|
||||
/**
|
||||
* Market Outlook Panel — macro analysis and sector context for the active symbol.
|
||||
* Shows sector/industry info, ticker kind classification, and peer context.
|
||||
* Educational display only — no trade recommendations.
|
||||
*/
|
||||
|
||||
interface SectorData {
|
||||
symbol: string;
|
||||
name: string | null;
|
||||
sector: string | null;
|
||||
industry: string | null;
|
||||
exchange: string | null;
|
||||
tickerKind: string;
|
||||
peers: string[] | null;
|
||||
}
|
||||
|
||||
export function MarketOutlookPanel() {
|
||||
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);
|
||||
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); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [symbol]);
|
||||
|
||||
const sector: SectorData | null = snap?.sector ?? null;
|
||||
const quote = snap?.quote ?? null;
|
||||
const candles = snap?.candles ?? [];
|
||||
|
||||
// Simple trend estimation from recent candles
|
||||
const trend = candles.length >= 20 ? (() => {
|
||||
const recent20 = candles.slice(-20);
|
||||
const firstHalf = recent20.slice(0, 10).reduce((s, c) => s + c.c, 0) / 10;
|
||||
const secondHalf = recent20.slice(10).reduce((s, c) => s + c.c, 0) / 10;
|
||||
const pctChange = ((secondHalf - firstHalf) / firstHalf) * 100;
|
||||
if (pctChange > 3) return { label: 'Trending Up', color: 'text-[#34d399]', icon: '▲' };
|
||||
if (pctChange < -3) return { label: 'Trending Down', color: 'text-[#ef4444]', icon: '▼' };
|
||||
return { label: 'Range-Bound', color: 'text-[#fbbf24]', icon: '—' };
|
||||
})() : null;
|
||||
|
||||
// Volatility estimate from recent candles
|
||||
const volatility = candles.length >= 20 ? (() => {
|
||||
const returns = candles.slice(-20).map((c, i, arr) => i > 0 ? (c.c - arr[i - 1].c) / arr[i - 1].c : 0).slice(1);
|
||||
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
|
||||
const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / returns.length;
|
||||
const dailyVol = Math.sqrt(variance);
|
||||
const annualizedVol = dailyVol * Math.sqrt(252) * 100;
|
||||
if (annualizedVol > 50) return { label: 'High', color: 'text-[#ef4444]', value: annualizedVol.toFixed(0) };
|
||||
if (annualizedVol > 25) return { label: 'Moderate', color: 'text-[#fbbf24]', value: annualizedVol.toFixed(0) };
|
||||
return { label: 'Low', color: 'text-[#34d399]', value: annualizedVol.toFixed(0) };
|
||||
})() : null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Market Outlook</h2>
|
||||
<p className="text-sm text-[#5a5b6a] py-8">Loading market data…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Market Outlook</h2>
|
||||
<p className="text-sm text-[#ef4444] py-8">{error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<header className="flex items-baseline justify-between gap-4">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Market Outlook</h2>
|
||||
<span className="text-xs text-[#8a8b9a]">{symbol}</span>
|
||||
</header>
|
||||
|
||||
{/* Sector & Industry */}
|
||||
{sector && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Sector</p>
|
||||
<p className="text-sm font-semibold text-[#e6e7ec]">{sector.sector ?? '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Industry</p>
|
||||
<p className="text-sm font-semibold text-[#e6e7ec]">{sector.industry ?? '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Type</p>
|
||||
<p className="text-sm font-semibold text-[#e6e7ec] capitalize">{sector.tickerKind}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Exchange</p>
|
||||
<p className="text-sm font-semibold text-[#e6e7ec]">{sector.exchange ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trend & Volatility */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{trend && (
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-4">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-2">20-Day Trend</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-lg ${trend.color}`} aria-hidden="true">{trend.icon}</span>
|
||||
<span className={`text-sm font-semibold ${trend.color}`}>{trend.label}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[#5a5b6a] mt-2">
|
||||
Based on the average closing price of the first 10 vs last 10 of the most recent 20 trading days.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{volatility && (
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-4">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-2">Volatility (Annualized)</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-2xl font-bold font-mono ${volatility.color}`}>{volatility.value}%</span>
|
||||
<span className={`text-xs ${volatility.color}`}>{volatility.label}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-[#5a5b6a] mt-2">
|
||||
Derived from daily returns over the last 20 trading days, annualized (×√252).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Peers */}
|
||||
{sector?.peers && sector.peers.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-2">Peers</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{sector.peers.map((peer) => (
|
||||
<span key={peer} className="px-2 py-1 rounded-lg bg-[#1a1b24] border border-[#2a2b3a] text-xs text-[#8a8b9a] font-mono">
|
||||
{peer}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Educational note */}
|
||||
<div className="pt-3 border-t border-[#2a2b3a]">
|
||||
<p className="text-[11px] text-[#5a5b6a]">
|
||||
This panel provides sector and statistical context for the active symbol. Trend and volatility
|
||||
estimates are based on recent price history and are for educational reference only.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function MobileTabNav() {
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`flex-1 flex flex-col items-center justify-center py-2 transition-colors ${
|
||||
className={`flex-1 flex flex-col items-center justify-center py-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-inset ${
|
||||
isActive
|
||||
? "text-[#4f8cff] border-b-2 border-b-[#4f8cff]"
|
||||
: "text-[#8a8b9a] hover:text-[#e6e7ec]"
|
||||
|
||||
@@ -53,9 +53,9 @@ function IVRankBar({ data }: { data: IVRankData | null }) {
|
||||
}
|
||||
|
||||
const getColor = (rank: number) => {
|
||||
if (rank < 25) return "bg-[#3b82f6]"; // low IV — good for buyers
|
||||
if (rank < 50) return "bg-[#22c55e]"; // moderate
|
||||
if (rank < 75) return "bg-[#eab308]"; // elevated
|
||||
if (rank < 25) return "bg-[#4f8cff]"; // low IV — good for buyers
|
||||
if (rank < 50) return "bg-[#34d399]"; // moderate
|
||||
if (rank < 75) return "bg-[#fbbf24]"; // elevated
|
||||
return "bg-[#ef4444]"; // high IV — expensive
|
||||
};
|
||||
|
||||
@@ -154,7 +154,7 @@ function OIWalls({ walls }: { walls: OIWall[] }) {
|
||||
{(wall.openInterest / 1000).toFixed(0)}k
|
||||
</span>
|
||||
<div
|
||||
className={`w-full rounded-t ${wall.right === "call" ? "bg-[#3b82f6]" : "bg-[#ef4444]"}`}
|
||||
className={`w-full rounded-t ${wall.right === "call" ? "bg-[#4f8cff]" : "bg-[#ef4444]"}`}
|
||||
style={{ height: `${(wall.openInterest / maxOI) * 80}px`, minHeight: 2 }}
|
||||
/>
|
||||
<span className="text-[10px] text-[#5a5b6a] mt-1">${wall.strike}</span>
|
||||
@@ -162,8 +162,8 @@ function OIWalls({ walls }: { walls: OIWall[] }) {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span className="text-[#3b82f6]">● Calls</span>
|
||||
<span className="text-[#ef4444]">● Puts</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-[#4f8cff]" aria-hidden="true" /> Calls</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-[#ef4444]" aria-hidden="true" /> Puts</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -259,7 +259,7 @@ function PayoffDiagram({ rows, currentStockPrice }: { rows: OptionChainRow[]; cu
|
||||
stroke="#5a5b6a" strokeWidth="1" strokeDasharray="2" />
|
||||
|
||||
{/* Payoff line */}
|
||||
<path d={pathD} fill="none" stroke={atmRow.right === "call" ? "#3b82f6" : "#ef4444"} strokeWidth="2" />
|
||||
<path d={pathD} fill="none" stroke={atmRow.right === "call" ? "#4f8cff" : "#ef4444"} strokeWidth="2" />
|
||||
|
||||
{/* Zero PnL label */}
|
||||
<text x={padding.left + 5} y={zeroY - 5} fill="#5a5b6a" fontSize="10">PnL $0</text>
|
||||
@@ -292,22 +292,22 @@ function PayoffDiagram({ rows, currentStockPrice }: { rows: OptionChainRow[]; cu
|
||||
function RiskStamp({ isSpread }: { isSpread: boolean }) {
|
||||
if (isSpread) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[#052e1a] border border-[#166534]">
|
||||
<span className="text-green-400 text-lg">✓</span>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[#0a2e1a] border border-[#166534]">
|
||||
<span className="text-[#34d399] text-lg" aria-hidden="true">✓</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-green-400">Defined-Risk Spread</p>
|
||||
<p className="text-xs text-green-300/70">Max loss is known upfront</p>
|
||||
<p className="text-sm font-semibold text-[#34d399]">Defined-Risk Spread</p>
|
||||
<p className="text-xs text-[#34d399]/70">Max loss is known upfront</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[#4c1d1d] border border-[#991b1b]">
|
||||
<span className="text-red-400 text-lg">⚠</span>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[#2a1010] border border-[#991b1b]">
|
||||
<span className="text-[#ef4444] text-lg" aria-hidden="true">⚠</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-red-400">Advanced Only</p>
|
||||
<p className="text-xs text-red-300/70">Undefined risk — potential for significant losses</p>
|
||||
<p className="text-sm font-semibold text-[#ef4444]">Advanced Only</p>
|
||||
<p className="text-xs text-[#ef4444]/70">Undefined risk — potential for significant losses</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -384,7 +384,7 @@ export function OptionsDDPanel() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Options Due Diligence</h2>
|
||||
<div className="flex items-center gap-2 text-[#5a5b6a] text-sm">
|
||||
<div className="w-4 h-4 border-2 border-[#5a5b6a] border-t-transparent rounded-full animate-spin" />
|
||||
@@ -396,18 +396,19 @@ export function OptionsDDPanel() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Options Due Diligence</h2>
|
||||
<p className="text-sm text-red-400">Error: {error}</p>
|
||||
<p className="text-sm text-[#ef4444]">Error: {error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chainData || chainData.rows.length === 0) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Options Due Diligence</h2>
|
||||
<p className="text-sm text-[#5a5b6a]">No options data available for {symbol}</p>
|
||||
<p className="text-sm text-[#5a5b6a]">No options data available for {symbol}.</p>
|
||||
<p className="text-xs text-[#5a5b6a]">This may be a crypto or index symbol without an options chain.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,11 +68,14 @@ export function OverviewPanel() {
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<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>
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">{symbol}</h2>
|
||||
<p className="text-xs 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>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-[#fcd34d] border border-[#78350f] rounded px-2 py-0.5">
|
||||
<span aria-hidden="true">⚠</span>
|
||||
{UI_STRINGS.staleBadge}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -87,11 +90,11 @@ export function OverviewPanel() {
|
||||
<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>
|
||||
<p className="text-2xl 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]"}`}>
|
||||
<p className={`text-sm 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>
|
||||
|
||||
@@ -22,6 +22,7 @@ const SECTIONS: NavSection[] = [
|
||||
{ label: "Overview", href: "/" },
|
||||
{ label: "Chart Lab", href: "/chart-lab" },
|
||||
{ label: "Institutional", href: "/institutional" },
|
||||
{ label: "Market Outlook", href: "/market-outlook" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -30,6 +31,7 @@ const SECTIONS: NavSection[] = [
|
||||
items: [
|
||||
{ label: "Trade Plan", href: "/trade-plan" },
|
||||
{ label: "Execution", href: "/execution" },
|
||||
{ label: "Trade Closure", href: "/trade-closure" },
|
||||
{ label: "Daily Focus", href: "/daily-focus" },
|
||||
],
|
||||
},
|
||||
@@ -66,7 +68,7 @@ export function SidebarNav() {
|
||||
{/* Section Header */}
|
||||
<button
|
||||
onClick={() => toggleCollapse(section.title)}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-[#8a8b9a] hover:text-[#e6e7ec] transition-colors"
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 text-xs font-semibold text-[#8a8b9a] hover:text-[#e6e7ec] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] rounded-lg"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{section.icon}</span>
|
||||
@@ -86,7 +88,7 @@ export function SidebarNav() {
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={`block px-2 py-1.5 rounded-lg text-xs transition-colors ${
|
||||
className={`block px-2 py-1.5 rounded-lg text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] ${
|
||||
isActive
|
||||
? "bg-[#4f8cff]/20 text-[#4f8cff] border-l-2 border-[#4f8cff]"
|
||||
: "text-[#8a8b9a] hover:text-[#e6e7ec] hover:bg-[#1a1b24]"
|
||||
|
||||
@@ -13,9 +13,14 @@ export function SymbolHeader() {
|
||||
const setActive = useActiveSymbol((s) => s.setActive);
|
||||
const [draft, setDraft] = useState(symbol);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [snap, setSnap] = useState<ReturnType<typeof api.market.snapshot> | null>(null);
|
||||
|
||||
// Fetch current price for display
|
||||
const { data: snap } = api.market.snapshot.useQuery(draft || symbol);
|
||||
useEffect(() => {
|
||||
const sym = draft || symbol;
|
||||
api.market.snapshot(sym).then(setSnap).catch(() => {});
|
||||
}, [draft, symbol]);
|
||||
|
||||
const quote = snap?.quote ?? null;
|
||||
const sector = snap?.sector ?? null;
|
||||
|
||||
@@ -55,49 +60,58 @@ export function SymbolHeader() {
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-[#2a2b3a] bg-[#121318]/95 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-4 px-4 py-2.5">
|
||||
{/* Symbol Info */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<h1 className="text-base font-bold text-[#4f8cff]">Investor Flow</h1>
|
||||
<div className="hidden sm:flex items-baseline gap-2">
|
||||
<span className="text-lg font-bold text-[#e6e7ec]">{symbol}</span>
|
||||
{priceLabel && <span className="text-sm font-mono text-[#e6e7ec]">{priceLabel}</span>}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
{/* Left: Logo mark */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#4f8cff] flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-[#121318]">IF</span>
|
||||
</div>
|
||||
<h1 className="hidden sm:block text-sm font-semibold text-[#e6e7ec] tracking-tight">
|
||||
Investor Flow
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Right: Ticker info + Search grouped together */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Active symbol — compact, next to search */}
|
||||
<div className="hidden sm:flex items-baseline gap-2 text-xs">
|
||||
<span className="font-bold text-[#e6e7ec]">{symbol}</span>
|
||||
{priceLabel && (
|
||||
<span className="font-mono text-[#8a8b9a]">{priceLabel}</span>
|
||||
)}
|
||||
{changeLabel && (
|
||||
<span className={`text-xs font-mono ${up ? "text-[#34d399]" : "text-[#fbbf24]"}`}>
|
||||
<span className={`font-mono ${up ? "text-[#34d399]" : "text-[#fbbf24]"}`}>
|
||||
{changeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 max-w-xs">
|
||||
<div className={`relative transition-all duration-200 ${showSearch ? "w-full" : ""}`}>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onFocus={() => setShowSearch(true)}
|
||||
onBlur={() => setTimeout(() => setShowSearch(false), 200)}
|
||||
placeholder="Search symbol (Ctrl+K)"
|
||||
className="w-full bg-[#1a1b24] border border-[#2a2b3a] rounded-lg px-3 py-1.5 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a] focus:outline-none focus:border-[#4f8cff]"
|
||||
/>
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] text-[#5a5b6a] bg-[#0a0b0f] border border-[#2a2b3a] rounded px-1.5">
|
||||
⌘K
|
||||
</kbd>
|
||||
{/* Mobile: just ticker */}
|
||||
<div className="sm:hidden flex items-center gap-1.5 text-xs">
|
||||
<span className="font-bold text-[#e6e7ec]">{symbol}</span>
|
||||
{priceLabel && (
|
||||
<span className="font-mono text-[#8a8b9a]">{priceLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Sector info */}
|
||||
{sectorLabel && (
|
||||
<span className="hidden md:block text-xs text-[#5a5b6a]">{sectorLabel}</span>
|
||||
)}
|
||||
|
||||
{/* Mobile symbol display */}
|
||||
<div className="sm:hidden flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-[#e6e7ec]">{symbol}</span>
|
||||
{priceLabel && (
|
||||
<span className="text-xs font-mono text-[#e6e7ec]">{priceLabel}</span>
|
||||
)}
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSubmit} className="flex items-center">
|
||||
<label className="sr-only" htmlFor="symbol-search">Search symbol</label>
|
||||
<div className={`relative transition-all duration-200 ${showSearch ? "w-56" : "w-40"}`}>
|
||||
<input
|
||||
id="symbol-search"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onFocus={() => setShowSearch(true)}
|
||||
onBlur={() => setTimeout(() => setShowSearch(false), 200)}
|
||||
placeholder="Search…"
|
||||
className="w-full bg-[#1a1b24] border border-[#2a2b3a] rounded-lg px-3 py-1.5 text-xs text-[#e6e7ec] placeholder:text-[#5a5b6a] focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
/>
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] text-[#5a5b6a] bg-[#0a0b0f] border border-[#2a2b3a] rounded px-1.5">
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -12,16 +12,24 @@ interface MiniQuote extends Quote {
|
||||
sparkline?: number[];
|
||||
}
|
||||
|
||||
export function WatchlistSidebar() {
|
||||
interface WatchlistSidebarProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const setActive = useActiveSymbol((s) => s.setActive);
|
||||
const [entries, setEntries] = useState<WatchlistEntry[]>([]);
|
||||
const [quotes, setQuotes] = useState<Map<string, MiniQuote>>(new Map());
|
||||
const [draft, setDraft] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [holdings, setHoldings] = useState<ReturnType<typeof api.portfolio.holdings>[]>([]);
|
||||
|
||||
// Portfolio summary
|
||||
const { data: holdings } = api.portfolio.holdings.useQuery();
|
||||
useEffect(() => {
|
||||
api.portfolio.holdings().then(setHoldings).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const totalValue = (holdings ?? []).reduce((sum, h) => sum + h.shares * h.avg_cost, 0);
|
||||
|
||||
const load = async () => {
|
||||
@@ -64,37 +72,64 @@ export function WatchlistSidebar() {
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-56 border-r border-[#2a2b3a] bg-[#121318] flex-shrink-0 hidden lg:block">
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Portfolio Summary */}
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Portfolio</p>
|
||||
<p className="text-sm font-bold text-[#e6e7ec]">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<span className="text-[10px] text-[#5a5b6a]">{entries.length} symbols</span>
|
||||
<span className="text-[10px] text-[#5a5b6a]">{holdings?.length ?? 0} holdings</span>
|
||||
</div>
|
||||
</div>
|
||||
const containerClass = compact
|
||||
? "w-full border-b border-[#2a2b3a] bg-[#121318] flex-shrink-0"
|
||||
: "w-56 border-r border-[#2a2b3a] bg-[#121318] flex-shrink-0 hidden lg:block";
|
||||
|
||||
{/* Add Symbol */}
|
||||
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-1">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Add symbol..."
|
||||
className="flex-1 bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-1.5 text-xs text-[#e6e7ec] placeholder:text-[#5a5b6a] focus:outline-none focus:border-[#4f8cff]"
|
||||
/>
|
||||
<button type="submit" className="px-2 py-1.5 rounded text-xs bg-[#4f8cff] text-white">+</button>
|
||||
</form>
|
||||
const contentClass = compact ? "flex items-center gap-3 px-4 py-2 overflow-x-auto" : "p-3 space-y-3";
|
||||
|
||||
return (
|
||||
<aside className={containerClass}>
|
||||
<div className={contentClass}>
|
||||
{/* Portfolio Summary — compact mode shows minimal */}
|
||||
{compact ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[#5a5b6a]">Portfolio</span>
|
||||
<span className="text-xs font-bold text-[#e6e7ec]">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</span>
|
||||
<span className="text-[10px] text-[#5a5b6a]">{entries.length} sym</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Portfolio</p>
|
||||
<p className="text-sm font-bold text-[#e6e7ec]">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<span className="text-[10px] text-[#5a5b6a]">{entries.length} symbols</span>
|
||||
<span className="text-[10px] text-[#5a5b6a]">{holdings?.length ?? 0} holdings</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Symbol — compact mode hides */}
|
||||
{!compact && (
|
||||
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-1">
|
||||
<label className="sr-only" htmlFor="watchlist-add-symbol">Add symbol</label>
|
||||
<input
|
||||
id="watchlist-add-symbol"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Add symbol..."
|
||||
className="flex-1 bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-1.5 text-xs text-[#e6e7ec] placeholder:text-[#5a5b6a] focus:outline-none focus:border-[#4f8cff] focus:ring-1 focus:ring-[#4f8cff]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="Add symbol to watchlist"
|
||||
className="px-2 py-1.5 rounded text-xs bg-[#4f8cff] text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f]"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Watchlist */}
|
||||
{loading ? (
|
||||
<p className="text-xs text-[#5a5b6a]">Loading...</p>
|
||||
<p className="text-xs text-[#5a5b6a]">Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="text-xs text-[#5a5b6a]">No symbols tracked</p>
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-[#5a5b6a]">No symbols tracked</p>
|
||||
<p className="text-[10px] text-[#5a5b6a] mt-1">Use the input above to add one</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5 max-h-[calc(100vh-320px)] overflow-y-auto">
|
||||
<div className={compact ? "flex items-center gap-2 overflow-x-auto" : "space-y-0.5 max-h-[calc(100vh-320px)] overflow-y-auto"}>
|
||||
{entries.map((e) => {
|
||||
const quote = quotes.get(e.symbol);
|
||||
const isActive = e.symbol === activeSymbol;
|
||||
@@ -104,46 +139,49 @@ export function WatchlistSidebar() {
|
||||
<button
|
||||
key={e.symbol}
|
||||
onClick={() => setActive(e.symbol)}
|
||||
className={`w-full flex items-center justify-between py-1.5 px-2 rounded-lg text-xs transition-colors ${
|
||||
aria-label={`Switch to ${e.symbol}`}
|
||||
className={`shrink-0 flex items-center gap-2 py-1 px-2 rounded-lg text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] ${
|
||||
isActive
|
||||
? "bg-[#4f8cff]/20 border-l-2 border-[#4f8cff]"
|
||||
: "hover:bg-[#1a1b24]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono font-bold ${isActive ? "text-[#4f8cff]" : "text-[#e6e7ec]"}`}>
|
||||
{e.symbol}
|
||||
</span>
|
||||
{/* Mini sparkline */}
|
||||
{quote?.sparkline && quote.sparkline.length > 1 && (
|
||||
<svg width="24" height="12" className="flex-shrink-0">
|
||||
<span className={`font-mono font-bold whitespace-nowrap ${isActive ? "text-[#4f8cff]" : "text-[#e6e7ec]"}`}>
|
||||
{e.symbol}
|
||||
</span>
|
||||
{/* Mini sparkline with shape indicator */}
|
||||
{quote?.sparkline && quote.sparkline.length > 1 && (
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0" aria-hidden="true">
|
||||
<svg width="20" height="12" className="flex-shrink-0">
|
||||
<polyline
|
||||
points={quote.sparkline.map((v, i) => `${(i / (quote.sparkline!.length - 1)) * 24},${12 - ((v - Math.min(...quote.sparkline)) / (Math.max(...quote.sparkline) - Math.min(...quote.sparkline) || 1)) * 10}`).join(" ")}
|
||||
points={quote.sparkline.map((v, i) => `${(i / (quote.sparkline!.length - 1)) * 20},${12 - ((v - Math.min(...quote.sparkline)) / (Math.max(...quote.sparkline) - Math.min(...quote.sparkline) || 1)) * 10}`).join(" ")}
|
||||
fill="none"
|
||||
stroke={up ? "#34d399" : "#fbbf24"}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{quote && (
|
||||
<>
|
||||
<span className={`font-mono ${isActive ? "text-[#e6e7ec]" : "text-[#8a8b9a]"}`}>
|
||||
${quote.price?.toFixed(0)}
|
||||
</span>
|
||||
<span className={`text-[9px] font-mono ${up ? "text-[#34d399]" : "text-[#fbbf24]"}`}>
|
||||
{up ? "+" : ""}{quote.changePercent?.toFixed(1)}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className={`text-[8px] ${up ? "text-[#34d399]" : "text-[#fbbf24]"}`}>
|
||||
{up ? "▲" : "▼"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{quote && (
|
||||
<span className="flex items-center gap-0.5 font-mono text-[9px] whitespace-nowrap">
|
||||
<span className={up ? "text-[#34d399]" : "text-[#fbbf24]"} aria-hidden="true">{up ? "▲" : "▼"}</span>
|
||||
<span className={up ? "text-[#34d399]" : "text-[#fbbf24]"}>
|
||||
{up ? "+" : ""}{quote.changePercent?.toFixed(1)}%
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{!compact && (
|
||||
<button
|
||||
onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }}
|
||||
className="text-[9px] text-[#5a5b6a] hover:text-[#ef4444]"
|
||||
aria-label={`Remove ${e.symbol} from watchlist`}
|
||||
className="text-[9px] text-[#5a5b6a] hover:text-[#ef4444] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4f8cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] rounded"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -108,6 +108,55 @@ export interface IndicatorResult {
|
||||
relvol?: Array<{ ts: string; value: number }>;
|
||||
}
|
||||
|
||||
// --- EDGAR / SEC Filings types (Slice 6 / M6) -------------------------------
|
||||
|
||||
export interface EdgarFiling {
|
||||
form: string;
|
||||
dateReporter: string;
|
||||
accessionNumber: string;
|
||||
accessionNormalization: string;
|
||||
reportDate: string;
|
||||
reportFile: string;
|
||||
primaryDocument: string;
|
||||
}
|
||||
|
||||
export interface FilerMeta {
|
||||
cik: string;
|
||||
name: string | null;
|
||||
sic: string | null;
|
||||
}
|
||||
|
||||
export interface Form13fHolding {
|
||||
cusip: string;
|
||||
issuerName: string;
|
||||
value: number;
|
||||
sshPrnamt: number;
|
||||
}
|
||||
|
||||
export interface Form13fResult {
|
||||
holdings: Form13fHolding[];
|
||||
accession: string;
|
||||
}
|
||||
|
||||
export interface Form4Transaction {
|
||||
reporter: string;
|
||||
relationship: string;
|
||||
securityTitle: string;
|
||||
transactionDate: string;
|
||||
transactionCode: string;
|
||||
shares: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface Form4Result {
|
||||
transactions: Form4Transaction[];
|
||||
accession: string;
|
||||
}
|
||||
|
||||
export interface FilingsSearchResult {
|
||||
filings: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
market: {
|
||||
snapshot: (symbol: string) => trpcQuery<Snapshot>("market.snapshot", { symbol }),
|
||||
@@ -116,6 +165,20 @@ export const api = {
|
||||
indicators: (symbol: string, opts: IndicatorOptions) =>
|
||||
trpcQuery<IndicatorResult>("market.indicators", { symbol, ...opts }),
|
||||
},
|
||||
edgar: {
|
||||
filings_index: (input: { cik: string; formTypes?: string[]; dateRange?: { from?: string; to?: string } }) =>
|
||||
trpcQuery<{ filings: EdgarFiling[]; cik: string }>("edgar.filings_index", input),
|
||||
company_facts: (cik: string) =>
|
||||
trpcQuery<{ facts: Record<string, unknown>; cik: string }>("edgar.company_facts", { cik }),
|
||||
filer_cik_meta: (cik: string) =>
|
||||
trpcQuery<{ meta: FilerMeta; cik: string }>("edgar.filer_cik_meta", { cik }),
|
||||
full_text_search: (q: string) =>
|
||||
trpcQuery<FilingsSearchResult>("edgar.full_text_search", { q }),
|
||||
form13f_holdings: (cik: string, accession: string) =>
|
||||
trpcQuery<{ holdings: Form13fResult }>("edgar.form13f_holdings", { cik, accession }),
|
||||
form4_tx: (cik: string, accession: string) =>
|
||||
trpcQuery<{ transactions: Form4Result }>("edgar.form4_tx", { cik, accession }),
|
||||
},
|
||||
auth: {
|
||||
signup: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.signup", { email, password }),
|
||||
login: (email: string, password: string, totp?: string) => trpcMutate<{ userId: string }>("auth.login", { email, password, ...(totp ? { totp } : {}) }),
|
||||
@@ -130,4 +193,54 @@ export const api = {
|
||||
complete: (input: { complexity: Complexity; drawdownTolerancePct?: number; firstWatchlistSymbols?: string[]; portfolio?: PortfolioEntry[] }) =>
|
||||
trpcMutate<{ ok: boolean; watchlistId: string }>("onboarding.complete", input),
|
||||
},
|
||||
watchlists: {
|
||||
list: () => trpcQuery<WatchlistEntry[]>("watchlists.list"),
|
||||
addSymbol: (symbol: string) => trpcMutate<{ added: boolean }>("watchlists.addSymbol", { symbol }),
|
||||
removeSymbol: (symbol: string) => trpcMutate<{ removed: boolean }>("watchlists.removeSymbol", { symbol }),
|
||||
},
|
||||
options: {
|
||||
chain: (symbol: string, opts?: { expiry?: string }) =>
|
||||
trpcQuery<{ symbol: string; expiration: string | null; rows: Array<Record<string, unknown>> }>("options.chain", { symbol, ...opts }),
|
||||
greeks: (symbol: string, opts?: { expiry?: string; strike?: number }) =>
|
||||
trpcQuery<{ symbol: string; greeks: Record<string, unknown> | null; strike?: number; right?: string; lastPrice?: number; impliedVolatility?: number }>("options.greeks", { symbol, ...opts }),
|
||||
},
|
||||
portfolio: {
|
||||
holdings: () => trpcQuery<PortfolioHolding[]>("portfolio.holdings"),
|
||||
addHolding: (symbol: string, shares: number, avgCost: number) => trpcMutate<{ created: boolean }>("portfolio.addHolding", { symbol, shares, avgCost }),
|
||||
removeHolding: (symbol: string) => trpcMutate<{ removed: boolean }>("portfolio.removeHolding", { symbol }),
|
||||
},
|
||||
dashboard: {
|
||||
rollup: () => trpcQuery<Record<string, unknown>[]>("dashboard.rollup"),
|
||||
},
|
||||
institutional: {
|
||||
flow: (symbol: string) =>
|
||||
trpcQuery<{ symbol: string; flow: Array<Record<string, unknown>>; quarters: string[] }>("institutional.flow", { symbol }),
|
||||
insiderStream: (symbol: string, limit = 50) =>
|
||||
trpcQuery<{ symbol: string; events: Array<Record<string, unknown>>; netShares: number; count: number }>("institutional.insiderStream", { symbol, limit }),
|
||||
},
|
||||
emotionLogger: {
|
||||
add: (input: { tradeExecutionId: string; priceAtEvent?: number; emotion: string; note?: string }) =>
|
||||
trpcMutate<{ success: boolean; log: Record<string, unknown> }>("emotionLogger.add", input),
|
||||
getByTrade: (tradeExecutionId: string) =>
|
||||
trpcQuery<{ logs: Record<string, unknown>[] }>("emotionLogger.getByTrade", { tradeExecutionId }),
|
||||
delete: (id: string) =>
|
||||
trpcMutate<{ success: boolean }>("emotionLogger.delete", { id }),
|
||||
},
|
||||
alerts: {
|
||||
unackedCount: () => trpcQuery<{ count: number }>("alerts.unackedCount"),
|
||||
acknowledgeAll: () => trpcMutate<{ ok: boolean }>("alerts.acknowledgeAll"),
|
||||
},
|
||||
};
|
||||
|
||||
export interface WatchlistEntry {
|
||||
symbol: string;
|
||||
added_at: string;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface PortfolioHolding {
|
||||
symbol: string;
|
||||
shares: number;
|
||||
avg_cost: number;
|
||||
added_at: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface KeyboardShortcut {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
handler: (e: KeyboardEvent) => void;
|
||||
// Only fire when no input/modal is focused
|
||||
when?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared keyboard shortcut hook.
|
||||
* Registers shortcuts at the LayoutShell level (closest to root).
|
||||
* Uses keydown events with preventDefault only when needed.
|
||||
*/
|
||||
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
for (const shortcut of shortcuts) {
|
||||
const matchesKey = e.key === shortcut.key;
|
||||
const matchesCtrl = shortcut.ctrl ? e.ctrlKey || e.metaKey : !e.ctrlKey && !e.metaKey;
|
||||
const matchesShift = shortcut.shift ? e.shiftKey : !e.shiftKey;
|
||||
const matchesAlt = shortcut.alt ? e.altKey : !e.altKey;
|
||||
|
||||
if (matchesKey && matchesCtrl && matchesShift && matchesAlt) {
|
||||
if (shortcut.when && !shortcut.when()) continue;
|
||||
e.preventDefault();
|
||||
shortcut.handler(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [shortcuts]);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ interface ClosureStore {
|
||||
emotionLogs: EmotionLog[];
|
||||
addEmotionLog: (log: Omit<EmotionLog, 'id' | 'timestamp'>) => void;
|
||||
removeEmotionLog: (idx: number) => void;
|
||||
persistEmotionLogs: (logs: EmotionLog[]) => void;
|
||||
|
||||
// Post-trade review fields
|
||||
diary: string;
|
||||
@@ -36,30 +37,77 @@ interface ClosureStore {
|
||||
removeMistake: (idx: number) => void;
|
||||
}
|
||||
|
||||
export const useClosureStore = create<ClosureStore>((set, get) => ({
|
||||
activeExecutionId: null,
|
||||
const STORAGE_KEY = 'investor-flow-closure-state';
|
||||
|
||||
emotionLogs: [],
|
||||
addEmotionLog: (log) => {
|
||||
const entry = { id: crypto.randomUUID(), timestamp: Date.now(), ...log };
|
||||
set({ emotionLogs: [...get().emotionLogs, entry] });
|
||||
},
|
||||
removeEmotionLog: (idx) => set({ emotionLogs: get().emotionLogs.filter((_, i) => i !== idx) }),
|
||||
function loadFromStorage(): Partial<ClosureStore> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
emotionLogs: parsed.emotionLogs ?? [],
|
||||
activeExecutionId: parsed.activeExecutionId ?? null,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
diary: '',
|
||||
setDiary: (d) => set({ diary: d }),
|
||||
function saveToStorage(state: ClosureStore): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const data = {
|
||||
emotionLogs: state.emotionLogs,
|
||||
activeExecutionId: state.activeExecutionId,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
// Storage full or unavailable — silent fail
|
||||
}
|
||||
}
|
||||
|
||||
followedPlan: null,
|
||||
setFollowedPlan: (v) => set({ followedPlan: v }),
|
||||
export const useClosureStore = create<ClosureStore>((set, get) => {
|
||||
const stored = loadFromStorage();
|
||||
|
||||
deviationReason: '',
|
||||
setDeviationReason: (r) => set({ deviationReason: r }),
|
||||
return {
|
||||
activeExecutionId: stored.activeExecutionId ?? null,
|
||||
|
||||
strengths: [],
|
||||
addStrength: (s) => set({ strengths: [...get().strengths, s] }),
|
||||
removeStrength: (idx) => set({ strengths: get().strengths.filter((_, i) => i !== idx) }),
|
||||
emotionLogs: stored.emotionLogs ?? [],
|
||||
addEmotionLog: (log) => {
|
||||
const entry = { id: crypto.randomUUID(), timestamp: Date.now(), ...log };
|
||||
set((state) => {
|
||||
const updated = [...state.emotionLogs, entry];
|
||||
set({ emotionLogs: updated });
|
||||
// Persist after state update
|
||||
setTimeout(() => saveToStorage(get()), 0);
|
||||
});
|
||||
},
|
||||
removeEmotionLog: (idx) => set((state) => {
|
||||
const updated = state.emotionLogs.filter((_, i) => i !== idx);
|
||||
set({ emotionLogs: updated });
|
||||
setTimeout(() => saveToStorage(get()), 0);
|
||||
}),
|
||||
persistEmotionLogs: (logs) => {
|
||||
set({ emotionLogs: logs });
|
||||
setTimeout(() => saveToStorage(get()), 0);
|
||||
},
|
||||
|
||||
mistakes: [],
|
||||
addMistake: (desc, stage) => set({ mistakes: [...get().mistakes, { description: desc, stage }] }),
|
||||
removeMistake: (idx) => set({ mistakes: get().mistakes.filter((_, i) => i !== idx) }),
|
||||
}));
|
||||
diary: '',
|
||||
setDiary: (d) => set({ diary: d }),
|
||||
|
||||
followedPlan: null,
|
||||
setFollowedPlan: (v) => set({ followedPlan: v }),
|
||||
|
||||
deviationReason: '',
|
||||
setDeviationReason: (r) => set({ deviationReason: r }),
|
||||
|
||||
strengths: [],
|
||||
addStrength: (s) => set({ strengths: [...get().strengths, s] }),
|
||||
removeStrength: (idx) => set({ strengths: get().strengths.filter((_, i) => i !== idx) }),
|
||||
|
||||
mistakes: [],
|
||||
addMistake: (desc, stage) => set({ mistakes: [...get().mistakes, { description: desc, stage }] }),
|
||||
removeMistake: (idx) => set({ mistakes: get().mistakes.filter((_, i) => i !== idx) }),
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user