1import { ExternalLink } from 'lucide-react';2import { EntityLink } from '@/components/ui/entity';3import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';4import { Note } from '@/components/ui/section';5import { fmtDateTime, fmtAgo, fmtUsdPerM, num } from '@/lib/format';6import type { ChangeEvent } from '@/lib/types';78type PriceValue = { input_per_mtok?: unknown; output_per_mtok?: unknown } | null | undefined;910function pv(v: unknown): { input: number | null; output: number | null } {11 const o = (v && typeof v === 'object' ? v : {}) as PriceValue;12 return { input: num(o?.input_per_mtok), output: num(o?.output_per_mtok) };13}1415function host(url: string): string {16 try {17 return new URL(url).hostname.replace(/^www\./, '');18 } catch {19 return 'source';20 }21}2223/** Old → new price cell: strike-through old, bold new, direction sign. Amber = money. */24function Move({ from, to }: { from: number | null; to: number | null }) {25 if (from === null && to === null) return <span className="text-ink-3">—</span>;26 const dir = from !== null && to !== null ? (to < from ? '↓' : to > from ? '↑' : '=') : '';27 return (28 <span className="tnum inline-flex items-center gap-1.5">29 <span className="text-ink-3 line-through decoration-ink-3/60">{fmtUsdPerM(from)}</span>30 <span aria-hidden className="text-ink-3">→</span>31 <span className={dir === '=' ? 'text-ink-2' : 'font-semibold text-accent-2'}>{fmtUsdPerM(to)}</span>32 {dir && dir !== '=' && <span className={dir === '↓' ? 'text-positive' : 'text-danger'} aria-label={dir === '↓' ? 'decrease' : 'increase'}>{dir}</span>}33 </span>34 );35}3637/** PRICE_CHANGED events as a dense table: model · provider · input move · output move · when · source. */38export function PriceMovers({ movers }: { movers: ChangeEvent[] }) {39 const rows = movers.filter((e) => e.event_type === 'PRICE_CHANGED' || e.category === 'price');40 return (41 <>42 <DataTable caption="Recent price changes">43 <thead>44 <tr>45 <Th>Model</Th>46 <Th>Provider</Th>47 <Th>Input / 1M</Th>48 <Th>Output / 1M</Th>49 <Th>Observed</Th>50 <Th>Source</Th>51 </tr>52 </thead>53 <tbody>54 {rows.length === 0 && <EmptyRow cols={6}>No price change recorded in this window. The first observation of a price is not a change.</EmptyRow>}55 {rows.map((e) => {56 const o = pv(e.old_value);57 const n = pv(e.new_value);58 const provider = typeof e.meta?.provider === 'string' ? e.meta.provider : null;59 return (60 <tr key={e.id}>61 <Td primary>62 {e.entity ? <EntityLink e={e.entity} /> : <span className="text-ink-3">—</span>}63 {e.entity?.organization && <span className="ml-2 text-xs text-ink-3">{e.entity.organization.name}</span>}64 </Td>65 <Td label="Provider" className="text-ink-2">{provider ?? <span className="text-ink-3">—</span>}</Td>66 <Td label="Input / 1M"><Move from={o.input} to={n.input} /></Td>67 <Td label="Output / 1M"><Move from={o.output} to={n.output} /></Td>68 <Td label="Observed" className="text-ink-2" title={fmtDateTime(e.observed_at)}>{fmtAgo(e.observed_at)}</Td>69 <Td label="Source">70 {e.source_url ? (71 <a href={e.source_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs text-ink-2 hover:text-accent">72 {host(e.source_url)} <ExternalLink className="size-3" aria-hidden />73 </a>74 ) : (75 <span className="text-ink-3">—</span>76 )}77 </Td>78 </tr>79 );80 })}81 </tbody>82 </DataTable>83 {rows.length > 0 && <Note className="mt-3">Movers are PRICE_CHANGED events emitted when a provider's published price for a model differs from the previous observation. Green arrow = cheaper, red = dearer.</Note>}84 </>85 );86}87