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.

add traffic sources pie chart to stats page

track search API traffic by Origin header domain, display as an
animated donut chart with hover tooltips and a flex-wrap legend.

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

+182 -3
+5
schema.sql
··· 45 45 with_handles INTEGER NOT NULL DEFAULT 0, 46 46 with_avatars INTEGER NOT NULL DEFAULT 0 47 47 ); 48 + 49 + CREATE TABLE IF NOT EXISTS traffic_sources ( 50 + domain TEXT PRIMARY KEY, 51 + hits INTEGER NOT NULL DEFAULT 0 52 + );
+177 -3
src/index.ts
··· 333 333 .run(); 334 334 } 335 335 336 + /** fire-and-forget: increment cumulative hit counter per origin domain */ 337 + async function recordTrafficSource(db: TursoDB, request: Request): Promise<void> { 338 + const origin = request.headers.get("Origin"); 339 + const domain = origin ? new URL(origin).hostname : "unknown"; 340 + await db.prepare( 341 + `INSERT INTO traffic_sources (domain, hits) 342 + VALUES (?1, 1) 343 + ON CONFLICT(domain) DO UPDATE SET hits = hits + 1` 344 + ) 345 + .bind(domain) 346 + .run(); 347 + } 348 + 336 349 async function handleSearch( 337 350 request: Request, 338 351 db: TursoDB, ··· 397 410 } 398 411 // --- end backfill --- 399 412 400 - ctx.waitUntil(recordMetric(db, Date.now() - t0)); 413 + ctx.waitUntil(Promise.all([ 414 + recordMetric(db, Date.now() - t0), 415 + recordTrafficSource(db, request), 416 + ])); 401 417 402 418 const response = json({ actors }); 403 419 ··· 594 610 } 595 611 596 612 async function handleStats(db: TursoDB): Promise<Response> { 597 - const [totalRes, handlesRes, avatarsRes, hiddenRes, metricsRes, snapshotRes] = 613 + const [totalRes, handlesRes, avatarsRes, hiddenRes, metricsRes, snapshotRes, trafficRes] = 598 614 await db.batch([ 599 615 db.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE hidden = 0"), 600 616 db.prepare("SELECT COUNT(*) AS cnt FROM actors WHERE handle != '' AND hidden = 0"), ··· 605 621 ), 606 622 db.prepare( 607 623 "SELECT hour, total, with_handles, with_avatars FROM snapshots ORDER BY hour ASC LIMIT 2000" 624 + ), 625 + db.prepare( 626 + "SELECT domain, hits FROM traffic_sources ORDER BY hits DESC LIMIT 10" 608 627 ), 609 628 ]); 610 629 ··· 618 637 total_ms: number; 619 638 }[]; 620 639 const snapshots = (snapshotRes.results ?? []) as SnapshotPoint[]; 640 + const trafficSources = (trafficRes.results ?? []) as { domain: string; hits: number }[]; 621 641 622 642 // append live counts as the latest point 623 643 const liveHour = Math.floor(Date.now() / 3_600_000); ··· 631 651 const handlePct = total > 0 ? ((withHandles / total) * 100).toFixed(1) : "0"; 632 652 const avatarPct = total > 0 ? ((withAvatars / total) * 100).toFixed(1) : "0"; 633 653 634 - return html(statsPage({ total, hiddenCount, rows, totalSearches, avgLatency, handlePct, avatarPct, snapshots })); 654 + return html(statsPage({ total, hiddenCount, rows, totalSearches, avgLatency, handlePct, avatarPct, snapshots, trafficSources })); 635 655 } 636 656 637 657 interface SnapshotPoint { ··· 650 670 handlePct: string; 651 671 avatarPct: string; 652 672 snapshots: SnapshotPoint[]; 673 + trafficSources: { domain: string; hits: number }[]; 653 674 } 654 675 655 676 function statsPage(d: StatsData): string { ··· 670 691 ); 671 692 672 693 const snapshotJson = JSON.stringify(d.snapshots); 694 + const trafficJson = JSON.stringify(d.trafficSources); 695 + 696 + const PIE_COLORS = ['#4a9', '#58a6ff', '#bc8cff', '#e5a04b', '#e06c75', '#56b6c2', '#c678dd', '#98c379', '#d19a66']; 697 + const trafficTotal = d.trafficSources.reduce((s, r) => s + r.hits, 0); 698 + const pieLegendHtml = d.trafficSources.map((r, i) => { 699 + const color = r.domain === 'unknown' ? '#555' : PIE_COLORS[i % PIE_COLORS.length]; 700 + const pct = trafficTotal > 0 ? ((r.hits / trafficTotal) * 100).toFixed(1) : '0'; 701 + return `<span><span class="ldot" style="background:${color}"></span>${r.domain} (${pct}%)</span>`; 702 + }).join(''); 673 703 674 704 return `<!doctype html> 675 705 <html> ··· 724 754 border-radius: 4px; padding: 0.4rem 0.6rem; font-size: 0.75rem; color: #ccc; 725 755 pointer-events: none; z-index: 50; } 726 756 757 + .pie-wrap { background: #111; border: 1px solid #222; border-radius: 6px; 758 + padding: 0.9rem; margin-bottom: 1.5rem; } 759 + .pie-wrap h2 { font-size: 0.75rem; font-weight: 400; color: #555; margin-bottom: 0.6rem; } 760 + #pie { display: block; margin: 0 auto; width: 280px; height: 280px; touch-action: none; } 761 + .pie-legend { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; margin-top: 0.7rem; 762 + font-size: 0.7rem; color: #888; justify-content: center; } 763 + .pie-legend span { display: flex; align-items: center; gap: 0.3rem; } 764 + .pie-tip { display: none; position: fixed; background: rgba(17, 17, 17, 0.95); 765 + border: 1px solid #333; border-radius: 6px; padding: 0.5rem 0.7rem; 766 + font-size: 0.72rem; color: #ccc; pointer-events: none; z-index: 50; 767 + white-space: nowrap; box-shadow: 0 4px 12px rgba(0,0,0,0.5); } 768 + 727 769 footer { padding-top: 1.5rem; border-top: 1px solid #1a1a1a; font-size: 0.7rem; 728 770 color: #444; display: flex; justify-content: center; gap: 0.4rem; } 729 771 footer a { color: #555; text-decoration: none; } ··· 734 776 #trend { height: 160px; } 735 777 .legend { gap: 0.8rem; font-size: 0.65rem; flex-wrap: wrap; } 736 778 .summary { grid-template-columns: 1fr 1fr; } 779 + #pie { width: 220px; height: 220px; } 737 780 } 738 781 </style> 739 782 </head> ··· 765 808 </svg>` 766 809 : `<div style="color:#444;font-size:0.8rem;padding:1rem 0;text-align:center">no data yet</div>`} 767 810 </div> 811 + 812 + <div class="pie-wrap"> 813 + <h2>traffic sources</h2> 814 + ${d.trafficSources.length > 0 815 + ? `<canvas id="pie"></canvas> 816 + <div class="pie-legend">${pieLegendHtml}</div>` 817 + : `<div style="color:#444;font-size:0.8rem;padding:2rem 0;text-align:center">collecting data — check back soon</div>`} 818 + </div> 819 + <div class="pie-tip" id="pie-tip"></div> 768 820 769 821 <div class="summary"> 770 822 <div class="metric"> ··· 961 1013 chartTip.style.display = 'none'; 962 1014 drawChart(null); 963 1015 }); 1016 + } 1017 + 1018 + /* --- traffic sources pie chart --- */ 1019 + const PIE_COLORS = ['#4a9', '#58a6ff', '#bc8cff', '#e5a04b', '#e06c75', '#56b6c2', '#c678dd', '#98c379', '#d19a66']; 1020 + const traffic = ${trafficJson}; 1021 + const pieCanvas = document.getElementById('pie'); 1022 + const pieTip = document.getElementById('pie-tip'); 1023 + 1024 + if (pieCanvas && traffic.length > 0) { 1025 + const pieTotal = traffic.reduce((s, r) => s + r.hits, 0); 1026 + 1027 + function getColor(i) { 1028 + return traffic[i].domain === 'unknown' ? '#555' : PIE_COLORS[i % PIE_COLORS.length]; 1029 + } 1030 + 1031 + // build segments: { start, end, color, domain, hits } 1032 + const segments = []; 1033 + let angle = -Math.PI / 2; 1034 + for (let i = 0; i < traffic.length; i++) { 1035 + const sweep = (traffic[i].hits / pieTotal) * Math.PI * 2; 1036 + segments.push({ start: angle, end: angle + sweep, color: getColor(i), domain: traffic[i].domain, hits: traffic[i].hits }); 1037 + angle += sweep; 1038 + } 1039 + 1040 + let animProgress = 0; 1041 + let hoverIdx = -1; 1042 + const animStart = performance.now(); 1043 + const animDur = 800; 1044 + 1045 + function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } 1046 + 1047 + function drawPie() { 1048 + const pctx = pieCanvas.getContext('2d'); 1049 + const dpr = window.devicePixelRatio || 1; 1050 + const W = pieCanvas.clientWidth, H = pieCanvas.clientHeight; 1051 + pieCanvas.width = W * dpr; pieCanvas.height = H * dpr; 1052 + pctx.scale(dpr, dpr); 1053 + pctx.clearRect(0, 0, W, H); 1054 + 1055 + const cx = W / 2, cy = H / 2; 1056 + const outerR = Math.min(W, H) / 2 - 8; 1057 + const innerR = outerR * 0.6; 1058 + const maxAngle = -Math.PI / 2 + animProgress * Math.PI * 2; 1059 + 1060 + for (let i = 0; i < segments.length; i++) { 1061 + const seg = segments[i]; 1062 + const drawStart = seg.start; 1063 + const drawEnd = Math.min(seg.end, maxAngle); 1064 + if (drawEnd <= drawStart) continue; 1065 + 1066 + const grow = hoverIdx === i ? 4 : 0; 1067 + const alpha = hoverIdx >= 0 && hoverIdx !== i ? 0.4 : 1; 1068 + 1069 + pctx.globalAlpha = alpha; 1070 + pctx.beginPath(); 1071 + pctx.arc(cx, cy, outerR + grow, drawStart, drawEnd); 1072 + pctx.arc(cx, cy, innerR - (grow ? 2 : 0), drawEnd, drawStart, true); 1073 + pctx.closePath(); 1074 + pctx.fillStyle = seg.color; 1075 + pctx.fill(); 1076 + } 1077 + 1078 + // center text 1079 + pctx.globalAlpha = 1; 1080 + pctx.fillStyle = '#888'; 1081 + pctx.font = '600 ' + (outerR * 0.22) + 'px system-ui, sans-serif'; 1082 + pctx.textAlign = 'center'; 1083 + pctx.textBaseline = 'middle'; 1084 + pctx.fillText(fmtNum(pieTotal), cx, cy - outerR * 0.06); 1085 + pctx.font = (outerR * 0.11) + 'px system-ui, sans-serif'; 1086 + pctx.fillStyle = '#555'; 1087 + pctx.fillText('hits', cx, cy + outerR * 0.14); 1088 + } 1089 + 1090 + function animLoop(now) { 1091 + const t = Math.min((now - animStart) / animDur, 1); 1092 + animProgress = easeOutCubic(t); 1093 + drawPie(); 1094 + if (t < 1) requestAnimationFrame(animLoop); 1095 + } 1096 + requestAnimationFrame(animLoop); 1097 + 1098 + function hitTest(clientX, clientY) { 1099 + const rect = pieCanvas.getBoundingClientRect(); 1100 + const mx = clientX - rect.left - rect.width / 2; 1101 + const my = clientY - rect.top - rect.height / 2; 1102 + const dist = Math.sqrt(mx * mx + my * my); 1103 + const outerR = Math.min(rect.width, rect.height) / 2 - 8; 1104 + const innerR = outerR * 0.6; 1105 + if (dist < innerR || dist > outerR + 8) return -1; 1106 + let a = Math.atan2(my, mx); 1107 + if (a < -Math.PI / 2) a += Math.PI * 2; 1108 + for (let i = 0; i < segments.length; i++) { 1109 + if (a >= segments[i].start && a < segments[i].end) return i; 1110 + } 1111 + return -1; 1112 + } 1113 + 1114 + function pieHover(clientX, clientY) { 1115 + const idx = hitTest(clientX, clientY); 1116 + if (idx !== hoverIdx) { 1117 + hoverIdx = idx; 1118 + drawPie(); 1119 + } 1120 + if (idx >= 0) { 1121 + const seg = segments[idx]; 1122 + const pct = ((seg.hits / pieTotal) * 100).toFixed(1); 1123 + pieTip.innerHTML = '<strong>' + seg.domain + '</strong><br>' + seg.hits.toLocaleString() + ' hits (' + pct + '%)'; 1124 + pieTip.style.display = 'block'; 1125 + const tx = clientX + 14, ty = clientY - 10; 1126 + pieTip.style.left = (tx + pieTip.offsetWidth > window.innerWidth ? clientX - pieTip.offsetWidth - 10 : tx) + 'px'; 1127 + pieTip.style.top = ty + 'px'; 1128 + } else { 1129 + pieTip.style.display = 'none'; 1130 + } 1131 + } 1132 + 1133 + pieCanvas.addEventListener('mousemove', e => pieHover(e.clientX, e.clientY)); 1134 + pieCanvas.addEventListener('mouseleave', () => { hoverIdx = -1; drawPie(); pieTip.style.display = 'none'; }); 1135 + pieCanvas.addEventListener('touchmove', e => { e.preventDefault(); const t = e.touches[0]; pieHover(t.clientX, t.clientY); }); 1136 + pieCanvas.addEventListener('touchend', () => { hoverIdx = -1; drawPie(); pieTip.style.display = 'none'; }); 1137 + window.addEventListener('resize', () => drawPie()); 964 1138 } 965 1139 966 1140 /* --- search sparkline --- */