mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-06 01:43:32 -04:00
Compare commits
2 Commits
6554ef8f41
...
bd36f29419
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd36f29419 | ||
|
|
3036264d44 |
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -119,8 +119,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if streakPercentile !== null}
|
{#if streakPercentile !== null}
|
||||||
<p class="text-sm mt-4 text-gray-700 font-triodion">
|
<p class="text-sm mt-4 text-gray-700 font-triodion">
|
||||||
Only {streakPercentile}% of players have a streak of {streak}
|
{streakPercentile <= 50 ? "Only " : ""}{streakPercentile}% of players have a streak of {streak} or greater.
|
||||||
or greater.
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -209,7 +208,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 +217,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
(window as any).rybbit?.event("Copy to Clipboard");
|
||||||
copyToClipboard();
|
copyToClipboard();
|
||||||
copySuccess = true;
|
copySuccess = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,30 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the current streak for each user
|
// Calculate the current streak for each user.
|
||||||
const streaks: number[] = [];
|
// 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) {
|
for (const [, dates] of byUser) {
|
||||||
// dates are already desc-sorted
|
// dates are already desc-sorted
|
||||||
const dateSet = new Set(dates);
|
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 streak = 0;
|
||||||
let cursor = new Date(`${localDate}T00:00:00`);
|
let cursor = new Date(`${anchor}T00:00:00`);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const dateStr = cursor.toLocaleDateString('en-CA');
|
const dateStr = cursor.toLocaleDateString('en-CA');
|
||||||
@@ -53,20 +70,34 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
cursor.setDate(cursor.getDate() - 1);
|
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 eligiblePlayers = userStats.filter((u) => u.isEligible);
|
||||||
const activeStreaks = streaks.filter((s) => s >= 1);
|
|
||||||
|
|
||||||
if (activeStreaks.length === 0) {
|
if (eligiblePlayers.length === 0) {
|
||||||
|
console.log('[streak-percentile] No eligible players found, returning 100th percentile');
|
||||||
return json({ percentile: 100 });
|
return json({ percentile: 100 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Percentage of active-streak users who have a streak >= targetStreak
|
// Percentage of eligible players who have a streak >= targetStreak
|
||||||
const atOrAbove = activeStreaks.filter((s) => s >= targetStreak).length;
|
const atOrAbove = eligiblePlayers.filter((u) => u.streak >= targetStreak).length;
|
||||||
const raw = (atOrAbove / activeStreaks.length) * 100;
|
const raw = (atOrAbove / eligiblePlayers.length) * 100;
|
||||||
const percentile = raw < 1 ? Math.round(raw * 100) / 100 : Math.round(raw);
|
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 });
|
return json({ percentile });
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user