mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-06 01:43:32 -04:00
Compare commits
41 Commits
rss
...
77cc83841d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77cc83841d | ||
|
|
e8b2d2e35e | ||
|
|
c50cccd3d3 | ||
|
|
638a789a0f | ||
|
|
e815e15ce5 | ||
|
|
e6081c28f1 | ||
|
|
2de4e9e2a7 | ||
|
|
ea7a848125 | ||
|
|
1719e0bbbf | ||
|
|
885adad756 | ||
|
|
1b96919acd | ||
|
|
8ef2a41a69 | ||
|
|
ac6ec051d4 | ||
|
|
a12c7d011a | ||
|
|
77ffd6fbee | ||
|
|
f6652e59a7 | ||
|
|
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=="],
|
||||||
|
|||||||
22
deploy-staging.sh
Executable file
22
deploy-staging.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "Pulling latest changes..."
|
||||||
|
git pull
|
||||||
|
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
bun i
|
||||||
|
|
||||||
|
echo "Pushing database changes..."
|
||||||
|
bun run db:generate
|
||||||
|
bun run db:migrate
|
||||||
|
|
||||||
|
echo "Building..."
|
||||||
|
bun --bun run build
|
||||||
|
|
||||||
|
echo "Restarting service..."
|
||||||
|
sudo systemctl restart bibdle-test
|
||||||
|
|
||||||
|
echo "Done!"
|
||||||
@@ -10,7 +10,7 @@ echo "Installing dependencies..."
|
|||||||
bun i
|
bun i
|
||||||
|
|
||||||
echo "Building..."
|
echo "Building..."
|
||||||
bun run build
|
bun --bun run build
|
||||||
|
|
||||||
echo "Restarting service..."
|
echo "Restarting service..."
|
||||||
sudo systemctl restart bibdle
|
sudo systemctl restart bibdle
|
||||||
|
|||||||
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
|
||||||
|
});
|
||||||
10
drizzle/0002_outstanding_hiroim.sql
Normal file
10
drizzle/0002_outstanding_hiroim.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE `user` ADD `first_name` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `last_name` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `email` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `password_hash` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `apple_id` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `is_private` integer DEFAULT false;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_apple_id_unique` ON `user` (`apple_id`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `age`;--> statement-breakpoint
|
||||||
|
CREATE INDEX `anonymous_id_date_idx` ON `daily_completions` (`anonymous_id`,`date`);
|
||||||
275
drizzle/meta/0002_snapshot.json
Normal file
275
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
|
||||||
|
"prevId": "569c1d8d-7308-47c2-ba44-85c4917b789d",
|
||||||
|
"tables": {
|
||||||
|
"daily_completions": {
|
||||||
|
"name": "daily_completions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"anonymous_id": {
|
||||||
|
"name": "anonymous_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"guess_count": {
|
||||||
|
"name": "guess_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"anonymous_id_date_idx": {
|
||||||
|
"name": "anonymous_id_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"anonymous_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"date_idx": {
|
||||||
|
"name": "date_idx",
|
||||||
|
"columns": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"date_guess_idx": {
|
||||||
|
"name": "date_guess_idx",
|
||||||
|
"columns": [
|
||||||
|
"date",
|
||||||
|
"guess_count"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"daily_completions_anonymous_id_date_unique": {
|
||||||
|
"name": "daily_completions_anonymous_id_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"anonymous_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"daily_verses": {
|
||||||
|
"name": "daily_verses",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"book_id": {
|
||||||
|
"name": "book_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"verse_text": {
|
||||||
|
"name": "verse_text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"name": "reference",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"daily_verses_date_unique": {
|
||||||
|
"name": "daily_verses_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"name": "session",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"first_name": {
|
||||||
|
"name": "first_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_name": {
|
||||||
|
"name": "last_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"apple_id": {
|
||||||
|
"name": "apple_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_private": {
|
||||||
|
"name": "is_private",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"user_apple_id_unique": {
|
||||||
|
"name": "user_apple_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"apple_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,20 @@
|
|||||||
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1770961427714,
|
||||||
|
"tag": "0002_outstanding_hiroim",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
41
scripts/dedup-completions.ts
Normal file
41
scripts/dedup-completions.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
if (!dbUrl) throw new Error('DATABASE_URL is not set');
|
||||||
|
|
||||||
|
const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice(5) : dbUrl;
|
||||||
|
const db = new Database(path.resolve(dbPath));
|
||||||
|
|
||||||
|
const duplicates = db.query(`
|
||||||
|
SELECT anonymous_id, date, COUNT(*) as count
|
||||||
|
FROM daily_completions
|
||||||
|
GROUP BY anonymous_id, date
|
||||||
|
HAVING count > 1
|
||||||
|
`).all() as { anonymous_id: string; date: string; count: number }[];
|
||||||
|
|
||||||
|
if (duplicates.length === 0) {
|
||||||
|
console.log('No duplicates found.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${duplicates.length} duplicate group(s):`);
|
||||||
|
|
||||||
|
const deleteStmt = db.query(`
|
||||||
|
DELETE FROM daily_completions
|
||||||
|
WHERE anonymous_id = $anonymous_id AND date = $date
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT id FROM daily_completions
|
||||||
|
WHERE anonymous_id = $anonymous_id AND date = $date
|
||||||
|
ORDER BY completed_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const { anonymous_id, date, count } of duplicates) {
|
||||||
|
deleteStmt.run({ $anonymous_id: anonymous_id, $date: date });
|
||||||
|
console.log(` ${anonymous_id} / ${date}: kept earliest, deleted ${count - 1} row(s) (had ${count})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
|
db.close();
|
||||||
75
scripts/deduplicate-completions.ts
Normal file
75
scripts/deduplicate-completions.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import Database from 'bun:sqlite';
|
||||||
|
|
||||||
|
// Database path - adjust if your database is located elsewhere
|
||||||
|
const dbPath = Bun.env.DATABASE_URL || './local.db';
|
||||||
|
console.log(`Connecting to database: ${dbPath}`);
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
interface DuplicateGroup {
|
||||||
|
anonymous_id: string;
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Completion {
|
||||||
|
id: string;
|
||||||
|
anonymous_id: string;
|
||||||
|
date: string;
|
||||||
|
guess_count: number;
|
||||||
|
completed_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Finding duplicates...\n');
|
||||||
|
|
||||||
|
// Find all (anonymous_id, date) pairs with duplicates
|
||||||
|
const duplicatesQuery = db.query<DuplicateGroup, []>(`
|
||||||
|
SELECT anonymous_id, date, COUNT(*) as count
|
||||||
|
FROM daily_completions
|
||||||
|
GROUP BY anonymous_id, date
|
||||||
|
HAVING count > 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const duplicates = duplicatesQuery.all();
|
||||||
|
console.log(`Found ${duplicates.length} duplicate groups\n`);
|
||||||
|
|
||||||
|
if (duplicates.length === 0) {
|
||||||
|
console.log('No duplicates to clean up!');
|
||||||
|
db.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalDeleted = 0;
|
||||||
|
|
||||||
|
// Process each duplicate group
|
||||||
|
for (const dup of duplicates) {
|
||||||
|
// Get all completions for this (anonymous_id, date) pair
|
||||||
|
const completionsQuery = db.query<Completion, [string, string]>(`
|
||||||
|
SELECT id, anonymous_id, date, guess_count, completed_at
|
||||||
|
FROM daily_completions
|
||||||
|
WHERE anonymous_id = ? AND date = ?
|
||||||
|
ORDER BY completed_at ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const completions = completionsQuery.all(dup.anonymous_id, dup.date);
|
||||||
|
console.log(` ${dup.anonymous_id} on ${dup.date}: ${completions.length} entries`);
|
||||||
|
|
||||||
|
// Keep the first (earliest completion), delete the rest
|
||||||
|
const toKeep = completions[0];
|
||||||
|
const toDelete = completions.slice(1);
|
||||||
|
|
||||||
|
console.log(` Keeping: ${toKeep.id} (completed at ${new Date(toKeep.completed_at * 1000).toISOString()})`);
|
||||||
|
|
||||||
|
const deleteQuery = db.query('DELETE FROM daily_completions WHERE id = ?');
|
||||||
|
|
||||||
|
for (const comp of toDelete) {
|
||||||
|
console.log(` Deleting: ${comp.id} (completed at ${new Date(comp.completed_at * 1000).toISOString()})`);
|
||||||
|
deleteQuery.run(comp.id);
|
||||||
|
totalDeleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Deduplication complete!`);
|
||||||
|
console.log(`Total records deleted: ${totalDeleted}`);
|
||||||
|
console.log(`Unique completions preserved: ${duplicates.length}`);
|
||||||
|
|
||||||
|
db.close();
|
||||||
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 |
236
src/lib/components/AuthModal.svelte
Normal file
236
src/lib/components/AuthModal.svelte
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<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 onkeydown={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="/auth/apple">
|
||||||
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Apple
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex items-center my-4">
|
||||||
|
<div class="flex-1 border-t border-white/20"></div>
|
||||||
|
<span class="px-3 text-sm text-white/60">or</span>
|
||||||
|
<div class="flex-1 border-t border-white/20"></div>
|
||||||
|
</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,88 +1,82 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
|
||||||
let timeUntilNext = $state("");
|
let timeUntilNext = $state("");
|
||||||
let intervalId: number | null = null;
|
let newVerseReady = $state(false);
|
||||||
|
let intervalId: number | null = null;
|
||||||
|
let targetTime = 0;
|
||||||
|
|
||||||
function calculateTimeUntilFivePM(): string {
|
function initTarget() {
|
||||||
const now = new Date();
|
const target = new Date();
|
||||||
const target = new Date(now);
|
target.setHours(0, 0, 0, 0);
|
||||||
|
if (Date.now() >= target.getTime()) {
|
||||||
|
target.setDate(target.getDate() + 1);
|
||||||
|
}
|
||||||
|
targetTime = target.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
// Set target to 5:00 PM today
|
function updateTimer() {
|
||||||
target.setHours(17, 0, 0, 0);
|
const diff = targetTime - Date.now();
|
||||||
|
|
||||||
// If it's already past 5:00 PM, set target to tomorrow 5:00 PM
|
if (diff <= 0) {
|
||||||
if (now.getTime() >= target.getTime()) {
|
newVerseReady = true;
|
||||||
target.setDate(target.getDate() + 1);
|
timeUntilNext = "";
|
||||||
}
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const diff = target.getTime() - now.getTime();
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
if (diff <= 0) {
|
timeUntilNext = `${hours.toString().padStart(2, "0")}h ${minutes
|
||||||
return "00:00:00";
|
.toString()
|
||||||
}
|
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||||
|
}
|
||||||
|
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
onMount(() => {
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
initTarget();
|
||||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
updateTimer();
|
||||||
|
intervalId = window.setInterval(updateTimer, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
return `${hours.toString().padStart(2, "0")}h ${minutes
|
onDestroy(() => {
|
||||||
.toString()
|
if (intervalId) {
|
||||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
clearInterval(intervalId);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
function calculateTimeUntilMidnight(): string {
|
|
||||||
const now = new Date();
|
|
||||||
const target = new Date(now);
|
|
||||||
|
|
||||||
// Set target to midnight today
|
|
||||||
target.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
// If it's already past midnight, set target to tomorrow midnight
|
|
||||||
if (now.getTime() >= target.getTime()) {
|
|
||||||
target.setDate(target.getDate() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const diff = target.getTime() - now.getTime();
|
|
||||||
|
|
||||||
if (diff <= 0) {
|
|
||||||
return "00:00:00";
|
|
||||||
}
|
|
||||||
|
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
||||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
|
||||||
|
|
||||||
return `${hours.toString().padStart(2, "0")}h ${minutes
|
|
||||||
.toString()
|
|
||||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTimer() {
|
|
||||||
timeUntilNext = calculateTimeUntilMidnight();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
updateTimer();
|
|
||||||
intervalId = window.setInterval(updateTimer, 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (intervalId) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full"
|
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full"
|
||||||
>
|
>
|
||||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2">
|
{#if newVerseReady}
|
||||||
Next Verse In
|
<p
|
||||||
</p>
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
|
||||||
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
>
|
||||||
{timeUntilNext}
|
Next Verse In
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p class="text-4xl font-triodion font-black text-gray-800">Now</p>
|
||||||
|
<p
|
||||||
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mt-2"
|
||||||
|
>
|
||||||
|
(refresh page to see the new verse)
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p
|
||||||
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
|
||||||
|
>
|
||||||
|
Next Verse In
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-4xl font-triodion font-black text-gray-800 tabular-nums"
|
||||||
|
>
|
||||||
|
{timeUntilNext}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</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"
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import Button from "$lib/components/Button.svelte";
|
import Button from "$lib/components/Button.svelte";
|
||||||
|
|
||||||
|
let { anonymousId }: { anonymousId: string | null } = $props();
|
||||||
|
|
||||||
|
let seeding = $state(false);
|
||||||
|
|
||||||
|
async function seedHistory() {
|
||||||
|
if (!browser || !anonymousId || seeding) return;
|
||||||
|
seeding = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/dev/seed-history", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ anonymousId })
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
alert(
|
||||||
|
`Seeded! Inserted: ${result.inserted?.join(", ")}. Skipped (already exist): ${result.skipped?.join(", ") || "none"}`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
alert("Failed to seed history");
|
||||||
|
} finally {
|
||||||
|
seeding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearLocalStorage() {
|
function clearLocalStorage() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
// Clear all bibdle-related localStorage items
|
// Clear all bibdle-related localStorage items
|
||||||
@@ -86,4 +110,13 @@
|
|||||||
>
|
>
|
||||||
Clear LocalStorage
|
Clear LocalStorage
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onclick={seedHistory}
|
||||||
|
disabled={seeding}
|
||||||
|
class="w-full py-4 md:py-2"
|
||||||
|
>
|
||||||
|
{seeding ? "Seeding..." : "Seed 10 Days of History"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks } from "$lib/types/bible";
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
|
import { getFirstLetter, type Guess } from "$lib/utils/game";
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
interface Guess {
|
|
||||||
book: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
testament: string;
|
|
||||||
section: string;
|
|
||||||
};
|
|
||||||
testamentMatch: boolean;
|
|
||||||
sectionMatch: boolean;
|
|
||||||
adjacent: boolean;
|
|
||||||
firstLetterMatch: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
guesses,
|
guesses,
|
||||||
correctBookId,
|
correctBookId,
|
||||||
@@ -28,11 +16,6 @@
|
|||||||
return "bg-red-500 border-red-600";
|
return "bg-red-500 border-red-600";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstLetter(bookName: string): string {
|
|
||||||
const match = bookName.match(/[a-zA-Z]/);
|
|
||||||
return match ? match[0] : bookName[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBoxContent(
|
function getBoxContent(
|
||||||
guess: Guess,
|
guess: Guess,
|
||||||
column: "book" | "firstLetter" | "testament" | "section",
|
column: "book" | "firstLetter" | "testament" | "section",
|
||||||
@@ -88,8 +71,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<p class="text-gray-700 leading-relaxed italic">
|
<p class="text-gray-700 leading-relaxed italic">
|
||||||
Guess what book of the bible you think the verse is from. You will
|
Guess what book of the bible you think the verse is from. You will
|
||||||
get clues to tell you if your guess is close or not. Green means the
|
get clues to help you after each guess.
|
||||||
category is correct; red means wrong.
|
|
||||||
</p>
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -99,47 +81,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 +122,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 +137,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>
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
imposterIndex: number;
|
imposterIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: ImposterData | null = null;
|
let data: ImposterData | null = $state(null);
|
||||||
let clicked: boolean[] = [];
|
let clicked: boolean[] = $state([]);
|
||||||
let gameOver = false;
|
let gameOver = $state(false);
|
||||||
let loading = true;
|
let loading = $state(true);
|
||||||
let error: string | null = null;
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
async function loadGame() {
|
async function loadGame() {
|
||||||
try {
|
try {
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">
|
<div class="error">
|
||||||
<p>Error: {error}</p>
|
<p>Error: {error}</p>
|
||||||
<button on:click={newGame}>Retry</button>
|
<button onclick={newGame}>Retry</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if data}
|
{:else if data}
|
||||||
<!-- <div class="instructions">
|
<!-- <div class="instructions">
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
class:clicked={clicked[i]}
|
class:clicked={clicked[i]}
|
||||||
class:correct={clicked[i] && i === data.imposterIndex}
|
class:correct={clicked[i] && i === data.imposterIndex}
|
||||||
class:wrong={clicked[i] && i !== data.imposterIndex}
|
class:wrong={clicked[i] && i !== data.imposterIndex}
|
||||||
on:click={() => handleClick(i)}
|
onclick={() => handleClick(i)}
|
||||||
disabled={gameOver}
|
disabled={gameOver}
|
||||||
>
|
>
|
||||||
{formatVerse(verse)}
|
{formatVerse(verse)}
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<div class="result">
|
<div class="result">
|
||||||
<button on:click={newGame}>New Game</button>
|
<button onclick={newGame}>New Game</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
@@ -20,19 +22,52 @@
|
|||||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||||
.replace(/[,:;-—]$/, "...")
|
.replace(/[,:;-—]$/, "...")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let showReference = $state(false);
|
||||||
|
|
||||||
|
// Delay showing reference until GuessesTable animation completes
|
||||||
|
$effect(() => {
|
||||||
|
if (!isWon) {
|
||||||
|
showReference = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already won today (page reload case)
|
||||||
|
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||||
|
const alreadyWonToday = browser && localStorage.getItem(winTrackedKey) === "true";
|
||||||
|
|
||||||
|
if (alreadyWonToday) {
|
||||||
|
// User already won and is refreshing - show immediately
|
||||||
|
showReference = true;
|
||||||
|
} else {
|
||||||
|
// User just won this session - delay for animation
|
||||||
|
const animationDelay = 1800;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
showReference = true;
|
||||||
|
}, animationDelay);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container class="w-full p-8 sm:p-12 bg-white/70">
|
<Container class="w-full p-8 sm:p-12 bg-white/70 overflow-hidden">
|
||||||
<blockquote
|
<blockquote
|
||||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
||||||
>
|
>
|
||||||
{displayVerseText}
|
{displayVerseText}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
{#if isWon}
|
<div
|
||||||
<p
|
class="transition-all duration-500 ease-in-out overflow-hidden"
|
||||||
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
style="max-height: {showReference ? '200px' : '0px'};"
|
||||||
>
|
>
|
||||||
{displayReference}
|
{#if showReference}
|
||||||
</p>
|
<p
|
||||||
{/if}
|
transition:fade={{ duration: 400 }}
|
||||||
|
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
||||||
|
>
|
||||||
|
{displayReference}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
|
|
||||||
export * from './utils/game';
|
export * from './utils/game';
|
||||||
export { default as VerseDisplay } from './components/VerseDisplay.svelte';
|
|
||||||
export { default as SearchInput } from './components/SearchInput.svelte';
|
|
||||||
export { default as GuessesTable } from './components/GuessesTable.svelte';
|
|
||||||
export { default as WinScreen } from './components/WinScreen.svelte';
|
|
||||||
export { default as Feedback } from './components/Feedback.svelte';
|
|
||||||
|
|||||||
144
src/lib/server/apple-auth.ts
Normal file
144
src/lib/server/apple-auth.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
|
|
||||||
|
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
||||||
|
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
||||||
|
|
||||||
|
export function getAppleAuthUrl(state: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: Bun.env.APPLE_ID!,
|
||||||
|
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/apple/callback`,
|
||||||
|
response_type: 'code',
|
||||||
|
response_mode: 'form_post',
|
||||||
|
scope: 'name email',
|
||||||
|
state
|
||||||
|
});
|
||||||
|
return `${APPLE_AUTH_URL}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateAppleClientSecret(): Promise<string> {
|
||||||
|
const header = { alg: 'ES256', kid: Bun.env.APPLE_KEY_ID! };
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload = {
|
||||||
|
iss: Bun.env.APPLE_TEAM_ID!,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 86400 * 180,
|
||||||
|
aud: 'https://appleid.apple.com',
|
||||||
|
sub: Bun.env.APPLE_ID!
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
|
||||||
|
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
|
||||||
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
|
||||||
|
// Import PEM private key
|
||||||
|
const pemBody = Bun.env.APPLE_PRIVATE_KEY!.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||||
|
.replace(/-----END PRIVATE KEY-----/, '')
|
||||||
|
.replace(/\s/g, '');
|
||||||
|
const keyBuffer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0));
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
keyBuffer,
|
||||||
|
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signatureBuffer = await crypto.subtle.sign(
|
||||||
|
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||||
|
key,
|
||||||
|
new TextEncoder().encode(signingInput)
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = new Uint8Array(signatureBuffer);
|
||||||
|
|
||||||
|
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
||||||
|
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
||||||
|
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
||||||
|
const encodedSignature = encodeBase64url(rawSignature);
|
||||||
|
|
||||||
|
return `${signingInput}.${encodedSignature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DER-encoded ECDSA signature to raw r||s format (64 bytes for P-256)
|
||||||
|
*/
|
||||||
|
function derToRaw(der: Uint8Array): Uint8Array {
|
||||||
|
// DER structure: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s]
|
||||||
|
let offset = 2; // skip 0x30 and total length
|
||||||
|
|
||||||
|
// Read r
|
||||||
|
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||||
|
offset++;
|
||||||
|
const rLen = der[offset];
|
||||||
|
offset++;
|
||||||
|
let r = der.slice(offset, offset + rLen);
|
||||||
|
offset += rLen;
|
||||||
|
|
||||||
|
// Read s
|
||||||
|
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||||
|
offset++;
|
||||||
|
const sLen = der[offset];
|
||||||
|
offset++;
|
||||||
|
let s = der.slice(offset, offset + sLen);
|
||||||
|
|
||||||
|
// Remove leading zero padding (DER uses it for positive sign)
|
||||||
|
if (r.length === 33 && r[0] === 0) r = r.slice(1);
|
||||||
|
if (s.length === 33 && s[0] === 0) s = s.slice(1);
|
||||||
|
|
||||||
|
// Pad to 32 bytes each
|
||||||
|
const raw = new Uint8Array(64);
|
||||||
|
raw.set(r, 32 - r.length);
|
||||||
|
raw.set(s, 64 - s.length);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeAppleCode(
|
||||||
|
code: string,
|
||||||
|
redirectUri: string
|
||||||
|
): Promise<{
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token: string;
|
||||||
|
id_token: string;
|
||||||
|
}> {
|
||||||
|
const clientSecret = await generateAppleClientSecret();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: Bun.env.APPLE_ID!,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: redirectUri
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(APPLE_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`Apple token exchange failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode Apple's id_token JWT payload without signature verification.
|
||||||
|
* Safe because the token is received directly from Apple's token endpoint over TLS.
|
||||||
|
*/
|
||||||
|
export function decodeAppleIdToken(idToken: string): {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: string;
|
||||||
|
is_private_email?: string;
|
||||||
|
} {
|
||||||
|
const [, payloadB64] = idToken.split('.');
|
||||||
|
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
|
||||||
|
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
116
src/lib/server/auth.test.ts
Normal file
116
src/lib/server/auth.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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,
|
||||||
|
appleId: null,
|
||||||
|
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, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId },
|
||||||
session: table.session
|
session: table.session
|
||||||
})
|
})
|
||||||
.from(table.session)
|
.from(table.session)
|
||||||
@@ -79,3 +79,83 @@ 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,
|
||||||
|
appleId: null,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByAppleId(appleId: string) {
|
||||||
|
const [user] = await db.select().from(table.user).where(eq(table.user.appleId, appleId));
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
|
||||||
|
if (!anonymousId || anonymousId === userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { dailyCompletions } = await import('$lib/server/db/schema');
|
||||||
|
|
||||||
|
const anonCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||||
|
|
||||||
|
const userCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
|
||||||
|
const userDates = new Set(userCompletions.map((c) => c.date));
|
||||||
|
|
||||||
|
let migrated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const completion of anonCompletions) {
|
||||||
|
if (!userDates.has(completion.date)) {
|
||||||
|
await db
|
||||||
|
.update(dailyCompletions)
|
||||||
|
.set({ anonymousId: userId })
|
||||||
|
.where(eq(dailyCompletions.id, completion.id));
|
||||||
|
migrated++;
|
||||||
|
} else {
|
||||||
|
await db.delete(dailyCompletions).where(eq(dailyCompletions.id, completion.id));
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error migrating anonymous stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
33
src/lib/server/daily-verse.ts
Normal file
33
src/lib/server/daily-verse.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyVerses } from '$lib/server/db/schema';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||||
|
import type { DailyVerse } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
export async function getVerseForDate(dateStr: string): Promise<DailyVerse> {
|
||||||
|
// Validate date format (YYYY-MM-DD)
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||||
|
throw new Error('Invalid date format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's an existing verse for this date, return it
|
||||||
|
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return existing[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise get a new random verse for this date
|
||||||
|
const apiVerse = await fetchRandomVerse();
|
||||||
|
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
||||||
|
|
||||||
|
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||||
|
id: Bun.randomUUIDv7(),
|
||||||
|
date: dateStr,
|
||||||
|
bookId: apiVerse.bookId,
|
||||||
|
verseText: apiVerse.verseText,
|
||||||
|
reference: apiVerse.reference,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
@@ -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,15 @@
|
|||||||
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'),
|
||||||
|
appleId: text('apple_id').unique(),
|
||||||
|
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(),
|
||||||
@@ -28,11 +37,14 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
|||||||
anonymousId: text('anonymous_id').notNull(),
|
anonymousId: text('anonymous_id').notNull(),
|
||||||
date: text('date').notNull(),
|
date: text('date').notNull(),
|
||||||
guessCount: integer('guess_count').notNull(),
|
guessCount: integer('guess_count').notNull(),
|
||||||
|
guesses: text('guesses'), // nullable; only stored for logged-in users
|
||||||
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
||||||
}, (table) => ({
|
}, (table) => [
|
||||||
uniqueCompletion: unique().on(table.anonymousId, table.date),
|
index('anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||||
dateIndex: index('date_idx').on(table.date),
|
index('date_idx').on(table.date),
|
||||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
index('date_guess_idx').on(table.date, table.guessCount),
|
||||||
}));
|
// Ensures schema matches the database migration and prevents duplicate submissions
|
||||||
|
unique('daily_completions_anonymous_id_date_unique').on(table.anonymousId, table.date),
|
||||||
|
]);
|
||||||
|
|
||||||
export type DailyCompletion = typeof dailyCompletions.$inferSelect;
|
export type DailyCompletion = typeof dailyCompletions.$inferSelect;
|
||||||
|
|||||||
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 });
|
||||||
148
src/lib/stores/game-persistence.svelte.ts
Normal file
148
src/lib/stores/game-persistence.svelte.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
|
||||||
|
|
||||||
|
function getOrCreateAnonymousId(): string {
|
||||||
|
if (!browser) return "";
|
||||||
|
const key = "bibdle-anonymous-id";
|
||||||
|
let id = localStorage.getItem(key);
|
||||||
|
if (!id) {
|
||||||
|
id = generateUUID();
|
||||||
|
localStorage.setItem(key, id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGamePersistence(
|
||||||
|
getDate: () => string,
|
||||||
|
getReference: () => string,
|
||||||
|
getCorrectBookId: () => string,
|
||||||
|
getUserId: () => string | undefined,
|
||||||
|
) {
|
||||||
|
let guesses = $state<Guess[]>([]);
|
||||||
|
let anonymousId = $state("");
|
||||||
|
let statsSubmitted = $state(false);
|
||||||
|
let chapterGuessCompleted = $state(false);
|
||||||
|
let chapterCorrect = $state(false);
|
||||||
|
|
||||||
|
// Initialize anonymous ID and load persisted flags
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
const userId = getUserId();
|
||||||
|
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
||||||
|
if (userId) {
|
||||||
|
anonymousId = userId;
|
||||||
|
} else {
|
||||||
|
anonymousId = getOrCreateAnonymousId();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((window as any).umami) {
|
||||||
|
(window as any).umami.identify(anonymousId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = getDate();
|
||||||
|
const reference = getReference();
|
||||||
|
|
||||||
|
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
|
||||||
|
|
||||||
|
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||||
|
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
||||||
|
if (chapterGuessCompleted) {
|
||||||
|
const saved = localStorage.getItem(chapterGuessKey);
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
const match = reference.match(/\s(\d+):/);
|
||||||
|
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||||
|
chapterCorrect = data.selectedChapter === correctChapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load saved guesses from localStorage
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
const date = getDate();
|
||||||
|
const correctBookId = getCorrectBookId();
|
||||||
|
const key = `bibdle-guesses-${date}`;
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
if (!saved) {
|
||||||
|
guesses = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let savedIds: string[] = JSON.parse(saved);
|
||||||
|
savedIds = Array.from(new Set(savedIds));
|
||||||
|
guesses = savedIds
|
||||||
|
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||||
|
.filter((g): g is Guess => g !== null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save guesses to localStorage whenever they change
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const date = getDate();
|
||||||
|
localStorage.setItem(
|
||||||
|
`bibdle-guesses-${date}`,
|
||||||
|
JSON.stringify(guesses.map((g) => g.book.id)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function markStatsSubmitted() {
|
||||||
|
if (!browser) return;
|
||||||
|
statsSubmitted = true;
|
||||||
|
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function markWinTracked() {
|
||||||
|
if (!browser) return;
|
||||||
|
const key = `bibdle-win-tracked-${getDate()}`;
|
||||||
|
if (localStorage.getItem(key) === "true") return false;
|
||||||
|
localStorage.setItem(key, "true");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWinAlreadyTracked(): boolean {
|
||||||
|
if (!browser) return false;
|
||||||
|
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateFromServer(guessIds: string[]) {
|
||||||
|
if (!browser) return;
|
||||||
|
const correctBookId = getCorrectBookId();
|
||||||
|
const date = getDate();
|
||||||
|
guesses = guessIds
|
||||||
|
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||||
|
.filter((g): g is Guess => g !== null);
|
||||||
|
// Persist to localStorage so subsequent loads on this device skip the server check
|
||||||
|
localStorage.setItem(`bibdle-guesses-${date}`, JSON.stringify(guessIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChapterGuessCompleted() {
|
||||||
|
if (!browser) return;
|
||||||
|
chapterGuessCompleted = true;
|
||||||
|
const reference = getReference();
|
||||||
|
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||||
|
const saved = localStorage.getItem(chapterGuessKey);
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
const match = reference.match(/\s(\d+):/);
|
||||||
|
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||||
|
chapterCorrect = data.selectedChapter === correctChapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get guesses() { return guesses; },
|
||||||
|
set guesses(v: Guess[]) { guesses = v; },
|
||||||
|
get anonymousId() { return anonymousId; },
|
||||||
|
get statsSubmitted() { return statsSubmitted; },
|
||||||
|
get chapterGuessCompleted() { return chapterGuessCompleted; },
|
||||||
|
get chapterCorrect() { return chapterCorrect; },
|
||||||
|
markStatsSubmitted,
|
||||||
|
markWinTracked,
|
||||||
|
isWinAlreadyTracked,
|
||||||
|
onChapterGuessCompleted,
|
||||||
|
hydrateFromServer,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import { bibleBooks, type BibleBook } from '$lib/types/bible';
|
import { bibleBooks, type BibleBook } from '$lib/types/bible';
|
||||||
|
|
||||||
|
export interface Guess {
|
||||||
|
book: BibleBook;
|
||||||
|
testamentMatch: boolean;
|
||||||
|
sectionMatch: boolean;
|
||||||
|
adjacent: boolean;
|
||||||
|
firstLetterMatch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function getBookById(id: string): BibleBook | undefined {
|
export function getBookById(id: string): BibleBook | undefined {
|
||||||
return bibleBooks.find((b) => b.id === id);
|
return bibleBooks.find((b) => b.id === id);
|
||||||
}
|
}
|
||||||
@@ -10,7 +18,47 @@ export function isAdjacent(id1: string, id2: string): boolean {
|
|||||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGrade(numGuesses: number, popularity: number): string {
|
export function getFirstLetter(bookName: string): string {
|
||||||
|
const match = bookName.match(/[a-zA-Z]/);
|
||||||
|
return match ? match[0] : bookName[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateGuess(guessBookId: string, correctBookId: string): Guess | null {
|
||||||
|
const book = getBookById(guessBookId);
|
||||||
|
const correctBook = getBookById(correctBookId);
|
||||||
|
if (!book || !correctBook) return null;
|
||||||
|
|
||||||
|
const testamentMatch = book.testament === correctBook.testament;
|
||||||
|
const sectionMatch = book.section === correctBook.section;
|
||||||
|
const adjacent = isAdjacent(guessBookId, correctBookId);
|
||||||
|
|
||||||
|
// Special case: if correct book is in the Epistles + starts with "1",
|
||||||
|
// any guess starting with "1" counts as first letter match
|
||||||
|
const correctIsEpistlesWithNumber =
|
||||||
|
(correctBook.section === "Pauline Epistles" ||
|
||||||
|
correctBook.section === "General Epistles") &&
|
||||||
|
correctBook.name[0] === "1";
|
||||||
|
const guessIsEpistlesWithNumber =
|
||||||
|
(book.section === "Pauline Epistles" ||
|
||||||
|
book.section === "General Epistles") &&
|
||||||
|
book.name[0] === "1";
|
||||||
|
|
||||||
|
const firstLetterMatch =
|
||||||
|
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
||||||
|
? true
|
||||||
|
: getFirstLetter(book.name).toUpperCase() ===
|
||||||
|
getFirstLetter(correctBook.name).toUpperCase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
book,
|
||||||
|
testamentMatch,
|
||||||
|
sectionMatch,
|
||||||
|
adjacent,
|
||||||
|
firstLetterMatch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGrade(numGuesses: number): string {
|
||||||
if (numGuesses === 1) return "S+";
|
if (numGuesses === 1) return "S+";
|
||||||
if (numGuesses === 2) return "A+";
|
if (numGuesses === 2) return "A+";
|
||||||
if (numGuesses === 3) return "A";
|
if (numGuesses === 3) return "A";
|
||||||
|
|||||||
65
src/lib/utils/share.ts
Normal file
65
src/lib/utils/share.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Guess } from './game';
|
||||||
|
|
||||||
|
export function generateShareText(params: {
|
||||||
|
guesses: Guess[];
|
||||||
|
correctBookId: string;
|
||||||
|
dailyVerseDate: string;
|
||||||
|
grade: string;
|
||||||
|
chapterCorrect: boolean;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
userStreak?: number;
|
||||||
|
origin: string;
|
||||||
|
}): string {
|
||||||
|
const { guesses, correctBookId, dailyVerseDate, grade, chapterCorrect, isLoggedIn, userStreak, origin } = params;
|
||||||
|
|
||||||
|
const emojis = guesses
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((guess) => {
|
||||||
|
if (guess.book.id === correctBookId) return "✅";
|
||||||
|
if (guess.adjacent) return "‼️";
|
||||||
|
if (guess.sectionMatch) return "🟩";
|
||||||
|
if (guess.testamentMatch) return "🟧";
|
||||||
|
return "🟥";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
const formattedDate = dateFormatter.format(
|
||||||
|
new Date(`${dailyVerseDate}T00:00:00`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookEmoji = isLoggedIn ? "📜" : "📖";
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
||||||
|
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isLoggedIn && userStreak !== undefined) {
|
||||||
|
lines.push(`🔥 ${userStreak} day streak`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shareResult(shareText: string): Promise<void> {
|
||||||
|
if ("share" in navigator) {
|
||||||
|
await (navigator as any).share({ text: shareText });
|
||||||
|
} else {
|
||||||
|
await (navigator as any).clipboard.writeText(shareText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyToClipboard(shareText: string): Promise<void> {
|
||||||
|
await (navigator as any).clipboard.writeText(shareText);
|
||||||
|
}
|
||||||
68
src/lib/utils/stats-client.ts
Normal file
68
src/lib/utils/stats-client.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
export interface StatsData {
|
||||||
|
solveRank: number;
|
||||||
|
guessRank: number;
|
||||||
|
totalSolves: number;
|
||||||
|
averageGuesses: number;
|
||||||
|
tiedCount: number;
|
||||||
|
percentile: number;
|
||||||
|
guesses?: string[]; // Present when fetching an existing completion (cross-device sync)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitCompletion(params: {
|
||||||
|
anonymousId: string;
|
||||||
|
date: string;
|
||||||
|
guessCount: number;
|
||||||
|
guesses: string[];
|
||||||
|
}): Promise<StatsData | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/submit-completion", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.stats) {
|
||||||
|
return result.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
// Already submitted from another device — fetch existing stats
|
||||||
|
return fetchExistingStats({ anonymousId: params.anonymousId, date: params.date });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error("Stats server error:", result.error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Stats submission failed:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchExistingStats(params: {
|
||||||
|
anonymousId: string;
|
||||||
|
date: string;
|
||||||
|
}): Promise<StatsData | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/stats?anonymousId=${params.anonymousId}&date=${params.date}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.stats) {
|
||||||
|
return result.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error("Stats server error:", result.error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Stats fetch failed:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
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()}
|
||||||
|
|||||||
@@ -1,47 +1,14 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
import { eq, sql, asc } from 'drizzle-orm';
|
import { eq, asc } from 'drizzle-orm';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
|
||||||
import { getBookById } from '$lib/server/bible';
|
|
||||||
import type { DailyVerse } from '$lib/server/db/schema';
|
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
async function getTodayVerse(): Promise<DailyVerse> {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
// Get the current date (server-side)
|
|
||||||
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
|
|
||||||
|
|
||||||
// If there's an existing verse for the current date, return it
|
|
||||||
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
|
||||||
if (existing.length > 0) {
|
|
||||||
return existing[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise get a new random verse
|
|
||||||
const apiVerse = await fetchRandomVerse();
|
|
||||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
|
||||||
|
|
||||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
date: dateStr,
|
|
||||||
bookId: apiVerse.bookId,
|
|
||||||
verseText: apiVerse.verseText,
|
|
||||||
reference: apiVerse.reference,
|
|
||||||
};
|
|
||||||
|
|
||||||
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
|
||||||
return inserted;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
|
||||||
const dailyVerse = await getTodayVerse();
|
|
||||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dailyVerse,
|
user: locals.user,
|
||||||
correctBookId: dailyVerse.bookId,
|
session: locals.session
|
||||||
correctBook
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
|
||||||
|
|
||||||
import type { PageProps } from "./$types";
|
import type { PageProps } from "./$types";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
|
||||||
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
||||||
import SearchInput from "$lib/components/SearchInput.svelte";
|
import SearchInput from "$lib/components/SearchInput.svelte";
|
||||||
@@ -11,42 +10,46 @@
|
|||||||
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 { getGrade } from "$lib/utils/game";
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
|
|
||||||
interface Guess {
|
import { evaluateGuess, getGrade } from "$lib/utils/game";
|
||||||
book: BibleBook;
|
import {
|
||||||
testamentMatch: boolean;
|
generateShareText,
|
||||||
sectionMatch: boolean;
|
shareResult,
|
||||||
adjacent: boolean;
|
copyToClipboard as clipboardCopy,
|
||||||
firstLetterMatch: boolean;
|
} from "$lib/utils/share";
|
||||||
}
|
import {
|
||||||
|
submitCompletion,
|
||||||
|
fetchExistingStats,
|
||||||
|
type StatsData,
|
||||||
|
} from "$lib/utils/stats-client";
|
||||||
|
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
let dailyVerse = $derived(data.dailyVerse);
|
let dailyVerse = $derived(data.dailyVerse);
|
||||||
let correctBookId = $derived(data.correctBookId);
|
let correctBookId = $derived(data.correctBookId);
|
||||||
|
let correctBook = $derived(data.correctBook);
|
||||||
let guesses = $state<Guess[]>([]);
|
let user = $derived(data.user);
|
||||||
|
let session = $derived(data.session);
|
||||||
|
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
|
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let isDev = $state(false);
|
let isDev = $state(false);
|
||||||
let chapterGuessCompleted = $state(false);
|
let authModalOpen = $state(false);
|
||||||
let chapterCorrect = $state(false);
|
let showWinScreen = $state(false);
|
||||||
|
let statsData = $state<StatsData | null>(null);
|
||||||
|
|
||||||
let anonymousId = $state("");
|
const persistence = createGamePersistence(
|
||||||
let statsSubmitted = $state(false);
|
() => dailyVerse.date,
|
||||||
let statsData = $state<{
|
() => dailyVerse.reference,
|
||||||
solveRank: number;
|
() => correctBookId,
|
||||||
guessRank: number;
|
() => user?.id,
|
||||||
totalSolves: number;
|
);
|
||||||
averageGuesses: number;
|
|
||||||
tiedCount: number;
|
|
||||||
percentile: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
|
let guessedIds = $derived(
|
||||||
|
new Set(persistence.guesses.map((g) => g.book.id)),
|
||||||
|
);
|
||||||
|
|
||||||
const currentDate = $derived(
|
const currentDate = $derived(
|
||||||
new Date().toLocaleDateString("en-US", {
|
new Date().toLocaleDateString("en-US", {
|
||||||
@@ -57,75 +60,33 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
|
let isWon = $derived(
|
||||||
|
persistence.guesses.some((g) => g.book.id === correctBookId),
|
||||||
|
);
|
||||||
let grade = $derived(
|
let grade = $derived(
|
||||||
isWon
|
isWon
|
||||||
? guesses.length === 1 && chapterCorrect
|
? persistence.guesses.length === 1 && persistence.chapterCorrect
|
||||||
? "S++"
|
? "S++"
|
||||||
: getGrade(
|
: getGrade(persistence.guesses.length)
|
||||||
guesses.length,
|
|
||||||
getBookById(correctBookId)?.popularity ?? 0,
|
|
||||||
)
|
|
||||||
: "",
|
: "",
|
||||||
);
|
);
|
||||||
let blurChapter = $derived(
|
let blurChapter = $derived(
|
||||||
isWon && guesses.length === 1 && !chapterGuessCompleted,
|
isWon &&
|
||||||
|
persistence.guesses.length === 1 &&
|
||||||
|
!persistence.chapterGuessCompleted,
|
||||||
);
|
);
|
||||||
|
|
||||||
function getBookById(id: string): BibleBook | undefined {
|
async function submitGuess(bookId: string) {
|
||||||
return bibleBooks.find((b) => b.id === id);
|
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
|
||||||
}
|
|
||||||
|
|
||||||
function isAdjacent(id1: string, id2: string): boolean {
|
const guess = evaluateGuess(bookId, correctBookId);
|
||||||
const b1 = getBookById(id1);
|
if (!guess) return;
|
||||||
const b2 = getBookById(id2);
|
|
||||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFirstLetter(bookName: string): string {
|
if (persistence.guesses.length === 0) {
|
||||||
const match = bookName.match(/[a-zA-Z]/);
|
|
||||||
return match ? match[0] : bookName[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitGuess(bookId: string) {
|
|
||||||
if (guesses.some((g) => g.book.id === bookId)) return;
|
|
||||||
|
|
||||||
const book = getBookById(bookId);
|
|
||||||
if (!book) return;
|
|
||||||
|
|
||||||
const correctBook = getBookById(correctBookId);
|
|
||||||
if (!correctBook) return;
|
|
||||||
|
|
||||||
const testamentMatch = book.testament === correctBook.testament;
|
|
||||||
const sectionMatch = book.section === correctBook.section;
|
|
||||||
const adjacent = isAdjacent(bookId, correctBookId);
|
|
||||||
|
|
||||||
// Special case: if correct book is in the Epistles + starts with "1",
|
|
||||||
// any guess starting with "1" counts as first letter match
|
|
||||||
const correctIsEpistlesWithNumber =
|
|
||||||
(correctBook.section === "Pauline Epistles" ||
|
|
||||||
correctBook.section === "General Epistles") &&
|
|
||||||
correctBook.name[0] === "1";
|
|
||||||
const guessIsEpistlesWithNumber =
|
|
||||||
(book.section === "Pauline Epistles" ||
|
|
||||||
book.section === "General Epistles") &&
|
|
||||||
book.name[0] === "1";
|
|
||||||
|
|
||||||
const firstLetterMatch =
|
|
||||||
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
|
||||||
? true
|
|
||||||
: getFirstLetter(book.name).toUpperCase() ===
|
|
||||||
getFirstLetter(correctBook.name).toUpperCase();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (guesses.length === 0) {
|
|
||||||
const key = `bibdle-first-guess-${dailyVerse.date}`;
|
const key = `bibdle-first-guess-${dailyVerse.date}`;
|
||||||
if (
|
if (
|
||||||
localStorage.getItem(key) !== "true" &&
|
|
||||||
browser &&
|
browser &&
|
||||||
|
localStorage.getItem(key) !== "true" &&
|
||||||
(window as any).umami
|
(window as any).umami
|
||||||
) {
|
) {
|
||||||
(window as any).umami.track("First guess");
|
(window as any).umami.track("First guess");
|
||||||
@@ -133,285 +94,164 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guesses = [
|
persistence.guesses = [guess, ...persistence.guesses];
|
||||||
{
|
|
||||||
book,
|
|
||||||
testamentMatch,
|
|
||||||
sectionMatch,
|
|
||||||
adjacent,
|
|
||||||
firstLetterMatch,
|
|
||||||
},
|
|
||||||
...guesses,
|
|
||||||
];
|
|
||||||
|
|
||||||
searchQuery = "";
|
searchQuery = "";
|
||||||
}
|
|
||||||
|
|
||||||
function generateUUID(): string {
|
if (
|
||||||
// Try native randomUUID if available
|
guess.book.id === correctBookId &&
|
||||||
if (typeof window.crypto.randomUUID === "function") {
|
browser &&
|
||||||
return window.crypto.randomUUID();
|
persistence.anonymousId
|
||||||
}
|
) {
|
||||||
|
statsData = await submitCompletion({
|
||||||
// Fallback UUID v4 generator for older browsers
|
anonymousId: persistence.anonymousId,
|
||||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
date: dailyVerse.date,
|
||||||
const r =
|
guessCount: persistence.guesses.length,
|
||||||
window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
|
guesses: persistence.guesses.map((g) => g.book.id),
|
||||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrCreateAnonymousId(): string {
|
|
||||||
if (!browser) return "";
|
|
||||||
const key = "bibdle-anonymous-id";
|
|
||||||
let id = localStorage.getItem(key);
|
|
||||||
if (!id) {
|
|
||||||
id = generateUUID();
|
|
||||||
localStorage.setItem(key, id);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize anonymous ID
|
|
||||||
$effect(() => {
|
|
||||||
if (!browser) return;
|
|
||||||
anonymousId = getOrCreateAnonymousId();
|
|
||||||
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
|
||||||
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
|
||||||
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
|
||||||
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
|
||||||
if (chapterGuessCompleted) {
|
|
||||||
const saved = localStorage.getItem(chapterGuessKey);
|
|
||||||
if (saved) {
|
|
||||||
const data = JSON.parse(saved);
|
|
||||||
const match = dailyVerse.reference.match(/\s(\d+):/);
|
|
||||||
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
|
||||||
chapterCorrect = data.selectedChapter === correctChapter;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!browser) return;
|
|
||||||
isDev = window.location.host === "localhost:5173";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load saved guesses
|
|
||||||
$effect(() => {
|
|
||||||
if (!browser) return;
|
|
||||||
|
|
||||||
const key = `bibdle-guesses-${dailyVerse.date}`;
|
|
||||||
const saved = localStorage.getItem(key);
|
|
||||||
if (saved) {
|
|
||||||
let savedIds: string[] = JSON.parse(saved);
|
|
||||||
savedIds = Array.from(new Set(savedIds));
|
|
||||||
guesses = savedIds.map((bookId: string) => {
|
|
||||||
const book = getBookById(bookId)!;
|
|
||||||
const correctBook = getBookById(correctBookId)!;
|
|
||||||
const testamentMatch = book.testament === correctBook.testament;
|
|
||||||
const sectionMatch = book.section === correctBook.section;
|
|
||||||
const adjacent = isAdjacent(bookId, correctBookId);
|
|
||||||
|
|
||||||
// Apply same first letter logic as in submitGuess
|
|
||||||
const correctIsEpistlesWithNumber =
|
|
||||||
(correctBook.section === "Pauline Epistles" ||
|
|
||||||
correctBook.section === "General Epistles") &&
|
|
||||||
correctBook.name[0] === "1";
|
|
||||||
const guessIsEpistlesWithNumber =
|
|
||||||
(book.section === "Pauline Epistles" ||
|
|
||||||
book.section === "General Epistles") &&
|
|
||||||
book.name[0] === "1";
|
|
||||||
|
|
||||||
const firstLetterMatch =
|
|
||||||
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
|
||||||
? true
|
|
||||||
: getFirstLetter(book.name).toUpperCase() ===
|
|
||||||
getFirstLetter(correctBook.name).toUpperCase();
|
|
||||||
|
|
||||||
return {
|
|
||||||
book,
|
|
||||||
testamentMatch,
|
|
||||||
sectionMatch,
|
|
||||||
adjacent,
|
|
||||||
firstLetterMatch,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
if (statsData) {
|
||||||
|
persistence.markStatsSubmitted();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server date doesn't match client's local date, fetch timezone-correct verse
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
|
if (dailyVerse.date === localDate) return;
|
||||||
|
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
fetch("/api/daily-verse", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ date: localDate, timezone }),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((result) => {
|
||||||
|
dailyVerse = result.dailyVerse;
|
||||||
|
correctBookId = result.correctBookId;
|
||||||
|
correctBook = result.correctBook;
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
console.error("Failed to fetch timezone-correct verse:", err),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload when the user returns to a stale tab on a new calendar day
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
const loadedDate = new Date().toLocaleDateString("en-CA");
|
||||||
|
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (document.hidden) return;
|
||||||
|
const now = new Date().toLocaleDateString("en-CA");
|
||||||
|
if (now !== loadedDate) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener(
|
||||||
|
"visibilitychange",
|
||||||
|
onVisibilityChange,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
localStorage.setItem(
|
isDev =
|
||||||
`bibdle-guesses-${dailyVerse.date}`,
|
window.location.host === "localhost:5173" ||
|
||||||
JSON.stringify(guesses.map((g) => g.book.id)),
|
window.location.host === "test.bibdle.com";
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-submit stats when user wins
|
// Fetch stats on page load if user already won in a previous session (same device)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
console.log("Stats effect triggered:", {
|
if (
|
||||||
browser,
|
!browser ||
|
||||||
isWon,
|
!isWon ||
|
||||||
anonymousId,
|
!persistence.anonymousId ||
|
||||||
statsSubmitted,
|
statsData ||
|
||||||
statsData,
|
!persistence.statsSubmitted
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
fetchExistingStats({
|
||||||
|
anonymousId: persistence.anonymousId,
|
||||||
|
date: dailyVerse.date,
|
||||||
|
}).then((data) => {
|
||||||
|
statsData = data;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!browser || !isWon || !anonymousId) {
|
|
||||||
console.log("Basic conditions not met");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statsSubmitted && !statsData) {
|
|
||||||
console.log("Fetching existing stats...");
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`,
|
|
||||||
);
|
|
||||||
const result = await response.json();
|
|
||||||
console.log("Stats response:", result);
|
|
||||||
|
|
||||||
if (result.success && result.stats) {
|
|
||||||
console.log("Setting stats data:", result.stats);
|
|
||||||
statsData = result.stats;
|
|
||||||
localStorage.setItem(
|
|
||||||
`bibdle-stats-submitted-${dailyVerse.date}`,
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
} else if (result.error) {
|
|
||||||
console.error("Server error:", result.error);
|
|
||||||
} else {
|
|
||||||
console.error("Unexpected response format:", result);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Stats fetch failed:", err);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Submitting stats...");
|
|
||||||
|
|
||||||
async function submitStats() {
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
anonymousId,
|
|
||||||
date: dailyVerse.date,
|
|
||||||
guessCount: guesses.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("Sending POST request with:", payload);
|
|
||||||
|
|
||||||
const response = await fetch("/api/submit-completion", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
console.log("Stats response:", result);
|
|
||||||
|
|
||||||
if (result.success && result.stats) {
|
|
||||||
console.log("Setting stats data:", result.stats);
|
|
||||||
statsData = result.stats;
|
|
||||||
statsSubmitted = true;
|
|
||||||
localStorage.setItem(
|
|
||||||
`bibdle-stats-submitted-${dailyVerse.date}`,
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
} else if (result.error) {
|
|
||||||
console.error("Server error:", result.error);
|
|
||||||
} else {
|
|
||||||
console.error("Unexpected response format:", result);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Stats submission failed:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
submitStats();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// For logged-in users on a new device: restore today's game state from the server.
|
||||||
|
// Runs even when isWon is true so that logging in after completing the game on another
|
||||||
|
// device always replaces local localStorage with the authoritative DB record.
|
||||||
|
let crossDeviceCheckDate = $state<string | null>(null);
|
||||||
|
$effect(() => {
|
||||||
|
if (
|
||||||
|
!browser ||
|
||||||
|
!user ||
|
||||||
|
!dailyVerse?.date ||
|
||||||
|
crossDeviceCheckDate === dailyVerse.date ||
|
||||||
|
!persistence.anonymousId
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
crossDeviceCheckDate = dailyVerse.date;
|
||||||
|
fetchExistingStats({
|
||||||
|
anonymousId: persistence.anonymousId,
|
||||||
|
date: dailyVerse.date,
|
||||||
|
}).then((data) => {
|
||||||
|
if (data?.guesses?.length) {
|
||||||
|
persistence.hydrateFromServer(data.guesses);
|
||||||
|
statsData = data;
|
||||||
|
persistence.markStatsSubmitted();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delay showing win screen until GuessesTable animation completes
|
||||||
|
$effect(() => {
|
||||||
|
if (!isWon) {
|
||||||
|
showWinScreen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (persistence.isWinAlreadyTracked()) {
|
||||||
|
showWinScreen = true;
|
||||||
|
} else {
|
||||||
|
const animationDelay = 1800;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
showWinScreen = true;
|
||||||
|
}, animationDelay);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track win analytics
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser || !isWon) return;
|
if (!browser || !isWon) return;
|
||||||
const key = `bibdle-win-tracked-${dailyVerse.date}`;
|
const isNew = persistence.markWinTracked();
|
||||||
if (localStorage.getItem(key) === "true") return;
|
if (isNew && (window as any).umami) {
|
||||||
if ((window as any).umami) {
|
|
||||||
(window as any).umami.track("Guessed correctly", {
|
(window as any).umami.track("Guessed correctly", {
|
||||||
totalGuesses: guesses.length,
|
totalGuesses: persistence.guesses.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
localStorage.setItem(key, "true");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function generateShareText(): string {
|
function getShareText(): string {
|
||||||
const emojis = guesses
|
return generateShareText({
|
||||||
.slice()
|
guesses: persistence.guesses,
|
||||||
.reverse()
|
correctBookId,
|
||||||
.map((guess) => {
|
dailyVerseDate: dailyVerse.date,
|
||||||
if (guess.book.id === correctBookId) return "✅";
|
grade,
|
||||||
if (guess.adjacent) return "‼️";
|
chapterCorrect: persistence.chapterCorrect,
|
||||||
if (guess.sectionMatch) return "🟩";
|
isLoggedIn: !!user,
|
||||||
if (guess.testamentMatch) return "🟧";
|
userStreak: user ? (user as any).streak : undefined,
|
||||||
return "🟥";
|
origin: window.location.origin,
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
});
|
||||||
const formattedDate = dateFormatter.format(
|
|
||||||
new Date(`${dailyVerse.date}T00:00:00`),
|
|
||||||
);
|
|
||||||
const siteUrl = window.location.origin;
|
|
||||||
return [
|
|
||||||
`📖 Bibdle | ${formattedDate} 📖`,
|
|
||||||
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
|
|
||||||
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
|
||||||
siteUrl,
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function share() {
|
|
||||||
if (!browser) return;
|
|
||||||
|
|
||||||
const shareText = generateShareText();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ("share" in navigator) {
|
|
||||||
await (navigator as any).share({ text: shareText });
|
|
||||||
} else {
|
|
||||||
await (navigator as any).clipboard.writeText(shareText);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Share failed:", err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyToClipboard() {
|
|
||||||
if (!browser) return;
|
|
||||||
|
|
||||||
const shareText = generateShareText();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await (navigator as any).clipboard.writeText(shareText);
|
|
||||||
copied = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
copied = false;
|
|
||||||
}, 5000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Copy to clipboard failed:", err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShare() {
|
function handleShare() {
|
||||||
@@ -420,7 +260,7 @@
|
|||||||
if (useClipboard) {
|
if (useClipboard) {
|
||||||
copied = true;
|
copied = true;
|
||||||
}
|
}
|
||||||
share()
|
shareResult(getShareText())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (useClipboard) {
|
if (useClipboard) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -434,72 +274,174 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCopyToClipboard() {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
await clipboardCopy(getShareText());
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copied = false;
|
||||||
|
}, 5000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Copy to clipboard failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||||
<!-- <meta
|
|
||||||
name="description"
|
|
||||||
content="Guess which book of the Bible a verse comes from."
|
|
||||||
/> -->
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<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">
|
||||||
{:else}
|
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
||||||
<WinScreen
|
</div>
|
||||||
{grade}
|
{:else if showWinScreen}
|
||||||
{statsData}
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
{correctBookId}
|
<WinScreen
|
||||||
{handleShare}
|
{grade}
|
||||||
{copyToClipboard}
|
{statsData}
|
||||||
bind:copied
|
{correctBookId}
|
||||||
{statsSubmitted}
|
{handleShare}
|
||||||
guessCount={guesses.length}
|
copyToClipboard={handleCopyToClipboard}
|
||||||
reference={dailyVerse.reference}
|
bind:copied
|
||||||
onChapterGuessCompleted={() => {
|
statsSubmitted={persistence.statsSubmitted}
|
||||||
chapterGuessCompleted = true;
|
guessCount={persistence.guesses.length}
|
||||||
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
reference={dailyVerse.reference}
|
||||||
const saved = localStorage.getItem(key);
|
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
||||||
if (saved) {
|
/>
|
||||||
const data = JSON.parse(saved);
|
</div>
|
||||||
const match =
|
|
||||||
dailyVerse.reference.match(/\s(\d+):/);
|
|
||||||
const correctChapter = match
|
|
||||||
? parseInt(match[1], 10)
|
|
||||||
: 1;
|
|
||||||
chapterCorrect =
|
|
||||||
data.selectedChapter === correctChapter;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<GuessesTable {guesses} {correctBookId} />
|
<div class="animate-fade-in-up animate-delay-600">
|
||||||
|
<GuessesTable guesses={persistence.guesses} {correctBookId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if isWon}
|
{#if isWon}
|
||||||
<Credits />
|
<div class="animate-fade-in-up animate-delay-800">
|
||||||
|
<Credits />
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isDev}
|
{#if isDev}
|
||||||
<DevButtons />
|
<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=${persistence.anonymousId}`}&tz={encodeURIComponent(
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
)}"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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: {persistence.anonymousId || "Not set"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Client Local Time: {new Date().toLocaleString("en-US", {
|
||||||
|
timeZone:
|
||||||
|
Intl.DateTimeFormat().resolvedOptions()
|
||||||
|
.timeZone,
|
||||||
|
timeZoneName: "short",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Client Local Date: {new Date().toLocaleDateString(
|
||||||
|
"en-CA",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>Daily Verse Date: {dailyVerse.date}</div>
|
||||||
|
</div>
|
||||||
|
<DevButtons anonymousId={persistence.anonymousId} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if user && session}
|
||||||
|
<div
|
||||||
|
class="mt-6 pt-4 border-t border-gray-200 text-center text-xs text-gray-400"
|
||||||
|
>
|
||||||
|
Signed in as {[user.firstName, user.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}{user.email
|
||||||
|
? ` (${user.email})`
|
||||||
|
: ""}{user.appleId ? " using Apple" : ""} |
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="/auth/logout"
|
||||||
|
use:enhance
|
||||||
|
class="inline"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="ml-2 underline hover:text-gray-600 transition-colors cursor-pointer"
|
||||||
|
>Sign out</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AuthModal bind:isOpen={authModalOpen} anonymousId={persistence.anonymousId} />
|
||||||
|
|||||||
23
src/routes/+page.ts
Normal file
23
src/routes/+page.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
// Disable SSR so the load function runs on the client with the correct local date
|
||||||
|
export const ssr = false;
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, data }) => {
|
||||||
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
|
|
||||||
|
const res = await fetch('/api/daily-verse', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ date: localDate }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
dailyVerse: result.dailyVerse,
|
||||||
|
correctBookId: result.correctBookId,
|
||||||
|
correctBook: result.correctBook,
|
||||||
|
};
|
||||||
|
};
|
||||||
24
src/routes/api/daily-verse/+server.ts
Normal file
24
src/routes/api/daily-verse/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getVerseForDate } from '$lib/server/daily-verse';
|
||||||
|
import { getBookById } from '$lib/server/bible';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const body = await request.json();
|
||||||
|
const { date } = body;
|
||||||
|
|
||||||
|
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
return json({ error: 'A valid date (YYYY-MM-DD) is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = date;
|
||||||
|
|
||||||
|
const dailyVerse = await getVerseForDate(dateStr);
|
||||||
|
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
dailyVerse,
|
||||||
|
correctBookId: dailyVerse.bookId,
|
||||||
|
correctBook,
|
||||||
|
});
|
||||||
|
};
|
||||||
66
src/routes/api/dev/seed-history/+server.ts
Normal file
66
src/routes/api/dev/seed-history/+server.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const DEV_HOSTS = ['localhost:5173', 'test.bibdle.com'];
|
||||||
|
|
||||||
|
// A spread of book IDs to use as fake guesses
|
||||||
|
const SAMPLE_BOOK_IDS = [
|
||||||
|
'GEN', 'EXO', 'PSA', 'PRO', 'ISA', 'JER', 'MAT', 'MRK', 'LUK', 'JHN',
|
||||||
|
'ROM', 'GAL', 'EPH', 'PHP', 'REV', 'ACT', 'HEB', 'JAS', '1CO', '2CO',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const host = request.headers.get('host') ?? '';
|
||||||
|
if (!DEV_HOSTS.includes(host)) {
|
||||||
|
return json({ error: 'Not allowed in production' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { anonymousId } = await request.json();
|
||||||
|
|
||||||
|
if (!anonymousId || typeof anonymousId !== 'string') {
|
||||||
|
return json({ error: 'anonymousId required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const inserted: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const date = d.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||||
|
|
||||||
|
const guessCount = Math.floor(Math.random() * 6) + 1; // 1-6 guesses
|
||||||
|
// Pick `guessCount` random books (last one is the "correct" answer)
|
||||||
|
const shuffled = [...SAMPLE_BOOK_IDS].sort(() => Math.random() - 0.5);
|
||||||
|
const guesses = shuffled.slice(0, guessCount);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(dailyCompletions).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId,
|
||||||
|
date,
|
||||||
|
guessCount,
|
||||||
|
guesses: JSON.stringify(guesses),
|
||||||
|
completedAt: new Date(d.getTime() + 12 * 60 * 60 * 1000), // noon on that day
|
||||||
|
});
|
||||||
|
inserted.push(date);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
|
||||||
|
skipped.push(date);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true, inserted, skipped });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error seeding history:', err);
|
||||||
|
return json({ error: 'Failed to seed history' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
63
src/routes/api/stats/+server.ts
Normal file
63
src/routes/api/stats/+server.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { and, eq, asc } from 'drizzle-orm';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const anonymousId = url.searchParams.get('anonymousId');
|
||||||
|
const date = url.searchParams.get('date');
|
||||||
|
|
||||||
|
if (!anonymousId || !date) {
|
||||||
|
return json({ error: 'Invalid data' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(and(
|
||||||
|
eq(dailyCompletions.anonymousId, anonymousId),
|
||||||
|
eq(dailyCompletions.date, date)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (userCompletions.length === 0) {
|
||||||
|
return json({ error: 'No completion found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCompletion = userCompletions[0];
|
||||||
|
const guessCount = userCompletion.guessCount;
|
||||||
|
|
||||||
|
const allCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.date, date))
|
||||||
|
.orderBy(asc(dailyCompletions.completedAt));
|
||||||
|
|
||||||
|
const totalSolves = allCompletions.length;
|
||||||
|
|
||||||
|
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||||
|
|
||||||
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
|
const guessRank = betterGuesses + 1;
|
||||||
|
|
||||||
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
|
|
||||||
|
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||||
|
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||||
|
|
||||||
|
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
||||||
|
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
||||||
|
|
||||||
|
const guesses = userCompletion.guesses ? JSON.parse(userCompletion.guesses) : undefined;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile, guesses }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching stats:', err);
|
||||||
|
return json({ error: 'Failed to fetch stats' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { dailyCompletions } from '$lib/server/db/schema';
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
import { and, eq, asc } from 'drizzle-orm';
|
import { eq, asc } from 'drizzle-orm';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
const { anonymousId, date, guessCount } = await request.json();
|
const { anonymousId, date, guessCount, guesses } = await request.json();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
|
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
|
||||||
@@ -23,6 +23,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
anonymousId,
|
anonymousId,
|
||||||
date,
|
date,
|
||||||
guessCount,
|
guessCount,
|
||||||
|
guesses: Array.isArray(guesses) ? JSON.stringify(guesses) : null,
|
||||||
completedAt,
|
completedAt,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -68,67 +69,3 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
return json({ error: 'Failed to submit completion' }, { status: 500 });
|
return json({ error: 'Failed to submit completion' }, { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url }) => {
|
|
||||||
try {
|
|
||||||
const anonymousId = url.searchParams.get('anonymousId');
|
|
||||||
const date = url.searchParams.get('date');
|
|
||||||
|
|
||||||
if (!anonymousId || !date) {
|
|
||||||
return json({ error: 'Invalid data' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userCompletions = await db
|
|
||||||
.select()
|
|
||||||
.from(dailyCompletions)
|
|
||||||
.where(and(
|
|
||||||
eq(dailyCompletions.anonymousId, anonymousId),
|
|
||||||
eq(dailyCompletions.date, date)
|
|
||||||
))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (userCompletions.length === 0) {
|
|
||||||
return json({ error: 'No completion found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userCompletion = userCompletions[0];
|
|
||||||
const guessCount = userCompletion.guessCount;
|
|
||||||
|
|
||||||
// Calculate statistics
|
|
||||||
const allCompletions = await db
|
|
||||||
.select()
|
|
||||||
.from(dailyCompletions)
|
|
||||||
.where(eq(dailyCompletions.date, date))
|
|
||||||
.orderBy(asc(dailyCompletions.completedAt));
|
|
||||||
|
|
||||||
const totalSolves = allCompletions.length;
|
|
||||||
|
|
||||||
// Solve rank: position in time-ordered list
|
|
||||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
|
||||||
|
|
||||||
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
|
||||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
|
||||||
const guessRank = betterGuesses + 1;
|
|
||||||
|
|
||||||
// Count ties: how many have the SAME guessCount (excluding self)
|
|
||||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
|
||||||
|
|
||||||
// Average guesses
|
|
||||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
|
||||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
|
||||||
|
|
||||||
// Percentile: what percentage of people you beat (100 - your rank percentage)
|
|
||||||
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
|
||||||
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
|
||||||
|
|
||||||
return json({
|
|
||||||
success: true,
|
|
||||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching stats:', err);
|
|
||||||
return json({ error: 'Failed to fetch stats' }, { status: 500 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
27
src/routes/auth/apple/+page.server.ts
Normal file
27
src/routes/auth/apple/+page.server.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
||||||
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ cookies, request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const anonymousId = data.get('anonymousId')?.toString() || '';
|
||||||
|
|
||||||
|
// Generate CSRF state
|
||||||
|
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const state = encodeBase64url(stateBytes);
|
||||||
|
|
||||||
|
// Store state + anonymousId in a short-lived cookie
|
||||||
|
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
||||||
|
cookies.set('apple_oauth_state', JSON.stringify({ state, anonymousId }), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'none',
|
||||||
|
maxAge: 600
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(302, getAppleAuthUrl(state));
|
||||||
|
}
|
||||||
|
};
|
||||||
137
src/routes/auth/apple/callback/+server.ts
Normal file
137
src/routes/auth/apple/callback/+server.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { exchangeAppleCode, decodeAppleIdToken } from '$lib/server/apple-auth';
|
||||||
|
import { env as publicEnv } from '$env/dynamic/public';
|
||||||
|
import * as auth from '$lib/server/auth';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user as userTable } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const code = formData.get('code')?.toString();
|
||||||
|
const state = formData.get('state')?.toString();
|
||||||
|
// Apple sends user info as JSON string on FIRST authorization only
|
||||||
|
const userInfoStr = formData.get('user')?.toString();
|
||||||
|
|
||||||
|
// Validate CSRF state
|
||||||
|
const storedRaw = cookies.get('apple_oauth_state');
|
||||||
|
if (!storedRaw || !state || !code) {
|
||||||
|
throw error(400, 'Invalid OAuth callback');
|
||||||
|
}
|
||||||
|
const stored = JSON.parse(storedRaw) as { state: string; anonymousId: string };
|
||||||
|
if (stored.state !== state) {
|
||||||
|
throw error(400, 'State mismatch');
|
||||||
|
}
|
||||||
|
cookies.delete('apple_oauth_state', { path: '/' });
|
||||||
|
|
||||||
|
const anonId = stored.anonymousId;
|
||||||
|
if (!anonId) {
|
||||||
|
console.error('[Apple auth] Missing anonymousId in state cookie');
|
||||||
|
throw error(400, 'Missing anonymous ID — please return to the game and try again');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange authorization code for tokens
|
||||||
|
const tokens = await exchangeAppleCode(code, `${publicEnv.PUBLIC_SITE_URL}/auth/apple/callback`);
|
||||||
|
const claims = decodeAppleIdToken(tokens.id_token);
|
||||||
|
const appleId = claims.sub;
|
||||||
|
|
||||||
|
// Parse user info (only present on first authorization)
|
||||||
|
let appleFirstName: string | undefined;
|
||||||
|
let appleLastName: string | undefined;
|
||||||
|
if (userInfoStr) {
|
||||||
|
try {
|
||||||
|
const userInfo = JSON.parse(userInfoStr);
|
||||||
|
appleFirstName = userInfo.name?.firstName;
|
||||||
|
appleLastName = userInfo.name?.lastName;
|
||||||
|
} catch {
|
||||||
|
/* ignore parse errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User resolution ---
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
// 1. Check if a user with this appleId already exists (returning user)
|
||||||
|
const existingAppleUser = await auth.getUserByAppleId(appleId);
|
||||||
|
|
||||||
|
if (existingAppleUser) {
|
||||||
|
userId = existingAppleUser.id;
|
||||||
|
console.log(`[Apple auth] Returning Apple user: userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else if (claims.email) {
|
||||||
|
// 2. Check if email matches an existing email/password user
|
||||||
|
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
||||||
|
if (existingEmailUser) {
|
||||||
|
// Link Apple account to existing user
|
||||||
|
await db.update(userTable).set({ appleId }).where(eq(userTable.id, existingEmailUser.id));
|
||||||
|
userId = existingEmailUser.id;
|
||||||
|
console.log(`[Apple auth] Linked Apple to existing email user: userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else {
|
||||||
|
// 3. Brand new user — use anonymousId as user ID to preserve local stats
|
||||||
|
userId = anonId;
|
||||||
|
console.log(`[Apple auth] New user (has email): userId=${userId}`);
|
||||||
|
try {
|
||||||
|
await db.insert(userTable).values({
|
||||||
|
id: userId,
|
||||||
|
email: claims.email,
|
||||||
|
passwordHash: null,
|
||||||
|
appleId,
|
||||||
|
firstName: appleFirstName || null,
|
||||||
|
lastName: appleLastName || null,
|
||||||
|
isPrivate: false
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// Handle race condition: if appleId was inserted between our check and insert
|
||||||
|
if (e?.message?.includes('UNIQUE constraint')) {
|
||||||
|
const retryUser = await auth.getUserByAppleId(appleId);
|
||||||
|
if (retryUser) {
|
||||||
|
userId = retryUser.id;
|
||||||
|
console.log(`[Apple auth] Race condition (has email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else {
|
||||||
|
throw error(500, 'Failed to create user');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No email from Apple — create account with appleId only
|
||||||
|
userId = anonId;
|
||||||
|
console.log(`[Apple auth] New user (no email): userId=${userId}`);
|
||||||
|
try {
|
||||||
|
await db.insert(userTable).values({
|
||||||
|
id: userId,
|
||||||
|
email: null,
|
||||||
|
passwordHash: null,
|
||||||
|
appleId,
|
||||||
|
firstName: appleFirstName || null,
|
||||||
|
lastName: appleLastName || null,
|
||||||
|
isPrivate: false
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.message?.includes('UNIQUE constraint')) {
|
||||||
|
const retryUser = await auth.getUserByAppleId(appleId);
|
||||||
|
if (retryUser) {
|
||||||
|
userId = retryUser.id;
|
||||||
|
console.log(`[Apple auth] Race condition (no email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else {
|
||||||
|
throw error(500, 'Failed to create user');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionToken = auth.generateSessionToken();
|
||||||
|
const session = await auth.createSession(sessionToken, userId);
|
||||||
|
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt);
|
||||||
|
|
||||||
|
redirect(302, '/');
|
||||||
|
};
|
||||||
8
src/routes/auth/apple/test/+page.server.ts
Normal file
8
src/routes/auth/apple/test/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session
|
||||||
|
};
|
||||||
|
};
|
||||||
40
src/routes/auth/apple/test/+page.svelte
Normal file
40
src/routes/auth/apple/test/+page.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
|
|
||||||
|
let isOpen = $state(true);
|
||||||
|
const user = $derived(page.data.user);
|
||||||
|
let anonymousId = $state("");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (browser) {
|
||||||
|
anonymousId = localStorage.getItem("bibdle-anonymous-id") ?? "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||||
|
{#if user}
|
||||||
|
<div class="text-white text-center space-y-4">
|
||||||
|
<p class="text-lg">
|
||||||
|
Signed in as <strong>{user.email ?? "no email"}</strong>
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="/auth/logout">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-red-600 rounded-md hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (isOpen = true)}
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Open Auth Modal
|
||||||
|
</button>
|
||||||
|
<AuthModal bind:isOpen {anonymousId} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
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, '/');
|
||||||
|
}
|
||||||
|
};
|
||||||
53
src/routes/auth/signin/+page.server.ts
Normal file
53
src/routes/auth/signin/+page.server.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { 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 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
|
||||||
|
await auth.migrateAnonymousStats(anonymousId, user.id);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
256
src/routes/stats/+page.server.ts
Normal file
256
src/routes/stats/+page.server.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's current date from timezone query param
|
||||||
|
const timezone = url.searchParams.get('tz') || 'UTC';
|
||||||
|
const userToday = new Date().toLocaleDateString('en-CA', { timeZone: timezone });
|
||||||
|
|
||||||
|
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)
|
||||||
|
// Use the user's local date passed from the client
|
||||||
|
const today = userToday;
|
||||||
|
const yesterdayDate = new Date(userToday);
|
||||||
|
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||||
|
const yesterday = yesterdayDate.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";
|
||||||
|
}
|
||||||
417
src/routes/stats/+page.svelte
Normal file
417
src/routes/stats/+page.svelte
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
<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 anonymousId = $state("");
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
anonymousId = getOrCreateAnonymousId();
|
||||||
|
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-linear-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, idx (`${completion.date}-${idx}`)}
|
||||||
|
<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} />
|
||||||
@@ -7,7 +7,13 @@ const config = {
|
|||||||
// for more information about preprocessors
|
// for more information about preprocessors
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
kit: { adapter: adapter() }
|
kit: {
|
||||||
|
adapter: adapter(),
|
||||||
|
csrf: {
|
||||||
|
// Allow Apple Sign In cross-origin form_post callback
|
||||||
|
trustedOrigins: ['https://appleid.apple.com']
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
498
tests/timezone-handling.test.ts
Normal file
498
tests/timezone-handling.test.ts
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
import { describe, test, expect, beforeEach, mock } from 'bun:test';
|
||||||
|
import { testDb as db } from '$lib/server/db/test';
|
||||||
|
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
describe('Timezone-aware daily verse system', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clean up test data before each test
|
||||||
|
await db.delete(dailyVerses);
|
||||||
|
await db.delete(dailyCompletions);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Daily verse retrieval', () => {
|
||||||
|
test('users in different timezones can see different verses at the same UTC moment', async () => {
|
||||||
|
// Simulate: It's 2024-01-15 23:00 UTC
|
||||||
|
// - Tokyo (UTC+9): 2024-01-16 08:00
|
||||||
|
// - New York (UTC-5): 2024-01-15 18:00
|
||||||
|
|
||||||
|
const tokyoDate = '2024-01-16';
|
||||||
|
const newYorkDate = '2024-01-15';
|
||||||
|
|
||||||
|
// Create verses for both dates
|
||||||
|
const tokyoVerse = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
date: tokyoDate,
|
||||||
|
bookId: 'GEN',
|
||||||
|
verseText: 'Tokyo verse',
|
||||||
|
reference: 'Genesis 1:1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const newYorkVerse = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
date: newYorkDate,
|
||||||
|
bookId: 'EXO',
|
||||||
|
verseText: 'New York verse',
|
||||||
|
reference: 'Exodus 1:1',
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insert(dailyVerses).values([tokyoVerse, newYorkVerse]);
|
||||||
|
|
||||||
|
// Verify Tokyo user gets Jan 16 verse
|
||||||
|
const tokyoResult = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyVerses)
|
||||||
|
.where(eq(dailyVerses.date, tokyoDate))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
expect(tokyoResult).toHaveLength(1);
|
||||||
|
expect(tokyoResult[0].bookId).toBe('GEN');
|
||||||
|
expect(tokyoResult[0].verseText).toBe('Tokyo verse');
|
||||||
|
|
||||||
|
// Verify New York user gets Jan 15 verse
|
||||||
|
const newYorkResult = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyVerses)
|
||||||
|
.where(eq(dailyVerses.date, newYorkDate))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
expect(newYorkResult).toHaveLength(1);
|
||||||
|
expect(newYorkResult[0].bookId).toBe('EXO');
|
||||||
|
expect(newYorkResult[0].verseText).toBe('New York verse');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verse dates are stored in YYYY-MM-DD format', async () => {
|
||||||
|
const verse = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
date: '2024-01-15',
|
||||||
|
bookId: 'GEN',
|
||||||
|
verseText: 'Test verse',
|
||||||
|
reference: 'Genesis 1:1',
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insert(dailyVerses).values(verse);
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyVerses)
|
||||||
|
.where(eq(dailyVerses.id, verse.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
expect(result[0].date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Completion tracking', () => {
|
||||||
|
test('completions are stored with user local date', async () => {
|
||||||
|
const userId = 'test-user-1';
|
||||||
|
const localDate = '2024-01-16'; // User's local date
|
||||||
|
|
||||||
|
const completion = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: localDate,
|
||||||
|
guessCount: 3,
|
||||||
|
completedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insert(dailyCompletions).values(completion);
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].date).toBe(localDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('users in different timezones can complete different date verses simultaneously', async () => {
|
||||||
|
const tokyoUser = 'tokyo-user';
|
||||||
|
const newYorkUser = 'newyork-user';
|
||||||
|
|
||||||
|
const completions = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: tokyoUser,
|
||||||
|
date: '2024-01-16', // Tokyo: Jan 16
|
||||||
|
guessCount: 2,
|
||||||
|
completedAt: new Date('2024-01-15T23:00:00Z'), // 23:00 UTC
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: newYorkUser,
|
||||||
|
date: '2024-01-15', // New York: Jan 15
|
||||||
|
guessCount: 4,
|
||||||
|
completedAt: new Date('2024-01-15T23:00:00Z'), // 23:00 UTC
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(dailyCompletions).values(completions);
|
||||||
|
|
||||||
|
const tokyoResult = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, tokyoUser))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const newYorkResult = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, newYorkUser))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
expect(tokyoResult[0].date).toBe('2024-01-16');
|
||||||
|
expect(newYorkResult[0].date).toBe('2024-01-15');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Streak calculation', () => {
|
||||||
|
test('consecutive days count as a streak', async () => {
|
||||||
|
const userId = 'streak-user';
|
||||||
|
|
||||||
|
const completions = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-13',
|
||||||
|
guessCount: 2,
|
||||||
|
completedAt: new Date('2024-01-13T12:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-14',
|
||||||
|
guessCount: 3,
|
||||||
|
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-15',
|
||||||
|
guessCount: 1,
|
||||||
|
completedAt: new Date('2024-01-15T12:00:00Z'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(dailyCompletions).values(completions);
|
||||||
|
|
||||||
|
const allCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
|
||||||
|
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||||
|
|
||||||
|
// Verify consecutive dates
|
||||||
|
expect(sortedDates).toEqual(['2024-01-13', '2024-01-14', '2024-01-15']);
|
||||||
|
|
||||||
|
// Calculate streak
|
||||||
|
let streak = 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) {
|
||||||
|
streak++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(streak).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current streak is active if last completion was today', async () => {
|
||||||
|
const userId = 'current-streak-user';
|
||||||
|
const userToday = '2024-01-16';
|
||||||
|
|
||||||
|
const completions = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-14',
|
||||||
|
guessCount: 2,
|
||||||
|
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-15',
|
||||||
|
guessCount: 3,
|
||||||
|
completedAt: new Date('2024-01-15T12:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: userToday,
|
||||||
|
guessCount: 1,
|
||||||
|
completedAt: new Date('2024-01-16T12:00:00Z'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(dailyCompletions).values(completions);
|
||||||
|
|
||||||
|
const allCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
|
||||||
|
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||||
|
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||||
|
|
||||||
|
const yesterdayDate = new Date(userToday);
|
||||||
|
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||||
|
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday;
|
||||||
|
|
||||||
|
expect(isStreakActive).toBe(true);
|
||||||
|
expect(lastPlayedDate).toBe(userToday);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current streak is active if last completion was yesterday', async () => {
|
||||||
|
const userId = 'yesterday-streak-user';
|
||||||
|
const userToday = '2024-01-16';
|
||||||
|
|
||||||
|
const yesterdayDate = new Date(userToday);
|
||||||
|
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||||
|
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const completions = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-13',
|
||||||
|
guessCount: 2,
|
||||||
|
completedAt: new Date('2024-01-13T12:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-14',
|
||||||
|
guessCount: 3,
|
||||||
|
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: yesterday,
|
||||||
|
guessCount: 1,
|
||||||
|
completedAt: new Date(yesterday + 'T12:00:00Z'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(dailyCompletions).values(completions);
|
||||||
|
|
||||||
|
const allCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
|
||||||
|
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||||
|
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||||
|
|
||||||
|
const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday;
|
||||||
|
|
||||||
|
expect(isStreakActive).toBe(true);
|
||||||
|
expect(lastPlayedDate).toBe(yesterday);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current streak is not active if last completion was 2+ days ago', async () => {
|
||||||
|
const userId = 'broken-streak-user';
|
||||||
|
const userToday = '2024-01-16';
|
||||||
|
|
||||||
|
const completions = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-13',
|
||||||
|
guessCount: 2,
|
||||||
|
completedAt: new Date('2024-01-13T12:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-14',
|
||||||
|
guessCount: 3,
|
||||||
|
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(dailyCompletions).values(completions);
|
||||||
|
|
||||||
|
const allCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
|
||||||
|
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||||
|
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||||
|
|
||||||
|
const yesterdayDate = new Date(userToday);
|
||||||
|
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||||
|
const yesterday = yesterdayDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday;
|
||||||
|
|
||||||
|
expect(isStreakActive).toBe(false);
|
||||||
|
expect(lastPlayedDate).toBe('2024-01-14'); // 2 days ago
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gap in dates breaks the streak', async () => {
|
||||||
|
const userId = 'gap-user';
|
||||||
|
|
||||||
|
const completions = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-10',
|
||||||
|
guessCount: 2,
|
||||||
|
completedAt: new Date('2024-01-10T12:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-11',
|
||||||
|
guessCount: 3,
|
||||||
|
completedAt: new Date('2024-01-11T12:00:00Z'),
|
||||||
|
},
|
||||||
|
// Gap here (no 01-12)
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-13',
|
||||||
|
guessCount: 1,
|
||||||
|
completedAt: new Date('2024-01-13T12:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-14',
|
||||||
|
guessCount: 2,
|
||||||
|
completedAt: new Date('2024-01-14T12:00:00Z'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(dailyCompletions).values(completions);
|
||||||
|
|
||||||
|
const allCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
|
||||||
|
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||||
|
|
||||||
|
// Calculate best streak (should be 2, not 4)
|
||||||
|
let bestStreak = 1;
|
||||||
|
let tempStreak = 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);
|
||||||
|
|
||||||
|
expect(bestStreak).toBe(2); // Longest streak is Jan 13-14 or Jan 10-11
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Date validation', () => {
|
||||||
|
test('date must be in YYYY-MM-DD format', () => {
|
||||||
|
const validDates = ['2024-01-15', '2023-12-31', '2024-02-29'];
|
||||||
|
|
||||||
|
validDates.forEach((date) => {
|
||||||
|
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid date formats are rejected', () => {
|
||||||
|
const invalidDates = [
|
||||||
|
'2024/01/15', // Wrong separator
|
||||||
|
'01-15-2024', // Wrong order
|
||||||
|
'2024-1-15', // Missing leading zero
|
||||||
|
'2024-01-15T12:00:00Z', // Includes time
|
||||||
|
];
|
||||||
|
|
||||||
|
invalidDates.forEach((date) => {
|
||||||
|
if (date.includes('T')) {
|
||||||
|
expect(date).not.toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
} else {
|
||||||
|
expect(date).not.toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
test('crossing year boundary maintains streak', async () => {
|
||||||
|
const userId = 'year-boundary-user';
|
||||||
|
|
||||||
|
const completions = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2023-12-30',
|
||||||
|
guessCount: 2,
|
||||||
|
completedAt: new Date('2023-12-30T12:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2023-12-31',
|
||||||
|
guessCount: 3,
|
||||||
|
completedAt: new Date('2023-12-31T12:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-01',
|
||||||
|
guessCount: 1,
|
||||||
|
completedAt: new Date('2024-01-01T12:00:00Z'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(dailyCompletions).values(completions);
|
||||||
|
|
||||||
|
const allCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
|
||||||
|
const sortedDates = allCompletions.map((c) => c.date).sort();
|
||||||
|
|
||||||
|
let streak = 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) {
|
||||||
|
streak++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(streak).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Duplicate prevention is handled by the API endpoint, not at the DB level in these tests
|
||||||
|
// See /api/submit-completion for the unique constraint enforcement
|
||||||
|
});
|
||||||
|
});
|
||||||
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