feat: add trend history chart with strength/confidence/contradiction time series
This commit is contained in:
@@ -11,10 +11,15 @@ import {
|
||||
useHistoricalPatterns,
|
||||
useCompetitiveSignals,
|
||||
useCorporateDecisions,
|
||||
useTrends,
|
||||
} 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 } from '../api/hooks';
|
||||
import type { Source, Alias, MacroImpactRecord, CompetitorRelationship, HistoricalPattern, CompetitiveSignal, CorporateDecision, TrendSummary } from '../api/hooks';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
CartesianGrid, Legend,
|
||||
} from 'recharts';
|
||||
|
||||
const sourceCols: Column<Source>[] = [
|
||||
{ key: 'source_type', header: 'Type' },
|
||||
@@ -34,11 +39,12 @@ export function CompanyDetailPage() {
|
||||
const { data: patterns } = useHistoricalPatterns(company?.ticker);
|
||||
const { data: signals } = useCompetitiveSignals(company?.ticker);
|
||||
const { data: decisions } = useCorporateDecisions(company?.ticker);
|
||||
const [tab, setTab] = useState<'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('sources');
|
||||
const { data: trends } = useTrends({ ticker: company?.ticker, limit: 200 });
|
||||
const [tab, setTab] = useState<'trends' | 'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('trends');
|
||||
|
||||
if (isLoading || !company) return <LoadingSpinner />;
|
||||
|
||||
const tabs = ['sources', 'aliases', 'macro', 'competitors', 'patterns', 'signals', 'decisions'] as const;
|
||||
const tabs = ['trends', 'sources', 'aliases', 'macro', 'competitors', 'patterns', 'signals', 'decisions'] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -71,6 +77,10 @@ export function CompanyDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'trends' && (
|
||||
<TrendHistoryChart trends={trends ?? []} ticker={company.ticker} />
|
||||
)}
|
||||
|
||||
{tab === 'sources' && (
|
||||
<div className="space-y-4">
|
||||
<DataTable<Source> data={sources ?? []} columns={sourceCols} keyField="id" />
|
||||
@@ -528,3 +538,213 @@ function DecisionsPanel({ decisions }: { decisions: CorporateDecision[] }) {
|
||||
</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;
|
||||
}
|
||||
|
||||
function TrendHistoryChart({ trends, ticker }: { trends: TrendSummary[]; ticker: string }) {
|
||||
const [selectedWindow, setSelectedWindow] = useState('7d');
|
||||
|
||||
// Filter trends for this ticker and selected window, sorted by time
|
||||
const filtered = (trends ?? [])
|
||||
.filter((t) => t.entity_id === ticker && t.window === selectedWindow)
|
||||
.sort((a, b) => new Date(a.generated_at).getTime() - new Date(b.generated_at).getTime());
|
||||
|
||||
const chartData: ChartPoint[] = filtered.map((t) => ({
|
||||
time: new Date(t.generated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
||||
timestamp: new Date(t.generated_at).getTime(),
|
||||
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,
|
||||
}));
|
||||
|
||||
// Available windows from the data
|
||||
const availableWindows = [...new Set((trends ?? []).filter((t) => t.entity_id === ticker).map((t) => t.window))];
|
||||
availableWindows.sort((a, b) => WINDOW_ORDER.indexOf(a) - WINDOW_ORDER.indexOf(b));
|
||||
|
||||
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
|
||||
domain={[0, 100]}
|
||||
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
||||
tickLine={{ stroke: '#475569' }}
|
||||
tickFormatter={(v: number) => `${v}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
labelStyle={{ color: '#94a3b8' }}
|
||||
formatter={(value: number, name: string) => [`${value}%`, name]}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: '#94a3b8', fontSize: 12 }} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="strength"
|
||||
name="Trend Strength"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: '#3b82f6' }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="confidence"
|
||||
name="Confidence"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: '#10b981' }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="contradiction"
|
||||
name="Contradiction"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="5 5"
|
||||
dot={{ r: 2, fill: '#f59e0b' }}
|
||||
/>
|
||||
</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>
|
||||
{filtered.length > 0 && (() => {
|
||||
const latest = filtered[filtered.length - 1];
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user