Files
bibdle/src/routes/+page.svelte
2026-03-15 03:19:21 -04:00

413 lines
10 KiB
Svelte

<script lang="ts">
import type { PageProps } from "./$types";
import { browser } from "$app/environment";
import { enhance } from "$app/forms";
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
import SearchInput from "$lib/components/SearchInput.svelte";
import GuessesTable from "$lib/components/GuessesTable.svelte";
import WinScreen from "$lib/components/WinScreen.svelte";
import Credits from "$lib/components/Credits.svelte";
import GamePrompt from "$lib/components/GamePrompt.svelte";
import DevButtons from "$lib/components/DevButtons.svelte";
import AuthModal from "$lib/components/AuthModal.svelte";
import { evaluateGuess } from "$lib/utils/game";
import {
generateShareText,
shareResult,
copyToClipboard as clipboardCopy,
} from "$lib/utils/share";
import { fetchStreak, fetchStreakPercentile } from "$lib/utils/streak";
import {
submitCompletion,
fetchExistingStats,
type StatsData,
} from "$lib/utils/stats-client";
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
import { SvelteSet } from "svelte/reactivity";
let { data }: PageProps = $props();
let dailyVerse = $derived(data.dailyVerse);
let correctBookId = $derived(data.correctBookId);
let correctBook = $derived(data.correctBook);
let user = $derived(data.user);
let session = $derived(data.session);
const currentDate = $derived(
new Date().toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}),
);
let searchQuery = $state("");
let copied = $state(false);
let isDev = $state(false);
let authModalOpen = $state(false);
let showWinScreen = $state(false);
let statsData = $state<StatsData | null>(null);
let streak = $state(0);
let streakPercentile = $state<number | null>(null);
const persistence = createGamePersistence(
() => dailyVerse.date,
() => dailyVerse.reference,
() => correctBookId,
() => user,
);
let guessedIds = $derived(
new SvelteSet(persistence.guesses.map((g) => g.book.id)),
);
let isWon = $derived(
persistence.guesses.some((g) => g.book.id === correctBookId),
);
let blurChapter = $derived(
isWon &&
persistence.guesses.length === 1 &&
!persistence.chapterGuessCompleted,
);
async function submitGuess(bookId: string) {
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
const guess = evaluateGuess(bookId, correctBookId);
if (!guess) return;
if (persistence.guesses.length === 0) {
const key = `bibdle-first-guess-${dailyVerse.date}`;
if (
browser &&
localStorage.getItem(key) !== "true" &&
(window as any).umami
) {
(window as any).umami.track("First guess");
(window as any).rybbit?.event("First guess");
localStorage.setItem(key, "true");
}
}
persistence.guesses = [guess, ...persistence.guesses];
searchQuery = "";
if (
guess.book.id === correctBookId &&
browser &&
persistence.anonymousId
) {
statsData = await submitCompletion({
anonymousId: persistence.anonymousId,
date: dailyVerse.date,
guessCount: persistence.guesses.length,
guesses: persistence.guesses.map((g) => g.book.id),
});
if (statsData) {
persistence.markStatsSubmitted();
}
}
}
// Reload when the user returns to a stale tab on a new calendar day
$effect(() => {
if (!browser) return;
const loadedDate = new Date().toLocaleDateString("en-CA");
function onVisibilityChange() {
if (document.hidden) return;
const now = new Date().toLocaleDateString("en-CA");
if (now !== loadedDate) {
window.location.reload();
}
}
document.addEventListener("visibilitychange", onVisibilityChange);
return () =>
document.removeEventListener(
"visibilitychange",
onVisibilityChange,
);
});
$effect(() => {
if (!browser) return;
isDev =
window.location.host === "localhost:5173" ||
window.location.host === "test.bibdle.com";
});
// Fetch stats on page load if user already won in a previous session (same device)
$effect(() => {
if (
!browser ||
!isWon ||
!persistence.anonymousId ||
statsData ||
!persistence.statsSubmitted
)
return;
fetchExistingStats({
anonymousId: persistence.anonymousId,
date: dailyVerse.date,
}).then((data) => {
statsData = data;
});
});
// For logged-in users on a new device: restore today's game state from the server.
// Runs even when isWon is true so that logging in after completing the game on another
// device always replaces local localStorage with the authoritative DB record.
let crossDeviceCheckDate = $state<string | null>(null);
$effect(() => {
if (
!browser ||
!user ||
!dailyVerse?.date ||
crossDeviceCheckDate === dailyVerse.date ||
!persistence.anonymousId
)
return;
crossDeviceCheckDate = dailyVerse.date;
fetchExistingStats({
anonymousId: persistence.anonymousId,
date: dailyVerse.date,
}).then((data) => {
if (data?.guesses?.length) {
persistence.hydrateFromServer(data.guesses);
statsData = data;
persistence.markStatsSubmitted();
}
});
});
// Delay showing win screen until GuessesTable animation completes
$effect(() => {
if (!isWon) {
showWinScreen = false;
return;
}
if (persistence.isWinAlreadyTracked()) {
showWinScreen = true;
} else {
const animationDelay = 1800;
const timeoutId = setTimeout(() => {
showWinScreen = true;
}, animationDelay);
return () => clearTimeout(timeoutId);
}
});
// Track win analytics
$effect(() => {
if (!browser || !isWon) return;
const isNew = persistence.markWinTracked();
if (isNew && (window as any).umami) {
(window as any).umami.track("Guessed correctly", {
totalGuesses: persistence.guesses.length,
});
(window as any).rybbit?.event("Guessed correctly", {
totalGuesses: persistence.guesses.length,
});
}
});
// Fetch streak when the player wins
$effect(() => {
if (!browser || !isWon || !persistence.anonymousId) return;
const localDate = new Date().toLocaleDateString("en-CA");
fetchStreak(persistence.anonymousId, localDate).then((result) => {
streak = result;
if (result >= 2) {
fetchStreakPercentile(result, localDate).then((p) => {
streakPercentile = p;
});
}
});
});
function getShareText(): string {
return generateShareText({
guesses: persistence.guesses,
correctBookId,
dailyVerseDate: dailyVerse.date,
chapterCorrect: persistence.chapterCorrect,
isLoggedIn: !!user,
streak,
origin: window.location.origin,
verseText: dailyVerse.verseText,
});
}
function handleShare() {
if (copied || !browser) return;
const useClipboard = !("share" in navigator);
if (useClipboard) {
copied = true;
}
shareResult(getShareText())
.then(() => {
if (useClipboard) {
setTimeout(() => {
copied = false;
}, 5000);
}
})
.catch(() => {
if (useClipboard) {
copied = false;
}
});
}
async function handleCopyToClipboard() {
if (!browser) return;
try {
await clipboardCopy(getShareText());
copied = true;
setTimeout(() => {
copied = false;
}, 5000);
} catch (err) {
console.error("Copy to clipboard failed:", err);
}
}
</script>
<svelte:head>
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
</svelte:head>
<div class="pb-8">
<div class="w-full max-w-3xl mx-auto px-4">
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
<span class="big-text"
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
>
</div>
<div class="flex flex-col gap-6">
<div class="animate-fade-in-up animate-delay-200">
<VerseDisplay {data} {isWon} {blurChapter} />
</div>
{#if !isWon}
<div class="animate-fade-in-up animate-delay-400">
<GamePrompt guessCount={persistence.guesses.length} />
<SearchInput
bind:searchQuery
{guessedIds}
{submitGuess}
guessCount={persistence.guesses.length}
/>
</div>
{:else if showWinScreen}
<div class="animate-fade-in-up animate-delay-400">
<WinScreen
{statsData}
{correctBookId}
{handleShare}
copyToClipboard={handleCopyToClipboard}
bind:copied
statsSubmitted={persistence.statsSubmitted}
guessCount={persistence.guesses.length}
reference={dailyVerse.reference}
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
shareText={getShareText()}
verseText={dailyVerse.verseText}
{streak}
{streakPercentile}
isLoggedIn={!!user}
anonymousId={persistence.anonymousId}
/>
</div>
{/if}
<div class="animate-fade-in-up animate-delay-600">
<GuessesTable guesses={persistence.guesses} {correctBookId} />
</div>
{#if isWon}
<div class="animate-fade-in-up animate-delay-800">
<Credits />
</div>
{/if}
</div>
{#if isDev}
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
<div
class="text-xs text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded border dark:border-gray-700"
>
<div><strong>Debug Info:</strong></div>
<div>
User: {user
? `${user.email} (ID: ${user.id})`
: "Not signed in"}
</div>
<div>
Session: {session
? `Expires ${session.expiresAt.toLocaleDateString()}`
: "No session"}
</div>
<div>
Anonymous ID: {persistence.anonymousId || "Not set"}
</div>
<div>
Client Local Time: {new Date().toLocaleString("en-US", {
timeZone:
Intl.DateTimeFormat().resolvedOptions()
.timeZone,
timeZoneName: "short",
})}
</div>
<div>
Client Local Date: {new Date().toLocaleDateString(
"en-CA",
)}
</div>
<div>Daily Verse Date: {dailyVerse.date}</div>
<div>Streak: {streak}</div>
</div>
<DevButtons
anonymousId={persistence.anonymousId}
{user}
onSignIn={() => (authModalOpen = true)}
/>
</div>
{/if}
{#if user && session}
<div
class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center text-xs text-gray-400 dark:text-gray-500"
>
Signed in as {[user.firstName, user.lastName]
.filter(Boolean)
.join(" ")}{user.email
? ` (${user.email})`
: ""}{user.appleId ? " using Apple" : ""} |
<form
method="POST"
action="/auth/logout"
use:enhance
class="inline"
>
<button
type="submit"
class="ml-2 underline hover:text-gray-600 transition-colors cursor-pointer"
>Sign out</button
>
</form>
</div>
{/if}
</div>
</div>
<AuthModal bind:isOpen={authModalOpen} anonymousId={persistence.anonymousId} />