mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-06 01:43:32 -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
|
DATABASE_URL=example.db
|
||||||
|
|
||||||
PUBLIC_SITE_URL=https://bibdle.com
|
PUBLIC_SITE_URL=https://bibdle.com
|
||||||
|
|
||||||
# nodemailer
|
# 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
|
## 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
|
## Tech Stack
|
||||||
|
|
||||||
@@ -19,23 +45,23 @@ Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start development server
|
# Start development server
|
||||||
npm run dev
|
bun run dev
|
||||||
|
|
||||||
# Type checking
|
# Type checking
|
||||||
npm run check
|
bun run check
|
||||||
npm run check:watch
|
bun run check:watch
|
||||||
|
|
||||||
# Build for production
|
# Build for production
|
||||||
npm run build
|
bun run build
|
||||||
|
|
||||||
# Preview production build
|
# Preview production build
|
||||||
npm run preview
|
bun run preview
|
||||||
|
|
||||||
# Database operations
|
# Database operations
|
||||||
npm run db:push # Push schema changes to database
|
bun run db:push # Push schema changes to database
|
||||||
npm run db:generate # Generate migrations
|
bun run db:generate # Generate migrations (DO NOT RUN)
|
||||||
npm run db:migrate # Run migrations
|
bun run db:migrate # Run migrations (DO NOT RUN)
|
||||||
npm run db:studio # Open Drizzle Studio GUI
|
bun run db:studio # Open Drizzle Studio GUI
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## 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
|
```sh
|
||||||
# create a new project in the current directory
|
# create a new project in the current directory
|
||||||
npx sv create
|
bunx sv create
|
||||||
|
|
||||||
# create a new project in my-app
|
# create a new project in my-app
|
||||||
npx sv create my-app
|
bunx sv create my-app
|
||||||
```
|
```
|
||||||
|
|
||||||
## Developing
|
## 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:
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run dev
|
bun run dev
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
# or start the server and open the app in a new browser tab
|
||||||
npm run dev -- --open
|
bun run dev -- --open
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
@@ -30,9 +30,9 @@ npm run dev -- --open
|
|||||||
To create a production version of your app:
|
To create a production version of your app:
|
||||||
|
|
||||||
```sh
|
```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.
|
> 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",
|
"name": "bibdle",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"better-sqlite3": "^12.6.2",
|
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
"xml2js": "^0.6.2",
|
"xml2js": "^0.6.2",
|
||||||
},
|
},
|
||||||
@@ -18,11 +17,11 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/bun": "^1.3.8",
|
||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"svelte": "^5.48.3",
|
"svelte": "^5.48.5",
|
||||||
"svelte-check": "^4.3.5",
|
"svelte-check": "^4.3.5",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"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/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/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@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=="],
|
"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=="],
|
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
"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,
|
"when": 1765934144883,
|
||||||
"tag": "0000_clumsy_impossible_man",
|
"tag": "0000_clumsy_impossible_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1770266674489,
|
||||||
|
"tag": "0001_loose_kree",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "bibdle",
|
"name": "bibdle",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.5.0",
|
"version": "3.0.0alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "bun --bun vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"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:push": "drizzle-kit push",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
@@ -23,7 +25,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/bun": "^1.3.8",
|
||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
@@ -35,7 +37,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"better-sqlite3": "^12.6.2",
|
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
"xml2js": "^0.6.2"
|
"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>
|
</script>
|
||||||
|
|
||||||
<div
|
<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()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||||
|
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
||||||
@@ -32,16 +33,31 @@
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="inline-flex hover:opacity-80 transition-opacity"
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
aria-label="Follow on Bluesky"
|
aria-label="Follow on Bluesky"
|
||||||
|
data-umami-event="Bluesky clicked"
|
||||||
>
|
>
|
||||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="w-0.5 h-8 bg-gray-400"></div>
|
<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
|
<a
|
||||||
href="mailto:george+bibdle@silentsummit.co"
|
href="mailto:george+bibdle@silentsummit.co"
|
||||||
class="inline-flex hover:opacity-80 transition-opacity"
|
class="inline-flex hover:opacity-80 transition-opacity"
|
||||||
aria-label="Send email"
|
aria-label="Send email"
|
||||||
|
data-umami-event="Email clicked"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-8 h-8 text-gray-700"
|
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"
|
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
|
||||||
>
|
>
|
||||||
<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"
|
||||||
>
|
|
||||||
Book
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
|
||||||
>
|
>
|
||||||
Testament
|
Testament
|
||||||
</div>
|
</div>
|
||||||
<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
|
Section
|
||||||
</div>
|
</div>
|
||||||
<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
|
First Letter
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
||||||
|
>
|
||||||
|
Book
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each guesses as guess, rowIndex (guess.book.id)}
|
{#each guesses as guess, rowIndex (guess.book.id)}
|
||||||
<div class="flex gap-2 justify-center">
|
<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 -->
|
<!-- Testament Column -->
|
||||||
<div
|
<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(
|
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,
|
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"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "testament")}</span
|
>{getBoxContent(guess, "testament")}</span
|
||||||
@@ -152,7 +140,7 @@
|
|||||||
guess.sectionMatch,
|
guess.sectionMatch,
|
||||||
guess.adjacent,
|
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"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "section")}
|
>{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(
|
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,
|
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"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "firstLetter")}</span
|
>{getBoxContent(guess, "firstLetter")}</span
|
||||||
>
|
>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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
|
const [result] = await db
|
||||||
.select({
|
.select({
|
||||||
// Adjust user table here to tweak returned data
|
// 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
|
session: table.session
|
||||||
})
|
})
|
||||||
.from(table.session)
|
.from(table.session)
|
||||||
@@ -79,3 +79,37 @@ export function deleteSessionTokenCookie(event: RequestEvent) {
|
|||||||
path: '/'
|
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 { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
import Database from 'better-sqlite3';
|
import { Database } from 'bun:sqlite';
|
||||||
import * as schema from './schema';
|
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 });
|
export const db = drizzle(client, { schema });
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
export const user = sqliteTable('user', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
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', {
|
export const session = sqliteTable('session', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -30,7 +38,7 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
|||||||
guessCount: integer('guess_count').notNull(),
|
guessCount: integer('guess_count').notNull(),
|
||||||
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
||||||
}, (table) => ({
|
}, (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),
|
dateIndex: index('date_idx').on(table.date),
|
||||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
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">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
import favicon from "$lib/assets/favicon.ico";
|
import favicon from "$lib/assets/favicon.ico";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (browser) {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.defer = true;
|
||||||
|
script.src = 'https://umami.snail.city/script.js';
|
||||||
|
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
|
||||||
|
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
|
||||||
|
document.body.appendChild(script);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
<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>
|
</svelte:head>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -34,14 +34,16 @@ async function getTodayVerse(): Promise<DailyVerse> {
|
|||||||
return inserted;
|
return inserted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
const dailyVerse = await getTodayVerse();
|
const dailyVerse = await getTodayVerse();
|
||||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dailyVerse,
|
dailyVerse,
|
||||||
correctBookId: dailyVerse.bookId,
|
correctBookId: dailyVerse.bookId,
|
||||||
correctBook
|
correctBook,
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
import Credits from "$lib/components/Credits.svelte";
|
import Credits from "$lib/components/Credits.svelte";
|
||||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||||
import DevButtons from "$lib/components/DevButtons.svelte";
|
import DevButtons from "$lib/components/DevButtons.svelte";
|
||||||
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
import { getGrade } from "$lib/utils/game";
|
import { getGrade } from "$lib/utils/game";
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
interface Guess {
|
interface Guess {
|
||||||
book: BibleBook;
|
book: BibleBook;
|
||||||
@@ -25,6 +27,8 @@
|
|||||||
|
|
||||||
let dailyVerse = $derived(data.dailyVerse);
|
let dailyVerse = $derived(data.dailyVerse);
|
||||||
let correctBookId = $derived(data.correctBookId);
|
let correctBookId = $derived(data.correctBookId);
|
||||||
|
let user = $derived(data.user);
|
||||||
|
let session = $derived(data.session);
|
||||||
|
|
||||||
let guesses = $state<Guess[]>([]);
|
let guesses = $state<Guess[]>([]);
|
||||||
|
|
||||||
@@ -37,6 +41,7 @@
|
|||||||
|
|
||||||
let anonymousId = $state("");
|
let anonymousId = $state("");
|
||||||
let statsSubmitted = $state(false);
|
let statsSubmitted = $state(false);
|
||||||
|
let authModalOpen = $state(false);
|
||||||
let statsData = $state<{
|
let statsData = $state<{
|
||||||
solveRank: number;
|
solveRank: number;
|
||||||
guessRank: number;
|
guessRank: number;
|
||||||
@@ -177,6 +182,10 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
anonymousId = getOrCreateAnonymousId();
|
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}`;
|
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
||||||
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
||||||
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||||
@@ -258,7 +267,7 @@
|
|||||||
statsData,
|
statsData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!browser || !isWon || !anonymousId) {
|
if (!browser || !isWon || !anonymousId || statsData) {
|
||||||
console.log("Basic conditions not met");
|
console.log("Basic conditions not met");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -269,7 +278,7 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
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();
|
const result = await response.json();
|
||||||
console.log("Stats response:", result);
|
console.log("Stats response:", result);
|
||||||
@@ -299,7 +308,7 @@
|
|||||||
async function submitStats() {
|
async function submitStats() {
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
anonymousId,
|
anonymousId: user ? user.id : anonymousId,
|
||||||
date: dailyVerse.date,
|
date: dailyVerse.date,
|
||||||
guessCount: guesses.length,
|
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="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">
|
<div class="w-full max-w-3xl mx-auto px-4">
|
||||||
<h1
|
<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 />
|
<TitleAnimation />
|
||||||
<div class="font-normal"></div>
|
<div class="font-normal"></div>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
|
||||||
<span class="big-text"
|
<span class="big-text"
|
||||||
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<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}
|
{#if !isWon}
|
||||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
|
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<WinScreen
|
<WinScreen
|
||||||
{grade}
|
{grade}
|
||||||
@@ -492,14 +505,55 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<GuessesTable {guesses} {correctBookId} />
|
<div class="animate-fade-in-up animate-delay-600">
|
||||||
|
<GuessesTable {guesses} {correctBookId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if isWon}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isDev}
|
|
||||||
<DevButtons />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</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);
|
color: rgb(107 114 128);
|
||||||
font-weight: 700;
|
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
|
# in progress
|
||||||
|
|
||||||
- Show new/old testament after 3 guesses and section after 7 guesses
|
|
||||||
- Add sections for "first letter", "Canonical/deutero", etc...
|
|
||||||
- Make the UI more "wordle-like" ()
|
|
||||||
- How do you balance rewarding knowledge vs incentivising learning?
|
|
||||||
|
|
||||||
# todo
|
# todo
|
||||||
|
|
||||||
|
- login
|
||||||
|
- login route
|
||||||
|
|
||||||
- impossible mode (1904 greek bible) three guesses only.
|
- impossible mode (1904 greek bible) three guesses only.
|
||||||
|
|
||||||
- share both classic and impossible mode with both buttons
|
- share both classic and impossible mode with both buttons
|
||||||
|
|
||||||
- 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
|
- instructions
|
||||||
|
|
||||||
@@ -54,6 +59,29 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
|
|
||||||
# done
|
# done
|
||||||
|
|
||||||
|
## february 2nd
|
||||||
|
|
||||||
|
- created rss feed
|
||||||
|
- fixed "first letter" clue edge cases
|
||||||
|
- updated ranking formula
|
||||||
|
|
||||||
|
## january 28th
|
||||||
|
|
||||||
|
- add percentile stats, update chapter guess UI
|
||||||
|
- fixed middle statline (removed meaningless %)
|
||||||
|
- added instructions
|
||||||
|
- added email button
|
||||||
|
- added test buttons for 3.0 UI/UX
|
||||||
|
- package upgrades
|
||||||
|
|
||||||
|
## january 26th
|
||||||
|
|
||||||
|
- Make the UI more "wordle-like"
|
||||||
|
- added deployment script (./deploy.sh)
|
||||||
|
- added bluesky button
|
||||||
|
- added "first letter" column
|
||||||
|
- added imposter mode, v0.1 (mom likes it) but needs work
|
||||||
|
|
||||||
## january 5th
|
## january 5th
|
||||||
|
|
||||||
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
|
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
|
||||||
@@ -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
|
- For bonus points: guess the verse/psalm number
|
||||||
- major UI styling revamp
|
- major UI styling revamp
|
||||||
|
|
||||||
|
-- 2026 --
|
||||||
|
|
||||||
## december 30th
|
## december 30th
|
||||||
|
|
||||||
- merged the embeddings/similarity route into production
|
- merged the embeddings/similarity route into production
|
||||||
|
|||||||
Reference in New Issue
Block a user