mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
18 Commits
rss
...
95725ab4fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95725ab4fe | ||
|
|
06ff0820ce | ||
|
|
3cf95152e6 | ||
|
|
c04899d419 | ||
|
|
6161ef75a1 | ||
|
|
9d7399769a | ||
|
|
b1591229ba | ||
|
|
96024d5048 | ||
|
|
86f81cf9dd | ||
|
|
24a5fdbb80 | ||
|
|
dfe1c40a8a | ||
|
|
dfe784b744 | ||
|
|
6bced13543 | ||
|
|
7d93ead70c | ||
|
|
4c82aa078b | ||
|
|
2058149207 | ||
|
|
2bd86d37a1 | ||
|
|
33d6fae446 |
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
DATABASE_URL=example.db
|
||||||
|
|
||||||
|
AUTH_SECRET=your-random-secret-here
|
||||||
|
APPLE_ID=com.yourcompany.yourapp.client
|
||||||
|
APPLE_TEAM_ID=your-team-id
|
||||||
|
APPLE_KEY_ID=your-key-id
|
||||||
|
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
|
||||||
|
your-private-key-here
|
||||||
|
-----END PRIVATE KEY-----"
|
||||||
@@ -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": "0.0.1",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
217
src/lib/components/AuthModal.svelte
Normal file
217
src/lib/components/AuthModal.svelte
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import Container from './Container.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
isOpen = $bindable(),
|
||||||
|
anonymousId = ''
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
anonymousId: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let mode = $state<'signin' | 'signup'>('signin');
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let success = $state('');
|
||||||
|
|
||||||
|
let email = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let firstName = $state('');
|
||||||
|
let lastName = $state('');
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
email = '';
|
||||||
|
password = '';
|
||||||
|
firstName = '';
|
||||||
|
lastName = '';
|
||||||
|
error = '';
|
||||||
|
success = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMode() {
|
||||||
|
mode = mode === 'signin' ? 'signup' : 'signin';
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
isOpen = false;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
success = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResult(event: any) {
|
||||||
|
loading = false;
|
||||||
|
const result = event.result;
|
||||||
|
|
||||||
|
if (result.type === 'success') {
|
||||||
|
if (result.data?.success) {
|
||||||
|
success = mode === 'signin' ? 'Signed in successfully!' : 'Account created successfully!';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (browser) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} else if (result.data?.error) {
|
||||||
|
error = result.data.error;
|
||||||
|
}
|
||||||
|
} else if (result.type === 'failure') {
|
||||||
|
error = result.data?.error || 'An error occurred. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handleKeydown} />
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||||
|
<Container class="w-full max-w-md p-6 relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeModal}
|
||||||
|
class="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl leading-none"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-white">
|
||||||
|
{mode === 'signin' ? 'Sign In' : 'Create Account'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={mode === 'signin' ? '/auth/signin' : '/auth/signup'}
|
||||||
|
use:enhance={({ formData }) => {
|
||||||
|
if (anonymousId) {
|
||||||
|
formData.append('anonymousId', anonymousId);
|
||||||
|
}
|
||||||
|
handleSubmit();
|
||||||
|
return handleResult;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#if mode === 'signup'}
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="firstName" class="block text-sm font-medium text-white mb-1">
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
bind:value={firstName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||||
|
placeholder="John"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="lastName" class="block text-sm font-medium text-white mb-1">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
type="text"
|
||||||
|
bind:value={lastName}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||||
|
placeholder="Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-white mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
bind:value={email}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-white mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
bind:value={password}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||||
|
placeholder="••••••••"
|
||||||
|
minlength="6"
|
||||||
|
/>
|
||||||
|
{#if mode === 'signup'}
|
||||||
|
<p class="text-xs text-white/80 mt-1">Minimum 6 characters</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<p class="text-sm text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if success}
|
||||||
|
<div class="mt-4 p-3 bg-green-50 border border-green-200 rounded-md">
|
||||||
|
<p class="text-sm text-green-600">{success}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full mt-6 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{mode === 'signin' ? 'Signing in...' : 'Creating account...'}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
{mode === 'signin' ? 'Sign In' : 'Create Account'}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<p class="text-sm text-white">
|
||||||
|
{mode === 'signin' ? "Don't have an account?" : 'Already have an account?'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={switchMode}
|
||||||
|
class="text-blue-300 hover:text-blue-200 font-medium ml-1"
|
||||||
|
>
|
||||||
|
{mode === 'signin' ? 'Create one' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
rel="noopener noreferrer"
|
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>
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
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"
|
||||||
|
|||||||
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 });
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-co
|
|||||||
|
|
||||||
import { sql } from 'drizzle-orm';
|
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(),
|
||||||
@@ -32,7 +39,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 });
|
||||||
71
src/lib/utils/stats.ts
Normal file
71
src/lib/utils/stats.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
export interface UserStats {
|
||||||
|
totalSolves: number;
|
||||||
|
avgGuesses: number;
|
||||||
|
gradeDistribution: {
|
||||||
|
'S++': number;
|
||||||
|
'S+': number;
|
||||||
|
'A+': number;
|
||||||
|
'A': number;
|
||||||
|
'B+': number;
|
||||||
|
'B': number;
|
||||||
|
'C+': number;
|
||||||
|
'C': number;
|
||||||
|
};
|
||||||
|
currentStreak: number;
|
||||||
|
bestStreak: number;
|
||||||
|
recentCompletions: Array<{
|
||||||
|
date: string;
|
||||||
|
guessCount: number;
|
||||||
|
grade: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGradeColor(grade: string): string {
|
||||||
|
switch (grade) {
|
||||||
|
case 'S++': return 'text-purple-600 bg-purple-100';
|
||||||
|
case 'S+': return 'text-yellow-600 bg-yellow-100';
|
||||||
|
case 'A+': return 'text-green-600 bg-green-100';
|
||||||
|
case 'A': return 'text-green-500 bg-green-50';
|
||||||
|
case 'B+': return 'text-blue-600 bg-blue-100';
|
||||||
|
case 'B': return 'text-blue-500 bg-blue-50';
|
||||||
|
case 'C+': return 'text-orange-600 bg-orange-100';
|
||||||
|
case 'C': return 'text-red-600 bg-red-100';
|
||||||
|
default: return 'text-gray-600 bg-gray-100';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr + 'T00:00:00');
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStreakMessage(currentStreak: number): string {
|
||||||
|
if (currentStreak === 0) {
|
||||||
|
return "Start your streak today!";
|
||||||
|
} else if (currentStreak === 1) {
|
||||||
|
return "Keep it going!";
|
||||||
|
} else if (currentStreak < 7) {
|
||||||
|
return `${currentStreak} days strong!`;
|
||||||
|
} else if (currentStreak < 30) {
|
||||||
|
return `${currentStreak} day streak - amazing!`;
|
||||||
|
} else {
|
||||||
|
return `${currentStreak} days - you're unstoppable!`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPerformanceMessage(avgGuesses: number): string {
|
||||||
|
if (avgGuesses <= 2) {
|
||||||
|
return "Exceptional performance!";
|
||||||
|
} else if (avgGuesses <= 4) {
|
||||||
|
return "Great performance!";
|
||||||
|
} else if (avgGuesses <= 6) {
|
||||||
|
return "Good performance!";
|
||||||
|
} else if (avgGuesses <= 8) {
|
||||||
|
return "Room for improvement!";
|
||||||
|
} else {
|
||||||
|
return "Keep practicing!";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
import favicon from "$lib/assets/favicon.ico";
|
import favicon from "$lib/assets/favicon.ico";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (browser) {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.defer = true;
|
||||||
|
script.src = 'https://umami.snail.city/script.js';
|
||||||
|
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
|
||||||
|
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
|
||||||
|
document.body.appendChild(script);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<script
|
<!-- <script
|
||||||
defer
|
defer
|
||||||
src="https://umami.snail.city/script.js"
|
src="https://umami.snail.city/script.js"
|
||||||
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
||||||
data-domains="bibdle.com,www.bibdle.com"
|
data-domains="bibdle.com,www.bibdle.com"
|
||||||
></script>
|
></script> -->
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -168,6 +173,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}`;
|
||||||
@@ -256,7 +265,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);
|
||||||
@@ -286,7 +295,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,
|
||||||
};
|
};
|
||||||
@@ -486,8 +495,45 @@
|
|||||||
<Credits />
|
<Credits />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-8 flex flex-col items-center gap-3">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<a
|
||||||
|
href="/stats?{user ? `userId=${user.id}` : `anonymousId=${anonymousId}`}"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||||
|
>
|
||||||
|
📊 View Stats
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{#if user}
|
||||||
|
<form method="POST" action="/auth/logout" use:enhance>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
|
||||||
|
>
|
||||||
|
🚪 Sign Out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => authModalOpen = true}
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
|
||||||
|
>
|
||||||
|
🔐 Sign In
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if isDev}
|
{#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 />
|
<DevButtons />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
168
src/routes/stats/+page.server.ts
Normal file
168
src/routes/stats/+page.server.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions, type DailyCompletion } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||||
|
// Check if user is authenticated
|
||||||
|
if (!locals.user) {
|
||||||
|
return {
|
||||||
|
stats: null,
|
||||||
|
error: null,
|
||||||
|
user: null,
|
||||||
|
session: null,
|
||||||
|
requiresAuth: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = locals.user.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return {
|
||||||
|
stats: null,
|
||||||
|
error: 'No user ID provided',
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all completions for this user
|
||||||
|
const completions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId))
|
||||||
|
.orderBy(desc(dailyCompletions.date));
|
||||||
|
|
||||||
|
if (completions.length === 0) {
|
||||||
|
return {
|
||||||
|
stats: {
|
||||||
|
totalSolves: 0,
|
||||||
|
avgGuesses: 0,
|
||||||
|
gradeDistribution: {
|
||||||
|
'S++': 0,
|
||||||
|
'S+': 0,
|
||||||
|
'A+': 0,
|
||||||
|
'A': 0,
|
||||||
|
'B+': 0,
|
||||||
|
'B': 0,
|
||||||
|
'C+': 0,
|
||||||
|
'C': 0
|
||||||
|
},
|
||||||
|
currentStreak: 0,
|
||||||
|
bestStreak: 0,
|
||||||
|
recentCompletions: []
|
||||||
|
},
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate basic stats
|
||||||
|
const totalSolves = completions.length;
|
||||||
|
const totalGuesses = completions.reduce((sum: number, c: DailyCompletion) => sum + c.guessCount, 0);
|
||||||
|
const avgGuesses = Math.round((totalGuesses / totalSolves) * 100) / 100;
|
||||||
|
|
||||||
|
// Calculate grade distribution
|
||||||
|
const gradeDistribution = {
|
||||||
|
'S++': 0, // This will be calculated differently since we don't store chapter correctness
|
||||||
|
'S+': completions.filter((c: DailyCompletion) => c.guessCount === 1).length,
|
||||||
|
'A+': completions.filter((c: DailyCompletion) => c.guessCount === 2).length,
|
||||||
|
'A': completions.filter((c: DailyCompletion) => c.guessCount === 3).length,
|
||||||
|
'B+': completions.filter((c: DailyCompletion) => c.guessCount >= 4 && c.guessCount <= 6).length,
|
||||||
|
'B': completions.filter((c: DailyCompletion) => c.guessCount >= 7 && c.guessCount <= 10).length,
|
||||||
|
'C+': completions.filter((c: DailyCompletion) => c.guessCount >= 11 && c.guessCount <= 15).length,
|
||||||
|
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate streaks
|
||||||
|
const sortedDates = completions
|
||||||
|
.map((c: DailyCompletion) => c.date)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
let currentStreak = 0;
|
||||||
|
let bestStreak = 0;
|
||||||
|
let tempStreak = 1;
|
||||||
|
|
||||||
|
if (sortedDates.length > 0) {
|
||||||
|
// Check if current streak is active (includes today or yesterday)
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||||
|
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||||
|
|
||||||
|
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
|
||||||
|
currentStreak = 1;
|
||||||
|
|
||||||
|
// Count backwards from the most recent date
|
||||||
|
for (let i = sortedDates.length - 2; i >= 0; i--) {
|
||||||
|
const currentDate = new Date(sortedDates[i + 1]);
|
||||||
|
const prevDate = new Date(sortedDates[i]);
|
||||||
|
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (daysDiff === 1) {
|
||||||
|
currentStreak++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate best streak
|
||||||
|
bestStreak = 1;
|
||||||
|
for (let i = 1; i < sortedDates.length; i++) {
|
||||||
|
const currentDate = new Date(sortedDates[i]);
|
||||||
|
const prevDate = new Date(sortedDates[i - 1]);
|
||||||
|
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (daysDiff === 1) {
|
||||||
|
tempStreak++;
|
||||||
|
} else {
|
||||||
|
bestStreak = Math.max(bestStreak, tempStreak);
|
||||||
|
tempStreak = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bestStreak = Math.max(bestStreak, tempStreak);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent completions (last 7 days)
|
||||||
|
const recentCompletions = completions
|
||||||
|
.slice(0, 7)
|
||||||
|
.map((c: DailyCompletion) => ({
|
||||||
|
date: c.date,
|
||||||
|
guessCount: c.guessCount,
|
||||||
|
grade: getGradeFromGuesses(c.guessCount)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: {
|
||||||
|
totalSolves,
|
||||||
|
avgGuesses,
|
||||||
|
gradeDistribution,
|
||||||
|
currentStreak,
|
||||||
|
bestStreak,
|
||||||
|
recentCompletions
|
||||||
|
},
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user stats:', error);
|
||||||
|
return {
|
||||||
|
stats: null,
|
||||||
|
error: 'Failed to fetch stats',
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getGradeFromGuesses(guessCount: number): string {
|
||||||
|
if (guessCount === 1) return "S+";
|
||||||
|
if (guessCount === 2) return "A+";
|
||||||
|
if (guessCount === 3) return "A";
|
||||||
|
if (guessCount >= 4 && guessCount <= 6) return "B+";
|
||||||
|
if (guessCount >= 7 && guessCount <= 10) return "B";
|
||||||
|
if (guessCount >= 11 && guessCount <= 15) return "C+";
|
||||||
|
return "C";
|
||||||
|
}
|
||||||
221
src/routes/stats/+page.svelte
Normal file
221
src/routes/stats/+page.svelte
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
|
import {
|
||||||
|
getGradeColor,
|
||||||
|
formatDate,
|
||||||
|
getStreakMessage,
|
||||||
|
getPerformanceMessage,
|
||||||
|
type UserStats
|
||||||
|
} from "$lib/utils/stats";
|
||||||
|
|
||||||
|
interface PageData {
|
||||||
|
stats: UserStats | null;
|
||||||
|
error?: string;
|
||||||
|
user?: any;
|
||||||
|
session?: any;
|
||||||
|
requiresAuth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
let authModalOpen = $state(false);
|
||||||
|
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
function getOrCreateAnonymousId(): string {
|
||||||
|
if (!browser) return "";
|
||||||
|
const key = "bibdle-anonymous-id";
|
||||||
|
let id = localStorage.getItem(key);
|
||||||
|
if (!id) {
|
||||||
|
id = crypto.randomUUID();
|
||||||
|
localStorage.setItem(key, id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getGradePercentage(count: number, total: number): number {
|
||||||
|
return total > 0 ? Math.round((count / total) * 100) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inspect(data);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Stats | Bibdle</title>
|
||||||
|
<meta name="description" content="View your Bibdle game statistics and performance" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-amber-50 to-orange-100 p-4">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-800 mb-2">Your Stats</h1>
|
||||||
|
<p class="text-gray-600">Track your Bibdle performance over time</p>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Game
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="inline-block w-8 h-8 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<p class="mt-4 text-gray-600">Loading your stats...</p>
|
||||||
|
</div>
|
||||||
|
{:else if data.requiresAuth}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="bg-blue-100 border border-blue-300 rounded-lg p-8 max-w-md mx-auto">
|
||||||
|
<h2 class="text-2xl font-bold text-blue-800 mb-4">Authentication Required</h2>
|
||||||
|
<p class="text-blue-700 mb-6">You must be logged in to see your stats.</p>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => authModalOpen = true}
|
||||||
|
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
🔐 Sign In / Sign Up
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center justify-center px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
← Back to Game
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if data.error}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="bg-red-100 border border-red-300 rounded-lg p-6 max-w-md mx-auto">
|
||||||
|
<p class="text-red-700">{data.error}</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Return to Game
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !data.stats}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="bg-yellow-100 border border-yellow-300 rounded-lg p-6 max-w-md mx-auto">
|
||||||
|
<p class="text-yellow-700">No stats available.</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="mt-4 inline-block px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
|
||||||
|
>
|
||||||
|
Start Playing
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@const stats = data.stats}
|
||||||
|
|
||||||
|
<!-- Overview Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<!-- Total Solves -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold text-amber-600 mb-2">{stats.totalSolves}</div>
|
||||||
|
<div class="text-gray-600">Total Solves</div>
|
||||||
|
{#if stats.totalSolves > 0}
|
||||||
|
<div class="text-sm text-gray-500 mt-1">
|
||||||
|
{getPerformanceMessage(stats.avgGuesses)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Average Guesses -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold text-blue-600 mb-2">{stats.avgGuesses}</div>
|
||||||
|
<div class="text-gray-600">Avg. Guesses</div>
|
||||||
|
<div class="text-sm text-gray-500 mt-1">per solve</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Streak -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
|
||||||
|
<div class="text-gray-600">Current Streak</div>
|
||||||
|
<div class="text-sm text-gray-500 mt-1">
|
||||||
|
{getStreakMessage(stats.currentStreak)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grade Distribution -->
|
||||||
|
{#if stats.totalSolves > 0}
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-4">Grade Distribution</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{#each Object.entries(stats.gradeDistribution) as [grade, count]}
|
||||||
|
{@const percentage = getGradePercentage(count, stats.totalSolves)}
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mb-2">
|
||||||
|
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold {getGradeColor(grade)}">
|
||||||
|
{grade}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-800">{count}</div>
|
||||||
|
<div class="text-sm text-gray-500">{percentage}%</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Streak Info -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-4">Streak Information</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
|
||||||
|
<div class="text-gray-600">Current Streak</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold text-purple-600 mb-2">{stats.bestStreak}</div>
|
||||||
|
<div class="text-gray-600">Best Streak</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Performance -->
|
||||||
|
{#if stats.recentCompletions.length > 0}
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-4">Recent Performance</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each stats.recentCompletions as completion}
|
||||||
|
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">{formatDate(completion.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-gray-600">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span>
|
||||||
|
<span class="px-2 py-1 rounded text-sm font-semibold {getGradeColor(completion.grade)}">
|
||||||
|
{completion.grade}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AuthModal bind:isOpen={authModalOpen} anonymousId={""} />
|
||||||
245
tests/signin-migration-unit.test.ts
Normal file
245
tests/signin-migration-unit.test.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||||
|
|
||||||
|
describe('Signin Migration Logic (Unit Tests)', () => {
|
||||||
|
|
||||||
|
// Test the deduplication algorithm independently
|
||||||
|
it('should correctly identify and remove duplicates keeping earliest', () => {
|
||||||
|
// Mock completion data structure
|
||||||
|
type MockCompletion = {
|
||||||
|
id: string;
|
||||||
|
anonymousId: string;
|
||||||
|
date: string;
|
||||||
|
guessCount: number;
|
||||||
|
completedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test data: multiple completions on same date
|
||||||
|
const allUserCompletions: MockCompletion[] = [
|
||||||
|
{
|
||||||
|
id: 'comp1',
|
||||||
|
anonymousId: 'user123',
|
||||||
|
date: '2024-01-01',
|
||||||
|
guessCount: 4,
|
||||||
|
completedAt: new Date('2024-01-01T08:00:00Z') // Earliest
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comp2',
|
||||||
|
anonymousId: 'user123',
|
||||||
|
date: '2024-01-01',
|
||||||
|
guessCount: 2,
|
||||||
|
completedAt: new Date('2024-01-01T14:00:00Z') // Later
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comp3',
|
||||||
|
anonymousId: 'user123',
|
||||||
|
date: '2024-01-01',
|
||||||
|
guessCount: 6,
|
||||||
|
completedAt: new Date('2024-01-01T20:00:00Z') // Latest
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comp4',
|
||||||
|
anonymousId: 'user123',
|
||||||
|
date: '2024-01-02',
|
||||||
|
guessCount: 3,
|
||||||
|
completedAt: new Date('2024-01-02T09:00:00Z') // Unique date
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Implement the deduplication logic from signin server action
|
||||||
|
const dateGroups = new Map<string, MockCompletion[]>();
|
||||||
|
for (const completion of allUserCompletions) {
|
||||||
|
const date = completion.date;
|
||||||
|
if (!dateGroups.has(date)) {
|
||||||
|
dateGroups.set(date, []);
|
||||||
|
}
|
||||||
|
dateGroups.get(date)!.push(completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process dates with duplicates
|
||||||
|
const duplicateIds: string[] = [];
|
||||||
|
const keptEntries: MockCompletion[] = [];
|
||||||
|
|
||||||
|
for (const [date, completions] of dateGroups) {
|
||||||
|
if (completions.length > 1) {
|
||||||
|
// Sort by completedAt timestamp (earliest first)
|
||||||
|
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
||||||
|
|
||||||
|
// Keep the first (earliest), mark the rest for deletion
|
||||||
|
const toKeep = completions[0];
|
||||||
|
const toDelete = completions.slice(1);
|
||||||
|
|
||||||
|
keptEntries.push(toKeep);
|
||||||
|
duplicateIds.push(...toDelete.map(c => c.id));
|
||||||
|
} else {
|
||||||
|
// Single entry for this date, keep it
|
||||||
|
keptEntries.push(completions[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the logic worked correctly
|
||||||
|
expect(duplicateIds).toHaveLength(2); // comp2 and comp3 should be deleted
|
||||||
|
expect(duplicateIds).toContain('comp2');
|
||||||
|
expect(duplicateIds).toContain('comp3');
|
||||||
|
expect(duplicateIds).not.toContain('comp1'); // comp1 should be kept (earliest)
|
||||||
|
expect(duplicateIds).not.toContain('comp4'); // comp4 should be kept (unique date)
|
||||||
|
|
||||||
|
// Verify kept entries
|
||||||
|
expect(keptEntries).toHaveLength(2);
|
||||||
|
|
||||||
|
// Check that the earliest entry for 2024-01-01 was kept
|
||||||
|
const jan1Entry = keptEntries.find(e => e.date === '2024-01-01');
|
||||||
|
expect(jan1Entry).toBeTruthy();
|
||||||
|
expect(jan1Entry!.id).toBe('comp1'); // Earliest timestamp
|
||||||
|
expect(jan1Entry!.guessCount).toBe(4);
|
||||||
|
expect(jan1Entry!.completedAt.getTime()).toBe(new Date('2024-01-01T08:00:00Z').getTime());
|
||||||
|
|
||||||
|
// Check that unique date entry was preserved
|
||||||
|
const jan2Entry = keptEntries.find(e => e.date === '2024-01-02');
|
||||||
|
expect(jan2Entry).toBeTruthy();
|
||||||
|
expect(jan2Entry!.id).toBe('comp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle no duplicates correctly', () => {
|
||||||
|
type MockCompletion = {
|
||||||
|
id: string;
|
||||||
|
anonymousId: string;
|
||||||
|
date: string;
|
||||||
|
guessCount: number;
|
||||||
|
completedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test data: all unique dates
|
||||||
|
const allUserCompletions: MockCompletion[] = [
|
||||||
|
{
|
||||||
|
id: 'comp1',
|
||||||
|
anonymousId: 'user123',
|
||||||
|
date: '2024-01-01',
|
||||||
|
guessCount: 4,
|
||||||
|
completedAt: new Date('2024-01-01T08:00:00Z')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comp2',
|
||||||
|
anonymousId: 'user123',
|
||||||
|
date: '2024-01-02',
|
||||||
|
guessCount: 2,
|
||||||
|
completedAt: new Date('2024-01-02T14:00:00Z')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Run deduplication logic
|
||||||
|
const dateGroups = new Map<string, MockCompletion[]>();
|
||||||
|
for (const completion of allUserCompletions) {
|
||||||
|
if (!dateGroups.has(completion.date)) {
|
||||||
|
dateGroups.set(completion.date, []);
|
||||||
|
}
|
||||||
|
dateGroups.get(completion.date)!.push(completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateIds: string[] = [];
|
||||||
|
for (const [date, completions] of dateGroups) {
|
||||||
|
if (completions.length > 1) {
|
||||||
|
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
||||||
|
const toDelete = completions.slice(1);
|
||||||
|
duplicateIds.push(...toDelete.map(c => c.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should find no duplicates
|
||||||
|
expect(duplicateIds).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle edge case with same timestamp', () => {
|
||||||
|
type MockCompletion = {
|
||||||
|
id: string;
|
||||||
|
anonymousId: string;
|
||||||
|
date: string;
|
||||||
|
guessCount: number;
|
||||||
|
completedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edge case: same completion time (very unlikely but possible)
|
||||||
|
const sameTime = new Date('2024-01-01T08:00:00Z');
|
||||||
|
const allUserCompletions: MockCompletion[] = [
|
||||||
|
{
|
||||||
|
id: 'comp1',
|
||||||
|
anonymousId: 'user123',
|
||||||
|
date: '2024-01-01',
|
||||||
|
guessCount: 3,
|
||||||
|
completedAt: sameTime
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comp2',
|
||||||
|
anonymousId: 'user123',
|
||||||
|
date: '2024-01-01',
|
||||||
|
guessCount: 5,
|
||||||
|
completedAt: sameTime
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Run deduplication logic
|
||||||
|
const dateGroups = new Map<string, MockCompletion[]>();
|
||||||
|
for (const completion of allUserCompletions) {
|
||||||
|
if (!dateGroups.has(completion.date)) {
|
||||||
|
dateGroups.set(completion.date, []);
|
||||||
|
}
|
||||||
|
dateGroups.get(completion.date)!.push(completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateIds: string[] = [];
|
||||||
|
for (const [date, completions] of dateGroups) {
|
||||||
|
if (completions.length > 1) {
|
||||||
|
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
||||||
|
const toDelete = completions.slice(1);
|
||||||
|
duplicateIds.push(...toDelete.map(c => c.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should still remove one duplicate (deterministically based on array order)
|
||||||
|
expect(duplicateIds).toHaveLength(1);
|
||||||
|
// Since they have the same timestamp, it keeps the first one in the sorted array
|
||||||
|
expect(duplicateIds[0]).toBe('comp2'); // Second entry gets removed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate migration condition logic', () => {
|
||||||
|
// Test the condition check that determines when migration should occur
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
anonymousId: 'device2-id',
|
||||||
|
userId: 'device1-id',
|
||||||
|
shouldMigrate: true,
|
||||||
|
description: 'Different IDs should trigger migration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anonymousId: 'same-id',
|
||||||
|
userId: 'same-id',
|
||||||
|
shouldMigrate: false,
|
||||||
|
description: 'Same IDs should not trigger migration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anonymousId: null as any,
|
||||||
|
userId: 'user-id',
|
||||||
|
shouldMigrate: false,
|
||||||
|
description: 'Null anonymous ID should not trigger migration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anonymousId: undefined as any,
|
||||||
|
userId: 'user-id',
|
||||||
|
shouldMigrate: false,
|
||||||
|
description: 'Undefined anonymous ID should not trigger migration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
anonymousId: '',
|
||||||
|
userId: 'user-id',
|
||||||
|
shouldMigrate: false,
|
||||||
|
description: 'Empty anonymous ID should not trigger migration'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
// This is the exact condition from signin/+page.server.ts
|
||||||
|
const shouldMigrate = !!(testCase.anonymousId && testCase.anonymousId !== testCase.userId);
|
||||||
|
|
||||||
|
expect(shouldMigrate).toBe(testCase.shouldMigrate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
287
tests/signin-migration.test.ts
Normal file
287
tests/signin-migration.test.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||||
|
import { testDb as db } from '../src/lib/server/db/test';
|
||||||
|
import { user, session, dailyCompletions } from '../src/lib/server/db/schema';
|
||||||
|
import * as auth from '../src/lib/server/auth.test';
|
||||||
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
// Test helper functions
|
||||||
|
function generateTestUUID() {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestUser(anonymousId: string, email: string, password: string = 'testpass123') {
|
||||||
|
const passwordHash = await auth.hashPassword(password);
|
||||||
|
const testUser = await auth.createUser(anonymousId, email, passwordHash, 'Test', 'User');
|
||||||
|
return testUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestCompletion(anonymousId: string, date: string, guessCount: number, completedAt: Date) {
|
||||||
|
const completion = {
|
||||||
|
id: generateTestUUID(),
|
||||||
|
anonymousId,
|
||||||
|
date,
|
||||||
|
guessCount,
|
||||||
|
completedAt
|
||||||
|
};
|
||||||
|
await db.insert(dailyCompletions).values(completion);
|
||||||
|
return completion;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearTestData() {
|
||||||
|
// Clear test data in reverse dependency order
|
||||||
|
await db.delete(session);
|
||||||
|
await db.delete(dailyCompletions);
|
||||||
|
await db.delete(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Signin Stats Migration', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await clearTestData();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should migrate stats from local anonymous ID to user ID on signin', async () => {
|
||||||
|
// Setup: Create user with device 1 anonymous ID
|
||||||
|
const device1AnonymousId = generateTestUUID();
|
||||||
|
const device2AnonymousId = generateTestUUID();
|
||||||
|
const email = 'test@example.com';
|
||||||
|
|
||||||
|
const testUser = await createTestUser(device1AnonymousId, email);
|
||||||
|
|
||||||
|
// Add some completions for device 1 (user's original device)
|
||||||
|
await createTestCompletion(device1AnonymousId, '2024-01-01', 3, new Date('2024-01-01T08:00:00Z'));
|
||||||
|
await createTestCompletion(device1AnonymousId, '2024-01-02', 5, new Date('2024-01-02T09:00:00Z'));
|
||||||
|
|
||||||
|
// Add some completions for device 2 (before signin)
|
||||||
|
await createTestCompletion(device2AnonymousId, '2024-01-03', 2, new Date('2024-01-03T10:00:00Z'));
|
||||||
|
await createTestCompletion(device2AnonymousId, '2024-01-04', 4, new Date('2024-01-04T11:00:00Z'));
|
||||||
|
|
||||||
|
// Verify initial state
|
||||||
|
const initialDevice1Stats = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, device1AnonymousId));
|
||||||
|
const initialDevice2Stats = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
|
||||||
|
|
||||||
|
expect(initialDevice1Stats).toHaveLength(2);
|
||||||
|
expect(initialDevice2Stats).toHaveLength(2);
|
||||||
|
|
||||||
|
// Simulate signin action - this is what happens in signin/+page.server.ts
|
||||||
|
const user = await auth.getUserByEmail(email);
|
||||||
|
expect(user).toBeTruthy();
|
||||||
|
|
||||||
|
// Migrate stats (simulating the signin logic)
|
||||||
|
if (device2AnonymousId && device2AnonymousId !== user!.id) {
|
||||||
|
// Update all daily completions from device2 anonymous ID to user's ID
|
||||||
|
await db
|
||||||
|
.update(dailyCompletions)
|
||||||
|
.set({ anonymousId: user!.id })
|
||||||
|
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify migration worked
|
||||||
|
const finalUserStats = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, user!.id));
|
||||||
|
const remainingDevice2Stats = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
|
||||||
|
|
||||||
|
expect(finalUserStats).toHaveLength(4); // All 4 completions now under user ID
|
||||||
|
expect(remainingDevice2Stats).toHaveLength(0); // No more completions under device2 ID
|
||||||
|
|
||||||
|
// Verify the actual data is correct
|
||||||
|
const dates = finalUserStats.map(c => c.date).sort();
|
||||||
|
expect(dates).toEqual(['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduplicate entries for same date keeping earliest completion', async () => {
|
||||||
|
// Setup: User played same day on both devices
|
||||||
|
const device1AnonymousId = generateTestUUID();
|
||||||
|
const device2AnonymousId = generateTestUUID();
|
||||||
|
const email = 'test@example.com';
|
||||||
|
|
||||||
|
const testUser = await createTestUser(device1AnonymousId, email);
|
||||||
|
|
||||||
|
// Both devices played on same date - device1 played earlier and better
|
||||||
|
const date = '2024-01-01';
|
||||||
|
const earlierTime = new Date('2024-01-01T08:00:00Z');
|
||||||
|
const laterTime = new Date('2024-01-01T14:00:00Z');
|
||||||
|
|
||||||
|
await createTestCompletion(device1AnonymousId, date, 3, earlierTime); // Better score, earlier
|
||||||
|
await createTestCompletion(device2AnonymousId, date, 5, laterTime); // Worse score, later
|
||||||
|
|
||||||
|
// Also add unique dates to ensure they're preserved
|
||||||
|
await createTestCompletion(device1AnonymousId, '2024-01-02', 4, new Date('2024-01-02T09:00:00Z'));
|
||||||
|
await createTestCompletion(device2AnonymousId, '2024-01-03', 2, new Date('2024-01-03T10:00:00Z'));
|
||||||
|
|
||||||
|
// Migrate stats
|
||||||
|
const user = await auth.getUserByEmail(email);
|
||||||
|
await db
|
||||||
|
.update(dailyCompletions)
|
||||||
|
.set({ anonymousId: user!.id })
|
||||||
|
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
|
||||||
|
|
||||||
|
// Implement deduplication logic (from signin server action)
|
||||||
|
const allUserCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, user!.id));
|
||||||
|
|
||||||
|
// Group by date to find duplicates
|
||||||
|
const dateGroups = new Map<string, typeof allUserCompletions>();
|
||||||
|
for (const completion of allUserCompletions) {
|
||||||
|
const date = completion.date;
|
||||||
|
if (!dateGroups.has(date)) {
|
||||||
|
dateGroups.set(date, []);
|
||||||
|
}
|
||||||
|
dateGroups.get(date)!.push(completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process dates with duplicates
|
||||||
|
const duplicateIds: string[] = [];
|
||||||
|
for (const [date, completions] of dateGroups) {
|
||||||
|
if (completions.length > 1) {
|
||||||
|
// Sort by completedAt timestamp (earliest first)
|
||||||
|
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
||||||
|
|
||||||
|
// Keep the first (earliest), mark the rest for deletion
|
||||||
|
const toDelete = completions.slice(1);
|
||||||
|
duplicateIds.push(...toDelete.map(c => c.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete duplicate entries
|
||||||
|
if (duplicateIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(dailyCompletions)
|
||||||
|
.where(inArray(dailyCompletions.id, duplicateIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deduplication worked correctly
|
||||||
|
const finalStats = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, user!.id));
|
||||||
|
|
||||||
|
expect(finalStats).toHaveLength(3); // One duplicate removed
|
||||||
|
|
||||||
|
// Verify the correct entry was kept for the duplicate date
|
||||||
|
const duplicateDateEntry = finalStats.find(c => c.date === date);
|
||||||
|
expect(duplicateDateEntry).toBeTruthy();
|
||||||
|
expect(duplicateDateEntry!.guessCount).toBe(3); // Better score kept
|
||||||
|
expect(duplicateDateEntry!.completedAt.getTime()).toBe(earlierTime.getTime()); // Earlier time kept
|
||||||
|
|
||||||
|
// Verify unique dates are preserved
|
||||||
|
const allDates = finalStats.map(c => c.date).sort();
|
||||||
|
expect(allDates).toEqual(['2024-01-01', '2024-01-02', '2024-01-03']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle no migration when anonymous ID matches user ID', async () => {
|
||||||
|
// Setup: User signing in from same device they signed up on
|
||||||
|
const anonymousId = generateTestUUID();
|
||||||
|
const email = 'test@example.com';
|
||||||
|
|
||||||
|
const testUser = await createTestUser(anonymousId, email);
|
||||||
|
|
||||||
|
// Add some completions
|
||||||
|
await createTestCompletion(anonymousId, '2024-01-01', 3, new Date('2024-01-01T08:00:00Z'));
|
||||||
|
await createTestCompletion(anonymousId, '2024-01-02', 5, new Date('2024-01-02T09:00:00Z'));
|
||||||
|
|
||||||
|
// Verify initial state
|
||||||
|
const initialStats = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||||
|
expect(initialStats).toHaveLength(2);
|
||||||
|
|
||||||
|
// Simulate signin with same anonymous ID (no migration needed)
|
||||||
|
const user = await auth.getUserByEmail(email);
|
||||||
|
|
||||||
|
// Migration logic should skip when IDs match
|
||||||
|
const shouldMigrate = anonymousId && anonymousId !== user!.id;
|
||||||
|
expect(shouldMigrate).toBe(false);
|
||||||
|
|
||||||
|
// Verify no changes
|
||||||
|
const finalStats = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||||
|
expect(finalStats).toHaveLength(2);
|
||||||
|
expect(finalStats[0].anonymousId).toBe(anonymousId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple duplicates for same date correctly', async () => {
|
||||||
|
// Edge case: User played same date on 3+ devices
|
||||||
|
const device1AnonymousId = generateTestUUID();
|
||||||
|
const device2AnonymousId = generateTestUUID();
|
||||||
|
const device3AnonymousId = generateTestUUID();
|
||||||
|
const email = 'test@example.com';
|
||||||
|
|
||||||
|
const testUser = await createTestUser(device1AnonymousId, email);
|
||||||
|
|
||||||
|
const date = '2024-01-01';
|
||||||
|
// Three completions on same date at different times
|
||||||
|
await createTestCompletion(device1AnonymousId, date, 4, new Date('2024-01-01T08:00:00Z')); // Earliest
|
||||||
|
await createTestCompletion(device2AnonymousId, date, 2, new Date('2024-01-01T14:00:00Z')); // Middle
|
||||||
|
await createTestCompletion(device3AnonymousId, date, 6, new Date('2024-01-01T20:00:00Z')); // Latest
|
||||||
|
|
||||||
|
// Migrate all to user ID
|
||||||
|
const user = await auth.getUserByEmail(email);
|
||||||
|
await db
|
||||||
|
.update(dailyCompletions)
|
||||||
|
.set({ anonymousId: user!.id })
|
||||||
|
.where(eq(dailyCompletions.anonymousId, device2AnonymousId));
|
||||||
|
await db
|
||||||
|
.update(dailyCompletions)
|
||||||
|
.set({ anonymousId: user!.id })
|
||||||
|
.where(eq(dailyCompletions.anonymousId, device3AnonymousId));
|
||||||
|
|
||||||
|
// Implement deduplication
|
||||||
|
const allUserCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, user!.id));
|
||||||
|
|
||||||
|
const dateGroups = new Map<string, typeof allUserCompletions>();
|
||||||
|
for (const completion of allUserCompletions) {
|
||||||
|
if (!dateGroups.has(completion.date)) {
|
||||||
|
dateGroups.set(completion.date, []);
|
||||||
|
}
|
||||||
|
dateGroups.get(completion.date)!.push(completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateIds: string[] = [];
|
||||||
|
for (const [_, completions] of dateGroups) {
|
||||||
|
if (completions.length > 1) {
|
||||||
|
completions.sort((a, b) => a.completedAt.getTime() - b.completedAt.getTime());
|
||||||
|
const toDelete = completions.slice(1);
|
||||||
|
duplicateIds.push(...toDelete.map(c => c.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete duplicates
|
||||||
|
for (const id of duplicateIds) {
|
||||||
|
await db.delete(dailyCompletions).where(eq(dailyCompletions.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify only earliest kept
|
||||||
|
const finalStats = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, user!.id));
|
||||||
|
|
||||||
|
expect(finalStats).toHaveLength(1); // 2 duplicates removed
|
||||||
|
expect(finalStats[0].guessCount).toBe(4); // First device's score
|
||||||
|
expect(finalStats[0].completedAt.getTime()).toBe(new Date('2024-01-01T08:00:00Z').getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
40
todo.md
40
todo.md
@@ -1,17 +1,22 @@
|
|||||||
# in progress
|
# 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