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:
Investor Flow Build
2026-07-05 08:50:12 -04:00
parent 1e818c0881
commit 55f07e6b42
27 changed files with 2331 additions and 555 deletions
+5 -5
View File
@@ -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>
+26 -19
View File
@@ -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>
+40 -35
View File
@@ -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>}
+30 -16
View File
@@ -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>
);
+81 -16
View File
@@ -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 (
+164
View File
@@ -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>
);
}
+1 -1
View File
@@ -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]"
+21 -20
View File
@@ -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>
);
}
+8 -5
View File
@@ -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>
+4 -2
View File
@@ -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]"
+50 -36
View File
@@ -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>
+89 -51
View File
@@ -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>
);
})}