mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
25 Commits
rss
...
290fb06fe9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
290fb06fe9 | ||
|
|
df8a9e62bb | ||
|
|
730b65201a | ||
|
|
78440cfbc3 | ||
|
|
482ee0a83a | ||
|
|
342bd323a1 | ||
|
|
95725ab4fe | ||
|
|
06ff0820ce | ||
|
|
3cf95152e6 | ||
|
|
c04899d419 | ||
|
|
6161ef75a1 | ||
|
|
9d7399769a | ||
|
|
b1591229ba | ||
|
|
96024d5048 | ||
|
|
86f81cf9dd | ||
|
|
24a5fdbb80 | ||
|
|
dfe1c40a8a | ||
|
|
dfe784b744 | ||
|
|
6bced13543 | ||
|
|
7d93ead70c | ||
|
|
4c82aa078b | ||
|
|
2058149207 | ||
|
|
9406498cc9 | ||
|
|
2bd86d37a1 | ||
|
|
33d6fae446 |
@@ -1,4 +1,5 @@
|
||||
DATABASE_URL=example.db
|
||||
|
||||
PUBLIC_SITE_URL=https://bibdle.com
|
||||
|
||||
# nodemailer
|
||||
|
||||
@@ -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
|
||||
|
||||
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.
|
||||
|
||||
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=="],
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
4
src/lib/assets/Twitter_Logo.svg
Normal file
4
src/lib/assets/Twitter_Logo.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 248 204">
|
||||
<path fill="#1d9bf0" d="M221.95 51.29c.15 2.17.15 4.34.15 6.53 0 66.73-50.8 143.69-143.69 143.69v-.04c-27.44.04-54.31-7.82-77.41-22.64 3.99.48 8 .72 12.02.73 22.74.02 44.83-7.61 62.72-21.66-21.61-.41-40.56-14.5-47.18-35.07 7.57 1.46 15.37 1.16 22.8-.87-23.56-4.76-40.51-25.46-40.51-49.5v-.64c7.02 3.91 14.88 6.08 22.92 6.32C11.58 63.31 4.74 33.79 18.14 10.71c25.64 31.55 63.47 50.73 104.08 52.76-4.07-17.54 1.49-35.92 14.61-48.25 20.34-19.12 52.33-18.14 71.45 2.19 11.31-2.23 22.15-6.38 32.07-12.26-3.77 11.69-11.66 21.62-22.2 27.93 10.01-1.18 19.79-3.86 29-7.95-6.78 10.16-15.32 19.01-25.2 26.16z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 732 B |
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}
|
||||
@@ -10,7 +10,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm rounded-2xl border border-white/50 shadow-sm {className}"
|
||||
class="inline-flex flex-col items-center bg-white/10 backdrop-blur-sm rounded-2xl border border-white/20 shadow-sm {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
||||
</script>
|
||||
|
||||
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
||||
@@ -32,16 +33,31 @@
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Bluesky"
|
||||
data-umami-event="Bluesky clicked"
|
||||
>
|
||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400"></div>
|
||||
|
||||
<a
|
||||
href="https://x.com/pupperpowell"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Twitter"
|
||||
data-umami-event="Twitter clicked"
|
||||
>
|
||||
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400"></div>
|
||||
|
||||
<a
|
||||
href="mailto:george+bibdle@silentsummit.co"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Send email"
|
||||
data-umami-event="Email clicked"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-700"
|
||||
|
||||
@@ -99,47 +99,35 @@
|
||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
|
||||
>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
>
|
||||
Book
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
||||
>
|
||||
Testament
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
||||
>
|
||||
Section
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
||||
>
|
||||
First Letter
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
||||
>
|
||||
Book
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each guesses as guess, rowIndex (guess.book.id)}
|
||||
<div class="flex gap-2 justify-center">
|
||||
<!-- Book Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
|
||||
guess.book.id === correctBookId,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||
>{getBoxContent(guess, "book")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Testament Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||
guess.testamentMatch,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
|
||||
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "testament")}</span
|
||||
@@ -152,7 +140,7 @@
|
||||
guess.sectionMatch,
|
||||
guess.adjacent,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
|
||||
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "section")}
|
||||
@@ -167,12 +155,24 @@
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||
guess.firstLetterMatch,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
|
||||
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "firstLetter")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Book Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 border-opacity-100 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
|
||||
guess.book.id === correctBookId,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||
>{getBoxContent(guess, "book")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
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,14 @@
|
||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
||||
export const user = sqliteTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
firstName: text('first_name'),
|
||||
lastName: text('last_name'),
|
||||
email: text('email').unique(),
|
||||
passwordHash: text('password_hash'),
|
||||
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
|
||||
});
|
||||
|
||||
export const session = sqliteTable('session', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -30,7 +38,7 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
||||
guessCount: integer('guess_count').notNull(),
|
||||
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => ({
|
||||
uniqueCompletion: unique().on(table.anonymousId, table.date),
|
||||
anonymousIdDateIndex: index('anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||
dateIndex: index('date_idx').on(table.date),
|
||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
||||
}));
|
||||
|
||||
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 });
|
||||
86
src/lib/utils/stats.ts
Normal file
86
src/lib/utils/stats.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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;
|
||||
}>;
|
||||
worstDay: {
|
||||
date: string;
|
||||
guessCount: number;
|
||||
} | null;
|
||||
bestBook: {
|
||||
bookId: string;
|
||||
avgGuesses: number;
|
||||
count: number;
|
||||
} | null;
|
||||
mostSeenBook: {
|
||||
bookId: string;
|
||||
count: number;
|
||||
} | null;
|
||||
totalBooksSeenOT: number;
|
||||
totalBooksSeenNT: number;
|
||||
}
|
||||
|
||||
export function getGradeColor(grade: string): string {
|
||||
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,26 @@
|
||||
<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
|
||||
defer
|
||||
src="https://umami.snail.city/script.js"
|
||||
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
||||
data-domains="bibdle.com,www.bibdle.com"
|
||||
></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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -177,6 +182,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}`;
|
||||
@@ -258,7 +267,7 @@
|
||||
statsData,
|
||||
});
|
||||
|
||||
if (!browser || !isWon || !anonymousId) {
|
||||
if (!browser || !isWon || !anonymousId || statsData) {
|
||||
console.log("Basic conditions not met");
|
||||
return;
|
||||
}
|
||||
@@ -269,7 +278,7 @@
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`,
|
||||
`/api/submit-completion?anonymousId=${user ? user.id : anonymousId}&date=${dailyVerse.date}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
console.log("Stats response:", result);
|
||||
@@ -299,7 +308,7 @@
|
||||
async function submitStats() {
|
||||
try {
|
||||
const payload = {
|
||||
anonymousId,
|
||||
anonymousId: user ? user.id : anonymousId,
|
||||
date: dailyVerse.date,
|
||||
guessCount: guesses.length,
|
||||
};
|
||||
@@ -447,22 +456,26 @@
|
||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
|
||||
<div class="w-full max-w-3xl mx-auto px-4">
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4"
|
||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4 animate-fade-in-up"
|
||||
>
|
||||
<TitleAnimation />
|
||||
<div class="font-normal"></div>
|
||||
</h1>
|
||||
<div class="text-center mb-8">
|
||||
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
|
||||
<span class="big-text"
|
||||
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||
<div class="animate-fade-in-up animate-delay-200">
|
||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||
</div>
|
||||
|
||||
{#if !isWon}
|
||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
||||
</div>
|
||||
{:else}
|
||||
<WinScreen
|
||||
{grade}
|
||||
@@ -492,14 +505,55 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<GuessesTable {guesses} {correctBookId} />
|
||||
<div class="animate-fade-in-up animate-delay-600">
|
||||
<GuessesTable {guesses} {correctBookId} />
|
||||
</div>
|
||||
|
||||
{#if isWon}
|
||||
<Credits />
|
||||
<div class="animate-fade-in-up animate-delay-800">
|
||||
<Credits />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<a
|
||||
href="/stats?{user ? `userId=${user.id}` : `anonymousId=${anonymousId}`}"
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
📊 View Stats
|
||||
</a>
|
||||
|
||||
{#if user}
|
||||
<form method="POST" action="/auth/logout" use:enhance class="w-full md:w-auto">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🚪 Sign Out
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => authModalOpen = true}
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🔐 Sign In
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isDev}
|
||||
<div class="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded border">
|
||||
<div><strong>Debug Info:</strong></div>
|
||||
<div>User: {user ? `${user.email} (ID: ${user.id})` : 'Not signed in'}</div>
|
||||
<div>Session: {session ? `Expires ${session.expiresAt.toLocaleDateString()}` : 'No session'}</div>
|
||||
<div>Anonymous ID: {anonymousId || 'Not set'}</div>
|
||||
</div>
|
||||
<DevButtons />
|
||||
{/if}
|
||||
</div>
|
||||
{#if isDev}
|
||||
<DevButtons />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -17,3 +17,35 @@ html, body {
|
||||
color: rgb(107 114 128);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Page load animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.8s ease-out both;
|
||||
}
|
||||
|
||||
.animate-delay-200 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.animate-delay-400 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.animate-delay-600 {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
.animate-delay-800 {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
249
src/routes/stats/+page.server.ts
Normal file
249
src/routes/stats/+page.server.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions, dailyVerses, type DailyCompletion } from '$lib/server/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { bibleBooks } from '$lib/types/bible';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, 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: [],
|
||||
worstDay: null,
|
||||
bestBook: null,
|
||||
mostSeenBook: null,
|
||||
totalBooksSeenOT: 0,
|
||||
totalBooksSeenNT: 0
|
||||
},
|
||||
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)
|
||||
}));
|
||||
|
||||
// Calculate worst day (highest guess count)
|
||||
const worstDay = completions.reduce((max, c) =>
|
||||
c.guessCount > max.guessCount ? c : max,
|
||||
completions[0]
|
||||
);
|
||||
|
||||
// Get all daily verses to link completions to books
|
||||
const allVerses = await db
|
||||
.select()
|
||||
.from(dailyVerses);
|
||||
|
||||
// Create a map of date -> bookId
|
||||
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
|
||||
|
||||
// Calculate book-specific stats
|
||||
const bookStats = new Map<string, { count: number; totalGuesses: number }>();
|
||||
|
||||
for (const completion of completions) {
|
||||
const bookId = dateToBookId.get(completion.date);
|
||||
if (bookId) {
|
||||
const existing = bookStats.get(bookId) || { count: 0, totalGuesses: 0 };
|
||||
bookStats.set(bookId, {
|
||||
count: existing.count + 1,
|
||||
totalGuesses: existing.totalGuesses + completion.guessCount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find book you know the best (lowest avg guesses)
|
||||
let bestBook: { bookId: string; avgGuesses: number; count: number } | null = null;
|
||||
for (const [bookId, stats] of bookStats.entries()) {
|
||||
const avgGuesses = stats.totalGuesses / stats.count;
|
||||
if (!bestBook || avgGuesses < bestBook.avgGuesses) {
|
||||
bestBook = { bookId, avgGuesses, count: stats.count };
|
||||
}
|
||||
}
|
||||
|
||||
// Find most seen book
|
||||
let mostSeenBook: { bookId: string; count: number } | null = null;
|
||||
for (const [bookId, stats] of bookStats.entries()) {
|
||||
if (!mostSeenBook || stats.count > mostSeenBook.count) {
|
||||
mostSeenBook = { bookId, count: stats.count };
|
||||
}
|
||||
}
|
||||
|
||||
// Count unique books by testament
|
||||
const oldTestamentBooks = new Set<string>();
|
||||
const newTestamentBooks = new Set<string>();
|
||||
|
||||
for (const [bookId, _] of bookStats.entries()) {
|
||||
const book = bibleBooks.find(b => b.id === bookId);
|
||||
if (book) {
|
||||
if (book.testament === 'old') {
|
||||
oldTestamentBooks.add(bookId);
|
||||
} else {
|
||||
newTestamentBooks.add(bookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stats: {
|
||||
totalSolves,
|
||||
avgGuesses,
|
||||
gradeDistribution,
|
||||
currentStreak,
|
||||
bestStreak,
|
||||
recentCompletions,
|
||||
worstDay: {
|
||||
date: worstDay.date,
|
||||
guessCount: worstDay.guessCount
|
||||
},
|
||||
bestBook: bestBook ? {
|
||||
bookId: bestBook.bookId,
|
||||
avgGuesses: Math.round(bestBook.avgGuesses * 100) / 100,
|
||||
count: bestBook.count
|
||||
} : null,
|
||||
mostSeenBook: mostSeenBook ? {
|
||||
bookId: mostSeenBook.bookId,
|
||||
count: mostSeenBook.count
|
||||
} : null,
|
||||
totalBooksSeenOT: oldTestamentBooks.size,
|
||||
totalBooksSeenNT: newTestamentBooks.size
|
||||
},
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching user stats:', error);
|
||||
return {
|
||||
stats: null,
|
||||
error: 'Failed to fetch stats',
|
||||
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";
|
||||
}
|
||||
273
src/routes/stats/+page.svelte
Normal file
273
src/routes/stats/+page.svelte
Normal file
@@ -0,0 +1,273 @@
|
||||
<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 Container from "$lib/components/Container.svelte";
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
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;
|
||||
}
|
||||
|
||||
function getBookName(bookId: string): string {
|
||||
return bibleBooks.find(b => b.id === bookId)?.name || bookId;
|
||||
}
|
||||
|
||||
$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-gray-900 via-slate-900 to-gray-900 p-4 md:p-8">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6 md:mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">Your Stats</h1>
|
||||
<p class="text-sm md:text-base text-gray-300 mb-4">Track your Bibdle performance over time</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</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-300">Loading your stats...</p>
|
||||
</div>
|
||||
{:else if data.requiresAuth}
|
||||
<div class="text-center py-12">
|
||||
<div class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm">
|
||||
<h2 class="text-2xl font-bold text-blue-200 mb-4">Authentication Required</h2>
|
||||
<p class="text-blue-300 mb-6">You must be logged in to see your stats.</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
onclick={() => authModalOpen = true}
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
🔐 Sign In / Sign Up
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if data.error}
|
||||
<div class="text-center py-12">
|
||||
<div class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm">
|
||||
<p class="text-red-300">{data.error}</p>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Return to Game
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !data.stats}
|
||||
<div class="text-center py-12">
|
||||
<Container class="p-8 max-w-md mx-auto">
|
||||
<div class="text-yellow-400 mb-4 text-lg">No stats available yet.</div>
|
||||
<p class="text-gray-300 mb-6">Start playing to build your stats!</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-6 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium shadow-md"
|
||||
>
|
||||
Start Playing
|
||||
</a>
|
||||
</Container>
|
||||
</div>
|
||||
{:else}
|
||||
{@const stats = data.stats}
|
||||
|
||||
<!-- Key Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-6">
|
||||
<!-- Current Streak -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">🔥</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-orange-400 mb-1">{stats.currentStreak}</div>
|
||||
<div class="text-xs md:text-sm text-gray-300 font-medium">Current Streak</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Longest Streak -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">⭐</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-purple-400 mb-1">{stats.bestStreak}</div>
|
||||
<div class="text-xs md:text-sm text-gray-300 font-medium">Best Streak</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Average Guesses -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">🎯</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-blue-400 mb-1">{stats.avgGuesses}</div>
|
||||
<div class="text-xs md:text-sm text-gray-300 font-medium">Avg Guesses</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Total Solves -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">✅</div>
|
||||
<div class="text-2xl md:text-3xl font-bold text-green-400 mb-1">{stats.totalSolves}</div>
|
||||
<div class="text-xs md:text-sm text-gray-300 font-medium">Total Solves</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{#if stats.totalSolves > 0}
|
||||
<!-- Book Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 mb-6">
|
||||
<!-- Worst Day -->
|
||||
{#if stats.worstDay}
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">😅</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Worst Day</div>
|
||||
<div class="text-xl md:text-2xl font-bold text-red-400 truncate">{stats.worstDay.guessCount} guesses</div>
|
||||
<div class="text-xs md:text-sm text-gray-400">{formatDate(stats.worstDay.date)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<!-- Best Book -->
|
||||
{#if stats.bestBook}
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">🏆</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Best Book</div>
|
||||
<div class="text-lg md:text-xl font-bold text-amber-400 truncate">{getBookName(stats.bestBook.bookId)}</div>
|
||||
<div class="text-xs md:text-sm text-gray-400">{stats.bestBook.avgGuesses} avg guesses ({stats.bestBook.count}x)</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<!-- Most Seen Book -->
|
||||
{#if stats.mostSeenBook}
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">📖</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Most Seen Book</div>
|
||||
<div class="text-lg md:text-xl font-bold text-indigo-400 truncate">{getBookName(stats.mostSeenBook.bookId)}</div>
|
||||
<div class="text-xs md:text-sm text-gray-400">{stats.mostSeenBook.count} time{stats.mostSeenBook.count === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<!-- Total Books Seen -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">📚</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm md:text-base text-gray-300 font-medium mb-1">Unique Books</div>
|
||||
<div class="text-xl md:text-2xl font-bold text-teal-400">
|
||||
{stats.totalBooksSeenOT + stats.totalBooksSeenNT}
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-gray-400">OT: {stats.totalBooksSeenOT} / NT: {stats.totalBooksSeenNT}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<!-- Grade Distribution -->
|
||||
<Container class="p-5 md:p-6 mb-6">
|
||||
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">Grade Distribution</h2>
|
||||
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
|
||||
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
|
||||
{@const percentage = getGradePercentage(count, stats.totalSolves)}
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<span class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md:text-sm font-semibold {getGradeColor(grade)}">
|
||||
{grade}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-lg md:text-2xl font-bold text-gray-100">{count}</div>
|
||||
<div class="text-xs text-gray-400">{percentage}%</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Recent Performance -->
|
||||
{#if stats.recentCompletions.length > 0}
|
||||
<Container class="p-5 md:p-6">
|
||||
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">Recent Performance</h2>
|
||||
<div class="space-y-2">
|
||||
{#each stats.recentCompletions as completion (completion.date)}
|
||||
<div class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0">
|
||||
<div>
|
||||
<span class="text-sm md:text-base font-medium text-gray-200">{formatDate(completion.date)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 md:gap-3">
|
||||
<span class="text-xs md:text-sm text-gray-300">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span>
|
||||
<span class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(completion.grade)}">
|
||||
{completion.grade}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
{/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