Files
stonks-oracle/frontend/src/pages/CompanyDetail.tsx
T
Celes Renata 951b733ac3
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
fix: move cutoffTs declaration before its use in filtered
Variable was used before declaration (temporal dead zone error).
Moved windowHours/hoursBack/cutoffTs above the filtered const that
references cutoffTs.
2026-04-29 17:08:36 +00:00

846 lines
35 KiB
TypeScript

import { useParams, useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import {
useCompany,
useCompanySources,
useCreateAlias,
useCreateSource,
useCompanyMacroImpacts,
useCompanyCompetitors,
useInferCompetitors,
useHistoricalPatterns,
useCompetitiveSignals,
useCorporateDecisions,
useTrends,
useTrendHistory,
useMarketPrices,
} from '../api/hooks';
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
import { DataTable, type Column } from '../components/DataTable';
import type { Source, Alias, MacroImpactRecord, CompetitorRelationship, HistoricalPattern, CompetitiveSignal, CorporateDecision, TrendSummary, MarketPrice } from '../api/hooks';
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
CartesianGrid, Legend,
} from 'recharts';
const sourceCols: Column<Source>[] = [
{ key: 'source_type', header: 'Type' },
{ key: 'source_name', header: 'Name' },
{ key: 'credibility_score', header: 'Credibility', render: (r) => <span>{(r.credibility_score * 100).toFixed(0)}%</span> },
{ key: 'active', header: 'Status', render: (r) => <StatusBadge status={r.active ? 'active' : 'disabled'} /> },
];
export function CompanyDetailPage() {
const { id } = useParams({ from: '/companies/$id' });
const navigate = useNavigate();
const { data: company, isLoading } = useCompany(id);
const { data: sources } = useCompanySources(id);
const { data: macroData } = useCompanyMacroImpacts(company?.ticker);
const { data: competitors } = useCompanyCompetitors(id);
const inferCompetitors = useInferCompetitors(id);
const { data: patterns } = useHistoricalPatterns(company?.ticker);
const { data: signals } = useCompetitiveSignals(company?.ticker);
const { data: decisions } = useCorporateDecisions(company?.ticker);
const { data: trends } = useTrends({ ticker: company?.ticker, limit: 200 });
const { data: trendHistory } = useTrendHistory({ ticker: company?.ticker, limit: 500 });
const { data: marketPrices } = useMarketPrices(company?.ticker, 200);
const [tab, setTab] = useState<'trends' | 'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('trends');
if (isLoading || !company) return <LoadingSpinner />;
const tabs = ['trends', 'sources', 'aliases', 'macro', 'competitors', 'patterns', 'signals', 'decisions'] as const;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold text-gray-100">{company.ticker}</h1>
<StatusBadge status={company.active ? 'active' : 'disabled'} />
</div>
<Card>
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-4">
<div><dt className="text-gray-500">Name</dt><dd className="text-gray-200">{company.legal_name}</dd></div>
<div><dt className="text-gray-500">Exchange</dt><dd className="text-gray-200">{company.exchange ?? '—'}</dd></div>
<div><dt className="text-gray-500">Sector</dt><dd className="text-gray-200">{company.sector ?? '—'}</dd></div>
<div><dt className="text-gray-500">Industry</dt><dd className="text-gray-200">{company.industry ?? '—'}</dd></div>
<div><dt className="text-gray-500">Market Cap</dt><dd className="text-gray-200">{company.market_cap_bucket ?? '—'}</dd></div>
<div><dt className="text-gray-500">Sources</dt><dd className="text-gray-200">{company.active_source_count ?? 0}</dd></div>
</dl>
</Card>
{/* Tabs */}
<div className="flex gap-4 border-b border-surface-700 overflow-x-auto">
{tabs.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`whitespace-nowrap pb-2 text-sm font-medium capitalize transition-colors ${tab === t ? 'border-b-2 border-brand-500 text-brand-300' : 'text-gray-500 hover:text-gray-300'}`}
>
{t}
</button>
))}
</div>
{tab === 'trends' && (
<TrendHistoryChart trends={trendHistory ?? []} latestTrends={trends ?? []} ticker={company.ticker} marketPrices={marketPrices ?? []} />
)}
{tab === 'sources' && (
<div className="space-y-4">
<DataTable<Source> data={sources ?? []} columns={sourceCols} keyField="id" />
<AddSourceForm companyId={id} />
</div>
)}
{tab === 'aliases' && (
<div className="space-y-4">
<AliasesList aliases={company.aliases ?? []} />
<AddAliasForm companyId={id} />
</div>
)}
{tab === 'macro' && (
<MacroExposurePanel macroData={macroData} onEventClick={(eventId) => navigate({ to: '/macro/events/$id', params: { id: eventId } })} />
)}
{tab === 'competitors' && (
<CompetitorsPanel competitors={competitors ?? []} onInfer={() => inferCompetitors.mutate()} isInferring={inferCompetitors.isPending} />
)}
{tab === 'patterns' && (
<HistoricalPatternsPanel patterns={patterns ?? []} />
)}
{tab === 'signals' && (
<CompetitiveSignalsPanel signals={signals ?? []} />
)}
{tab === 'decisions' && (
<DecisionsPanel decisions={decisions ?? []} />
)}
</div>
);
}
function AliasesList({ aliases }: { aliases: Alias[] }) {
if (aliases.length === 0) return <p className="text-sm text-gray-500">No aliases configured</p>;
return (
<div className="flex flex-wrap gap-2">
{aliases.map((a) => (
<span key={a.id} className="rounded-full border border-surface-700 bg-surface-800 px-3 py-1 text-xs text-gray-300">
{a.alias} <span className="text-gray-500">({a.alias_type})</span>
</span>
))}
</div>
);
}
function AddAliasForm({ companyId }: { companyId: string }) {
const [alias, setAlias] = useState('');
const mutation = useCreateAlias(companyId);
return (
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
if (alias.trim()) mutation.mutate({ alias: alias.trim() }, { onSuccess: () => setAlias('') });
}}
>
<input
type="text"
placeholder="Add alias…"
value={alias}
onChange={(e) => setAlias(e.target.value)}
className="rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
aria-label="New alias"
/>
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
Add
</button>
</form>
);
}
function AddSourceForm({ companyId }: { companyId: string }) {
const [open, setOpen] = useState(false);
const [sourceType, setSourceType] = useState('market_api');
const [sourceName, setSourceName] = useState('');
const [credibility, setCredibility] = useState(0.5);
const mutation = useCreateSource(companyId);
if (!open) {
return (
<button onClick={() => setOpen(true)} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">
Add Source
</button>
);
}
return (
<Card>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
mutation.mutate(
{ source_type: sourceType, source_name: sourceName, credibility_score: credibility },
{ onSuccess: () => { setOpen(false); setSourceName(''); } },
);
}}
>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs text-gray-500" htmlFor="source-type">Type</label>
<select
id="source-type"
value={sourceType}
onChange={(e) => setSourceType(e.target.value)}
className="w-full rounded-md border border-surface-700 bg-surface-900 px-2 py-1.5 text-sm text-gray-200"
>
{['market_api', 'news_api', 'filings_api', 'web_scrape', 'broker'].map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs text-gray-500" htmlFor="source-name">Name</label>
<input
id="source-name"
type="text"
value={sourceName}
onChange={(e) => setSourceName(e.target.value)}
className="w-full rounded-md border border-surface-700 bg-surface-900 px-2 py-1.5 text-sm text-gray-200 placeholder-gray-500"
placeholder="Source name"
required
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs text-gray-500" htmlFor="credibility">Credibility: {(credibility * 100).toFixed(0)}%</label>
<input
id="credibility"
type="range"
min={0}
max={1}
step={0.05}
value={credibility}
onChange={(e) => setCredibility(Number(e.target.value))}
className="w-full"
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
Save
</button>
<button type="button" onClick={() => setOpen(false)} className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800">
Cancel
</button>
</div>
</form>
</Card>
);
}
function MacroExposurePanel({ macroData, onEventClick }: {
macroData: { exposure_profile: import('../api/hooks').ExposureProfile | null; impacts: MacroImpactRecord[] } | undefined;
onEventClick: (eventId: string) => void;
}) {
if (!macroData) return <p className="text-sm text-gray-500">Loading macro data</p>;
const profile = macroData.exposure_profile;
const impacts = macroData.impacts ?? [];
return (
<div className="space-y-4">
{/* Exposure Profile */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Exposure Profile</h2>
{!profile ? (
<p className="text-sm text-gray-500">No exposure profile configured</p>
) : (
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-3">
<div>
<dt className="text-gray-500">Market Position</dt>
<dd><StatusBadge status={profile.market_position_tier} /></dd>
</div>
<div>
<dt className="text-gray-500">Export Dependency</dt>
<dd className="text-gray-200">{(profile.export_dependency_pct * 100).toFixed(0)}%</dd>
</div>
<div>
<dt className="text-gray-500">Source</dt>
<dd><StatusBadge status={profile.source} /></dd>
</div>
<div>
<dt className="text-gray-500">Confidence</dt>
<dd><ConfidenceBar value={profile.confidence} /></dd>
</div>
<div>
<dt className="text-gray-500">Revenue Mix</dt>
<dd className="flex flex-wrap gap-1 mt-1">
{Object.entries(profile.geographic_revenue_mix).map(([region, pct]) => (
<span key={region} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">
{region}: {(pct * 100).toFixed(0)}%
</span>
))}
</dd>
</div>
<div>
<dt className="text-gray-500">Supply Chain Regions</dt>
<dd className="flex flex-wrap gap-1 mt-1">
{profile.supply_chain_regions.map((r) => (
<span key={r} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">{r}</span>
))}
</dd>
</div>
<div>
<dt className="text-gray-500">Key Commodities</dt>
<dd className="flex flex-wrap gap-1 mt-1">
{profile.key_input_commodities.length > 0
? profile.key_input_commodities.map((c) => (
<span key={c} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">{c}</span>
))
: <span className="text-gray-500"></span>}
</dd>
</div>
</dl>
)}
</Card>
{/* Active Macro Impacts */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Active Macro Impacts ({impacts.length})</h2>
{impacts.length === 0 ? (
<p className="text-sm text-gray-500">No active macro impacts</p>
) : (
<div className="space-y-2">
{impacts.map((impact) => (
<div
key={impact.id}
className="flex items-center justify-between rounded-lg border border-surface-700 bg-surface-950 p-3 cursor-pointer hover:border-brand-500/50"
onClick={() => onEventClick(impact.event_id)}
>
<div className="flex items-center gap-3">
<StatusBadge status={impact.impact_direction} />
<ConfidenceBar value={impact.macro_impact_score} />
<div className="flex flex-wrap gap-1">
{impact.contributing_factors.map((f, i) => (
<span key={i} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">{f}</span>
))}
</div>
</div>
<span className="text-xs text-gray-500">{new Date(impact.computed_at).toLocaleDateString()}</span>
</div>
))}
</div>
)}
</Card>
</div>
);
}
function CompetitorsPanel({ competitors, onInfer, isInferring }: {
competitors: CompetitorRelationship[];
onInfer: () => void;
isInferring: boolean;
}) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-gray-400">Active Competitors ({competitors.length})</h2>
<button
onClick={onInfer}
disabled={isInferring}
className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{isInferring ? 'Inferring…' : 'Infer Competitors'}
</button>
</div>
{competitors.length === 0 ? (
<p className="text-sm text-gray-500">No competitor relationships defined</p>
) : (
<div className="space-y-2">
{competitors.map((c) => (
<div key={c.id} className="flex items-center justify-between rounded-lg border border-surface-700 bg-surface-950 p-3">
<div className="flex items-center gap-3">
<span className="font-mono font-semibold text-brand-300">{c.ticker ?? c.legal_name ?? 'Unknown'}</span>
<StatusBadge status={c.relationship_type} />
<ConfidenceBar value={c.strength} />
</div>
<div className="flex items-center gap-2">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${c.source === 'manual' ? 'bg-blue-900/40 border border-blue-700/50 text-blue-400' : 'bg-emerald-900/40 border border-emerald-700/50 text-emerald-400'}`}>
{c.source.toUpperCase()}
</span>
{c.bidirectional && (
<span className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400"> bidirectional</span>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
function HistoricalPatternsPanel({ patterns }: { patterns: HistoricalPattern[] }) {
return (
<div className="space-y-4">
<h2 className="text-sm font-medium text-gray-400">Historical Patterns ({patterns.length})</h2>
{patterns.length === 0 ? (
<p className="text-sm text-gray-500">No historical patterns found</p>
) : (
<div className="space-y-2">
{patterns.map((p, i) => (
<Card key={i}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-200">{p.catalyst_type}</span>
<span className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-400">{p.time_horizon}</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${p.tier === 'major_corporate_decision' ? 'bg-orange-900/40 border border-orange-700/50 text-orange-400' : 'bg-surface-800 text-gray-400'}`}>
{p.tier === 'major_corporate_decision' ? 'MAJOR' : 'ROUTINE'}
</span>
{p.insufficient_data && (
<span className="rounded bg-yellow-900/40 border border-yellow-700/50 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">LOW DATA</span>
)}
</div>
<ConfidenceBar value={p.pattern_confidence} />
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs sm:grid-cols-4">
<div>
<span className="text-gray-500">Samples: </span>
<span className="text-gray-300">{p.sample_count}</span>
</div>
<div>
<span className="text-gray-500">Bullish: </span>
<span className="text-green-400">{(p.bullish_pct * 100).toFixed(0)}%</span>
</div>
<div>
<span className="text-gray-500">Bearish: </span>
<span className="text-red-400">{(p.bearish_pct * 100).toFixed(0)}%</span>
</div>
<div>
<span className="text-gray-500">Avg Strength: </span>
<span className="text-gray-300">{(p.avg_strength * 100).toFixed(0)}%</span>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}
function CompetitiveSignalsPanel({ signals }: { signals: CompetitiveSignal[] }) {
const [expandedId, setExpandedId] = useState<string | null>(null);
return (
<div className="space-y-4">
<h2 className="text-sm font-medium text-gray-400">Incoming Competitive Signals ({signals.length})</h2>
{signals.length === 0 ? (
<p className="text-sm text-gray-500">No competitive signals received</p>
) : (
<div className="space-y-2">
{signals.map((s) => (
<div key={s.id}>
<div
className="flex items-center justify-between rounded-lg border border-cyan-700/30 bg-cyan-900/10 p-3 cursor-pointer hover:border-cyan-500/50"
onClick={() => setExpandedId(expandedId === s.id ? null : s.id)}
>
<div className="flex items-center gap-3">
<span className="rounded bg-cyan-900/40 border border-cyan-700/50 px-1.5 py-0.5 text-[10px] font-medium text-cyan-400">COMPETITIVE</span>
<span className="font-mono text-sm text-brand-300">{s.source_ticker}</span>
<span className="text-xs text-gray-400"></span>
<StatusBadge status={s.catalyst_type} />
<StatusBadge status={s.signal_direction} />
</div>
<div className="flex items-center gap-3">
<ConfidenceBar value={s.signal_strength} />
<span className="text-xs text-gray-500">{new Date(s.computed_at).toLocaleDateString()}</span>
</div>
</div>
{expandedId === s.id && (
<Card className="mt-1 ml-4">
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs sm:grid-cols-3">
<div>
<dt className="text-gray-500">Source Ticker</dt>
<dd className="font-mono text-gray-200">{s.source_ticker}</dd>
</div>
<div>
<dt className="text-gray-500">Target Ticker</dt>
<dd className="font-mono text-gray-200">{s.target_ticker}</dd>
</div>
<div>
<dt className="text-gray-500">Catalyst Type</dt>
<dd className="text-gray-200">{s.catalyst_type}</dd>
</div>
<div>
<dt className="text-gray-500">Pattern Confidence</dt>
<dd><ConfidenceBar value={s.pattern_confidence} /></dd>
</div>
<div>
<dt className="text-gray-500">Signal Strength</dt>
<dd><ConfidenceBar value={s.signal_strength} /></dd>
</div>
<div>
<dt className="text-gray-500">Relationship Strength</dt>
<dd><ConfidenceBar value={s.relationship_strength} /></dd>
</div>
<div>
<dt className="text-gray-500">Source Document</dt>
<dd className="font-mono text-gray-400 text-[10px]">{s.source_document_id}</dd>
</div>
<div>
<dt className="text-gray-500">Computed At</dt>
<dd className="text-gray-200">{new Date(s.computed_at).toLocaleString()}</dd>
</div>
</dl>
</Card>
)}
</div>
))}
</div>
)}
</div>
);
}
function DecisionsPanel({ decisions }: { decisions: CorporateDecision[] }) {
return (
<div className="space-y-4">
<h2 className="text-sm font-medium text-gray-400">Corporate Decision Timeline ({decisions.length})</h2>
{decisions.length === 0 ? (
<p className="text-sm text-gray-500">No major corporate decisions found</p>
) : (
<div className="space-y-2">
{decisions.map((d, i) => (
<div key={i} className="flex items-start gap-4 rounded-lg border border-surface-700 bg-surface-950 p-3">
<div className="flex flex-col items-center">
<span className="text-xs font-medium text-gray-400">{new Date(d.date).toLocaleDateString()}</span>
<div className="mt-1 h-full w-px bg-surface-700" />
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="rounded bg-orange-900/40 border border-orange-700/50 px-1.5 py-0.5 text-[10px] font-medium text-orange-400">
{d.catalyst_type}
</span>
<StatusBadge status={d.trend_direction} />
<ConfidenceBar value={d.trend_strength} />
</div>
<p className="text-sm text-gray-300">{d.summary}</p>
<div className="flex gap-4 text-xs text-gray-500">
<span>Samples: {d.sample_count}</span>
<span>Confidence: {(d.pattern_confidence * 100).toFixed(0)}%</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Trend History Chart
// ---------------------------------------------------------------------------
const DIRECTION_VALUE: Record<string, number> = {
bullish: 1,
bearish: -1,
mixed: 0,
neutral: 0,
};
const WINDOW_ORDER = ['intraday', '1d', '7d', '30d', '90d'];
interface ChartPoint {
time: string;
timestamp: number;
strength: number;
confidence: number;
contradiction: number;
direction: number;
directionLabel: string;
window: string;
price?: number;
}
function TrendTooltip({ active, payload, label }: Record<string, unknown>) {
if (!active) return null;
const items = payload as Array<{ name: string; value: number; color: string; dataKey: string }> | undefined;
if (!items?.length) return null;
return (
<div className="rounded-lg border border-surface-700 bg-surface-900 px-3 py-2 text-xs shadow-lg">
<div className="mb-1 text-gray-400">{String(label ?? '')}</div>
{items.map((item, i) => (
<div key={i} className="flex justify-between gap-4" style={{ color: item.color }}>
<span>{item.name}:</span>
<span className="font-semibold">
{item.dataKey === 'price' ? `$${item.value.toFixed(2)}` : `${item.value}%`}
</span>
</div>
))}
</div>
);
}
function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices }: { trends: TrendSummary[]; latestTrends: TrendSummary[]; ticker: string; marketPrices: MarketPrice[] }) {
const [selectedWindow, setSelectedWindow] = useState('7d');
// Determine the time range for the selected window to filter data
const windowHours: Record<string, number> = {
intraday: 12,
'1d': 24,
'7d': 7 * 24,
'30d': 30 * 24,
'90d': 90 * 24,
};
const hoursBack = windowHours[selectedWindow] ?? 7 * 24;
const cutoffTs = Date.now() - hoursBack * 3600_000;
// Use history data for charts — filter to selected window and time range
const filtered = (trends ?? [])
.filter((t) => t.entity_id === ticker && t.window === selectedWindow && new Date(t.generated_at).getTime() >= cutoffTs)
.sort((a, b) => new Date(a.generated_at).getTime() - new Date(b.generated_at).getTime());
// Build a price lookup — match by closest timestamp to each trend point
const sortedPrices = [...(marketPrices ?? [])]
.filter((p) => p.bar_timestamp != null && p.close != null)
.sort((a, b) => a.bar_timestamp - b.bar_timestamp);
// Filter prices to the selected window's time range
const windowPrices = sortedPrices.filter((p) => p.bar_timestamp >= cutoffTs);
function findClosestPrice(ts: number): number | undefined {
if (windowPrices.length === 0) return undefined;
let best = windowPrices[0];
let bestDiff = Math.abs(ts - best.bar_timestamp);
for (const p of windowPrices) {
const diff = Math.abs(ts - p.bar_timestamp);
if (diff < bestDiff) {
best = p;
bestDiff = diff;
}
}
// Only match if within 2 hours (for intraday) or 36 hours (for daily)
const maxGap = selectedWindow === 'intraday' ? 2 * 3600_000 : 36 * 3600_000;
return bestDiff <= maxGap ? best.close : undefined;
}
const chartData: ChartPoint[] = filtered.map((t) => {
const trendTs = new Date(t.generated_at).getTime();
const price = findClosestPrice(trendTs);
return {
time: new Date(t.generated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
timestamp: trendTs,
strength: +(t.trend_strength * 100).toFixed(1),
confidence: +(t.confidence * 100).toFixed(1),
contradiction: +(t.contradiction_score * 100).toFixed(1),
direction: DIRECTION_VALUE[t.trend_direction] ?? 0,
directionLabel: t.trend_direction,
window: t.window,
price,
};
});
const hasPrice = chartData.some((pt) => pt.price != null);
// Available windows from the data (check both history and latest)
const allTrends = [...(trends ?? []), ...(latestTrends ?? [])];
const availableWindows = [...new Set(allTrends.filter((t) => t.entity_id === ticker).map((t) => t.window))];
availableWindows.sort((a, b) => WINDOW_ORDER.indexOf(a) - WINDOW_ORDER.indexOf(b));
// Use latest trends for the summary card
const latestForWindow = (latestTrends ?? [])
.filter((t) => t.entity_id === ticker && t.window === selectedWindow)
.sort((a, b) => new Date(b.generated_at).getTime() - new Date(a.generated_at).getTime());
const latest = latestForWindow[0] ?? (filtered.length > 0 ? filtered[filtered.length - 1] : null);
return (
<div className="space-y-4">
{/* Window selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-gray-400">Window:</span>
{(availableWindows.length > 0 ? availableWindows : WINDOW_ORDER).map((w) => (
<button
key={w}
onClick={() => setSelectedWindow(w)}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
selectedWindow === w
? 'bg-brand-600 text-white'
: 'border border-surface-700 bg-surface-900 text-gray-400 hover:bg-surface-800'
}`}
>
{w}
</button>
))}
</div>
{chartData.length === 0 ? (
<Card>
<p className="text-sm text-gray-500">No trend history for {ticker} / {selectedWindow}</p>
</Card>
) : (
<>
{/* Trend Strength & Confidence Chart */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">
Trend Strength & Confidence {ticker} / {selectedWindow}
</h2>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis
dataKey="time"
tick={{ fill: '#94a3b8', fontSize: 11 }}
tickLine={{ stroke: '#475569' }}
/>
<YAxis
yAxisId="left"
domain={[0, 100]}
tick={{ fill: '#94a3b8', fontSize: 11 }}
tickLine={{ stroke: '#475569' }}
tickFormatter={(v) => `${v}%`}
/>
{hasPrice && (
<YAxis
yAxisId="right"
orientation="right"
tick={{ fill: '#e879f9', fontSize: 11 }}
tickLine={{ stroke: '#475569' }}
tickFormatter={(v) => `$${v}`}
domain={['dataMin - 2', 'dataMax + 2']}
/>
)}
<Tooltip content={TrendTooltip} />
<Legend wrapperStyle={{ color: '#94a3b8', fontSize: 12 }} />
<Line
yAxisId="left"
type="monotone"
dataKey="strength"
name="Trend Strength"
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 3, fill: '#3b82f6' }}
activeDot={{ r: 5 }}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="confidence"
name="Confidence"
stroke="#10b981"
strokeWidth={2}
dot={{ r: 3, fill: '#10b981' }}
activeDot={{ r: 5 }}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="contradiction"
name="Contradiction"
stroke="#f59e0b"
strokeWidth={1.5}
strokeDasharray="5 5"
dot={{ r: 2, fill: '#f59e0b' }}
/>
{hasPrice && (
<Line
yAxisId="right"
type="monotone"
dataKey="price"
name="Price"
stroke="#e879f9"
strokeWidth={2}
dot={{ r: 3, fill: '#e879f9' }}
activeDot={{ r: 5 }}
connectNulls
/>
)}
</LineChart>
</ResponsiveContainer>
</Card>
{/* Direction Timeline */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">
Direction Timeline {ticker} / {selectedWindow}
</h2>
<div className="flex items-end gap-1 overflow-x-auto pb-2" style={{ minHeight: 60 }}>
{chartData.map((pt, i) => {
const color =
pt.directionLabel === 'bullish' ? 'bg-green-500' :
pt.directionLabel === 'bearish' ? 'bg-red-500' :
pt.directionLabel === 'mixed' ? 'bg-yellow-500' :
'bg-gray-600';
const height = Math.max(8, pt.strength * 0.5);
return (
<div key={i} className="flex flex-col items-center gap-1" title={`${pt.time}: ${pt.directionLabel} (${pt.strength}%)`}>
<div
className={`w-3 rounded-sm ${color}`}
style={{ height: `${height}px` }}
/>
{i % Math.max(1, Math.floor(chartData.length / 8)) === 0 && (
<span className="text-[9px] text-gray-500 -rotate-45 origin-top-left whitespace-nowrap">{pt.time}</span>
)}
</div>
);
})}
</div>
<div className="mt-2 flex gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-sm bg-green-500" /> Bullish</span>
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-sm bg-red-500" /> Bearish</span>
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-sm bg-yellow-500" /> Mixed</span>
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-sm bg-gray-600" /> Neutral</span>
</div>
</Card>
{/* Latest trend summary */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Latest Trend</h2>
{latest && (() => {
return (
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-4">
<div>
<dt className="text-gray-500">Direction</dt>
<dd><StatusBadge status={latest.trend_direction} /></dd>
</div>
<div>
<dt className="text-gray-500">Strength</dt>
<dd><ConfidenceBar value={latest.trend_strength} /></dd>
</div>
<div>
<dt className="text-gray-500">Confidence</dt>
<dd><ConfidenceBar value={latest.confidence} /></dd>
</div>
<div>
<dt className="text-gray-500">Contradiction</dt>
<dd className={`font-mono ${latest.contradiction_score > 0.5 ? 'text-yellow-400' : 'text-gray-300'}`}>
{(latest.contradiction_score * 100).toFixed(0)}%
</dd>
</div>
<div>
<dt className="text-gray-500">Catalysts</dt>
<dd className="flex flex-wrap gap-1">
{(latest.dominant_catalysts ?? []).map((c, i) => (
<span key={i} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-300">{c}</span>
))}
</dd>
</div>
<div>
<dt className="text-gray-500">Generated</dt>
<dd className="text-gray-300">{new Date(latest.generated_at).toLocaleString()}</dd>
</div>
</dl>
);
})()}
</Card>
</>
)}
</div>
);
}