mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
- 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>
187 lines
6.3 KiB
TypeScript
187 lines
6.3 KiB
TypeScript
import { browser } from "$app/environment";
|
|
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
|
|
|
|
// Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet.
|
|
// Used to attribute stats to a player who hasn't signed in.
|
|
function getOrCreateAnonymousId(): string {
|
|
if (!browser) return "";
|
|
const key = "bibdle-anonymous-id";
|
|
let id = localStorage.getItem(key);
|
|
if (!id) {
|
|
id = generateUUID();
|
|
localStorage.setItem(key, id);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
// 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,
|
|
getUser: () => AuthUser | null | undefined,
|
|
) {
|
|
let guesses = $state<Guess[]>([]);
|
|
let anonymousId = $state("");
|
|
let statsSubmitted = $state(false);
|
|
let chapterGuessCompleted = $state(false);
|
|
let chapterCorrect = $state(false);
|
|
|
|
// On mount (and if the user logs in/out), resolve the player's identity and
|
|
// restore per-day flags from localStorage.
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
|
|
const user = getUser();
|
|
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
|
if (user) {
|
|
anonymousId = user.id;
|
|
} else {
|
|
anonymousId = getOrCreateAnonymousId();
|
|
}
|
|
|
|
// 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();
|
|
|
|
// Restore whether today's completion was already submitted to the server.
|
|
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
|
|
|
|
// Restore the chapter bonus guess result. The stored value includes the
|
|
// chapter the player selected, so we can re-derive whether it was correct.
|
|
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
|
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
|
if (chapterGuessCompleted) {
|
|
const saved = localStorage.getItem(chapterGuessKey);
|
|
if (saved) {
|
|
const data = JSON.parse(saved);
|
|
const match = reference.match(/\s(\d+):/);
|
|
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
|
chapterCorrect = data.selectedChapter === correctChapter;
|
|
}
|
|
}
|
|
});
|
|
|
|
// On mount (and if the date or correct answer changes), load today's guesses
|
|
// from localStorage and reconstruct them as typed Guess objects by re-evaluating
|
|
// each stored book ID against the correct answer.
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
|
|
const date = getDate();
|
|
const correctBookId = getCorrectBookId();
|
|
const key = `bibdle-guesses-${date}`;
|
|
const saved = localStorage.getItem(key);
|
|
if (!saved) {
|
|
guesses = [];
|
|
return;
|
|
}
|
|
|
|
let savedIds: string[] = JSON.parse(saved);
|
|
savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case
|
|
guesses = savedIds
|
|
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
|
.filter((g): g is Guess => g !== null);
|
|
});
|
|
|
|
// Persist guesses to localStorage whenever they change. Only the book IDs are
|
|
// stored — the full Guess shape is re-derived on load (see effect above).
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
const date = getDate();
|
|
localStorage.setItem(
|
|
`bibdle-guesses-${date}`,
|
|
JSON.stringify(guesses.map((g) => g.book.id)),
|
|
);
|
|
});
|
|
|
|
// Called after stats are successfully submitted to the server so that
|
|
// returning to the page doesn't trigger a duplicate submission.
|
|
function markStatsSubmitted() {
|
|
if (!browser) return;
|
|
statsSubmitted = true;
|
|
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
|
|
}
|
|
|
|
// Marks the win as tracked for analytics. Returns true the first time (new
|
|
// win), false on subsequent calls so the analytics event fires exactly once.
|
|
function markWinTracked() {
|
|
if (!browser) return;
|
|
const key = `bibdle-win-tracked-${getDate()}`;
|
|
if (localStorage.getItem(key) === "true") return false;
|
|
localStorage.setItem(key, "true");
|
|
return true;
|
|
}
|
|
|
|
// Returns true if the win has already been tracked in a previous render/session.
|
|
// Used to skip the animation delay when returning to an already-won game.
|
|
function isWinAlreadyTracked(): boolean {
|
|
if (!browser) return false;
|
|
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
|
|
}
|
|
|
|
// Overwrites local state with the server's authoritative guess record.
|
|
// Called when a logged-in user opens the game on a new device so their
|
|
// progress from another device is restored.
|
|
function hydrateFromServer(guessIds: string[]) {
|
|
if (!browser) return;
|
|
const correctBookId = getCorrectBookId();
|
|
const date = getDate();
|
|
guesses = guessIds
|
|
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
|
.filter((g): g is Guess => g !== null);
|
|
}
|
|
|
|
// Called by the WinScreen after the player submits their chapter bonus guess.
|
|
// Reads the result written to localStorage by WinScreen and updates reactive state.
|
|
function onChapterGuessCompleted() {
|
|
if (!browser) return;
|
|
chapterGuessCompleted = true;
|
|
const reference = getReference();
|
|
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
|
const saved = localStorage.getItem(chapterGuessKey);
|
|
if (saved) {
|
|
const data = JSON.parse(saved);
|
|
const match = reference.match(/\s(\d+):/);
|
|
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
|
chapterCorrect = data.selectedChapter === correctChapter;
|
|
}
|
|
}
|
|
|
|
return {
|
|
get guesses() { return guesses; },
|
|
set guesses(v: Guess[]) { guesses = v; },
|
|
get anonymousId() { return anonymousId; },
|
|
get statsSubmitted() { return statsSubmitted; },
|
|
get chapterGuessCompleted() { return chapterGuessCompleted; },
|
|
get chapterCorrect() { return chapterCorrect; },
|
|
markStatsSubmitted,
|
|
markWinTracked,
|
|
isWinAlreadyTracked,
|
|
onChapterGuessCompleted,
|
|
hydrateFromServer,
|
|
};
|
|
}
|