tracks lexicons and how many times they appeared on the jetstream
1<script lang="ts"> 2 import { dev } from "$app/environment"; 3 import type { EventRecord, NsidCount, SortOption } from "$lib/types"; 4 import { onMount, onDestroy } from "svelte"; 5 import { writable } from "svelte/store"; 6 import { PUBLIC_API_URL } from "$env/static/public"; 7 import { fetchEvents } from "$lib/api"; 8 import { createRegexFilter } from "$lib/filter"; 9 import StatsCard from "$lib/components/StatsCard.svelte"; 10 import StatusBadge from "$lib/components/StatusBadge.svelte"; 11 import EventCard from "$lib/components/EventCard.svelte"; 12 import FilterControls from "$lib/components/FilterControls.svelte"; 13 import SortControls from "$lib/components/SortControls.svelte"; 14 import BskyToggle from "$lib/components/BskyToggle.svelte"; 15 import RefreshControl from "$lib/components/RefreshControl.svelte"; 16 17 const events = writable(new Map<string, EventRecord>()); 18 const pendingUpdates = new Map<string, EventRecord>(); 19 let eventsList: NsidCount[] = $state([]); 20 let updateTimer: NodeJS.Timeout | null = null; 21 events.subscribe((value) => { 22 eventsList = value 23 .entries() 24 .map(([nsid, event]) => ({ 25 nsid, 26 ...event, 27 })) 28 .toArray(); 29 }); 30 let per_second = $state(0); 31 32 let all: EventRecord = $derived( 33 eventsList.reduce( 34 (acc, event) => { 35 return { 36 last_seen: 37 acc.last_seen > event.last_seen 38 ? acc.last_seen 39 : event.last_seen, 40 count: acc.count + event.count, 41 deleted_count: acc.deleted_count + event.deleted_count, 42 }; 43 }, 44 { 45 last_seen: 0, 46 count: 0, 47 deleted_count: 0, 48 }, 49 ), 50 ); 51 let error: string | null = $state(null); 52 let filterRegex = $state(""); 53 let dontShowBsky = $state(false); 54 let sortBy: SortOption = $state("total"); 55 let refreshRate = $state(""); 56 let previousRefreshRate = $state(""); 57 58 let websocket: WebSocket | null = null; 59 let isStreamOpen = $state(false); 60 let websocketStatus = $state< 61 "connecting" | "connected" | "disconnected" | "error" 62 >("disconnected"); 63 const connectToStream = async () => { 64 if (isStreamOpen) return; 65 websocketStatus = "connecting"; 66 websocket = new WebSocket( 67 `${dev ? "ws" : "wss"}://${PUBLIC_API_URL}/stream_events`, 68 ); 69 websocket.binaryType = "arraybuffer"; 70 websocket.onopen = () => { 71 console.log("ws opened"); 72 isStreamOpen = true; 73 websocketStatus = "connected"; 74 }; 75 websocket.onmessage = async (event) => { 76 const jsonData = JSON.parse(event.data); 77 78 if (jsonData.per_second > 0) { 79 per_second = jsonData.per_second; 80 } 81 82 // Store updates in pending map if refresh rate is set 83 if (refreshRate) { 84 for (const [nsid, event] of Object.entries(jsonData.events)) { 85 pendingUpdates.set(nsid, event as EventRecord); 86 } 87 } else { 88 // Apply updates immediately if no refresh rate 89 events.update((map) => { 90 for (const [nsid, event] of Object.entries( 91 jsonData.events, 92 )) { 93 map.set(nsid, event as EventRecord); 94 } 95 return map; 96 }); 97 } 98 }; 99 websocket.onerror = (error) => { 100 console.error("ws error:", error); 101 websocketStatus = "error"; 102 }; 103 websocket.onclose = () => { 104 console.log("ws closed"); 105 isStreamOpen = false; 106 websocketStatus = "disconnected"; 107 }; 108 }; 109 110 const loadData = async () => { 111 try { 112 error = null; 113 const data = await fetchEvents(); 114 per_second = data.per_second; 115 events.update((map) => { 116 for (const [nsid, event] of Object.entries(data.events)) { 117 map.set(nsid, event); 118 } 119 return map; 120 }); 121 } catch (err) { 122 error = 123 err instanceof Error 124 ? err.message 125 : "an unknown error occurred"; 126 console.error("error loading data:", err); 127 } 128 }; 129 130 // Set refresh rate when sort mode changes 131 $effect(() => { 132 if (sortBy === "date" && refreshRate === "") { 133 // Only set to 2 if we're in real-time mode 134 refreshRate = "2"; 135 } else if ( 136 sortBy !== "date" && 137 refreshRate === "2" && 138 previousRefreshRate === "2" 139 ) { 140 // Return to real-time mode if we auto-set the refresh rate 141 refreshRate = ""; 142 } 143 }); 144 145 // Update the refresh timer when refresh rate changes 146 $effect(() => { 147 if (updateTimer) { 148 clearInterval(updateTimer); 149 updateTimer = null; 150 } 151 152 if (refreshRate) { 153 const rate = parseInt(refreshRate, 10) * 1000; // Convert to milliseconds 154 if (!isNaN(rate) && rate > 0) { 155 updateTimer = setInterval(() => { 156 if (pendingUpdates.size > 0) { 157 events.update((map) => { 158 for (const [nsid, event] of pendingUpdates) { 159 map.set(nsid, event); 160 } 161 pendingUpdates.clear(); 162 return map; 163 }); 164 } 165 }, rate); 166 } 167 } 168 }); 169 170 onMount(() => { 171 loadData(); 172 connectToStream(); 173 }); 174 175 onDestroy(() => { 176 // Clear refresh timer 177 if (updateTimer) { 178 clearInterval(updateTimer); 179 updateTimer = null; 180 } 181 // Close WebSocket connection 182 if (websocket) { 183 websocket.close(); 184 } 185 }); 186 187 const sortEvents = (events: NsidCount[], sortBy: SortOption) => { 188 const sorted = [...events]; 189 switch (sortBy) { 190 case "total": 191 sorted.sort( 192 (a, b) => 193 b.count + b.deleted_count - (a.count + a.deleted_count), 194 ); 195 break; 196 case "created": 197 sorted.sort((a, b) => b.count - a.count); 198 break; 199 case "deleted": 200 sorted.sort((a, b) => b.deleted_count - a.deleted_count); 201 break; 202 case "date": 203 sorted.sort((a, b) => b.last_seen - a.last_seen); 204 break; 205 } 206 return sorted; 207 }; 208 209 const filterEvents = (events: NsidCount[]) => { 210 let filtered = events; 211 212 // Apply regex filter 213 if (filterRegex) { 214 const regex = createRegexFilter(filterRegex); 215 if (regex) { 216 filtered = filtered.filter((e) => regex.test(e.nsid)); 217 } 218 } 219 220 // Apply app.bsky filter 221 if (dontShowBsky) { 222 filtered = filtered.filter((e) => !e.nsid.startsWith("app.bsky.")); 223 } 224 225 return filtered; 226 }; 227</script> 228 229<svelte:head> 230 <title>lexicon tracker</title> 231 <meta 232 name="description" 233 content="tracks bluesky jetstream events by collection" 234 /> 235</svelte:head> 236 237<header class="border-gray-300 border-b mb-4 pb-2"> 238 <div 239 class="px-2 md:ml-[19vw] mx-auto flex flex-wrap items-center text-center" 240 > 241 <h1 class="text-4xl font-bold mr-4 text-gray-900">lexicon tracker</h1> 242 <p class="text-lg mt-1 text-gray-600"> 243 tracks lexicons seen on the jetstream 244 </p> 245 </div> 246</header> 247<div class="md:max-w-[61vw] mx-auto p-2"> 248 <div 249 class="min-w-fit grid grid-cols-2 xl:grid-cols-4 gap-2 2xl:gap-6 2xl:mx-16 mb-8" 250 > 251 <StatsCard 252 title="total creation" 253 value={all.count} 254 colorScheme="green" 255 /> 256 <StatsCard 257 title="total deletion" 258 value={all.deleted_count} 259 colorScheme="red" 260 /> 261 <StatsCard 262 title="per second" 263 value={per_second} 264 colorScheme="turqoise" 265 /> 266 <StatsCard 267 title="unique collections" 268 value={eventsList.length} 269 colorScheme="orange" 270 /> 271 </div> 272 273 {#if error} 274 <div 275 class="bg-red-100 border border-red-300 text-red-700 px-4 py-3 rounded-lg mb-6" 276 > 277 <p>Error: {error}</p> 278 </div> 279 {/if} 280 281 {#if eventsList.length > 0} 282 <div class="mb-8"> 283 <div class="flex flex-wrap items-center gap-3 mb-3"> 284 <h2 class="text-2xl font-bold text-gray-900">seen lexicons</h2> 285 <StatusBadge status={websocketStatus} /> 286 </div> 287 <div class="flex flex-wrap items-center gap-1.5 mb-6"> 288 <FilterControls 289 {filterRegex} 290 onFilterChange={(value) => (filterRegex = value)} 291 /> 292 <SortControls 293 {sortBy} 294 onSortChange={(value: SortOption) => (sortBy = value)} 295 /> 296 <BskyToggle 297 {dontShowBsky} 298 onBskyToggle={() => (dontShowBsky = !dontShowBsky)} 299 /> 300 <RefreshControl 301 {refreshRate} 302 onRefreshChange={(value) => { 303 previousRefreshRate = refreshRate; 304 refreshRate = value; 305 }} 306 /> 307 </div> 308 <div 309 class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4" 310 > 311 {#each sortEvents(filterEvents(eventsList), sortBy) as event, index (event.nsid)} 312 <EventCard {event} {index} /> 313 {/each} 314 </div> 315 </div> 316 {:else} 317 <div class="text-center py-12 bg-gray-50 rounded-lg"> 318 <div class="text-gray-400 text-4xl mb-4">📊</div> 319 <p class="text-gray-600">no events tracked yet. try refreshing?</p> 320 </div> 321 {/if} 322</div> 323 324<footer class="py-2 border-t border-gray-200 text-center"> 325 <p class="text-gray-600 text-sm"> 326 source code <a 327 href="https://tangled.sh/@poor.dog/nsid-tracker" 328 target="_blank" 329 rel="noopener noreferrer" 330 class="text-blue-600 hover:text-blue-800 underline" 331 >@poor.dog/nsid-tracker</a 332 > 333 </p> 334</footer>