24c753f6e6
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-3 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
The TickerFilter triggered a query on every keystroke, causing re-renders that stole focus from the input. Now uses a local input state with a 300ms debounce before updating the query, keeping focus on the text box while typing.
177 lines
7.0 KiB
TypeScript
177 lines
7.0 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
||
import { useNavigate, Link } from '@tanstack/react-router';
|
||
import { useTrends, useDocument } from '../api/hooks';
|
||
import { TrendArrow, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
|
||
import type { TrendSummary } from '../api/hooks';
|
||
|
||
const WINDOWS = ['intraday', '1d', '7d', '30d', '90d'];
|
||
|
||
export function TrendsPage() {
|
||
const navigate = useNavigate();
|
||
const [ticker, setTicker] = useState('');
|
||
const [debouncedTicker, setDebouncedTicker] = useState('');
|
||
const [window, setWindow] = useState<string | undefined>(undefined);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Debounce ticker search — only query after 300ms of no typing
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => setDebouncedTicker(ticker), 300);
|
||
return () => clearTimeout(timer);
|
||
}, [ticker]);
|
||
|
||
const { data, isLoading } = useTrends({ ticker: debouncedTicker || undefined, window, limit: 100 });
|
||
|
||
if (isLoading) return <LoadingSpinner />;
|
||
|
||
return (
|
||
<div>
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h1 className="text-xl font-semibold text-gray-100">Trends</h1>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
placeholder="Ticker…"
|
||
value={ticker}
|
||
onChange={(e) => setTicker(e.target.value.toUpperCase())}
|
||
className="w-24 rounded-md border border-surface-700 bg-surface-900 px-2 py-1 text-xs text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
|
||
aria-label="Filter by ticker"
|
||
/>
|
||
<div className="inline-flex rounded-md border border-surface-700" role="group" aria-label="Window selector">
|
||
<button
|
||
onClick={() => setWindow(undefined)}
|
||
className={`px-2 py-1 text-xs font-medium first:rounded-l-md last:rounded-r-md ${!window ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'}`}
|
||
>
|
||
All
|
||
</button>
|
||
{WINDOWS.map((w) => (
|
||
<button
|
||
key={w}
|
||
onClick={() => setWindow(w)}
|
||
className={`px-2 py-1 text-xs font-medium last:rounded-r-md ${window === w ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'}`}
|
||
>
|
||
{w}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{(data ?? []).map((trend) => (
|
||
<TrendCard key={trend.id} trend={trend} onClick={() => navigate({ to: '/trends/$id', params: { id: trend.id } })} />
|
||
))}
|
||
{data?.length === 0 && <p className="col-span-full text-center text-gray-500">No trends found</p>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TrendCard({ trend, onClick }: { trend: TrendSummary; onClick: () => void }) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
|
||
return (
|
||
<Card className="cursor-pointer transition-colors hover:border-brand-500/50">
|
||
<div onClick={onClick}>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-mono font-semibold text-brand-300">{trend.entity_id}</span>
|
||
<TrendArrow direction={trend.trend_direction} />
|
||
</div>
|
||
<span className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-400">{trend.window}</span>
|
||
</div>
|
||
|
||
<div className="mt-3 space-y-2">
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="text-gray-500">Strength</span>
|
||
<div className="h-1.5 w-24 rounded-full bg-surface-700">
|
||
<div className="h-full rounded-full bg-brand-500" style={{ width: `${trend.trend_strength * 100}%` }} />
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="text-gray-500">Confidence</span>
|
||
<ConfidenceBar value={trend.confidence} />
|
||
</div>
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="text-gray-500">Contradiction</span>
|
||
<span className={`font-mono ${trend.contradiction_score > 0.5 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||
{(trend.contradiction_score * 100).toFixed(0)}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{trend.dominant_catalysts && trend.dominant_catalysts.length > 0 && (
|
||
<div className="mt-2 flex flex-wrap gap-1">
|
||
{trend.dominant_catalysts.map((c, i) => (
|
||
<span key={i} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">{c}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Expandable evidence preview */}
|
||
{(trend.top_supporting_evidence?.length || trend.top_opposing_evidence?.length) && (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
||
className="mt-2 text-xs text-brand-400 hover:underline"
|
||
>
|
||
{expanded ? 'Hide' : 'Show'} evidence ({(trend.top_supporting_evidence?.length ?? 0) + (trend.top_opposing_evidence?.length ?? 0)})
|
||
</button>
|
||
)}
|
||
{expanded && (
|
||
<div className="mt-2 space-y-1 text-xs">
|
||
{[...new Set(trend.top_supporting_evidence ?? [])].map((e, i) => (
|
||
<EvidenceRef key={i} id={e} direction="supporting" />
|
||
))}
|
||
{[...new Set(trend.top_opposing_evidence ?? [])].map((e, i) => (
|
||
<EvidenceRef key={i} id={e} direction="opposing" />
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||
|
||
function EvidenceRef({ id, direction }: { id: string; direction: 'supporting' | 'opposing' }) {
|
||
const sign = direction === 'supporting' ? '+' : '−';
|
||
const color = direction === 'supporting' ? 'text-green-400' : 'text-red-400';
|
||
const isUuid = UUID_RE.test(id);
|
||
const { data: doc } = useDocument(isUuid ? id : undefined);
|
||
|
||
// Pattern IDs like "pattern:META:other:1d" are already readable
|
||
if (id.startsWith('pattern:')) {
|
||
const parts = id.split(':');
|
||
const label = parts.length >= 4
|
||
? `${parts[1]} ${parts[2]} (${parts[3]})`
|
||
: id.replace('pattern:', '');
|
||
return (
|
||
<div className={`truncate ${color}`}>
|
||
{sign} <span className="rounded bg-surface-800 px-1 py-0.5 text-[10px]">pattern</span> {label}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// UUIDs — show document title if available, otherwise short ID
|
||
if (isUuid) {
|
||
const label = doc?.title ?? `doc:${id.slice(0, 8)}…`;
|
||
return (
|
||
<div className={`truncate ${color}`}>
|
||
{sign}{' '}
|
||
<Link
|
||
to="/documents/$id"
|
||
params={{ id }}
|
||
className="hover:underline"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{label}
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Fallback — show as-is but truncated
|
||
return <div className={`truncate ${color}`}>{sign} {id}</div>;
|
||
}
|