mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
413 lines
10 KiB
Svelte
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} />
|