26 Commits

Author SHA1 Message Date
George Powell
3947e8adb0 rss improvements 2026-02-02 02:52:53 -05:00
George Powell
244113671e created rss feed 2026-02-02 02:07:12 -05:00
George Powell
5b9b2f76f4 added some more words to the "first letter" edge case 2026-02-02 01:32:17 -05:00
George Powell
f7ec0742e1 fixed "first letter" clue edge cases 2026-02-02 01:27:12 -05:00
George Powell
d797b980ea updated ranking formula 2026-02-02 00:48:35 -05:00
George Powell
ff228fb547 Update package.json version 2026-01-28 23:35:33 -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
35 changed files with 2485 additions and 1049 deletions

11
.claude/settings.json Normal file
View File

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

22
.env.example Normal file
View File

@@ -0,0 +1,22 @@
DATABASE_URL=example.db
PUBLIC_SITE_URL=https://bibdle.com
# nodemailer
SMTP_USERNAME=email@example.com
SMTP_TOKEN=TOKEN
SMTP_SERVER=smtp.example.com
SMTP_PORT=port
# note from mail provider: Enable TLS or SSL on the external service if it is supported.
# sign in with Discord
# sign in with google
# sign in with apple
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-----"

4
.gitignore vendored
View File

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

View File

@@ -24143,7 +24143,7 @@
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, Where is their God?</verse>
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
@@ -25056,7 +25056,7 @@
<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="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="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>

31
analyze_top_users.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# analyze_top_users.sh
# Analyzes the daily_completions table to find the top 10 anonymous IDs by completion count
# Set database path from argument or default to dev.db
DB_PATH="${1:-dev.db}"
# Check if database file exists
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database file not found: $DB_PATH"
echo "Usage: $0 [database_path]"
exit 1
fi
# Run the analysis query
sqlite3 "$DB_PATH" <<EOF
.mode column
.headers on
.width 36 16 16 17
SELECT
anonymous_id,
COUNT(*) as completion_count,
MIN(date) as first_completion,
MAX(date) as latest_completion
FROM daily_completions
GROUP BY anonymous_id
ORDER BY completion_count DESC
LIMIT 10;
EOF

View File

@@ -6,27 +6,27 @@
"name": "bibdle",
"dependencies": {
"@xenova/transformers": "^2.17.2",
"better-sqlite3": "^12.5.0",
"better-sqlite3": "^12.6.2",
"fast-xml-parser": "^5.3.3",
"xml2js": "^0.6.2",
},
"devDependencies": {
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@tailwindcss/vite": "^4.1.18",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22",
"@types/node": "^22.19.7",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.0",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"drizzle-orm": "^0.45.1",
"svelte": "^5.48.3",
"svelte-check": "^4.3.5",
"tailwindcss": "^4.1.18",
"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/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=="],
@@ -235,7 +235,7 @@
"@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=="],
@@ -263,7 +263,7 @@
"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=="],
@@ -303,7 +303,7 @@
"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=="],
@@ -421,6 +421,8 @@
"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=="],
"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=="],
"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=="],
@@ -523,7 +525,7 @@
"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=="],
@@ -551,8 +553,12 @@
"@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=="],
"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=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],

20
clear-today-verse.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/zsh
# Clear today's verse from daily_verses table
DB_PATH="dev.db"
TODAY=$(date +%Y-%m-%d)
echo "Deleting verse for date: $TODAY"
sqlite3 "$DB_PATH" "DELETE FROM daily_verses WHERE date = '$TODAY';"
if [ $? -eq 0 ]; then
echo "✓ Successfully deleted verse for $TODAY"
# Show remaining verses in table
COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_verses;")
echo "Remaining verses in database: $COUNT"
else
echo "✗ Failed to delete verse"
exit 1
fi

34
daily_completions_report.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env zsh
DB_PATH="./local.db"
# Check if database exists
if [ ! -f "$DB_PATH" ]; then
echo "Error: Database not found at $DB_PATH"
exit 1
fi
# Query for daily completions on 2026-02-01 with ranking
echo "Daily Completions for 2026-02-01"
echo "================================="
echo ""
printf "%-12s %-10s %-6s\n" "Anonymous ID" "Guesses" "Rank"
printf "%-12s %-10s %-6s\n" "------------" "-------" "----"
# Execute query with custom column mode
sqlite3 "$DB_PATH" <<SQL
.mode column
.headers off
.width 12 10 6
SELECT
SUBSTR(anonymous_id, 1, 10) as anon_id,
guess_count,
RANK() OVER (ORDER BY guess_count ASC) as rank
FROM daily_completions
WHERE date = '2026-02-01'
ORDER BY rank, guess_count;
SQL
echo ""
echo "Total entries:"
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '2026-02-01';"

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

View File

@@ -2,9 +2,7 @@ import Database from 'bun:sqlite';
// Database path - adjust if your database is located elsewhere
const dbPath = process.env.DATABASE_URL || './local.db';
console.log(`Connecting to database: ${dbPath}`);
const db = new Database(dbPath);
// Query all rows from daily_completions
@@ -13,7 +11,6 @@ const query = db.query(`
FROM daily_completions
ORDER BY date
`);
const rows = query.all() as { date: string; guess_count: number }[];
if (rows.length === 0) {
@@ -50,4 +47,60 @@ const overallAvg = (totalGuesses / totalCompletions).toFixed(2);
console.log('--------------|-------------|-------------------');
console.log(`Overall Average: ${overallAvg} guesses across ${totalCompletions} completions`);
// 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

@@ -74,13 +74,11 @@
});
</script>
<div class="text-center py-12">
<div class="w-full">
<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"
>
<p
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full"
>
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2">
Next Verse In
</p>
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">

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

View File

@@ -0,0 +1,246 @@
<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;
}
// Replace trailing punctuation with ellipsis
// Preserve closing quotes/brackets that may have been added
formatted = formatted.replace(
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
"...$1",
);
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

@@ -5,8 +5,8 @@
let filteredBooks = $derived(
bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
),
book.name.toLowerCase().includes(searchQuery.toLowerCase())
)
);
function handleKeydown(e: KeyboardEvent) {
@@ -16,23 +16,60 @@
}
</script>
<div class="mb-12">
<div class="relative">
<div class="relative">
<svg
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"
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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
bind:value={searchQuery}
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
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"
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}
autocomplete="off"
/>
{#if searchQuery}
<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"
onclick={() => (searchQuery = "")}
aria-label="Clear search"
>
<svg
class="w-5 h-5 sm:w-6 sm:h-6"
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="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-200 rounded-2xl shadow-lg"
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,
)
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)}

View File

@@ -1,25 +1,38 @@
<script lang="ts">
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 displayReference = $derived(
dailyVerse.reference.replace(/^Psalms /, "Psalm ")
blurChapter
? dailyVerse.reference
.replace(/^Psalms /, "Psalm ")
.replace(/\s(\d+):/, " ?:")
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
);
let displayVerseText = $derived(
dailyVerse.verseText.replace(/^([a-z])/, (c) => c.toUpperCase())
dailyVerse.verseText
.replace(/^([a-z])/, (c) => c.toUpperCase())
.replace(/[,:;-—]$/, "...")
);
</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
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
>
{displayVerseText}
</blockquote>
{#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}
</p>
{/if}
</div>
</Container>

View File

@@ -1,13 +1,22 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { getBookById, toOrdinal, getNextGradeMessage } from "$lib/utils/game";
import {
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 {
solveRank: number;
guessRank: number;
totalSolves: number;
averageGuesses: number;
tiedCount: number;
percentile: number;
}
interface WeightedMessage {
@@ -24,11 +33,13 @@
copied = $bindable(false),
statsSubmitted,
guessCount,
reference,
onChapterGuessCompleted,
} = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
let hasWebShare = $derived(
typeof navigator !== "undefined" && "share" in navigator
typeof navigator !== "undefined" && "share" in navigator,
);
let copySuccess = $state(false);
@@ -36,7 +47,7 @@
const congratulationsMessages: WeightedMessage[] = [
{ text: "Congratulations!", weight: 10 },
{ text: "You got it!", weight: 1000 },
{ text: "Yup,", weight: 100 },
{ text: "Yup.", weight: 100 },
{ text: "Very nice!", weight: 1 },
];
@@ -54,7 +65,7 @@
const totalWeight = congratulationsMessages.reduce(
(sum, msg) => sum + msg.weight,
0
0,
);
let random = Math.random() * totalWeight;
@@ -73,27 +84,29 @@
let congratulationsMessage = $derived(getRandomCongratulationsMessage());
</script>
<div
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"
>
<!-- <h2 class="text-2xl sm:text-4xl font-black mb-4 drop-shadow-lg">
{congratulationsMessage}
</h2> -->
<p class="text-xl sm:text-3xl md:text-4xl">
{congratulationsMessage} The verse is from
<span class="font-black text-xl sm:text-2xl md:text-3xl">{bookName}</span>.
</p>
<p
class="text-2xl font-bold mt-6 p-2 mx-2 bg-black/20 rounded-lg inline-block"
<div class="flex flex-col gap-6">
<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"
>
<p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed">
{congratulationsMessage} The verse is from
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
</p>
<p class="text-lg sm:text-xl md:text-2xl mt-4">
You guessed correctly after {guessCount}
{guessCount === 1 ? "guess" : "guesses"}.
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
>{grade}</span
>
Your grade: {grade}
</p>
<div class="flex justify-center mt-6">
{#if hasWebShare}
<!-- mobile and arc in production -->
<button
onclick={handleShare}
data-umami-event="Share"
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"
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"
>
📤 Share
</button>
@@ -106,75 +119,111 @@
}, 3000);
}}
data-umami-event="Copy to Clipboard"
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 ${
class={`text-2xl font-bold p-4 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
copySuccess
? "bg-green-400/50 hover:bg-green-500/60"
: "bg-white/20 hover:bg-white/30"
? "bg-white/30"
: "bg-white/70 hover:bg-white/80"
}`}
>
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"}
{copySuccess ? "✅ Copied!" : "📋 Copy"}
</button>
{:else}
<!-- dev mode and desktop browsers -->
<button
onclick={handleShare}
data-umami-event="Share"
class={`mt-4 text-2xl font-bold p-2 ${
copied
? "bg-green-400/50 hover:bg-green-500/60"
: "bg-white/20 hover:bg-white/30"
} rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
data-umami-event="Copy to Clipboard"
class={`text-2xl font-bold p-4 ${
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"}
{copied ? "Copied!" : "📋 Share"}
</button>
{/if}
</div>
<p class="pt-6 big-text text-gray-100!">
{#if guessCount !== 1}
<p class="pt-6 big-text text-gray-700!">
{getNextGradeMessage(guessCount)}
</p>
{/if}
</Container>
<!-- S++ Bonus Challenge for first try -->
{#if guessCount === 1}
<ChapterGuess
{reference}
bookId={correctBookId}
onCompleted={onChapterGuessCompleted}
/>
{/if}
<CountdownTimer />
<!-- Statistics Display -->
{#if statsData}
<div class="mt-6" in:fade={{ delay: 800 }}>
<div class="grid grid-cols-3 gap-4 gap-x-8 text-center">
<Container
class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
>
<div
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
in:fade={{ delay: 800 }}
>
<!-- Solve Rank Column -->
<div class="flex flex-col">
<div class="text-3xl sm:text-4xl font-black">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
>
#{statsData.solveRank}
</div>
<div class="text-xs sm:text-sm opacity-90 mt-1">
You were the {toOrdinal(statsData.solveRank)} person to solve today
<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">
{Math.round(
((statsData.totalSolves - statsData.guessRank + 1) /
statsData.totalSolves) *
100
)}%
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
>
{toOrdinal(statsData.guessRank)}
</div>
<div class="text-xs sm:text-sm opacity-90 mt-1">
<div class="text-sm sm:text-sm opacity-90 mt-1">
You ranked {toOrdinal(statsData.guessRank)} of {statsData.totalSolves}
total solves
{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">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
>
{statsData.averageGuesses}
</div>
<div class="text-xs sm:text-sm opacity-90 mt-1">
People guessed correctly after {statsData.averageGuesses}
{statsData.averageGuesses === 1 ? "guess" : "guesses"} on average
</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}
<div class="mt-6 text-sm opacity-80">Submitting stats...</div>
<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>

View File

@@ -1,7 +1,5 @@
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
export const session = sqliteTable('session', {

View File

@@ -353,6 +353,54 @@ export function getRandomGreekVerses(count: number = 3): {
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
*/

View File

@@ -7,6 +7,7 @@
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
<script
defer
src="https://umami.snail.city/script.js"

View File

@@ -91,13 +91,20 @@ export const actions: Actions = {
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
const guessRank = betterGuesses + 1;
// Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
// Average guesses
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
// Percentile: what percentage of people you beat (100 - your rank percentage)
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
return {
success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses }
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
};
}
};

View File

@@ -7,10 +7,10 @@
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
import SearchInput from "$lib/components/SearchInput.svelte";
import GuessesTable from "$lib/components/GuessesTable.svelte";
import CountdownTimer from "$lib/components/CountdownTimer.svelte";
import WinScreen from "$lib/components/WinScreen.svelte";
import Feedback from "$lib/components/Feedback.svelte";
import Credits from "$lib/components/Credits.svelte";
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import DevButtons from "$lib/components/DevButtons.svelte";
import { getGrade } from "$lib/utils/game";
interface Guess {
@@ -18,6 +18,7 @@
testamentMatch: boolean;
sectionMatch: boolean;
adjacent: boolean;
firstLetterMatch: boolean;
}
let { data }: PageProps = $props();
@@ -31,6 +32,8 @@
let copied = $state(false);
let isDev = $state(false);
let chapterGuessCompleted = $state(false);
let chapterCorrect = $state(false);
let anonymousId = $state("");
let statsSubmitted = $state(false);
@@ -39,6 +42,8 @@
guessRank: number;
totalSolves: number;
averageGuesses: number;
tiedCount: number;
percentile: number;
} | null>(null);
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
@@ -49,14 +54,22 @@
year: "numeric",
month: "long",
day: "numeric",
})
}),
);
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
let grade = $derived(
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 {
@@ -69,6 +82,11 @@
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
}
function getFirstLetter(bookName: string): string {
const match = bookName.match(/[a-zA-Z]/);
return match ? match[0] : bookName[0];
}
function submitGuess(bookId: string) {
if (guesses.some((g) => g.book.id === bookId)) return;
@@ -80,10 +98,27 @@
const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(book.id, correctBookId);
const adjacent = isAdjacent(bookId, correctBookId);
// Special case: if correct book is in the Epistles + starts with "1",
// any guess starting with "1" counts as first letter match
const correctIsEpistlesWithNumber =
(correctBook.section === "Pauline Epistles" ||
correctBook.section === "General Epistles") &&
correctBook.name[0] === "1";
const guessIsEpistlesWithNumber =
(book.section === "Pauline Epistles" ||
book.section === "General Epistles") &&
book.name[0] === "1";
const firstLetterMatch =
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
? true
: getFirstLetter(book.name).toUpperCase() ===
getFirstLetter(correctBook.name).toUpperCase();
console.log(
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
);
if (guesses.length === 0) {
@@ -104,6 +139,7 @@
testamentMatch,
sectionMatch,
adjacent,
firstLetterMatch,
},
...guesses,
];
@@ -119,7 +155,8 @@
// Fallback UUID v4 generator for older browsers
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
const r =
window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
@@ -142,6 +179,17 @@
anonymousId = getOrCreateAnonymousId();
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
statsSubmitted = localStorage.getItem(statsKey) === "true";
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
if (chapterGuessCompleted) {
const saved = localStorage.getItem(chapterGuessKey);
if (saved) {
const data = JSON.parse(saved);
const match = dailyVerse.reference.match(/\s(\d+):/);
const correctChapter = match ? parseInt(match[1], 10) : 1;
chapterCorrect = data.selectedChapter === correctChapter;
}
}
});
$effect(() => {
@@ -164,11 +212,29 @@
const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(bookId, correctBookId);
// Apply same first letter logic as in submitGuess
const correctIsEpistlesWithNumber =
(correctBook.section === "Pauline Epistles" ||
correctBook.section === "General Epistles") &&
correctBook.name[0] === "1";
const guessIsEpistlesWithNumber =
(book.section === "Pauline Epistles" ||
book.section === "General Epistles") &&
book.name[0] === "1";
const firstLetterMatch =
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
? true
: getFirstLetter(book.name).toUpperCase() ===
getFirstLetter(correctBook.name).toUpperCase();
return {
book,
testamentMatch,
sectionMatch,
adjacent,
firstLetterMatch,
};
});
}
@@ -178,7 +244,7 @@
if (!browser) return;
localStorage.setItem(
`bibdle-guesses-${dailyVerse.date}`,
JSON.stringify(guesses.map((g) => g.book.id))
JSON.stringify(guesses.map((g) => g.book.id)),
);
});
@@ -203,7 +269,7 @@
(async () => {
try {
const response = await fetch(
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`,
);
const result = await response.json();
console.log("Stats response:", result);
@@ -213,7 +279,7 @@
statsData = result.stats;
localStorage.setItem(
`bibdle-stats-submitted-${dailyVerse.date}`,
"true"
"true",
);
} else if (result.error) {
console.error("Server error:", result.error);
@@ -257,7 +323,7 @@
statsSubmitted = true;
localStorage.setItem(
`bibdle-stats-submitted-${dailyVerse.date}`,
"true"
"true",
);
} else if (result.error) {
console.error("Server error:", result.error);
@@ -303,13 +369,13 @@
year: "numeric",
});
const formattedDate = dateFormatter.format(
new Date(`${dailyVerse.date}T00:00:00`)
new Date(`${dailyVerse.date}T00:00:00`),
);
const siteUrl = window.location.origin;
return [
`📖 Bibdle | ${formattedDate} 📖`,
`${grade} (${guesses.length} ${guesses.length == 1 ? "guess" : "guesses"})`,
`${emojis}`,
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
siteUrl,
].join("\n");
}
@@ -368,25 +434,9 @@
}
});
}
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>
<svelte:head>
<!-- <title>Bibdle &mdash; A daily bible game{isDev ? " (dev)" : ""}</title> -->
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
<!-- <meta
name="description"
@@ -408,7 +458,8 @@
>
</div>
<VerseDisplay {data} {isWon} />
<div class="flex flex-col gap-6">
<VerseDisplay {data} {isWon} {blurChapter} />
{#if !isWon}
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
@@ -422,21 +473,33 @@
bind:copied
{statsSubmitted}
guessCount={guesses.length}
reference={dailyVerse.reference}
onChapterGuessCompleted={() => {
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;
}
}}
/>
<CountdownTimer />
{/if}
<GuessesTable {guesses} {correctBookId} />
{#if isWon}
<Feedback />
<Credits />
{/if}
</div>
{#if isDev}
<button
onclick={clearLocalStorage}
class="mt-4 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-bold transition-colors"
>
Clear LocalStorage
</button>
<DevButtons />
{/if}
</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

@@ -48,13 +48,20 @@ export const POST: RequestHandler = async ({ request }) => {
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
const guessRank = betterGuesses + 1;
// Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
// Average guesses
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
// Percentile: what percentage of people you beat (100 - your rank percentage)
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
return json({
success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses }
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
});
} catch (err) {
console.error('Error submitting completion:', err);
@@ -105,13 +112,20 @@ export const GET: RequestHandler = async ({ url }) => {
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
const guessRank = betterGuesses + 1;
// Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
// Average guesses
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
// Percentile: what percentage of people you beat (100 - your rank percentage)
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
return json({
success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses }
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
});
} catch (err) {
console.error('Error fetching stats:', err);

View File

@@ -0,0 +1,145 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyVerses } from '$lib/server/db/schema';
import { desc } from 'drizzle-orm';
// Helper: Escape XML special characters
function escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// Helper: Format YYYY-MM-DD to RFC 822 date string
function formatRFC822(dateStr: string): string {
// Parse date in America/New_York timezone (EST/EDT)
// Assuming midnight ET
const date = new Date(dateStr + 'T00:00:00-05:00');
return date.toUTCString().replace('GMT', 'EST');
}
// Helper: Format YYYY-MM-DD to readable date
function formatReadableDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'America/New_York'
});
}
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
function formatVerseText(text: string): string {
let formatted = text;
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
const pairs: [string, string][] = [
['(', ')'],
['[', ']'],
['{', '}'],
['"', '"'],
["'", "'"],
['\u201C', '\u201D'], // " "
['\u2018', '\u2019'] // ' '
];
// Check if text starts with opening punctuation without closing
for (const [open, close] of pairs) {
if (formatted.startsWith(open) && !formatted.includes(close)) {
formatted += '...' + close;
break;
}
}
// Check if text ends with closing punctuation without opening
for (const [open, close] of pairs) {
if (formatted.endsWith(close) && !formatted.includes(open)) {
formatted = open + '...' + formatted;
break;
}
}
// Check if text contains unbalanced opening quotes (not at start) without closing
for (const [open, close] of pairs) {
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
if (openCount > closeCount) {
formatted += close;
break;
}
}
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
// Replace trailing punctuation with ellipsis
// Preserve closing quotes/brackets that may have been added
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
return formatted;
}
export const GET: RequestHandler = async ({ request }) => {
try {
// Query last 30 verses, ordered by date descending
const verses = await db
.select()
.from(dailyVerses)
.orderBy(desc(dailyVerses.date))
.limit(30);
// Generate ETag based on latest verse date
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
// Check if client has cached version
if (request.headers.get('If-None-Match') === etag) {
return new Response(null, { status: 304 });
}
// Get site URL from environment or use default
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
// Build RSS XML
const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
const items = verses
.map(
(verse) => `
<item>
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
<description>${escapeXml(formatVerseText(verse.verseText))}</description>
<link>${SITE_URL}</link>
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
<pubDate>${formatRFC822(verse.date)}</pubDate>
</item>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Bibdle</title>
<link>${SITE_URL}</link>
<description>A daily Bible game</description>
<language>en-us</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<ttl>720</ttl>${items}
</channel>
</rss>`;
return new Response(xml, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
ETag: etag
}
});
} catch (error) {
console.error('RSS feed generation error:', error);
return new Response('Internal Server Error', { status: 500 });
}
};

View File

@@ -1,214 +1,17 @@
<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;
}
}
import Imposter from "$lib/components/Imposter.svelte";
import Container from "$lib/components/Container.svelte";
</script>
<div class="page">
<h1 class="title">Similar Verse Finder</h1>
<svelte:head>
<title>Bibdle (imposter mode)</title>
</svelte:head>
<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>
<Container>
<Container class="p-2 mt-12">
<h1><i>Imposter Mode</i></h1>
<p>Click the verse that doesn't belong</p>
</Container>
{#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>
<Imposter />
</Container>

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>

23
todo.md
View File

@@ -1,6 +1,9 @@
# in progress
- root menu: classic / imposter mode / impossible mode (complete today's classic and imposter modes to unlock)
- Show new/old testament after 3 guesses and section after 7 guesses
- Add sections for "first letter", "Canonical/deutero", etc...
- Make the UI more "wordle-like" ()
- How do you balance rewarding knowledge vs incentivising learning?
# todo
@@ -14,8 +17,12 @@
- 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
- 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.
- The gambling aspect of hoping you get a verse you already know is VERY strong
- add login + saved stats + streak etc.
- add deuterocanonical books
@@ -47,6 +54,20 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
# done
## 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
## december 30th
- merged the embeddings/similarity route into production
## december 27th
- add event log to submitting first-guess or correct-guess to umami (to make bounce rate more accurate)