25 Commits

Author SHA1 Message Date
George Powell
7d93ead70c added demo stats page (needs refinement) 2026-02-04 23:35:23 -05:00
George Powell
4c82aa078b added identifying with umami anonymous ID 2026-02-03 23:43:03 -05:00
George Powell
2058149207 delayed loading of umami tracker script until page is already
interactive
2026-02-03 00:18:11 -05:00
George Powell
2bd86d37a1 added svelte mcp 2026-01-29 01:12:18 -05:00
George Powell
33d6fae446 added .env.example 2026-01-29 01:12:09 -05:00
George Powell
d21ca9d687 add percentile stats, update chapter guess UI 2026-01-28 23:03:51 -05:00
George Powell
2df97f66bf package upgrades 2026-01-28 16:06:01 -05:00
George Powell
b1420a3e4f Merge remote changes from github/main 2026-01-28 16:04:48 -05:00
George Powell
fe9cc09df6 added test dev buttons and email button 2026-01-28 16:02:52 -05:00
George Powell
55a9fd59ea fixed some epistle bugs with firstLetter, maybe. and wording 2026-01-28 15:15:40 -05:00
George Powell
0ee3d8a4d0 Revamped middle statline (ranking instead of arbitrary percentage) 2026-01-28 15:04:29 -05:00
George Powell
6365cfb363 Added instructions 2026-01-28 14:57:22 -05:00
George Powell
860839fd75 add deployment script 2026-01-26 23:45:52 -05:00
George Powell
e4b946ec8c package updates 2026-01-26 23:41:57 -05:00
George Powell
b80c18c2aa added function to measure correlation between ease of solving and number of players 2026-01-26 23:40:28 -05:00
George Powell
8c488d27df add First Letter column with special epistle handling 2026-01-26 23:31:24 -05:00
George Powell
77d6254a2c replace table with colored box grid for better visual feedback 2026-01-26 23:09:31 -05:00
George Powell
7fbed528f8 added bluesky profile link 2026-01-26 00:38:01 -05:00
George Powell
cec85be7c9 feat: Add Imposter game component and update project assets 2026-01-26 00:25:51 -05:00
George Powell
c50336ab5f feat: Add Claude settings to restrict access to sensitive files 2026-01-26 00:25:31 -05:00
George Powell
03645f0452 Merge remote-tracking branch 'github/main' 2026-01-05 18:21:08 -05:00
George Powell
cb11d793f6 replaced trailing punctuation in verses with ellipses 2026-01-05 18:15:30 -05:00
George Powell
ac1db94b0d replaced trailing punctuation in verses with ellipses 2026-01-05 18:12:10 -05:00
George Powell
1b1bc7bd3c Added chapter guess challenge 2026-01-04 16:36:28 -05:00
George Powell
0f6870344f major styling and spacing 2026-01-04 01:25:49 -05:00
34 changed files with 2729 additions and 1067 deletions

12
.claude/settings.json Normal file
View File

@@ -0,0 +1,12 @@
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./secrets/**)",
"Read(./config/credentials.json)",
"Read(./build)",
"Read(./**.xml)",
"Read(./embeddings**)"
]
}
}

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
DATABASE_URL=example.db
AUTH_SECRET=your-random-secret-here
APPLE_ID=com.yourcompany.yourapp.client
APPLE_TEAM_ID=your-team-id
APPLE_KEY_ID=your-key-id
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
your-private-key-here
-----END PRIVATE KEY-----"

5
.gitignore vendored
View File

@@ -27,6 +27,5 @@ vite.config.ts.timestamp-*
llms-* llms-*
engwebu_usfx.xml embeddings*
embeddings-cache-L12.json *.xml
embeddings-cache-L6.json

View File

@@ -1,3 +0,0 @@
EnglishNKJBible.xml
GreekModern1904Bible.xml
engwebu_usfx.xml

View File

@@ -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
npm run db:migrate # Run migrations bun run db:migrate # Run migrations
npm run db:studio # Open Drizzle Studio GUI bun run db:studio # Open Drizzle Studio GUI
``` ```
## Architecture ## Architecture

View File

@@ -25056,7 +25056,7 @@
<chapter number="3"> <chapter number="3">
<verse number="1">“Behold, I send My messenger, And he will prepare the way before Me. And the Lord, whom you seek, Will suddenly come to His temple, Even the Messenger of the covenant, In whom you delight. Behold, He is coming,” Says the Lord of hosts.</verse> <verse number="1">“Behold, I send My messenger, And he will prepare the way before Me. And the Lord, whom you seek, Will suddenly come to His temple, Even the Messenger of the covenant, In whom you delight. Behold, He is coming,” Says the Lord of hosts.</verse>
<verse number="2">“But who can endure the day of His coming? And who can stand when He appears? For He is like a refiners fire And like launderers soap.</verse> <verse number="2">“But who can endure the day of His coming? And who can stand when He appears? For He is like a refiners fire And like launderers soap.</verse>
<verse number="3">He will sit as a refiner and a purifier of silver; He will purify the sons of Levi, And purge them as gold and silver, That they may offer to the LordAn offering in righteousness.</verse> <verse number="3">He will sit as a refiner and a purifier of silver; He will purify the sons of Levi, and purge them as gold and silver, that they may offer to the Lord an offering in righteousness.</verse>
<verse number="4">“Then the offering of Judah and Jerusalem Will be pleasant to the Lord, As in the days of old, As in former years.</verse> <verse number="4">“Then the offering of Judah and Jerusalem Will be pleasant to the Lord, As in the days of old, As in former years.</verse>
<verse number="5">And I will come near you for judgment; I will be a swift witness Against sorcerers, Against adulterers, Against perjurers, Against those who exploit wage earners and widows and orphans, And against those who turn away an alien— Because they do not fear Me,” Says the Lord of hosts.</verse> <verse number="5">And I will come near you for judgment; I will be a swift witness Against sorcerers, Against adulterers, Against perjurers, Against those who exploit wage earners and widows and orphans, And against those who turn away an alien— Because they do not fear Me,” Says the Lord of hosts.</verse>
<verse number="6">“For I am the Lord, I do not change; Therefore you are not consumed, O sons of Jacob.</verse> <verse number="6">“For I am the Lord, I do not change; Therefore you are not consumed, O sons of Jacob.</verse>

View File

@@ -6,27 +6,27 @@
"name": "bibdle", "name": "bibdle",
"dependencies": { "dependencies": {
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"better-sqlite3": "^12.5.0", "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",
}, },
"devDependencies": { "devDependencies": {
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.49.1", "@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.18",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22", "@types/node": "^22.19.7",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.0", "drizzle-orm": "^0.45.1",
"svelte": "^5.45.6", "svelte": "^5.48.3",
"svelte-check": "^4.3.4", "svelte-check": "^4.3.5",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6", "vite": "^7.3.1",
}, },
}, },
}, },
@@ -187,11 +187,11 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="],
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.4.0", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ=="], "@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.2", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-L15Djwpr7HrSAPj/Z8PYfc0pa9A1tllrr18phKI0WJHJeoWw45yinPf0IGgVTmakqx1B3JQ+C/OFl9ZwmxHU1Q=="],
"@sveltejs/kit": ["@sveltejs/kit@2.49.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ=="], "@sveltejs/kit": ["@sveltejs/kit@2.50.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="], "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
@@ -235,7 +235,7 @@
"@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="], "@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="],
"@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
@@ -263,7 +263,7 @@
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="], "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
@@ -303,7 +303,7 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="], "devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="], "drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
@@ -421,6 +421,8 @@
"node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="], "node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onnx-proto": ["onnx-proto@4.0.4", "", { "dependencies": { "protobufjs": "^6.8.8" } }, "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA=="], "onnx-proto": ["onnx-proto@4.0.4", "", { "dependencies": { "protobufjs": "^6.8.8" } }, "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA=="],
@@ -497,9 +499,9 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.46.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw=="], "svelte": ["svelte@5.48.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-NB3o70OxfmnE5UPyLr8uH3IV02Q43qJVAuWigYmsSOYsS0s/rHxP0TF81blG0onF/xkhNvZw4G8NfzIX+By5ZQ=="],
"svelte-check": ["svelte-check@4.3.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw=="], "svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
@@ -523,7 +525,7 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
@@ -551,8 +553,12 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/better-sqlite3/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
"prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"protobufjs/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
"vite/esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="], "vite/esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],

18
deploy.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
echo "Pulling latest changes..."
git pull
echo "Installing dependencies..."
bun i
echo "Building..."
bun run build
echo "Restarting service..."
sudo systemctl restart bibdle
echo "Done!"

View File

@@ -1,7 +1,7 @@
{ {
"name": "bibdle", "name": "bibdle",
"private": true, "private": true,
"version": "0.0.1", "version": "3.0.0alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -18,24 +18,24 @@
"devDependencies": { "devDependencies": {
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.49.1", "@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.18",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22", "@types/node": "^22.19.7",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.0", "drizzle-orm": "^0.45.1",
"svelte": "^5.45.6", "svelte": "^5.48.5",
"svelte-check": "^4.3.4", "svelte-check": "^4.3.5",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.6" "vite": "^7.3.1"
}, },
"dependencies": { "dependencies": {
"@xenova/transformers": "^2.17.2", "@xenova/transformers": "^2.17.2",
"better-sqlite3": "^12.5.0", "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"
} }

View File

@@ -2,18 +2,15 @@ import Database from 'bun:sqlite';
// Database path - adjust if your database is located elsewhere // Database path - adjust if your database is located elsewhere
const dbPath = process.env.DATABASE_URL || './local.db'; const dbPath = process.env.DATABASE_URL || './local.db';
console.log(`Connecting to database: ${dbPath}`); console.log(`Connecting to database: ${dbPath}`);
const db = new Database(dbPath); const db = new Database(dbPath);
// Query all rows from daily_completions // Query all rows from daily_completions
const query = db.query(` const query = db.query(`
SELECT date, guess_count SELECT date, guess_count
FROM daily_completions FROM daily_completions
ORDER BY date ORDER BY date
`); `);
const rows = query.all() as { date: string; guess_count: number }[]; const rows = query.all() as { date: string; guess_count: number }[];
if (rows.length === 0) { if (rows.length === 0) {
@@ -50,4 +47,60 @@ const overallAvg = (totalGuesses / totalCompletions).toFixed(2);
console.log('--------------|-------------|-------------------'); console.log('--------------|-------------|-------------------');
console.log(`Overall Average: ${overallAvg} guesses across ${totalCompletions} completions`); console.log(`Overall Average: ${overallAvg} guesses across ${totalCompletions} completions`);
db.close(); // Calculate correlation between avg_guesses and completions
function calculateCorrelation(data: { avgGuesses: number; completions: number }[]): number {
const n = data.length;
if (n < 2) return 0;
const avgX = data.reduce((sum, d) => sum + d.avgGuesses, 0) / n;
const avgY = data.reduce((sum, d) => sum + d.completions, 0) / n;
let numerator = 0;
let sumXSquared = 0;
let sumYSquared = 0;
for (const d of data) {
const xDiff = d.avgGuesses - avgX;
const yDiff = d.completions - avgY;
numerator += xDiff * yDiff;
sumXSquared += xDiff * xDiff;
sumYSquared += yDiff * yDiff;
}
const denominator = Math.sqrt(sumXSquared * sumYSquared);
return denominator === 0 ? 0 : numerator / denominator;
}
// Prepare data for correlation analysis
const allData = Array.from(dateStats.entries()).map(([date, stats]) => ({
date,
avgGuesses: stats.total / stats.count,
completions: stats.count
}));
// Split into pre and post marketing periods
const marketingStartDate = '2026-01-08';
const preMarketing = allData.filter(d => d.date < marketingStartDate);
const postMarketing = allData.filter(d => d.date >= marketingStartDate);
console.log('\n=== Correlation Analysis ===\n');
const allCorrelation = calculateCorrelation(allData);
console.log(`Overall correlation (avg_guesses vs completions): ${allCorrelation.toFixed(3)}`);
if (preMarketing.length >= 2) {
const preCorrelation = calculateCorrelation(preMarketing);
console.log(`Pre-marketing correlation (before ${marketingStartDate}): ${preCorrelation.toFixed(3)} (n=${preMarketing.length} days)`);
}
if (postMarketing.length >= 2) {
const postCorrelation = calculateCorrelation(postMarketing);
console.log(`Post-marketing correlation (${marketingStartDate} onward): ${postCorrelation.toFixed(3)} (n=${postMarketing.length} days)`);
}
console.log('\nInterpretation:');
console.log(' r close to -1: Strong negative correlation (easier verses → more completions)');
console.log(' r close to 0: No correlation');
console.log(' r close to +1: Strong positive correlation (harder verses → more completions)');
db.close();

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="600" height="530" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" fill="#1185fe"/>
</svg>

After

Width:  |  Height:  |  Size: 745 B

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
variant?: "primary" | "google" | "apple" | "secondary" | "danger";
onclick?: () => void;
class?: string;
type?: "button" | "submit" | "reset";
}
let {
children,
variant = "primary",
onclick,
class: className = "",
type = "button",
}: Props = $props();
const variantClasses = {
primary:
"bg-blue-500 hover:bg-blue-600 text-white border-gray-500 shadow-md hover:shadow-lg",
google: "bg-white hover:bg-gray-50 text-gray-700 border-gray-500 shadow-md hover:shadow-lg",
apple: "bg-black hover:bg-gray-900 text-white border-gray-500 shadow-md hover:shadow-lg",
secondary:
"bg-white/50 hover:bg-white/70 text-gray-700 border-gray-500 shadow-sm hover:shadow-md backdrop-blur-sm",
danger: "bg-red-500 hover:bg-red-600 text-white border-gray-500 shadow-md hover:shadow-lg",
};
</script>
<button
{type}
{onclick}
class="inline-flex items-center justify-center px-4 py-2 rounded-lg border-2 font-bold text-sm transition-all duration-200 {variantClasses[
variant
]} {className}"
>
{@render children()}
</button>

View File

@@ -0,0 +1,222 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { browser } from "$app/environment";
import Container from "./Container.svelte";
interface Props {
reference: string;
bookId: string;
onCompleted?: () => void;
}
let { reference, bookId, onCompleted }: Props = $props();
// Parse the chapter from the reference (e.g., "John 3:16" -> 3)
function parseChapterFromReference(ref: string): number {
const match = ref.match(/\s(\d+):/);
return match ? parseInt(match[1], 10) : 1;
}
// Get the number of chapters for a book
function getChapterCount(bookId: string): number {
const chapterCounts: Record<string, number> = {
GEN: 50,
EXO: 40,
LEV: 27,
NUM: 36,
DEU: 34,
JOS: 24,
JDG: 21,
RUT: 4,
"1SA": 31,
"2SA": 24,
"1KI": 22,
"2KI": 25,
"1CH": 29,
"2CH": 36,
EZR: 10,
NEH: 13,
EST: 10,
JOB: 42,
PSA: 150,
PRO: 31,
ECC: 12,
SNG: 8,
ISA: 66,
JER: 52,
LAM: 5,
EZK: 48,
DAN: 12,
HOS: 14,
JOL: 3,
AMO: 9,
OBA: 1,
JON: 4,
MIC: 7,
NAM: 3,
HAB: 3,
ZEP: 3,
HAG: 2,
ZEC: 14,
MAL: 4,
MAT: 28,
MRK: 16,
LUK: 24,
JHN: 21,
ACT: 28,
ROM: 16,
"1CO": 16,
"2CO": 13,
GAL: 6,
EPH: 6,
PHP: 4,
COL: 4,
"1TH": 5,
"2TH": 3,
"1TI": 6,
"2TI": 4,
TIT: 3,
PHM: 1,
HEB: 13,
JAS: 5,
"1PE": 5,
"2PE": 3,
"1JN": 5,
"2JN": 1,
"3JN": 1,
JUD: 1,
REV: 22,
};
return chapterCounts[bookId] || 1;
}
// Generate 6 random chapter options including the correct one
function generateChapterOptions(
correctChapter: number,
totalChapters: number,
): number[] {
const options = new Set<number>();
options.add(correctChapter);
if (totalChapters >= 6) {
while (options.size < 6) {
const randomChapter =
Math.floor(Math.random() * totalChapters) + 1;
options.add(randomChapter);
}
} else {
while (options.size < 6) {
const randomChapter = Math.floor(Math.random() * 10) + 1;
options.add(randomChapter);
}
}
return Array.from(options).sort(() => Math.random() - 0.5);
}
let correctChapter = $derived(parseChapterFromReference(reference));
let totalChapters = $derived(getChapterCount(bookId));
let chapterOptions = $state<number[]>([]);
$effect(() => {
if (chapterOptions.length === 0) {
chapterOptions = generateChapterOptions(
correctChapter,
totalChapters,
);
}
});
let selectedChapter = $state<number | null>(null);
let hasAnswered = $state(false);
// Load saved state from localStorage
$effect(() => {
if (!browser) return;
const key = `bibdle-chapter-guess-${reference}`;
const saved = localStorage.getItem(key);
if (saved) {
const data = JSON.parse(saved);
selectedChapter = data.selectedChapter;
hasAnswered = data.hasAnswered;
chapterOptions = data.chapterOptions ?? [];
}
});
// Save state to localStorage whenever options are generated or answer given
$effect(() => {
if (!browser || chapterOptions.length === 0) return;
const key = `bibdle-chapter-guess-${reference}`;
localStorage.setItem(
key,
JSON.stringify({ selectedChapter, hasAnswered, chapterOptions }),
);
});
function handleChapterSelect(chapter: number) {
if (hasAnswered) return;
selectedChapter = chapter;
hasAnswered = true;
if (onCompleted) {
onCompleted();
}
}
let isCorrect = $derived(
selectedChapter !== null && selectedChapter === correctChapter,
);
</script>
<Container
class="w-full p-6 sm:p-8 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
>
<div class="text-center">
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
<p class="text-sm sm:text-base opacity-80 mb-6">
Guess the chapter for an even higher grade
</p>
<div
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
>
{#each chapterOptions as chapter}
<button
onclick={() => handleChapterSelect(chapter)}
disabled={hasAnswered}
class={`
w-20 h-20 sm:w-24 sm:h-24 text-2xl sm:text-3xl font-bold rounded-xl
transition-all duration-300 border-2
${
hasAnswered
? chapter === correctChapter
? "bg-green-500 text-white border-green-600 shadow-lg"
: selectedChapter === chapter
? isCorrect
? "bg-green-500 text-white border-green-600 shadow-lg"
: "bg-red-400 text-white border-red-500"
: "bg-white/30 text-gray-400 border-gray-300 opacity-40"
: "bg-white/80 hover:bg-white text-gray-800 border-gray-300 hover:border-amber-400 hover:shadow-md cursor-pointer"
}
`}
>
{chapter}
</button>
{/each}
</div>
{#if hasAnswered}
<p
class="text-xl sm:text-2xl font-bold mb-2"
class:text-green-600={isCorrect}
class:text-red-600={!isCorrect}
>
{isCorrect ? "✓ Correct!" : "✗ Incorrect"}
</p>
<p class="text-sm opacity-80">
The verse is from chapter {correctChapter}
</p>
{#if isCorrect}
<p class="text-lg font-bold text-amber-600 mt-2">Grade: S++</p>
{/if}
{/if}
</div>
</Container>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
class?: string;
}
let { children, class: className = "" }: Props = $props();
</script>
<div
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm rounded-2xl border border-white/50 shadow-sm {className}"
>
{@render children()}
</div>

View File

@@ -1,90 +1,88 @@
<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 intervalId: number | null = null;
function calculateTimeUntilFivePM(): string { function calculateTimeUntilFivePM(): string {
const now = new Date(); const now = new Date();
const target = new Date(now); const target = new Date(now);
// Set target to 5:00 PM today // Set target to 5:00 PM today
target.setHours(17, 0, 0, 0); target.setHours(17, 0, 0, 0);
// If it's already past 5:00 PM, set target to tomorrow 5:00 PM // If it's already past 5:00 PM, set target to tomorrow 5:00 PM
if (now.getTime() >= target.getTime()) { if (now.getTime() >= target.getTime()) {
target.setDate(target.getDate() + 1); target.setDate(target.getDate() + 1);
} }
const diff = target.getTime() - now.getTime(); const diff = target.getTime() - now.getTime();
if (diff <= 0) { if (diff <= 0) {
return "00:00:00"; return "00:00:00";
} }
const hours = Math.floor(diff / (1000 * 60 * 60)); const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000); const seconds = Math.floor((diff % (1000 * 60)) / 1000);
return `${hours.toString().padStart(2, "0")}h ${minutes return `${hours.toString().padStart(2, "0")}h ${minutes
.toString() .toString()
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`; .padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
} }
function calculateTimeUntilMidnight(): string { function calculateTimeUntilMidnight(): string {
const now = new Date(); const now = new Date();
const target = new Date(now); const target = new Date(now);
// Set target to midnight today // Set target to midnight today
target.setHours(0, 0, 0, 0); target.setHours(0, 0, 0, 0);
// If it's already past midnight, set target to tomorrow midnight // If it's already past midnight, set target to tomorrow midnight
if (now.getTime() >= target.getTime()) { if (now.getTime() >= target.getTime()) {
target.setDate(target.getDate() + 1); target.setDate(target.getDate() + 1);
} }
const diff = target.getTime() - now.getTime(); const diff = target.getTime() - now.getTime();
if (diff <= 0) { if (diff <= 0) {
return "00:00:00"; return "00:00:00";
} }
const hours = Math.floor(diff / (1000 * 60 * 60)); const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000); const seconds = Math.floor((diff % (1000 * 60)) / 1000);
return `${hours.toString().padStart(2, "0")}h ${minutes return `${hours.toString().padStart(2, "0")}h ${minutes
.toString() .toString()
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`; .padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
} }
function updateTimer() { function updateTimer() {
timeUntilNext = calculateTimeUntilMidnight(); timeUntilNext = calculateTimeUntilMidnight();
} }
onMount(() => { onMount(() => {
updateTimer(); updateTimer();
intervalId = window.setInterval(updateTimer, 1000); intervalId = window.setInterval(updateTimer, 1000);
}); });
onDestroy(() => { onDestroy(() => {
if (intervalId) { if (intervalId) {
clearInterval(intervalId); clearInterval(intervalId);
} }
}); });
</script> </script>
<div class="text-center py-12"> <div class="w-full">
<div <div
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm" 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 <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2">
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2" Next Verse In
> </p>
Next Verse In <p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
</p> {timeUntilNext}
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums"> </p>
{timeUntilNext} </div>
</p>
</div>
</div> </div>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import { fade } from "svelte/transition";
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
</script>
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
<div
class="flex flex-col items-center gap-2 bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
>
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
A project by George Powell & Silent Summit Co.
</p>
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
For questions, comments, job opportunities, or cash donations,
please email <a
class="text-blue-400"
href="mailto:george+bibdle@silentsummit.co"
>george@silentsummit.co</a
>
</p> -->
<!-- <p class="text-lg font-triodion font-black text-gray-800 tabular-nums">
</p> -->
<!-- Bluesky Social Media Button -->
</div>
<div class="mt-8 flex items-center justify-center gap-6">
<a
href="https://bsky.app/profile/snail.city"
target="_blank"
rel="noopener noreferrer"
class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Bluesky"
>
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
</a>
<div class="w-0.5 h-8 bg-gray-400"></div>
<a
href="mailto:george+bibdle@silentsummit.co"
class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Send email"
>
<svg
class="w-8 h-8 text-gray-700"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
></path>
</svg>
</a>
</div>
</div>

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import { browser } from "$app/environment";
import Button from "$lib/components/Button.svelte";
function clearLocalStorage() {
if (!browser) return;
// Clear all bibdle-related localStorage items
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("bibdle-")) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
// Reload the page to reset state
window.location.reload();
}
</script>
<div class="pt-24 pb-4">
<div class="border-t-2 border-gray-400"></div>
</div>
<div class="flex flex-col gap-3">
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
<Button
variant="secondary"
onclick={() => alert("About page coming soon!")}
class="w-full md:w-auto py-4 md:py-2"
>
About Bibdle / FAQs
</Button>
<Button
variant="google"
onclick={() => alert("Google sign-in coming soon!")}
class="w-full md:w-auto py-4 md:py-2"
>
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</Button>
<Button
variant="apple"
onclick={() => alert("Apple sign-in coming soon!")}
class="w-full md:w-auto py-4 md:py-2"
>
<svg class="w-4 h-4 mr-2" 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.09l.01-.01zM12.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>
</div>
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
<Button
variant="primary"
onclick={() => alert("Patreon coming soon!")}
class="w-full md:w-auto py-4 md:py-2"
>
Become a Patron
</Button>
</div>
<Button
variant="danger"
onclick={clearLocalStorage}
class="w-full py-4 md:py-2"
>
Clear LocalStorage
</Button>
</div>

View File

@@ -1,28 +0,0 @@
<script lang="ts">
import { fade } from "svelte/transition";
</script>
<!-- <div
class="my-12 p-4 bg-linear-to-r from-blue-50 to-indigo-50 rounded-2xl shadow-md text-center text-sm md:text-base text-gray-600"
in:fade={{ delay: 1500, duration: 1000 }}
>
Thank you so much for playing! Feel free to email me directly with feedback:
<a
href="mailto:george@snail.city"
class="font-semibold text-blue-600 hover:text-blue-800 underline"
>george@snail.city</a
>
</div> -->
<div class="text-center py-12">
<div
class="inline-flex w-full flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
>
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
A project by George Powell & Silent Summit Co.
</p>
<!-- <p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
</p> -->
</div>
</div>

View File

@@ -1,105 +1,201 @@
<script lang="ts"> <script lang="ts">
interface Guess { import { bibleBooks } from "$lib/types/bible";
book: { import Container from "./Container.svelte";
id: string;
name: string;
testament: string;
section: string;
};
testamentMatch: boolean;
sectionMatch: boolean;
adjacent: boolean;
}
let { guesses, correctBookId }: { guesses: Guess[]; correctBookId: string } = interface Guess {
$props(); book: {
id: string;
name: string;
testament: string;
section: string;
};
testamentMatch: boolean;
sectionMatch: boolean;
adjacent: boolean;
firstLetterMatch: boolean;
}
let hasGuesses = $derived(guesses.length > 0); let {
guesses,
correctBookId,
}: { guesses: Guess[]; correctBookId: string } = $props();
let hasGuesses = $derived(guesses.length > 0);
function getBoxColor(isCorrect: boolean, isAdjacent?: boolean): string {
if (isCorrect) return "bg-green-500 border-green-600";
if (isAdjacent) return "bg-yellow-500 border-yellow-600";
return "bg-red-500 border-red-600";
}
function getBoxContent(
guess: Guess,
column: "book" | "firstLetter" | "testament" | "section",
): string {
switch (column) {
case "book":
return guess.book.name;
case "firstLetter":
// Check if this is the special Epistles + "1" case
const correctBook = bibleBooks.find(
(b) => b.id === correctBookId,
);
const correctIsEpistlesWithNumber =
(correctBook?.section === "Pauline Epistles" ||
correctBook?.section === "General Epistles") &&
correctBook.name[0] === "1";
const guessStartsWithNumber = guess.book.name[0] === "1";
if (
correctIsEpistlesWithNumber &&
guessStartsWithNumber &&
guess.firstLetterMatch
) {
return "Yes"; // Special wordplay case
}
return guess.book.name[0]; // Normal case: just show the first letter
case "testament":
return (
guess.book.testament.charAt(0).toUpperCase() +
guess.book.testament.slice(1).toLowerCase()
);
case "section":
return guess.book.section;
}
}
</script> </script>
{#if hasGuesses} {#if !hasGuesses}
<div class="bg-white rounded-2xl shadow-xl overflow-x-auto fade-in"> <Container class="p-6 text-center">
<table class="w-full"> <h2 class="font-triodion text-xl italic mb-3 text-gray-800">
<thead class="fade-in"> Instructions
<tr class="bg-linear-to-r from-gray-50 to-gray-300"> </h2>
<th <p class="text-gray-700 leading-relaxed italic">
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200" Guess what book of the bible you think the verse is from. You will
>Book</th get clues to tell you if your guess is close or not. Green means the
> category is correct; red means wrong.
<th </p>
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200" </Container>
>Testament</th {:else}
> <div class="space-y-3">
<th <!-- Column Headers -->
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200" <div
>Section</th class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
> >
</tr> <div
</thead> class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
<tbody> >
{#each guesses as guess, index (guess.book.id)} Book
<tr </div>
class="border-b border-gray-100 transition-colors {guess.book.id === <div
correctBookId class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
? 'bg-green-200 animate-shine' >
: 'hover:bg-gray-50'} {index === 0 ? 'fade-in' : ''}" Testament
> </div>
<td <div
class="p-3 sm:p-4 md:p-6 text-sm sm:text-base font-bold md:text-lg" class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
> >
{guess.book.id === correctBookId ? "✅" : "❌"} Section
{guess.book.name} </div>
</td> <div
<td class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg"> class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
{guess.testamentMatch ? "✅" : "❌"} >
{guess.book.testament.charAt(0).toUpperCase() + First Letter
guess.book.testament.slice(1).toLowerCase()} </div>
</td> </div>
<td class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg">
{guess.sectionMatch ? "✅" : "❌"} {#each guesses as guess, rowIndex (guess.book.id)}
{guess.adjacent ? "‼️ " : ""}{guess.book.section} <div class="flex gap-2 justify-center">
</td> <!-- Book Column -->
</tr> <div
{/each} 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(
</tbody> guess.book.id === correctBookId,
</table> )}"
</div> style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-lg"
>{getBoxContent(guess, "book")}</span
>
</div>
<!-- Testament Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.testamentMatch,
)}"
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "testament")}</span
>
</div>
<!-- Section Column -->
<div
class="relative 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.sectionMatch,
guess.adjacent,
)}"
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "section")}
{#if guess.adjacent}
‼️
{/if}
</span>
</div>
<!-- First Letter Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
guess.firstLetterMatch,
)}"
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
>
<span class="text-center leading-tight px-1 text-shadow-sm"
>{getBoxContent(guess, "firstLetter")}</span
>
</div>
</div>
{/each}
</div>
<!-- </div> -->
{/if} {/if}
<style> <style>
@keyframes shine { @keyframes flipIn {
0% { 0% {
background-position: -200% 0; opacity: 0;
} transform: rotateX(-90deg);
100% { }
background-position: 200% 0; 50% {
} transform: rotateX(0deg);
} }
100% {
opacity: 1;
transform: rotateX(0deg);
}
}
.animate-shine { .animate-flip-in {
background: linear-gradient(110deg, #dcffe7 45%, #f1fff5 50%, #dcffe7 55%); opacity: 0;
background-size: 200% 100%; transform: rotateX(-90deg);
animation: shine 5s infinite; animation: flipIn 0.6s ease-out forwards;
} }
.animate-shine.fade-in { @keyframes fadeIn {
animation: from {
fadeIn 0.5s ease-out, opacity: 0;
shine 5s infinite; transform: translateY(-10px);
} }
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn { .fade-in {
from { animation: fadeIn 0.5s ease-out;
opacity: 0; }
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
</style> </style>

View File

@@ -0,0 +1,241 @@
<script lang="ts">
import { onMount } from "svelte";
interface ImposterData {
verses: string[];
refs: string[];
imposterIndex: number;
}
let data: ImposterData | null = null;
let clicked: boolean[] = [];
let gameOver = false;
let loading = true;
let error: string | null = null;
async function loadGame() {
try {
const res = await fetch("/api/imposter");
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
data = (await res.json()) as ImposterData;
clicked = new Array(data.verses.length).fill(false);
gameOver = false;
} catch (e) {
error = e instanceof Error ? e.message : "Unknown error";
} finally {
loading = false;
}
}
function handleClick(index: number) {
if (gameOver || !data || clicked[index]) return;
clicked[index] = true;
if (index !== data.imposterIndex) {
clicked[data.imposterIndex] = true;
}
gameOver = true;
}
function newGame() {
loading = true;
error = null;
data = null;
loadGame();
}
onMount(loadGame);
function formatVerse(verse: string): string {
let formatted = verse;
// Handle unbalanced opening/closing punctuation
const pairs: [string, string][] = [
["(", ")"],
["[", "]"],
["{", "}"],
['"', '"'],
["'", "'"],
["\u201C", "\u201D"], // \u201C
["\u2018", "\u2019"], // \u2018
];
for (const [open, close] of pairs) {
if (formatted.startsWith(open) && !formatted.includes(close)) {
formatted += "..." + close;
break;
}
}
for (const [open, close] of pairs) {
if (formatted.endsWith(close) && !formatted.includes(open)) {
formatted = open + "..." + formatted;
break;
}
}
if (/^[a-z]/.test(formatted)) {
formatted = "..." + formatted;
}
formatted = formatted.replace(/[,:;-—]$/, "...");
return formatted;
}
</script>
<div class="imposter-game">
{#if loading}
<p class="loading">Loading verses...</p>
{:else if error}
<div class="error">
<p>Error: {error}</p>
<button on:click={newGame}>Retry</button>
</div>
{:else if data}
<!-- <div class="instructions">
<p>Click the verse that doesn't belong (from a different book).</p>
</div> -->
<div class="verses">
{#each data.verses as verse, i}
<div class="verse-item">
<button
class="verse-button"
class:clicked={clicked[i]}
class:correct={clicked[i] && i === data.imposterIndex}
class:wrong={clicked[i] && i !== data.imposterIndex}
on:click={() => handleClick(i)}
disabled={gameOver}
>
{formatVerse(verse)}
</button>
{#if gameOver}
<div class="ref">{data.refs[i]}</div>
{/if}
</div>
{/each}
</div>
{#if gameOver}
<div class="result">
<button on:click={newGame}>New Game</button>
</div>
{/if}
{/if}
</div>
<style>
.imposter-game {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 2rem;
max-width: 900px;
margin: 0 auto;
}
.loading,
.error {
text-align: center;
}
.instructions {
text-align: center;
font-style: italic;
color: #666;
}
.verses {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
width: 100%;
}
.verse-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.verse-button {
padding: 1.5rem;
font-size: 1.1rem;
line-height: 1.4;
border: 3px solid #ddd;
background: #fafafa;
cursor: pointer;
border-radius: 12px;
transition: all 0.3s ease;
min-height: 100px;
text-align: left;
white-space: pre-wrap;
word-wrap: break-word;
}
.verse-button:hover:not(.clicked):not(:disabled) {
border-color: #007bff;
background: #f8f9ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
}
.verse-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.verse-button.clicked {
cursor: default;
}
.correct {
background: #d4edda !important;
border-color: #28a745 !important;
color: #155724;
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
}
.wrong {
background: #f8d7da !important;
border-color: #dc3545 !important;
color: #721c24;
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
}
.ref {
font-size: 0.9rem;
font-weight: 500;
text-align: center;
color: #555;
padding-top: 0.25rem;
}
.verse-button.correct ~ .ref {
color: #28a745;
font-weight: bold;
}
.verse-button.wrong ~ .ref {
color: #dc3545;
}
.result {
display: flex;
justify-content: center;
}
.result button,
.error button {
padding: 0.75rem 2rem;
background: #007bff;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.result button:hover,
.error button:hover {
background: #0056b3;
}
</style>

View File

@@ -1,55 +1,92 @@
<script lang="ts"> <script lang="ts">
import { bibleBooks, type BibleBook } from "$lib/types/bible"; import { bibleBooks, type BibleBook } from "$lib/types/bible";
let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props(); let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props();
let filteredBooks = $derived( let filteredBooks = $derived(
bibleBooks.filter((book) => bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase()), book.name.toLowerCase().includes(searchQuery.toLowerCase())
), )
); );
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && filteredBooks.length > 0) { if (e.key === "Enter" && filteredBooks.length > 0) {
submitGuess(filteredBooks[0].id); submitGuess(filteredBooks[0].id);
} }
} }
</script> </script>
<div class="mb-12"> <div class="relative">
<input <div class="relative">
bind:value={searchQuery} <svg
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..." class="absolute left-4 sm:left-6 top-1/2 transform -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
class="w-full p-4 sm:p-6 border-2 border-gray-200 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all shadow-lg" fill="none"
onkeydown={handleKeydown} stroke="currentColor"
/> viewBox="0 0 24 24"
{#if searchQuery && filteredBooks.length > 0} xmlns="http://www.w3.org/2000/svg"
<ul >
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-200 rounded-2xl shadow-lg" <path
> stroke-linecap="round"
{#each filteredBooks as book (book.id)} stroke-linejoin="round"
<li> stroke-width="2"
<button d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
class="w-full p-4 sm:p-5 text-left {guessedIds.has( />
book.id, </svg>
) <input
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600' bind:value={searchQuery}
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center" placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
onclick={() => submitGuess(book.id)} class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-200 transition-all bg-white"
> onkeydown={handleKeydown}
<span autocomplete="off"
class="font-semibold {guessedIds.has(book.id) />
? 'line-through text-gray-500' {#if searchQuery}
: ''}">{book.name}</span <button
> class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
<span class="ml-auto text-sm opacity-75" onclick={() => (searchQuery = "")}
>({book.testament.toUpperCase()})</span aria-label="Clear search"
> >
</button> <svg
</li> class="w-5 h-5 sm:w-6 sm:h-6"
{/each} fill="none"
</ul> stroke="currentColor"
{:else if searchQuery} viewBox="0 0 24 24"
<p class="mt-4 text-center text-gray-500 p-8">No books found</p> xmlns="http://www.w3.org/2000/svg"
{/if} >
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
{#if searchQuery && filteredBooks.length > 0}
<ul
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
>
{#each filteredBooks as book (book.id)}
<li>
<button
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id)
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
onclick={() => submitGuess(book.id)}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-500'
: ''}">{book.name}</span
>
<span class="ml-auto text-sm opacity-75"
>({book.testament.toUpperCase()})</span
>
</button>
</li>
{/each}
</ul>
{:else if searchQuery}
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
{/if}
</div> </div>

View File

@@ -1,25 +1,38 @@
<script lang="ts"> <script lang="ts">
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";
let { data, isWon }: { data: PageData; isWon: boolean } = $props(); let {
data,
isWon,
blurChapter = false,
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
let dailyVerse = $derived(data.dailyVerse); let dailyVerse = $derived(data.dailyVerse);
let displayReference = $derived( let displayReference = $derived(
dailyVerse.reference.replace(/^Psalms /, "Psalm ") blurChapter
? dailyVerse.reference
.replace(/^Psalms /, "Psalm ")
.replace(/\s(\d+):/, " ?:")
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
); );
let displayVerseText = $derived( let displayVerseText = $derived(
dailyVerse.verseText.replace(/^([a-z])/, (c) => c.toUpperCase()) dailyVerse.verseText
.replace(/^([a-z])/, (c) => c.toUpperCase())
.replace(/[,:;-—]$/, "...")
); );
</script> </script>
<div class="bg-gray-50 rounded-2xl shadow-xl p-8 sm:p-12 mb-4 sm:mb-12 w-full"> <Container class="w-full p-8 sm:p-12 bg-white/70">
<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} {#if isWon}
<p class="text-center text-lg! big-text text-green-600! font-bold mt-8"> <p
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
>
{displayReference} {displayReference}
</p> </p>
{/if} {/if}
</div> </Container>

View File

@@ -1,196 +1,245 @@
<script lang="ts"> <script lang="ts">
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { getBookById, toOrdinal, getNextGradeMessage } from "$lib/utils/game"; import {
import { onMount } from "svelte"; getBookById,
toOrdinal,
getNextGradeMessage,
} from "$lib/utils/game";
import { onMount } from "svelte";
import Container from "./Container.svelte";
import CountdownTimer from "./CountdownTimer.svelte";
import ChapterGuess from "./ChapterGuess.svelte";
interface StatsData { interface StatsData {
solveRank: number; solveRank: number;
guessRank: number; guessRank: number;
totalSolves: number; totalSolves: number;
averageGuesses: number; averageGuesses: number;
} tiedCount: number;
percentile: number;
}
interface WeightedMessage { interface WeightedMessage {
text: string; text: string;
weight: number; weight: number;
} }
let { let {
grade, grade,
statsData, statsData,
correctBookId, correctBookId,
handleShare, handleShare,
copyToClipboard, copyToClipboard,
copied = $bindable(false), copied = $bindable(false),
statsSubmitted, statsSubmitted,
guessCount, guessCount,
} = $props(); reference,
onChapterGuessCompleted,
} = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? ""); let bookName = $derived(getBookById(correctBookId)?.name ?? "");
let hasWebShare = $derived( let hasWebShare = $derived(
typeof navigator !== "undefined" && "share" in navigator typeof navigator !== "undefined" && "share" in navigator,
); );
let copySuccess = $state(false); let copySuccess = $state(false);
// List of congratulations messages with weights // List of congratulations messages with weights
const congratulationsMessages: WeightedMessage[] = [ const congratulationsMessages: WeightedMessage[] = [
{ text: "Congratulations!", weight: 10 }, { text: "Congratulations!", weight: 10 },
{ text: "You got it!", weight: 1000 }, { text: "You got it!", weight: 1000 },
{ text: "Yup,", weight: 100 }, { text: "Yup.", weight: 100 },
{ text: "Very nice!", weight: 1 }, { text: "Very nice!", weight: 1 },
]; ];
// Function to select a random message based on weights // Function to select a random message based on weights
function getRandomCongratulationsMessage(): string { function getRandomCongratulationsMessage(): string {
// Special case for first try success // Special case for first try success
if (guessCount === 1) { if (guessCount === 1) {
const n = Math.random(); const n = Math.random();
if (n < 0.99) { if (n < 0.99) {
return "🌟 First try! 🌟"; return "🌟 First try! 🌟";
} else { } else {
return "🗣️ Axios! 🗣️"; return "🗣️ Axios! 🗣️";
} }
} }
const totalWeight = congratulationsMessages.reduce( const totalWeight = congratulationsMessages.reduce(
(sum, msg) => sum + msg.weight, (sum, msg) => sum + msg.weight,
0 0,
); );
let random = Math.random() * totalWeight; let random = Math.random() * totalWeight;
for (const message of congratulationsMessages) { for (const message of congratulationsMessages) {
random -= message.weight; random -= message.weight;
if (random <= 0) { if (random <= 0) {
return message.text; return message.text;
} }
} }
// Fallback to first message if something goes wrong // Fallback to first message if something goes wrong
return congratulationsMessages[0].text; return congratulationsMessages[0].text;
} }
// Generate the congratulations message // Generate the congratulations message
let congratulationsMessage = $derived(getRandomCongratulationsMessage()); let congratulationsMessage = $derived(getRandomCongratulationsMessage());
</script> </script>
<div <div class="flex flex-col gap-6">
class="p-8 sm:p-12 w-full bg-linear-to-r from-green-400 to-green-600 text-white rounded-2xl shadow-2xl text-center fade-in" <Container
> class="w-full p-8 sm:p-12 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
<!-- <h2 class="text-2xl sm:text-4xl font-black mb-4 drop-shadow-lg"> >
{congratulationsMessage} <p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed">
</h2> --> {congratulationsMessage} The verse is from
<p class="text-xl sm:text-3xl md:text-4xl"> <span class="font-black text-3xl md:text-4xl">{bookName}</span>.
{congratulationsMessage} The verse is from </p>
<span class="font-black text-xl sm:text-2xl md:text-3xl">{bookName}</span>. <p class="text-lg sm:text-xl md:text-2xl mt-4">
</p> You guessed correctly after {guessCount}
<p {guessCount === 1 ? "guess" : "guesses"}.
class="text-2xl font-bold mt-6 p-2 mx-2 bg-black/20 rounded-lg inline-block" <span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
> >{grade}</span
Your grade: {grade} >
</p> </p>
{#if hasWebShare} <div class="flex justify-center mt-6">
<button {#if hasWebShare}
onclick={handleShare} <!-- mobile and arc in production -->
data-umami-event="Share" <button
class="mt-4 text-2xl font-bold p-2 bg-white/20 hover:bg-white/30 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none" onclick={handleShare}
> data-umami-event="Share"
📤 Share class="text-2xl font-bold p-4 bg-white/70 hover:bg-white/80 rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none"
</button> >
<button 📤 Share
onclick={() => { </button>
copyToClipboard(); <button
copySuccess = true; onclick={() => {
setTimeout(() => { copyToClipboard();
copySuccess = false; copySuccess = true;
}, 3000); setTimeout(() => {
}} copySuccess = false;
data-umami-event="Copy to Clipboard" }, 3000);
class={`mt-4 text-2xl font-bold p-2 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${ }}
copySuccess data-umami-event="Copy to Clipboard"
? "bg-green-400/50 hover:bg-green-500/60" class={`text-2xl font-bold p-4 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
: "bg-white/20 hover:bg-white/30" copySuccess
}`} ? "bg-white/30"
> : "bg-white/70 hover:bg-white/80"
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"} }`}
</button> >
{:else} {copySuccess ? "✅ Copied!" : "📋 Copy"}
<button </button>
onclick={handleShare} {:else}
data-umami-event="Share" <!-- dev mode and desktop browsers -->
class={`mt-4 text-2xl font-bold p-2 ${ <button
copied onclick={handleShare}
? "bg-green-400/50 hover:bg-green-500/60" data-umami-event="Copy to Clipboard"
: "bg-white/20 hover:bg-white/30" class={`text-2xl font-bold p-4 ${
} rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`} copied ? "bg-white/30" : "bg-white/70 hover:bg-white/80"
> } rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
{copied ? "Copied to clipboard!" : "📤 Share"} >
</button> {copied ? "✅ Copied!" : "📋 Share"}
{/if} </button>
{/if}
</div>
<p class="pt-6 big-text text-gray-100!"> {#if guessCount !== 1}
{getNextGradeMessage(guessCount)} <p class="pt-6 big-text text-gray-700!">
</p> {getNextGradeMessage(guessCount)}
</p>
{/if}
</Container>
<!-- Statistics Display --> <!-- S++ Bonus Challenge for first try -->
{#if statsData} {#if guessCount === 1}
<div class="mt-6" in:fade={{ delay: 800 }}> <ChapterGuess
<div class="grid grid-cols-3 gap-4 gap-x-8 text-center"> {reference}
<!-- Solve Rank Column --> bookId={correctBookId}
<div class="flex flex-col"> onCompleted={onChapterGuessCompleted}
<div class="text-3xl sm:text-4xl font-black"> />
#{statsData.solveRank} {/if}
</div>
<div class="text-xs sm:text-sm opacity-90 mt-1">
You were the {toOrdinal(statsData.solveRank)} person to solve today
</div>
</div>
<!-- Guess Rank Column --> <CountdownTimer />
<div class="flex flex-col">
<div class="text-3xl sm:text-4xl font-black">
{Math.round(
((statsData.totalSolves - statsData.guessRank + 1) /
statsData.totalSolves) *
100
)}%
</div>
<div class="text-xs sm:text-sm opacity-90 mt-1">
You ranked {toOrdinal(statsData.guessRank)} of {statsData.totalSolves}
total solves
</div>
</div>
<!-- Average Column --> <!-- Statistics Display -->
<div class="flex flex-col"> {#if statsData}
<div class="text-3xl sm:text-4xl font-black"> <Container
{statsData.averageGuesses} class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
</div> >
<div class="text-xs sm:text-sm opacity-90 mt-1"> <div
People guessed correctly after {statsData.averageGuesses} class="grid grid-cols-3 gap-4 gap-x-8 text-center"
{statsData.averageGuesses === 1 ? "guess" : "guesses"} on average in:fade={{ delay: 800 }}
</div> >
</div> <!-- Solve Rank Column -->
</div> <div class="flex flex-col">
</div> <div
{:else if !statsSubmitted} class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
<div class="mt-6 text-sm opacity-80">Submitting stats...</div> >
{/if} #{statsData.solveRank}
</div>
<div class="text-sm sm:text-sm opacity-90 mt-1">
You were the {toOrdinal(statsData.solveRank)} person to solve
today
</div>
</div>
<!-- Guess Rank Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
>
{toOrdinal(statsData.guessRank)}
</div>
<div class="text-sm sm:text-sm opacity-90 mt-1">
You ranked {toOrdinal(statsData.guessRank)} of {statsData.totalSolves}
{statsData.totalSolves === 1
? "solve"
: "solves"}{statsData.tiedCount > 0
? `, tied with ${statsData.tiedCount} ${statsData.tiedCount === 1 ? "other" : "others"}`
: ""}.<br />
{#if statsData.percentile <= 25}
<span class="font-bold">
(Top {statsData.percentile}%)
</span>
{/if}
</div>
</div>
<!-- Average Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
>
{statsData.averageGuesses}
</div>
<div class="text-sm sm:text-sm opacity-90 mt-1">
People solved after {statsData.averageGuesses}
{statsData.averageGuesses === 1 ? "guess" : "guesses"} on
average
</div>
</div>
</div>
</Container>
{:else if !statsSubmitted}
<Container
class="w-full p-6 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
>
<div class="text-sm opacity-80">Submitting stats...</div>
</Container>
{/if}
</div> </div>
<style> <style>
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(-10px); transform: translateY(-10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
.fade-in { .fade-in {
animation: fadeIn 0.5s ease-out; animation: fadeIn 0.5s ease-out;
} }
</style> </style>

View File

@@ -353,6 +353,54 @@ export function getRandomGreekVerses(count: number = 3): {
return null; return null;
} }
/**
* Get a random set of verses from a specific book
* Returns `count` consecutive verses by default
*/
export function getRandomVersesFromBook(
bookNumber: number,
count: number = 1
): {
bookId: string;
bookName: string;
chapter: number;
startVerse: number;
endVerse: number;
verses: string[];
} | null {
const book = getBookByNumber(bookNumber);
if (!book) {
return null;
}
// Try up to 10 times to find a valid passage
for (let attempt = 0; attempt < 10; attempt++) {
const chapterNumber = getRandomChapterNumber(bookNumber);
const verseCount = getVerseCount(bookNumber, chapterNumber);
// Skip chapters that don't have enough verses
if (verseCount < count) {
continue;
}
const startVerse = getRandomStartVerse(bookNumber, chapterNumber, count);
const verses = extractVerses(bookNumber, chapterNumber, startVerse, count);
if (verses.length === count) {
return {
bookId: book.id,
bookName: book.name,
chapter: chapterNumber,
startVerse,
endVerse: startVerse + count - 1,
verses
};
}
}
return null;
}
/** /**
* Format a reference string from verse data * Format a reference string from verse data
*/ */

71
src/lib/utils/stats.ts Normal file
View File

@@ -0,0 +1,71 @@
export interface UserStats {
totalSolves: number;
avgGuesses: number;
gradeDistribution: {
'S++': number;
'S+': number;
'A+': number;
'A': number;
'B+': number;
'B': number;
'C+': number;
'C': number;
};
currentStreak: number;
bestStreak: number;
recentCompletions: Array<{
date: string;
guessCount: number;
grade: string;
}>;
}
export function getGradeColor(grade: string): string {
switch (grade) {
case 'S++': return 'text-purple-600 bg-purple-100';
case 'S+': return 'text-yellow-600 bg-yellow-100';
case 'A+': return 'text-green-600 bg-green-100';
case 'A': return 'text-green-500 bg-green-50';
case 'B+': return 'text-blue-600 bg-blue-100';
case 'B': return 'text-blue-500 bg-blue-50';
case 'C+': return 'text-orange-600 bg-orange-100';
case 'C': return 'text-red-600 bg-red-100';
default: return 'text-gray-600 bg-gray-100';
}
}
export function formatDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
}
export function getStreakMessage(currentStreak: number): string {
if (currentStreak === 0) {
return "Start your streak today!";
} else if (currentStreak === 1) {
return "Keep it going!";
} else if (currentStreak < 7) {
return `${currentStreak} days strong!`;
} else if (currentStreak < 30) {
return `${currentStreak} day streak - amazing!`;
} else {
return `${currentStreak} days - you're unstoppable!`;
}
}
export function getPerformanceMessage(avgGuesses: number): string {
if (avgGuesses <= 2) {
return "Exceptional performance!";
} else if (avgGuesses <= 4) {
return "Great performance!";
} else if (avgGuesses <= 6) {
return "Good performance!";
} else if (avgGuesses <= 8) {
return "Room for improvement!";
} else {
return "Keep practicing!";
}
}

View File

@@ -1,17 +1,31 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import "./layout.css"; import "./layout.css";
import favicon from "$lib/assets/favicon.ico"; import favicon from "$lib/assets/favicon.ico";
onMount(() => {
if (browser) {
const script = document.createElement('script');
script.defer = true;
script.src = 'https://umami.snail.city/script.js';
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
document.body.appendChild(script);
}
});
let { children } = $props(); let { children } = $props();
</script> </script>
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<script <!-- <script
defer defer
src="https://umami.snail.city/script.js" src="https://umami.snail.city/script.js"
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc" data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
data-domains="bibdle.com,www.bibdle.com" data-domains="bibdle.com,www.bibdle.com"
></script> ></script> -->
</svelte:head> </svelte:head>
{@render children()} {@render children()}

View File

@@ -1,442 +1,504 @@
<script lang="ts"> <script lang="ts">
import { bibleBooks, type BibleBook } from "$lib/types/bible"; 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 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";
import GuessesTable from "$lib/components/GuessesTable.svelte"; import GuessesTable from "$lib/components/GuessesTable.svelte";
import CountdownTimer from "$lib/components/CountdownTimer.svelte"; import WinScreen from "$lib/components/WinScreen.svelte";
import WinScreen from "$lib/components/WinScreen.svelte"; import Credits from "$lib/components/Credits.svelte";
import Feedback from "$lib/components/Feedback.svelte"; import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import TitleAnimation from "$lib/components/TitleAnimation.svelte"; import DevButtons from "$lib/components/DevButtons.svelte";
import { getGrade } from "$lib/utils/game"; import { getGrade } from "$lib/utils/game";
interface Guess { interface Guess {
book: BibleBook; book: BibleBook;
testamentMatch: boolean; testamentMatch: boolean;
sectionMatch: boolean; sectionMatch: boolean;
adjacent: boolean; adjacent: boolean;
} firstLetterMatch: boolean;
}
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 guesses = $state<Guess[]>([]); let guesses = $state<Guess[]>([]);
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 chapterCorrect = $state(false);
let anonymousId = $state(""); let anonymousId = $state("");
let statsSubmitted = $state(false); let statsSubmitted = $state(false);
let statsData = $state<{ let statsData = $state<{
solveRank: number; solveRank: number;
guessRank: number; guessRank: number;
totalSolves: number; totalSolves: number;
averageGuesses: number; averageGuesses: number;
} | null>(null); tiedCount: number;
percentile: number;
} | null>(null);
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id))); let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
const currentDate = $derived( const currentDate = $derived(
new Date().toLocaleDateString("en-US", { new Date().toLocaleDateString("en-US", {
weekday: "long", weekday: "long",
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}) }),
); );
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId)); let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
let grade = $derived( let grade = $derived(
isWon isWon
? getGrade(guesses.length, getBookById(correctBookId)?.popularity ?? 0) ? guesses.length === 1 && chapterCorrect
: "" ? "S++"
); : getGrade(
guesses.length,
getBookById(correctBookId)?.popularity ?? 0,
)
: "",
);
let blurChapter = $derived(
isWon && guesses.length === 1 && !chapterGuessCompleted,
);
function getBookById(id: string): BibleBook | undefined { function getBookById(id: string): BibleBook | undefined {
return bibleBooks.find((b) => b.id === id); return bibleBooks.find((b) => b.id === id);
} }
function isAdjacent(id1: string, id2: string): boolean { function isAdjacent(id1: string, id2: string): boolean {
const b1 = getBookById(id1); const b1 = getBookById(id1);
const b2 = getBookById(id2); const b2 = getBookById(id2);
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1); return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
} }
function submitGuess(bookId: string) { function submitGuess(bookId: string) {
if (guesses.some((g) => g.book.id === bookId)) return; if (guesses.some((g) => g.book.id === bookId)) return;
const book = getBookById(bookId); const book = getBookById(bookId);
if (!book) return; if (!book) return;
const correctBook = getBookById(correctBookId); const correctBook = getBookById(correctBookId);
if (!correctBook) return; if (!correctBook) return;
const testamentMatch = book.testament === correctBook.testament; const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section; const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(book.id, correctBookId); const adjacent = isAdjacent(bookId, correctBookId);
console.log( // Special case: if correct book is in the Epistles + starts with "1",
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}` // any guess starting with "1" counts as first letter match
); const correctIsEpistlesWithNumber =
correctBook.section === "Pauline Epistles" &&
correctBook.name[0] === "1";
const guessStartsWithNumber = book.name[0] === "1";
if (guesses.length === 0) { const firstLetterMatch =
const key = `bibdle-first-guess-${dailyVerse.date}`; correctIsEpistlesWithNumber && guessStartsWithNumber
if ( ? true
localStorage.getItem(key) !== "true" && : book.name[0].toUpperCase() ===
browser && correctBook.name[0].toUpperCase();
(window as any).umami
) {
(window as any).umami.track("First guess");
localStorage.setItem(key, "true");
}
}
guesses = [ console.log(
{ `Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
book, );
testamentMatch,
sectionMatch,
adjacent,
},
...guesses,
];
searchQuery = ""; if (guesses.length === 0) {
} const key = `bibdle-first-guess-${dailyVerse.date}`;
if (
localStorage.getItem(key) !== "true" &&
browser &&
(window as any).umami
) {
(window as any).umami.track("First guess");
localStorage.setItem(key, "true");
}
}
function generateUUID(): string { guesses = [
// Try native randomUUID if available {
if (typeof window.crypto.randomUUID === "function") { book,
return window.crypto.randomUUID(); testamentMatch,
} sectionMatch,
adjacent,
firstLetterMatch,
},
...guesses,
];
// Fallback UUID v4 generator for older browsers searchQuery = "";
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { }
const r = window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function getOrCreateAnonymousId(): string { function generateUUID(): string {
if (!browser) return ""; // Try native randomUUID if available
const key = "bibdle-anonymous-id"; if (typeof window.crypto.randomUUID === "function") {
let id = localStorage.getItem(key); return window.crypto.randomUUID();
if (!id) { }
id = generateUUID();
localStorage.setItem(key, id);
}
return id;
}
// Initialize anonymous ID // Fallback UUID v4 generator for older browsers
$effect(() => { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
if (!browser) return; const r =
anonymousId = getOrCreateAnonymousId(); window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`; const v = c === "x" ? r : (r & 0x3) | 0x8;
statsSubmitted = localStorage.getItem(statsKey) === "true"; return v.toString(16);
}); });
}
$effect(() => { function getOrCreateAnonymousId(): string {
if (!browser) return; if (!browser) return "";
isDev = window.location.host === "localhost:5173"; const key = "bibdle-anonymous-id";
}); let id = localStorage.getItem(key);
if (!id) {
id = generateUUID();
localStorage.setItem(key, id);
}
return id;
}
// Load saved guesses // Initialize anonymous ID
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
anonymousId = getOrCreateAnonymousId();
if ((window as any).umami) {
(window as any).umami.identify(anonymousId);
}
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;
}
}
});
const key = `bibdle-guesses-${dailyVerse.date}`; $effect(() => {
const saved = localStorage.getItem(key); if (!browser) return;
if (saved) { isDev = window.location.host === "localhost:5173";
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);
return {
book,
testamentMatch,
sectionMatch,
adjacent,
};
});
}
});
$effect(() => { // Load saved guesses
if (!browser) return; $effect(() => {
localStorage.setItem( if (!browser) return;
`bibdle-guesses-${dailyVerse.date}`,
JSON.stringify(guesses.map((g) => g.book.id))
);
});
// Auto-submit stats when user wins const key = `bibdle-guesses-${dailyVerse.date}`;
$effect(() => { const saved = localStorage.getItem(key);
console.log("Stats effect triggered:", { if (saved) {
browser, let savedIds: string[] = JSON.parse(saved);
isWon, savedIds = Array.from(new Set(savedIds));
anonymousId, guesses = savedIds.map((bookId: string) => {
statsSubmitted, const book = getBookById(bookId)!;
statsData, const correctBook = getBookById(correctBookId)!;
}); const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(bookId, correctBookId);
if (!browser || !isWon || !anonymousId) { // Apply same first letter logic as in submitGuess
console.log("Basic conditions not met"); const correctIsEpistlesWithNumber =
return; correctBook.section === "Pauline Epistles" &&
} correctBook.name[0] === "1";
const guessStartsWithNumber = book.name[0] === "1";
if (statsSubmitted && !statsData) { const firstLetterMatch =
console.log("Fetching existing stats..."); correctIsEpistlesWithNumber && guessStartsWithNumber
? true
: book.name[0].toUpperCase() ===
correctBook.name[0].toUpperCase();
(async () => { return {
try { book,
const response = await fetch( testamentMatch,
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}` sectionMatch,
); adjacent,
const result = await response.json(); firstLetterMatch,
console.log("Stats response:", result); };
});
}
});
if (result.success && result.stats) { $effect(() => {
console.log("Setting stats data:", result.stats); if (!browser) return;
statsData = result.stats; localStorage.setItem(
localStorage.setItem( `bibdle-guesses-${dailyVerse.date}`,
`bibdle-stats-submitted-${dailyVerse.date}`, JSON.stringify(guesses.map((g) => g.book.id)),
"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; // Auto-submit stats when user wins
} $effect(() => {
console.log("Stats effect triggered:", {
browser,
isWon,
anonymousId,
statsSubmitted,
statsData,
});
console.log("Submitting stats..."); if (!browser || !isWon || !anonymousId) {
console.log("Basic conditions not met");
return;
}
async function submitStats() { if (statsSubmitted && !statsData) {
try { console.log("Fetching existing stats...");
const payload = {
anonymousId,
date: dailyVerse.date,
guessCount: guesses.length,
};
console.log("Sending POST request with:", payload); (async () => {
try {
const response = await fetch(
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`,
);
const result = await response.json();
console.log("Stats response:", result);
const response = await fetch("/api/submit-completion", { if (result.success && result.stats) {
method: "POST", console.log("Setting stats data:", result.stats);
headers: { statsData = result.stats;
"Content-Type": "application/json", localStorage.setItem(
}, `bibdle-stats-submitted-${dailyVerse.date}`,
body: JSON.stringify(payload), "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);
}
})();
const result = await response.json(); return;
console.log("Stats response:", result); }
if (result.success && result.stats) { console.log("Submitting 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(); async function submitStats() {
}); try {
const payload = {
anonymousId,
date: dailyVerse.date,
guessCount: guesses.length,
};
$effect(() => { console.log("Sending POST request with:", payload);
if (!browser || !isWon) return;
const key = `bibdle-win-tracked-${dailyVerse.date}`;
if (localStorage.getItem(key) === "true") return;
if ((window as any).umami) {
(window as any).umami.track("Guessed correctly", {
totalGuesses: guesses.length,
});
}
localStorage.setItem(key, "true");
});
function generateShareText(): string { const response = await fetch("/api/submit-completion", {
const emojis = guesses method: "POST",
.slice() headers: {
.reverse() "Content-Type": "application/json",
.map((guess) => { },
if (guess.book.id === correctBookId) return "✅"; body: JSON.stringify(payload),
if (guess.adjacent) return "‼️"; });
if (guess.sectionMatch) return "🟩";
if (guess.testamentMatch) return "🟧";
return "🟥";
})
.join("");
const dateFormatter = new Intl.DateTimeFormat("en-US", { const result = await response.json();
month: "short", console.log("Stats response:", result);
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}`,
siteUrl,
].join("\n");
}
async function share() { if (result.success && result.stats) {
if (!browser) return; 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);
}
}
const shareText = generateShareText(); submitStats();
});
try { $effect(() => {
if ("share" in navigator) { if (!browser || !isWon) return;
await (navigator as any).share({ text: shareText }); const key = `bibdle-win-tracked-${dailyVerse.date}`;
} else { if (localStorage.getItem(key) === "true") return;
await (navigator as any).clipboard.writeText(shareText); if ((window as any).umami) {
} (window as any).umami.track("Guessed correctly", {
} catch (err) { totalGuesses: guesses.length,
console.error("Share failed:", err); });
throw err; }
} localStorage.setItem(key, "true");
} });
async function copyToClipboard() { function generateShareText(): string {
if (!browser) return; 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 shareText = generateShareText(); 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");
}
try { async function share() {
await (navigator as any).clipboard.writeText(shareText); if (!browser) return;
copied = true;
setTimeout(() => {
copied = false;
}, 5000);
} catch (err) {
console.error("Copy to clipboard failed:", err);
throw err;
}
}
function handleShare() { const shareText = generateShareText();
if (copied || !browser) return;
const useClipboard = !("share" in navigator);
if (useClipboard) {
copied = true;
}
share()
.then(() => {
if (useClipboard) {
setTimeout(() => {
copied = false;
}, 5000);
}
})
.catch(() => {
if (useClipboard) {
copied = false;
}
});
}
function clearLocalStorage() { try {
if (!browser) return; if ("share" in navigator) {
// Clear all bibdle-related localStorage items await (navigator as any).share({ text: shareText });
const keysToRemove: string[] = []; } else {
for (let i = 0; i < localStorage.length; i++) { await (navigator as any).clipboard.writeText(shareText);
const key = localStorage.key(i); }
if (key && key.startsWith("bibdle-")) { } catch (err) {
keysToRemove.push(key); console.error("Share failed:", err);
} throw err;
} }
keysToRemove.forEach((key) => localStorage.removeItem(key)); }
// Reload the page to reset state
window.location.reload(); 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() {
if (copied || !browser) return;
const useClipboard = !("share" in navigator);
if (useClipboard) {
copied = true;
}
share()
.then(() => {
if (useClipboard) {
setTimeout(() => {
copied = false;
}, 5000);
}
})
.catch(() => {
if (useClipboard) {
copied = false;
}
});
}
</script> </script>
<svelte:head> <svelte:head>
<!-- <title>Bibdle &mdash; A daily bible game{isDev ? " (dev)" : ""}</title> --> <!-- <title>Bibdle &mdash; A daily bible game{isDev ? " (dev)" : ""}</title> -->
<title>A daily bible game{isDev ? " (dev)" : ""}</title> <title>A daily bible game{isDev ? " (dev)" : ""}</title>
<!-- <meta <!-- <meta
name="description" name="description"
content="Guess which book of the Bible a verse comes from." 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"
> >
<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">
<span class="big-text" <span class="big-text"
>{isDev ? "Dev Edition | " : ""}{currentDate}</span >{isDev ? "Dev Edition | " : ""}{currentDate}</span
> >
</div> <div class="mt-4">
<a
href="/stats?anonymousId={anonymousId}"
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
>
📊 View Stats
</a>
</div>
</div>
<VerseDisplay {data} {isWon} /> <div class="flex flex-col gap-6">
<VerseDisplay {data} {isWon} {blurChapter} />
{#if !isWon} {#if !isWon}
<SearchInput bind:searchQuery {guessedIds} {submitGuess} /> <SearchInput bind:searchQuery {guessedIds} {submitGuess} />
{:else} {:else}
<WinScreen <WinScreen
{grade} {grade}
{statsData} {statsData}
{correctBookId} {correctBookId}
{handleShare} {handleShare}
{copyToClipboard} {copyToClipboard}
bind:copied bind:copied
{statsSubmitted} {statsSubmitted}
guessCount={guesses.length} guessCount={guesses.length}
/> reference={dailyVerse.reference}
<CountdownTimer /> onChapterGuessCompleted={() => {
{/if} chapterGuessCompleted = true;
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
const saved = localStorage.getItem(key);
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;
}
}}
/>
{/if}
<GuessesTable {guesses} {correctBookId} /> <GuessesTable {guesses} {correctBookId} />
{#if isWon}
<Feedback /> {#if isWon}
{/if} <Credits />
{#if isDev} {/if}
<button </div>
onclick={clearLocalStorage} {#if isDev}
class="mt-4 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-bold transition-colors" <DevButtons />
> {/if}
Clear LocalStorage </div>
</button>
{/if}
</div>
</div> </div>

View File

@@ -0,0 +1,68 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRandomVersesFromBook } from '$lib/server/xml-bible';
interface VerseOption {
text: string;
isImposter: boolean;
ref: string;
}
export const GET: RequestHandler = async () => {
try {
// Select two different random books (1-66)
let book1Num = Math.floor(Math.random() * 66) + 1;
let book2Num = Math.floor(Math.random() * 66) + 1;
while (book2Num === book1Num) {
book2Num = Math.floor(Math.random() * 66) + 1;
}
// Randomly decide which is majority
const majorityBookNum = Math.random() < 0.5 ? book1Num : book2Num;
const imposterBookNum = majorityBookNum === book1Num ? book2Num : book1Num;
// Get 3 random verses from majority book
const options: VerseOption[] = [];
for (let i = 0; i < 3; i++) {
const verseData = getRandomVersesFromBook(majorityBookNum, 1);
if (!verseData) {
throw new Error('Failed to get majority verse');
}
options.push({
text: verseData.verses[0],
isImposter: false,
ref: `${verseData.bookName} ${verseData.chapter}:${verseData.startVerse}`
});
}
// Get 1 random verse from imposter book
const imposterVerseData = getRandomVersesFromBook(imposterBookNum, 1);
if (!imposterVerseData) {
throw new Error('Failed to get imposter verse');
}
options.push({
text: imposterVerseData.verses[0],
isImposter: true,
ref: `${imposterVerseData.bookName} ${imposterVerseData.chapter}:${imposterVerseData.startVerse}`
});
// Fisher-Yates shuffle
for (let i = options.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[options[i], options[j]] = [options[j], options[i]];
}
const verses = options.map(o => o.text);
const refs = options.map(o => o.ref);
const imposterIndex = options.findIndex(o => o.isImposter);
return json({
verses,
refs,
imposterIndex
});
} catch (error) {
console.error('Imposter API error:', error);
return json({ error: 'Failed to generate imposter game' }, { status: 500 });
}
};

View File

@@ -44,17 +44,26 @@ export const POST: RequestHandler = async ({ request }) => {
// Solve rank: position in time-ordered list // Solve rank: position in time-ordered list
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1; const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
// Guess rank: count how many had FEWER guesses (ties get same rank) // Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length; const uniqueBetterGuessCounts = new Set(
const guessRank = betterGuesses + 1; allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
);
const guessRank = uniqueBetterGuessCounts.size + 1;
// Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
// Average guesses // Average guesses
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0); const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10; 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({ return json({
success: true, success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses } stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
}); });
} catch (err) { } catch (err) {
console.error('Error submitting completion:', err); console.error('Error submitting completion:', err);
@@ -101,17 +110,26 @@ export const GET: RequestHandler = async ({ url }) => {
// Solve rank: position in time-ordered list // Solve rank: position in time-ordered list
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1; const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
// Guess rank: count how many had FEWER guesses (ties get same rank) // Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length; const uniqueBetterGuessCounts = new Set(
const guessRank = betterGuesses + 1; allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
);
const guessRank = uniqueBetterGuessCounts.size + 1;
// Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
// Average guesses // Average guesses
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0); const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10; 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({ return json({
success: true, success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses } stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
}); });
} catch (err) { } catch (err) {
console.error('Error fetching stats:', err); console.error('Error fetching stats:', err);

View File

@@ -1,214 +1,17 @@
<script lang="ts"> <script lang="ts">
let sentence = $state(""); import Imposter from "$lib/components/Imposter.svelte";
let results = $state< import Container from "$lib/components/Container.svelte";
Array<{
book: string;
chapter: number;
verse: number;
text: string;
score: number;
}>
>([]);
let loading = $state(false);
async function searchVerses() {
loading = true;
try {
const response = await fetch("/api/similar-verses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sentence, topK: 10 }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
results = data.results || [];
} catch (error) {
console.error("Search error:", error);
results = [];
} finally {
loading = false;
}
}
</script> </script>
<div class="page"> <svelte:head>
<h1 class="title">Similar Verse Finder</h1> <title>Bibdle (imposter mode)</title>
</svelte:head>
<div class="search-section"> <Container>
<input <Container class="p-2 mt-12">
bind:value={sentence} <h1><i>Imposter Mode</i></h1>
placeholder="Enter a sentence to find similar Bible verses..." <p>Click the verse that doesn't belong</p>
class="input" </Container>
/>
<button onclick={searchVerses} disabled={loading} class="button">
{loading ? "Searching..." : "Find Similar Verses"}
</button>
</div>
{#if results.length > 0} <Imposter />
<div class="results"> </Container>
{#each results as result, i (i)}
<article class="result">
<header>
<strong>{result.book} {result.chapter}:{result.verse}</strong>
<span class="score">Score: {result.score.toFixed(3)}</span>
</header>
<p>{result.text}</p>
</article>
{/each}
</div>
{:else if sentence.trim() && !loading}
<p class="no-results">No similar verses found. Try another sentence!</p>
{/if}
</div>
<style>
.page {
max-width: 900px;
margin: 0 auto;
padding: 1rem 0.75rem;
font-family:
system-ui,
-apple-system,
sans-serif;
}
.title {
text-align: center;
margin-bottom: 1.75rem;
font-size: clamp(2rem, 5vw, 3rem);
color: #2c3e50;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.search-section {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.input {
flex: 1;
min-width: 300px;
padding: 0.75rem 1rem;
border: 2px solid #e1e5e9;
border-radius: 12px;
font-size: 1.1rem;
transition: all 0.2s ease;
background: #fafbfc;
}
.input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
background: white;
}
.button {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
}
.button:disabled {
background: #a0aec0;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.result {
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 1.25rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
}
.result:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}
.result header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
gap: 0.75rem;
}
.result strong {
font-size: 1.3rem;
color: #1a202c;
}
.score {
font-size: 1rem;
color: #718096;
font-weight: 500;
white-space: nowrap;
}
.result p {
margin: 0;
line-height: 1.7;
color: #4a5568;
font-size: 1.1rem;
}
.no-results {
text-align: center;
padding: 1.75rem 0.75rem;
color: #a0aec0;
font-size: 1.2rem;
font-style: italic;
}
@media (max-width: 768px) {
.search-section {
flex-direction: column;
}
.input {
min-width: unset;
}
.result header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,214 @@
<script lang="ts">
let sentence = $state("");
let results = $state<
Array<{
book: string;
chapter: number;
verse: number;
text: string;
score: number;
}>
>([]);
let loading = $state(false);
async function searchVerses() {
loading = true;
try {
const response = await fetch("/api/similar-verses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sentence, topK: 10 }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
results = data.results || [];
} catch (error) {
console.error("Search error:", error);
results = [];
} finally {
loading = false;
}
}
</script>
<div class="page">
<h1 class="title">Similar Verse Finder</h1>
<div class="search-section">
<input
bind:value={sentence}
placeholder="Enter a sentence to find similar Bible verses..."
class="input"
/>
<button onclick={searchVerses} disabled={loading} class="button">
{loading ? "Searching..." : "Find Similar Verses"}
</button>
</div>
{#if results.length > 0}
<div class="results">
{#each results as result, i (i)}
<article class="result">
<header>
<strong>{result.book} {result.chapter}:{result.verse}</strong>
<span class="score">Score: {result.score.toFixed(3)}</span>
</header>
<p>{result.text}</p>
</article>
{/each}
</div>
{:else if sentence.trim() && !loading}
<p class="no-results">No similar verses found. Try another sentence!</p>
{/if}
</div>
<style>
.page {
max-width: 900px;
margin: 0 auto;
padding: 1rem 0.75rem;
font-family:
system-ui,
-apple-system,
sans-serif;
}
.title {
text-align: center;
margin-bottom: 1.75rem;
font-size: clamp(2rem, 5vw, 3rem);
color: #2c3e50;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.search-section {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.input {
flex: 1;
min-width: 300px;
padding: 0.75rem 1rem;
border: 2px solid #e1e5e9;
border-radius: 12px;
font-size: 1.1rem;
transition: all 0.2s ease;
background: #fafbfc;
}
.input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
background: white;
}
.button {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
}
.button:disabled {
background: #a0aec0;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.result {
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 1.25rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
}
.result:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}
.result header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
gap: 0.75rem;
}
.result strong {
font-size: 1.3rem;
color: #1a202c;
}
.score {
font-size: 1rem;
color: #718096;
font-weight: 500;
white-space: nowrap;
}
.result p {
margin: 0;
line-height: 1.7;
color: #4a5568;
font-size: 1.1rem;
}
.no-results {
text-align: center;
padding: 1.75rem 0.75rem;
color: #a0aec0;
font-size: 1.2rem;
font-style: italic;
}
@media (max-width: 768px) {
.search-section {
flex-direction: column;
}
.input {
min-width: unset;
}
.result header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,149 @@
import { db } from '$lib/server/db';
import { dailyCompletions, type DailyCompletion } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const anonymousId = url.searchParams.get('anonymousId');
if (!anonymousId) {
return {
stats: null,
error: 'No anonymous ID provided'
};
}
try {
// Get all completions for this user
const completions = await db
.select()
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, anonymousId))
.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: []
}
};
}
// Calculate basic stats
const totalSolves = completions.length;
const totalGuesses = completions.reduce((sum: number, c: DailyCompletion) => sum + c.guessCount, 0);
const avgGuesses = Math.round((totalGuesses / totalSolves) * 100) / 100;
// Calculate grade distribution
const gradeDistribution = {
'S++': 0, // This will be calculated differently since we don't store chapter correctness
'S+': completions.filter((c: DailyCompletion) => c.guessCount === 1).length,
'A+': completions.filter((c: DailyCompletion) => c.guessCount === 2).length,
'A': completions.filter((c: DailyCompletion) => c.guessCount === 3).length,
'B+': completions.filter((c: DailyCompletion) => c.guessCount >= 4 && c.guessCount <= 6).length,
'B': completions.filter((c: DailyCompletion) => c.guessCount >= 7 && c.guessCount <= 10).length,
'C+': completions.filter((c: DailyCompletion) => c.guessCount >= 11 && c.guessCount <= 15).length,
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
};
// Calculate streaks
const sortedDates = completions
.map((c: DailyCompletion) => c.date)
.sort();
let currentStreak = 0;
let bestStreak = 0;
let tempStreak = 1;
if (sortedDates.length > 0) {
// Check if current streak is active (includes today or yesterday)
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const lastPlayedDate = sortedDates[sortedDates.length - 1];
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
currentStreak = 1;
// Count backwards from the most recent date
for (let i = sortedDates.length - 2; i >= 0; i--) {
const currentDate = new Date(sortedDates[i + 1]);
const prevDate = new Date(sortedDates[i]);
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 1) {
currentStreak++;
} else {
break;
}
}
}
// Calculate best streak
bestStreak = 1;
for (let i = 1; i < sortedDates.length; i++) {
const currentDate = new Date(sortedDates[i]);
const prevDate = new Date(sortedDates[i - 1]);
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff === 1) {
tempStreak++;
} else {
bestStreak = Math.max(bestStreak, tempStreak);
tempStreak = 1;
}
}
bestStreak = Math.max(bestStreak, tempStreak);
}
// Get recent completions (last 7 days)
const recentCompletions = completions
.slice(0, 7)
.map((c: DailyCompletion) => ({
date: c.date,
guessCount: c.guessCount,
grade: getGradeFromGuesses(c.guessCount)
}));
return {
stats: {
totalSolves,
avgGuesses,
gradeDistribution,
currentStreak,
bestStreak,
recentCompletions
}
};
} catch (error) {
console.error('Error fetching user stats:', error);
return {
stats: null,
error: 'Failed to fetch stats'
};
}
};
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";
}

View File

@@ -0,0 +1,206 @@
<script lang="ts">
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import {
getGradeColor,
formatDate,
getStreakMessage,
getPerformanceMessage,
type UserStats
} from "$lib/utils/stats";
interface PageData {
stats: UserStats | null;
error?: string;
}
let { data }: { data: PageData } = $props();
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 () => {
const anonymousId = getOrCreateAnonymousId();
if (!anonymousId) {
goto("/");
return;
}
// If no anonymousId in URL, redirect with it
const url = new URL(window.location.href);
if (!url.searchParams.get('anonymousId')) {
url.searchParams.set('anonymousId', anonymousId);
goto(url.pathname + url.search);
return;
}
loading = false;
});
function getGradePercentage(count: number, total: number): number {
return total > 0 ? Math.round((count / total) * 100) : 0;
}
$inspect(data);
</script>
<svelte:head>
<title>Stats | Bibdle</title>
<meta name="description" content="View your Bibdle game statistics and performance" />
</svelte:head>
<div class="min-h-screen bg-gradient-to-br from-amber-50 to-orange-100 p-4">
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-gray-800 mb-2">Your Stats</h1>
<p class="text-gray-600">Track your Bibdle performance over time</p>
<div class="mt-4">
<a
href="/"
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
>
← Back to Game
</a>
</div>
</div>
{#if loading}
<div class="text-center py-12">
<div class="inline-block w-8 h-8 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"></div>
<p class="mt-4 text-gray-600">Loading your stats...</p>
</div>
{:else if data.error}
<div class="text-center py-12">
<div class="bg-red-100 border border-red-300 rounded-lg p-6 max-w-md mx-auto">
<p class="text-red-700">{data.error}</p>
<a
href="/"
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Return to Game
</a>
</div>
</div>
{:else if !data.stats}
<div class="text-center py-12">
<div class="bg-yellow-100 border border-yellow-300 rounded-lg p-6 max-w-md mx-auto">
<p class="text-yellow-700">No stats available.</p>
<a
href="/"
class="mt-4 inline-block px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
>
Start Playing
</a>
</div>
</div>
{:else}
{@const stats = data.stats}
<!-- Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Total Solves -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="text-center">
<div class="text-3xl font-bold text-amber-600 mb-2">{stats.totalSolves}</div>
<div class="text-gray-600">Total Solves</div>
{#if stats.totalSolves > 0}
<div class="text-sm text-gray-500 mt-1">
{getPerformanceMessage(stats.avgGuesses)}
</div>
{/if}
</div>
</div>
<!-- Average Guesses -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="text-center">
<div class="text-3xl font-bold text-blue-600 mb-2">{stats.avgGuesses}</div>
<div class="text-gray-600">Avg. Guesses</div>
<div class="text-sm text-gray-500 mt-1">per solve</div>
</div>
</div>
<!-- Current Streak -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="text-center">
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
<div class="text-gray-600">Current Streak</div>
<div class="text-sm text-gray-500 mt-1">
{getStreakMessage(stats.currentStreak)}
</div>
</div>
</div>
</div>
<!-- Grade Distribution -->
{#if stats.totalSolves > 0}
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Grade Distribution</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{#each Object.entries(stats.gradeDistribution) as [grade, count]}
{@const percentage = getGradePercentage(count, stats.totalSolves)}
<div class="text-center">
<div class="mb-2">
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold {getGradeColor(grade)}">
{grade}
</span>
</div>
<div class="text-2xl font-bold text-gray-800">{count}</div>
<div class="text-sm text-gray-500">{percentage}%</div>
</div>
{/each}
</div>
</div>
<!-- Streak Info -->
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Streak Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="text-center">
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
<div class="text-gray-600">Current Streak</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-purple-600 mb-2">{stats.bestStreak}</div>
<div class="text-gray-600">Best Streak</div>
</div>
</div>
</div>
<!-- Recent Performance -->
{#if stats.recentCompletions.length > 0}
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Recent Performance</h2>
<div class="space-y-3">
{#each stats.recentCompletions as completion}
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
<div>
<span class="font-medium">{formatDate(completion.date)}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-gray-600">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span>
<span class="px-2 py-1 rounded text-sm font-semibold {getGradeColor(completion.grade)}">
{completion.grade}
</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/if}
{/if}
</div>
</div>

55
todo.md
View File

@@ -1,21 +1,33 @@
# in progress # in progress
- root menu: classic / imposter mode / impossible mode (complete today's classic and imposter modes to unlock)
# 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
- classic mode: identify what book the verse is from (e.g. Genesis, John, Revelations...) in as few guesses as possible. - classic mode: identify what book the verse is from (e.g. Genesis, John, Revelations...) in as few guesses as possible.
- imposter mode: out of four options, identify the verse that is not in the Bible - imposter mode: out of four options, identify the verse that is not in the Bible
- OR out of four options, identify the verse that is not in the same book as the other three options
- OR, out of four options, drag them into the
- impossible mode: identify which book of the bible the verse is from in less than three guesses. - impossible mode: identify which book of the bible the verse is from in less than three guesses.
- The gambling aspect of hoping you get a verse you already know is VERY strong
- add login + saved stats + streak etc. - add login + saved stats + streak etc.
- add deuterocanonical books - add deuterocanonical books
@@ -47,6 +59,45 @@ 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
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
- Verses ending in semicolons, commas, etc. will be replaced with "..."
## january 4th
- For bonus points: guess the verse/psalm number
- major UI styling revamp
-- 2026 --
## december 30th
- merged the embeddings/similarity route into production
## december 27th ## december 27th
- add event log to submitting first-guess or correct-guess to umami (to make bounce rate more accurate) - add event log to submitting first-guess or correct-guess to umami (to make bounce rate more accurate)