feat: competitive intelligence & historical pattern matching layer

This commit is contained in:
Celes Renata
2026-04-14 19:42:48 +00:00
parent b478022ba3
commit f7a11d14ea
203 changed files with 20155 additions and 97 deletions
+351 -9
View File
@@ -1,10 +1,20 @@
import { useParams } from '@tanstack/react-router';
import { useParams, useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useCompany, useCompanySources, useCreateAlias, useCreateSource } from '../api/hooks';
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
import {
useCompany,
useCompanySources,
useCreateAlias,
useCreateSource,
useCompanyMacroImpacts,
useCompanyCompetitors,
useInferCompetitors,
useHistoricalPatterns,
useCompetitiveSignals,
useCorporateDecisions,
} from '../api/hooks';
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
import { DataTable, type Column } from '../components/DataTable';
import type { Source } from '../api/hooks';
import type { Alias } from '../api/hooks';
import type { Source, Alias, MacroImpactRecord, CompetitorRelationship, HistoricalPattern, CompetitiveSignal, CorporateDecision } from '../api/hooks';
const sourceCols: Column<Source>[] = [
{ key: 'source_type', header: 'Type' },
@@ -15,12 +25,21 @@ const sourceCols: Column<Source>[] = [
export function CompanyDetailPage() {
const { id } = useParams({ from: '/companies/$id' });
const navigate = useNavigate();
const { data: company, isLoading } = useCompany(id);
const { data: sources } = useCompanySources(id);
const [tab, setTab] = useState<'aliases' | 'sources'>('sources');
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 [tab, setTab] = useState<'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('sources');
if (isLoading || !company) return <LoadingSpinner />;
const tabs = ['sources', 'aliases', 'macro', 'competitors', 'patterns', 'signals', 'decisions'] as const;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
@@ -40,12 +59,12 @@ export function CompanyDetailPage() {
</Card>
{/* Tabs */}
<div className="flex gap-4 border-b border-surface-700">
{(['sources', 'aliases'] as const).map((t) => (
<div className="flex gap-4 border-b border-surface-700 overflow-x-auto">
{tabs.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`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'}`}
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>
@@ -65,6 +84,28 @@ export function CompanyDetailPage() {
<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>
);
</div>
);
}
@@ -188,3 +229,304 @@ function AddSourceForm({ companyId }: { companyId: string }) {
</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.company_b_id.slice(0, 8)}</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>
);
}