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