Compare commits

...

2 Commits

Author SHA1 Message Date
George Powell
bd36f29419 Updated streak-percentile to count all players from the last 30 days (or
all active streaks if players have a greater than 30 day streak)
2026-02-22 00:25:08 -05:00
George Powell
3036264d44 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>
2026-02-21 17:13:41 -05:00
6 changed files with 74 additions and 19 deletions

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<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%
</head>
<body data-sveltekit-preload-data="hover">

View File

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

View File

@@ -119,8 +119,7 @@
{/if}
{#if streakPercentile !== null}
<p class="text-sm mt-4 text-gray-700 font-triodion">
Only {streakPercentile}% of players have a streak of {streak}
or greater.
{streakPercentile <= 50 ? "Only " : ""}{streakPercentile}% of players have a streak of {streak} or greater.
</p>
{/if}
{/if}
@@ -209,7 +208,7 @@
<div class="share-buttons">
{#if hasWebShare}
<button
onclick={handleShare}
onclick={() => { (window as any).rybbit?.event("Share"); handleShare(); }}
data-umami-event="Share"
class="share-btn primary"
>
@@ -218,6 +217,7 @@
{:else}
<button
onclick={() => {
(window as any).rybbit?.event("Copy to Clipboard");
copyToClipboard();
copySuccess = true;
setTimeout(() => {

View File

@@ -17,11 +17,18 @@ function getOrCreateAnonymousId(): string {
// Reactive store that keeps in-memory game state in sync with localStorage.
// Accepts getter functions (rather than plain values) so Svelte's reactivity
// 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(
getDate: () => string,
getReference: () => string,
getCorrectBookId: () => string,
getUserId: () => string | undefined,
getUser: () => AuthUser | null | undefined,
) {
let guesses = $state<Guess[]>([]);
let anonymousId = $state("");
@@ -34,18 +41,27 @@ export function createGamePersistence(
$effect(() => {
if (!browser) return;
const userId = getUserId();
const user = getUser();
// CRITICAL: If user is logged in, ALWAYS use their user ID
if (userId) {
anonymousId = userId;
if (user) {
anonymousId = user.id;
} else {
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) {
(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 reference = getReference();

View File

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

View File

@@ -38,13 +38,30 @@ export const GET: RequestHandler = async ({ url }) => {
}
}
// Calculate the current streak for each user
const streaks: number[] = [];
// Calculate the current streak for each user.
// Start from today; if the user hasn't played today yet, try yesterday so
// that streaks aren't zeroed out mid-day before the player has had a chance
// to complete today's puzzle.
const yesterday = new Date(`${localDate}T00:00:00`);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toLocaleDateString('en-CA');
const thirtyDaysAgo = new Date(`${localDate}T00:00:00`);
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const thirtyDaysAgoStr = thirtyDaysAgo.toLocaleDateString('en-CA');
// For each user, compute their current streak and whether they've played
// within the last 30 days. "Eligible players" = active streak OR recent play.
const userStats: { streak: number; isEligible: boolean }[] = [];
for (const [, dates] of byUser) {
// dates are already desc-sorted
const dateSet = new Set(dates);
// Pick the most recent anchor: today if played, otherwise yesterday
const anchor = dateSet.has(localDate) ? localDate : yesterdayStr;
let streak = 0;
let cursor = new Date(`${localDate}T00:00:00`);
let cursor = new Date(`${anchor}T00:00:00`);
while (true) {
const dateStr = cursor.toLocaleDateString('en-CA');
@@ -53,20 +70,34 @@ export const GET: RequestHandler = async ({ url }) => {
cursor.setDate(cursor.getDate() - 1);
}
streaks.push(streak);
const hasRecentPlay = dates.some((d) => d >= thirtyDaysAgoStr);
userStats.push({ streak, isEligible: streak >= 1 || hasRecentPlay });
}
// Only count users who have an active streak (streak >= 1)
const activeStreaks = streaks.filter((s) => s >= 1);
const eligiblePlayers = userStats.filter((u) => u.isEligible);
if (activeStreaks.length === 0) {
if (eligiblePlayers.length === 0) {
console.log('[streak-percentile] No eligible players found, returning 100th percentile');
return json({ percentile: 100 });
}
// Percentage of active-streak users who have a streak >= targetStreak
const atOrAbove = activeStreaks.filter((s) => s >= targetStreak).length;
const raw = (atOrAbove / activeStreaks.length) * 100;
// Percentage of eligible players who have a streak >= targetStreak
const atOrAbove = eligiblePlayers.filter((u) => u.streak >= targetStreak).length;
const raw = (atOrAbove / eligiblePlayers.length) * 100;
const percentile = raw < 1 ? Math.round(raw * 100) / 100 : Math.round(raw);
console.log('[streak-percentile]', {
localDate,
targetStreak,
totalUsers: byUser.size,
totalRows: rows.length,
eligiblePlayers: eligiblePlayers.length,
activeStreaks: userStats.filter((u) => u.streak >= 1).length,
recentPlayers: userStats.filter((u) => u.isEligible).length,
atOrAbove,
raw,
percentile,
});
return json({ percentile });
};