5 Commits

Author SHA1 Message Date
George Powell
7d93ead70c added demo stats page (needs refinement) 2026-02-04 23:35:23 -05:00
George Powell
4c82aa078b added identifying with umami anonymous ID 2026-02-03 23:43:03 -05:00
George Powell
2058149207 delayed loading of umami tracker script until page is already
interactive
2026-02-03 00:18:11 -05:00
George Powell
2bd86d37a1 added svelte mcp 2026-01-29 01:12:18 -05:00
George Powell
33d6fae446 added .env.example 2026-01-29 01:12:09 -05:00
10 changed files with 534 additions and 21 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
DATABASE_URL=example.db
AUTH_SECRET=your-random-secret-here
APPLE_ID=com.yourcompany.yourapp.client
APPLE_TEAM_ID=your-team-id
APPLE_KEY_ID=your-key-id
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
your-private-key-here
-----END PRIVATE KEY-----"

View File

@@ -1,3 +0,0 @@
EnglishNKJBible.xml
GreekModern1904Bible.xml
engwebu_usfx.xml

View File

@@ -4,7 +4,33 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book, etc.) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
(Make sure you use the Svelte agent to execute these commands)
## Available MCP Tools:
### 1. list-sections
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
### 2. get-documentation
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
### 3. svelte-autofixer
Analyzes Svelte code and returns issues and suggestions.
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
### 4. playground-link
Generates a Svelte Playground link with the provided code.
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
## Tech Stack
@@ -19,23 +45,23 @@ Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read
```bash
# Start development server
npm run dev
bun run dev
# Type checking
npm run check
npm run check:watch
bun run check
bun run check:watch
# Build for production
npm run build
bun run build
# Preview production build
npm run preview
bun run preview
# Database operations
npm run db:push # Push schema changes to database
npm run db:generate # Generate migrations
npm run db:migrate # Run migrations
npm run db:studio # Open Drizzle Studio GUI
bun run db:push # Push schema changes to database
bun run db:generate # Generate migrations
bun run db:migrate # Run migrations
bun run db:studio # Open Drizzle Studio GUI
```
## Architecture

View File

@@ -1,7 +1,7 @@
{
"name": "bibdle",
"private": true,
"version": "0.0.1",
"version": "3.0.0alpha",
"type": "module",
"scripts": {
"dev": "vite dev",

71
src/lib/utils/stats.ts Normal file
View File

@@ -0,0 +1,71 @@
export interface UserStats {
totalSolves: number;
avgGuesses: number;
gradeDistribution: {
'S++': number;
'S+': number;
'A+': number;
'A': number;
'B+': number;
'B': number;
'C+': number;
'C': number;
};
currentStreak: number;
bestStreak: number;
recentCompletions: Array<{
date: string;
guessCount: number;
grade: string;
}>;
}
export function getGradeColor(grade: string): string {
switch (grade) {
case 'S++': return 'text-purple-600 bg-purple-100';
case 'S+': return 'text-yellow-600 bg-yellow-100';
case 'A+': return 'text-green-600 bg-green-100';
case 'A': return 'text-green-500 bg-green-50';
case 'B+': return 'text-blue-600 bg-blue-100';
case 'B': return 'text-blue-500 bg-blue-50';
case 'C+': return 'text-orange-600 bg-orange-100';
case 'C': return 'text-red-600 bg-red-100';
default: return 'text-gray-600 bg-gray-100';
}
}
export function formatDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
}
export function getStreakMessage(currentStreak: number): string {
if (currentStreak === 0) {
return "Start your streak today!";
} else if (currentStreak === 1) {
return "Keep it going!";
} else if (currentStreak < 7) {
return `${currentStreak} days strong!`;
} else if (currentStreak < 30) {
return `${currentStreak} day streak - amazing!`;
} else {
return `${currentStreak} days - you're unstoppable!`;
}
}
export function getPerformanceMessage(avgGuesses: number): string {
if (avgGuesses <= 2) {
return "Exceptional performance!";
} else if (avgGuesses <= 4) {
return "Great performance!";
} else if (avgGuesses <= 6) {
return "Good performance!";
} else if (avgGuesses <= 8) {
return "Room for improvement!";
} else {
return "Keep practicing!";
}
}

View File

@@ -1,17 +1,31 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import "./layout.css";
import favicon from "$lib/assets/favicon.ico";
onMount(() => {
if (browser) {
const script = document.createElement('script');
script.defer = true;
script.src = 'https://umami.snail.city/script.js';
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
document.body.appendChild(script);
}
});
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<script
<!-- <script
defer
src="https://umami.snail.city/script.js"
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
data-domains="bibdle.com,www.bibdle.com"
></script>
></script> -->
</svelte:head>
{@render children()}

View File

@@ -168,6 +168,9 @@
$effect(() => {
if (!browser) return;
anonymousId = getOrCreateAnonymousId();
if ((window as any).umami) {
(window as any).umami.identify(anonymousId);
}
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
statsSubmitted = localStorage.getItem(statsKey) === "true";
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
@@ -444,6 +447,14 @@
<span class="big-text"
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
>
<div class="mt-4">
<a
href="/stats?anonymousId={anonymousId}"
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
>
📊 View Stats
</a>
</div>
</div>
<div class="flex flex-col gap-6">

View File

@@ -0,0 +1,149 @@
import { db } from '$lib/server/db';
import { dailyCompletions, type DailyCompletion } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const anonymousId = url.searchParams.get('anonymousId');
if (!anonymousId) {
return {
stats: null,
error: 'No anonymous ID provided'
};
}
try {
// Get all completions for this user
const completions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, anonymousId))
.orderBy(desc(dailyCompletions.date));
if (completions.length === 0) {
return {
stats: {
totalSolves: 0,
avgGuesses: 0,
gradeDistribution: {
'S++': 0,
'S+': 0,
'A+': 0,
'A': 0,
'B+': 0,
'B': 0,
'C+': 0,
'C': 0
},
currentStreak: 0,
bestStreak: 0,
recentCompletions: []
}
};
}
// Calculate basic stats
const totalSolves = completions.length;
const totalGuesses = completions.reduce((sum: number, c: DailyCompletion) => sum + c.guessCount, 0);
const avgGuesses = Math.round((totalGuesses / totalSolves) * 100) / 100;
// Calculate grade distribution
const gradeDistribution = {
'S++': 0, // This will be calculated differently since we don't store chapter correctness
'S+': completions.filter((c: DailyCompletion) => c.guessCount === 1).length,
'A+': completions.filter((c: DailyCompletion) => c.guessCount === 2).length,
'A': completions.filter((c: DailyCompletion) => c.guessCount === 3).length,
'B+': completions.filter((c: DailyCompletion) => c.guessCount >= 4 && c.guessCount <= 6).length,
'B': completions.filter((c: DailyCompletion) => c.guessCount >= 7 && c.guessCount <= 10).length,
'C+': completions.filter((c: DailyCompletion) => c.guessCount >= 11 && c.guessCount <= 15).length,
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
};
// Calculate streaks
const sortedDates = completions
.map((c: DailyCompletion) => c.date)
.sort();
let currentStreak = 0;
let bestStreak = 0;
let tempStreak = 1;
if (sortedDates.length > 0) {
// Check if current streak is active (includes today or yesterday)
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const lastPlayedDate = sortedDates[sortedDates.length - 1];
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
currentStreak = 1;
// Count backwards from the most recent date
for (let i = sortedDates.length - 2; i >= 0; i--) {
const currentDate = new Date(sortedDates[i + 1]);
const prevDate = new Date(sortedDates[i]);
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 1) {
currentStreak++;
} else {
break;
}
}
}
// Calculate best streak
bestStreak = 1;
for (let i = 1; i < sortedDates.length; i++) {
const currentDate = new Date(sortedDates[i]);
const prevDate = new Date(sortedDates[i - 1]);
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 1) {
tempStreak++;
} else {
bestStreak = Math.max(bestStreak, tempStreak);
tempStreak = 1;
}
}
bestStreak = Math.max(bestStreak, tempStreak);
}
// Get recent completions (last 7 days)
const recentCompletions = completions
.slice(0, 7)
.map((c: DailyCompletion) => ({
date: c.date,
guessCount: c.guessCount,
grade: getGradeFromGuesses(c.guessCount)
}));
return {
stats: {
totalSolves,
avgGuesses,
gradeDistribution,
currentStreak,
bestStreak,
recentCompletions
}
};
} catch (error) {
console.error('Error fetching user stats:', error);
return {
stats: null,
error: 'Failed to fetch stats'
};
}
};
function getGradeFromGuesses(guessCount: number): string {
if (guessCount === 1) return "S+";
if (guessCount === 2) return "A+";
if (guessCount === 3) return "A";
if (guessCount >= 4 && guessCount <= 6) return "B+";
if (guessCount >= 7 && guessCount <= 10) return "B";
if (guessCount >= 11 && guessCount <= 15) return "C+";
return "C";
}

View File

@@ -0,0 +1,206 @@
<script lang="ts">
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import {
getGradeColor,
formatDate,
getStreakMessage,
getPerformanceMessage,
type UserStats
} from "$lib/utils/stats";
interface PageData {
stats: UserStats | null;
error?: string;
}
let { data }: { data: PageData } = $props();
let loading = $state(true);
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
}
onMount(async () => {
const anonymousId = getOrCreateAnonymousId();
if (!anonymousId) {
goto("/");
return;
}
// If no anonymousId in URL, redirect with it
const url = new URL(window.location.href);
if (!url.searchParams.get('anonymousId')) {
url.searchParams.set('anonymousId', anonymousId);
goto(url.pathname + url.search);
return;
}
loading = false;
});
function getGradePercentage(count: number, total: number): number {
return total > 0 ? Math.round((count / total) * 100) : 0;
}
$inspect(data);
</script>
<svelte:head>
<title>Stats | Bibdle</title>
<meta name="description" content="View your Bibdle game statistics and performance" />
</svelte:head>
<div class="min-h-screen bg-gradient-to-br from-amber-50 to-orange-100 p-4">
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-gray-800 mb-2">Your Stats</h1>
<p class="text-gray-600">Track your Bibdle performance over time</p>
<div class="mt-4">
<a
href="/"
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
>
← Back to Game
</a>
</div>
</div>
{#if loading}
<div class="text-center py-12">
<div class="inline-block w-8 h-8 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"></div>
<p class="mt-4 text-gray-600">Loading your stats...</p>
</div>
{:else if data.error}
<div class="text-center py-12">
<div class="bg-red-100 border border-red-300 rounded-lg p-6 max-w-md mx-auto">
<p class="text-red-700">{data.error}</p>
<a
href="/"
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Return to Game
</a>
</div>
</div>
{:else if !data.stats}
<div class="text-center py-12">
<div class="bg-yellow-100 border border-yellow-300 rounded-lg p-6 max-w-md mx-auto">
<p class="text-yellow-700">No stats available.</p>
<a
href="/"
class="mt-4 inline-block px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
>
Start Playing
</a>
</div>
</div>
{:else}
{@const stats = data.stats}
<!-- Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Total Solves -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="text-center">
<div class="text-3xl font-bold text-amber-600 mb-2">{stats.totalSolves}</div>
<div class="text-gray-600">Total Solves</div>
{#if stats.totalSolves > 0}
<div class="text-sm text-gray-500 mt-1">
{getPerformanceMessage(stats.avgGuesses)}
</div>
{/if}
</div>
</div>
<!-- Average Guesses -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="text-center">
<div class="text-3xl font-bold text-blue-600 mb-2">{stats.avgGuesses}</div>
<div class="text-gray-600">Avg. Guesses</div>
<div class="text-sm text-gray-500 mt-1">per solve</div>
</div>
</div>
<!-- Current Streak -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="text-center">
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
<div class="text-gray-600">Current Streak</div>
<div class="text-sm text-gray-500 mt-1">
{getStreakMessage(stats.currentStreak)}
</div>
</div>
</div>
</div>
<!-- Grade Distribution -->
{#if stats.totalSolves > 0}
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Grade Distribution</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{#each Object.entries(stats.gradeDistribution) as [grade, count]}
{@const percentage = getGradePercentage(count, stats.totalSolves)}
<div class="text-center">
<div class="mb-2">
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold {getGradeColor(grade)}">
{grade}
</span>
</div>
<div class="text-2xl font-bold text-gray-800">{count}</div>
<div class="text-sm text-gray-500">{percentage}%</div>
</div>
{/each}
</div>
</div>
<!-- Streak Info -->
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Streak Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="text-center">
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
<div class="text-gray-600">Current Streak</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-purple-600 mb-2">{stats.bestStreak}</div>
<div class="text-gray-600">Best Streak</div>
</div>
</div>
</div>
<!-- Recent Performance -->
{#if stats.recentCompletions.length > 0}
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Recent Performance</h2>
<div class="space-y-3">
{#each stats.recentCompletions as completion}
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
<div>
<span class="font-medium">{formatDate(completion.date)}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-gray-600">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span>
<span class="px-2 py-1 rounded text-sm font-semibold {getGradeColor(completion.grade)}">
{completion.grade}
</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/if}
{/if}
</div>
</div>

40
todo.md
View File

@@ -1,17 +1,22 @@
# in progress
- Show new/old testament after 3 guesses and section after 7 guesses
- Add sections for "first letter", "Canonical/deutero", etc...
- Make the UI more "wordle-like" ()
- How do you balance rewarding knowledge vs incentivising learning?
# todo
- login
- login route
- impossible mode (1904 greek bible) three guesses only.
- share both classic and impossible mode with both buttons
- add imposter mode
- improve imposter mode
- Show new/old testament after 3 guesses and section after 7 guesses
- Add sections for "first letter", "Canonical/deutero", etc...
- How do you balance rewarding knowledge vs incentivising learning?
- instructions
@@ -54,6 +59,29 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
# done
## february 2nd
- created rss feed
- fixed "first letter" clue edge cases
- updated ranking formula
## january 28th
- add percentile stats, update chapter guess UI
- fixed middle statline (removed meaningless %)
- added instructions
- added email button
- added test buttons for 3.0 UI/UX
- package upgrades
## january 26th
- Make the UI more "wordle-like"
- added deployment script (./deploy.sh)
- added bluesky button
- added "first letter" column
- added imposter mode, v0.1 (mom likes it) but needs work
## january 5th
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
@@ -64,6 +92,8 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
- For bonus points: guess the verse/psalm number
- major UI styling revamp
-- 2026 --
## december 30th
- merged the embeddings/similarity route into production