mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
18 Commits
rss
...
95725ab4fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95725ab4fe | ||
|
|
06ff0820ce | ||
|
|
3cf95152e6 | ||
|
|
c04899d419 | ||
|
|
6161ef75a1 | ||
|
|
9d7399769a | ||
|
|
b1591229ba | ||
|
|
96024d5048 | ||
|
|
86f81cf9dd | ||
|
|
24a5fdbb80 | ||
|
|
dfe1c40a8a | ||
|
|
dfe784b744 | ||
|
|
6bced13543 | ||
|
|
7d93ead70c | ||
|
|
4c82aa078b | ||
|
|
2058149207 | ||
|
|
2bd86d37a1 | ||
|
|
33d6fae446 |
@@ -5,6 +5,7 @@
|
||||
"Read(./secrets/**)",
|
||||
"Read(./config/credentials.json)",
|
||||
"Read(./build)",
|
||||
"Read(./**.xml)",
|
||||
"Read(./embeddings**)"
|
||||
]
|
||||
}
|
||||
|
||||
13
.env.example
13
.env.example
@@ -1,18 +1,5 @@
|
||||
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
3
.gitignore
vendored
@@ -28,5 +28,4 @@ vite.config.ts.timestamp-*
|
||||
llms-*
|
||||
|
||||
embeddings*
|
||||
*bible.xml
|
||||
engwebu_usfx.xml
|
||||
*.xml
|
||||
@@ -1,3 +0,0 @@
|
||||
EnglishNKJBible.xml
|
||||
GreekModern1904Bible.xml
|
||||
engwebu_usfx.xml
|
||||
46
CLAUDE.md
46
CLAUDE.md
@@ -4,7 +4,33 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
|
||||
Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book, etc.) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
|
||||
|
||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||
|
||||
(Make sure you use the Svelte agent to execute these commands)
|
||||
|
||||
## Available MCP Tools:
|
||||
|
||||
### 1. list-sections
|
||||
|
||||
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
||||
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
|
||||
|
||||
### 2. get-documentation
|
||||
|
||||
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
|
||||
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
|
||||
|
||||
### 3. svelte-autofixer
|
||||
|
||||
Analyzes Svelte code and returns issues and suggestions.
|
||||
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||
|
||||
### 4. playground-link
|
||||
|
||||
Generates a Svelte Playground link with the provided code.
|
||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -19,23 +45,23 @@ Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read
|
||||
|
||||
```bash
|
||||
# Start development server
|
||||
npm run dev
|
||||
bun run dev
|
||||
|
||||
# Type checking
|
||||
npm run check
|
||||
npm run check:watch
|
||||
bun run check
|
||||
bun run check:watch
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
bun run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
bun run preview
|
||||
|
||||
# Database operations
|
||||
npm run db:push # Push schema changes to database
|
||||
npm run db:generate # Generate migrations
|
||||
npm run db:migrate # Run migrations
|
||||
npm run db:studio # Open Drizzle Studio GUI
|
||||
bun run db:push # Push schema changes to database
|
||||
bun run db:generate # Generate migrations (DO NOT RUN)
|
||||
bun run db:migrate # Run migrations (DO NOT RUN)
|
||||
bun run db:studio # Open Drizzle Studio GUI
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
README.md
12
README.md
@@ -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.
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/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
|
||||
9
bun.lock
9
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/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
11
drizzle.test.config.ts
Normal 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
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "bibdle",
|
||||
"private": true,
|
||||
"version": "2.5.0",
|
||||
"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"
|
||||
}
|
||||
|
||||
217
src/lib/components/AuthModal.svelte
Normal file
217
src/lib/components/AuthModal.svelte
Normal 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}
|
||||
@@ -32,6 +32,7 @@
|
||||
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>
|
||||
@@ -42,6 +43,7 @@
|
||||
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"
|
||||
|
||||
@@ -28,11 +28,6 @@
|
||||
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",
|
||||
@@ -49,27 +44,16 @@
|
||||
(correctBook?.section === "Pauline Epistles" ||
|
||||
correctBook?.section === "General Epistles") &&
|
||||
correctBook.name[0] === "1";
|
||||
const guessIsEpistlesWithNumber =
|
||||
(guess.book.section === "Pauline Epistles" ||
|
||||
guess.book.section === "General Epistles") &&
|
||||
guess.book.name[0] === "1";
|
||||
const guessStartsWithNumber = guess.book.name[0] === "1";
|
||||
|
||||
if (
|
||||
correctIsEpistlesWithNumber &&
|
||||
guessIsEpistlesWithNumber &&
|
||||
guessStartsWithNumber &&
|
||||
guess.firstLetterMatch
|
||||
) {
|
||||
const words = [
|
||||
"Exactly",
|
||||
"Right",
|
||||
"Yes",
|
||||
"Naturally",
|
||||
"Of course",
|
||||
"Sure",
|
||||
];
|
||||
return words[Math.floor(Math.random() * words.length)]; // Special wordplay case
|
||||
return "Yes"; // Special wordplay case
|
||||
}
|
||||
return getFirstLetter(guess.book.name); // Normal case: show first letter, ignoring numbers
|
||||
return guess.book.name[0]; // Normal case: just show the first letter
|
||||
case "testament":
|
||||
return (
|
||||
guess.book.testament.charAt(0).toUpperCase() +
|
||||
|
||||
@@ -76,12 +76,7 @@
|
||||
if (/^[a-z]/.test(formatted)) {
|
||||
formatted = "..." + formatted;
|
||||
}
|
||||
// Replace trailing punctuation with ellipsis
|
||||
// Preserve closing quotes/brackets that may have been added
|
||||
formatted = formatted.replace(
|
||||
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
|
||||
"...$1",
|
||||
);
|
||||
formatted = formatted.replace(/[,:;-—]$/, "...");
|
||||
return formatted;
|
||||
}
|
||||
</script>
|
||||
@@ -141,11 +136,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
115
src/lib/server/auth.test.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
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(),
|
||||
@@ -30,7 +39,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),
|
||||
}));
|
||||
|
||||
9
src/lib/server/db/test.ts
Normal file
9
src/lib/server/db/test.ts
Normal 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 });
|
||||
71
src/lib/utils/stats.ts
Normal file
71
src/lib/utils/stats.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export interface UserStats {
|
||||
totalSolves: number;
|
||||
avgGuesses: number;
|
||||
gradeDistribution: {
|
||||
'S++': number;
|
||||
'S+': number;
|
||||
'A+': number;
|
||||
'A': number;
|
||||
'B+': number;
|
||||
'B': number;
|
||||
'C+': number;
|
||||
'C': number;
|
||||
};
|
||||
currentStreak: number;
|
||||
bestStreak: number;
|
||||
recentCompletions: Array<{
|
||||
date: string;
|
||||
guessCount: number;
|
||||
grade: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function getGradeColor(grade: string): string {
|
||||
switch (grade) {
|
||||
case 'S++': return 'text-purple-600 bg-purple-100';
|
||||
case 'S+': return 'text-yellow-600 bg-yellow-100';
|
||||
case 'A+': return 'text-green-600 bg-green-100';
|
||||
case 'A': return 'text-green-500 bg-green-50';
|
||||
case 'B+': return 'text-blue-600 bg-blue-100';
|
||||
case 'B': return 'text-blue-500 bg-blue-50';
|
||||
case 'C+': return 'text-orange-600 bg-orange-100';
|
||||
case 'C': return 'text-red-600 bg-red-100';
|
||||
default: return 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
export function getStreakMessage(currentStreak: number): string {
|
||||
if (currentStreak === 0) {
|
||||
return "Start your streak today!";
|
||||
} else if (currentStreak === 1) {
|
||||
return "Keep it going!";
|
||||
} else if (currentStreak < 7) {
|
||||
return `${currentStreak} days strong!`;
|
||||
} else if (currentStreak < 30) {
|
||||
return `${currentStreak} day streak - amazing!`;
|
||||
} else {
|
||||
return `${currentStreak} days - you're unstoppable!`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPerformanceMessage(avgGuesses: number): string {
|
||||
if (avgGuesses <= 2) {
|
||||
return "Exceptional performance!";
|
||||
} else if (avgGuesses <= 4) {
|
||||
return "Great performance!";
|
||||
} else if (avgGuesses <= 6) {
|
||||
return "Good performance!";
|
||||
} else if (avgGuesses <= 8) {
|
||||
return "Room for improvement!";
|
||||
} else {
|
||||
return "Keep practicing!";
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.ico";
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
const script = document.createElement('script');
|
||||
script.defer = true;
|
||||
script.src = 'https://umami.snail.city/script.js';
|
||||
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
|
||||
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
});
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||
<script
|
||||
<!-- <script
|
||||
defer
|
||||
src="https://umami.snail.city/script.js"
|
||||
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
||||
data-domains="bibdle.com,www.bibdle.com"
|
||||
></script>
|
||||
></script> -->
|
||||
</svelte:head>
|
||||
{@render children()}
|
||||
|
||||
@@ -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,20 +93,13 @@ 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, tiedCount, percentile }
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,11 +87,6 @@
|
||||
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;
|
||||
|
||||
@@ -103,19 +103,15 @@
|
||||
// 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 === "General Epistles") &&
|
||||
correctBook.section === "Pauline Epistles" &&
|
||||
correctBook.name[0] === "1";
|
||||
const guessIsEpistlesWithNumber =
|
||||
(book.section === "Pauline Epistles" ||
|
||||
book.section === "General Epistles") &&
|
||||
book.name[0] === "1";
|
||||
const guessStartsWithNumber = book.name[0] === "1";
|
||||
|
||||
const firstLetterMatch =
|
||||
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
||||
correctIsEpistlesWithNumber && guessStartsWithNumber
|
||||
? true
|
||||
: getFirstLetter(book.name).toUpperCase() ===
|
||||
getFirstLetter(correctBook.name).toUpperCase();
|
||||
: book.name[0].toUpperCase() ===
|
||||
correctBook.name[0].toUpperCase();
|
||||
|
||||
console.log(
|
||||
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
|
||||
@@ -177,6 +173,10 @@
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
if ((window as any).umami) {
|
||||
// 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";
|
||||
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||
@@ -215,19 +215,15 @@
|
||||
|
||||
// Apply same first letter logic as in submitGuess
|
||||
const correctIsEpistlesWithNumber =
|
||||
(correctBook.section === "Pauline Epistles" ||
|
||||
correctBook.section === "General Epistles") &&
|
||||
correctBook.section === "Pauline Epistles" &&
|
||||
correctBook.name[0] === "1";
|
||||
const guessIsEpistlesWithNumber =
|
||||
(book.section === "Pauline Epistles" ||
|
||||
book.section === "General Epistles") &&
|
||||
book.name[0] === "1";
|
||||
const guessStartsWithNumber = book.name[0] === "1";
|
||||
|
||||
const firstLetterMatch =
|
||||
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
||||
correctIsEpistlesWithNumber && guessStartsWithNumber
|
||||
? true
|
||||
: getFirstLetter(book.name).toUpperCase() ===
|
||||
getFirstLetter(correctBook.name).toUpperCase();
|
||||
: book.name[0].toUpperCase() ===
|
||||
correctBook.name[0].toUpperCase();
|
||||
|
||||
return {
|
||||
book,
|
||||
@@ -269,7 +265,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);
|
||||
@@ -299,7 +295,7 @@
|
||||
async function submitStats() {
|
||||
try {
|
||||
const payload = {
|
||||
anonymousId,
|
||||
anonymousId: user ? user.id : anonymousId,
|
||||
date: dailyVerse.date,
|
||||
guessCount: guesses.length,
|
||||
};
|
||||
@@ -437,6 +433,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<!-- <title>Bibdle — A daily bible game{isDev ? " (dev)" : ""}</title> -->
|
||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||
<!-- <meta
|
||||
name="description"
|
||||
@@ -498,8 +495,45 @@
|
||||
<Credits />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-8 flex flex-col items-center gap-3">
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
href="/stats?{user ? `userId=${user.id}` : `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>
|
||||
|
||||
{#if user}
|
||||
<form method="POST" action="/auth/logout" use:enhance>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center 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 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} />
|
||||
|
||||
@@ -44,9 +44,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
// Solve rank: position in time-ordered list
|
||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||
|
||||
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
const guessRank = betterGuesses + 1;
|
||||
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
|
||||
const uniqueBetterGuessCounts = new Set(
|
||||
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
|
||||
);
|
||||
const guessRank = uniqueBetterGuessCounts.size + 1;
|
||||
|
||||
// Count ties: how many have the SAME guessCount (excluding self)
|
||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||
@@ -108,9 +110,11 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
// Solve rank: position in time-ordered list
|
||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||
|
||||
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
const guessRank = betterGuesses + 1;
|
||||
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
|
||||
const uniqueBetterGuessCounts = new Set(
|
||||
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
|
||||
);
|
||||
const guessRank = uniqueBetterGuessCounts.size + 1;
|
||||
|
||||
// Count ties: how many have the SAME guessCount (excluding self)
|
||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||
|
||||
13
src/routes/auth/logout/+page.server.ts
Normal file
13
src/routes/auth/logout/+page.server.ts
Normal 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, '/');
|
||||
}
|
||||
};
|
||||
110
src/routes/auth/signin/+page.server.ts
Normal file
110
src/routes/auth/signin/+page.server.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
64
src/routes/auth/signup/+page.server.ts
Normal file
64
src/routes/auth/signup/+page.server.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
};
|
||||
168
src/routes/stats/+page.server.ts
Normal file
168
src/routes/stats/+page.server.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
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, locals }) => {
|
||||
// Check if user is authenticated
|
||||
if (!locals.user) {
|
||||
return {
|
||||
stats: null,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all completions for this user
|
||||
const completions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId))
|
||||
.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: []
|
||||
},
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
},
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching user stats:', error);
|
||||
return {
|
||||
stats: null,
|
||||
error: 'Failed to fetch stats',
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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";
|
||||
}
|
||||
221
src/routes/stats/+page.svelte
Normal file
221
src/routes/stats/+page.svelte
Normal file
@@ -0,0 +1,221 @@
|
||||
<script lang="ts">
|
||||
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 {
|
||||
getGradeColor,
|
||||
formatDate,
|
||||
getStreakMessage,
|
||||
getPerformanceMessage,
|
||||
type UserStats
|
||||
} from "$lib/utils/stats";
|
||||
|
||||
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);
|
||||
|
||||
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 () => {
|
||||
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.requiresAuth}
|
||||
<div class="text-center py-12">
|
||||
<div class="bg-blue-100 border border-blue-300 rounded-lg p-8 max-w-md mx-auto">
|
||||
<h2 class="text-2xl font-bold text-blue-800 mb-4">Authentication Required</h2>
|
||||
<p class="text-blue-700 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-600 text-white rounded-lg hover:bg-gray-700 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>
|
||||
<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>
|
||||
|
||||
<AuthModal bind:isOpen={authModalOpen} anonymousId={""} />
|
||||
245
tests/signin-migration-unit.test.ts
Normal file
245
tests/signin-migration-unit.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
287
tests/signin-migration.test.ts
Normal file
287
tests/signin-migration.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
40
todo.md
40
todo.md
@@ -1,17 +1,22 @@
|
||||
# in progress
|
||||
|
||||
- Show new/old testament after 3 guesses and section after 7 guesses
|
||||
- Add sections for "first letter", "Canonical/deutero", etc...
|
||||
- Make the UI more "wordle-like" ()
|
||||
- How do you balance rewarding knowledge vs incentivising learning?
|
||||
|
||||
# todo
|
||||
|
||||
- login
|
||||
- login route
|
||||
|
||||
- impossible mode (1904 greek bible) three guesses only.
|
||||
|
||||
- share both classic and impossible mode with both buttons
|
||||
|
||||
- add imposter mode
|
||||
- improve imposter mode
|
||||
|
||||
- Show new/old testament after 3 guesses and section after 7 guesses
|
||||
- Add sections for "first letter", "Canonical/deutero", etc...
|
||||
|
||||
- How do you balance rewarding knowledge vs incentivising learning?
|
||||
|
||||
|
||||
- instructions
|
||||
|
||||
@@ -54,6 +59,29 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
||||
|
||||
# done
|
||||
|
||||
## february 2nd
|
||||
|
||||
- created rss feed
|
||||
- fixed "first letter" clue edge cases
|
||||
- updated ranking formula
|
||||
|
||||
## january 28th
|
||||
|
||||
- add percentile stats, update chapter guess UI
|
||||
- fixed middle statline (removed meaningless %)
|
||||
- added instructions
|
||||
- added email button
|
||||
- added test buttons for 3.0 UI/UX
|
||||
- package upgrades
|
||||
|
||||
## january 26th
|
||||
|
||||
- Make the UI more "wordle-like"
|
||||
- added deployment script (./deploy.sh)
|
||||
- added bluesky button
|
||||
- added "first letter" column
|
||||
- added imposter mode, v0.1 (mom likes it) but needs work
|
||||
|
||||
## january 5th
|
||||
|
||||
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
|
||||
@@ -64,6 +92,8 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
||||
- For bonus points: guess the verse/psalm number
|
||||
- major UI styling revamp
|
||||
|
||||
-- 2026 --
|
||||
|
||||
## december 30th
|
||||
|
||||
- merged the embeddings/similarity route into production
|
||||
|
||||
Reference in New Issue
Block a user