mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
11 Commits
e1a665ba63
...
auth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae4482a551 | ||
|
|
884bbe65c7 | ||
|
|
3de55ba216 | ||
|
|
6e74fffb65 | ||
|
|
1ae2b2ac6c | ||
|
|
a188be167b | ||
|
|
e550965086 | ||
|
|
03429b17cc | ||
|
|
3ee7331510 | ||
|
|
592fa917cd | ||
|
|
ad1774e6b0 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,3 +32,4 @@ embeddings*
|
||||
engwebu_usfx.xml
|
||||
|
||||
deploy.log
|
||||
bibdle.socket
|
||||
|
||||
132
CLAUDE.md
132
CLAUDE.md
@@ -27,19 +27,14 @@ After calling the list-sections tool, you MUST analyze the returned documentatio
|
||||
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
|
||||
|
||||
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
|
||||
- **Styling**: Tailwind CSS 4
|
||||
- **Database**: SQLite with Drizzle ORM
|
||||
- **Auth**: Session-based authentication using @oslojs/crypto (SHA-256 hashed tokens)
|
||||
- **Auth**: Session-based authentication using Bun's built-in cryptographically secure functions
|
||||
- **Deployment**: Node.js adapter for production builds
|
||||
- **External API**: bible-api.com for fetching random verses
|
||||
- **ML**: `@xenova/transformers` for verse embeddings (initialized in server hook) (currently disabled, was a test for a cancelled project)
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -51,6 +46,11 @@ bun run dev
|
||||
bun run check
|
||||
bun run check:watch
|
||||
|
||||
# Run tests
|
||||
bun test
|
||||
bun test --watch
|
||||
bun test tests/timezone-handling.test.ts # Run a single test file
|
||||
|
||||
# Build for production
|
||||
bun run build
|
||||
|
||||
@@ -58,92 +58,108 @@ bun run build
|
||||
bun run preview
|
||||
|
||||
# Database operations
|
||||
bun run db:push # Push schema changes to database
|
||||
bun run db:generate # Generate migrations (DO NOT RUN)
|
||||
bun run db:migrate # Run migrations (DO NOT RUN)
|
||||
bun run db:push # Push schema changes directly (avoid in prod)
|
||||
bun run db:generate # Generate migrations
|
||||
bun run db:migrate # Run migrations
|
||||
bun run db:studio # Open Drizzle Studio GUI
|
||||
```
|
||||
|
||||
## Critical: Date/Time Handling
|
||||
|
||||
**Bibdle is played by users across many timezones worldwide. The verse shown to a player must always be the verse for the calendar date at *their* location — not the server's timezone, not UTC. A user in Tokyo on Wednesday must see Wednesday's verse, even if the server (or a user in New York) is still on Tuesday.**
|
||||
|
||||
**NEVER use server time or UTC time for user-facing date calculations.**
|
||||
|
||||
- Get today's date client-side: `new Date().toLocaleDateString("en-CA")` → `YYYY-MM-DD`
|
||||
- Pass the date to the server as a query param or POST body (`localDate`)
|
||||
- Server-side date arithmetic must use UTC methods on the client-provided date string: `new Date(dateStr + 'T00:00:00Z')` + `setUTCDate`/`getUTCDate`
|
||||
- `src/routes/+page.ts` has `ssr = false` so the load runs client-side with the true local date
|
||||
- Never set the user-facing URL to include their date as a parameter. It should always be passed to an API route behind the scenes if needed.
|
||||
|
||||
### Streak Calculation
|
||||
|
||||
A streak counts consecutive calendar days (in the user's local timezone) on which the user completed the puzzle. The rules:
|
||||
|
||||
- The client passes its local date (`localDate`) to the streak API. The server never uses its own clock.
|
||||
- A streak is **active** if the user has completed today's puzzle *or* yesterday's puzzle (they still have time to play today).
|
||||
- Walk backwards from `localDate` through the `dailyCompletions` records, counting each day that has a completion. Stop as soon as a day is missing.
|
||||
- A streak of 1 (completed only today or only yesterday, with no prior consecutive days) is **not displayed** — the minimum shown streak is 2.
|
||||
- "Yesterday" and all date arithmetic on the server must use UTC methods on the client-provided date string to avoid timezone drift: `new Date(localDate + 'T00:00:00Z')`, then `setUTCDate`/`getUTCDate`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Database Schema (`src/lib/server/db/schema.ts`)
|
||||
|
||||
- **user**: User accounts with id and age
|
||||
- **session**: Auth sessions linked to users with expiration timestamps
|
||||
- **user**: `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `isPrivate`
|
||||
- **session**: `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
|
||||
- **daily_verses**: Cached daily verses with book ID, verse text, reference, and date
|
||||
- **dailyCompletions**: Game results per user/date with guess count, grade, book; unique on `(userId, date)`
|
||||
|
||||
Sessions expire after 30 days and are automatically renewed when less than 15 days remain.
|
||||
Sessions expire after 30 days and auto-renew when < 15 days remain.
|
||||
|
||||
### Bible Data (`src/lib/types/bible.ts`)
|
||||
|
||||
The `bibleBooks` array contains all 66 Bible books with metadata:
|
||||
- Testament (old/new)
|
||||
- Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
|
||||
- Testament (old/new), Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
|
||||
- Order (1-66, used for adjacency detection)
|
||||
- Popularity (2-10, affects grading - higher is more popular)
|
||||
|
||||
### Daily Verse System (`src/routes/+page.server.ts`)
|
||||
|
||||
The `getTodayVerse()` function:
|
||||
1. Checks database for existing verse for today's date
|
||||
2. If none exists, fetches from bible-api.com (random verse + 2 consecutive verses)
|
||||
3. Caches in database with UTC date key
|
||||
4. Returns verse with book metadata for the game
|
||||
`getTodayVerse()` checks the database for today's date, fetches a verse if missing, caches permanently, and returns verse with book metadata.
|
||||
|
||||
### Game Logic (`src/routes/+page.svelte`)
|
||||
|
||||
**State Management:**
|
||||
- `guesses` array stores game state in localStorage keyed by date
|
||||
- `guesses` array stored in localStorage keyed by date: `bibdle-guesses-${date}`
|
||||
- Each guess tracks: book, testamentMatch, sectionMatch, adjacent
|
||||
- `isWon` is derived from whether any guess matches the correct book
|
||||
- `isWon` derived from whether any guess matches the correct book
|
||||
|
||||
**Grading System:**
|
||||
```javascript
|
||||
// Grade formula combines performance + difficulty
|
||||
performanceScore = max(0, 10 - numGuesses)
|
||||
difficulty = 14 - popularity
|
||||
totalScore = performanceScore + difficulty * 0.8
|
||||
|
||||
// S: 14+, A: 11+, B: 8+, C: 5+, C-: <5
|
||||
```
|
||||
|
||||
**Hint System:**
|
||||
- ✅ Green checkmark: Exact match
|
||||
- 🟩 Green square: Section matches
|
||||
- 🟧 Orange square: Testament matches (shared results)
|
||||
- ‼️ Double exclamation: Adjacent book in Bible order
|
||||
- 🟥 Red square: No match
|
||||
**Hint System, for share grid:**
|
||||
- ✅ Exact match | 🟩 Section match | 🟧 Testament match | ‼️ Adjacent book | 🟥 No match
|
||||
|
||||
### Authentication System (`src/lib/server/auth.ts`)
|
||||
|
||||
- Token-based sessions with SHA-256 hashing
|
||||
- Cookies store session tokens, validated on each request
|
||||
- Hook in `src/hooks.server.ts` populates `event.locals.user` and `event.locals.session`
|
||||
- Note: Currently the schema includes user table but auth UI is not yet implemented
|
||||
- Token generation: base64-encoded random bytes; stored as SHA-256 hash in DB
|
||||
- Cookie name: `auth-session`
|
||||
- Anonymous users: identified by a client-generated ID; stats migrate on sign-up via `migrateAnonymousStats()`
|
||||
- Apple Sign-In supported via `appleId` field
|
||||
|
||||
### Stats & Streak (`src/routes/stats/`)
|
||||
|
||||
- Stats page requires auth; returns `requiresAuth: true` if unauthenticated
|
||||
- Streak calculated client-side by calling `GET /api/streak?userId=X&localDate=Y`
|
||||
- Streak walk-back: counts consecutive days backwards from `localDate` through completed dates
|
||||
- Minimum displayed streak is 2 (single-day streaks suppressed)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `POST /api/daily-verse` — Fetch verse for a specific date
|
||||
- `POST /api/submit-completion` — Submit game result with stats
|
||||
- `GET /api/streak?userId=X&localDate=Y` — Current streak for user
|
||||
- `GET /api/streak-percentile` — Streak percentile ranking
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/routes/+page.svelte` - Main game UI and client-side logic
|
||||
- `src/routes/+page.server.ts` - Server load function, fetches/caches daily verse
|
||||
- `src/lib/server/bible-api.ts` - External API integration for verse fetching
|
||||
- `src/lib/server/bible.ts` - Bible book utility functions
|
||||
- `src/lib/types/bible.ts` - Bible books data and TypeScript types
|
||||
- `src/lib/server/db/schema.ts` - Drizzle ORM schema definitions
|
||||
- `src/hooks.server.ts` - SvelteKit server hook for session validation
|
||||
- `src/routes/+page.svelte` — Main game UI and client-side logic
|
||||
- `src/routes/+page.server.ts` / `+page.ts` — Server load (verse) + client load (`ssr: false`)
|
||||
- `src/routes/stats/+page.svelte` / `+page.server.ts` — Stats UI and server calculations
|
||||
- `src/lib/server/auth.ts` — Session management, password hashing, anonymous migration
|
||||
- `src/lib/server/bible-api.ts` — Random verse fetching from local XML Bible
|
||||
- `src/lib/server/bible.ts` — Bible book utility functions
|
||||
- `src/lib/types/bible.ts` — Bible books data and TypeScript types
|
||||
- `src/lib/server/db/schema.ts` — Drizzle ORM schema
|
||||
- `src/hooks.server.ts` — Session validation hook; initializes ML embeddings
|
||||
- `tests/` — Bun test suites (timezone, game, bible, stats, share, auth migration)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required in `.env`:
|
||||
- `DATABASE_URL` - Path to SQLite database file (e.g., `./local.db`)
|
||||
- `DATABASE_URL` — Path to SQLite database file (e.g., `./local.db`)
|
||||
|
||||
## Deployment
|
||||
|
||||
The project uses `@sveltejs/adapter-node` for deployment. The build output is a Node.js server that can be run with systemd or similar process managers. See `bibdle.service` and `bibdle.socket` for systemd configuration.
|
||||
Uses `@sveltejs/adapter-node`. See `bibdle.service` systemd configuration.
|
||||
|
||||
## Important Notes
|
||||
## A Note
|
||||
|
||||
- Uses Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`) - not stores or reactive declarations
|
||||
- The schema includes authentication tables but the login/signup UI is not yet implemented
|
||||
- Daily verses are cached permanently in the database to ensure consistency
|
||||
- LocalStorage persists guesses per day using the key pattern `bibdle-guesses-${date}`
|
||||
- The game validates book IDs from the API against the hardcoded `bibleBooks` array
|
||||
The main developer of this project is still learning a lot about developing full-stack applications. If they ask you to do something, make sure they understand how it will be implemented before proceeding.
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
[Unit]
|
||||
Description=Bibdle SvelteKit App
|
||||
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
||||
Requires=bibdle.socket
|
||||
After=network-online.target bibdle.socket
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Environment=NODE_ENV=production
|
||||
Environment=ORIGIN=https://bibdle.com
|
||||
Environment=DATABASE_URL=local.db
|
||||
Environment=IDLE_TIMEOUT=60
|
||||
WorkingDirectory=/home/george/projects/bibdle
|
||||
ExecStart=/home/george/.nvm/versions/node/v24.12.0/bin/node build/index.js
|
||||
Environment=DATABASE_URL=prod.db
|
||||
Environment=IDLE_TIMEOUT=300
|
||||
Environment=PORT=5173
|
||||
WorkingDirectory=/home/xenia/projects/bibdle
|
||||
#ExecStart=/home/xenia/.nvm/versions/node/v24.13.0/bin/node build/index.js
|
||||
ExecStart=/home/xenia/.bun/bin/bun --bun build/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
[Socket]
|
||||
ListenStream=5173
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
13
bun.lock
13
bun.lock
@@ -7,11 +7,10 @@
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"marked": "^17.0.4",
|
||||
"xml2js": "^0.6.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@sveltejs/adapter-node": "^5.5.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
@@ -100,14 +99,6 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
|
||||
|
||||
"@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
@@ -404,6 +395,8 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["marked@17.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@sveltejs/adapter-node": "^5.5.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
@@ -38,6 +36,7 @@
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"marked": "^17.0.4",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,4 +32,4 @@ export const handle: Handle = handleAuth;
|
||||
|
||||
// Initialize embeddings on server start (runs once on module load)
|
||||
const verses = getAllNKJVVerses();
|
||||
await initializeEmbeddings(verses);
|
||||
// await initializeEmbeddings(verses);
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
</script>
|
||||
|
||||
<Container
|
||||
class="w-full p-3 sm:p-4 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
|
||||
class="w-full p-3 sm:p-4 bg-linear-to-br from-yellow-100/80 to-amber-200/80 dark:from-amber-900/40 dark:to-yellow-900/30 text-gray-800 dark:text-gray-100 shadow-md"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="font-bold mb-3 text-lg sm:text-xl">
|
||||
@@ -193,8 +193,8 @@
|
||||
? 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"
|
||||
: "bg-white/30 dark:bg-white/10 text-gray-400 border-gray-300 dark:border-gray-600 opacity-40"
|
||||
: "bg-white/80 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 text-gray-800 dark:text-gray-100 border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500 hover:shadow-md cursor-pointer"
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="inline-flex flex-col items-center bg-white/10 backdrop-blur-sm rounded-2xl border border-white/20 shadow-sm {className}"
|
||||
class="inline-flex flex-col items-center bg-white/10 dark:bg-white/5 backdrop-blur-sm rounded-2xl border border-white/20 dark:border-white/10 shadow-sm {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -52,28 +52,28 @@
|
||||
|
||||
<div class="w-full flex flex-col flex-1">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full flex-1"
|
||||
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm w-full flex-1"
|
||||
>
|
||||
{#if newVerseReady}
|
||||
<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 dark:text-gray-400 font-bold mb-2"
|
||||
>
|
||||
Next Verse In
|
||||
</p>
|
||||
<p class="text-4xl font-triodion font-black text-gray-800">Now</p>
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mt-2"
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mt-2"
|
||||
>
|
||||
(refresh page to see the new verse)
|
||||
</p>
|
||||
{:else}
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||
>
|
||||
Next Verse In
|
||||
</p>
|
||||
<p
|
||||
class="text-4xl font-triodion font-black text-gray-800 tabular-nums whitespace-nowrap"
|
||||
class="text-4xl font-triodion font-black text-gray-800 dark:text-gray-100 tabular-nums whitespace-nowrap"
|
||||
>
|
||||
{timeUntilNext}
|
||||
</p>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
||||
import SocialLinks from "$lib/components/SocialLinks.svelte";
|
||||
</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"
|
||||
class="flex flex-col items-center gap-2 bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-300 font-bold"
|
||||
>
|
||||
A project by George Powell & Silent Summit Co.
|
||||
</p>
|
||||
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||
@@ -26,56 +27,8 @@
|
||||
<!-- 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"
|
||||
data-umami-event="Bluesky clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Bluesky clicked")}
|
||||
>
|
||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400"></div>
|
||||
|
||||
<a
|
||||
href="https://x.com/pupperpowell"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Twitter"
|
||||
data-umami-event="Twitter clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Twitter clicked")}
|
||||
>
|
||||
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400"></div>
|
||||
|
||||
<a
|
||||
href="mailto:george+bibdle@silentsummit.co"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Send email"
|
||||
data-umami-event="Email clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Email clicked")}
|
||||
>
|
||||
<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 class="mt-8">
|
||||
<SocialLinks />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { enhance } from "$app/forms";
|
||||
import Button from "$lib/components/Button.svelte";
|
||||
|
||||
let { anonymousId }: { anonymousId: string | null } = $props();
|
||||
type User = {
|
||||
id: string;
|
||||
email?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
appleId?: string | null;
|
||||
} | null;
|
||||
|
||||
let {
|
||||
anonymousId,
|
||||
user,
|
||||
onSignIn,
|
||||
}: { anonymousId: string | null; user: User; onSignIn: () => void } = $props();
|
||||
|
||||
let seeding = $state(false);
|
||||
|
||||
async function seedHistory() {
|
||||
async function seedHistory(days: number = 10) {
|
||||
if (!browser || !anonymousId || seeding) return;
|
||||
seeding = true;
|
||||
try {
|
||||
const response = await fetch("/api/dev/seed-history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ anonymousId })
|
||||
body: JSON.stringify({ anonymousId, days })
|
||||
});
|
||||
const result = await response.json();
|
||||
alert(
|
||||
@@ -46,6 +59,35 @@
|
||||
<div class="border-t-2 border-gray-400"></div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<a
|
||||
href="/stats?{user
|
||||
? `userId=${user.id}`
|
||||
: `anonymousId=${anonymousId}`}&tz={encodeURIComponent(
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
)}"
|
||||
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
📊 View Stats
|
||||
</a>
|
||||
|
||||
{#if user}
|
||||
<form method="POST" action="/auth/logout" use:enhance class="w-full">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🚪 Sign Out
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
onclick={onSignIn}
|
||||
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🔐 Sign In
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -113,7 +155,15 @@
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={seedHistory}
|
||||
onclick={() => seedHistory(1)}
|
||||
disabled={seeding}
|
||||
class="w-full py-4 md:py-2"
|
||||
>
|
||||
{seeding ? "Seeding..." : "Add 1 Day of History"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={() => seedHistory(10)}
|
||||
disabled={seeding}
|
||||
class="w-full py-4 md:py-2"
|
||||
>
|
||||
|
||||
@@ -66,10 +66,10 @@
|
||||
|
||||
{#if !hasGuesses}
|
||||
<Container class="p-6 text-center">
|
||||
<h2 class="font-triodion text-xl italic mb-3 text-gray-800">
|
||||
<h2 class="font-triodion text-xl italic mb-3 text-gray-800 dark:text-gray-100">
|
||||
Instructions
|
||||
</h2>
|
||||
<p class="text-gray-700 leading-relaxed italic">
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed italic">
|
||||
Guess what book of the bible you think the verse is from. You will
|
||||
get clues to help you after each guess.
|
||||
</p>
|
||||
@@ -78,25 +78,25 @@
|
||||
<div class="space-y-3">
|
||||
<!-- Column Headers -->
|
||||
<div
|
||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
|
||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400 dark:border-gray-600"
|
||||
>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Testament
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Section
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
First Letter
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Book
|
||||
</div>
|
||||
|
||||
@@ -127,21 +127,17 @@
|
||||
</script>
|
||||
|
||||
{#if showBanner}
|
||||
<div
|
||||
class="mb-3 flex items-center gap-2 px-4 py-2 rounded-full text-xs font-medium border w-fit transition-all duration-300
|
||||
{bannerIsIndigo
|
||||
? 'bg-indigo-50 border-indigo-200 text-indigo-700'
|
||||
: 'bg-amber-50 border-amber-200 text-amber-700'}"
|
||||
<p
|
||||
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span aria-hidden="true" class="text-[10px] leading-none">✦</span>
|
||||
{#if bannerIsIndigo}
|
||||
Testament & section groups now visible
|
||||
{:else}
|
||||
Old & New Testament groups now visible
|
||||
{/if}
|
||||
</div>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
@@ -164,13 +160,13 @@
|
||||
<input
|
||||
bind:value={searchQuery}
|
||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||
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"
|
||||
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 dark:border-gray-600 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 dark:focus:border-blue-400 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-900/50 transition-all bg-white dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
||||
onkeydown={handleKeydown}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
onclick={() => (searchQuery = "")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
@@ -195,7 +191,7 @@
|
||||
|
||||
{#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"
|
||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-2xl shadow-xl"
|
||||
role="listbox"
|
||||
>
|
||||
{#if displayMode === "simple"}
|
||||
@@ -210,8 +206,8 @@
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-400'
|
||||
class="font-semibold dark:text-gray-100 {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
@@ -223,29 +219,29 @@
|
||||
{#each testamentGroups as group (group.testament)}
|
||||
<li role="presentation">
|
||||
<div
|
||||
class="px-5 py-2 flex items-center gap-3 bg-gray-50 border-b border-gray-100"
|
||||
class="px-5 py-2 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wider text-gray-400"
|
||||
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-200"></div>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each group.books as book (book.id)}
|
||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||
{guessedIds.has(book.id)
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-400'
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
@@ -261,40 +257,40 @@
|
||||
<li role="presentation">
|
||||
{#if group.showTestamentHeader}
|
||||
<div
|
||||
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 border-b border-gray-100"
|
||||
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold uppercase tracking-wider text-gray-500"
|
||||
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{group.testamentLabel}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-200"></div>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 border-b border-gray-100"
|
||||
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-[11px] font-medium uppercase tracking-wider text-gray-400"
|
||||
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{group.section}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-100"></div>
|
||||
<div class="flex-1 h-px bg-gray-100 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each group.books as book (book.id)}
|
||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||
{guessedIds.has(book.id)
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-400'
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
@@ -308,6 +304,6 @@
|
||||
{/if}
|
||||
</ul>
|
||||
{:else if searchQuery}
|
||||
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
|
||||
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">No books found</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
57
src/lib/components/SocialLinks.svelte
Normal file
57
src/lib/components/SocialLinks.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
||||
</script>
|
||||
|
||||
<div class="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"
|
||||
data-umami-event="Bluesky clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Bluesky clicked")}
|
||||
>
|
||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div>
|
||||
|
||||
<!-- <a
|
||||
href="https://x.com/pupperpowell"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Twitter"
|
||||
data-umami-event="Twitter clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Twitter clicked")}
|
||||
>
|
||||
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div> -->
|
||||
|
||||
<a
|
||||
href="mailto:george+bibdle@silentsummit.co"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Send email"
|
||||
data-umami-event="Email clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Email clicked")}
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-700 dark:text-gray-300"
|
||||
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>
|
||||
@@ -9,7 +9,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center justify-center bg-white/50 backdrop-blur-sm px-4 py-4 rounded-2xl border border-white/50 shadow-sm flex-1 text-center"
|
||||
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-4 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm flex-1 text-center"
|
||||
>
|
||||
<p
|
||||
class="text-5xl font-triodion font-black text-orange-500 leading-none tabular-nums"
|
||||
@@ -17,13 +17,13 @@
|
||||
{streak}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs uppercase justify-center tracking-widest text-gray-500 font-triodion font-bold py-2 leading-tight"
|
||||
class="text-xs uppercase justify-center tracking-widest text-gray-500 dark:text-gray-200 font-triodion font-bold py-2 leading-tight"
|
||||
>
|
||||
day{streak === 1 ? "" : "s"} in a row
|
||||
</p>
|
||||
{#if streakPercentile !== null && streakPercentile <= 50}
|
||||
<p
|
||||
class="text-xs text-black w-full tracking-widest uppercase font-semibold border-t border-t-stone-400 pt-2"
|
||||
class="text-xs text-black dark:text-gray-200 w-full tracking-widest uppercase font-semibold border-t border-t-stone-400 dark:border-t-stone-600 pt-2"
|
||||
>
|
||||
Top {streakPercentile}%
|
||||
</p>
|
||||
|
||||
62
src/lib/components/ThemeToggle.svelte
Normal file
62
src/lib/components/ThemeToggle.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let isDarkMode = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
const stored = localStorage.getItem('bibdle-theme');
|
||||
if (stored === 'dark') {
|
||||
isDarkMode = true;
|
||||
} else if (stored === 'light') {
|
||||
isDarkMode = false;
|
||||
} else {
|
||||
isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.classList.remove('light');
|
||||
localStorage.setItem('bibdle-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.classList.add('light');
|
||||
localStorage.setItem('bibdle-theme', 'light');
|
||||
}
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
isDarkMode = !isDarkMode;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if browser}
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
aria-label={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
class="flex items-center gap-2 p-1 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
{#if isDarkMode}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
<path d="M12 2v2"/>
|
||||
<path d="M12 20v2"/>
|
||||
<path d="m4.93 4.93 1.41 1.41"/>
|
||||
<path d="m17.66 17.66 1.41 1.41"/>
|
||||
<path d="M2 12h2"/>
|
||||
<path d="M20 12h2"/>
|
||||
<path d="m6.34 17.66-1.41 1.41"/>
|
||||
<path d="m19.07 4.93-1.41 1.41"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="text-xs uppercase tracking-widest">
|
||||
{isDarkMode ? 'Light mode' : 'Dark mode'}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -63,9 +63,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container class="w-full p-8 sm:p-12 bg-white/70 overflow-hidden">
|
||||
<Container
|
||||
class="w-full p-8 sm:p-12 bg-white/70 dark:bg-black/30 overflow-hidden"
|
||||
>
|
||||
<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 dark:text-gray-200 text-center"
|
||||
>
|
||||
{displayVerseText}
|
||||
</blockquote>
|
||||
@@ -76,7 +78,7 @@
|
||||
{#if showReference}
|
||||
<p
|
||||
transition:fade={{ duration: 400 }}
|
||||
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
||||
class="text-center text-lg! big-text text-green-600! dark:text-green-400! font-bold mt-8 bg-white/70 dark:bg-black/50 rounded-xl px-4 py-2"
|
||||
>
|
||||
{displayReference}
|
||||
</p>
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
verseText,
|
||||
streak = 0,
|
||||
streakPercentile = null,
|
||||
isLoggedIn = false,
|
||||
anonymousId = '',
|
||||
}: {
|
||||
statsData: StatsData | null;
|
||||
correctBookId: string;
|
||||
@@ -53,6 +55,8 @@
|
||||
verseText: string;
|
||||
streak?: number;
|
||||
streakPercentile?: number | null;
|
||||
isLoggedIn?: boolean;
|
||||
anonymousId?: string;
|
||||
} = $props();
|
||||
|
||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||
@@ -122,7 +126,7 @@
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<Container
|
||||
class="w-full px-4 sm:px-6 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
|
||||
class="w-full px-4 sm:px-6 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 dark:text-gray-100 shadow-2xl text-center fade-in"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
||||
@@ -168,7 +172,7 @@
|
||||
<!-- Statistics Display -->
|
||||
{#if statsData}
|
||||
<Container
|
||||
class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
||||
class="w-full p-4 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
||||
@@ -177,7 +181,7 @@
|
||||
<!-- Solve Rank Column -->
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||
>
|
||||
#{statsData.solveRank}
|
||||
</div>
|
||||
@@ -190,7 +194,7 @@
|
||||
<!-- Guess Rank Column -->
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||
>
|
||||
{toOrdinal(statsData.guessRank)}
|
||||
</div>
|
||||
@@ -212,7 +216,7 @@
|
||||
<!-- Average Column -->
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||
>
|
||||
{statsData.averageGuesses}
|
||||
</div>
|
||||
@@ -226,7 +230,7 @@
|
||||
</Container>
|
||||
{:else if !statsSubmitted}
|
||||
<Container
|
||||
class="w-full p-6 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
||||
class="w-full p-6 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
|
||||
>
|
||||
<div class="text-sm opacity-80">Submitting stats...</div>
|
||||
</Container>
|
||||
@@ -319,6 +323,24 @@
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isLoggedIn}
|
||||
<div class="signin-prompt">
|
||||
<p class="signin-text">Sign in to save your streak & see your stats</p>
|
||||
<form method="POST" action="/auth/apple">
|
||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="apple-signin-btn"
|
||||
>
|
||||
<svg class="apple-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||
</svg>
|
||||
Sign in with Apple
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -352,6 +374,13 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.share-card {
|
||||
background: oklch(22% 0.025 298.626);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.share-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -372,6 +401,12 @@
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.chat-window {
|
||||
--bg: oklch(22% 0.025 298.626);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Bubble wrappers ── */
|
||||
.bubble-wrapper {
|
||||
display: flex;
|
||||
@@ -525,6 +560,12 @@
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.copy-hint {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Snippet toggle row ── */
|
||||
.snippet-toggle-row {
|
||||
display: flex;
|
||||
@@ -541,6 +582,12 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.snippet-label {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.snippet-toggle {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
@@ -573,4 +620,71 @@
|
||||
.snippet-toggle.on .toggle-thumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* ── Apple Sign In prompt ── */
|
||||
.signin-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.signin-text {
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.signin-text {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
.apple-signin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1.5rem;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, transform 80ms ease;
|
||||
}
|
||||
|
||||
.apple-signin-btn:hover {
|
||||
background: #222;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.apple-signin-btn:active {
|
||||
background: #111;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.apple-signin-btn {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
.apple-signin-btn:hover {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
.apple-signin-btn:active {
|
||||
background: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.apple-icon {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
|
||||
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
||||
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
||||
|
||||
@@ -26,8 +24,8 @@ export async function generateAppleClientSecret(): Promise<string> {
|
||||
sub: Bun.env.APPLE_ID!
|
||||
};
|
||||
|
||||
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
|
||||
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
|
||||
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
// Import PEM private key
|
||||
@@ -55,7 +53,7 @@ export async function generateAppleClientSecret(): Promise<string> {
|
||||
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
||||
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
||||
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
||||
const encodedSignature = encodeBase64url(rawSignature);
|
||||
const encodedSignature = Buffer.from(rawSignature).toString('base64url');
|
||||
|
||||
return `${signingInput}.${encodedSignature}`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||
import { testDb as db } from '$lib/server/db/test';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
|
||||
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
|
||||
|
||||
export function generateSessionToken() {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||
const token = encodeBase64url(bytes);
|
||||
return token;
|
||||
return Buffer.from(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
export async function createSession(token: string, userId: string) {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||
const session: table.Session = {
|
||||
id: sessionId,
|
||||
userId,
|
||||
@@ -27,7 +24,7 @@ export async function createSession(token: string, userId: string) {
|
||||
}
|
||||
|
||||
export async function validateSessionToken(token: string) {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||
const [result] = await db
|
||||
.select({
|
||||
// Adjust user table here to tweak returned data
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
|
||||
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
|
||||
|
||||
export function generateSessionToken() {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||
const token = encodeBase64url(bytes);
|
||||
return token;
|
||||
return Buffer.from(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
export async function createSession(token: string, userId: string) {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||
const session: table.Session = {
|
||||
id: sessionId,
|
||||
userId,
|
||||
@@ -27,7 +24,7 @@ export async function createSession(token: string, userId: string) {
|
||||
}
|
||||
|
||||
export async function validateSessionToken(token: string) {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||
const [result] = await db
|
||||
.select({
|
||||
// Adjust user table here to tweak returned data
|
||||
|
||||
@@ -17,75 +17,73 @@ export interface BibleBook {
|
||||
testament: Testament;
|
||||
section: BibleSection;
|
||||
order: number;
|
||||
url: string;
|
||||
popularity: number;
|
||||
}
|
||||
|
||||
export const bibleBooks: BibleBook[] = [
|
||||
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1, url: 'https://bible-api.com/data/web/GEN', popularity: 8 },
|
||||
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2, url: 'https://bible-api.com/data/web/EXO', popularity: 3 },
|
||||
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3, url: 'https://bible-api.com/data/web/LEV', popularity: 2 },
|
||||
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4, url: 'https://bible-api.com/data/web/NUM', popularity: 2 },
|
||||
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5, url: 'https://bible-api.com/data/web/DEU', popularity: 2 },
|
||||
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6, url: 'https://bible-api.com/data/web/JOS', popularity: 2 },
|
||||
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7, url: 'https://bible-api.com/data/web/JDG', popularity: 2 },
|
||||
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8, url: 'https://bible-api.com/data/web/RUT', popularity: 2 },
|
||||
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9, url: 'https://bible-api.com/data/web/1SA', popularity: 1 },
|
||||
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10, url: 'https://bible-api.com/data/web/2SA', popularity: 0 },
|
||||
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11, url: 'https://bible-api.com/data/web/1KI', popularity: 1 },
|
||||
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12, url: 'https://bible-api.com/data/web/2KI', popularity: 0 },
|
||||
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13, url: 'https://bible-api.com/data/web/1CH', popularity: 1 },
|
||||
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14, url: 'https://bible-api.com/data/web/2CH', popularity: 0 },
|
||||
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15, url: 'https://bible-api.com/data/web/EZR', popularity: 1 },
|
||||
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16, url: 'https://bible-api.com/data/web/NEH', popularity: 1 },
|
||||
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17, url: 'https://bible-api.com/data/web/EST', popularity: 1 },
|
||||
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18, url: 'https://bible-api.com/data/web/JOB', popularity: 2 },
|
||||
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19, url: 'https://bible-api.com/data/web/PSA', popularity: 7 },
|
||||
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20, url: 'https://bible-api.com/data/web/PRO', popularity: 7 },
|
||||
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21, url: 'https://bible-api.com/data/web/ECC', popularity: 2 },
|
||||
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22, url: 'https://bible-api.com/data/web/SNG', popularity: 2 },
|
||||
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23, url: 'https://bible-api.com/data/web/ISA', popularity: 2 },
|
||||
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24, url: 'https://bible-api.com/data/web/JER', popularity: 2 },
|
||||
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25, url: 'https://bible-api.com/data/web/LAM', popularity: 2 },
|
||||
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26, url: 'https://bible-api.com/data/web/EZK', popularity: 2 },
|
||||
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27, url: 'https://bible-api.com/data/web/DAN', popularity: 2 },
|
||||
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28, url: 'https://bible-api.com/data/web/HOS', popularity: 2 },
|
||||
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29, url: 'https://bible-api.com/data/web/JOL', popularity: 2 },
|
||||
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30, url: 'https://bible-api.com/data/web/AMO', popularity: 2 },
|
||||
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31, url: 'https://bible-api.com/data/web/OBA', popularity: 2 },
|
||||
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32, url: 'https://bible-api.com/data/web/JON', popularity: 2 },
|
||||
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33, url: 'https://bible-api.com/data/web/MIC', popularity: 2 },
|
||||
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34, url: 'https://bible-api.com/data/web/NAM', popularity: 2 },
|
||||
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35, url: 'https://bible-api.com/data/web/HAB', popularity: 2 },
|
||||
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36, url: 'https://bible-api.com/data/web/ZEP', popularity: 2 },
|
||||
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37, url: 'https://bible-api.com/data/web/HAG', popularity: 2 },
|
||||
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38, url: 'https://bible-api.com/data/web/ZEC', popularity: 2 },
|
||||
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39, url: 'https://bible-api.com/data/web/MAL', popularity: 2 },
|
||||
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40, url: 'https://bible-api.com/data/web/MAT', popularity: 8 },
|
||||
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41, url: 'https://bible-api.com/data/web/MRK', popularity: 8 },
|
||||
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42, url: 'https://bible-api.com/data/web/LUK', popularity: 8 },
|
||||
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43, url: 'https://bible-api.com/data/web/JHN', popularity: 8 },
|
||||
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44, url: 'https://bible-api.com/data/web/ACT', popularity: 2 },
|
||||
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45, url: 'https://bible-api.com/data/web/ROM', popularity: 6 },
|
||||
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46, url: 'https://bible-api.com/data/web/1CO', popularity: 5 },
|
||||
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47, url: 'https://bible-api.com/data/web/2CO', popularity: 5 },
|
||||
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48, url: 'https://bible-api.com/data/web/GAL', popularity: 5 },
|
||||
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49, url: 'https://bible-api.com/data/web/EPH', popularity: 5 },
|
||||
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50, url: 'https://bible-api.com/data/web/PHP', popularity: 5 },
|
||||
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51, url: 'https://bible-api.com/data/web/COL', popularity: 5 },
|
||||
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52, url: 'https://bible-api.com/data/web/1TH', popularity: 5 },
|
||||
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53, url: 'https://bible-api.com/data/web/2TH', popularity: 5 },
|
||||
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54, url: 'https://bible-api.com/data/web/1TI', popularity: 5 },
|
||||
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55, url: 'https://bible-api.com/data/web/2TI', popularity: 5 },
|
||||
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56, url: 'https://bible-api.com/data/web/TIT', popularity: 5 },
|
||||
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57, url: 'https://bible-api.com/data/web/PHM', popularity: 5 },
|
||||
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58, url: 'https://bible-api.com/data/web/HEB', popularity: 4 },
|
||||
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59, url: 'https://bible-api.com/data/web/JAS', popularity: 4 },
|
||||
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60, url: 'https://bible-api.com/data/web/1PE', popularity: 4 },
|
||||
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61, url: 'https://bible-api.com/data/web/2PE', popularity: 4 },
|
||||
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62, url: 'https://bible-api.com/data/web/1JN', popularity: 4 },
|
||||
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63, url: 'https://bible-api.com/data/web/2JN', popularity: 4 },
|
||||
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64, url: 'https://bible-api.com/data/web/3JN', popularity: 4 },
|
||||
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65, url: 'https://bible-api.com/data/web/JUD', popularity: 4 },
|
||||
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66, url: 'https://bible-api.com/data/web/REV', popularity: 2 }
|
||||
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1 },
|
||||
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2 },
|
||||
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3 },
|
||||
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4 },
|
||||
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5 },
|
||||
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6 },
|
||||
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7 },
|
||||
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8 },
|
||||
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9 },
|
||||
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10 },
|
||||
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11 },
|
||||
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12 },
|
||||
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13 },
|
||||
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14 },
|
||||
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15 },
|
||||
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16 },
|
||||
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17 },
|
||||
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18 },
|
||||
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19 },
|
||||
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20 },
|
||||
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21 },
|
||||
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22 },
|
||||
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23 },
|
||||
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24 },
|
||||
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25 },
|
||||
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26 },
|
||||
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27 },
|
||||
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28 },
|
||||
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29 },
|
||||
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30 },
|
||||
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31 },
|
||||
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32 },
|
||||
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33 },
|
||||
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34 },
|
||||
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35 },
|
||||
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36 },
|
||||
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37 },
|
||||
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38 },
|
||||
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39 },
|
||||
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40 },
|
||||
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41 },
|
||||
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42 },
|
||||
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43 },
|
||||
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44 },
|
||||
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45 },
|
||||
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46 },
|
||||
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47 },
|
||||
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48 },
|
||||
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49 },
|
||||
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50 },
|
||||
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51 },
|
||||
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52 },
|
||||
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53 },
|
||||
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54 },
|
||||
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55 },
|
||||
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56 },
|
||||
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57 },
|
||||
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58 },
|
||||
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59 },
|
||||
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60 },
|
||||
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61 },
|
||||
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62 },
|
||||
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63 },
|
||||
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64 },
|
||||
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65 },
|
||||
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66 }
|
||||
];
|
||||
|
||||
@@ -79,7 +79,7 @@ export function getNextGradeMessage(numGuesses: number): string {
|
||||
}
|
||||
|
||||
export function toOrdinal(n: number): string {
|
||||
if (n >= 11 && n <= 13) {
|
||||
if (n % 100 >= 11 && n % 100 <= 13) {
|
||||
return `${n}th`;
|
||||
}
|
||||
const mod = n % 10;
|
||||
|
||||
@@ -78,9 +78,8 @@ export function generateShareText(params: {
|
||||
|
||||
const lines = [
|
||||
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
||||
`${guesses.length} ${guessWord},${streakPart}`,
|
||||
`${emojis}${chapterStar}`,
|
||||
origin,
|
||||
`${guesses.length} ${guessWord}${streakPart ? `,${streakPart}` : ""}`,
|
||||
`${emojis}${chapterStar}`
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.ico";
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.ico";
|
||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
onMount(() => {
|
||||
// Inject analytics script
|
||||
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();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||
<meta name="description" content="A daily Bible game" />
|
||||
</svelte:head>
|
||||
{@render children()}
|
||||
|
||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950">
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 dark:text-gray-300 drop-shadow-2xl tracking-widest p-4 pt-12 animate-fade-in-up"
|
||||
>
|
||||
<TitleAnimation />
|
||||
<div class="font-normal"></div>
|
||||
</h1>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import GuessesTable from "$lib/components/GuessesTable.svelte";
|
||||
import WinScreen from "$lib/components/WinScreen.svelte";
|
||||
import Credits from "$lib/components/Credits.svelte";
|
||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
import DevButtons from "$lib/components/DevButtons.svelte";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
|
||||
@@ -35,6 +35,15 @@
|
||||
let user = $derived(data.user);
|
||||
let session = $derived(data.session);
|
||||
|
||||
const currentDate = $derived(
|
||||
new Date().toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
);
|
||||
|
||||
let searchQuery = $state("");
|
||||
let copied = $state(false);
|
||||
let isDev = $state(false);
|
||||
@@ -55,15 +64,6 @@
|
||||
new SvelteSet(persistence.guesses.map((g) => g.book.id)),
|
||||
);
|
||||
|
||||
const currentDate = $derived(
|
||||
new Date().toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
);
|
||||
|
||||
let isWon = $derived(
|
||||
persistence.guesses.some((g) => g.book.id === correctBookId),
|
||||
);
|
||||
@@ -283,20 +283,13 @@
|
||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
|
||||
<div class="pb-8">
|
||||
<div class="w-full max-w-3xl mx-auto px-4">
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4 animate-fade-in-up"
|
||||
>
|
||||
<TitleAnimation />
|
||||
<div class="font-normal"></div>
|
||||
</h1>
|
||||
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
|
||||
<span class="big-text"
|
||||
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="animate-fade-in-up animate-delay-200">
|
||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||
@@ -304,7 +297,12 @@
|
||||
|
||||
{#if !isWon}
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} guessCount={persistence.guesses.length} />
|
||||
<SearchInput
|
||||
bind:searchQuery
|
||||
{guessedIds}
|
||||
{submitGuess}
|
||||
guessCount={persistence.guesses.length}
|
||||
/>
|
||||
</div>
|
||||
{:else if showWinScreen}
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
@@ -322,6 +320,8 @@
|
||||
verseText={dailyVerse.verseText}
|
||||
{streak}
|
||||
{streakPercentile}
|
||||
isLoggedIn={!!user}
|
||||
anonymousId={persistence.anonymousId}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -335,47 +335,16 @@
|
||||
<Credits />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- We will just go with the user's system color theme for now. -->
|
||||
<div class="flex justify-center hidden mt-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
{#if isDev}
|
||||
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<a
|
||||
href="/stats?{user
|
||||
? `userId=${user.id}`
|
||||
: `anonymousId=${persistence.anonymousId}`}&tz={encodeURIComponent(
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
)}"
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
📊 View Stats
|
||||
</a>
|
||||
|
||||
{#if user}
|
||||
<form
|
||||
method="POST"
|
||||
action="/auth/logout"
|
||||
use:enhance
|
||||
class="w-full md:w-auto"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🚪 Sign Out
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (authModalOpen = true)}
|
||||
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🔐 Sign In
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded border"
|
||||
class="text-xs text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded border dark:border-gray-700"
|
||||
>
|
||||
<div><strong>Debug Info:</strong></div>
|
||||
<div>
|
||||
@@ -407,13 +376,13 @@
|
||||
<div>Daily Verse Date: {dailyVerse.date}</div>
|
||||
<div>Streak: {streak}</div>
|
||||
</div>
|
||||
<DevButtons anonymousId={persistence.anonymousId} />
|
||||
<DevButtons anonymousId={persistence.anonymousId} {user} onSignIn={() => (authModalOpen = true)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if user && session}
|
||||
<div
|
||||
class="mt-6 pt-4 border-t border-gray-200 text-center text-xs text-gray-400"
|
||||
class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
Signed in as {[user.firstName, user.lastName]
|
||||
.filter(Boolean)
|
||||
|
||||
13
src/routes/about/+page.server.ts
Normal file
13
src/routes/about/+page.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { marked } from 'marked';
|
||||
|
||||
export async function load() {
|
||||
const about = readFileSync(resolve('static/about.md'), 'utf-8');
|
||||
const howToPlay = readFileSync(resolve('static/how-to-play.md'), 'utf-8');
|
||||
|
||||
return {
|
||||
about: await marked(about),
|
||||
howToPlay: await marked(howToPlay)
|
||||
};
|
||||
}
|
||||
48
src/routes/about/+page.svelte
Normal file
48
src/routes/about/+page.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<svelte:head>
|
||||
<title>About — Bibdle</title>
|
||||
</svelte:head>
|
||||
|
||||
<script lang="ts">
|
||||
import SocialLinks from "$lib/components/SocialLinks.svelte";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const SOCIAL_PLACEHOLDER = "<!-- social -->";
|
||||
|
||||
const aboutParts = $derived(
|
||||
data.about.includes(SOCIAL_PLACEHOLDER)
|
||||
? data.about.split(SOCIAL_PLACEHOLDER)
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="min-h-dvh py-10 px-4">
|
||||
<div class="w-full max-w-xl mx-auto">
|
||||
|
||||
<div class="mb-8">
|
||||
<a
|
||||
href="/"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="prose dark:prose-invert text-justify max-w-none">
|
||||
{#if aboutParts}
|
||||
{@html aboutParts[0]}
|
||||
<div class="my-8 not-prose">
|
||||
<SocialLinks />
|
||||
</div>
|
||||
{@html aboutParts[1]}
|
||||
{:else}
|
||||
{@html data.about}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="prose dark:prose-invert text-justify max-w-none mt-10">
|
||||
{@html data.howToPlay}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,7 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const { anonymousId } = await request.json();
|
||||
const { anonymousId, days = 10 } = await request.json();
|
||||
|
||||
if (!anonymousId || typeof anonymousId !== 'string') {
|
||||
return json({ error: 'anonymousId required' }, { status: 400 });
|
||||
@@ -29,7 +29,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
const inserted: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
for (let i = 1; i <= days; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const date = d.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
|
||||
@@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
error(400, 'Missing anonymousId or localDate');
|
||||
}
|
||||
|
||||
// Fetch all completion dates for this user, newest first
|
||||
// Fetch all completion dates for this user (stored as the user's local date)
|
||||
const rows = await db
|
||||
.select({ date: dailyCompletions.date })
|
||||
.from(dailyCompletions)
|
||||
@@ -21,16 +21,22 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
|
||||
const completedDates = new Set(rows.map((r) => r.date));
|
||||
|
||||
// Walk backwards from localDate, counting consecutive completed days
|
||||
let streak = 0;
|
||||
let cursor = new Date(`${localDate}T00:00:00`);
|
||||
|
||||
while (true) {
|
||||
const dateStr = cursor.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
if (!completedDates.has(dateStr)) break;
|
||||
streak++;
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
// Subtract one calendar day from a YYYY-MM-DD string using UTC arithmetic —
|
||||
// this avoids any dependence on the server's local timezone or DST offsets.
|
||||
function prevDay(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
return json({ streak });
|
||||
// Walk backwards from the user's local date, counting consecutive completed days
|
||||
let streak = 0;
|
||||
let cursor = localDate;
|
||||
|
||||
while (completedDates.has(cursor)) {
|
||||
streak++;
|
||||
cursor = prevDay(cursor);
|
||||
}
|
||||
|
||||
return json({ streak: streak < 2 ? 0 : streak });
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
||||
import { encodeBase64url } from '@oslojs/encoding';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ cookies, request }) => {
|
||||
@@ -10,7 +9,7 @@ export const actions: Actions = {
|
||||
|
||||
// Generate CSRF state
|
||||
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
const state = encodeBase64url(stateBytes);
|
||||
const state = Buffer.from(stateBytes).toString('base64url');
|
||||
|
||||
// Store state + anonymousId in a short-lived cookie
|
||||
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
||||
|
||||
@@ -2,12 +2,29 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--font-triodion: "PT Serif", serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: oklch(89.126% 0.06134 298.626);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not(.light), body:not(.light) {
|
||||
background: oklch(18% 0.03 298.626);
|
||||
}
|
||||
}
|
||||
|
||||
html.dark, html.dark body {
|
||||
background: oklch(18% 0.03 298.626);
|
||||
}
|
||||
|
||||
html.light, html.light body {
|
||||
background: oklch(89.126% 0.06134 298.626);
|
||||
}
|
||||
|
||||
.big-text {
|
||||
@@ -18,6 +35,20 @@ html, body {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not(.light) .big-text {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .big-text {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
|
||||
html.light .big-text {
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
|
||||
/* Page load animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
@@ -48,4 +79,4 @@ html, body {
|
||||
|
||||
.animate-delay-800 {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
}
|
||||
|
||||
23
src/routes/sitemap.xml/+server.ts
Normal file
23
src/routes/sitemap.xml/+server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = () => {
|
||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://bibdle.com/</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://bibdle.com/about</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
</urlset>`;
|
||||
|
||||
return new Response(sitemap, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -27,9 +27,9 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Get user's current date from timezone query param
|
||||
const timezone = url.searchParams.get('tz') || 'UTC';
|
||||
const userToday = new Date().toLocaleDateString('en-CA', { timeZone: timezone });
|
||||
// Note: userToday is used only for the initial server-side streak estimate.
|
||||
// The client overrides this with a precise local-date calculation via /api/streak.
|
||||
const userToday = new Date().toISOString().slice(0, 10); // UTC date as safe fallback
|
||||
|
||||
try {
|
||||
// Get all completions for this user
|
||||
@@ -85,7 +85,7 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
|
||||
};
|
||||
|
||||
// Calculate streaks
|
||||
// Calculate streaks — dates are stored as the user's local date
|
||||
const sortedDates = completions
|
||||
.map((c: DailyCompletion) => c.date)
|
||||
.sort();
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import { enhance } from "$app/forms";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
import {
|
||||
getGradeColor,
|
||||
formatDate,
|
||||
getStreakMessage,
|
||||
getPerformanceMessage,
|
||||
type UserStats,
|
||||
} from "$lib/utils/stats";
|
||||
import { fetchStreak } from "$lib/utils/streak";
|
||||
|
||||
interface PageData {
|
||||
stats: UserStats | null;
|
||||
@@ -27,6 +23,7 @@
|
||||
let anonymousId = $state("");
|
||||
|
||||
let loading = $state(true);
|
||||
let currentStreak = $state(0);
|
||||
|
||||
function getOrCreateAnonymousId(): string {
|
||||
if (!browser) return "";
|
||||
@@ -41,13 +38,15 @@
|
||||
|
||||
onMount(async () => {
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
if (data.user?.id) {
|
||||
const localDate = new Date().toLocaleDateString("en-CA");
|
||||
currentStreak = await fetchStreak(data.user.id, localDate);
|
||||
} else {
|
||||
currentStreak = data.stats?.currentStreak ?? 0;
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function getGradePercentage(count: number, total: number): number {
|
||||
return total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
}
|
||||
|
||||
function getBookName(bookId: string): string {
|
||||
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
||||
}
|
||||
@@ -160,7 +159,7 @@
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
||||
>
|
||||
{stats.currentStreak}
|
||||
{currentStreak}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
@@ -333,83 +332,7 @@
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<!-- Grade Distribution -->
|
||||
<Container class="p-5 md:p-6 mb-6">
|
||||
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">
|
||||
Grade Distribution
|
||||
</h2>
|
||||
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
|
||||
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
|
||||
{@const percentage = getGradePercentage(
|
||||
count,
|
||||
stats.totalSolves,
|
||||
)}
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<span
|
||||
class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md:text-sm font-semibold {getGradeColor(
|
||||
grade,
|
||||
)}"
|
||||
>
|
||||
{grade}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-lg md:text-2xl font-bold text-gray-100"
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
{percentage}%
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Recent Performance -->
|
||||
{#if stats.recentCompletions.length > 0}
|
||||
<Container class="p-5 md:p-6">
|
||||
<h2
|
||||
class="text-lg md:text-xl font-bold text-gray-100 mb-4"
|
||||
>
|
||||
Recent Performance
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{#each stats.recentCompletions as completion, idx (`${completion.date}-${idx}`)}
|
||||
<div
|
||||
class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="text-sm md:text-base font-medium text-gray-200"
|
||||
>{formatDate(completion.date)}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 md:gap-3"
|
||||
>
|
||||
<span
|
||||
class="text-xs md:text-sm text-gray-300"
|
||||
>{completion.guessCount} guess{completion.guessCount ===
|
||||
1
|
||||
? ""
|
||||
: "es"}</span
|
||||
>
|
||||
<span
|
||||
class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(
|
||||
completion.grade,
|
||||
)}"
|
||||
>
|
||||
{completion.grade}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
15
static/about.md
Normal file
15
static/about.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# About Bibdle
|
||||
|
||||
Bibdle is a daily Bible guessing game. Every day, a random verse is posted to the website. Try to figure out which book of the Bible it comes from, in as few guesses as possible. That's it!
|
||||
|
||||
---
|
||||
|
||||
The game was built with the hope that it would be a small, delightful thing people can make part of their day. It's not a Bible study course. You don't need to know the Bible inside and out to enjoy it or to learn something from it.
|
||||
|
||||
If you're someone who grew up in church and can name all 66 books in order, great. If you're someone who can barely tell the Old Testament from the New, that's great too. The game meets you where you are.
|
||||
|
||||
It is completely free. If you'd like to support the developer (who works solely on small projects like this one) or express your thanks, you can become a Bibdle patron.
|
||||
|
||||
If you use Bibdle, I would love to hear from you! I can be reached via email or through Bluesky.
|
||||
|
||||
<!-- social -->
|
||||
47
static/how-to-play.md
Normal file
47
static/how-to-play.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# How to Play
|
||||
|
||||
Each day, Bibdle gives you a verse from the Bible. Your job is to guess which book it comes from. (Genesis, John, Corinthians, etc.)
|
||||
|
||||
You have unlimited guesses.
|
||||
|
||||
---
|
||||
|
||||
## The Basics
|
||||
|
||||
1. **Read the verse.** It appears at the top of the page.
|
||||
2. **Make a guess.** Type or select a book of the Bible from the list.
|
||||
3. **Read the feedback.** After each guess, you'll get clues telling you how close you were.
|
||||
4. **Keep guessing** until you get it right.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Feedback Hints
|
||||
|
||||
After each wrong guess, you'll see the following hints:
|
||||
|
||||
| Hint | What it means |
|
||||
|---|---|
|
||||
| **Testament** | If your guess was in the correct Testament (Old or New) |
|
||||
| **Section** | If your guess was in the correct section of the Bible (e.g. Gospels, Epistles, Major Prophets) |
|
||||
| **First Letter** | If your guess has the same first letter as the correct guess |
|
||||
|
||||
Use the hints to narrow down your search.
|
||||
|
||||
---
|
||||
|
||||
## A Few Things to Know
|
||||
|
||||
- **Everyone plays the same verse each day.** The daily verse resets at midnight.
|
||||
- **Your progress is saved automatically.** You can close the tab and come back later.
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- Pay attention to writing style: the voice of Psalms is very different from Paul's letters.
|
||||
- Historical narrative (battles, kings, genealogies) tends to be Old Testament.
|
||||
- Short, poetic, or wisdom-focused verses could be Proverbs, Ecclesiastes, or Psalms.
|
||||
- If a verse mentions Jesus by name, it's in the New Testament.
|
||||
|
||||
Good luck!
|
||||
@@ -1,3 +1,5 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
||||
Sitemap: https://bibdle.com/sitemap.xml
|
||||
|
||||
234
tests/bible.test.ts
Normal file
234
tests/bible.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
getBookById,
|
||||
getBookByNumber,
|
||||
getBooksByTestament,
|
||||
getBooksBySection,
|
||||
isAdjacent,
|
||||
bookNumberToId,
|
||||
bookIdToNumber,
|
||||
bibleBooks,
|
||||
} from "$lib/server/bible";
|
||||
|
||||
describe("bibleBooks data integrity", () => {
|
||||
test("contains exactly 66 books", () => {
|
||||
expect(bibleBooks).toHaveLength(66);
|
||||
});
|
||||
|
||||
test("order numbers are 1 through 66 with no gaps or duplicates", () => {
|
||||
const orders = bibleBooks.map((b) => b.order).sort((a, b) => a - b);
|
||||
for (let i = 0; i < 66; i++) {
|
||||
expect(orders[i]).toBe(i + 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("book IDs are unique", () => {
|
||||
const ids = bibleBooks.map((b) => b.id);
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(66);
|
||||
});
|
||||
|
||||
test("every book has a non-empty name", () => {
|
||||
for (const book of bibleBooks) {
|
||||
expect(book.name.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("Old Testament has 39 books", () => {
|
||||
expect(bibleBooks.filter((b) => b.testament === "old")).toHaveLength(39);
|
||||
});
|
||||
|
||||
test("New Testament has 27 books", () => {
|
||||
expect(bibleBooks.filter((b) => b.testament === "new")).toHaveLength(27);
|
||||
});
|
||||
|
||||
test("Genesis is first and Revelation is last", () => {
|
||||
const sorted = [...bibleBooks].sort((a, b) => a.order - b.order);
|
||||
expect(sorted[0].id).toBe("GEN");
|
||||
expect(sorted[65].id).toBe("REV");
|
||||
});
|
||||
|
||||
test("Matthew is the first New Testament book", () => {
|
||||
const nt = bibleBooks
|
||||
.filter((b) => b.testament === "new")
|
||||
.sort((a, b) => a.order - b.order);
|
||||
expect(nt[0].id).toBe("MAT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("section counts", () => {
|
||||
test("Law: 5 books", () => {
|
||||
expect(getBooksBySection("Law")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("History: 13 books (12 OT + Acts)", () => {
|
||||
expect(getBooksBySection("History")).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("Wisdom: 5 books", () => {
|
||||
expect(getBooksBySection("Wisdom")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("Major Prophets: 5 books", () => {
|
||||
expect(getBooksBySection("Major Prophets")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("Minor Prophets: 12 books", () => {
|
||||
expect(getBooksBySection("Minor Prophets")).toHaveLength(12);
|
||||
});
|
||||
|
||||
test("Gospels: 4 books", () => {
|
||||
expect(getBooksBySection("Gospels")).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("Pauline Epistles: 13 books", () => {
|
||||
expect(getBooksBySection("Pauline Epistles")).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("General Epistles: 8 books", () => {
|
||||
expect(getBooksBySection("General Epistles")).toHaveLength(8);
|
||||
});
|
||||
|
||||
test("Apocalyptic: 1 book (Revelation)", () => {
|
||||
const books = getBooksBySection("Apocalyptic");
|
||||
expect(books).toHaveLength(1);
|
||||
expect(books[0].id).toBe("REV");
|
||||
});
|
||||
|
||||
test("all sections sum to 66", () => {
|
||||
const total =
|
||||
getBooksBySection("Law").length +
|
||||
getBooksBySection("History").length +
|
||||
getBooksBySection("Wisdom").length +
|
||||
getBooksBySection("Major Prophets").length +
|
||||
getBooksBySection("Minor Prophets").length +
|
||||
getBooksBySection("Gospels").length +
|
||||
getBooksBySection("Pauline Epistles").length +
|
||||
getBooksBySection("General Epistles").length +
|
||||
getBooksBySection("Apocalyptic").length;
|
||||
expect(total).toBe(66);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBookById", () => {
|
||||
test("returns Genesis for GEN", () => {
|
||||
const book = getBookById("GEN");
|
||||
expect(book).toBeDefined();
|
||||
expect(book!.name).toBe("Genesis");
|
||||
});
|
||||
|
||||
test("returns Revelation for REV", () => {
|
||||
const book = getBookById("REV");
|
||||
expect(book!.name).toBe("Revelation");
|
||||
});
|
||||
|
||||
test("returns undefined for unknown ID", () => {
|
||||
expect(getBookById("UNKNOWN")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBookByNumber", () => {
|
||||
test("1 → Genesis", () => {
|
||||
expect(getBookByNumber(1)!.id).toBe("GEN");
|
||||
});
|
||||
|
||||
test("66 → Revelation", () => {
|
||||
expect(getBookByNumber(66)!.id).toBe("REV");
|
||||
});
|
||||
|
||||
test("40 → Matthew (first NT book)", () => {
|
||||
expect(getBookByNumber(40)!.id).toBe("MAT");
|
||||
});
|
||||
|
||||
test("0 → undefined", () => {
|
||||
expect(getBookByNumber(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("67 → undefined", () => {
|
||||
expect(getBookByNumber(67)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBooksByTestament", () => {
|
||||
test("old returns 39 books", () => {
|
||||
expect(getBooksByTestament("old")).toHaveLength(39);
|
||||
});
|
||||
|
||||
test("new returns 27 books", () => {
|
||||
expect(getBooksByTestament("new")).toHaveLength(27);
|
||||
});
|
||||
|
||||
test("all OT books have testament = old", () => {
|
||||
for (const book of getBooksByTestament("old")) {
|
||||
expect(book.testament).toBe("old");
|
||||
}
|
||||
});
|
||||
|
||||
test("all NT books have testament = new", () => {
|
||||
for (const book of getBooksByTestament("new")) {
|
||||
expect(book.testament).toBe("new");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAdjacent", () => {
|
||||
test("Genesis and Exodus are adjacent", () => {
|
||||
expect(isAdjacent("GEN", "EXO")).toBe(true);
|
||||
});
|
||||
|
||||
test("adjacency is symmetric", () => {
|
||||
expect(isAdjacent("EXO", "GEN")).toBe(true);
|
||||
});
|
||||
|
||||
test("Malachi and Matthew are adjacent across testament boundary", () => {
|
||||
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
|
||||
});
|
||||
|
||||
test("Jude and Revelation are adjacent", () => {
|
||||
expect(isAdjacent("JUD", "REV")).toBe(true); // 65, 66
|
||||
});
|
||||
|
||||
test("same book is not adjacent to itself", () => {
|
||||
expect(isAdjacent("GEN", "GEN")).toBe(false);
|
||||
});
|
||||
|
||||
test("books two apart are not adjacent", () => {
|
||||
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
|
||||
});
|
||||
|
||||
test("returns false for invalid IDs", () => {
|
||||
expect(isAdjacent("FAKE", "GEN")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bookNumberToId / bookIdToNumber lookup tables", () => {
|
||||
test("bookNumberToId[1] is GEN", () => {
|
||||
expect(bookNumberToId[1]).toBe("GEN");
|
||||
});
|
||||
|
||||
test("bookNumberToId[66] is REV", () => {
|
||||
expect(bookNumberToId[66]).toBe("REV");
|
||||
});
|
||||
|
||||
test("bookIdToNumber['GEN'] is 1", () => {
|
||||
expect(bookIdToNumber["GEN"]).toBe(1);
|
||||
});
|
||||
|
||||
test("bookIdToNumber['REV'] is 66", () => {
|
||||
expect(bookIdToNumber["REV"]).toBe(66);
|
||||
});
|
||||
|
||||
test("round-trip: number → ID → number", () => {
|
||||
for (let i = 1; i <= 66; i++) {
|
||||
const id = bookNumberToId[i];
|
||||
expect(bookIdToNumber[id]).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
test("round-trip: ID → number → ID", () => {
|
||||
for (const book of bibleBooks) {
|
||||
const num = bookIdToNumber[book.id];
|
||||
expect(bookNumberToId[num]).toBe(book.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
271
tests/game.test.ts
Normal file
271
tests/game.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
evaluateGuess,
|
||||
getBookById,
|
||||
getFirstLetter,
|
||||
getGrade,
|
||||
getNextGradeMessage,
|
||||
isAdjacent,
|
||||
toOrdinal,
|
||||
} from "$lib/utils/game";
|
||||
|
||||
describe("getBookById", () => {
|
||||
test("returns correct book for a valid ID", () => {
|
||||
const book = getBookById("GEN");
|
||||
expect(book).toBeDefined();
|
||||
expect(book!.name).toBe("Genesis");
|
||||
expect(book!.order).toBe(1);
|
||||
});
|
||||
|
||||
test("returns the last book by ID", () => {
|
||||
const book = getBookById("REV");
|
||||
expect(book).toBeDefined();
|
||||
expect(book!.name).toBe("Revelation");
|
||||
expect(book!.order).toBe(66);
|
||||
});
|
||||
|
||||
test("returns undefined for an invalid ID", () => {
|
||||
expect(getBookById("INVALID")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for an empty string", () => {
|
||||
expect(getBookById("")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("is case-sensitive", () => {
|
||||
expect(getBookById("gen")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAdjacent", () => {
|
||||
test("consecutive books are adjacent", () => {
|
||||
expect(isAdjacent("GEN", "EXO")).toBe(true); // 1, 2
|
||||
});
|
||||
|
||||
test("adjacency is symmetric", () => {
|
||||
expect(isAdjacent("EXO", "GEN")).toBe(true);
|
||||
});
|
||||
|
||||
test("books two apart are not adjacent", () => {
|
||||
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
|
||||
});
|
||||
|
||||
test("the same book is not adjacent to itself", () => {
|
||||
expect(isAdjacent("GEN", "GEN")).toBe(false); // diff = 0
|
||||
});
|
||||
|
||||
test("works across testament boundary (Malachi / Matthew)", () => {
|
||||
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
|
||||
});
|
||||
|
||||
test("far-apart books are not adjacent", () => {
|
||||
expect(isAdjacent("GEN", "REV")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for unknown IDs", () => {
|
||||
expect(isAdjacent("FAKE", "GEN")).toBe(false);
|
||||
expect(isAdjacent("GEN", "FAKE")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFirstLetter", () => {
|
||||
test("returns first letter of a normal book name", () => {
|
||||
expect(getFirstLetter("Genesis")).toBe("G");
|
||||
expect(getFirstLetter("Revelation")).toBe("R");
|
||||
});
|
||||
|
||||
test("skips leading digits and returns first letter", () => {
|
||||
expect(getFirstLetter("1 Samuel")).toBe("S");
|
||||
expect(getFirstLetter("2 Kings")).toBe("K");
|
||||
expect(getFirstLetter("1 Corinthians")).toBe("C");
|
||||
expect(getFirstLetter("3 John")).toBe("J");
|
||||
});
|
||||
|
||||
test("returns first letter of multi-word names", () => {
|
||||
expect(getFirstLetter("Song of Solomon")).toBe("S");
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateGuess", () => {
|
||||
test("returns null for an invalid guess ID", () => {
|
||||
expect(evaluateGuess("INVALID", "GEN")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for an invalid correct ID", () => {
|
||||
expect(evaluateGuess("GEN", "INVALID")).toBeNull();
|
||||
});
|
||||
|
||||
test("exact book match: testamentMatch and sectionMatch are true, adjacent is false", () => {
|
||||
const result = evaluateGuess("GEN", "GEN");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.book.id).toBe("GEN");
|
||||
expect(result!.testamentMatch).toBe(true);
|
||||
expect(result!.sectionMatch).toBe(true);
|
||||
expect(result!.adjacent).toBe(false);
|
||||
});
|
||||
|
||||
test("same section implies same testament", () => {
|
||||
// Genesis and Exodus are both OT Law
|
||||
const result = evaluateGuess("GEN", "EXO");
|
||||
expect(result!.testamentMatch).toBe(true);
|
||||
expect(result!.sectionMatch).toBe(true);
|
||||
expect(result!.adjacent).toBe(true);
|
||||
});
|
||||
|
||||
test("same testament, different section", () => {
|
||||
// Genesis (Law) vs Joshua (History) — both OT
|
||||
const result = evaluateGuess("GEN", "JOS");
|
||||
expect(result!.testamentMatch).toBe(true);
|
||||
expect(result!.sectionMatch).toBe(false);
|
||||
expect(result!.adjacent).toBe(false);
|
||||
});
|
||||
|
||||
test("different testament, no match", () => {
|
||||
// Genesis (OT) vs Matthew (NT)
|
||||
const result = evaluateGuess("GEN", "MAT");
|
||||
expect(result!.testamentMatch).toBe(false);
|
||||
expect(result!.sectionMatch).toBe(false);
|
||||
expect(result!.adjacent).toBe(false);
|
||||
});
|
||||
|
||||
test("adjacent books across testament boundary", () => {
|
||||
// Malachi (OT, 39) and Matthew (NT, 40)
|
||||
const result = evaluateGuess("MAL", "MAT");
|
||||
expect(result!.adjacent).toBe(true);
|
||||
expect(result!.testamentMatch).toBe(false);
|
||||
expect(result!.sectionMatch).toBe(false);
|
||||
});
|
||||
|
||||
test("adjacent books within same testament and section", () => {
|
||||
// Hosea (28) and Joel (29), both Minor Prophets
|
||||
const result = evaluateGuess("HOS", "JOL");
|
||||
expect(result!.adjacent).toBe(true);
|
||||
expect(result!.testamentMatch).toBe(true);
|
||||
expect(result!.sectionMatch).toBe(true);
|
||||
});
|
||||
|
||||
test("firstLetterMatch: same first letter", () => {
|
||||
// Genesis and Galatians both start with G
|
||||
const result = evaluateGuess("GEN", "GAL");
|
||||
expect(result!.firstLetterMatch).toBe(true);
|
||||
});
|
||||
|
||||
test("firstLetterMatch: different first letter", () => {
|
||||
// Genesis (G) vs Matthew (M)
|
||||
const result = evaluateGuess("GEN", "MAT");
|
||||
expect(result!.firstLetterMatch).toBe(false);
|
||||
});
|
||||
|
||||
test("firstLetterMatch is case-insensitive", () => {
|
||||
// Both start with J but from different contexts — Jeremiah vs Joel
|
||||
const result = evaluateGuess("JER", "JOL");
|
||||
expect(result!.firstLetterMatch).toBe(true);
|
||||
});
|
||||
|
||||
test("special case: two Epistle '1' books always firstLetterMatch", () => {
|
||||
// 1 Corinthians (Pauline) vs 1 John (General) — both Epistles starting with "1"
|
||||
const result = evaluateGuess("1CO", "1JN");
|
||||
expect(result!.firstLetterMatch).toBe(true);
|
||||
});
|
||||
|
||||
test("special case: Epistle '1' book vs non-Epistle '1' book — no special treatment", () => {
|
||||
// 1 Corinthians (Pauline Epistles) vs 1 Samuel (History)
|
||||
// Correct is NOT Epistles, so special case doesn't apply
|
||||
const result = evaluateGuess("1CO", "1SA");
|
||||
// getFirstLetter("1 Corinthians") = "C", getFirstLetter("1 Samuel") = "S" → false
|
||||
expect(result!.firstLetterMatch).toBe(false);
|
||||
});
|
||||
|
||||
test("special case only triggers when BOTH are Epistle '1' books", () => {
|
||||
// 2 Corinthians (Pauline, starts with "2") vs 1 John (General, starts with "1")
|
||||
// guessIsEpistlesWithNumber requires name[0] === "1", so 2CO fails
|
||||
const result = evaluateGuess("2CO", "1JN");
|
||||
// getFirstLetter("2 Corinthians") = "C", getFirstLetter("1 John") = "J" → false
|
||||
expect(result!.firstLetterMatch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGrade", () => {
|
||||
test("1 guess → S+", () => expect(getGrade(1)).toBe("S+"));
|
||||
test("2 guesses → A+", () => expect(getGrade(2)).toBe("A+"));
|
||||
test("3 guesses → A", () => expect(getGrade(3)).toBe("A"));
|
||||
test("4 guesses → B+", () => expect(getGrade(4)).toBe("B+"));
|
||||
test("6 guesses → B+", () => expect(getGrade(6)).toBe("B+"));
|
||||
test("7 guesses → B", () => expect(getGrade(7)).toBe("B"));
|
||||
test("10 guesses → B", () => expect(getGrade(10)).toBe("B"));
|
||||
test("11 guesses → C+", () => expect(getGrade(11)).toBe("C+"));
|
||||
test("15 guesses → C+", () => expect(getGrade(15)).toBe("C+"));
|
||||
test("16 guesses → C", () => expect(getGrade(16)).toBe("C"));
|
||||
test("100 guesses → C", () => expect(getGrade(100)).toBe("C"));
|
||||
});
|
||||
|
||||
describe("getNextGradeMessage", () => {
|
||||
test("returns empty string at top grade", () => {
|
||||
expect(getNextGradeMessage(1)).toBe("");
|
||||
});
|
||||
|
||||
test("grade A+ shows 1 guess threshold", () => {
|
||||
expect(getNextGradeMessage(2)).toBe("Next grade: 1 guess or less");
|
||||
});
|
||||
|
||||
test("grade A shows 2 guess threshold", () => {
|
||||
expect(getNextGradeMessage(3)).toBe("Next grade: 2 guesses or less");
|
||||
});
|
||||
|
||||
test("grade B+ shows 3 guess threshold", () => {
|
||||
expect(getNextGradeMessage(4)).toBe("Next grade: 3 guesses or less");
|
||||
expect(getNextGradeMessage(6)).toBe("Next grade: 3 guesses or less");
|
||||
});
|
||||
|
||||
test("grade B shows 6 guess threshold", () => {
|
||||
expect(getNextGradeMessage(7)).toBe("Next grade: 6 guesses or less");
|
||||
expect(getNextGradeMessage(10)).toBe("Next grade: 6 guesses or less");
|
||||
});
|
||||
|
||||
test("grade C+ shows 10 guess threshold", () => {
|
||||
expect(getNextGradeMessage(11)).toBe("Next grade: 10 guesses or less");
|
||||
expect(getNextGradeMessage(15)).toBe("Next grade: 10 guesses or less");
|
||||
});
|
||||
|
||||
test("grade C shows 15 guess threshold", () => {
|
||||
expect(getNextGradeMessage(16)).toBe("Next grade: 15 guesses or less");
|
||||
expect(getNextGradeMessage(50)).toBe("Next grade: 15 guesses or less");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toOrdinal", () => {
|
||||
test("1st, 2nd, 3rd", () => {
|
||||
expect(toOrdinal(1)).toBe("1st");
|
||||
expect(toOrdinal(2)).toBe("2nd");
|
||||
expect(toOrdinal(3)).toBe("3rd");
|
||||
});
|
||||
|
||||
test("4-10 use th", () => {
|
||||
expect(toOrdinal(4)).toBe("4th");
|
||||
expect(toOrdinal(10)).toBe("10th");
|
||||
});
|
||||
|
||||
test("11, 12, 13 use th (not st/nd/rd)", () => {
|
||||
expect(toOrdinal(11)).toBe("11th");
|
||||
expect(toOrdinal(12)).toBe("12th");
|
||||
expect(toOrdinal(13)).toBe("13th");
|
||||
});
|
||||
|
||||
test("21, 22, 23 use st/nd/rd", () => {
|
||||
expect(toOrdinal(21)).toBe("21st");
|
||||
expect(toOrdinal(22)).toBe("22nd");
|
||||
expect(toOrdinal(23)).toBe("23rd");
|
||||
});
|
||||
|
||||
test("101, 102, 103 use st/nd/rd", () => {
|
||||
expect(toOrdinal(101)).toBe("101st");
|
||||
expect(toOrdinal(102)).toBe("102nd");
|
||||
expect(toOrdinal(103)).toBe("103rd");
|
||||
});
|
||||
|
||||
test("111, 112, 113 use th", () => {
|
||||
expect(toOrdinal(111)).toBe("111th");
|
||||
expect(toOrdinal(112)).toBe("112th");
|
||||
expect(toOrdinal(113)).toBe("113th");
|
||||
});
|
||||
});
|
||||
339
tests/share.test.ts
Normal file
339
tests/share.test.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { generateShareText, getVerseSnippet } from "$lib/utils/share";
|
||||
import { getBookById } from "$lib/utils/game";
|
||||
import type { Guess } from "$lib/utils/game";
|
||||
|
||||
// Helpers to build Guess objects without calling evaluateGuess
|
||||
function makeGuess(bookId: string, overrides: Partial<Omit<Guess, "book">> = {}): Guess {
|
||||
const book = getBookById(bookId)!;
|
||||
return {
|
||||
book,
|
||||
testamentMatch: false,
|
||||
sectionMatch: false,
|
||||
adjacent: false,
|
||||
firstLetterMatch: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const CORRECT_BOOK_ID = "GEN";
|
||||
|
||||
const exactGuess = makeGuess("GEN", {
|
||||
testamentMatch: true,
|
||||
sectionMatch: true,
|
||||
});
|
||||
|
||||
const adjacentGuess = makeGuess("EXO", {
|
||||
testamentMatch: true,
|
||||
sectionMatch: true,
|
||||
adjacent: true,
|
||||
});
|
||||
|
||||
const sectionGuess = makeGuess("LEV", {
|
||||
testamentMatch: true,
|
||||
sectionMatch: true,
|
||||
});
|
||||
|
||||
const testamentGuess = makeGuess("JOS", {
|
||||
testamentMatch: true,
|
||||
sectionMatch: false,
|
||||
});
|
||||
|
||||
const noMatchGuess = makeGuess("MAT", {
|
||||
testamentMatch: false,
|
||||
sectionMatch: false,
|
||||
});
|
||||
|
||||
describe("generateShareText — emoji mapping", () => {
|
||||
test("exact match → ✅", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("✅");
|
||||
});
|
||||
|
||||
test("adjacent book → ‼️", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [adjacentGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("‼️");
|
||||
});
|
||||
|
||||
test("section match → 🟩", () => {
|
||||
// LEV matches section (Law) but is not adjacent to GEN (order 1 vs 3)
|
||||
const text = generateShareText({
|
||||
guesses: [sectionGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("🟩");
|
||||
});
|
||||
|
||||
test("testament match only → 🟧", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [testamentGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("🟧");
|
||||
});
|
||||
|
||||
test("no match → 🟥", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [noMatchGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("🟥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — guess count wording", () => {
|
||||
test("1 guess uses singular 'guess'", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("1 guess,");
|
||||
});
|
||||
|
||||
test("multiple guesses uses plural 'guesses'", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [noMatchGuess, testamentGuess, exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("3 guesses,");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — streak display", () => {
|
||||
test("streak > 1 is shown with fire emoji", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
streak: 5,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("5 days 🔥");
|
||||
});
|
||||
|
||||
test("streak of 1 is not shown", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
streak: 1,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).not.toContain("🔥");
|
||||
});
|
||||
|
||||
test("undefined streak is not shown", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).not.toContain("🔥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — chapter star", () => {
|
||||
test("1 guess + chapterCorrect → ⭐ appended to emoji line", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: true,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("✅ ⭐");
|
||||
});
|
||||
|
||||
test("multiple guesses + chapterCorrect → no star (only awarded for hole-in-one)", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [noMatchGuess, exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: true,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).not.toContain("⭐");
|
||||
});
|
||||
|
||||
test("1 guess + chapterCorrect false → no star", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).not.toContain("⭐");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — login book emoji", () => {
|
||||
test("logged in uses 📜", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: true,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("📜");
|
||||
expect(text).not.toContain("📖");
|
||||
});
|
||||
|
||||
test("not logged in uses 📖", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("📖");
|
||||
expect(text).not.toContain("📜");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — date formatting", () => {
|
||||
test("date is formatted as 'Mon DD, YYYY'", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("Jan 15, 2025");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — guess order", () => {
|
||||
test("guesses are reversed in the emoji line (first guess last)", () => {
|
||||
// noMatchGuess first, then exactGuess — reversed output: ✅🟥
|
||||
const text = generateShareText({
|
||||
guesses: [noMatchGuess, exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
const lines = text.split("\n");
|
||||
expect(lines[2]).toBe("✅🟥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVerseSnippet", () => {
|
||||
test("wraps output in curly double quotes", () => {
|
||||
const result = getVerseSnippet("Hello world");
|
||||
expect(result.startsWith("\u201C")).toBe(true);
|
||||
expect(result.endsWith("\u201D")).toBe(true);
|
||||
});
|
||||
|
||||
test("short verse (fewer than 10 words) returns full text", () => {
|
||||
const result = getVerseSnippet("For God so loved");
|
||||
// No punctuation search happens, returns all words
|
||||
expect(result).toContain("For God so loved");
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
test("verse with no punctuation in range returns first 25 words", () => {
|
||||
const words = Array.from({ length: 30 }, (_, i) => `word${i + 1}`);
|
||||
const verse = words.join(" ");
|
||||
const result = getVerseSnippet(verse);
|
||||
// Should contain up to 25 words
|
||||
expect(result).toContain("word25");
|
||||
expect(result).not.toContain("word26");
|
||||
});
|
||||
|
||||
test("truncates at punctuation between words 10 and 25", () => {
|
||||
// 12 words before comma, rest after
|
||||
const verse =
|
||||
"one two three four five six seven eight nine ten eleven twelve, thirteen fourteen fifteen twenty";
|
||||
const result = getVerseSnippet(verse);
|
||||
// The comma is after word 12, which is between word 10 and 25
|
||||
expect(result).toContain("twelve");
|
||||
expect(result).not.toContain("thirteen");
|
||||
});
|
||||
|
||||
test("punctuation before word 10 does not trigger truncation", () => {
|
||||
// Comma is after word 5 — before the search window starts at word 10
|
||||
const verse =
|
||||
"one two three four five, six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen";
|
||||
const result = getVerseSnippet(verse);
|
||||
// The comma at word 5 is before start of search range, so we continue
|
||||
// The snippet should contain word 10 at minimum
|
||||
expect(result).toContain("ten");
|
||||
});
|
||||
|
||||
test("does not include trailing whitespace before ellipsis", () => {
|
||||
const verse =
|
||||
"one two three four five six seven eight nine ten eleven twelve, rest of verse here";
|
||||
const result = getVerseSnippet(verse);
|
||||
// trimEnd is applied before adding ..., so no space before ...
|
||||
expect(result).not.toMatch(/\s\.\.\./);
|
||||
});
|
||||
});
|
||||
129
tests/stats.test.ts
Normal file
129
tests/stats.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
formatDate,
|
||||
getGradeColor,
|
||||
getPerformanceMessage,
|
||||
getStreakMessage,
|
||||
} from "$lib/utils/stats";
|
||||
|
||||
describe("getGradeColor", () => {
|
||||
test("S++ → purple", () => {
|
||||
expect(getGradeColor("S++")).toBe("text-purple-600 bg-purple-100");
|
||||
});
|
||||
|
||||
test("S+ → yellow", () => {
|
||||
expect(getGradeColor("S+")).toBe("text-yellow-600 bg-yellow-100");
|
||||
});
|
||||
|
||||
test("A+ → green", () => {
|
||||
expect(getGradeColor("A+")).toBe("text-green-600 bg-green-100");
|
||||
});
|
||||
|
||||
test("A → light green", () => {
|
||||
expect(getGradeColor("A")).toBe("text-green-500 bg-green-50");
|
||||
});
|
||||
|
||||
test("B+ → blue", () => {
|
||||
expect(getGradeColor("B+")).toBe("text-blue-600 bg-blue-100");
|
||||
});
|
||||
|
||||
test("B → light blue", () => {
|
||||
expect(getGradeColor("B")).toBe("text-blue-500 bg-blue-50");
|
||||
});
|
||||
|
||||
test("C+ → orange", () => {
|
||||
expect(getGradeColor("C+")).toBe("text-orange-600 bg-orange-100");
|
||||
});
|
||||
|
||||
test("C → red", () => {
|
||||
expect(getGradeColor("C")).toBe("text-red-600 bg-red-100");
|
||||
});
|
||||
|
||||
test("unknown grade → gray fallback", () => {
|
||||
expect(getGradeColor("X")).toBe("text-gray-600 bg-gray-100");
|
||||
expect(getGradeColor("")).toBe("text-gray-600 bg-gray-100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
test("formats a mid-year date", () => {
|
||||
expect(formatDate("2024-07-04")).toBe("Jul 4");
|
||||
});
|
||||
|
||||
test("formats a January date", () => {
|
||||
expect(formatDate("2024-01-15")).toBe("Jan 15");
|
||||
});
|
||||
|
||||
test("formats the last day of the year", () => {
|
||||
expect(formatDate("2023-12-31")).toBe("Dec 31");
|
||||
});
|
||||
|
||||
test("formats a single-digit day without leading zero", () => {
|
||||
expect(formatDate("2025-03-01")).toBe("Mar 1");
|
||||
});
|
||||
|
||||
test("year in input does not appear in output", () => {
|
||||
expect(formatDate("2024-06-20")).not.toContain("2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStreakMessage", () => {
|
||||
test("0 → prompt to start", () => {
|
||||
expect(getStreakMessage(0)).toBe("Start your streak today!");
|
||||
});
|
||||
|
||||
test("1 → encouragement", () => {
|
||||
expect(getStreakMessage(1)).toBe("Keep it going!");
|
||||
});
|
||||
|
||||
test("2 → X days strong", () => {
|
||||
expect(getStreakMessage(2)).toBe("2 days strong!");
|
||||
});
|
||||
|
||||
test("6 → X days strong (upper bound of that range)", () => {
|
||||
expect(getStreakMessage(6)).toBe("6 days strong!");
|
||||
});
|
||||
|
||||
test("7 → week streak message", () => {
|
||||
expect(getStreakMessage(7)).toBe("7 day streak - amazing!");
|
||||
});
|
||||
|
||||
test("29 → upper bound of week-streak range", () => {
|
||||
expect(getStreakMessage(29)).toBe("29 day streak - amazing!");
|
||||
});
|
||||
|
||||
test("30 → unstoppable message", () => {
|
||||
expect(getStreakMessage(30)).toBe("30 days - you're unstoppable!");
|
||||
});
|
||||
|
||||
test("100 → unstoppable message", () => {
|
||||
expect(getStreakMessage(100)).toBe("100 days - you're unstoppable!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPerformanceMessage", () => {
|
||||
test("≤ 2 guesses → exceptional", () => {
|
||||
expect(getPerformanceMessage(1)).toBe("Exceptional performance!");
|
||||
expect(getPerformanceMessage(2)).toBe("Exceptional performance!");
|
||||
});
|
||||
|
||||
test("≤ 4 guesses → great", () => {
|
||||
expect(getPerformanceMessage(2.1)).toBe("Great performance!");
|
||||
expect(getPerformanceMessage(4)).toBe("Great performance!");
|
||||
});
|
||||
|
||||
test("≤ 6 guesses → good", () => {
|
||||
expect(getPerformanceMessage(4.1)).toBe("Good performance!");
|
||||
expect(getPerformanceMessage(6)).toBe("Good performance!");
|
||||
});
|
||||
|
||||
test("≤ 8 guesses → room for improvement", () => {
|
||||
expect(getPerformanceMessage(6.1)).toBe("Room for improvement!");
|
||||
expect(getPerformanceMessage(8)).toBe("Room for improvement!");
|
||||
});
|
||||
|
||||
test("> 8 guesses → keep practicing", () => {
|
||||
expect(getPerformanceMessage(8.1)).toBe("Keep practicing!");
|
||||
expect(getPerformanceMessage(20)).toBe("Keep practicing!");
|
||||
});
|
||||
});
|
||||
@@ -496,3 +496,149 @@ describe('Timezone-aware daily verse system', () => {
|
||||
// See /api/submit-completion for the unique constraint enforcement
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streak walk-back logic — mirrors /api/streak/+server.ts
|
||||
// UTC is NEVER used for date comparison; all walk-back is pure string arithmetic.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function prevDay(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function calcStreak(completedDates: Set<string>, localDate: string): number {
|
||||
let streak = 0;
|
||||
let cursor = localDate;
|
||||
while (completedDates.has(cursor)) {
|
||||
streak++;
|
||||
cursor = prevDay(cursor);
|
||||
}
|
||||
return streak < 2 ? 0 : streak;
|
||||
}
|
||||
|
||||
describe('Streak walk-back — local time always used, UTC never', () => {
|
||||
test('prevDay uses UTC arithmetic, never server local time', () => {
|
||||
// Regardless of server timezone, prevDay("2024-03-10") must always be "2024-03-09"
|
||||
// (including across DST boundaries where local midnight ≠ UTC midnight)
|
||||
expect(prevDay('2024-03-10')).toBe('2024-03-09'); // DST spring-forward in US
|
||||
expect(prevDay('2024-11-03')).toBe('2024-11-02'); // DST fall-back in US
|
||||
expect(prevDay('2024-01-01')).toBe('2023-12-31'); // year boundary
|
||||
expect(prevDay('2024-03-01')).toBe('2024-02-29'); // leap year
|
||||
expect(prevDay('2023-03-01')).toBe('2023-02-28'); // non-leap year
|
||||
});
|
||||
|
||||
test('UTC+9 user: plays at 01:00 local (still previous UTC date) — streak uses local date', () => {
|
||||
// Scenario: It is 2024-01-16 01:00 in Tokyo (UTC+9) = 2024-01-15 16:00 UTC.
|
||||
// The verse served is for 2024-01-15 (UTC), but the user's LOCAL date is 2024-01-16.
|
||||
// Completions are stored as the user's local date (as returned by dailyVerse.date
|
||||
// which is set from the client's new Date().toLocaleDateString("en-CA")).
|
||||
// The streak walk-back must use local dates, not UTC dates.
|
||||
|
||||
// Four consecutive local dates for a Tokyo user
|
||||
const completedLocalDates = new Set(['2024-01-13', '2024-01-14', '2024-01-15', '2024-01-16']);
|
||||
|
||||
// If we (incorrectly) walked back from the UTC date "2024-01-15" instead of
|
||||
// the local date "2024-01-16", we would miss the most recent completion.
|
||||
const wrongStreakIfUTC = calcStreak(completedLocalDates, '2024-01-15');
|
||||
const correctStreakWithLocalDate = calcStreak(completedLocalDates, '2024-01-16');
|
||||
|
||||
// UTC walk-back misses the local "2024-01-16" entry → only finds 3 consecutive days
|
||||
// (but actually it finds 2024-01-15, 2024-01-14, 2024-01-13 = 3, returned as 3)
|
||||
// The point is it does NOT include 2024-01-16 which is "today" for the user.
|
||||
expect(wrongStreakIfUTC).toBe(3); // stale — missing today's local entry
|
||||
|
||||
// Local walk-back correctly finds all four entries
|
||||
expect(correctStreakWithLocalDate).toBe(4);
|
||||
});
|
||||
|
||||
test('UTC-8 user: plays at 23:00 local (next UTC date) — streak uses local date', () => {
|
||||
// Scenario: It is 2024-01-15 23:00 in Los Angeles (UTC-8) = 2024-01-16 07:00 UTC.
|
||||
// The verse served is for 2024-01-16 (UTC), but the user's LOCAL date is still 2024-01-15.
|
||||
// Completion is stored as local date "2024-01-15".
|
||||
// Walk-back from "2024-01-15" (local) must find it; walk-back from "2024-01-16" (UTC)
|
||||
// would NOT find it (the entry is stored as "2024-01-15").
|
||||
|
||||
const completedLocalDates = new Set(['2024-01-12', '2024-01-13', '2024-01-14', '2024-01-15']);
|
||||
|
||||
// If we (incorrectly) walked back from the UTC date "2024-01-16" instead of
|
||||
// the local date "2024-01-15", "2024-01-16" is not in the set → streak = 0.
|
||||
const wrongStreakIfUTC = calcStreak(completedLocalDates, '2024-01-16');
|
||||
const correctStreakWithLocalDate = calcStreak(completedLocalDates, '2024-01-15');
|
||||
|
||||
expect(wrongStreakIfUTC).toBe(0); // broken — UTC date not in DB
|
||||
expect(correctStreakWithLocalDate).toBe(4);
|
||||
});
|
||||
|
||||
test('UTC+13 user (Samoa): local date is two days ahead of UTC-11', () => {
|
||||
// Extreme case: UTC+13 user on 2024-01-16 local = 2024-01-15 UTC.
|
||||
const completedLocalDates = new Set(['2024-01-14', '2024-01-15', '2024-01-16']);
|
||||
|
||||
expect(calcStreak(completedLocalDates, '2024-01-14')).toBe(0); // only 1 day (< 2)
|
||||
expect(calcStreak(completedLocalDates, '2024-01-16')).toBe(3); // correct local streak
|
||||
expect(calcStreak(completedLocalDates, '2024-01-15')).toBe(0); // UTC date misses Jan 16
|
||||
});
|
||||
|
||||
test('streak is 0 when today is missing even if yesterday exists', () => {
|
||||
// User missed today — streak must reset regardless of timezone
|
||||
const completedLocalDates = new Set(['2024-01-12', '2024-01-13', '2024-01-14']);
|
||||
// "Today" is 2024-01-16 — they missed the 15th and 16th
|
||||
expect(calcStreak(completedLocalDates, '2024-01-16')).toBe(0);
|
||||
});
|
||||
|
||||
test('streak suppressed below 2 — single day returns 0', () => {
|
||||
const completedLocalDates = new Set(['2024-01-15']);
|
||||
expect(calcStreak(completedLocalDates, '2024-01-15')).toBe(0);
|
||||
});
|
||||
|
||||
test('DB entries with local date store correctly alongside UTC completion timestamp', async () => {
|
||||
// Verify DB round-trip: storing completions with local dates (not UTC)
|
||||
// ensures streak calculation with local dates always works.
|
||||
const userId = 'tz-test-user-' + crypto.randomUUID();
|
||||
|
||||
// UTC+9 scenario: played at 01:00 local on Jan 16 (= UTC Jan 15)
|
||||
// Local date is Jan 16, stored as "2024-01-16" in the DB.
|
||||
const completions = [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-14', // local date
|
||||
guessCount: 2,
|
||||
completedAt: new Date('2024-01-13T15:00:00Z'), // UTC
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-15', // local date
|
||||
guessCount: 3,
|
||||
completedAt: new Date('2024-01-14T15:00:00Z'), // UTC
|
||||
},
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId: userId,
|
||||
date: '2024-01-16', // local date — UTC equivalent is 2024-01-15
|
||||
guessCount: 1,
|
||||
completedAt: new Date('2024-01-15T16:00:00Z'), // 01:00 Tokyo time
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(dailyCompletions).values(completions);
|
||||
|
||||
const rows = await db
|
||||
.select({ date: dailyCompletions.date })
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const storedDates = new Set(rows.map((r) => r.date));
|
||||
|
||||
// Walk-back from LOCAL date "2024-01-16" finds all three entries
|
||||
expect(calcStreak(storedDates, '2024-01-16')).toBe(3);
|
||||
|
||||
// Walk-back from UTC date "2024-01-15" misses the "2024-01-16" local entry
|
||||
// (demonstrates the bug that was fixed: UTC walk-back gives wrong result)
|
||||
expect(calcStreak(storedDates, '2024-01-15')).toBe(0);
|
||||
|
||||
await db.delete(dailyCompletions).where(eq(dailyCompletions.anonymousId, userId));
|
||||
});
|
||||
});
|
||||
|
||||
12
todo.md
12
todo.md
@@ -59,6 +59,18 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
||||
|
||||
# done
|
||||
|
||||
## march 12th
|
||||
|
||||
- Added about page with social buttons and XML sitemap for SEO
|
||||
- Fixed incorrect header background color on Desktop
|
||||
- Added color theme toggle button (commented out for now)
|
||||
|
||||
## feb 26th
|
||||
|
||||
- Added dark mode
|
||||
- Removed URL from share text (Wordle said it was ratchet)
|
||||
- added option for sharing with verse snippet (hidden on share text first copy)
|
||||
|
||||
## february 22nd
|
||||
|
||||
- New share button design; speech bubbles
|
||||
|
||||
Reference in New Issue
Block a user