GET /xrpc/app.bsky.actor.searchActorsTypeahead typeahead.waow.tech
16
fork

Configure Feed

Select the types of activity you want to include in your feed.

granular stats: 5-min actor deltas, 5-min search buckets, layout rearrange

- actor_deltas table tracks actors/handles/avatars at 5-min granularity
- instrument ingest, delete, and enrichment paths to record deltas
- stitch deltas after last hourly snapshot for interpolated trend points
- search metrics switch from hourly to 5-min buckets (LIMIT 2016)
- stats layout: pills co-located with their charts (3 under trend, 2 under sparkline)
- legend + tooltip sort dynamically by value descending
- fix x-axis timezone bug (UTC midnight labels were off by a day)
- normalize loopback IPs to "unknown" in traffic sources
- singular/plural in sparkline + pie tooltips
- 2x enrichment throughput (100 identity / 20 avatar per run)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+275 -86
+11 -1
schema.sql
··· 4 4 display_name TEXT DEFAULT '', 5 5 avatar_url TEXT DEFAULT '', -- stores CID only (e.g. bafkrei...); reconstruct URL at query time 6 6 updated_at INTEGER NOT NULL DEFAULT (unixepoch()), 7 - hidden INTEGER NOT NULL DEFAULT 0 7 + hidden INTEGER NOT NULL DEFAULT 0, 8 + pds TEXT DEFAULT '', 9 + identity_checked_at INTEGER DEFAULT 0, 10 + profile_checked_at INTEGER DEFAULT 0 8 11 ); 9 12 10 13 CREATE INDEX IF NOT EXISTS idx_actors_handle ON actors(handle COLLATE NOCASE); ··· 44 47 total INTEGER NOT NULL DEFAULT 0, 45 48 with_handles INTEGER NOT NULL DEFAULT 0, 46 49 with_avatars INTEGER NOT NULL DEFAULT 0 50 + ); 51 + 52 + CREATE TABLE IF NOT EXISTS actor_deltas ( 53 + bucket INTEGER PRIMARY KEY, -- 5-min bucket (Date.now() / 300_000) 54 + actors_delta INTEGER NOT NULL DEFAULT 0, 55 + handles_delta INTEGER NOT NULL DEFAULT 0, 56 + avatars_delta INTEGER NOT NULL DEFAULT 0 47 57 ); 48 58 49 59 CREATE TABLE IF NOT EXISTS traffic_sources (
+6
scripts/add-actor-deltas.sql
··· 1 + CREATE TABLE IF NOT EXISTS actor_deltas ( 2 + bucket INTEGER PRIMARY KEY, -- 5-min bucket (Date.now() / 300_000) 3 + actors_delta INTEGER NOT NULL DEFAULT 0, 4 + handles_delta INTEGER NOT NULL DEFAULT 0, 5 + avatars_delta INTEGER NOT NULL DEFAULT 0 6 + );
+258 -85
src/index.ts
··· 221 221 } 222 222 } 223 223 224 - /** resolve handles for actors missing them via slingshot */ 225 - async function resolveHandles(db: TursoDB): Promise<void> { 226 - const { results } = await db.prepare( 227 - "SELECT did FROM actors WHERE handle = '' ORDER BY updated_at DESC LIMIT 5000" 228 - ).all<{ did: string }>(); 229 - if (!results || results.length === 0) return; 224 + /** lease-coordinated, two-phase enrichment: slingshot identity + PDS-native profile. 225 + * returns { resolved, enriched } counts for delta tracking. */ 226 + async function enrichActors(db: TursoDB, env: Env): Promise<{ resolved: number; enriched: number }> { 227 + // lease via KV — 30s TTL, skip if another run is active 228 + const existing = await env.KV.get("enrich_lock"); 229 + if (existing) return { resolved: 0, enriched: 0 }; 230 + await env.KV.put("enrich_lock", "1", { expirationTtl: 30 }); 231 + 232 + let totalResolved = 0; 233 + let totalEnriched = 0; 234 + 235 + try { 236 + // phase 1: identity resolution via slingshot 237 + const { results: identityRows } = await db.prepare( 238 + `SELECT did FROM actors 239 + WHERE handle = '' AND identity_checked_at < unixepoch() - 3600 240 + ORDER BY identity_checked_at ASC LIMIT 100` 241 + ).all<{ did: string }>(); 230 242 231 - let resolved = 0; 232 - const BATCH = 10; 233 - for (let i = 0; i < results.length; i += BATCH) { 234 - const batch = results.slice(i, i + BATCH); 235 - await Promise.all(batch.map(async ({ did }) => { 236 - try { 237 - const res = await fetch( 238 - `${SLINGSHOT_URL}?identifier=${encodeURIComponent(did)}` 239 - ); 240 - if (!res.ok) return; 241 - const identity: SlingshotResponse = await res.json(); 242 - if (identity.handle) { 243 + if (identityRows && identityRows.length > 0) { 244 + let resolved = 0; 245 + const BATCH = 20; 246 + for (let i = 0; i < identityRows.length; i += BATCH) { 247 + const batch = identityRows.slice(i, i + BATCH); 248 + await Promise.all(batch.map(async ({ did }) => { 249 + try { 250 + const res = await fetch( 251 + `${SLINGSHOT_URL}?identifier=${encodeURIComponent(did)}` 252 + ); 253 + if (!res.ok) { 254 + // mark attempt so we back off for 1hr 255 + await db.prepare( 256 + "UPDATE actors SET identity_checked_at = unixepoch() WHERE did = ?1" 257 + ).bind(did).run(); 258 + return; 259 + } 260 + const identity: SlingshotResponse = await res.json(); 261 + await db.prepare( 262 + `UPDATE actors SET handle = COALESCE(NULLIF(?1, ''), handle), 263 + pds = COALESCE(NULLIF(?2, ''), pds), 264 + identity_checked_at = unixepoch() 265 + WHERE did = ?3` 266 + ).bind(identity.handle || '', identity.pds || '', did).run(); 267 + if (identity.handle) resolved++; 268 + } catch { 269 + await db.prepare( 270 + "UPDATE actors SET identity_checked_at = unixepoch() WHERE did = ?1" 271 + ).bind(did).run().catch(() => {}); 272 + } 273 + })); 274 + } 275 + totalResolved = resolved; 276 + if (resolved > 0) { 277 + console.log(JSON.stringify({ event: "enrich_identity", resolved, checked: identityRows.length })); 278 + } 279 + } 280 + 281 + // phase 2: profile enrichment via PDS-native getRecord 282 + const { results: profileRows } = await db.prepare( 283 + `SELECT did, pds FROM actors 284 + WHERE handle != '' AND avatar_url = '' AND pds != '' 285 + AND profile_checked_at < unixepoch() - 3600 286 + ORDER BY profile_checked_at ASC LIMIT 20` 287 + ).all<{ did: string; pds: string }>(); 288 + 289 + if (profileRows && profileRows.length > 0) { 290 + let enriched = 0; 291 + await Promise.all(profileRows.map(async ({ did, pds }) => { 292 + try { 293 + const res = await fetch( 294 + `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self` 295 + ); 296 + if (!res.ok) { 297 + await db.prepare( 298 + "UPDATE actors SET profile_checked_at = unixepoch() WHERE did = ?1" 299 + ).bind(did).run(); 300 + return; 301 + } 302 + const data: any = await res.json(); 303 + const value = data?.value; 304 + const avatarCid = value?.avatar?.ref?.$link || ''; 305 + const displayName = value?.displayName || ''; 243 306 await db.prepare( 244 - "UPDATE actors SET handle = ?1 WHERE did = ?2 AND handle = ''" 245 - ).bind(identity.handle, did).run(); 246 - resolved++; 307 + `UPDATE actors SET 308 + avatar_url = COALESCE(NULLIF(?1, ''), avatar_url), 309 + display_name = COALESCE(NULLIF(?2, ''), display_name), 310 + profile_checked_at = unixepoch() 311 + WHERE did = ?3` 312 + ).bind(avatarCid, displayName, did).run(); 313 + if (avatarCid) enriched++; 314 + } catch { 315 + await db.prepare( 316 + "UPDATE actors SET profile_checked_at = unixepoch() WHERE did = ?1" 317 + ).bind(did).run().catch(() => {}); 247 318 } 248 - } catch { 249 - // best-effort — skip failures 319 + })); 320 + totalEnriched = enriched; 321 + if (enriched > 0) { 322 + console.log(JSON.stringify({ event: "enrich_profile", enriched, checked: profileRows.length })); 250 323 } 251 - })); 252 - } 253 - if (resolved > 0) { 254 - console.log(JSON.stringify({ event: "handle_resolve", resolved, checked: results.length })); 324 + } 325 + } finally { 326 + await env.KV.delete("enrich_lock").catch(() => {}); 255 327 } 328 + 329 + return { resolved: totalResolved, enriched: totalEnriched }; 256 330 } 257 331 258 332 const BSKY_GET_PROFILES_URL = ··· 302 376 const stmts: Stmt[] = []; 303 377 for (const p of profiles) { 304 378 const hide = shouldHide(p.labels) ? 1 : 0; 379 + const avatarCid = extractAvatarCid(p.avatar || ''); 305 380 stmts.push( 306 381 db.prepare( 307 - "UPDATE actors SET hidden = ?1 WHERE did = ?2 AND hidden != ?1" 308 - ).bind(hide, p.did) 382 + `UPDATE actors SET hidden = ?1, 383 + handle = COALESCE(NULLIF(?3, ''), handle), 384 + display_name = COALESCE(NULLIF(?4, ''), display_name), 385 + avatar_url = COALESCE(NULLIF(?5, ''), avatar_url) 386 + WHERE did = ?2` 387 + ).bind(hide, p.did, p.handle || '', p.displayName || '', avatarCid) 309 388 ); 310 389 } 311 390 if (stmts.length > 0) { ··· 323 402 console.log(JSON.stringify({ event: "moderation_refresh", checked, changed, cursor: lastRowid })); 324 403 } 325 404 326 - /** fire-and-forget: increment hourly search count + accumulate response time */ 405 + /** fire-and-forget: increment 5-min search count + accumulate response time */ 327 406 async function recordMetric(db: TursoDB, ms: number): Promise<void> { 328 - const hour = Math.floor(Date.now() / 3_600_000); 407 + const bucket = Math.floor(Date.now() / 300_000); 329 408 await db.prepare( 330 409 `INSERT INTO metrics (hour, searches, total_ms) 331 410 VALUES (?1, 1, ?2) ··· 333 412 searches = searches + 1, 334 413 total_ms = total_ms + ?2` 335 414 ) 336 - .bind(hour, ms) 415 + .bind(bucket, ms) 416 + .run(); 417 + } 418 + 419 + /** fire-and-forget: record actor count deltas at 5-min granularity */ 420 + async function recordActorDelta( 421 + db: TursoDB, 422 + deltas: { actors?: number; handles?: number; avatars?: number }, 423 + ): Promise<void> { 424 + const bucket = Math.floor(Date.now() / 300_000); 425 + await db.prepare( 426 + `INSERT INTO actor_deltas (bucket, actors_delta, handles_delta, avatars_delta) 427 + VALUES (?1, ?2, ?3, ?4) 428 + ON CONFLICT(bucket) DO UPDATE SET 429 + actors_delta = actors_delta + ?2, 430 + handles_delta = handles_delta + ?3, 431 + avatars_delta = avatars_delta + ?4` 432 + ) 433 + .bind(bucket, deltas.actors ?? 0, deltas.handles ?? 0, deltas.avatars ?? 0) 337 434 .run(); 338 435 } 339 436 ··· 357 454 } else { 358 455 domain = "unknown"; 359 456 } 457 + 458 + // normalize local/dev traffic 459 + if (domain === "localhost" || domain.startsWith("127.") || domain === "[::1]") { 460 + domain = "unknown"; 461 + } 360 462 await db.prepare( 361 463 `INSERT INTO traffic_sources (domain, hits) 362 464 VALUES (?1, 1) ··· 449 551 request: Request, 450 552 db: TursoDB, 451 553 env: Env, 554 + ctx: ExecutionContext, 452 555 ): Promise<Response> { 453 556 const auth = request.headers.get("Authorization"); 454 557 if (auth !== `Bearer ${env.ADMIN_SECRET}`) { ··· 502 605 ); 503 606 }); 504 607 608 + let batchResults: { results: unknown[]; meta: { changes: number } }[]; 505 609 try { 506 - await db.batch(stmts); 610 + batchResults = await db.batch(stmts); 507 611 } catch (e: any) { 508 612 console.log(JSON.stringify({ event: "ingest_error", error: e?.message, count: events.length })); 509 613 return json({ error: e?.message || "db batch failed" }, 500); 510 614 } 511 615 616 + // count newly inserted bare DIDs (changes === 1 means INSERT succeeded, not IGNORE'd) 617 + let newActors = 0; 618 + for (let i = 0; i < events.length; i++) { 619 + const e = events[i]; 620 + const isBareDID = !e.handle && !e.display_name && !e.avatar_cid && e.hidden === undefined; 621 + if (isBareDID && batchResults[i]?.meta.changes === 1) newActors++; 622 + } 623 + if (newActors > 0) { 624 + ctx.waitUntil(recordActorDelta(db, { actors: newActors })); 625 + } 626 + 512 627 if (cursor !== undefined) { 513 628 try { 514 629 await env.KV.put("jetstream_cursor", String(cursor)); ··· 517 632 } 518 633 } 519 634 635 + ctx.waitUntil( 636 + enrichActors(db, env).then(({ resolved, enriched }) => { 637 + if (resolved > 0 || enriched > 0) { 638 + return recordActorDelta(db, { handles: resolved, avatars: enriched }); 639 + } 640 + }), 641 + ); 642 + 520 643 return json({ ok: true, ingested: events.length }); 521 644 } 522 645 ··· 524 647 request: Request, 525 648 db: TursoDB, 526 649 env: Env, 650 + ctx: ExecutionContext, 527 651 ): Promise<Response> { 528 652 const auth = request.headers.get("Authorization"); 529 653 if (auth !== `Bearer ${env.ADMIN_SECRET}`) { ··· 551 675 const stmts = dids.map((did) => 552 676 db.prepare("DELETE FROM actors WHERE did = ?1").bind(did) 553 677 ); 554 - await db.batch(stmts); 678 + const batchResults = await db.batch(stmts); 679 + const actualDeletes = batchResults.reduce((s, r) => s + r.meta.changes, 0); 680 + 681 + if (actualDeletes > 0) { 682 + ctx.waitUntil(recordActorDelta(db, { actors: -actualDeletes })); 683 + } 555 684 556 685 return json({ ok: true, deleted: dids.length }); 557 686 } ··· 637 766 } 638 767 639 768 async function handleStats(db: TursoDB): Promise<Response> { 640 - const [totalRes, handlesRes, avatarsRes, hiddenRes, metricsRes, snapshotRes, trafficRes] = 769 + const [totalRes, handlesRes, avatarsRes, hiddenRes, metricsRes, snapshotRes, trafficRes, deltasRes] = 641 770 await db.batch([ 642 771 db.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE hidden = 0"), 643 772 db.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE handle != '' AND hidden = 0"), 644 773 db.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE avatar_url != '' AND hidden = 0"), 645 774 db.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE hidden != 0"), 646 775 db.prepare( 647 - "SELECT hour, searches, total_ms FROM metrics ORDER BY hour DESC LIMIT 168" 776 + "SELECT hour, searches, total_ms FROM metrics ORDER BY hour DESC LIMIT 2016" 648 777 ), 649 778 db.prepare( 650 779 "SELECT hour, total, with_handles, with_avatars FROM snapshots ORDER BY hour ASC LIMIT 2000" ··· 652 781 db.prepare( 653 782 "SELECT domain, hits FROM traffic_sources ORDER BY hits DESC LIMIT 10" 654 783 ), 784 + db.prepare( 785 + "SELECT bucket, actors_delta, handles_delta, avatars_delta FROM actor_deltas ORDER BY bucket ASC LIMIT 2016" 786 + ), 655 787 ]); 656 788 657 789 const total = (totalRes.results[0] as any)?.cnt ?? 0; ··· 663 795 searches: number; 664 796 total_ms: number; 665 797 }[]; 666 - const snapshots = (snapshotRes.results ?? []) as SnapshotPoint[]; 798 + const dbSnapshots = (snapshotRes.results ?? []) as { hour: number; total: number; with_handles: number; with_avatars: number }[]; 667 799 const trafficSources = (trafficRes.results ?? []) as { domain: string; hits: number }[]; 800 + const deltas = (deltasRes.results ?? []) as { bucket: number; actors_delta: number; handles_delta: number; avatars_delta: number }[]; 668 801 669 - // append live counts as the latest point 670 - const liveHour = Math.floor(Date.now() / 3_600_000); 671 - if (snapshots.length === 0 || snapshots[snapshots.length - 1].hour < liveHour) { 672 - snapshots.push({ hour: liveHour, total, with_handles: withHandles, with_avatars: withAvatars }); 802 + // build snapshot points with timestamps 803 + const snapshots: SnapshotPoint[] = dbSnapshots.map((s) => ({ 804 + ts: s.hour * 3_600_000, 805 + total: s.total, 806 + with_handles: s.with_handles, 807 + with_avatars: s.with_avatars, 808 + })); 809 + 810 + // stitch deltas after last snapshot for interpolated points 811 + if (snapshots.length > 0 && deltas.length > 0) { 812 + const lastSnap = snapshots[snapshots.length - 1]; 813 + const lastSnapBucket = Math.floor(lastSnap.ts / 300_000); 814 + let cumActors = 0, cumHandles = 0, cumAvatars = 0; 815 + for (const d of deltas) { 816 + if (d.bucket <= lastSnapBucket) continue; 817 + cumActors += d.actors_delta; 818 + cumHandles += d.handles_delta; 819 + cumAvatars += d.avatars_delta; 820 + snapshots.push({ 821 + ts: d.bucket * 300_000, 822 + total: lastSnap.total + cumActors, 823 + with_handles: lastSnap.with_handles + cumHandles, 824 + with_avatars: lastSnap.with_avatars + cumAvatars, 825 + }); 826 + } 827 + } 828 + 829 + // append live point 830 + const now = Date.now(); 831 + if (snapshots.length === 0 || snapshots[snapshots.length - 1].ts < now - 60_000) { 832 + snapshots.push({ ts: now, total, with_handles: withHandles, with_avatars: withAvatars }); 673 833 } 674 834 675 835 const totalSearches = rows.reduce((s, r) => s + r.searches, 0); ··· 682 842 } 683 843 684 844 interface SnapshotPoint { 685 - hour: number; 845 + ts: number; // millisecond timestamp 686 846 total: number; 687 847 with_handles: number; 688 848 with_avatars: number; ··· 712 872 .join(" "); 713 873 const sparkJson = JSON.stringify( 714 874 sorted.map((r) => ({ 715 - hour: new Date(r.hour * 3_600_000).toISOString().slice(0, 13) + ":00Z", 875 + time: new Date(r.hour * 300_000).toISOString().slice(0, 16) + "Z", 716 876 searches: r.searches, 717 877 })) 718 878 ); ··· 771 931 svg { width: 100%; height: auto; } 772 932 polyline { fill: none; stroke: #4a9; stroke-width: 1.5; } 773 933 774 - .summary { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.8rem; margin-bottom: 1.5rem; } 934 + .summary { display: grid; gap: 0.8rem; margin-bottom: 1.5rem; } 935 + .summary-2 { grid-template-columns: repeat(2, 1fr); } 936 + .summary-3 { grid-template-columns: repeat(3, 1fr); } 775 937 .metric { background: #111; border: 1px solid #222; border-radius: 6px; padding: 0.7rem 0.9rem; } 776 938 .metric .label { font-size: 0.7rem; color: #555; margin-bottom: 0.2rem; } 777 939 .metric .label[data-tip] { cursor: default; position: relative; border-bottom: 1px dotted #444; display: inline-block; } ··· 808 970 body { padding: 1.5rem 0.75rem; } 809 971 #trend { height: 160px; } 810 972 .legend { gap: 0.8rem; font-size: 0.65rem; flex-wrap: wrap; } 811 - .summary { grid-template-columns: 1fr 1fr; } 973 + .summary-3 { grid-template-columns: 1fr 1fr; } 812 974 #pie { width: 220px; height: 220px; } 813 975 } 814 976 </style> ··· 826 988 ? `<canvas id="trend"></canvas>` 827 989 : `<div style="color:#444;font-size:0.8rem;padding:2rem 0;text-align:center">collecting data — check back soon</div>`} 828 990 <div class="legend"> 829 - <span><span class="ldot" style="background:#4a9"></span> total (${d.total.toLocaleString()})</span> 830 - <span><span class="ldot" style="background:#58a6ff"></span> with handles</span> 831 - <span><span class="ldot" style="background:#bc8cff"></span> with avatars</span> 991 + ${[ 992 + { color: '#4a9', label: `total (${d.total.toLocaleString()})`, value: d.total }, 993 + { color: '#bc8cff', label: 'with avatars', value: d.snapshots[d.snapshots.length - 1]?.with_avatars ?? 0 }, 994 + { color: '#58a6ff', label: 'with handles', value: d.snapshots[d.snapshots.length - 1]?.with_handles ?? 0 }, 995 + ].sort((a, b) => b.value - a.value) 996 + .map(r => `<span><span class="ldot" style="background:${r.color}"></span> ${r.label}</span>`) 997 + .join('\n ')} 832 998 </div> 833 999 </div> 834 1000 <div class="chart-tip" id="chart-tip"></div> 835 1001 1002 + <div class="summary summary-3"> 1003 + <div class="metric"> 1004 + <div class="label" data-tip="% of indexed actors with a resolved handle (vs bare DID only)">handle coverage</div> 1005 + <div class="value">${d.handlePct}%</div> 1006 + </div> 1007 + <div class="metric"> 1008 + <div class="label" data-tip="% of indexed actors with a profile image">avatar coverage</div> 1009 + <div class="value">${d.avatarPct}%</div> 1010 + </div> 1011 + <div class="metric"> 1012 + <div class="label" data-tip="actors hidden by bluesky moderation (!hide, !takedown, spam)">hidden by moderation</div> 1013 + <div class="value">${d.hiddenCount.toLocaleString()}</div> 1014 + </div> 1015 + </div> 1016 + 836 1017 <div class="sparkline-wrap"> 837 - <h2>searches / hour (7 days)</h2> 1018 + <h2>searches / 5 min (7 days)</h2> 838 1019 ${counts.length > 1 839 1020 ? `<svg viewBox="0 0 ${sw} ${sh}" preserveAspectRatio="none" id="spark"> 840 1021 <polyline points="${sparkPoints}" /> 841 1022 </svg>` 842 1023 : `<div style="color:#444;font-size:0.8rem;padding:1rem 0;text-align:center">no data yet</div>`} 843 1024 </div> 1025 + <div class="summary summary-2"> 1026 + <div class="metric"> 1027 + <div class="label">total searches (7d)</div> 1028 + <div class="value">${d.totalSearches.toLocaleString()}</div> 1029 + </div> 1030 + <div class="metric"> 1031 + <div class="label" data-tip="average response time for uncached searches">avg latency</div> 1032 + <div class="value">${d.avgLatency.toFixed(1)} ms</div> 1033 + </div> 1034 + </div> 844 1035 845 1036 <div class="pie-wrap"> 846 1037 <h2>traffic sources</h2> ··· 854 1045 </div> 855 1046 <div class="pie-tip" id="pie-tip"></div> 856 1047 857 - <div class="summary"> 858 - <div class="metric"> 859 - <div class="label">total searches (7d)</div> 860 - <div class="value">${d.totalSearches.toLocaleString()}</div> 861 - </div> 862 - <div class="metric"> 863 - <div class="label" data-tip="average response time for uncached searches">avg latency</div> 864 - <div class="value">${d.avgLatency.toFixed(1)} ms</div> 865 - </div> 866 - <div class="metric"> 867 - <div class="label" data-tip="% of indexed actors with a resolved handle (vs bare DID only)">handle coverage</div> 868 - <div class="value">${d.handlePct}%</div> 869 - </div> 870 - <div class="metric"> 871 - <div class="label" data-tip="% of indexed actors with a profile image">avatar coverage</div> 872 - <div class="value">${d.avatarPct}%</div> 873 - </div> 874 - <div class="metric"> 875 - <div class="label" data-tip="actors hidden by bluesky moderation (!hide, !takedown, spam)">hidden by moderation</div> 876 - <div class="value">${d.hiddenCount.toLocaleString()}</div> 877 - </div> 878 - </div> 879 - 880 1048 <footer> 881 1049 <a href="/">&larr; home</a> 882 1050 </footer> ··· 958 1126 959 1127 ctx.font = '10px system-ui, sans-serif'; 960 1128 ctx.fillStyle = '#555'; ctx.textAlign = 'center'; 961 - const t0ms = snaps[0].hour * 3600000; 962 - const tNms = snaps[n - 1].hour * 3600000; 1129 + const t0ms = snaps[0].ts; 1130 + const tNms = snaps[n - 1].ts; 963 1131 const spanDays = (tNms - t0ms) / 86400000; 964 1132 965 1133 // walk midnights ··· 977 1145 ctx.beginPath(); ctx.moveTo(x, axisY); ctx.lineTo(x, axisY + 4); 978 1146 ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.stroke(); 979 1147 const label = spanDays > 14 980 - ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) 981 - : d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); 1148 + ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }) 1149 + : d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }); 982 1150 ctx.globalAlpha = 0.4; 983 1151 ctx.fillText(label, x, axisY + 15); 984 1152 ctx.globalAlpha = 1; ··· 1018 1186 drawChart(idx); 1019 1187 1020 1188 const snap = snaps[idx]; 1021 - const t = new Date(snap.hour * 3600000); 1189 + const t = new Date(snap.ts); 1022 1190 const time = t.toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; 1023 1191 chartTip.innerHTML = 1024 1192 '<div class="ct-time">' + time + '</div>' 1025 - + '<div class="ct-row"><span class="ct-dot" style="background:' + COLORS.total + '"></span> ' + fmtNum(snap.total) + ' actors</div>' 1026 - + '<div class="ct-row"><span class="ct-dot" style="background:' + COLORS.handles + '"></span> ' + fmtNum(snap.with_handles) + ' with handles</div>' 1027 - + '<div class="ct-row"><span class="ct-dot" style="background:' + COLORS.avatars + '"></span> ' + fmtNum(snap.with_avatars) + ' with avatars</div>'; 1193 + const tipRows = [ 1194 + { color: COLORS.total, value: snap.total, label: 'actors' }, 1195 + { color: COLORS.avatars, value: snap.with_avatars, label: 'with avatars' }, 1196 + { color: COLORS.handles, value: snap.with_handles, label: 'with handles' }, 1197 + ].sort((a, b) => b.value - a.value); 1198 + chartTip.innerHTML = '<div class="ct-time">' + time + '</div>' 1199 + + tipRows.map(r => '<div class="ct-row"><span class="ct-dot" style="background:' + r.color + '"></span> ' + fmtNum(r.value) + ' ' + r.label + '</div>').join(''); 1028 1200 chartTip.style.display = 'block'; 1029 1201 const tx = clientX + 14, ty = clientY - 10; 1030 1202 chartTip.style.left = (tx + chartTip.offsetWidth > window.innerWidth ? clientX - chartTip.offsetWidth - 10 : tx) + 'px'; ··· 1157 1329 if (idx >= 0) { 1158 1330 const seg = segments[idx]; 1159 1331 const pct = ((seg.hits / pieTotal) * 100).toFixed(1); 1160 - pieTip.innerHTML = '<strong>' + seg.domain + '</strong><br>' + seg.hits.toLocaleString() + ' hits (' + pct + '%)'; 1332 + pieTip.innerHTML = '<strong>' + seg.domain + '</strong><br>' + seg.hits.toLocaleString() + (seg.hits === 1 ? ' hit' : ' hits') + ' (' + pct + '%)'; 1161 1333 pieTip.style.display = 'block'; 1162 1334 const tx = clientX + 14, ty = clientY - 10; 1163 1335 pieTip.style.left = (tx + pieTip.offsetWidth > window.innerWidth ? clientX - pieTip.offsetWidth - 10 : tx) + 'px'; ··· 1184 1356 const x = e.clientX - rect.left; 1185 1357 const idx = Math.min(Math.round(x / rect.width * (sparkData.length - 1)), sparkData.length - 1); 1186 1358 if (idx >= 0 && sparkData[idx]) { 1187 - sparkTip.textContent = sparkData[idx].hour + ' — ' + sparkData[idx].searches + ' searches'; 1359 + const sc = sparkData[idx].searches; 1360 + sparkTip.textContent = sparkData[idx].time + ' — ' + sc + (sc === 1 ? ' search' : ' searches'); 1188 1361 sparkTip.style.display = 'block'; 1189 1362 sparkTip.style.left = (e.clientX + 12) + 'px'; 1190 1363 sparkTip.style.top = (e.clientY - 28) + 'px'; ··· 1589 1762 const db = tursoDb(createClient({ url: env.TURSO_URL, authToken: env.TURSO_AUTH_TOKEN })); 1590 1763 await recordSnapshot(db); 1591 1764 await refreshModeration(db, env); 1592 - await resolveHandles(db); 1765 + await enrichActors(db, env); 1593 1766 }, 1594 1767 1595 1768 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { ··· 1642 1815 } 1643 1816 1644 1817 if (pathname === "/admin/ingest" && request.method === "POST") { 1645 - return handleIngest(request, db, env); 1818 + return handleIngest(request, db, env, ctx); 1646 1819 } 1647 1820 1648 1821 if (pathname === "/admin/delete" && request.method === "POST") { 1649 - return handleDelete(request, db, env); 1822 + return handleDelete(request, db, env, ctx); 1650 1823 } 1651 1824 1652 1825 return json({ error: "not found" }, 404);