tracks lexicons and how many times they appeared on the jetstream

feat(client): implement sorting and setting refresh rate

ptr.pet e8dfb4a1 b4c008ad

verified
+25
client/src/lib/components/BskyToggle.svelte
···
+
<script lang="ts">
+
interface Props {
+
dontShowBsky: boolean;
+
onBskyToggle: () => void;
+
}
+
+
let { dontShowBsky, onBskyToggle }: Props = $props();
+
</script>
+
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<button
+
onclick={onBskyToggle}
+
class="wsbadge !mt-0 !font-normal bg-yellow-100 hover:bg-yellow-200 border-yellow-300"
+
>
+
<input checked={dontShowBsky} type="checkbox" />
+
<span class="ml-0.5"> hide app.bsky.* </span>
+
</button>
+
+
<style lang="postcss">
+
@reference "../../app.css";
+
.wsbadge {
+
@apply text-sm font-semibold mt-1.5 px-2.5 py-0.5 rounded-full border;
+
}
+
</style>
+13 -28
client/src/lib/components/FilterControls.svelte
···
<script lang="ts">
interface Props {
filterRegex: string;
-
dontShowBsky: boolean;
onFilterChange: (value: string) => void;
-
onBskyToggle: () => void;
}
-
let { filterRegex, dontShowBsky, onFilterChange, onBskyToggle }: Props =
-
$props();
+
let { filterRegex, onFilterChange }: Props = $props();
</script>
-
<div class="flex flex-wrap items-center gap-3 mb-6">
-
<div
-
class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-blue-100 hover:bg-blue-200 border-blue-300"
-
>
-
<label for="filter-regex" class="text-blue-800 mr-1"> filter: </label>
-
<input
-
id="filter-regex"
-
value={filterRegex}
-
oninput={(e) =>
-
onFilterChange((e.target as HTMLInputElement).value)}
-
type="text"
-
placeholder="regex..."
-
class="bg-blue-50 text-blue-900 placeholder-blue-400 border border-blue-200 rounded-full px-1 outline-none focus:bg-white focus:border-blue-400 min-w-0 w-24"
-
/>
-
</div>
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
-
<button
-
onclick={onBskyToggle}
-
class="wsbadge !mt-0 !font-normal bg-yellow-100 hover:bg-yellow-200 border-yellow-300"
-
>
-
<input checked={dontShowBsky} type="checkbox" />
-
<span class="ml-0.5"> hide app.bsky.* </span>
-
</button>
+
<div
+
class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-blue-100 hover:bg-blue-200 border-blue-300"
+
>
+
<label for="filter-regex" class="text-blue-800 mr-1"> filter: </label>
+
<input
+
id="filter-regex"
+
value={filterRegex}
+
oninput={(e) => onFilterChange((e.target as HTMLInputElement).value)}
+
type="text"
+
placeholder="regex..."
+
class="bg-blue-50 text-blue-900 placeholder-blue-400 border border-blue-200 rounded-full px-1 outline-none focus:bg-white focus:border-blue-400 min-w-0 w-24"
+
/>
</div>
<style lang="postcss">
+37
client/src/lib/components/RefreshControl.svelte
···
+
<script lang="ts">
+
interface Props {
+
refreshRate: string;
+
onRefreshChange: (value: string) => void;
+
}
+
+
let { refreshRate, onRefreshChange }: Props = $props();
+
</script>
+
+
<div
+
class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-green-100 hover:bg-green-200 border-green-300"
+
>
+
<label for="refresh-rate" class="text-green-800 mr-1">refresh:</label>
+
<input
+
id="refresh-rate"
+
value={refreshRate}
+
oninput={(e) => {
+
const el = e.target as HTMLInputElement;
+
if (!el.validity.valid) el.value = el.value.replace(/\D+/g, "");
+
onRefreshChange(el.value);
+
}}
+
type="text"
+
inputmode="numeric"
+
pattern="[0-9]*"
+
min="0"
+
placeholder="real-time"
+
class="bg-green-50 text-green-900 placeholder-green-400 border border-green-200 rounded-full px-1 outline-none focus:bg-white focus:border-green-400 min-w-0 w-20"
+
/>
+
<span class="text-green-700">s</span>
+
</div>
+
+
<style lang="postcss">
+
@reference "../../app.css";
+
.wsbadge {
+
@apply text-sm font-semibold mt-1.5 px-2.5 py-0.5 rounded-full border;
+
}
+
</style>
+41
client/src/lib/components/SortControls.svelte
···
+
<script lang="ts">
+
import type { SortOption } from "$lib/types";
+
+
interface Props {
+
sortBy: SortOption;
+
onSortChange: (value: SortOption) => void;
+
}
+
+
let { sortBy, onSortChange }: Props = $props();
+
+
const sortOptions = [
+
{ value: "total" as const, label: "total count" },
+
{ value: "created" as const, label: "created count" },
+
{ value: "deleted" as const, label: "deleted count" },
+
{ value: "date" as const, label: "newest first" },
+
];
+
</script>
+
+
<div
+
class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-purple-100 hover:bg-purple-200 border-purple-300"
+
>
+
<label for="sort-by" class="text-purple-800 mr-1"> sort by: </label>
+
<select
+
id="sort-by"
+
value={sortBy}
+
onchange={(e) =>
+
onSortChange((e.target as HTMLSelectElement).value as SortOption)}
+
class="bg-purple-50 text-purple-900 border border-purple-200 rounded-full px-1 outline-none focus:bg-white focus:border-purple-400 min-w-0"
+
>
+
{#each sortOptions as option}
+
<option value={option.value}>{option.label}</option>
+
{/each}
+
</select>
+
</div>
+
+
<style lang="postcss">
+
@reference "../../app.css";
+
.wsbadge {
+
@apply text-sm font-semibold mt-1.5 px-2.5 py-0.5 rounded-full border;
+
}
+
</style>
+2
client/src/lib/types.ts
···
count: number;
deleted_count: number;
};
+
+
export type SortOption = "total" | "created" | "deleted" | "date";
+108 -13
client/src/routes/+page.svelte
···
<script lang="ts">
import { dev } from "$app/environment";
-
import type { EventRecord, NsidCount } from "$lib/types";
+
import type { EventRecord, NsidCount, SortOption } from "$lib/types";
import { onMount, onDestroy } from "svelte";
import { writable } from "svelte/store";
import { PUBLIC_API_URL } from "$env/static/public";
···
import StatusBadge from "$lib/components/StatusBadge.svelte";
import EventCard from "$lib/components/EventCard.svelte";
import FilterControls from "$lib/components/FilterControls.svelte";
+
import SortControls from "$lib/components/SortControls.svelte";
+
import BskyToggle from "$lib/components/BskyToggle.svelte";
+
import RefreshControl from "$lib/components/RefreshControl.svelte";
const events = writable(new Map<string, EventRecord>());
+
const pendingUpdates = new Map<string, EventRecord>();
let eventsList: NsidCount[] = $state([]);
+
let updateTimer: NodeJS.Timeout | null = null;
events.subscribe((value) => {
eventsList = value
.entries()
···
...event,
}))
.toArray();
-
eventsList.sort((a, b) => b.count - a.count);
});
let per_second = $state(0);
···
let error: string | null = $state(null);
let filterRegex = $state("");
let dontShowBsky = $state(false);
+
let sortBy: SortOption = $state("total");
+
let refreshRate = $state("");
+
let previousRefreshRate = "";
let websocket: WebSocket | null = null;
let isStreamOpen = $state(false);
···
if (jsonData.per_second > 0) {
per_second = jsonData.per_second;
}
-
events.update((map) => {
+
+
// Store updates in pending map if refresh rate is set
+
if (refreshRate) {
for (const [nsid, event] of Object.entries(jsonData.events)) {
-
map.set(nsid, event as EventRecord);
+
pendingUpdates.set(nsid, event as EventRecord);
}
-
return map;
-
});
+
} else {
+
// Apply updates immediately if no refresh rate
+
events.update((map) => {
+
for (const [nsid, event] of Object.entries(
+
jsonData.events,
+
)) {
+
map.set(nsid, event as EventRecord);
+
}
+
return map;
+
});
+
}
};
websocket.onerror = (error) => {
console.error("ws error:", error);
···
}
};
+
// Set refresh rate when sort mode changes
+
$effect(() => {
+
if (sortBy === "date" && !refreshRate) {
+
// Only set to 2 if currently empty (real-time)
+
previousRefreshRate = "";
+
refreshRate = "2";
+
} else if (refreshRate === "2" && sortBy !== "date") {
+
// Only restore to empty if we auto-set it and switching away from date
+
refreshRate = previousRefreshRate;
+
previousRefreshRate = "";
+
}
+
});
+
+
// Update the refresh timer when refresh rate changes
+
$effect(() => {
+
if (updateTimer) {
+
clearInterval(updateTimer);
+
updateTimer = null;
+
}
+
+
if (refreshRate) {
+
const rate = parseInt(refreshRate, 10) * 1000; // Convert to milliseconds
+
if (!isNaN(rate) && rate > 0) {
+
updateTimer = setInterval(() => {
+
if (pendingUpdates.size > 0) {
+
events.update((map) => {
+
for (const [nsid, event] of pendingUpdates) {
+
map.set(nsid, event);
+
}
+
pendingUpdates.clear();
+
return map;
+
});
+
}
+
}, rate);
+
}
+
}
+
});
+
onMount(() => {
loadData();
connectToStream();
});
onDestroy(() => {
+
// Clear refresh timer
+
if (updateTimer) {
+
clearInterval(updateTimer);
+
updateTimer = null;
+
}
// Close WebSocket connection
if (websocket) {
websocket.close();
}
});
+
+
const sortEvents = (events: NsidCount[], sortBy: SortOption) => {
+
const sorted = [...events];
+
switch (sortBy) {
+
case "total":
+
sorted.sort(
+
(a, b) =>
+
b.count + b.deleted_count - (a.count + a.deleted_count),
+
);
+
break;
+
case "created":
+
sorted.sort((a, b) => b.count - a.count);
+
break;
+
case "deleted":
+
sorted.sort((a, b) => b.deleted_count - a.deleted_count);
+
break;
+
case "date":
+
sorted.sort((a, b) => b.last_seen - a.last_seen);
+
break;
+
}
+
return sorted;
+
};
const filterEvents = (events: NsidCount[]) => {
let filtered = events;
···
<h2 class="text-2xl font-bold text-gray-900">seen lexicons</h2>
<StatusBadge status={websocketStatus} />
</div>
-
<FilterControls
-
{filterRegex}
-
{dontShowBsky}
-
onFilterChange={(value) => (filterRegex = value)}
-
onBskyToggle={() => (dontShowBsky = !dontShowBsky)}
-
/>
+
<div class="flex flex-wrap items-center gap-1.5 mb-6">
+
<FilterControls
+
{filterRegex}
+
onFilterChange={(value) => (filterRegex = value)}
+
/>
+
<SortControls
+
{sortBy}
+
onSortChange={(value: SortOption) => (sortBy = value)}
+
/>
+
<BskyToggle
+
{dontShowBsky}
+
onBskyToggle={() => (dontShowBsky = !dontShowBsky)}
+
/>
+
<RefreshControl
+
{refreshRate}
+
onRefreshChange={(value) => (refreshRate = value)}
+
/>
+
</div>
<div
class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4"
>
-
{#each filterEvents(eventsList) as event, index (event.nsid)}
+
{#each sortEvents(filterEvents(eventsList), sortBy) as event, index (event.nsid)}
<EventCard {event} {index} />
{/each}
</div>