add percentile stats, update chapter guess UI

This commit is contained in:
George Powell
2026-01-28 23:03:51 -05:00
parent 2df97f66bf
commit d21ca9d687
4 changed files with 235 additions and 204 deletions

View File

@@ -1,216 +1,222 @@
<script lang="ts"> <script lang="ts">
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import Container from "./Container.svelte"; import Container from "./Container.svelte";
interface Props { interface Props {
reference: string; reference: string;
bookId: string; bookId: string;
onCompleted?: () => void; onCompleted?: () => void;
} }
let { reference, bookId, onCompleted }: Props = $props(); let { reference, bookId, onCompleted }: Props = $props();
// Parse the chapter from the reference (e.g., "John 3:16" -> 3) // Parse the chapter from the reference (e.g., "John 3:16" -> 3)
function parseChapterFromReference(ref: string): number { function parseChapterFromReference(ref: string): number {
const match = ref.match(/\s(\d+):/); const match = ref.match(/\s(\d+):/);
return match ? parseInt(match[1], 10) : 1; return match ? parseInt(match[1], 10) : 1;
} }
// Get the number of chapters for a book // Get the number of chapters for a book
function getChapterCount(bookId: string): number { function getChapterCount(bookId: string): number {
const chapterCounts: Record<string, number> = { const chapterCounts: Record<string, number> = {
GEN: 50, GEN: 50,
EXO: 40, EXO: 40,
LEV: 27, LEV: 27,
NUM: 36, NUM: 36,
DEU: 34, DEU: 34,
JOS: 24, JOS: 24,
JDG: 21, JDG: 21,
RUT: 4, RUT: 4,
"1SA": 31, "1SA": 31,
"2SA": 24, "2SA": 24,
"1KI": 22, "1KI": 22,
"2KI": 25, "2KI": 25,
"1CH": 29, "1CH": 29,
"2CH": 36, "2CH": 36,
EZR: 10, EZR: 10,
NEH: 13, NEH: 13,
EST: 10, EST: 10,
JOB: 42, JOB: 42,
PSA: 150, PSA: 150,
PRO: 31, PRO: 31,
ECC: 12, ECC: 12,
SNG: 8, SNG: 8,
ISA: 66, ISA: 66,
JER: 52, JER: 52,
LAM: 5, LAM: 5,
EZK: 48, EZK: 48,
DAN: 12, DAN: 12,
HOS: 14, HOS: 14,
JOL: 3, JOL: 3,
AMO: 9, AMO: 9,
OBA: 1, OBA: 1,
JON: 4, JON: 4,
MIC: 7, MIC: 7,
NAM: 3, NAM: 3,
HAB: 3, HAB: 3,
ZEP: 3, ZEP: 3,
HAG: 2, HAG: 2,
ZEC: 14, ZEC: 14,
MAL: 4, MAL: 4,
MAT: 28, MAT: 28,
MRK: 16, MRK: 16,
LUK: 24, LUK: 24,
JHN: 21, JHN: 21,
ACT: 28, ACT: 28,
ROM: 16, ROM: 16,
"1CO": 16, "1CO": 16,
"2CO": 13, "2CO": 13,
GAL: 6, GAL: 6,
EPH: 6, EPH: 6,
PHP: 4, PHP: 4,
COL: 4, COL: 4,
"1TH": 5, "1TH": 5,
"2TH": 3, "2TH": 3,
"1TI": 6, "1TI": 6,
"2TI": 4, "2TI": 4,
TIT: 3, TIT: 3,
PHM: 1, PHM: 1,
HEB: 13, HEB: 13,
JAS: 5, JAS: 5,
"1PE": 5, "1PE": 5,
"2PE": 3, "2PE": 3,
"1JN": 5, "1JN": 5,
"2JN": 1, "2JN": 1,
"3JN": 1, "3JN": 1,
JUD: 1, JUD: 1,
REV: 22, REV: 22,
}; };
return chapterCounts[bookId] || 1; return chapterCounts[bookId] || 1;
} }
// Generate 6 random chapter options including the correct one // Generate 6 random chapter options including the correct one
function generateChapterOptions( function generateChapterOptions(
correctChapter: number, correctChapter: number,
totalChapters: number totalChapters: number,
): number[] { ): number[] {
const options = new Set<number>(); const options = new Set<number>();
options.add(correctChapter); options.add(correctChapter);
if (totalChapters >= 6) { if (totalChapters >= 6) {
while (options.size < 6) { while (options.size < 6) {
const randomChapter = Math.floor(Math.random() * totalChapters) + 1; const randomChapter =
options.add(randomChapter); Math.floor(Math.random() * totalChapters) + 1;
} options.add(randomChapter);
} else { }
while (options.size < 6) { } else {
const randomChapter = Math.floor(Math.random() * 10) + 1; while (options.size < 6) {
options.add(randomChapter); const randomChapter = Math.floor(Math.random() * 10) + 1;
} options.add(randomChapter);
} }
return Array.from(options).sort(() => Math.random() - 0.5); }
} return Array.from(options).sort(() => Math.random() - 0.5);
}
let correctChapter = $derived(parseChapterFromReference(reference)); let correctChapter = $derived(parseChapterFromReference(reference));
let totalChapters = $derived(getChapterCount(bookId)); let totalChapters = $derived(getChapterCount(bookId));
let chapterOptions = $state<number[]>([]); let chapterOptions = $state<number[]>([]);
$effect(() => { $effect(() => {
if (chapterOptions.length === 0) { if (chapterOptions.length === 0) {
chapterOptions = generateChapterOptions(correctChapter, totalChapters); chapterOptions = generateChapterOptions(
} correctChapter,
}); totalChapters,
);
}
});
let selectedChapter = $state<number | null>(null); let selectedChapter = $state<number | null>(null);
let hasAnswered = $state(false); let hasAnswered = $state(false);
// Load saved state from localStorage // Load saved state from localStorage
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
const key = `bibdle-chapter-guess-${reference}`; const key = `bibdle-chapter-guess-${reference}`;
const saved = localStorage.getItem(key); const saved = localStorage.getItem(key);
if (saved) { if (saved) {
const data = JSON.parse(saved); const data = JSON.parse(saved);
selectedChapter = data.selectedChapter; selectedChapter = data.selectedChapter;
hasAnswered = data.hasAnswered; hasAnswered = data.hasAnswered;
chapterOptions = data.chapterOptions ?? []; chapterOptions = data.chapterOptions ?? [];
} }
}); });
// Save state to localStorage whenever options are generated or answer given // Save state to localStorage whenever options are generated or answer given
$effect(() => { $effect(() => {
if (!browser || chapterOptions.length === 0) return; if (!browser || chapterOptions.length === 0) return;
const key = `bibdle-chapter-guess-${reference}`; const key = `bibdle-chapter-guess-${reference}`;
localStorage.setItem( localStorage.setItem(
key, key,
JSON.stringify({ selectedChapter, hasAnswered, chapterOptions }) JSON.stringify({ selectedChapter, hasAnswered, chapterOptions }),
); );
}); });
function handleChapterSelect(chapter: number) { function handleChapterSelect(chapter: number) {
if (hasAnswered) return; if (hasAnswered) return;
selectedChapter = chapter; selectedChapter = chapter;
hasAnswered = true; hasAnswered = true;
if (onCompleted) { if (onCompleted) {
onCompleted(); onCompleted();
} }
} }
let isCorrect = $derived( let isCorrect = $derived(
selectedChapter !== null && selectedChapter === correctChapter selectedChapter !== null && selectedChapter === correctChapter,
); );
</script> </script>
<Container <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" 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"> <div class="text-center">
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p> <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"> <p class="text-sm sm:text-base opacity-80 mb-6">
Guess the chapter for an even higher grade Guess the chapter for an even higher grade
</p> </p>
<div class="grid grid-cols-3 gap-3 sm:gap-4 max-w-md mx-auto mb-6"> <div
{#each chapterOptions as chapter} class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
<button >
onclick={() => handleChapterSelect(chapter)} {#each chapterOptions as chapter}
disabled={hasAnswered} <button
class={` onclick={() => handleChapterSelect(chapter)}
aspect-square text-2xl sm:text-3xl font-bold rounded-xl disabled={hasAnswered}
class={`
w-20 h-20 sm:w-24 sm:h-24 text-2xl sm:text-3xl font-bold rounded-xl
transition-all duration-300 border-2 transition-all duration-300 border-2
${ ${
hasAnswered hasAnswered
? chapter === correctChapter ? chapter === correctChapter
? "bg-green-500 text-white border-green-600 shadow-lg" ? "bg-green-500 text-white border-green-600 shadow-lg"
: selectedChapter === chapter : selectedChapter === chapter
? isCorrect ? isCorrect
? "bg-green-500 text-white border-green-600 shadow-lg" ? "bg-green-500 text-white border-green-600 shadow-lg"
: "bg-red-400 text-white border-red-500" : "bg-red-400 text-white border-red-500"
: "bg-white/30 text-gray-400 border-gray-300 opacity-40" : "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" : "bg-white/80 hover:bg-white text-gray-800 border-gray-300 hover:border-amber-400 hover:shadow-md cursor-pointer"
} }
`} `}
> >
{chapter} {chapter}
</button> </button>
{/each} {/each}
</div> </div>
{#if hasAnswered} {#if hasAnswered}
<p <p
class="text-xl sm:text-2xl font-bold mb-2" class="text-xl sm:text-2xl font-bold mb-2"
class:text-green-600={isCorrect} class:text-green-600={isCorrect}
class:text-red-600={!isCorrect} class:text-red-600={!isCorrect}
> >
{isCorrect ? "✓ Correct!" : "✗ Incorrect"} {isCorrect ? "✓ Correct!" : "✗ Incorrect"}
</p> </p>
<p class="text-sm opacity-80"> <p class="text-sm opacity-80">
The verse is from chapter {correctChapter} The verse is from chapter {correctChapter}
</p> </p>
{#if isCorrect} {#if isCorrect}
<p class="text-lg font-bold text-amber-600 mt-2">Grade: S++</p> <p class="text-lg font-bold text-amber-600 mt-2">Grade: S++</p>
{/if} {/if}
{/if} {/if}
</div> </div>
</Container> </Container>

View File

@@ -16,6 +16,7 @@
totalSolves: number; totalSolves: number;
averageGuesses: number; averageGuesses: number;
tiedCount: number; tiedCount: number;
percentile: number;
} }
interface WeightedMessage { interface WeightedMessage {
@@ -169,7 +170,9 @@
> >
<!-- Solve Rank Column --> <!-- Solve Rank Column -->
<div class="flex flex-col"> <div class="flex flex-col">
<div class="text-3xl sm:text-4xl font-black"> <div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
>
#{statsData.solveRank} #{statsData.solveRank}
</div> </div>
<div class="text-sm sm:text-sm opacity-90 mt-1"> <div class="text-sm sm:text-sm opacity-90 mt-1">
@@ -180,7 +183,9 @@
<!-- Guess Rank Column --> <!-- Guess Rank Column -->
<div class="flex flex-col"> <div class="flex flex-col">
<div class="text-3xl sm:text-4xl font-black"> <div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
>
{toOrdinal(statsData.guessRank)} {toOrdinal(statsData.guessRank)}
</div> </div>
<div class="text-sm sm:text-sm opacity-90 mt-1"> <div class="text-sm sm:text-sm opacity-90 mt-1">
@@ -189,13 +194,20 @@
? "solve" ? "solve"
: "solves"}{statsData.tiedCount > 0 : "solves"}{statsData.tiedCount > 0
? `, tied with ${statsData.tiedCount} ${statsData.tiedCount === 1 ? "other" : "others"}` ? `, tied with ${statsData.tiedCount} ${statsData.tiedCount === 1 ? "other" : "others"}`
: ""} : ""}.<br />
{#if statsData.percentile <= 25}
<span class="font-bold">
(Top {statsData.percentile}%)
</span>
{/if}
</div> </div>
</div> </div>
<!-- Average Column --> <!-- Average Column -->
<div class="flex flex-col"> <div class="flex flex-col">
<div class="text-3xl sm:text-4xl font-black"> <div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
>
{statsData.averageGuesses} {statsData.averageGuesses}
</div> </div>
<div class="text-sm sm:text-sm opacity-90 mt-1"> <div class="text-sm sm:text-sm opacity-90 mt-1">

View File

@@ -43,6 +43,7 @@
totalSolves: number; totalSolves: number;
averageGuesses: number; averageGuesses: number;
tiedCount: number; tiedCount: number;
percentile: number;
} | null>(null); } | null>(null);
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id))); let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));

View File

@@ -44,9 +44,11 @@ export const POST: RequestHandler = async ({ request }) => {
// Solve rank: position in time-ordered list // Solve rank: position in time-ordered list
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1; const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
// Guess rank: count how many had FEWER guesses (ties get same rank) // Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length; const uniqueBetterGuessCounts = new Set(
const guessRank = betterGuesses + 1; allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
);
const guessRank = uniqueBetterGuessCounts.size + 1;
// Count ties: how many have the SAME guessCount (excluding self) // Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length; const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
@@ -55,9 +57,13 @@ export const POST: RequestHandler = async ({ request }) => {
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0); const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10; const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
// Percentile: what percentage of people you beat (100 - your rank percentage)
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
return json({ return json({
success: true, success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount } stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
}); });
} catch (err) { } catch (err) {
console.error('Error submitting completion:', err); console.error('Error submitting completion:', err);
@@ -104,9 +110,11 @@ export const GET: RequestHandler = async ({ url }) => {
// Solve rank: position in time-ordered list // Solve rank: position in time-ordered list
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1; const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
// Guess rank: count how many had FEWER guesses (ties get same rank) // Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length; const uniqueBetterGuessCounts = new Set(
const guessRank = betterGuesses + 1; allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
);
const guessRank = uniqueBetterGuessCounts.size + 1;
// Count ties: how many have the SAME guessCount (excluding self) // Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length; const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
@@ -115,9 +123,13 @@ export const GET: RequestHandler = async ({ url }) => {
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0); const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10; const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
// Percentile: what percentage of people you beat (100 - your rank percentage)
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
return json({ return json({
success: true, success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount } stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
}); });
} catch (err) { } catch (err) {
console.error('Error fetching stats:', err); console.error('Error fetching stats:', err);