From d426fe1ffd5b408fc2f02122ee288b92513d542f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 05:41:12 +0000 Subject: [PATCH] TrendingLexicons: metric toggle, default to DIDs, scope bsky filter to trending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three behavior changes: 1. New Metric toggle (Creates / Updates / Deletes / DIDs). The right column and sparkline both reflect the chosen metric. DIDs (unique creators) is the new default — the most "interesting humans" view when scanning what the network is up to. 2. The Bluesky filter now only applies in Trending mode. Top is the absolute leaders view; hiding app.bsky.* there was misleading (you'd see Tangled with 700 creates above Bluesky's 14M). Trending keeps the filter — bsky doesn't move much in % terms so it would crowd the table with sideways activity. 3. Default metric is DIDs (was creates). Default mode is still Trending, default window is still 7d. Implementation: /collections supports server-sort by records-created or dids-estimate. For Creates / DIDs we read from /collections directly. For Updates / Deletes the API can't sort by them, so we fetch a candidate pool ordered by records-created and re-rank client-side via /collections/stats (which returns all four metrics). Sparklines / timeseries already had all four metrics per bucket so they're free to project. --- src/components/explore/TrendingLexicons.tsx | 262 ++++++++++++++------ 1 file changed, 181 insertions(+), 81 deletions(-) diff --git a/src/components/explore/TrendingLexicons.tsx b/src/components/explore/TrendingLexicons.tsx index e4eae23..8351262 100644 --- a/src/components/explore/TrendingLexicons.tsx +++ b/src/components/explore/TrendingLexicons.tsx @@ -9,6 +9,7 @@ const UFOS_API = 'https://ufos-api.microcosm.blue'; type Window = '1d' | '7d' | '30d'; type Mode = 'top' | 'trending'; +type Metric = 'creates' | 'updates' | 'deletes' | 'dids'; type WindowConfig = { label: string; @@ -23,17 +24,25 @@ const WINDOWS: Record = { '30d': { label: '30d', hours: 24 * 30, step: 60 * 60 * 24, bucketCount: 30 }, }; +const METRIC_LABEL: Record = { + creates: 'Creates', + updates: 'Updates', + deletes: 'Deletes', + dids: 'DIDs', +}; + const RESULT_COUNT = 10; /** - * Pull more candidates than we'll display so the filter (drop app.bsky.*) - * + dedup (one row per top-2-segment namespace) still leaves enough rows - * to fill the table. + * Pull more candidates than we'll display so the filter (drop app.bsky.* + * in trending) + dedup (one row per top-2-segment namespace) still leaves + * enough rows to fill the table. */ const CANDIDATE_POOL = 50; type CollectionRow = { nsid: string; - creates: number; + /** Metric value in the current window — what gets displayed in Top mode. */ + value: number; series: number[]; /** % change vs the equivalent prior window; null when prior is 0 or unknown. */ deltaPct: number | null; @@ -41,19 +50,21 @@ type CollectionRow = { /** * Top / Trending lexicons strip on /explore. Hits the UFOs API - * (ufos-api.microcosm.blue) to rank collections in the chosen window, - * filters out the Bluesky namespace (it would otherwise dominate every - * row), and dedupes so each top-2-segment namespace contributes one row. + * (ufos-api.microcosm.blue) and exposes three independent toggles: * - * Two modes: - * - Top: sorted by absolute creates in the window. - * - Trending: sorted by % change vs the prior equivalent window. Rows - * show the delta instead of the absolute count. + * - mode: Trending (% change vs prior window) or Top (absolute leaders) + * - metric: creates / updates / deletes / dids + * - window: 1d / 7d / 30d * - * Sparklines come from per-collection timeseries fetches. + * Bluesky-namespace collections are filtered out of TRENDING only — they + * dominate every absolute ranking but the % change view is more useful + * highlighting non-Bluesky momentum. Top keeps the whole catalog. Both + * modes dedupe by top-2-segment namespace so `social.grain.gallery` and + * `social.grain.like` aren't both in the table at once. */ export default function TrendingLexicons() { const [mode, setMode] = useState('trending'); + const [metric, setMetric] = useState('dids'); const [window, setWindow] = useState('7d'); const [rows, setRows] = useState(null); const [error, setError] = useState(null); @@ -66,7 +77,7 @@ export default function TrendingLexicons() { (async () => { try { - const next = await fetchRanking(window, mode); + const next = await fetchRanking(window, mode, metric); if (!cancelled) setRows(next); } catch (err) { if (!cancelled) setError(err instanceof Error ? err.message : String(err)); @@ -78,7 +89,7 @@ export default function TrendingLexicons() { return () => { cancelled = true; }; - }, [window, mode]); + }, [window, mode, metric]); return ( @@ -88,33 +99,44 @@ export default function TrendingLexicons() { background: 'var(--bg-secondary)', }} > -
+
{error ? (
Couldn’t reach the UFOs API: {error}
) : rows ? ( -
    - {rows.map((row, i) => ( - - ))} - {rows.length === 0 && ( -

    - No non-Bluesky lexicons matched in this window. -

    - )} -
+ rows.length === 0 ? ( +

+ {mode === 'trending' + ? 'No non-Bluesky lexicons matched in this window.' + : 'No lexicons matched in this window.'} +

+ ) : ( +
    + {rows.map((row, i) => ( + + ))} +
+ ) ) : loading ? (

void; + metric: Metric; + setMetric: (m: Metric) => void; window: Window; setWindow: (w: Window) => void; }) { @@ -145,11 +171,9 @@ function Header({

-
+
setMode(v as Mode)} /> + ({ + value: m, + label: METRIC_LABEL[m], + }))} + value={metric} + onChange={(v) => setMetric(v as Metric)} + /> ({ @@ -253,12 +292,14 @@ function Row({ row, rank, mode, + metric, window, isLast, }: { row: CollectionRow; rank: number; mode: Mode; + metric: Metric; window: Window; isLast: boolean; }) { @@ -317,6 +358,7 @@ function Row({ ) : ( - {formatCount(row.creates)} + {formatCount(row.value)} )} @@ -425,15 +467,12 @@ function formatCount(n: number): string { function formatPct(pct: number): string { const sign = pct >= 0 ? '+' : ''; - if (Math.abs(pct) >= 1000) return `${sign}${pct.toFixed(0)}%`; if (Math.abs(pct) >= 100) return `${sign}${pct.toFixed(0)}%`; return `${sign}${pct.toFixed(1)}%`; } // ─── NSID grouping ───────────────────────────────────────────────────────── -/** Bluesky namespaces dominate every ranking; filter them out so the - * surface highlights everything else. */ function isBluesky(nsid: string): boolean { return nsid.startsWith('app.bsky.'); } @@ -446,11 +485,14 @@ function namespaceKey(nsid: string): string { return `${parts[0]}.${parts[1]}`; } -function filterAndDedup(rows: T[]): T[] { +function filterAndDedup( + rows: T[], + dropBluesky: boolean, +): T[] { const seen = new Set(); const out: T[] = []; for (const r of rows) { - if (isBluesky(r.nsid)) continue; + if (dropBluesky && isBluesky(r.nsid)) continue; const key = namespaceKey(r.nsid); if (seen.has(key)) continue; seen.add(key); @@ -461,56 +503,108 @@ function filterAndDedup(rows: T[]): T[] { // ─── Fetching ────────────────────────────────────────────────────────────── -type RawCollection = { nsid: string; creates: number; dids_estimate: number }; +type RawCollection = { + nsid: string; + creates: number; + updates: number; + deletes: number; + dids_estimate: number; +}; + +type MetricStats = { + creates: number; + updates: number; + deletes: number; + dids_estimate: number; +}; function isoAgo(hours: number): string { return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); } -async function fetchRanking(window: Window, mode: Mode): Promise { +/** /collections only supports two sort orders. Pick the closest to the + * chosen metric so the candidate pool is well-aligned; deletes / updates + * fall back to records-created since the API can't sort by them. */ +function orderForMetric(metric: Metric): 'records-created' | 'dids-estimate' { + return metric === 'dids' ? 'dids-estimate' : 'records-created'; +} + +function statForMetric(s: MetricStats, metric: Metric): number { + if (metric === 'creates') return s.creates ?? 0; + if (metric === 'updates') return s.updates ?? 0; + if (metric === 'deletes') return s.deletes ?? 0; + return s.dids_estimate ?? 0; +} + +async function fetchRanking( + window: Window, + mode: Mode, + metric: Metric, +): Promise { const cfg = WINDOWS[window]; const sinceIso = isoAgo(cfg.hours); - // 1) Pull a candidate pool ranked by absolute creates in the window — - // bigger than RESULT_COUNT because filter + dedup will thin it. - const ranked = await fetchTopRaw(sinceIso, CANDIDATE_POOL); - const filtered = filterAndDedup(ranked); + // 1) Candidate pool. Order by whichever the API supports best for this + // metric; ties / unsupported orders fall back to records-created. + const candidates = await fetchTopRaw( + sinceIso, + CANDIDATE_POOL, + orderForMetric(metric), + ); + + // 2) Filter (bsky dropped only in trending) + dedup by top-2-segment ns. + const filtered = filterAndDedup(candidates, mode === 'trending'); if (filtered.length === 0) return []; + // 3) Score by the chosen metric. /collections gives us creates + + // dids_estimate directly; updates / deletes need a stats lookup. + const directlyAvailable = metric === 'creates' || metric === 'dids'; + const currentMap = directlyAvailable + ? new Map(filtered.map((c) => [c.nsid, statForMetric(c, metric)])) + : await fetchStats( + filtered.map((c) => c.nsid), + sinceIso, + /* until */ new Date().toISOString(), + metric, + ); + if (mode === 'top') { - const picked = filtered.slice(0, RESULT_COUNT); + const sorted = [...filtered].sort( + (a, b) => (currentMap.get(b.nsid) ?? 0) - (currentMap.get(a.nsid) ?? 0), + ); + const picked = sorted.slice(0, RESULT_COUNT); const series = await Promise.all( - picked.map((c) => fetchSeries(c.nsid, sinceIso, cfg.step, cfg.bucketCount)), + picked.map((c) => + fetchSeries(c.nsid, sinceIso, cfg.step, cfg.bucketCount, metric), + ), ); return picked.map((c, i) => ({ nsid: c.nsid, - creates: c.creates, + value: currentMap.get(c.nsid) ?? 0, series: series[i], deltaPct: null, })); } - // 2) Trending — get the equivalent prior window's creates per candidate, - // then rank by % change. Capped at the filtered pool size so we don't - // hit /timeseries for collections that won't make the cut. + // Trending: pull prior-window metric values, compute % change. const priorSinceIso = isoAgo(cfg.hours * 2); const priorUntilIso = sinceIso; - const candidates = filtered.slice(0, CANDIDATE_POOL); - const priorMap = await fetchPriorCreates( - candidates.map((c) => c.nsid), + const priorMap = await fetchStats( + filtered.map((c) => c.nsid), priorSinceIso, priorUntilIso, + metric, ); - const withDelta = candidates.map((c) => { + const withDelta = filtered.map((c) => { + const current = currentMap.get(c.nsid) ?? 0; const prior = priorMap.get(c.nsid) ?? 0; - const deltaPct = prior > 0 ? ((c.creates - prior) / prior) * 100 : null; - return { ...c, deltaPct }; + const deltaPct = prior > 0 ? ((current - prior) / prior) * 100 : null; + return { nsid: c.nsid, value: current, deltaPct }; }); - // Rank by computable delta descending; null deltas (brand-new lexicons) - // sort to the back so the headline rows are genuine accelerations. - const ranked2 = withDelta + // Rank by delta desc; null deltas (no prior data) sort to the back. + const ranked = withDelta .sort((a, b) => { const ad = a.deltaPct; const bd = b.deltaPct; @@ -522,32 +616,39 @@ async function fetchRanking(window: Window, mode: Mode): Promise fetchSeries(c.nsid, sinceIso, cfg.step, cfg.bucketCount)), + ranked.map((c) => + fetchSeries(c.nsid, sinceIso, cfg.step, cfg.bucketCount, metric), + ), ); - return ranked2.map((c, i) => ({ + return ranked.map((c, i) => ({ nsid: c.nsid, - creates: c.creates, + value: c.value, series: series[i], deltaPct: c.deltaPct, })); } -async function fetchTopRaw(sinceIso: string, limit: number): Promise { +async function fetchTopRaw( + sinceIso: string, + limit: number, + order: 'records-created' | 'dids-estimate', +): Promise { const url = `${UFOS_API}/collections` + - `?order=records-created&limit=${limit}&since=${encodeURIComponent(sinceIso)}`; + `?order=${order}&limit=${limit}&since=${encodeURIComponent(sinceIso)}`; const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = (await res.json()) as { collections?: RawCollection[] }; return data.collections || []; } -/** Fetch /collections/stats for several collections at once — the endpoint - * accepts repeated `collection=` query params. Returns NSID -> creates. */ -async function fetchPriorCreates( +/** /collections/stats returns a flat { nsid: { creates, updates, deletes, + * dids_estimate } } map. Pick the metric we care about and project. */ +async function fetchStats( nsids: string[], sinceIso: string, untilIso: string, + metric: Metric, ): Promise> { if (nsids.length === 0) return new Map(); const params = new URLSearchParams(); @@ -559,13 +660,11 @@ async function fetchPriorCreates( cache: 'no-store', }); if (!res.ok) return new Map(); - // Response is a flat { nsid: { creates, updates, deletes, dids_estimate } } - // map, not a { collections: [...] } envelope like /collections. - const data = (await res.json()) as Record; + const data = (await res.json()) as Record>; const out = new Map(); for (const [nsid, entry] of Object.entries(data)) { if (entry && typeof entry === 'object') { - out.set(nsid, entry.creates ?? 0); + out.set(nsid, statForMetric(entry as MetricStats, metric)); } } return out; @@ -579,6 +678,7 @@ async function fetchSeries( sinceIso: string, step: number, bucketCount: number, + metric: Metric, ): Promise { const url = `${UFOS_API}/timeseries` + @@ -589,11 +689,11 @@ async function fetchSeries( const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) return []; const data = (await res.json()) as { - series?: Record; + series?: Record; }; const bucket = data.series?.[collection] || []; const trimmed = bucket.slice(-bucketCount); - return trimmed.map((b) => b.creates ?? 0); + return trimmed.map((b) => statForMetric(b, metric)); } catch { return []; } -- 2.51.2