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

@@ -1,20 +1,8 @@
<script lang="ts">
import { bibleBooks } from "$lib/types/bible";
import { getFirstLetter, type Guess } from "$lib/utils/game";
import Container from "./Container.svelte";
interface Guess {
book: {
id: string;
name: string;
testament: string;
section: string;
};
testamentMatch: boolean;
sectionMatch: boolean;
adjacent: boolean;
firstLetterMatch: boolean;
}
let {
guesses,
correctBookId,
@@ -28,11 +16,6 @@
return "bg-red-500 border-red-600";
}
function getFirstLetter(bookName: string): string {
const match = bookName.match(/[a-zA-Z]/);
return match ? match[0] : bookName[0];
}
function getBoxContent(
guess: Guess,
column: "book" | "firstLetter" | "testament" | "section",

View File

@@ -7,11 +7,11 @@
imposterIndex: number;
}
let data: ImposterData | null = null;
let clicked: boolean[] = [];
let gameOver = false;
let loading = true;
let error: string | null = null;
let data: ImposterData | null = $state(null);
let clicked: boolean[] = $state([]);
let gameOver = $state(false);
let loading = $state(true);
let error: string | null = $state(null);
async function loadGame() {
try {
@@ -92,7 +92,7 @@
{:else if error}
<div class="error">
<p>Error: {error}</p>
<button on:click={newGame}>Retry</button>
<button onclick={newGame}>Retry</button>
</div>
{:else if data}
<!-- <div class="instructions">
@@ -106,7 +106,7 @@
class:clicked={clicked[i]}
class:correct={clicked[i] && i === data.imposterIndex}
class:wrong={clicked[i] && i !== data.imposterIndex}
on:click={() => handleClick(i)}
onclick={() => handleClick(i)}
disabled={gameOver}
>
{formatVerse(verse)}
@@ -119,7 +119,7 @@
</div>
{#if gameOver}
<div class="result">
<button on:click={newGame}>New Game</button>
<button onclick={newGame}>New Game</button>
</div>
{/if}
{/if}

View File

@@ -1,8 +1,3 @@
// place files you want to import through the `$lib` alias in this folder.
export * from './utils/game';
export { default as VerseDisplay } from './components/VerseDisplay.svelte';
export { default as SearchInput } from './components/SearchInput.svelte';
export { default as GuessesTable } from './components/GuessesTable.svelte';
export { default as WinScreen } from './components/WinScreen.svelte';
export { default as Feedback } from './components/Feedback.svelte';

View File

@@ -37,13 +37,14 @@ export const dailyCompletions = sqliteTable('daily_completions', {
anonymousId: text('anonymous_id').notNull(),
date: text('date').notNull(),
guessCount: integer('guess_count').notNull(),
guesses: text('guesses'), // nullable; only stored for logged-in users
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
anonymousIdDateIndex: index('anonymous_id_date_idx').on(table.anonymousId, table.date),
dateIndex: index('date_idx').on(table.date),
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
}, (table) => [
index('anonymous_id_date_idx').on(table.anonymousId, table.date),
index('date_idx').on(table.date),
index('date_guess_idx').on(table.date, table.guessCount),
// Ensures schema matches the database migration and prevents duplicate submissions
uniqueAnonymousIdDate: unique('daily_completions_anonymous_id_date_unique').on(table.anonymousId, table.date),
}));
unique('daily_completions_anonymous_id_date_unique').on(table.anonymousId, table.date),
]);
export type DailyCompletion = typeof dailyCompletions.$inferSelect;

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,
};
}

View File

@@ -1,5 +1,13 @@
import { bibleBooks, type BibleBook } from '$lib/types/bible';
export interface Guess {
book: BibleBook;
testamentMatch: boolean;
sectionMatch: boolean;
adjacent: boolean;
firstLetterMatch: boolean;
}
export function getBookById(id: string): BibleBook | undefined {
return bibleBooks.find((b) => b.id === id);
}
@@ -10,7 +18,47 @@ export function isAdjacent(id1: string, id2: string): boolean {
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
}
export function getGrade(numGuesses: number, popularity: number): string {
export function getFirstLetter(bookName: string): string {
const match = bookName.match(/[a-zA-Z]/);
return match ? match[0] : bookName[0];
}
export function evaluateGuess(guessBookId: string, correctBookId: string): Guess | null {
const book = getBookById(guessBookId);
const correctBook = getBookById(correctBookId);
if (!book || !correctBook) return null;
const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(guessBookId, correctBookId);
// Special case: if correct book is in the Epistles + starts with "1",
// any guess starting with "1" counts as first letter match
const correctIsEpistlesWithNumber =
(correctBook.section === "Pauline Epistles" ||
correctBook.section === "General Epistles") &&
correctBook.name[0] === "1";
const guessIsEpistlesWithNumber =
(book.section === "Pauline Epistles" ||
book.section === "General Epistles") &&
book.name[0] === "1";
const firstLetterMatch =
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
? true
: getFirstLetter(book.name).toUpperCase() ===
getFirstLetter(correctBook.name).toUpperCase();
return {
book,
testamentMatch,
sectionMatch,
adjacent,
firstLetterMatch,
};
}
export function getGrade(numGuesses: number): string {
if (numGuesses === 1) return "S+";
if (numGuesses === 2) return "A+";
if (numGuesses === 3) return "A";
@@ -49,4 +97,4 @@ export function generateUUID(): string {
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}

65
src/lib/utils/share.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { Guess } from './game';
export function generateShareText(params: {
guesses: Guess[];
correctBookId: string;
dailyVerseDate: string;
grade: string;
chapterCorrect: boolean;
isLoggedIn: boolean;
userStreak?: number;
origin: string;
}): string {
const { guesses, correctBookId, dailyVerseDate, grade, chapterCorrect, isLoggedIn, userStreak, origin } = params;
const emojis = guesses
.slice()
.reverse()
.map((guess) => {
if (guess.book.id === correctBookId) return "✅";
if (guess.adjacent) return "‼️";
if (guess.sectionMatch) return "🟩";
if (guess.testamentMatch) return "🟧";
return "🟥";
})
.join("");
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const formattedDate = dateFormatter.format(
new Date(`${dailyVerseDate}T00:00:00`),
);
const bookEmoji = isLoggedIn ? "📜" : "📖";
const lines = [
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
];
if (isLoggedIn && userStreak !== undefined) {
lines.push(`🔥 ${userStreak} day streak`);
}
lines.push(
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
origin,
);
return lines.join("\n");
}
export async function shareResult(shareText: string): Promise<void> {
if ("share" in navigator) {
await (navigator as any).share({ text: shareText });
} else {
await (navigator as any).clipboard.writeText(shareText);
}
}
export async function copyToClipboard(shareText: string): Promise<void> {
await (navigator as any).clipboard.writeText(shareText);
}

View File

@@ -0,0 +1,68 @@
export interface StatsData {
solveRank: number;
guessRank: number;
totalSolves: number;
averageGuesses: number;
tiedCount: number;
percentile: number;
guesses?: string[]; // Present when fetching an existing completion (cross-device sync)
}
export async function submitCompletion(params: {
anonymousId: string;
date: string;
guessCount: number;
guesses: string[];
}): Promise<StatsData | null> {
try {
const response = await fetch("/api/submit-completion", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const result = await response.json();
if (result.success && result.stats) {
return result.stats;
}
if (response.status === 409) {
// Already submitted from another device — fetch existing stats
return fetchExistingStats({ anonymousId: params.anonymousId, date: params.date });
}
if (result.error) {
console.error("Stats server error:", result.error);
}
return null;
} catch (err) {
console.error("Stats submission failed:", err);
return null;
}
}
export async function fetchExistingStats(params: {
anonymousId: string;
date: string;
}): Promise<StatsData | null> {
try {
const response = await fetch(
`/api/stats?anonymousId=${params.anonymousId}&date=${params.date}`,
);
const result = await response.json();
if (result.success && result.stats) {
return result.stats;
}
if (result.error) {
console.error("Stats server error:", result.error);
}
return null;
} catch (err) {
console.error("Stats fetch failed:", err);
return null;
}
}