124 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|