HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1'use client';2/**3 * Local watchlist (no account): `localStorage['aia-watchlist']` = `WatchItem[]` (slug, name, entity_type, org).4 * Same-tab `aia-watchlist-change` event + cross-tab `storage`. Capped at WATCH_MAX items (the `/watchlist` page5 * fetches events per item, so the cap keeps the page honest and fast).6 */7import { useCallback, useEffect, useState } from 'react';8import type { WatchItem } from './types';910export const WATCH_KEY = 'aia-watchlist';11export const WATCH_MAX = 12;12const EVENT = 'aia-watchlist-change';1314/** Event types that matter for a watched entity (new version / price / deprecation / benchmark / provider / licence). */15export const WATCH_EVENT_TYPES = new Set([16 'NEW_MODEL',17 'RELEASE',18 'VERSION_RELEASED',19 'MODEL_UPDATED',20 'PRICE_CHANGED',21 'PROVIDER_LISTED',22 'DEPRECATION_ANNOUNCED',23 'RETIREMENT_ANNOUNCED',24 'STATUS_CHANGED',25 'BENCHMARK_RESULT',26 'BENCHMARK_UPDATED',27 'NEW_PROVIDER',28 'LICENSE_CHANGED',29 'OPENNESS_CHANGED',30 'CONTEXT_CHANGED',31]);3233function read(): WatchItem[] {34 if (typeof window === 'undefined') return [];35 try {36 const raw = window.localStorage.getItem(WATCH_KEY);37 if (!raw) return [];38 const arr = JSON.parse(raw) as unknown;39 if (!Array.isArray(arr)) return [];40 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);41 } catch {42 return [];43 }44}45function write(items: WatchItem[]) {46 if (typeof window === 'undefined') return;47 try {48 window.localStorage.setItem(WATCH_KEY, JSON.stringify(items.slice(0, WATCH_MAX)));49 } catch {50 /* storage full / disabled */51 }52 window.dispatchEvent(new CustomEvent(EVENT));53}5455export function readWatchlist(): WatchItem[] {56 return read();57}58export function isWatched(slug: string): boolean {59 return read().some((i) => i.slug === slug);60}61export function addWatch(item: WatchItem): WatchItem[] {62 const cur = read();63 if (cur.some((i) => i.slug === item.slug)) return cur;64 const next = [{ ...item, added_at: new Date().toISOString() }, ...cur].slice(0, WATCH_MAX);65 write(next);66 return next;67}68export function removeWatch(slug: string): WatchItem[] {69 const next = read().filter((i) => i.slug !== slug);70 write(next);71 return next;72}73export function toggleWatch(item: WatchItem): WatchItem[] {74 return isWatched(item.slug) ? removeWatch(item.slug) : addWatch(item);75}76export function clearWatchlist() {77 write([]);78}7980/** Normalise an entity summary/detail into a WatchItem. */81export function toWatchItem(e: { slug: string; name: string; entity_type: string; organization?: { name: string } | string | null }): WatchItem {82 return { slug: e.slug, name: e.name, entity_type: e.entity_type, org: typeof e.organization === 'string' ? e.organization : e.organization?.name ?? null };83}8485/** React binding; `ready` is false during SSR/hydration so server and client markup match. */86export function useWatchlist() {87 const [items, setItems] = useState<WatchItem[]>([]);88 const [ready, setReady] = useState(false);89 useEffect(() => {90 const sync = () => setItems(read());91 sync();92 setReady(true);93 window.addEventListener(EVENT, sync);94 window.addEventListener('storage', sync);95 return () => {96 window.removeEventListener(EVENT, sync);97 window.removeEventListener('storage', sync);98 };99 }, []);100 const add = useCallback((i: WatchItem) => setItems(addWatch(i)), []);101 const remove = useCallback((slug: string) => setItems(removeWatch(slug)), []);102 const toggle = useCallback((i: WatchItem) => setItems(toggleWatch(i)), []);103 const clear = useCallback(() => {104 clearWatchlist();105 setItems([]);106 }, []);107 const has = useCallback((slug: string) => items.some((i) => i.slug === slug), [items]);108 return { items, ready, add, remove, toggle, clear, has, full: items.length >= WATCH_MAX };109}110