26 Commits

Author SHA1 Message Date
George Powell
290fb06fe9 Reordered guesses table and added emphasis 2026-02-11 23:42:50 -05:00
George Powell
df8a9e62bb Add staggered page load animations
Implement elegant fadeInUp animations with staggered delays for main page
elements to create a polished, progressive reveal effect on page load.

Changes:
- layout.css: Added fadeInUp keyframes and delay utility classes
  (200ms, 400ms, 600ms, 800ms)
- +page.svelte: Applied animations to title, date, verse display,
  search input, guesses table, and credits

Animation sequence:
1. Title (0ms)
2. Date + Verse Display (200ms)
3. Search Input (400ms)
4. Guesses Table (600ms)
5. Credits (800ms - when won)

Creates a smooth, professional page load experience without changing any
existing design or functionality.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 17:24:14 -05:00
George Powell
730b65201a Redesign stats page with dark theme and enhanced statistics
- Implement dark gradient background with glassmorphism cards
- Add new statistics: worst day, best book, most seen book, unique books by testament
- Design mobile-first responsive grid layout with optimized spacing
- Update Container component to support dark theme (bg-white/10, border-white/20)
- Calculate book-specific stats by linking completions to daily verses
- Improve visual hierarchy with icons and color-coded stat cards

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 13:01:53 -05:00
George Powell
78440cfbc3 Fix infinite stats submission and improve mobile button layout
- Fix infinite loop in stats submission effect by adding statsData to early return condition
- Make bottom buttons (View Stats, Sign In/Out) full-width on small screens
- Buttons now stack vertically on mobile, side-by-side on medium+ screens

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 21:05:10 -05:00
George Powell
482ee0a83a added twitter logo 2026-02-09 12:16:44 -05:00
George Powell
342bd323a1 Merge branch 'main' into auth
Brought in latest changes from main including:
- RSS feed implementation
- First letter edge case fixes
- Updated ranking formula

Resolved conflicts by:
- Combining .env.example variables from both branches
- Keeping auth version (3.0.0alpha)
- Preserving extended user schema from auth
- Keeping onMount umami approach and adding RSS feed link

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 12:03:31 -05:00
George Powell
95725ab4fe Add test infrastructure and signin migration tests
- Add test-specific Drizzle config and database connection
- Create test version of auth module using test database
- Add comprehensive integration tests for signin migration logic
- Add unit tests for deduplication algorithm
- Tests cover edge cases like multiple duplicates, timing, and error handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-05 18:49:21 -05:00
George Powell
06ff0820ce Implement anonymous stats migration on signin
- Fix AuthModal to pass anonymousId on both signin and signup
- Add comprehensive migration logic in signin that moves anonymous completion stats to authenticated user
- Implement deduplication algorithm to handle overlapping completion dates
- Maintain earliest completion when duplicates exist

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-05 18:49:14 -05:00
George Powell
3cf95152e6 Replace unique constraint with index on dailyCompletions
Changes unique constraint on (anonymousId, date) to a regular index for better performance and to support the migration logic where duplicates may temporarily exist before deduplication.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-05 18:49:07 -05:00
George Powell
c04899d419 removed dumb $env/dynamic/private import and replaced with Bun.env 2026-02-05 18:14:13 -05:00
George Powell
6161ef75a1 added bun types as a dev dependency 2026-02-05 18:13:47 -05:00
George Powell
9d7399769a some little wording changes xd 2026-02-05 18:13:30 -05:00
George Powell
b1591229ba Move UI controls to bottom and require authentication for stats
- Moved stats button, auth buttons, and debug info to bottom of main page
- Added authentication requirement for /stats route
- Show login prompt for unauthenticated users accessing stats
- Include AuthModal for sign in/sign up from stats page

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-05 17:57:29 -05:00
George Powell
96024d5048 Support authenticated users in stats and page loading 2026-02-05 17:46:53 -05:00
George Powell
86f81cf9dd Use user ID for umami identify when authenticated 2026-02-05 17:46:14 -05:00
George Powell
24a5fdbb80 added umami events on social buttons 2026-02-05 17:43:51 -05:00
George Powell
dfe1c40a8a switched to bun:sqlite 2026-02-05 00:47:55 -05:00
George Powell
dfe784b744 added login modal 2026-02-05 00:47:47 -05:00
George Powell
6bced13543 Merge pull request #2 from pupperpowell/stats
added demo stats page (needs refinement)
2026-02-04 23:36:10 -05:00
George Powell
9406498cc9 Merge pull request #1 from pupperpowell/rss
created rss feeds
2026-02-02 02:53:50 -05:00
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
38 changed files with 2100 additions and 425 deletions

View File

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

View File

@@ -1,5 +1,19 @@
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
APPLE_ID=com.yourcompany.yourapp.client
APPLE_TEAM_ID=your-team-id

3
.gitignore vendored
View File

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

View File

@@ -59,8 +59,8 @@ bun run preview
# Database operations
bun run db:push # Push schema changes to database
bun run db:generate # Generate migrations
bun run db:migrate # Run migrations
bun run db:generate # Generate migrations (DO NOT RUN)
bun run db:migrate # Run migrations (DO NOT RUN)
bun run db:studio # Open Drizzle Studio GUI
```

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="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="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="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>

View File

@@ -8,10 +8,10 @@ If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
bunx sv create
# create a new project in my-app
npx sv create my-app
bunx sv create my-app
```
## Developing
@@ -19,10 +19,10 @@ npx sv create my-app
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
bun run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
bun run dev -- --open
```
## Building
@@ -30,9 +30,9 @@ npm run dev -- --open
To create a production version of your app:
```sh
npm run build
bun run build
```
You can preview the production build with `npm run preview`.
You can preview the production build with `bun run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

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

View File

@@ -6,7 +6,6 @@
"name": "bibdle",
"dependencies": {
"@xenova/transformers": "^2.17.2",
"better-sqlite3": "^12.6.2",
"fast-xml-parser": "^5.3.3",
"xml2js": "^0.6.2",
},
@@ -18,11 +17,11 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "^1.3.8",
"@types/node": "^22.19.7",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"svelte": "^5.48.3",
"svelte": "^5.48.5",
"svelte-check": "^4.3.5",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
@@ -229,6 +228,8 @@
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -273,6 +274,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],

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';"

11
drizzle.test.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'drizzle-kit';
if (!process.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set');
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
dialect: 'sqlite',
dbCredentials: { url: process.env.TEST_DATABASE_URL },
verbose: true,
strict: true
});

View File

@@ -8,6 +8,13 @@
"when": 1765934144883,
"tag": "0000_clumsy_impossible_man",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1770266674489,
"tag": "0001_loose_kree",
"breakpoints": true
}
]
}

View File

@@ -4,12 +4,14 @@
"version": "3.0.0alpha",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev": "bun --bun vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "bun test",
"test:watch": "bun test --watch",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
@@ -23,7 +25,7 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "^1.3.8",
"@types/node": "^22.19.7",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
@@ -35,7 +37,6 @@
},
"dependencies": {
"@xenova/transformers": "^2.17.2",
"better-sqlite3": "^12.6.2",
"fast-xml-parser": "^5.3.3",
"xml2js": "^0.6.2"
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 248 204">
<path fill="#1d9bf0" d="M221.95 51.29c.15 2.17.15 4.34.15 6.53 0 66.73-50.8 143.69-143.69 143.69v-.04c-27.44.04-54.31-7.82-77.41-22.64 3.99.48 8 .72 12.02.73 22.74.02 44.83-7.61 62.72-21.66-21.61-.41-40.56-14.5-47.18-35.07 7.57 1.46 15.37 1.16 22.8-.87-23.56-4.76-40.51-25.46-40.51-49.5v-.64c7.02 3.91 14.88 6.08 22.92 6.32C11.58 63.31 4.74 33.79 18.14 10.71c25.64 31.55 63.47 50.73 104.08 52.76-4.07-17.54 1.49-35.92 14.61-48.25 20.34-19.12 52.33-18.14 71.45 2.19 11.31-2.23 22.15-6.38 32.07-12.26-3.77 11.69-11.66 21.62-22.2 27.93 10.01-1.18 19.79-3.86 29-7.95-6.78 10.16-15.32 19.01-25.2 26.16z"/>
</svg>

After

Width:  |  Height:  |  Size: 732 B

View File

@@ -0,0 +1,217 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { browser } from '$app/environment';
import Container from './Container.svelte';
let {
isOpen = $bindable(),
anonymousId = ''
}: {
isOpen: boolean;
anonymousId: string;
} = $props();
let mode = $state<'signin' | 'signup'>('signin');
let loading = $state(false);
let error = $state('');
let success = $state('');
let email = $state('');
let password = $state('');
let firstName = $state('');
let lastName = $state('');
function resetForm() {
email = '';
password = '';
firstName = '';
lastName = '';
error = '';
success = '';
}
function switchMode() {
mode = mode === 'signin' ? 'signup' : 'signin';
resetForm();
}
function closeModal() {
isOpen = false;
resetForm();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeModal();
}
}
function handleSubmit() {
loading = true;
error = '';
success = '';
}
function handleResult(event: any) {
loading = false;
const result = event.result;
if (result.type === 'success') {
if (result.data?.success) {
success = mode === 'signin' ? 'Signed in successfully!' : 'Account created successfully!';
setTimeout(() => {
if (browser) {
window.location.reload();
}
}, 1000);
} else if (result.data?.error) {
error = result.data.error;
}
} else if (result.type === 'failure') {
error = result.data?.error || 'An error occurred. Please try again.';
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
<Container class="w-full max-w-md p-6 relative">
<button
type="button"
onclick={closeModal}
class="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl leading-none"
>
×
</button>
<div class="mb-6">
<h2 class="text-2xl font-bold text-white">
{mode === 'signin' ? 'Sign In' : 'Create Account'}
</h2>
</div>
<form
method="POST"
action={mode === 'signin' ? '/auth/signin' : '/auth/signup'}
use:enhance={({ formData }) => {
if (anonymousId) {
formData.append('anonymousId', anonymousId);
}
handleSubmit();
return handleResult;
}}
>
<div class="space-y-4">
{#if mode === 'signup'}
<div class="grid grid-cols-2 gap-4">
<div>
<label for="firstName" class="block text-sm font-medium text-white mb-1">
First Name
</label>
<input
id="firstName"
name="firstName"
type="text"
bind:value={firstName}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
placeholder="John"
/>
</div>
<div>
<label for="lastName" class="block text-sm font-medium text-white mb-1">
Last Name
</label>
<input
id="lastName"
name="lastName"
type="text"
bind:value={lastName}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
placeholder="Doe"
/>
</div>
</div>
{/if}
<div>
<label for="email" class="block text-sm font-medium text-white mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
bind:value={email}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
placeholder="john@example.com"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-white mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
bind:value={password}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
placeholder="••••••••"
minlength="6"
/>
{#if mode === 'signup'}
<p class="text-xs text-white/80 mt-1">Minimum 6 characters</p>
{/if}
</div>
</div>
{#if error}
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
<p class="text-sm text-red-600">{error}</p>
</div>
{/if}
{#if success}
<div class="mt-4 p-3 bg-green-50 border border-green-200 rounded-md">
<p class="text-sm text-green-600">{success}</p>
</div>
{/if}
<button
type="submit"
disabled={loading}
class="w-full mt-6 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{#if loading}
<span class="inline-flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{mode === 'signin' ? 'Signing in...' : 'Creating account...'}
</span>
{:else}
{mode === 'signin' ? 'Sign In' : 'Create Account'}
{/if}
</button>
</form>
<div class="mt-6 text-center">
<p class="text-sm text-white">
{mode === 'signin' ? "Don't have an account?" : 'Already have an account?'}
<button
type="button"
onclick={switchMode}
class="text-blue-300 hover:text-blue-200 font-medium ml-1"
>
{mode === 'signin' ? 'Create one' : 'Sign in'}
</button>
</p>
</div>
</Container>
</div>
{/if}

View File

@@ -10,7 +10,7 @@
</script>
<div
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm rounded-2xl border border-white/50 shadow-sm {className}"
class="inline-flex flex-col items-center bg-white/10 backdrop-blur-sm rounded-2xl border border-white/20 shadow-sm {className}"
>
{@render children()}
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { fade } from "svelte/transition";
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
</script>
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
@@ -32,16 +33,31 @@
rel="noopener noreferrer"
class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Bluesky"
data-umami-event="Bluesky clicked"
>
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
</a>
<div class="w-0.5 h-8 bg-gray-400"></div>
<a
href="https://x.com/pupperpowell"
target="_blank"
rel="noopener noreferrer"
class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Twitter"
data-umami-event="Twitter clicked"
>
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
</a>
<div class="w-0.5 h-8 bg-gray-400"></div>
<a
href="mailto:george+bibdle@silentsummit.co"
class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Send email"
data-umami-event="Email clicked"
>
<svg
class="w-8 h-8 text-gray-700"

View File

@@ -28,6 +28,11 @@
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(
guess: Guess,
column: "book" | "firstLetter" | "testament" | "section",
@@ -44,16 +49,27 @@
(correctBook?.section === "Pauline Epistles" ||
correctBook?.section === "General Epistles") &&
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 (
correctIsEpistlesWithNumber &&
guessStartsWithNumber &&
guessIsEpistlesWithNumber &&
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":
return (
guess.book.testament.charAt(0).toUpperCase() +
@@ -83,47 +99,35 @@
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
>
<div
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
>
Book
</div>
<div
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
>
Testament
</div>
<div
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
>
Section
</div>
<div
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
>
First Letter
</div>
<div
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
>
Book
</div>
</div>
{#each guesses as guess, rowIndex (guess.book.id)}
<div class="flex gap-2 justify-center">
<!-- Book Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
guess.book.id === correctBookId,
)}"
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-lg"
>{getBoxContent(guess, "book")}</span
>
</div>
<!-- Testament Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.testamentMatch,
)}"
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "testament")}</span
@@ -136,7 +140,7 @@
guess.sectionMatch,
guess.adjacent,
)}"
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "section")}
@@ -151,12 +155,24 @@
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.firstLetterMatch,
)}"
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "firstLetter")}</span
>
</div>
<!-- Book Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 border-opacity-100 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
guess.book.id === correctBookId,
)}"
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-lg"
>{getBoxContent(guess, "book")}</span
>
</div>
</div>
{/each}
</div>

View File

@@ -76,7 +76,12 @@
if (/^[a-z]/.test(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;
}
</script>
@@ -136,11 +141,11 @@
text-align: center;
}
.instructions {
/*.instructions {
text-align: center;
font-style: italic;
color: #666;
}
}*/
.verses {
display: grid;

115
src/lib/server/auth.test.ts Normal file
View File

@@ -0,0 +1,115 @@
import type { RequestEvent } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { testDb as db } from '$lib/server/db/test';
import * as table from '$lib/server/db/schema';
const DAY_IN_MS = 1000 * 60 * 60 * 24;
export const sessionCookieName = 'auth-session';
export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(18));
const token = encodeBase64url(bytes);
return token;
}
export async function createSession(token: string, userId: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: table.Session = {
id: sessionId,
userId,
expiresAt: new Date(Date.now() + DAY_IN_MS * 30)
};
await db.insert(table.session).values(session);
return session;
}
export async function validateSessionToken(token: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const [result] = await db
.select({
// Adjust user table here to tweak returned data
user: { id: table.user.id, email: table.user.email },
session: table.session
})
.from(table.session)
.innerJoin(table.user, eq(table.session.userId, table.user.id))
.where(eq(table.session.id, sessionId));
if (!result) {
return { session: null, user: null };
}
const { session, user } = result;
const sessionExpired = Date.now() >= session.expiresAt.getTime();
if (sessionExpired) {
await db.delete(table.session).where(eq(table.session.id, session.id));
return { session: null, user: null };
}
const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15;
if (renewSession) {
session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30);
await db
.update(table.session)
.set({ expiresAt: session.expiresAt })
.where(eq(table.session.id, session.id));
}
return { session, user };
}
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
export async function invalidateSession(sessionId: string) {
await db.delete(table.session).where(eq(table.session.id, sessionId));
}
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
event.cookies.set(sessionCookieName, token, {
expires: expiresAt,
path: '/'
});
}
export function deleteSessionTokenCookie(event: RequestEvent) {
event.cookies.delete(sessionCookieName, {
path: '/'
});
}
export async function hashPassword(password: string): Promise<string> {
return await Bun.password.hash(password, {
algorithm: 'argon2id',
memoryCost: 4,
timeCost: 3
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
try {
return await Bun.password.verify(password, hash);
} catch {
return false;
}
}
export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) {
const user: table.User = {
id: anonymousId, // Use anonymousId as the user ID to preserve stats
email,
passwordHash,
firstName: firstName || null,
lastName: lastName || null,
isPrivate: false
};
await db.insert(table.user).values(user);
return user;
}
export async function getUserByEmail(email: string) {
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
return user || null;
}

View File

@@ -31,7 +31,7 @@ export async function validateSessionToken(token: string) {
const [result] = await db
.select({
// Adjust user table here to tweak returned data
user: { id: table.user.id, username: table.user.username },
user: { id: table.user.id, email: table.user.email },
session: table.session
})
.from(table.session)
@@ -79,3 +79,37 @@ export function deleteSessionTokenCookie(event: RequestEvent) {
path: '/'
});
}
export async function hashPassword(password: string): Promise<string> {
return await Bun.password.hash(password, {
algorithm: 'argon2id',
memoryCost: 4,
timeCost: 3
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
try {
return await Bun.password.verify(password, hash);
} catch {
return false;
}
}
export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) {
const user: table.User = {
id: anonymousId, // Use anonymousId as the user ID to preserve stats
email,
passwordHash,
firstName: firstName || null,
lastName: lastName || null,
isPrivate: false
};
await db.insert(table.user).values(user);
return user;
}
export async function getUserByEmail(email: string) {
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
return user || null;
}

View File

@@ -1,10 +1,9 @@
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/bun-sqlite';
import { Database } from 'bun:sqlite';
import * as schema from './schema';
import { env } from '$env/dynamic/private';
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
if (!Bun.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
const client = new Database(env.DATABASE_URL);
const client = new Database(Bun.env.DATABASE_URL);
export const db = drizzle(client, { schema });

View File

@@ -1,8 +1,14 @@
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(),
firstName: text('first_name'),
lastName: text('last_name'),
email: text('email').unique(),
passwordHash: text('password_hash'),
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
});
export const session = sqliteTable('session', {
id: text('id').primaryKey(),
@@ -32,7 +38,7 @@ export const dailyCompletions = sqliteTable('daily_completions', {
guessCount: integer('guess_count').notNull(),
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
uniqueCompletion: unique().on(table.anonymousId, table.date),
anonymousIdDateIndex: index('anonymous_id_date_idx').on(table.anonymousId, table.date),
dateIndex: index('date_idx').on(table.date),
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
}));

View File

@@ -0,0 +1,9 @@
import { drizzle } from 'drizzle-orm/bun-sqlite';
import { Database } from 'bun:sqlite';
import * as schema from './schema';
if (!Bun.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set');
const testClient = new Database(Bun.env.TEST_DATABASE_URL);
export const testDb = drizzle(testClient, { schema });

View File

@@ -18,6 +18,21 @@ export interface UserStats {
guessCount: number;
grade: string;
}>;
worstDay: {
date: string;
guessCount: number;
} | null;
bestBook: {
bookId: string;
avgGuesses: number;
count: number;
} | null;
mostSeenBook: {
bookId: string;
count: number;
} | null;
totalBooksSeenOT: number;
totalBooksSeenNT: number;
}
export function getGradeColor(grade: string): string {

View File

@@ -21,11 +21,6 @@
<svelte:head>
<link rel="icon" href={favicon} />
<!-- <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> -->
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
</svelte:head>
{@render children()}

View File

@@ -34,14 +34,16 @@ async function getTodayVerse(): Promise<DailyVerse> {
return inserted;
}
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async ({ locals }) => {
const dailyVerse = await getTodayVerse();
const correctBook = getBookById(dailyVerse.bookId) ?? null;
return {
dailyVerse,
correctBookId: dailyVerse.bookId,
correctBook
correctBook,
user: locals.user,
session: locals.session
};
};
@@ -91,13 +93,20 @@ export const actions: Actions = {
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
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
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 {
success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses }
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
};
}
};

View File

@@ -11,7 +11,9 @@
import Credits from "$lib/components/Credits.svelte";
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import DevButtons from "$lib/components/DevButtons.svelte";
import AuthModal from "$lib/components/AuthModal.svelte";
import { getGrade } from "$lib/utils/game";
import { enhance } from '$app/forms';
interface Guess {
book: BibleBook;
@@ -25,6 +27,8 @@
let dailyVerse = $derived(data.dailyVerse);
let correctBookId = $derived(data.correctBookId);
let user = $derived(data.user);
let session = $derived(data.session);
let guesses = $state<Guess[]>([]);
@@ -37,6 +41,7 @@
let anonymousId = $state("");
let statsSubmitted = $state(false);
let authModalOpen = $state(false);
let statsData = $state<{
solveRank: number;
guessRank: number;
@@ -82,6 +87,11 @@
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) {
if (guesses.some((g) => g.book.id === bookId)) return;
@@ -98,15 +108,19 @@
// Special case: if correct book is in the Epistles + starts with "1",
// any guess starting with "1" counts as first letter match
const correctIsEpistlesWithNumber =
correctBook.section === "Pauline Epistles" &&
(correctBook.section === "Pauline Epistles" ||
correctBook.section === "General Epistles") &&
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 =
correctIsEpistlesWithNumber && guessStartsWithNumber
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
? true
: book.name[0].toUpperCase() ===
correctBook.name[0].toUpperCase();
: getFirstLetter(book.name).toUpperCase() ===
getFirstLetter(correctBook.name).toUpperCase();
console.log(
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
@@ -169,7 +183,8 @@
if (!browser) return;
anonymousId = getOrCreateAnonymousId();
if ((window as any).umami) {
(window as any).umami.identify(anonymousId);
// Use user id if logged in, otherwise use anonymous id
(window as any).umami.identify(user ? user.id : anonymousId);
}
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
statsSubmitted = localStorage.getItem(statsKey) === "true";
@@ -209,15 +224,19 @@
// Apply same first letter logic as in submitGuess
const correctIsEpistlesWithNumber =
correctBook.section === "Pauline Epistles" &&
(correctBook.section === "Pauline Epistles" ||
correctBook.section === "General Epistles") &&
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 =
correctIsEpistlesWithNumber && guessStartsWithNumber
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
? true
: book.name[0].toUpperCase() ===
correctBook.name[0].toUpperCase();
: getFirstLetter(book.name).toUpperCase() ===
getFirstLetter(correctBook.name).toUpperCase();
return {
book,
@@ -248,7 +267,7 @@
statsData,
});
if (!browser || !isWon || !anonymousId) {
if (!browser || !isWon || !anonymousId || statsData) {
console.log("Basic conditions not met");
return;
}
@@ -259,7 +278,7 @@
(async () => {
try {
const response = await fetch(
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`,
`/api/submit-completion?anonymousId=${user ? user.id : anonymousId}&date=${dailyVerse.date}`,
);
const result = await response.json();
console.log("Stats response:", result);
@@ -289,7 +308,7 @@
async function submitStats() {
try {
const payload = {
anonymousId,
anonymousId: user ? user.id : anonymousId,
date: dailyVerse.date,
guessCount: guesses.length,
};
@@ -427,7 +446,6 @@
</script>
<svelte:head>
<!-- <title>Bibdle &mdash; A daily bible game{isDev ? " (dev)" : ""}</title> -->
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
<!-- <meta
name="description"
@@ -438,30 +456,26 @@
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
<div class="w-full max-w-3xl mx-auto px-4">
<h1
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4"
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4 animate-fade-in-up"
>
<TitleAnimation />
<div class="font-normal"></div>
</h1>
<div class="text-center mb-8">
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
<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">
<div class="animate-fade-in-up animate-delay-200">
<VerseDisplay {data} {isWon} {blurChapter} />
</div>
{#if !isWon}
<div class="animate-fade-in-up animate-delay-400">
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
</div>
{:else}
<WinScreen
{grade}
@@ -491,14 +505,55 @@
/>
{/if}
<div class="animate-fade-in-up animate-delay-600">
<GuessesTable {guesses} {correctBookId} />
</div>
{#if isWon}
<div class="animate-fade-in-up animate-delay-800">
<Credits />
</div>
{/if}
</div>
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
<div class="flex flex-col md:flex-row gap-3">
<a
href="/stats?{user ? `userId=${user.id}` : `anonymousId=${anonymousId}`}"
class="inline-flex items-center justify-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>
{#if user}
<form method="POST" action="/auth/logout" use:enhance class="w-full md:w-auto">
<button
type="submit"
class="inline-flex items-center justify-center w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
>
🚪 Sign Out
</button>
</form>
{:else}
<button
onclick={() => authModalOpen = true}
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
>
🔐 Sign In
</button>
{/if}
</div>
{#if isDev}
<div class="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded border">
<div><strong>Debug Info:</strong></div>
<div>User: {user ? `${user.email} (ID: ${user.id})` : 'Not signed in'}</div>
<div>Session: {session ? `Expires ${session.expiresAt.toLocaleDateString()}` : 'No session'}</div>
<div>Anonymous ID: {anonymousId || 'Not set'}</div>
</div>
<DevButtons />
{/if}
</div>
</div>
</div>
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />

View File

@@ -44,11 +44,9 @@ 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 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;
// 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;
// Count ties: how many have the SAME guessCount (excluding self)
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
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 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;
// 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;
// Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;

View File

@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import * as auth from '$lib/server/auth';
export const actions: Actions = {
default: async ({ locals, cookies }) => {
if (locals.session) {
await auth.invalidateSession(locals.session.id);
}
auth.deleteSessionTokenCookie({ cookies });
redirect(302, '/');
}
};

View File

@@ -0,0 +1,110 @@
import { redirect, fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import * as auth from '$lib/server/auth';
import { db } from '$lib/server/db';
import { dailyCompletions } from '$lib/server/db/schema';
import { eq, inArray } from 'drizzle-orm';
export const actions: Actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get('email')?.toString();
const password = data.get('password')?.toString();
const anonymousId = data.get('anonymousId')?.toString();
if (!email || !password) {
return fail(400, { error: 'Email and password are required' });
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return fail(400, { error: 'Please enter a valid email address' });
}
if (password.length < 6) {
return fail(400, { error: 'Password must be at least 6 characters' });
}
try {
// Get user by email
const user = await auth.getUserByEmail(email);
if (!user || !user.passwordHash) {
return fail(400, { error: 'Invalid email or password' });
}
// Verify password
const isValidPassword = await auth.verifyPassword(password, user.passwordHash);
if (!isValidPassword) {
return fail(400, { error: 'Invalid email or password' });
}
// Migrate anonymous stats if different anonymous ID
if (anonymousId && anonymousId !== user.id) {
try {
// Update all daily completions from the local anonymous ID to the user's ID
await db
.update(dailyCompletions)
.set({ anonymousId: user.id })
.where(eq(dailyCompletions.anonymousId, anonymousId));
console.log(`Migrated stats from ${anonymousId} to ${user.id}`);
// Deduplicate any entries for the same date after migration
const allUserCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, user.id));
// Group by date to find duplicates
const dateGroups = new Map<string, typeof allUserCompletions>();
for (const completion of allUserCompletions) {
const date = completion.date;
if (!dateGroups.has(date)) {
dateGroups.set(date, []);
}
dateGroups.get(date)!.push(completion);
}
// Process dates with duplicates
const duplicateIds: string[] = [];
for (const [date, completions] of dateGroups) {
if (completions.length > 1) {
// Sort by completedAt timestamp (earliest first)
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
// Keep the first (earliest), mark the rest for deletion
const toDelete = completions.slice(1);
duplicateIds.push(...toDelete.map(c => c.id));
console.log(`Found ${completions.length} duplicates for date ${date}, keeping earliest, deleting ${toDelete.length}`);
}
}
// Delete duplicate entries
if (duplicateIds.length > 0) {
await db
.delete(dailyCompletions)
.where(inArray(dailyCompletions.id, duplicateIds));
console.log(`Deleted ${duplicateIds.length} duplicate completion entries`);
}
} catch (error) {
console.error('Error migrating anonymous stats:', error);
// Don't fail the signin if stats migration fails
}
}
// Create session
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, user.id);
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
return { success: true };
} catch (error) {
console.error('Sign in error:', error);
return fail(500, { error: 'An error occurred during sign in' });
}
}
};

View File

@@ -0,0 +1,64 @@
import { redirect, fail } from '@sveltejs/kit';
import type { Actions } from './$types';
import * as auth from '$lib/server/auth';
export const actions: Actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get('email')?.toString();
const password = data.get('password')?.toString();
const firstName = data.get('firstName')?.toString();
const lastName = data.get('lastName')?.toString();
const anonymousId = data.get('anonymousId')?.toString();
if (!email || !password || !anonymousId) {
return fail(400, { error: 'Email, password, and anonymous ID are required' });
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return fail(400, { error: 'Please enter a valid email address' });
}
if (password.length < 6) {
return fail(400, { error: 'Password must be at least 6 characters' });
}
try {
// Check if user already exists
const existingUser = await auth.getUserByEmail(email);
if (existingUser) {
return fail(400, { error: 'An account with this email already exists' });
}
// Hash password
const passwordHash = await auth.hashPassword(password);
// Create user with anonymousId as the user ID
const user = await auth.createUser(
anonymousId,
email,
passwordHash,
firstName || undefined,
lastName || undefined
);
// Create session
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, user.id);
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
return { success: true };
} catch (error) {
console.error('Sign up error:', error);
// Check if it's a unique constraint error (user with this ID already exists)
if (error instanceof Error && error.message.includes('UNIQUE constraint')) {
return fail(400, { error: 'This account is already registered. Please sign in instead.' });
}
return fail(500, { error: 'An error occurred during account creation' });
}
}
};

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

@@ -17,3 +17,35 @@ html, body {
color: rgb(107 114 128);
font-weight: 700;
}
/* Page load animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.8s ease-out both;
}
.animate-delay-200 {
animation-delay: 0.2s;
}
.animate-delay-400 {
animation-delay: 0.4s;
}
.animate-delay-600 {
animation-delay: 0.6s;
}
.animate-delay-800 {
animation-delay: 0.8s;
}

View File

@@ -1,15 +1,29 @@
import { db } from '$lib/server/db';
import { dailyCompletions, type DailyCompletion } from '$lib/server/db/schema';
import { dailyCompletions, dailyVerses, type DailyCompletion } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { bibleBooks } from '$lib/types/bible';
export const load: PageServerLoad = async ({ url }) => {
const anonymousId = url.searchParams.get('anonymousId');
if (!anonymousId) {
export const load: PageServerLoad = async ({ url, locals }) => {
// Check if user is authenticated
if (!locals.user) {
return {
stats: null,
error: 'No anonymous ID provided'
error: null,
user: null,
session: null,
requiresAuth: true
};
}
const userId = locals.user.id;
if (!userId) {
return {
stats: null,
error: 'No user ID provided',
user: locals.user,
session: locals.session
};
}
@@ -18,7 +32,7 @@ export const load: PageServerLoad = async ({ url }) => {
const completions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, anonymousId))
.where(eq(dailyCompletions.anonymousId, userId))
.orderBy(desc(dailyCompletions.date));
if (completions.length === 0) {
@@ -38,8 +52,15 @@ export const load: PageServerLoad = async ({ url }) => {
},
currentStreak: 0,
bestStreak: 0,
recentCompletions: []
}
recentCompletions: [],
worstDay: null,
bestBook: null,
mostSeenBook: null,
totalBooksSeenOT: 0,
totalBooksSeenNT: 0
},
user: locals.user,
session: locals.session
};
}
@@ -118,6 +139,66 @@ export const load: PageServerLoad = async ({ url }) => {
grade: getGradeFromGuesses(c.guessCount)
}));
// Calculate worst day (highest guess count)
const worstDay = completions.reduce((max, c) =>
c.guessCount > max.guessCount ? c : max,
completions[0]
);
// Get all daily verses to link completions to books
const allVerses = await db
.select()
.from(dailyVerses);
// Create a map of date -> bookId
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
// Calculate book-specific stats
const bookStats = new Map<string, { count: number; totalGuesses: number }>();
for (const completion of completions) {
const bookId = dateToBookId.get(completion.date);
if (bookId) {
const existing = bookStats.get(bookId) || { count: 0, totalGuesses: 0 };
bookStats.set(bookId, {
count: existing.count + 1,
totalGuesses: existing.totalGuesses + completion.guessCount
});
}
}
// Find book you know the best (lowest avg guesses)
let bestBook: { bookId: string; avgGuesses: number; count: number } | null = null;
for (const [bookId, stats] of bookStats.entries()) {
const avgGuesses = stats.totalGuesses / stats.count;
if (!bestBook || avgGuesses < bestBook.avgGuesses) {
bestBook = { bookId, avgGuesses, count: stats.count };
}
}
// Find most seen book
let mostSeenBook: { bookId: string; count: number } | null = null;
for (const [bookId, stats] of bookStats.entries()) {
if (!mostSeenBook || stats.count > mostSeenBook.count) {
mostSeenBook = { bookId, count: stats.count };
}
}
// Count unique books by testament
const oldTestamentBooks = new Set<string>();
const newTestamentBooks = new Set<string>();
for (const [bookId, _] of bookStats.entries()) {
const book = bibleBooks.find(b => b.id === bookId);
if (book) {
if (book.testament === 'old') {
oldTestamentBooks.add(bookId);
} else {
newTestamentBooks.add(bookId);
}
}
}
return {
stats: {
totalSolves,
@@ -125,15 +206,34 @@ export const load: PageServerLoad = async ({ url }) => {
gradeDistribution,
currentStreak,
bestStreak,
recentCompletions
}
recentCompletions,
worstDay: {
date: worstDay.date,
guessCount: worstDay.guessCount
},
bestBook: bestBook ? {
bookId: bestBook.bookId,
avgGuesses: Math.round(bestBook.avgGuesses * 100) / 100,
count: bestBook.count
} : null,
mostSeenBook: mostSeenBook ? {
bookId: mostSeenBook.bookId,
count: mostSeenBook.count
} : null,
totalBooksSeenOT: oldTestamentBooks.size,
totalBooksSeenNT: newTestamentBooks.size
},
user: locals.user,
session: locals.session
};
} catch (error) {
console.error('Error fetching user stats:', error);
return {
stats: null,
error: 'Failed to fetch stats'
error: 'Failed to fetch stats',
user: locals.user,
session: locals.session
};
}
};

View File

@@ -2,6 +2,10 @@
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { enhance } from '$app/forms';
import AuthModal from "$lib/components/AuthModal.svelte";
import Container from "$lib/components/Container.svelte";
import { bibleBooks } from "$lib/types/bible";
import {
getGradeColor,
formatDate,
@@ -13,9 +17,13 @@
interface PageData {
stats: UserStats | null;
error?: string;
user?: any;
session?: any;
requiresAuth?: boolean;
}
let { data }: { data: PageData } = $props();
let authModalOpen = $state(false);
let loading = $state(true);
@@ -31,20 +39,6 @@
}
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;
});
@@ -52,6 +46,10 @@
return total > 0 ? Math.round((count / total) * 100) : 0;
}
function getBookName(bookId: string): string {
return bibleBooks.find(b => b.id === bookId)?.name || bookId;
}
$inspect(data);
</script>
@@ -60,31 +58,50 @@
<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">
<div class="min-h-screen bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8">
<div class="max-w-6xl 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">
<div class="text-center mb-6 md:mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">Your Stats</h1>
<p class="text-sm md:text-base text-gray-300 mb-4">Track your Bibdle performance over time</p>
<a
href="/"
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
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"
>
← 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>
<p class="mt-4 text-gray-300">Loading your stats...</p>
</div>
{:else if data.requiresAuth}
<div class="text-center py-12">
<div class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm">
<h2 class="text-2xl font-bold text-blue-200 mb-4">Authentication Required</h2>
<p class="text-blue-300 mb-6">You must be logged in to see your stats.</p>
<div class="flex flex-col gap-3">
<button
onclick={() => authModalOpen = true}
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
🔐 Sign In / Sign Up
</button>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
← Back to Game
</a>
</div>
</div>
</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>
<div class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm">
<p class="text-red-300">{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"
@@ -95,112 +112,162 @@
</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>
<Container class="p-8 max-w-md mx-auto">
<div class="text-yellow-400 mb-4 text-lg">No stats available yet.</div>
<p class="text-gray-300 mb-6">Start playing to build your stats!</p>
<a
href="/"
class="mt-4 inline-block px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
class="inline-flex items-center px-6 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium shadow-md"
>
Start Playing
</a>
</div>
</Container>
</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">
<!-- Key Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-6">
<!-- Current Streak -->
<Container class="p-4 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 class="text-2xl md:text-3xl mb-1">🔥</div>
<div class="text-2xl md:text-3xl font-bold text-orange-400 mb-1">{stats.currentStreak}</div>
<div class="text-xs md:text-sm text-gray-300 font-medium">Current Streak</div>
</div>
</Container>
<!-- Longest Streak -->
<Container class="p-4 md:p-6">
<div class="text-center">
<div class="text-2xl md:text-3xl mb-1"></div>
<div class="text-2xl md:text-3xl font-bold text-purple-400 mb-1">{stats.bestStreak}</div>
<div class="text-xs md:text-sm text-gray-300 font-medium">Best Streak</div>
</div>
</Container>
<!-- Average Guesses -->
<div class="bg-white rounded-lg shadow-md p-6">
<Container class="p-4 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 class="text-2xl md:text-3xl mb-1">🎯</div>
<div class="text-2xl md:text-3xl font-bold text-blue-400 mb-1">{stats.avgGuesses}</div>
<div class="text-xs md:text-sm text-gray-300 font-medium">Avg Guesses</div>
</div>
</Container>
<!-- Total Solves -->
<Container class="p-4 md:p-6">
<div class="text-center">
<div class="text-2xl md:text-3xl mb-1"></div>
<div class="text-2xl md:text-3xl font-bold text-green-400 mb-1">{stats.totalSolves}</div>
<div class="text-xs md:text-sm text-gray-300 font-medium">Total Solves</div>
</div>
</Container>
</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)}
{#if stats.totalSolves > 0}
<!-- Book Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 mb-6">
<!-- Worst Day -->
{#if stats.worstDay}
<Container class="p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="text-3xl md:text-4xl">😅</div>
<div class="flex-1 min-w-0">
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Worst Day</div>
<div class="text-xl md:text-2xl font-bold text-red-400 truncate">{stats.worstDay.guessCount} guesses</div>
<div class="text-xs md:text-sm text-gray-400">{formatDate(stats.worstDay.date)}</div>
</div>
</div>
</Container>
{/if}
<!-- Best Book -->
{#if stats.bestBook}
<Container class="p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="text-3xl md:text-4xl">🏆</div>
<div class="flex-1 min-w-0">
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Best Book</div>
<div class="text-lg md:text-xl font-bold text-amber-400 truncate">{getBookName(stats.bestBook.bookId)}</div>
<div class="text-xs md:text-sm text-gray-400">{stats.bestBook.avgGuesses} avg guesses ({stats.bestBook.count}x)</div>
</div>
</div>
</Container>
{/if}
<!-- Most Seen Book -->
{#if stats.mostSeenBook}
<Container class="p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="text-3xl md:text-4xl">📖</div>
<div class="flex-1 min-w-0">
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Most Seen Book</div>
<div class="text-lg md:text-xl font-bold text-indigo-400 truncate">{getBookName(stats.mostSeenBook.bookId)}</div>
<div class="text-xs md:text-sm text-gray-400">{stats.mostSeenBook.count} time{stats.mostSeenBook.count === 1 ? '' : 's'}</div>
</div>
</div>
</Container>
{/if}
<!-- Total Books Seen -->
<Container class="p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="text-3xl md:text-4xl">📚</div>
<div class="flex-1 min-w-0">
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Unique Books</div>
<div class="text-xl md:text-2xl font-bold text-teal-400">
{stats.totalBooksSeenOT + stats.totalBooksSeenNT}
</div>
<div class="text-xs md:text-sm text-gray-400">OT: {stats.totalBooksSeenOT} / NT: {stats.totalBooksSeenNT}</div>
</div>
</div>
</Container>
</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]}
<Container class="p-5 md:p-6 mb-6">
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">Grade Distribution</h2>
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
{@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)}">
<span class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md: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 class="text-lg md:text-2xl font-bold text-gray-100">{count}</div>
<div class="text-xs text-gray-400">{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>
</Container>
<!-- 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">
<Container class="p-5 md:p-6">
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">Recent Performance</h2>
<div class="space-y-2">
{#each stats.recentCompletions as completion (completion.date)}
<div class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0">
<div>
<span class="font-medium">{formatDate(completion.date)}</span>
<span class="text-sm md:text-base font-medium text-gray-200">{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)}">
<div class="flex items-center gap-2 md:gap-3">
<span class="text-xs md:text-sm text-gray-300">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span>
<span class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(completion.grade)}">
{completion.grade}
</span>
</div>
</div>
{/each}
</div>
</div>
</Container>
{/if}
{/if}
{/if}
</div>
</div>
<AuthModal bind:isOpen={authModalOpen} anonymousId="" />

View File

@@ -0,0 +1,245 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
describe('Signin Migration Logic (Unit Tests)', () => {
// Test the deduplication algorithm independently
it('should correctly identify and remove duplicates keeping earliest', () => {
// Mock completion data structure
type MockCompletion = {
id: string;
anonymousId: string;
date: string;
guessCount: number;
completedAt: Date;
};
// Test data: multiple completions on same date
const allUserCompletions: MockCompletion[] = [
{
id: 'comp1',
anonymousId: 'user123',
date: '2024-01-01',
guessCount: 4,
completedAt: new Date('2024-01-01T08:00:00Z') // Earliest
},
{
id: 'comp2',
anonymousId: 'user123',
date: '2024-01-01',
guessCount: 2,
completedAt: new Date('2024-01-01T14:00:00Z') // Later
},
{
id: 'comp3',
anonymousId: 'user123',
date: '2024-01-01',
guessCount: 6,
completedAt: new Date('2024-01-01T20:00:00Z') // Latest
},
{
id: 'comp4',
anonymousId: 'user123',
date: '2024-01-02',
guessCount: 3,
completedAt: new Date('2024-01-02T09:00:00Z') // Unique date
}
];
// Implement the deduplication logic from signin server action
const dateGroups = new Map<string, MockCompletion[]>();
for (const completion of allUserCompletions) {
const date = completion.date;
if (!dateGroups.has(date)) {
dateGroups.set(date, []);
}
dateGroups.get(date)!.push(completion);
}
// Process dates with duplicates
const duplicateIds: string[] = [];
const keptEntries: MockCompletion[] = [];
for (const [date, completions] of dateGroups) {
if (completions.length > 1) {
// Sort by completedAt timestamp (earliest first)
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
// Keep the first (earliest), mark the rest for deletion
const toKeep = completions[0];
const toDelete = completions.slice(1);
keptEntries.push(toKeep);
duplicateIds.push(...toDelete.map(c => c.id));
} else {
// Single entry for this date, keep it
keptEntries.push(completions[0]);
}
}
// Verify the logic worked correctly
expect(duplicateIds).toHaveLength(2); // comp2 and comp3 should be deleted
expect(duplicateIds).toContain('comp2');
expect(duplicateIds).toContain('comp3');
expect(duplicateIds).not.toContain('comp1'); // comp1 should be kept (earliest)
expect(duplicateIds).not.toContain('comp4'); // comp4 should be kept (unique date)
// Verify kept entries
expect(keptEntries).toHaveLength(2);
// Check that the earliest entry for 2024-01-01 was kept
const jan1Entry = keptEntries.find(e => e.date === '2024-01-01');
expect(jan1Entry).toBeTruthy();
expect(jan1Entry!.id).toBe('comp1'); // Earliest timestamp
expect(jan1Entry!.guessCount).toBe(4);
expect(jan1Entry!.completedAt.getTime()).toBe(new Date('2024-01-01T08:00:00Z').getTime());
// Check that unique date entry was preserved
const jan2Entry = keptEntries.find(e => e.date === '2024-01-02');
expect(jan2Entry).toBeTruthy();
expect(jan2Entry!.id).toBe('comp4');
});
it('should handle no duplicates correctly', () => {
type MockCompletion = {
id: string;
anonymousId: string;
date: string;
guessCount: number;
completedAt: Date;
};
// Test data: all unique dates
const allUserCompletions: MockCompletion[] = [
{
id: 'comp1',
anonymousId: 'user123',
date: '2024-01-01',
guessCount: 4,
completedAt: new Date('2024-01-01T08:00:00Z')
},
{
id: 'comp2',
anonymousId: 'user123',
date: '2024-01-02',
guessCount: 2,
completedAt: new Date('2024-01-02T14:00:00Z')
}
];
// Run deduplication logic
const dateGroups = new Map<string, MockCompletion[]>();
for (const completion of allUserCompletions) {
if (!dateGroups.has(completion.date)) {
dateGroups.set(completion.date, []);
}
dateGroups.get(completion.date)!.push(completion);
}
const duplicateIds: string[] = [];
for (const [date, completions] of dateGroups) {
if (completions.length > 1) {
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
const toDelete = completions.slice(1);
duplicateIds.push(...toDelete.map(c => c.id));
}
}
// Should find no duplicates
expect(duplicateIds).toHaveLength(0);
});
it('should handle edge case with same timestamp', () => {
type MockCompletion = {
id: string;
anonymousId: string;
date: string;
guessCount: number;
completedAt: Date;
};
// Edge case: same completion time (very unlikely but possible)
const sameTime = new Date('2024-01-01T08:00:00Z');
const allUserCompletions: MockCompletion[] = [
{
id: 'comp1',
anonymousId: 'user123',
date: '2024-01-01',
guessCount: 3,
completedAt: sameTime
},
{
id: 'comp2',
anonymousId: 'user123',
date: '2024-01-01',
guessCount: 5,
completedAt: sameTime
}
];
// Run deduplication logic
const dateGroups = new Map<string, MockCompletion[]>();
for (const completion of allUserCompletions) {
if (!dateGroups.has(completion.date)) {
dateGroups.set(completion.date, []);
}
dateGroups.get(completion.date)!.push(completion);
}
const duplicateIds: string[] = [];
for (const [date, completions] of dateGroups) {
if (completions.length > 1) {
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
const toDelete = completions.slice(1);
duplicateIds.push(...toDelete.map(c => c.id));
}
}
// Should still remove one duplicate (deterministically based on array order)
expect(duplicateIds).toHaveLength(1);
// Since they have the same timestamp, it keeps the first one in the sorted array
expect(duplicateIds[0]).toBe('comp2'); // Second entry gets removed
});
it('should validate migration condition logic', () => {
// Test the condition check that determines when migration should occur
const testCases = [
{
anonymousId: 'device2-id',
userId: 'device1-id',
shouldMigrate: true,
description: 'Different IDs should trigger migration'
},
{
anonymousId: 'same-id',
userId: 'same-id',
shouldMigrate: false,
description: 'Same IDs should not trigger migration'
},
{
anonymousId: null as any,
userId: 'user-id',
shouldMigrate: false,
description: 'Null anonymous ID should not trigger migration'
},
{
anonymousId: undefined as any,
userId: 'user-id',
shouldMigrate: false,
description: 'Undefined anonymous ID should not trigger migration'
},
{
anonymousId: '',
userId: 'user-id',
shouldMigrate: false,
description: 'Empty anonymous ID should not trigger migration'
}
];
for (const testCase of testCases) {
// This is the exact condition from signin/+page.server.ts
const shouldMigrate = !!(testCase.anonymousId && testCase.anonymousId !== testCase.userId);
expect(shouldMigrate).toBe(testCase.shouldMigrate);
}
});
});

View File

@@ -0,0 +1,287 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { testDb as db } from '../src/lib/server/db/test';
import { user, session, dailyCompletions } from '../src/lib/server/db/schema';
import * as auth from '../src/lib/server/auth.test';
import { eq, inArray } from 'drizzle-orm';
import crypto from 'node:crypto';
// Test helper functions
function generateTestUUID() {
return crypto.randomUUID();
}
async function createTestUser(anonymousId: string, email: string, password: string = 'testpass123') {
const passwordHash = await auth.hashPassword(password);
const testUser = await auth.createUser(anonymousId, email, passwordHash, 'Test', 'User');
return testUser;
}
async function createTestCompletion(anonymousId: string, date: string, guessCount: number, completedAt: Date) {
const completion = {
id: generateTestUUID(),
anonymousId,
date,
guessCount,
completedAt
};
await db.insert(dailyCompletions).values(completion);
return completion;
}
async function clearTestData() {
// Clear test data in reverse dependency order
await db.delete(session);
await db.delete(dailyCompletions);
await db.delete(user);
}
describe('Signin Stats Migration', () => {
beforeEach(async () => {
await clearTestData();
});
afterEach(async () => {
await clearTestData();
});
it('should migrate stats from local anonymous ID to user ID on signin', async () => {
// Setup: Create user with device 1 anonymous ID
const device1AnonymousId = generateTestUUID();
const device2AnonymousId = generateTestUUID();
const email = 'test@example.com';
const testUser = await createTestUser(device1AnonymousId, email);
// Add some completions for device 1 (user's original device)
await createTestCompletion(device1AnonymousId, '2024-01-01', 3, new Date('2024-01-01T08:00:00Z'));
await createTestCompletion(device1AnonymousId, '2024-01-02', 5, new Date('2024-01-02T09:00:00Z'));
// Add some completions for device 2 (before signin)
await createTestCompletion(device2AnonymousId, '2024-01-03', 2, new Date('2024-01-03T10:00:00Z'));
await createTestCompletion(device2AnonymousId, '2024-01-04', 4, new Date('2024-01-04T11:00:00Z'));
// Verify initial state
const initialDevice1Stats = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, device1AnonymousId));
const initialDevice2Stats = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
expect(initialDevice1Stats).toHaveLength(2);
expect(initialDevice2Stats).toHaveLength(2);
// Simulate signin action - this is what happens in signin/+page.server.ts
const user = await auth.getUserByEmail(email);
expect(user).toBeTruthy();
// Migrate stats (simulating the signin logic)
if (device2AnonymousId && device2AnonymousId !== user!.id) {
// Update all daily completions from device2 anonymous ID to user's ID
await db
.update(dailyCompletions)
.set({ anonymousId: user!.id })
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
}
// Verify migration worked
const finalUserStats = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, user!.id));
const remainingDevice2Stats = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
expect(finalUserStats).toHaveLength(4); // All 4 completions now under user ID
expect(remainingDevice2Stats).toHaveLength(0); // No more completions under device2 ID
// Verify the actual data is correct
const dates = finalUserStats.map(c => c.date).sort();
expect(dates).toEqual(['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04']);
});
it('should deduplicate entries for same date keeping earliest completion', async () => {
// Setup: User played same day on both devices
const device1AnonymousId = generateTestUUID();
const device2AnonymousId = generateTestUUID();
const email = 'test@example.com';
const testUser = await createTestUser(device1AnonymousId, email);
// Both devices played on same date - device1 played earlier and better
const date = '2024-01-01';
const earlierTime = new Date('2024-01-01T08:00:00Z');
const laterTime = new Date('2024-01-01T14:00:00Z');
await createTestCompletion(device1AnonymousId, date, 3, earlierTime); // Better score, earlier
await createTestCompletion(device2AnonymousId, date, 5, laterTime); // Worse score, later
// Also add unique dates to ensure they're preserved
await createTestCompletion(device1AnonymousId, '2024-01-02', 4, new Date('2024-01-02T09:00:00Z'));
await createTestCompletion(device2AnonymousId, '2024-01-03', 2, new Date('2024-01-03T10:00:00Z'));
// Migrate stats
const user = await auth.getUserByEmail(email);
await db
.update(dailyCompletions)
.set({ anonymousId: user!.id })
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
// Implement deduplication logic (from signin server action)
const allUserCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, user!.id));
// Group by date to find duplicates
const dateGroups = new Map<string, typeof allUserCompletions>();
for (const completion of allUserCompletions) {
const date = completion.date;
if (!dateGroups.has(date)) {
dateGroups.set(date, []);
}
dateGroups.get(date)!.push(completion);
}
// Process dates with duplicates
const duplicateIds: string[] = [];
for (const [date, completions] of dateGroups) {
if (completions.length > 1) {
// Sort by completedAt timestamp (earliest first)
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
// Keep the first (earliest), mark the rest for deletion
const toDelete = completions.slice(1);
duplicateIds.push(...toDelete.map(c => c.id));
}
}
// Delete duplicate entries
if (duplicateIds.length > 0) {
await db
.delete(dailyCompletions)
.where(inArray(dailyCompletions.id, duplicateIds));
}
// Verify deduplication worked correctly
const finalStats = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, user!.id));
expect(finalStats).toHaveLength(3); // One duplicate removed
// Verify the correct entry was kept for the duplicate date
const duplicateDateEntry = finalStats.find(c => c.date === date);
expect(duplicateDateEntry).toBeTruthy();
expect(duplicateDateEntry!.guessCount).toBe(3); // Better score kept
expect(duplicateDateEntry!.completedAt.getTime()).toBe(earlierTime.getTime()); // Earlier time kept
// Verify unique dates are preserved
const allDates = finalStats.map(c => c.date).sort();
expect(allDates).toEqual(['2024-01-01', '2024-01-02', '2024-01-03']);
});
it('should handle no migration when anonymous ID matches user ID', async () => {
// Setup: User signing in from same device they signed up on
const anonymousId = generateTestUUID();
const email = 'test@example.com';
const testUser = await createTestUser(anonymousId, email);
// Add some completions
await createTestCompletion(anonymousId, '2024-01-01', 3, new Date('2024-01-01T08:00:00Z'));
await createTestCompletion(anonymousId, '2024-01-02', 5, new Date('2024-01-02T09:00:00Z'));
// Verify initial state
const initialStats = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, anonymousId));
expect(initialStats).toHaveLength(2);
// Simulate signin with same anonymous ID (no migration needed)
const user = await auth.getUserByEmail(email);
// Migration logic should skip when IDs match
const shouldMigrate = anonymousId && anonymousId !== user!.id;
expect(shouldMigrate).toBe(false);
// Verify no changes
const finalStats = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, anonymousId));
expect(finalStats).toHaveLength(2);
expect(finalStats[0].anonymousId).toBe(anonymousId);
});
it('should handle multiple duplicates for same date correctly', async () => {
// Edge case: User played same date on 3+ devices
const device1AnonymousId = generateTestUUID();
const device2AnonymousId = generateTestUUID();
const device3AnonymousId = generateTestUUID();
const email = 'test@example.com';
const testUser = await createTestUser(device1AnonymousId, email);
const date = '2024-01-01';
// Three completions on same date at different times
await createTestCompletion(device1AnonymousId, date, 4, new Date('2024-01-01T08:00:00Z')); // Earliest
await createTestCompletion(device2AnonymousId, date, 2, new Date('2024-01-01T14:00:00Z')); // Middle
await createTestCompletion(device3AnonymousId, date, 6, new Date('2024-01-01T20:00:00Z')); // Latest
// Migrate all to user ID
const user = await auth.getUserByEmail(email);
await db
.update(dailyCompletions)
.set({ anonymousId: user!.id })
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
await db
.update(dailyCompletions)
.set({ anonymousId: user!.id })
.where(eq(dailyCompletions.anonymousId, device3AnonymousId));
// Implement deduplication
const allUserCompletions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, user!.id));
const dateGroups = new Map<string, typeof allUserCompletions>();
for (const completion of allUserCompletions) {
if (!dateGroups.has(completion.date)) {
dateGroups.set(completion.date, []);
}
dateGroups.get(completion.date)!.push(completion);
}
const duplicateIds: string[] = [];
for (const [_, completions] of dateGroups) {
if (completions.length > 1) {
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
const toDelete = completions.slice(1);
duplicateIds.push(...toDelete.map(c => c.id));
}
}
// Delete duplicates
for (const id of duplicateIds) {
await db.delete(dailyCompletions).where(eq(dailyCompletions.id, id));
}
// Verify only earliest kept
const finalStats = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, user!.id));
expect(finalStats).toHaveLength(1); // 2 duplicates removed
expect(finalStats[0].guessCount).toBe(4); // First device's score
expect(finalStats[0].completedAt.getTime()).toBe(new Date('2024-01-01T08:00:00Z').getTime());
});
});