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