'use client'; /** * Local watchlist (no account): `localStorage['aia-watchlist']` = `WatchItem[]` (slug, name, entity_type, org). * Same-tab `aia-watchlist-change` event + cross-tab `storage`. Capped at WATCH_MAX items (the `/watchlist` page * fetches events per item, so the cap keeps the page honest and fast). */ import { useCallback, useEffect, useState } from 'react'; import type { WatchItem } from './types'; export const WATCH_KEY = 'aia-watchlist'; export const WATCH_MAX = 12; const EVENT = 'aia-watchlist-change'; /** Event types that matter for a watched entity (new version / price / deprecation / benchmark / provider / licence). */ export const WATCH_EVENT_TYPES = new Set([ 'NEW_MODEL', 'RELEASE', 'VERSION_RELEASED', 'MODEL_UPDATED', 'PRICE_CHANGED', 'PROVIDER_LISTED', 'DEPRECATION_ANNOUNCED', 'RETIREMENT_ANNOUNCED', 'STATUS_CHANGED', 'BENCHMARK_RESULT', 'BENCHMARK_UPDATED', 'NEW_PROVIDER', 'LICENSE_CHANGED', 'OPENNESS_CHANGED', 'CONTEXT_CHANGED', ]); function read(): WatchItem[] { if (typeof window === 'undefined') return []; try { const raw = window.localStorage.getItem(WATCH_KEY); if (!raw) return []; const arr = JSON.parse(raw) as unknown; if (!Array.isArray(arr)) return []; return arr.filter((x): x is WatchItem => !!x && typeof x === 'object' && typeof (x as WatchItem).slug === 'string' && typeof (x as WatchItem).entity_type === 'string').slice(0, WATCH_MAX); } catch { return []; } } function write(items: WatchItem[]) { if (typeof window === 'undefined') return; try { window.localStorage.setItem(WATCH_KEY, JSON.stringify(items.slice(0, WATCH_MAX))); } catch { /* storage full / disabled */ } window.dispatchEvent(new CustomEvent(EVENT)); } export function readWatchlist(): WatchItem[] { return read(); } export function isWatched(slug: string): boolean { return read().some((i) => i.slug === slug); } export function addWatch(item: WatchItem): WatchItem[] { const cur = read(); if (cur.some((i) => i.slug === item.slug)) return cur; const next = [{ ...item, added_at: new Date().toISOString() }, ...cur].slice(0, WATCH_MAX); write(next); return next; } export function removeWatch(slug: string): WatchItem[] { const next = read().filter((i) => i.slug !== slug); write(next); return next; } export function toggleWatch(item: WatchItem): WatchItem[] { return isWatched(item.slug) ? removeWatch(item.slug) : addWatch(item); } export function clearWatchlist() { write([]); } /** Normalise an entity summary/detail into a WatchItem. */ export function toWatchItem(e: { slug: string; name: string; entity_type: string; organization?: { name: string } | string | null }): WatchItem { return { slug: e.slug, name: e.name, entity_type: e.entity_type, org: typeof e.organization === 'string' ? e.organization : e.organization?.name ?? null }; } /** React binding; `ready` is false during SSR/hydration so server and client markup match. */ export function useWatchlist() { const [items, setItems] = useState([]); const [ready, setReady] = useState(false); useEffect(() => { const sync = () => setItems(read()); sync(); setReady(true); window.addEventListener(EVENT, sync); window.addEventListener('storage', sync); return () => { window.removeEventListener(EVENT, sync); window.removeEventListener('storage', sync); }; }, []); const add = useCallback((i: WatchItem) => setItems(addWatch(i)), []); const remove = useCallback((slug: string) => setItems(removeWatch(slug)), []); const toggle = useCallback((i: WatchItem) => setItems(toggleWatch(i)), []); const clear = useCallback(() => { clearWatchlist(); setItems([]); }, []); const has = useCallback((slug: string) => items.some((i) => i.slug === slug), [items]); return { items, ready, add, remove, toggle, clear, has, full: items.length >= WATCH_MAX }; }