'use client'; import { Star, X } from 'lucide-react'; import Link from 'next/link'; import { useEffect, useMemo, useState } from 'react'; import { ChangeRow } from '@/components/changes/change-row'; import { EntityBadge } from '@/components/ui/badges'; import { EmptyState } from '@/components/ui/unavailable'; import { clientApi } from '@/lib/client-api'; import { routes } from '@/lib/site'; import type { ChangeEvent } from '@/lib/types'; import { useWatchlist, WATCH_EVENT_TYPES, WATCH_MAX } from '@/lib/watchlist'; type Feed = { status: 'idle' | 'loading' | 'ready'; events: ChangeEvent[]; failed: string[] }; /** * `/watchlist` body: the watched entities (remove buttons) and their merged event feed — `/changes?entity=&limit=20` * per item (≤ 12 items), merged, sorted by `effective_at ?? observed_at`, filtered to release / price / deprecation / * benchmark / provider / licence event types. Nothing is stored server-side. */ export function WatchlistView() { const wl = useWatchlist(); const [feed, setFeed] = useState({ status: 'idle', events: [], failed: [] }); const slugs = useMemo(() => wl.items.map((i) => i.slug).slice(0, WATCH_MAX), [wl.items]); const key = slugs.join(','); useEffect(() => { if (!wl.ready) return; if (!slugs.length) { setFeed({ status: 'ready', events: [], failed: [] }); return; } const ctrl = new AbortController(); setFeed((f) => ({ ...f, status: 'loading' })); (async () => { const results = await Promise.all(slugs.map((s) => clientApi.entityChanges(s, 20, ctrl.signal).then((p) => ({ s, items: p.items ?? [] })).catch((e) => ({ s, items: null as ChangeEvent[] | null, e })))); if (ctrl.signal.aborted) return; const seen = new Set(); const events: ChangeEvent[] = []; const failed: string[] = []; for (const r of results) { if (!r.items) { failed.push(r.s); continue; } for (const e of r.items) { if (!WATCH_EVENT_TYPES.has(e.event_type) || seen.has(e.id)) continue; seen.add(e.id); events.push(e); } } events.sort((a, b) => new Date(b.effective_at ?? b.observed_at).getTime() - new Date(a.effective_at ?? a.observed_at).getTime()); setFeed({ status: 'ready', events, failed }); })(); return () => ctrl.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, wl.ready]); if (!wl.ready) return

Loading your watchlist…

; if (!wl.items.length) return ( Press Watch on any model, organization, provider or benchmark page. The list lives in this browser only — no account.{' '} Browse models → ); return (

Watching · {wl.items.length}/{WATCH_MAX}

    {wl.items.map((i) => (
  • {i.name} {i.org && {i.org}}
  • ))}
{wl.items.length > 1 && (

i.entity_type === wl.items[0]?.entity_type).slice(0, 6).map((i) => i.slug))} className="link"> Compare the watched {wl.items[0]?.entity_type}s →

)}

Relevant events · releases, prices, deprecations, benchmarks, providers, licences

{feed.status === 'loading' && !feed.events.length &&

Loading events…

} {feed.failed.length > 0 &&

Events unavailable for {feed.failed.length} {feed.failed.length === 1 ? 'entity' : 'entities'} (API did not answer).

} {feed.status === 'ready' && !feed.events.length && feed.failed.length < slugs.length && (

No release, price, deprecation, benchmark, provider or licence events recorded yet for the watched entities.

)} {feed.events.length > 0 && (
    {feed.events.slice(0, 80).map((e) => ( ))}
)}
); }