fix: blank company charts + competitor GUIDs instead of tickers
Trend charts blank: - trend_windows uses upsert (1 row per ticker/window), so charts had at most 1 data point. Added trend_history table (migration 024) that appends every snapshot. New /api/trends/history endpoint serves the time series. Frontend now uses useTrendHistory for charts and useTrends for the latest summary card. Competitor GUIDs: - list_competitors query returned raw company_b_id UUIDs without joining companies table. Added LEFT JOIN with CASE to resolve the other company's ticker and legal_name. Updated Pydantic model to include enriched fields. Frontend fallback changed from truncated UUID to ticker/legal_name/Unknown.
This commit is contained in:
@@ -236,6 +236,15 @@ export function useTrends(params?: { ticker?: string; window?: string; limit?: n
|
|||||||
return useGet<TrendSummary[]>(['trends', params], 'query', path);
|
return useGet<TrendSummary[]>(['trends', params], 'query', path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useTrendHistory(params?: { ticker?: string; window?: string; limit?: number }) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.ticker) qs.set('ticker', params.ticker);
|
||||||
|
if (params?.window) qs.set('window', params.window);
|
||||||
|
if (params?.limit) qs.set('limit', String(params.limit ?? 200));
|
||||||
|
const path = `/api/trends/history${qs.toString() ? '?' + qs : ''}`;
|
||||||
|
return useGet<TrendSummary[]>(['trend-history', params], 'query', path);
|
||||||
|
}
|
||||||
|
|
||||||
export function useTrend(id: string | undefined) {
|
export function useTrend(id: string | undefined) {
|
||||||
return useGet<TrendSummary>(['trend', id], 'query', `/api/trends/${id}`, !!id);
|
return useGet<TrendSummary>(['trend', id], 'query', `/api/trends/${id}`, !!id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
useCompetitiveSignals,
|
useCompetitiveSignals,
|
||||||
useCorporateDecisions,
|
useCorporateDecisions,
|
||||||
useTrends,
|
useTrends,
|
||||||
|
useTrendHistory,
|
||||||
} from '../api/hooks';
|
} from '../api/hooks';
|
||||||
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
|
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
|
||||||
import { DataTable, type Column } from '../components/DataTable';
|
import { DataTable, type Column } from '../components/DataTable';
|
||||||
@@ -40,6 +41,7 @@ export function CompanyDetailPage() {
|
|||||||
const { data: signals } = useCompetitiveSignals(company?.ticker);
|
const { data: signals } = useCompetitiveSignals(company?.ticker);
|
||||||
const { data: decisions } = useCorporateDecisions(company?.ticker);
|
const { data: decisions } = useCorporateDecisions(company?.ticker);
|
||||||
const { data: trends } = useTrends({ ticker: company?.ticker, limit: 200 });
|
const { data: trends } = useTrends({ ticker: company?.ticker, limit: 200 });
|
||||||
|
const { data: trendHistory } = useTrendHistory({ ticker: company?.ticker, limit: 500 });
|
||||||
const [tab, setTab] = useState<'trends' | 'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('trends');
|
const [tab, setTab] = useState<'trends' | 'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('trends');
|
||||||
|
|
||||||
if (isLoading || !company) return <LoadingSpinner />;
|
if (isLoading || !company) return <LoadingSpinner />;
|
||||||
@@ -78,7 +80,7 @@ export function CompanyDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'trends' && (
|
{tab === 'trends' && (
|
||||||
<TrendHistoryChart trends={trends ?? []} ticker={company.ticker} />
|
<TrendHistoryChart trends={trendHistory ?? []} latestTrends={trends ?? []} ticker={company.ticker} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'sources' && (
|
{tab === 'sources' && (
|
||||||
@@ -360,7 +362,7 @@ function CompetitorsPanel({ competitors, onInfer, isInferring }: {
|
|||||||
{competitors.map((c) => (
|
{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 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">
|
<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>
|
<span className="font-mono font-semibold text-brand-300">{c.ticker ?? c.legal_name ?? 'Unknown'}</span>
|
||||||
<StatusBadge status={c.relationship_type} />
|
<StatusBadge status={c.relationship_type} />
|
||||||
<ConfidenceBar value={c.strength} />
|
<ConfidenceBar value={c.strength} />
|
||||||
</div>
|
</div>
|
||||||
@@ -563,10 +565,10 @@ interface ChartPoint {
|
|||||||
window: string;
|
window: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrendHistoryChart({ trends, ticker }: { trends: TrendSummary[]; ticker: string }) {
|
function TrendHistoryChart({ trends, latestTrends, ticker }: { trends: TrendSummary[]; latestTrends: TrendSummary[]; ticker: string }) {
|
||||||
const [selectedWindow, setSelectedWindow] = useState('7d');
|
const [selectedWindow, setSelectedWindow] = useState('7d');
|
||||||
|
|
||||||
// Filter trends for this ticker and selected window, sorted by time
|
// Use history data for charts
|
||||||
const filtered = (trends ?? [])
|
const filtered = (trends ?? [])
|
||||||
.filter((t) => t.entity_id === ticker && t.window === selectedWindow)
|
.filter((t) => t.entity_id === ticker && t.window === selectedWindow)
|
||||||
.sort((a, b) => new Date(a.generated_at).getTime() - new Date(b.generated_at).getTime());
|
.sort((a, b) => new Date(a.generated_at).getTime() - new Date(b.generated_at).getTime());
|
||||||
@@ -582,10 +584,17 @@ function TrendHistoryChart({ trends, ticker }: { trends: TrendSummary[]; ticker:
|
|||||||
window: t.window,
|
window: t.window,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Available windows from the data
|
// Available windows from the data (check both history and latest)
|
||||||
const availableWindows = [...new Set((trends ?? []).filter((t) => t.entity_id === ticker).map((t) => t.window))];
|
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));
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Window selector */}
|
{/* Window selector */}
|
||||||
@@ -705,8 +714,7 @@ function TrendHistoryChart({ trends, ticker }: { trends: TrendSummary[]; ticker:
|
|||||||
{/* Latest trend summary */}
|
{/* Latest trend summary */}
|
||||||
<Card>
|
<Card>
|
||||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Latest Trend</h2>
|
<h2 className="mb-3 text-sm font-medium text-gray-400">Latest Trend</h2>
|
||||||
{filtered.length > 0 && (() => {
|
{latest && (() => {
|
||||||
const latest = filtered[filtered.length - 1];
|
|
||||||
return (
|
return (
|
||||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-4">
|
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Trend history table for time-series charting.
|
||||||
|
-- trend_windows stores the latest snapshot per (entity, window) via upsert.
|
||||||
|
-- trend_history stores every snapshot so the frontend can plot trend evolution.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trend_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
entity_type VARCHAR(50) NOT NULL DEFAULT 'company',
|
||||||
|
entity_id VARCHAR(100) NOT NULL,
|
||||||
|
"window" VARCHAR(20) NOT NULL,
|
||||||
|
trend_direction VARCHAR(20) NOT NULL DEFAULT 'neutral',
|
||||||
|
trend_strength FLOAT DEFAULT 0.5,
|
||||||
|
confidence FLOAT DEFAULT 0.5,
|
||||||
|
contradiction_score FLOAT DEFAULT 0.0,
|
||||||
|
dominant_catalysts JSONB DEFAULT '[]',
|
||||||
|
material_risks JSONB DEFAULT '[]',
|
||||||
|
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trend_history_lookup
|
||||||
|
ON trend_history (entity_id, "window", generated_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trend_history_generated
|
||||||
|
ON trend_history (generated_at DESC);
|
||||||
|
|
||||||
|
-- Seed history from existing trend_windows so charts aren't empty
|
||||||
|
-- on first deploy. This gives at least one data point per ticker/window.
|
||||||
|
INSERT INTO trend_history (
|
||||||
|
entity_type, entity_id, "window", trend_direction,
|
||||||
|
trend_strength, confidence, contradiction_score,
|
||||||
|
dominant_catalysts, material_risks, generated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
entity_type, entity_id, "window", trend_direction,
|
||||||
|
trend_strength, confidence, contradiction_score,
|
||||||
|
dominant_catalysts, material_risks, generated_at
|
||||||
|
FROM trend_windows
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -742,11 +742,23 @@ RETURNING id
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_INSERT_TREND_HISTORY = """
|
||||||
|
INSERT INTO trend_history (
|
||||||
|
entity_type, entity_id, "window", trend_direction,
|
||||||
|
trend_strength, confidence, contradiction_score,
|
||||||
|
dominant_catalysts, material_risks, generated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def persist_trend_summary(
|
async def persist_trend_summary(
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
summary: TrendSummary,
|
summary: TrendSummary,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Insert a trend summary row and return its UUID."""
|
"""Insert a trend summary row and return its UUID.
|
||||||
|
|
||||||
|
Also appends a snapshot to trend_history for time-series charting.
|
||||||
|
"""
|
||||||
row = await pool.fetchrow(
|
row = await pool.fetchrow(
|
||||||
_UPSERT_TREND,
|
_UPSERT_TREND,
|
||||||
summary.entity_type,
|
summary.entity_type,
|
||||||
@@ -764,7 +776,28 @@ async def persist_trend_summary(
|
|||||||
json.dumps(summary.market_context.model_dump() if summary.market_context else {}, default=str),
|
json.dumps(summary.market_context.model_dump() if summary.market_context else {}, default=str),
|
||||||
summary.generated_at,
|
summary.generated_at,
|
||||||
)
|
)
|
||||||
return str(row["id"])
|
trend_id = str(row["id"])
|
||||||
|
|
||||||
|
# Append to trend_history for time-series charts
|
||||||
|
try:
|
||||||
|
await pool.execute(
|
||||||
|
_INSERT_TREND_HISTORY,
|
||||||
|
summary.entity_type,
|
||||||
|
summary.entity_id,
|
||||||
|
summary.window.value,
|
||||||
|
summary.trend_direction.value,
|
||||||
|
summary.trend_strength,
|
||||||
|
summary.confidence,
|
||||||
|
summary.contradiction_score,
|
||||||
|
json.dumps(summary.dominant_catalysts),
|
||||||
|
json.dumps(summary.material_risks),
|
||||||
|
summary.generated_at,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Don't fail the main upsert if history insert fails (table may not exist yet)
|
||||||
|
logger.debug("Could not insert trend history for %s/%s", summary.entity_id, summary.window.value, exc_info=True)
|
||||||
|
|
||||||
|
return trend_id
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -415,6 +415,56 @@ async def list_trends(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/trends/history")
|
||||||
|
async def list_trend_history(
|
||||||
|
ticker: Optional[str] = None,
|
||||||
|
window: Optional[str] = None,
|
||||||
|
limit: int = Query(default=200, le=1000),
|
||||||
|
):
|
||||||
|
"""Return historical trend snapshots for charting.
|
||||||
|
|
||||||
|
Unlike /api/trends which returns the latest snapshot per entity/window,
|
||||||
|
this endpoint returns the time series from the trend_history table.
|
||||||
|
"""
|
||||||
|
conditions: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if ticker:
|
||||||
|
conditions.append(f"entity_id = ${idx}")
|
||||||
|
params.append(ticker.upper())
|
||||||
|
idx += 1
|
||||||
|
if window:
|
||||||
|
conditions.append(f"\"window\" = ${idx}")
|
||||||
|
params.append(window)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
f"""SELECT id, entity_type, entity_id, "window", trend_direction,
|
||||||
|
trend_strength, confidence, contradiction_score,
|
||||||
|
dominant_catalysts, material_risks, generated_at
|
||||||
|
FROM trend_history
|
||||||
|
{where}
|
||||||
|
ORDER BY generated_at ASC
|
||||||
|
LIMIT ${idx}""",
|
||||||
|
*params, limit,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Table may not exist yet (pre-migration 024)
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for r in rows:
|
||||||
|
d = _row_to_dict(r)
|
||||||
|
d["dominant_catalysts"] = _parse_jsonb(d.get("dominant_catalysts"))
|
||||||
|
d["material_risks"] = _parse_jsonb(d.get("material_risks"))
|
||||||
|
results.append(d)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/trends/{trend_id}")
|
@app.get("/api/trends/{trend_id}")
|
||||||
async def get_trend(trend_id: str):
|
async def get_trend(trend_id: str):
|
||||||
"""Get a single trend summary by ID."""
|
"""Get a single trend summary by ID."""
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ class CompetitorRelationship(BaseModel):
|
|||||||
active: bool
|
active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
# Enriched from companies table
|
||||||
|
ticker: str | None = None
|
||||||
|
legal_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _row_dict(row: asyncpg.Record) -> dict[str, Any]:
|
def _row_dict(row: asyncpg.Record) -> dict[str, Any]:
|
||||||
@@ -128,18 +131,31 @@ async def create_competitor(company_id: str, body: CompetitorRelationshipCreate,
|
|||||||
|
|
||||||
@router.get("/companies/{company_id}/competitors", response_model=List[CompetitorRelationship])
|
@router.get("/companies/{company_id}/competitors", response_model=List[CompetitorRelationship])
|
||||||
async def list_competitors(company_id: str, request: Request):
|
async def list_competitors(company_id: str, request: Request):
|
||||||
"""List active competitor relationships for a company, ordered by strength descending."""
|
"""List active competitor relationships for a company, ordered by strength descending.
|
||||||
|
|
||||||
|
Enriches each relationship with the ticker and legal_name of the
|
||||||
|
*other* company (the one that isn't company_id).
|
||||||
|
"""
|
||||||
pool = _get_pool(request)
|
pool = _get_pool(request)
|
||||||
|
|
||||||
if not await _company_exists(pool, company_id):
|
if not await _company_exists(pool, company_id):
|
||||||
raise HTTPException(404, "Company not found")
|
raise HTTPException(404, "Company not found")
|
||||||
|
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
"""SELECT id, company_a_id, company_b_id, relationship_type, strength,
|
"""SELECT cr.id, cr.company_a_id, cr.company_b_id,
|
||||||
bidirectional, source, active, created_at, updated_at
|
cr.relationship_type, cr.strength,
|
||||||
FROM competitor_relationships
|
cr.bidirectional, cr.source, cr.active,
|
||||||
WHERE (company_a_id = $1 OR company_b_id = $1) AND active = TRUE
|
cr.created_at, cr.updated_at,
|
||||||
ORDER BY strength DESC""",
|
c.ticker, c.legal_name
|
||||||
|
FROM competitor_relationships cr
|
||||||
|
LEFT JOIN companies c
|
||||||
|
ON c.id = CASE
|
||||||
|
WHEN cr.company_a_id = $1 THEN cr.company_b_id
|
||||||
|
ELSE cr.company_a_id
|
||||||
|
END
|
||||||
|
WHERE (cr.company_a_id = $1 OR cr.company_b_id = $1)
|
||||||
|
AND cr.active = TRUE
|
||||||
|
ORDER BY cr.strength DESC""",
|
||||||
company_id,
|
company_id,
|
||||||
)
|
)
|
||||||
return [_row_dict(r) for r in rows]
|
return [_row_dict(r) for r in rows]
|
||||||
|
|||||||
Reference in New Issue
Block a user