6 Commits
stats ... rss

Author SHA1 Message Date
George Powell
3947e8adb0 rss improvements 2026-02-02 02:52:53 -05:00
George Powell
244113671e created rss feed 2026-02-02 02:07:12 -05:00
George Powell
5b9b2f76f4 added some more words to the "first letter" edge case 2026-02-02 01:32:17 -05:00
George Powell
f7ec0742e1 fixed "first letter" clue edge cases 2026-02-02 01:27:12 -05:00
George Powell
d797b980ea updated ranking formula 2026-02-02 00:48:35 -05:00
George Powell
ff228fb547 Update package.json version 2026-01-28 23:35:33 -05:00
22 changed files with 534 additions and 760 deletions

View File

@@ -5,7 +5,6 @@
"Read(./secrets/**)", "Read(./secrets/**)",
"Read(./config/credentials.json)", "Read(./config/credentials.json)",
"Read(./build)", "Read(./build)",
"Read(./**.xml)",
"Read(./embeddings**)" "Read(./embeddings**)"
] ]
} }

View File

@@ -1,5 +1,18 @@
DATABASE_URL=example.db DATABASE_URL=example.db
PUBLIC_SITE_URL=https://bibdle.com
# nodemailer
SMTP_USERNAME=email@example.com
SMTP_TOKEN=TOKEN
SMTP_SERVER=smtp.example.com
SMTP_PORT=port
# note from mail provider: Enable TLS or SSL on the external service if it is supported.
# sign in with Discord
# sign in with google
# sign in with apple
AUTH_SECRET=your-random-secret-here AUTH_SECRET=your-random-secret-here
APPLE_ID=com.yourcompany.yourapp.client APPLE_ID=com.yourcompany.yourapp.client
APPLE_TEAM_ID=your-team-id APPLE_TEAM_ID=your-team-id

3
.gitignore vendored
View File

@@ -28,4 +28,5 @@ vite.config.ts.timestamp-*
llms-* llms-*
embeddings* embeddings*
*.xml *bible.xml
engwebu_usfx.xml

3
.rooignore Normal file
View File

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

View File

@@ -4,33 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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, etc.) 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) 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 ## Tech Stack
@@ -45,23 +19,23 @@ After completing the code, ask the user if they want a playground link. Only cal
```bash ```bash
# Start development server # Start development server
bun run dev npm run dev
# Type checking # Type checking
bun run check npm run check
bun run check:watch npm run check:watch
# Build for production # Build for production
bun run build npm run build
# Preview production build # Preview production build
bun run preview npm run preview
# Database operations # Database operations
bun run db:push # Push schema changes to database npm run db:push # Push schema changes to database
bun run db:generate # Generate migrations npm run db:generate # Generate migrations
bun run db:migrate # Run migrations npm run db:migrate # Run migrations
bun run db:studio # Open Drizzle Studio GUI npm run db:studio # Open Drizzle Studio GUI
``` ```
## Architecture ## Architecture

View File

@@ -24143,7 +24143,7 @@
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse> <verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, Where is their God?</verse> <verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, Where is their God?</verse>
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse> <verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse> <verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse> <verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse> <verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse> <verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>

31
analyze_top_users.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# analyze_top_users.sh
# Analyzes the daily_completions table to find the top 10 anonymous IDs by completion count
# Set database path from argument or default to dev.db
DB_PATH="${1:-dev.db}"
# Check if database file exists
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database file not found: $DB_PATH"
echo "Usage: $0 [database_path]"
exit 1
fi
# Run the analysis query
sqlite3 "$DB_PATH" <<EOF
.mode column
.headers on
.width 36 16 16 17
SELECT
anonymous_id,
COUNT(*) as completion_count,
MIN(date) as first_completion,
MAX(date) as latest_completion
FROM daily_completions
GROUP BY anonymous_id
ORDER BY completion_count DESC
LIMIT 10;
EOF

20
clear-today-verse.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/zsh
# Clear today's verse from daily_verses table
DB_PATH="dev.db"
TODAY=$(date +%Y-%m-%d)
echo "Deleting verse for date: $TODAY"
sqlite3 "$DB_PATH" "DELETE FROM daily_verses WHERE date = '$TODAY';"
if [ $? -eq 0 ]; then
echo "✓ Successfully deleted verse for $TODAY"
# Show remaining verses in table
COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_verses;")
echo "Remaining verses in database: $COUNT"
else
echo "✗ Failed to delete verse"
exit 1
fi

34
daily_completions_report.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env zsh
DB_PATH="./local.db"
# Check if database exists
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database not found at $DB_PATH"
exit 1
fi
# Query for daily completions on 2026-02-01 with ranking
echo "Daily Completions for 2026-02-01"
echo "================================="
echo ""
printf "%-12s %-10s %-6s\n" "Anonymous ID" "Guesses" "Rank"
printf "%-12s %-10s %-6s\n" "------------" "-------" "----"
# Execute query with custom column mode
sqlite3 "$DB_PATH" <<SQL
.mode column
.headers off
.width 12 10 6
SELECT
SUBSTR(anonymous_id, 1, 10) as anon_id,
guess_count,
RANK() OVER (ORDER BY guess_count ASC) as rank
FROM daily_completions
WHERE date = '2026-02-01'
ORDER BY rank, guess_count;
SQL
echo ""
echo "Total entries:"
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '2026-02-01';"

View File

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

View File

@@ -28,6 +28,11 @@
return "bg-red-500 border-red-600"; return "bg-red-500 border-red-600";
} }
function getFirstLetter(bookName: string): string {
const match = bookName.match(/[a-zA-Z]/);
return match ? match[0] : bookName[0];
}
function getBoxContent( function getBoxContent(
guess: Guess, guess: Guess,
column: "book" | "firstLetter" | "testament" | "section", column: "book" | "firstLetter" | "testament" | "section",
@@ -44,16 +49,27 @@
(correctBook?.section === "Pauline Epistles" || (correctBook?.section === "Pauline Epistles" ||
correctBook?.section === "General Epistles") && correctBook?.section === "General Epistles") &&
correctBook.name[0] === "1"; correctBook.name[0] === "1";
const guessStartsWithNumber = guess.book.name[0] === "1"; const guessIsEpistlesWithNumber =
(guess.book.section === "Pauline Epistles" ||
guess.book.section === "General Epistles") &&
guess.book.name[0] === "1";
if ( if (
correctIsEpistlesWithNumber && correctIsEpistlesWithNumber &&
guessStartsWithNumber && guessIsEpistlesWithNumber &&
guess.firstLetterMatch guess.firstLetterMatch
) { ) {
return "Yes"; // Special wordplay case const words = [
"Exactly",
"Right",
"Yes",
"Naturally",
"Of course",
"Sure",
];
return words[Math.floor(Math.random() * words.length)]; // Special wordplay case
} }
return guess.book.name[0]; // Normal case: just show the first letter return getFirstLetter(guess.book.name); // Normal case: show first letter, ignoring numbers
case "testament": case "testament":
return ( return (
guess.book.testament.charAt(0).toUpperCase() + guess.book.testament.charAt(0).toUpperCase() +

View File

@@ -76,7 +76,12 @@
if (/^[a-z]/.test(formatted)) { if (/^[a-z]/.test(formatted)) {
formatted = "..." + formatted; formatted = "..." + formatted;
} }
formatted = formatted.replace(/[,:;-—]$/, "..."); // Replace trailing punctuation with ellipsis
// Preserve closing quotes/brackets that may have been added
formatted = formatted.replace(
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
"...$1",
);
return formatted; return formatted;
} }
</script> </script>
@@ -136,11 +141,11 @@
text-align: center; text-align: center;
} }
.instructions { /*.instructions {
text-align: center; text-align: center;
font-style: italic; font-style: italic;
color: #666; color: #666;
} }*/
.verses { .verses {
display: grid; display: grid;

View File

@@ -1,7 +1,5 @@
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core'; import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') }); export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
export const session = sqliteTable('session', { export const session = sqliteTable('session', {

View File

@@ -1,71 +0,0 @@
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,31 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import "./layout.css"; import "./layout.css";
import favicon from "$lib/assets/favicon.ico"; 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(); let { children } = $props();
</script> </script>
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<!-- <script <link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
<script
defer defer
src="https://umami.snail.city/script.js" src="https://umami.snail.city/script.js"
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc" data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
data-domains="bibdle.com,www.bibdle.com" data-domains="bibdle.com,www.bibdle.com"
></script> --> ></script>
</svelte:head> </svelte:head>
{@render children()} {@render children()}

View File

@@ -91,13 +91,20 @@ export const actions: Actions = {
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length; const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
const guessRank = betterGuesses + 1; const guessRank = betterGuesses + 1;
// Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
// Average guesses // Average guesses
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 { return {
success: true, success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses } stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
}; };
} }
}; };

View File

@@ -82,6 +82,11 @@
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1); return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
} }
function getFirstLetter(bookName: string): string {
const match = bookName.match(/[a-zA-Z]/);
return match ? match[0] : bookName[0];
}
function submitGuess(bookId: string) { function submitGuess(bookId: string) {
if (guesses.some((g) => g.book.id === bookId)) return; if (guesses.some((g) => g.book.id === bookId)) return;
@@ -98,15 +103,19 @@
// Special case: if correct book is in the Epistles + starts with "1", // Special case: if correct book is in the Epistles + starts with "1",
// any guess starting with "1" counts as first letter match // any guess starting with "1" counts as first letter match
const correctIsEpistlesWithNumber = const correctIsEpistlesWithNumber =
correctBook.section === "Pauline Epistles" && (correctBook.section === "Pauline Epistles" ||
correctBook.section === "General Epistles") &&
correctBook.name[0] === "1"; correctBook.name[0] === "1";
const guessStartsWithNumber = book.name[0] === "1"; const guessIsEpistlesWithNumber =
(book.section === "Pauline Epistles" ||
book.section === "General Epistles") &&
book.name[0] === "1";
const firstLetterMatch = const firstLetterMatch =
correctIsEpistlesWithNumber && guessStartsWithNumber correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
? true ? true
: book.name[0].toUpperCase() === : getFirstLetter(book.name).toUpperCase() ===
correctBook.name[0].toUpperCase(); getFirstLetter(correctBook.name).toUpperCase();
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}`,
@@ -168,9 +177,6 @@
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
anonymousId = getOrCreateAnonymousId(); anonymousId = getOrCreateAnonymousId();
if ((window as any).umami) {
(window as any).umami.identify(anonymousId);
}
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}`; const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
@@ -209,15 +215,19 @@
// Apply same first letter logic as in submitGuess // Apply same first letter logic as in submitGuess
const correctIsEpistlesWithNumber = const correctIsEpistlesWithNumber =
correctBook.section === "Pauline Epistles" && (correctBook.section === "Pauline Epistles" ||
correctBook.section === "General Epistles") &&
correctBook.name[0] === "1"; correctBook.name[0] === "1";
const guessStartsWithNumber = book.name[0] === "1"; const guessIsEpistlesWithNumber =
(book.section === "Pauline Epistles" ||
book.section === "General Epistles") &&
book.name[0] === "1";
const firstLetterMatch = const firstLetterMatch =
correctIsEpistlesWithNumber && guessStartsWithNumber correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
? true ? true
: book.name[0].toUpperCase() === : getFirstLetter(book.name).toUpperCase() ===
correctBook.name[0].toUpperCase(); getFirstLetter(correctBook.name).toUpperCase();
return { return {
book, book,
@@ -427,7 +437,6 @@
</script> </script>
<svelte:head> <svelte:head>
<!-- <title>Bibdle &mdash; A daily bible game{isDev ? " (dev)" : ""}</title> -->
<title>A daily bible game{isDev ? " (dev)" : ""}</title> <title>A daily bible game{isDev ? " (dev)" : ""}</title>
<!-- <meta <!-- <meta
name="description" name="description"
@@ -447,14 +456,6 @@
<span class="big-text" <span class="big-text"
>{isDev ? "Dev Edition | " : ""}{currentDate}</span >{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>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">

View File

@@ -44,11 +44,9 @@ 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 DISTINCT guess counts are better (grouped ranking) // Guess rank: count how many had FEWER guesses (ties get same rank)
const uniqueBetterGuessCounts = new Set( const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount) const guessRank = betterGuesses + 1;
);
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;
@@ -110,11 +108,9 @@ 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 DISTINCT guess counts are better (grouped ranking) // Guess rank: count how many had FEWER guesses (ties get same rank)
const uniqueBetterGuessCounts = new Set( const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount) const guessRank = betterGuesses + 1;
);
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;

View File

@@ -0,0 +1,145 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyVerses } from '$lib/server/db/schema';
import { desc } from 'drizzle-orm';
// Helper: Escape XML special characters
function escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// Helper: Format YYYY-MM-DD to RFC 822 date string
function formatRFC822(dateStr: string): string {
// Parse date in America/New_York timezone (EST/EDT)
// Assuming midnight ET
const date = new Date(dateStr + 'T00:00:00-05:00');
return date.toUTCString().replace('GMT', 'EST');
}
// Helper: Format YYYY-MM-DD to readable date
function formatReadableDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'America/New_York'
});
}
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
function formatVerseText(text: string): string {
let formatted = text;
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
const pairs: [string, string][] = [
['(', ')'],
['[', ']'],
['{', '}'],
['"', '"'],
["'", "'"],
['\u201C', '\u201D'], // " "
['\u2018', '\u2019'] // ' '
];
// Check if text starts with opening punctuation without closing
for (const [open, close] of pairs) {
if (formatted.startsWith(open) && !formatted.includes(close)) {
formatted += '...' + close;
break;
}
}
// Check if text ends with closing punctuation without opening
for (const [open, close] of pairs) {
if (formatted.endsWith(close) && !formatted.includes(open)) {
formatted = open + '...' + formatted;
break;
}
}
// Check if text contains unbalanced opening quotes (not at start) without closing
for (const [open, close] of pairs) {
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
if (openCount > closeCount) {
formatted += close;
break;
}
}
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
// Replace trailing punctuation with ellipsis
// Preserve closing quotes/brackets that may have been added
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
return formatted;
}
export const GET: RequestHandler = async ({ request }) => {
try {
// Query last 30 verses, ordered by date descending
const verses = await db
.select()
.from(dailyVerses)
.orderBy(desc(dailyVerses.date))
.limit(30);
// Generate ETag based on latest verse date
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
// Check if client has cached version
if (request.headers.get('If-None-Match') === etag) {
return new Response(null, { status: 304 });
}
// Get site URL from environment or use default
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
// Build RSS XML
const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
const items = verses
.map(
(verse) => `
<item>
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
<description>${escapeXml(formatVerseText(verse.verseText))}</description>
<link>${SITE_URL}</link>
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
<pubDate>${formatRFC822(verse.date)}</pubDate>
</item>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Bibdle</title>
<link>${SITE_URL}</link>
<description>A daily Bible game</description>
<language>en-us</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<ttl>720</ttl>${items}
</channel>
</rss>`;
return new Response(xml, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
ETag: etag
}
});
} catch (error) {
console.error('RSS feed generation error:', error);
return new Response('Internal Server Error', { status: 500 });
}
};

View File

@@ -1,149 +0,0 @@
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

@@ -1,206 +0,0 @@
<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,22 +1,17 @@
# in progress # 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 # todo
- login
- login route
- impossible mode (1904 greek bible) three guesses only. - impossible mode (1904 greek bible) three guesses only.
- share both classic and impossible mode with both buttons - share both classic and impossible mode with both buttons
- improve imposter mode - add 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 - instructions
@@ -59,29 +54,6 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
# done # 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 ## 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... - created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
@@ -92,8 +64,6 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
- For bonus points: guess the verse/psalm number - For bonus points: guess the verse/psalm number
- major UI styling revamp - major UI styling revamp
-- 2026 --
## december 30th ## december 30th
- merged the embeddings/similarity route into production - merged the embeddings/similarity route into production