Files
stonks-oracle/frontend/src/pages/Watchlists.tsx
T

124 lines
5.5 KiB
TypeScript

import { useState } from 'react';
import { useWatchlists, useWatchlistMembers, useCreateWatchlist, useRateLimits } from '../api/hooks';
import type { Company } from '../api/hooks';
import { LoadingSpinner, Card } from '../components/ui';
import { AlertTriangle } from 'lucide-react';
export function WatchlistsPage() {
const { data: watchlists, isLoading } = useWatchlists();
const { data: rateLimits } = useRateLimits();
const [selected, setSelected] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
if (isLoading) return <LoadingSpinner />;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-100">Watchlists</h1>
<button onClick={() => setShowCreate(true)} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">
New Watchlist
</button>
</div>
{showCreate && <CreateWatchlistForm onClose={() => setShowCreate(false)} />}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{(watchlists ?? []).map((wl) => (
<Card
key={wl.id}
className={`cursor-pointer transition-colors hover:border-brand-500/50 ${selected === wl.id ? 'border-brand-500' : ''}`}
>
<button className="w-full text-left" onClick={() => setSelected(selected === wl.id ? null : wl.id)}>
<div className="font-medium text-gray-200">{wl.name}</div>
{wl.description && <div className="mt-1 text-xs text-gray-500">{wl.description}</div>}
</button>
</Card>
))}
</div>
{selected && <WatchlistMembers watchlistId={selected} rateLimits={rateLimits} />}
</div>
);
}
interface RateLimitInfo {
polygon_global_limit: number;
market_api: {
rate_per_minute: number;
cadence_seconds: number;
max_tickers_per_cycle: number;
active_sources: number;
};
}
function WatchlistMembers({ watchlistId, rateLimits }: { watchlistId: string; rateLimits?: RateLimitInfo }) {
const { data: members, isLoading } = useWatchlistMembers(watchlistId);
if (isLoading) return <LoadingSpinner />;
if (!members?.length) return <p className="text-sm text-gray-500">No members in this watchlist</p>;
const maxTickers = rateLimits?.market_api.max_tickers_per_cycle;
const activeSources = rateLimits?.market_api.active_sources ?? 0;
const cadenceSec = rateLimits?.market_api.cadence_seconds ?? 300;
const cadenceMin = Math.round(cadenceSec / 60);
const overCapacity = maxTickers != null && activeSources > maxTickers;
return (
<Card>
<h2 className="mb-2 text-sm font-medium text-gray-400">Members</h2>
{overCapacity && (
<RateLimitWarning
activeSources={activeSources}
maxTickers={maxTickers!}
cadenceMin={cadenceMin}
/>
)}
<div className="flex flex-wrap gap-2">
{members.map((m: Company) => (
<span key={m.id} className="rounded-full border border-surface-700 bg-surface-800 px-3 py-1 text-xs text-gray-300">
{m.ticker} {m.legal_name}
</span>
))}
</div>
</Card>
);
}
function RateLimitWarning({ activeSources, maxTickers, cadenceMin }: { activeSources: number; maxTickers: number; cadenceMin: number }) {
const cycleMinutes = Math.ceil(activeSources / (maxTickers / cadenceMin));
return (
<div className="mb-3 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-400" aria-hidden="true" />
<div className="text-xs text-amber-300">
<span className="font-medium">Rate limit warning:</span>{' '}
{activeSources} active tickers exceed the {maxTickers}-ticker refresh capacity
per {cadenceMin}-minute cycle. Full refresh will take ~{cycleMinutes} minutes
instead of {cadenceMin}. Remove tickers or increase the rate limit to improve freshness.
</div>
</div>
);
}
function CreateWatchlistForm({ onClose }: { onClose: () => void }) {
const [name, setName] = useState('');
const [desc, setDesc] = useState('');
const mutation = useCreateWatchlist();
return (
<Card>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
if (name.trim()) mutation.mutate({ name: name.trim(), description: desc || undefined }, { onSuccess: onClose });
}}
>
<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} required className="rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none" aria-label="Watchlist name" />
<input type="text" placeholder="Description (optional)" value={desc} onChange={(e) => setDesc(e.target.value)} className="flex-1 rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none" aria-label="Watchlist description" />
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">Create</button>
<button type="button" onClick={onClose} className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
</form>
</Card>
);
}