Add Rybbit analytics alongside Umami

- Load Rybbit script via app.html (recommended SvelteKit approach)
- Mirror all Umami custom events (First guess, Guessed correctly, Share, Copy to Clipboard, social link clicks) with rybbit.event()
- Identify logged-in users with name/email traits; anonymous users by stable UUID

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George Powell
2026-02-21 17:13:41 -05:00
parent 6554ef8f41
commit 3036264d44
5 changed files with 32 additions and 7 deletions

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<script src="https://rybbit.snail.city/api/script.js" data-site-id="9abf0e81d024" defer></script>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View File

@@ -34,6 +34,7 @@
class="inline-flex hover:opacity-80 transition-opacity" class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Bluesky" aria-label="Follow on Bluesky"
data-umami-event="Bluesky clicked" data-umami-event="Bluesky clicked"
onclick={() => (window as any).rybbit?.event("Bluesky clicked")}
> >
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" /> <img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
</a> </a>
@@ -47,6 +48,7 @@
class="inline-flex hover:opacity-80 transition-opacity" class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Twitter" aria-label="Follow on Twitter"
data-umami-event="Twitter clicked" data-umami-event="Twitter clicked"
onclick={() => (window as any).rybbit?.event("Twitter clicked")}
> >
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" /> <img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
</a> </a>
@@ -58,6 +60,7 @@
class="inline-flex hover:opacity-80 transition-opacity" class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Send email" aria-label="Send email"
data-umami-event="Email clicked" data-umami-event="Email clicked"
onclick={() => (window as any).rybbit?.event("Email clicked")}
> >
<svg <svg
class="w-8 h-8 text-gray-700" class="w-8 h-8 text-gray-700"

View File

@@ -209,7 +209,7 @@
<div class="share-buttons"> <div class="share-buttons">
{#if hasWebShare} {#if hasWebShare}
<button <button
onclick={handleShare} onclick={() => { (window as any).rybbit?.event("Share"); handleShare(); }}
data-umami-event="Share" data-umami-event="Share"
class="share-btn primary" class="share-btn primary"
> >
@@ -218,6 +218,7 @@
{:else} {:else}
<button <button
onclick={() => { onclick={() => {
(window as any).rybbit?.event("Copy to Clipboard");
copyToClipboard(); copyToClipboard();
copySuccess = true; copySuccess = true;
setTimeout(() => { setTimeout(() => {

View File

@@ -17,11 +17,18 @@ function getOrCreateAnonymousId(): string {
// Reactive store that keeps in-memory game state in sync with localStorage. // Reactive store that keeps in-memory game state in sync with localStorage.
// Accepts getter functions (rather than plain values) so Svelte's reactivity // Accepts getter functions (rather than plain values) so Svelte's reactivity
// system can track dependencies and re-run effects when they change. // system can track dependencies and re-run effects when they change.
type AuthUser = {
id: string;
firstName?: string | null;
lastName?: string | null;
email?: string | null;
};
export function createGamePersistence( export function createGamePersistence(
getDate: () => string, getDate: () => string,
getReference: () => string, getReference: () => string,
getCorrectBookId: () => string, getCorrectBookId: () => string,
getUserId: () => string | undefined, getUser: () => AuthUser | null | undefined,
) { ) {
let guesses = $state<Guess[]>([]); let guesses = $state<Guess[]>([]);
let anonymousId = $state(""); let anonymousId = $state("");
@@ -34,18 +41,27 @@ export function createGamePersistence(
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
const userId = getUserId(); const user = getUser();
// CRITICAL: If user is logged in, ALWAYS use their user ID // CRITICAL: If user is logged in, ALWAYS use their user ID
if (userId) { if (user) {
anonymousId = userId; anonymousId = user.id;
} else { } else {
anonymousId = getOrCreateAnonymousId(); anonymousId = getOrCreateAnonymousId();
} }
// Tell Umami analytics which player this is so events are grouped correctly. // Tell analytics which player this is so events are grouped correctly.
if ((window as any).umami) { if ((window as any).umami) {
(window as any).umami.identify(anonymousId); (window as any).umami.identify(anonymousId);
} }
if (user) {
const nameParts = [user.firstName, user.lastName].filter(Boolean);
(window as any).rybbit?.identify(user.id, {
...(nameParts.length ? { name: nameParts.join(' ') } : {}),
...(user.email ? { email: user.email } : {}),
});
} else {
(window as any).rybbit?.identify(anonymousId);
}
const date = getDate(); const date = getDate();
const reference = getReference(); const reference = getReference();

View File

@@ -47,7 +47,7 @@
() => dailyVerse.date, () => dailyVerse.date,
() => dailyVerse.reference, () => dailyVerse.reference,
() => correctBookId, () => correctBookId,
() => user?.id, () => user,
); );
let guessedIds = $derived( let guessedIds = $derived(
@@ -86,6 +86,7 @@
(window as any).umami (window as any).umami
) { ) {
(window as any).umami.track("First guess"); (window as any).umami.track("First guess");
(window as any).rybbit?.event("First guess");
localStorage.setItem(key, "true"); localStorage.setItem(key, "true");
} }
} }
@@ -209,6 +210,9 @@
(window as any).umami.track("Guessed correctly", { (window as any).umami.track("Guessed correctly", {
totalGuesses: persistence.guesses.length, totalGuesses: persistence.guesses.length,
}); });
(window as any).rybbit?.event("Guessed correctly", {
totalGuesses: persistence.guesses.length,
});
} }
}); });