mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-06-25 08:45:22 -04:00
3d578a9eb8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
551 lines
15 KiB
Svelte
551 lines
15 KiB
Svelte
<script lang="ts">
|
|
import type { PageProps } from "./$types";
|
|
import { browser } from "$app/environment";
|
|
import { enhance } from "$app/forms";
|
|
import { onMount } from "svelte";
|
|
|
|
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, getFirstLetter } 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);
|
|
let guessesMinimized = $state(false);
|
|
|
|
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,
|
|
);
|
|
|
|
let knownTestament = $derived(
|
|
persistence.guesses.some((g) => g.testamentMatch)
|
|
? correctBook?.testament
|
|
: null,
|
|
);
|
|
let knownSection = $derived(
|
|
persistence.guesses.some((g) => g.sectionMatch)
|
|
? correctBook?.section
|
|
: null,
|
|
);
|
|
let knownFirstLetter = $derived(
|
|
persistence.guesses.some((g) => g.firstLetterMatch)
|
|
? getFirstLetter(correctBook?.name ?? "").toUpperCase()
|
|
: null,
|
|
);
|
|
|
|
let testamentVisible = $state(false);
|
|
let sectionVisible = $state(false);
|
|
let firstLetterVisible = $state(false);
|
|
let showHints = $state(false);
|
|
|
|
// On page load, show hints that are already known without animation
|
|
onMount(() => {
|
|
if (knownTestament) testamentVisible = true;
|
|
if (knownSection) sectionVisible = true;
|
|
if (knownFirstLetter) firstLetterVisible = true;
|
|
|
|
const winCount = Object.keys(localStorage).filter(
|
|
(k) => k.startsWith("bibdle-win-tracked-") && localStorage.getItem(k) === "true"
|
|
).length;
|
|
showHints = winCount < 3;
|
|
});
|
|
|
|
// Fade in newly revealed hints after the guess animation completes
|
|
$effect(() => {
|
|
if (!knownTestament || testamentVisible) return;
|
|
const id = setTimeout(() => {
|
|
testamentVisible = true;
|
|
}, 2800);
|
|
return () => clearTimeout(id);
|
|
});
|
|
$effect(() => {
|
|
if (!knownSection || sectionVisible) return;
|
|
const id = setTimeout(() => {
|
|
sectionVisible = true;
|
|
}, 2800);
|
|
return () => clearTimeout(id);
|
|
});
|
|
$effect(() => {
|
|
if (!knownFirstLetter || firstLetterVisible) return;
|
|
const id = setTimeout(() => {
|
|
firstLetterVisible = true;
|
|
}, 2800);
|
|
return () => clearTimeout(id);
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
// Delay collapsing the guesses grid until animations complete (mirrors showWinScreen delay)
|
|
$effect(() => {
|
|
if (!isWon || persistence.guesses.length <= 3) {
|
|
guessesMinimized = false;
|
|
return;
|
|
}
|
|
if (persistence.isWinAlreadyTracked()) {
|
|
guessesMinimized = true;
|
|
} else {
|
|
const animationDelay = 1800;
|
|
const timeoutId = setTimeout(() => {
|
|
guessesMinimized = 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} />
|
|
|
|
{#if showHints && (knownTestament || knownSection || knownFirstLetter)}
|
|
<div
|
|
class="text-xs uppercase tracking-widest font-bold text-center text-gray-500 dark:text-gray-400 flex flex-col gap-1 mb-4 -mt-2"
|
|
>
|
|
{#if knownTestament}
|
|
<p
|
|
style="transition: opacity 0.5s ease; opacity: {testamentVisible
|
|
? 1
|
|
: 0};"
|
|
>
|
|
It is in the {knownTestament === "old"
|
|
? "Old"
|
|
: "New"} Testament.
|
|
</p>
|
|
{/if}
|
|
{#if knownSection}
|
|
<p
|
|
style="transition: opacity 0.5s ease; opacity: {sectionVisible
|
|
? 1
|
|
: 0};"
|
|
>
|
|
It is in the {knownSection} section.
|
|
</p>
|
|
{/if}
|
|
{#if knownFirstLetter}
|
|
<p
|
|
style="transition: opacity 0.5s ease; opacity: {firstLetterVisible
|
|
? 1
|
|
: 0};"
|
|
>
|
|
The book's name starts with "{knownFirstLetter}".
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<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}
|
|
minimized={guessesMinimized}
|
|
/>
|
|
</div>
|
|
|
|
{#if isWon}
|
|
<hr
|
|
class="animate-fade-in-up animate-delay-800 border-gray-300 dark:border-gray-600"
|
|
/>
|
|
<div class="animate-fade-in-up animate-delay-800">
|
|
<a
|
|
href="https://discord.gg/yWQXbGK8SD"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="flex items-center justify-center gap-2 w-full px-5 py-2.5 bg-[#5865F2] hover:bg-[#4752C4] text-white font-semibold rounded-lg shadow-md transition-colors duration-200"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 127.14 96.36"
|
|
class="w-5 h-5 fill-white"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
|
|
/>
|
|
</svg>
|
|
Join the BIBDLE Discord!
|
|
</a>
|
|
</div>
|
|
<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" : user.googleId ? " using Google" : ""} |
|
|
|
|
<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} />
|