Refactor game logic into utility modules and add cross-device sync

Extracted game state management, share logic, and stats API calls into dedicated modules (game-persistence.svelte.ts, share.ts, stats-client.ts), and moved daily verse loading to client-side to fix timezone issues. Added a guesses column to daily_completions for cross-device state restoration for logged-in users, a new GET /api/stats endpoint, and a staging deploy script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George Powell
2026-02-18 13:25:40 -05:00
parent 2de4e9e2a7
commit e6081c28f1
17 changed files with 640 additions and 543 deletions

View File

@@ -0,0 +1,148 @@
import { browser } from "$app/environment";
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
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;
}
export function createGamePersistence(
getDate: () => string,
getReference: () => string,
getCorrectBookId: () => string,
getUserId: () => string | undefined,
) {
let guesses = $state<Guess[]>([]);
let anonymousId = $state("");
let statsSubmitted = $state(false);
let chapterGuessCompleted = $state(false);
let chapterCorrect = $state(false);
// Initialize anonymous ID and load persisted flags
$effect(() => {
if (!browser) return;
const userId = getUserId();
// CRITICAL: If user is logged in, ALWAYS use their user ID
if (userId) {
anonymousId = userId;
} else {
anonymousId = getOrCreateAnonymousId();
}
if ((window as any).umami) {
(window as any).umami.identify(anonymousId);
}
const date = getDate();
const reference = getReference();
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
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;
}
}
});
// Load saved guesses from localStorage
$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));
guesses = savedIds
.map((bookId) => evaluateGuess(bookId, correctBookId))
.filter((g): g is Guess => g !== null);
});
// Save guesses to localStorage whenever they change
$effect(() => {
if (!browser) return;
const date = getDate();
localStorage.setItem(
`bibdle-guesses-${date}`,
JSON.stringify(guesses.map((g) => g.book.id)),
);
});
function markStatsSubmitted() {
if (!browser) return;
statsSubmitted = true;
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
}
function markWinTracked() {
if (!browser) return;
const key = `bibdle-win-tracked-${getDate()}`;
if (localStorage.getItem(key) === "true") return false;
localStorage.setItem(key, "true");
return true;
}
function isWinAlreadyTracked(): boolean {
if (!browser) return false;
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
}
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);
// Persist to localStorage so subsequent loads on this device skip the server check
localStorage.setItem(`bibdle-guesses-${date}`, JSON.stringify(guessIds));
}
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,
};
}