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>