mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-02-04 10:54:44 -05:00
Added chapter guess challenge
This commit is contained in:
216
src/lib/components/ChapterGuess.svelte
Normal file
216
src/lib/components/ChapterGuess.svelte
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reference: string;
|
||||||
|
bookId: string;
|
||||||
|
onCompleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { reference, bookId, onCompleted }: Props = $props();
|
||||||
|
|
||||||
|
// Parse the chapter from the reference (e.g., "John 3:16" -> 3)
|
||||||
|
function parseChapterFromReference(ref: string): number {
|
||||||
|
const match = ref.match(/\s(\d+):/);
|
||||||
|
return match ? parseInt(match[1], 10) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the number of chapters for a book
|
||||||
|
function getChapterCount(bookId: string): number {
|
||||||
|
const chapterCounts: Record<string, number> = {
|
||||||
|
GEN: 50,
|
||||||
|
EXO: 40,
|
||||||
|
LEV: 27,
|
||||||
|
NUM: 36,
|
||||||
|
DEU: 34,
|
||||||
|
JOS: 24,
|
||||||
|
JDG: 21,
|
||||||
|
RUT: 4,
|
||||||
|
"1SA": 31,
|
||||||
|
"2SA": 24,
|
||||||
|
"1KI": 22,
|
||||||
|
"2KI": 25,
|
||||||
|
"1CH": 29,
|
||||||
|
"2CH": 36,
|
||||||
|
EZR: 10,
|
||||||
|
NEH: 13,
|
||||||
|
EST: 10,
|
||||||
|
JOB: 42,
|
||||||
|
PSA: 150,
|
||||||
|
PRO: 31,
|
||||||
|
ECC: 12,
|
||||||
|
SNG: 8,
|
||||||
|
ISA: 66,
|
||||||
|
JER: 52,
|
||||||
|
LAM: 5,
|
||||||
|
EZK: 48,
|
||||||
|
DAN: 12,
|
||||||
|
HOS: 14,
|
||||||
|
JOL: 3,
|
||||||
|
AMO: 9,
|
||||||
|
OBA: 1,
|
||||||
|
JON: 4,
|
||||||
|
MIC: 7,
|
||||||
|
NAM: 3,
|
||||||
|
HAB: 3,
|
||||||
|
ZEP: 3,
|
||||||
|
HAG: 2,
|
||||||
|
ZEC: 14,
|
||||||
|
MAL: 4,
|
||||||
|
MAT: 28,
|
||||||
|
MRK: 16,
|
||||||
|
LUK: 24,
|
||||||
|
JHN: 21,
|
||||||
|
ACT: 28,
|
||||||
|
ROM: 16,
|
||||||
|
"1CO": 16,
|
||||||
|
"2CO": 13,
|
||||||
|
GAL: 6,
|
||||||
|
EPH: 6,
|
||||||
|
PHP: 4,
|
||||||
|
COL: 4,
|
||||||
|
"1TH": 5,
|
||||||
|
"2TH": 3,
|
||||||
|
"1TI": 6,
|
||||||
|
"2TI": 4,
|
||||||
|
TIT: 3,
|
||||||
|
PHM: 1,
|
||||||
|
HEB: 13,
|
||||||
|
JAS: 5,
|
||||||
|
"1PE": 5,
|
||||||
|
"2PE": 3,
|
||||||
|
"1JN": 5,
|
||||||
|
"2JN": 1,
|
||||||
|
"3JN": 1,
|
||||||
|
JUD: 1,
|
||||||
|
REV: 22,
|
||||||
|
};
|
||||||
|
return chapterCounts[bookId] || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 6 random chapter options including the correct one
|
||||||
|
function generateChapterOptions(
|
||||||
|
correctChapter: number,
|
||||||
|
totalChapters: number
|
||||||
|
): number[] {
|
||||||
|
const options = new Set<number>();
|
||||||
|
options.add(correctChapter);
|
||||||
|
|
||||||
|
if (totalChapters >= 6) {
|
||||||
|
while (options.size < 6) {
|
||||||
|
const randomChapter = Math.floor(Math.random() * totalChapters) + 1;
|
||||||
|
options.add(randomChapter);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while (options.size < 6) {
|
||||||
|
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||||
|
options.add(randomChapter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(options).sort(() => Math.random() - 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
let correctChapter = $derived(parseChapterFromReference(reference));
|
||||||
|
let totalChapters = $derived(getChapterCount(bookId));
|
||||||
|
let chapterOptions = $state<number[]>([]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (chapterOptions.length === 0) {
|
||||||
|
chapterOptions = generateChapterOptions(correctChapter, totalChapters);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let selectedChapter = $state<number | null>(null);
|
||||||
|
let hasAnswered = $state(false);
|
||||||
|
|
||||||
|
// Load saved state from localStorage
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const key = `bibdle-chapter-guess-${reference}`;
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
selectedChapter = data.selectedChapter;
|
||||||
|
hasAnswered = data.hasAnswered;
|
||||||
|
chapterOptions = data.chapterOptions ?? [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save state to localStorage whenever options are generated or answer given
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser || chapterOptions.length === 0) return;
|
||||||
|
const key = `bibdle-chapter-guess-${reference}`;
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({ selectedChapter, hasAnswered, chapterOptions })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleChapterSelect(chapter: number) {
|
||||||
|
if (hasAnswered) return;
|
||||||
|
selectedChapter = chapter;
|
||||||
|
hasAnswered = true;
|
||||||
|
if (onCompleted) {
|
||||||
|
onCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCorrect = $derived(
|
||||||
|
selectedChapter !== null && selectedChapter === correctChapter
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Container
|
||||||
|
class="w-full p-6 sm:p-8 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
|
||||||
|
<p class="text-sm sm:text-base opacity-80 mb-6">
|
||||||
|
Guess the chapter for an even higher grade
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-3 sm:gap-4 max-w-md mx-auto mb-6">
|
||||||
|
{#each chapterOptions as chapter}
|
||||||
|
<button
|
||||||
|
onclick={() => handleChapterSelect(chapter)}
|
||||||
|
disabled={hasAnswered}
|
||||||
|
class={`
|
||||||
|
aspect-square text-2xl sm:text-3xl font-bold rounded-xl
|
||||||
|
transition-all duration-300 border-2
|
||||||
|
${
|
||||||
|
hasAnswered
|
||||||
|
? chapter === correctChapter
|
||||||
|
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||||
|
: selectedChapter === chapter
|
||||||
|
? isCorrect
|
||||||
|
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||||
|
: "bg-red-400 text-white border-red-500"
|
||||||
|
: "bg-white/30 text-gray-400 border-gray-300 opacity-40"
|
||||||
|
: "bg-white/80 hover:bg-white text-gray-800 border-gray-300 hover:border-amber-400 hover:shadow-md cursor-pointer"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{chapter}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if hasAnswered}
|
||||||
|
<p
|
||||||
|
class="text-xl sm:text-2xl font-bold mb-2"
|
||||||
|
class:text-green-600={isCorrect}
|
||||||
|
class:text-red-600={!isCorrect}
|
||||||
|
>
|
||||||
|
{isCorrect ? "✓ Correct!" : "✗ Incorrect"}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm opacity-80">
|
||||||
|
The verse is from chapter {correctChapter}
|
||||||
|
</p>
|
||||||
|
{#if isCorrect}
|
||||||
|
<p class="text-lg font-bold text-amber-600 mt-2">Grade: S++</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
@@ -16,16 +16,55 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div class="relative">
|
||||||
<input
|
<div class="relative">
|
||||||
bind:value={searchQuery}
|
<svg
|
||||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
class="absolute left-4 sm:left-6 top-1/2 transform -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
||||||
class="w-full p-4 sm:p-6 border-2 border-gray-200 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all shadow-lg"
|
fill="none"
|
||||||
onkeydown={handleKeydown}
|
stroke="currentColor"
|
||||||
/>
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||||
|
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-200 transition-all bg-white"
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
{#if searchQuery}
|
||||||
|
<button
|
||||||
|
class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
onclick={() => (searchQuery = "")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if searchQuery && filteredBooks.length > 0}
|
{#if searchQuery && filteredBooks.length > 0}
|
||||||
<ul
|
<ul
|
||||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-200 rounded-2xl shadow-lg"
|
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
|
||||||
>
|
>
|
||||||
{#each filteredBooks as book (book.id)}
|
{#each filteredBooks as book (book.id)}
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -2,17 +2,25 @@
|
|||||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
let { data, isWon }: { data: PageData; isWon: boolean } = $props();
|
let {
|
||||||
|
data,
|
||||||
|
isWon,
|
||||||
|
blurChapter = false,
|
||||||
|
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||||
let dailyVerse = $derived(data.dailyVerse);
|
let dailyVerse = $derived(data.dailyVerse);
|
||||||
let displayReference = $derived(
|
let displayReference = $derived(
|
||||||
dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
blurChapter
|
||||||
|
? dailyVerse.reference
|
||||||
|
.replace(/^Psalms /, "Psalm ")
|
||||||
|
.replace(/\s(\d+):/, " ?:")
|
||||||
|
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
||||||
);
|
);
|
||||||
let displayVerseText = $derived(
|
let displayVerseText = $derived(
|
||||||
dailyVerse.verseText.replace(/^([a-z])/, (c) => c.toUpperCase())
|
dailyVerse.verseText.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container class="w-full p-8 sm:p-12">
|
<Container class="w-full p-8 sm:p-12 bg-white/70">
|
||||||
<blockquote
|
<blockquote
|
||||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
import CountdownTimer from "./CountdownTimer.svelte";
|
import CountdownTimer from "./CountdownTimer.svelte";
|
||||||
|
import ChapterGuess from "./ChapterGuess.svelte";
|
||||||
|
|
||||||
interface StatsData {
|
interface StatsData {
|
||||||
solveRank: number;
|
solveRank: number;
|
||||||
@@ -26,6 +27,8 @@
|
|||||||
copied = $bindable(false),
|
copied = $bindable(false),
|
||||||
statsSubmitted,
|
statsSubmitted,
|
||||||
guessCount,
|
guessCount,
|
||||||
|
reference,
|
||||||
|
onChapterGuessCompleted,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||||
@@ -128,11 +131,22 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="pt-6 big-text text-gray-700!">
|
{#if guessCount !== 1}
|
||||||
{getNextGradeMessage(guessCount)}
|
<p class="pt-6 big-text text-gray-700!">
|
||||||
</p>
|
{getNextGradeMessage(guessCount)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
<!-- S++ Bonus Challenge for first try -->
|
||||||
|
{#if guessCount === 1}
|
||||||
|
<ChapterGuess
|
||||||
|
{reference}
|
||||||
|
bookId={correctBookId}
|
||||||
|
onCompleted={onChapterGuessCompleted}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<CountdownTimer />
|
<CountdownTimer />
|
||||||
|
|
||||||
<!-- Statistics Display -->
|
<!-- Statistics Display -->
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
|
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let isDev = $state(false);
|
let isDev = $state(false);
|
||||||
|
let chapterGuessCompleted = $state(false);
|
||||||
|
let chapterCorrect = $state(false);
|
||||||
|
|
||||||
let anonymousId = $state("");
|
let anonymousId = $state("");
|
||||||
let statsSubmitted = $state(false);
|
let statsSubmitted = $state(false);
|
||||||
@@ -54,9 +56,14 @@
|
|||||||
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
|
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
|
||||||
let grade = $derived(
|
let grade = $derived(
|
||||||
isWon
|
isWon
|
||||||
? getGrade(guesses.length, getBookById(correctBookId)?.popularity ?? 0)
|
? guesses.length === 1 && chapterCorrect
|
||||||
|
? "S++"
|
||||||
|
: getGrade(guesses.length, getBookById(correctBookId)?.popularity ?? 0)
|
||||||
: ""
|
: ""
|
||||||
);
|
);
|
||||||
|
let blurChapter = $derived(
|
||||||
|
isWon && guesses.length === 1 && !chapterGuessCompleted
|
||||||
|
);
|
||||||
|
|
||||||
function getBookById(id: string): BibleBook | undefined {
|
function getBookById(id: string): BibleBook | undefined {
|
||||||
return bibleBooks.find((b) => b.id === id);
|
return bibleBooks.find((b) => b.id === id);
|
||||||
@@ -79,7 +86,7 @@
|
|||||||
|
|
||||||
const testamentMatch = book.testament === correctBook.testament;
|
const testamentMatch = book.testament === correctBook.testament;
|
||||||
const sectionMatch = book.section === correctBook.section;
|
const sectionMatch = book.section === correctBook.section;
|
||||||
const adjacent = isAdjacent(book.id, correctBookId);
|
const adjacent = isAdjacent(bookId, correctBookId);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`
|
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`
|
||||||
@@ -141,6 +148,17 @@
|
|||||||
anonymousId = getOrCreateAnonymousId();
|
anonymousId = getOrCreateAnonymousId();
|
||||||
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
||||||
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
||||||
|
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||||
|
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
||||||
|
if (chapterGuessCompleted) {
|
||||||
|
const saved = localStorage.getItem(chapterGuessKey);
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
const match = dailyVerse.reference.match(/\s(\d+):/);
|
||||||
|
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||||
|
chapterCorrect = data.selectedChapter === correctChapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -307,8 +325,8 @@
|
|||||||
const siteUrl = window.location.origin;
|
const siteUrl = window.location.origin;
|
||||||
return [
|
return [
|
||||||
`📖 Bibdle | ${formattedDate} 📖`,
|
`📖 Bibdle | ${formattedDate} 📖`,
|
||||||
`${grade} (${guesses.length} ${guesses.length == 1 ? "guess" : "guesses"})`,
|
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
|
||||||
`${emojis}`,
|
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
||||||
siteUrl,
|
siteUrl,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
@@ -408,7 +426,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<VerseDisplay {data} {isWon} />
|
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||||
|
|
||||||
{#if !isWon}
|
{#if !isWon}
|
||||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
||||||
@@ -422,6 +440,18 @@
|
|||||||
bind:copied
|
bind:copied
|
||||||
{statsSubmitted}
|
{statsSubmitted}
|
||||||
guessCount={guesses.length}
|
guessCount={guesses.length}
|
||||||
|
reference={dailyVerse.reference}
|
||||||
|
onChapterGuessCompleted={() => {
|
||||||
|
chapterGuessCompleted = true;
|
||||||
|
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
const match = dailyVerse.reference.match(/\s(\d+):/);
|
||||||
|
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||||
|
chapterCorrect = data.selectedChapter === correctChapter;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user