mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-02-04 02:44:43 -05:00
add percentile stats, update chapter guess UI
This commit is contained in:
@@ -1,216 +1,222 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import { browser } from "$app/environment";
|
||||
import Container from "./Container.svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import { browser } from "$app/environment";
|
||||
import Container from "./Container.svelte";
|
||||
|
||||
interface Props {
|
||||
reference: string;
|
||||
bookId: string;
|
||||
onCompleted?: () => void;
|
||||
}
|
||||
interface Props {
|
||||
reference: string;
|
||||
bookId: string;
|
||||
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)
|
||||
function parseChapterFromReference(ref: string): number {
|
||||
const match = ref.match(/\s(\d+):/);
|
||||
return match ? parseInt(match[1], 10) : 1;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
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[]>([]);
|
||||
let correctChapter = $derived(parseChapterFromReference(reference));
|
||||
let totalChapters = $derived(getChapterCount(bookId));
|
||||
let chapterOptions = $state<number[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (chapterOptions.length === 0) {
|
||||
chapterOptions = generateChapterOptions(correctChapter, totalChapters);
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (chapterOptions.length === 0) {
|
||||
chapterOptions = generateChapterOptions(
|
||||
correctChapter,
|
||||
totalChapters,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let selectedChapter = $state<number | null>(null);
|
||||
let hasAnswered = $state(false);
|
||||
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 ?? [];
|
||||
}
|
||||
});
|
||||
// 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 })
|
||||
);
|
||||
});
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
function handleChapterSelect(chapter: number) {
|
||||
if (hasAnswered) return;
|
||||
selectedChapter = chapter;
|
||||
hasAnswered = true;
|
||||
if (onCompleted) {
|
||||
onCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
let isCorrect = $derived(
|
||||
selectedChapter !== null && selectedChapter === correctChapter
|
||||
);
|
||||
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"
|
||||
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="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
|
||||
<div
|
||||
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
|
||||
>
|
||||
{#each chapterOptions as chapter}
|
||||
<button
|
||||
onclick={() => handleChapterSelect(chapter)}
|
||||
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
|
||||
${
|
||||
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"
|
||||
}
|
||||
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>
|
||||
>
|
||||
{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>
|
||||
{#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,6 +16,7 @@
|
||||
totalSolves: number;
|
||||
averageGuesses: number;
|
||||
tiedCount: number;
|
||||
percentile: number;
|
||||
}
|
||||
|
||||
interface WeightedMessage {
|
||||
@@ -169,7 +170,9 @@
|
||||
>
|
||||
<!-- Solve Rank Column -->
|
||||
<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}
|
||||
</div>
|
||||
<div class="text-sm sm:text-sm opacity-90 mt-1">
|
||||
@@ -180,7 +183,9 @@
|
||||
|
||||
<!-- Guess Rank Column -->
|
||||
<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)}
|
||||
</div>
|
||||
<div class="text-sm sm:text-sm opacity-90 mt-1">
|
||||
@@ -189,13 +194,20 @@
|
||||
? "solve"
|
||||
: "solves"}{statsData.tiedCount > 0
|
||||
? `, 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>
|
||||
|
||||
<!-- Average Column -->
|
||||
<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}
|
||||
</div>
|
||||
<div class="text-sm sm:text-sm opacity-90 mt-1">
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
totalSolves: number;
|
||||
averageGuesses: number;
|
||||
tiedCount: number;
|
||||
percentile: number;
|
||||
} | null>(null);
|
||||
|
||||
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
|
||||
|
||||
@@ -44,9 +44,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
// Solve rank: position in time-ordered list
|
||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||
|
||||
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
const guessRank = betterGuesses + 1;
|
||||
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
|
||||
const uniqueBetterGuessCounts = new Set(
|
||||
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)
|
||||
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 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({
|
||||
success: true,
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount }
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error submitting completion:', err);
|
||||
@@ -104,9 +110,11 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
// Solve rank: position in time-ordered list
|
||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||
|
||||
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
const guessRank = betterGuesses + 1;
|
||||
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
|
||||
const uniqueBetterGuessCounts = new Set(
|
||||
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)
|
||||
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 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({
|
||||
success: true,
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount }
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching stats:', err);
|
||||
|
||||
Reference in New Issue
Block a user