Files
stonks-oracle/frontend/src/pages/Trends.tsx
T
Celes Renata 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
fix: debounce ticker search on Trends page to preserve input focus
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.
2026-04-29 16:54:19 +00:00

177 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}