mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-06 01:43:32 -04:00
Compare commits
32 Commits
c50cccd3d3
...
auth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae4482a551 | ||
|
|
884bbe65c7 | ||
|
|
3de55ba216 | ||
|
|
6e74fffb65 | ||
|
|
1ae2b2ac6c | ||
|
|
a188be167b | ||
|
|
e550965086 | ||
|
|
03429b17cc | ||
|
|
3ee7331510 | ||
|
|
592fa917cd | ||
|
|
ad1774e6b0 | ||
|
|
e1a665ba63 | ||
|
|
f3c9feaf97 | ||
|
|
a5cf248e29 | ||
|
|
f9f3f3de12 | ||
|
|
abab886d1a | ||
|
|
acc82af7cd | ||
|
|
fc674d6008 | ||
|
|
087a476df8 | ||
|
|
ba45cbdc37 | ||
|
|
1de436534c | ||
|
|
3bcd7ea266 | ||
|
|
7ecc84ffbc | ||
|
|
3d78353a90 | ||
|
|
bd36f29419 | ||
|
|
3036264d44 | ||
|
|
6554ef8f41 | ||
|
|
c3307b3920 | ||
|
|
19646c72ca | ||
|
|
e592751a1c | ||
|
|
77cc83841d | ||
|
|
e8b2d2e35e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,3 +30,6 @@ llms-*
|
|||||||
embeddings*
|
embeddings*
|
||||||
*bible.xml
|
*bible.xml
|
||||||
engwebu_usfx.xml
|
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.
|
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.
|
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||||
|
|
||||||
### 4. playground-link
|
|
||||||
|
|
||||||
Generates a Svelte Playground link with the provided code.
|
|
||||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
|
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
|
||||||
- **Styling**: Tailwind CSS 4
|
- **Styling**: Tailwind CSS 4
|
||||||
- **Database**: SQLite with Drizzle ORM
|
- **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
|
- **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
|
## Development Commands
|
||||||
|
|
||||||
@@ -51,6 +46,11 @@ bun run dev
|
|||||||
bun run check
|
bun run check
|
||||||
bun run check:watch
|
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
|
# Build for production
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
@@ -58,92 +58,108 @@ bun run build
|
|||||||
bun run preview
|
bun run preview
|
||||||
|
|
||||||
# Database operations
|
# Database operations
|
||||||
bun run db:push # Push schema changes to database
|
bun run db:push # Push schema changes directly (avoid in prod)
|
||||||
bun run db:generate # Generate migrations (DO NOT RUN)
|
bun run db:generate # Generate migrations
|
||||||
bun run db:migrate # Run migrations (DO NOT RUN)
|
bun run db:migrate # Run migrations
|
||||||
bun run db:studio # Open Drizzle Studio GUI
|
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
|
## Architecture
|
||||||
|
|
||||||
### Database Schema (`src/lib/server/db/schema.ts`)
|
### Database Schema (`src/lib/server/db/schema.ts`)
|
||||||
|
|
||||||
- **user**: User accounts with id and age
|
- **user**: `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `isPrivate`
|
||||||
- **session**: Auth sessions linked to users with expiration timestamps
|
- **session**: `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
|
||||||
- **daily_verses**: Cached daily verses with book ID, verse text, reference, and date
|
- **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`)
|
### Bible Data (`src/lib/types/bible.ts`)
|
||||||
|
|
||||||
The `bibleBooks` array contains all 66 Bible books with metadata:
|
The `bibleBooks` array contains all 66 Bible books with metadata:
|
||||||
- Testament (old/new)
|
- Testament (old/new), Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
|
||||||
- Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
|
|
||||||
- Order (1-66, used for adjacency detection)
|
- Order (1-66, used for adjacency detection)
|
||||||
- Popularity (2-10, affects grading - higher is more popular)
|
|
||||||
|
|
||||||
### Daily Verse System (`src/routes/+page.server.ts`)
|
### Daily Verse System (`src/routes/+page.server.ts`)
|
||||||
|
|
||||||
The `getTodayVerse()` function:
|
`getTodayVerse()` checks the database for today's date, fetches a verse if missing, caches permanently, and returns verse with book metadata.
|
||||||
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
|
|
||||||
|
|
||||||
### Game Logic (`src/routes/+page.svelte`)
|
### Game Logic (`src/routes/+page.svelte`)
|
||||||
|
|
||||||
**State Management:**
|
**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
|
- 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:**
|
**Hint System, for share grid:**
|
||||||
```javascript
|
- ✅ Exact match | 🟩 Section match | 🟧 Testament match | ‼️ Adjacent book | 🟥 No match
|
||||||
// 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
|
|
||||||
|
|
||||||
### Authentication System (`src/lib/server/auth.ts`)
|
### Authentication System (`src/lib/server/auth.ts`)
|
||||||
|
|
||||||
- Token-based sessions with SHA-256 hashing
|
- Token generation: base64-encoded random bytes; stored as SHA-256 hash in DB
|
||||||
- Cookies store session tokens, validated on each request
|
- Cookie name: `auth-session`
|
||||||
- Hook in `src/hooks.server.ts` populates `event.locals.user` and `event.locals.session`
|
- Anonymous users: identified by a client-generated ID; stats migrate on sign-up via `migrateAnonymousStats()`
|
||||||
- Note: Currently the schema includes user table but auth UI is not yet implemented
|
- 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
|
## Key Files
|
||||||
|
|
||||||
- `src/routes/+page.svelte` - Main game UI and client-side logic
|
- `src/routes/+page.svelte` — Main game UI and client-side logic
|
||||||
- `src/routes/+page.server.ts` - Server load function, fetches/caches daily verse
|
- `src/routes/+page.server.ts` / `+page.ts` — Server load (verse) + client load (`ssr: false`)
|
||||||
- `src/lib/server/bible-api.ts` - External API integration for verse fetching
|
- `src/routes/stats/+page.svelte` / `+page.server.ts` — Stats UI and server calculations
|
||||||
- `src/lib/server/bible.ts` - Bible book utility functions
|
- `src/lib/server/auth.ts` — Session management, password hashing, anonymous migration
|
||||||
- `src/lib/types/bible.ts` - Bible books data and TypeScript types
|
- `src/lib/server/bible-api.ts` — Random verse fetching from local XML Bible
|
||||||
- `src/lib/server/db/schema.ts` - Drizzle ORM schema definitions
|
- `src/lib/server/bible.ts` — Bible book utility functions
|
||||||
- `src/hooks.server.ts` - SvelteKit server hook for session validation
|
- `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
|
## Environment Variables
|
||||||
|
|
||||||
Required in `.env`:
|
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
|
## 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 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.
|
||||||
- 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
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Bibdle SvelteKit App
|
Description=Bibdle SvelteKit App
|
||||||
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
||||||
Requires=bibdle.socket
|
After=network-online.target
|
||||||
After=network-online.target bibdle.socket
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
Environment=ORIGIN=https://bibdle.com
|
Environment=ORIGIN=https://bibdle.com
|
||||||
Environment=DATABASE_URL=local.db
|
Environment=DATABASE_URL=prod.db
|
||||||
Environment=IDLE_TIMEOUT=60
|
Environment=IDLE_TIMEOUT=300
|
||||||
WorkingDirectory=/home/george/projects/bibdle
|
Environment=PORT=5173
|
||||||
ExecStart=/home/george/.nvm/versions/node/v24.12.0/bin/node build/index.js
|
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
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
[Socket]
|
|
||||||
ListenStream=5173
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=sockets.target
|
|
||||||
13
bun.lock
13
bun.lock
@@ -7,11 +7,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"xml2js": "^0.6.2",
|
"xml2js": "^0.6.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@oslojs/crypto": "^1.0.1",
|
|
||||||
"@oslojs/encoding": "^1.1.0",
|
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
echo "Pulling latest changes..."
|
|
||||||
git pull
|
|
||||||
|
|
||||||
echo "Installing dependencies..."
|
|
||||||
bun i
|
|
||||||
|
|
||||||
echo "Pushing database changes..."
|
|
||||||
bun run db:generate
|
|
||||||
bun run db:migrate
|
|
||||||
|
|
||||||
echo "Building..."
|
|
||||||
bun --bun run build
|
|
||||||
|
|
||||||
echo "Restarting service..."
|
|
||||||
sudo systemctl restart bibdle-test
|
|
||||||
|
|
||||||
echo "Done!"
|
|
||||||
18
deploy.sh
18
deploy.sh
@@ -3,16 +3,24 @@ set -e
|
|||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
BUN=$(which bun)
|
||||||
|
|
||||||
echo "Pulling latest changes..."
|
echo "Pulling latest changes..."
|
||||||
git pull
|
PULL_OUTPUT=$(git pull)
|
||||||
|
echo "$PULL_OUTPUT"
|
||||||
|
if [ "$PULL_OUTPUT" = "Already up to date." ]; then
|
||||||
|
echo "Nothing to deploy."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Installing dependencies..."
|
echo "Installing dependencies..."
|
||||||
bun i
|
$BUN i
|
||||||
|
|
||||||
echo "Building..."
|
echo "Building..."
|
||||||
bun --bun run build
|
$BUN --bun run build
|
||||||
|
|
||||||
echo "Restarting service..."
|
SERVICE_NAME="$(basename "$(pwd)").service"
|
||||||
sudo systemctl restart bibdle
|
echo "Restarting service ($SERVICE_NAME)..."
|
||||||
|
sudo systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
echo "Done!"
|
echo "Done!"
|
||||||
|
|||||||
@@ -18,8 +18,6 @@
|
|||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@oslojs/crypto": "^1.0.1",
|
|
||||||
"@oslojs/encoding": "^1.1.0",
|
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
@@ -38,6 +36,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
scripts/seed-fake-completions.sh
Executable file
26
scripts/seed-fake-completions.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
|
||||||
|
# Seed the database with 10 fake completions with random anonymous_ids
|
||||||
|
# Useful for testing streak percentile and stats features
|
||||||
|
|
||||||
|
DB_PATH="dev.db"
|
||||||
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
NOW=$(date +%s)
|
||||||
|
|
||||||
|
echo "Seeding 10 fake completions for date: $TODAY"
|
||||||
|
|
||||||
|
for i in {1..50}; do
|
||||||
|
ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||||
|
ANON_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||||
|
GUESS_COUNT=$(( (RANDOM % 6) + 1 )) # 1–6 guesses
|
||||||
|
|
||||||
|
sqlite3 "$DB_PATH" "
|
||||||
|
INSERT OR IGNORE INTO daily_completions (id, anonymous_id, date, guess_count, completed_at)
|
||||||
|
VALUES ('$ID', '$ANON_ID', '$TODAY', $GUESS_COUNT, $NOW);
|
||||||
|
"
|
||||||
|
|
||||||
|
echo " [$i] anon=$ANON_ID guesses=$GUESS_COUNT"
|
||||||
|
done
|
||||||
|
|
||||||
|
TOTAL=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '$TODAY';")
|
||||||
|
echo "✓ Done. Total completions for $TODAY: $TOTAL"
|
||||||
41
scripts/test-share-text.ts
Normal file
41
scripts/test-share-text.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||||
|
import { generateShareText } from '../src/lib/utils/share';
|
||||||
|
import { bibleBooks } from '../src/lib/types/bible';
|
||||||
|
|
||||||
|
const NUM_VERSES = 10;
|
||||||
|
|
||||||
|
for (let i = 0; i < NUM_VERSES; i++) {
|
||||||
|
const verse = await fetchRandomVerse();
|
||||||
|
|
||||||
|
// Build a fake "solved in N guesses" scenario with some wrong guesses first
|
||||||
|
const correctBook = bibleBooks.find((b) => b.id === verse.bookId)!;
|
||||||
|
const wrongBook = bibleBooks.find((b) => b.id !== verse.bookId)!;
|
||||||
|
const guessCount = Math.floor(Math.random() * 5) + 1;
|
||||||
|
const guesses = [
|
||||||
|
...Array(guessCount - 1).fill(null).map(() => ({
|
||||||
|
book: wrongBook,
|
||||||
|
testamentMatch: wrongBook.testament === correctBook.testament,
|
||||||
|
sectionMatch: wrongBook.section === correctBook.section,
|
||||||
|
adjacent: Math.abs(wrongBook.order - correctBook.order) === 1,
|
||||||
|
})),
|
||||||
|
{ book: correctBook, testamentMatch: true, sectionMatch: true, adjacent: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fakeStreak = Math.random() > 0.5 ? Math.floor(Math.random() * 14) + 2 : 0;
|
||||||
|
|
||||||
|
const shareText = generateShareText({
|
||||||
|
guesses,
|
||||||
|
correctBookId: verse.bookId,
|
||||||
|
dailyVerseDate: new Date().toISOString().slice(0, 10),
|
||||||
|
chapterCorrect: guessCount === 1 && Math.random() > 0.5,
|
||||||
|
isLoggedIn: Math.random() > 0.5,
|
||||||
|
streak: fakeStreak > 0 ? fakeStreak : undefined,
|
||||||
|
origin: 'https://bibdle.com',
|
||||||
|
verseText: verse.verseText,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n── Verse ${i + 1}: ${verse.reference} ──`);
|
||||||
|
console.log(`RAW: ${verse.verseText}`);
|
||||||
|
console.log('─'.repeat(40));
|
||||||
|
console.log(shareText);
|
||||||
|
}
|
||||||
10
scripts/test-verse-snippets.ts
Normal file
10
scripts/test-verse-snippets.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||||
|
import { getVerseSnippet } from '../src/lib/utils/share';
|
||||||
|
|
||||||
|
const NUM_VERSES = 10;
|
||||||
|
|
||||||
|
for (let i = 0; i < NUM_VERSES; i++) {
|
||||||
|
const verse = await fetchRandomVerse();
|
||||||
|
|
||||||
|
console.log(getVerseSnippet(verse.verseText));
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<script src="https://rybbit.snail.city/api/script.js" data-site-id="9abf0e81d024" defer></script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -32,4 +32,4 @@ export const handle: Handle = handleAuth;
|
|||||||
|
|
||||||
// Initialize embeddings on server start (runs once on module load)
|
// Initialize embeddings on server start (runs once on module load)
|
||||||
const verses = getAllNKJVVerses();
|
const verses = getAllNKJVVerses();
|
||||||
await initializeEmbeddings(verses);
|
// await initializeEmbeddings(verses);
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
return chapterCounts[bookId] || 1;
|
return chapterCounts[bookId] || 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 6 random chapter options including the correct one
|
// Generate 4 random chapter options including the correct one
|
||||||
function generateChapterOptions(
|
function generateChapterOptions(
|
||||||
correctChapter: number,
|
correctChapter: number,
|
||||||
totalChapters: number,
|
totalChapters: number,
|
||||||
@@ -98,14 +98,14 @@
|
|||||||
const options = new Set<number>();
|
const options = new Set<number>();
|
||||||
options.add(correctChapter);
|
options.add(correctChapter);
|
||||||
|
|
||||||
if (totalChapters >= 6) {
|
if (totalChapters >= 4) {
|
||||||
while (options.size < 6) {
|
while (options.size < 4) {
|
||||||
const randomChapter =
|
const randomChapter =
|
||||||
Math.floor(Math.random() * totalChapters) + 1;
|
Math.floor(Math.random() * totalChapters) + 1;
|
||||||
options.add(randomChapter);
|
options.add(randomChapter);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while (options.size < 6) {
|
while (options.size < 4) {
|
||||||
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||||
options.add(randomChapter);
|
options.add(randomChapter);
|
||||||
}
|
}
|
||||||
@@ -167,18 +167,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-6 sm:p-8 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
|
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">
|
<div class="text-center">
|
||||||
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
|
<p class="font-bold mb-3 text-lg sm:text-xl">
|
||||||
<p class="text-sm sm:text-base opacity-80 mb-6">
|
Bonus Challenge
|
||||||
Guess the chapter for an even higher grade
|
<span class="text-base sm:text-lg opacity-60 font-normal"
|
||||||
|
>— guess the chapter for an even higher grade</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div class="grid grid-cols-4 gap-2 justify-center mx-auto mb-3">
|
||||||
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
|
{#each chapterOptions as chapter (chapter)}
|
||||||
>
|
|
||||||
{#each chapterOptions as chapter}
|
|
||||||
<button
|
<button
|
||||||
onclick={() => handleChapterSelect(chapter)}
|
onclick={() => handleChapterSelect(chapter)}
|
||||||
disabled={hasAnswered}
|
disabled={hasAnswered}
|
||||||
@@ -193,8 +193,8 @@
|
|||||||
? isCorrect
|
? isCorrect
|
||||||
? "bg-green-500 text-white border-green-600 shadow-lg"
|
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||||
: "bg-red-400 text-white border-red-500"
|
: "bg-red-400 text-white border-red-500"
|
||||||
: "bg-white/30 text-gray-400 border-gray-300 opacity-40"
|
: "bg-white/30 dark:bg-white/10 text-gray-400 border-gray-300 dark:border-gray-600 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/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>
|
</script>
|
||||||
|
|
||||||
<div
|
<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()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,30 +50,30 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full flex flex-col flex-1">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full"
|
class="flex flex-col items-center 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}
|
{#if newVerseReady}
|
||||||
<p
|
<p
|
||||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||||
>
|
>
|
||||||
Next Verse In
|
Next Verse In
|
||||||
</p>
|
</p>
|
||||||
<p class="text-4xl font-triodion font-black text-gray-800">Now</p>
|
<p class="text-4xl font-triodion font-black text-gray-800">Now</p>
|
||||||
<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)
|
(refresh page to see the new verse)
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p
|
<p
|
||||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||||
>
|
>
|
||||||
Next Verse In
|
Next Verse In
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
class="text-4xl font-triodion font-black text-gray-800 tabular-nums"
|
class="text-4xl font-triodion font-black text-gray-800 dark:text-gray-100 tabular-nums whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{timeUntilNext}
|
{timeUntilNext}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
import SocialLinks from "$lib/components/SocialLinks.svelte";
|
||||||
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
||||||
<div
|
<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 dark:text-gray-300 font-bold"
|
||||||
>
|
>
|
||||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
|
||||||
A project by George Powell & Silent Summit Co.
|
A project by George Powell & Silent Summit Co.
|
||||||
</p>
|
</p>
|
||||||
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||||
@@ -26,53 +27,8 @@
|
|||||||
<!-- Bluesky Social Media Button -->
|
<!-- Bluesky Social Media Button -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex items-center justify-center gap-6">
|
<div class="mt-8">
|
||||||
<a
|
<SocialLinks />
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-8 h-8 text-gray-700"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,44 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
import Button from "$lib/components/Button.svelte";
|
import Button from "$lib/components/Button.svelte";
|
||||||
|
|
||||||
|
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(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, days })
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
alert(
|
||||||
|
`Seeded! Inserted: ${result.inserted?.join(", ")}. Skipped (already exist): ${result.skipped?.join(", ") || "none"}`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
alert("Failed to seed history");
|
||||||
|
} finally {
|
||||||
|
seeding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearLocalStorage() {
|
function clearLocalStorage() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
// Clear all bibdle-related localStorage items
|
// Clear all bibdle-related localStorage items
|
||||||
@@ -22,6 +59,35 @@
|
|||||||
<div class="border-t-2 border-gray-400"></div>
|
<div class="border-t-2 border-gray-400"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-3">
|
<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">
|
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -86,4 +152,21 @@
|
|||||||
>
|
>
|
||||||
Clear LocalStorage
|
Clear LocalStorage
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{seeding ? "Seeding..." : "Seed 10 Days of History"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,38 +66,37 @@
|
|||||||
|
|
||||||
{#if !hasGuesses}
|
{#if !hasGuesses}
|
||||||
<Container class="p-6 text-center">
|
<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
|
Instructions
|
||||||
</h2>
|
</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
|
Guess what book of the bible you think the verse is from. You will
|
||||||
get clues to tell you if your guess is close or not. Green means the
|
get clues to help you after each guess.
|
||||||
category is correct; red means wrong.
|
|
||||||
</p>
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<!-- Column Headers -->
|
<!-- Column Headers -->
|
||||||
<div
|
<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
|
<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
|
Testament
|
||||||
</div>
|
</div>
|
||||||
<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
|
Section
|
||||||
</div>
|
</div>
|
||||||
<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
|
First Letter
|
||||||
</div>
|
</div>
|
||||||
<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
|
Book
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,154 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
import { bibleBooks, type BibleBook, type BibleSection, type Testament } from "$lib/types/bible";
|
||||||
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
|
||||||
let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props();
|
let {
|
||||||
|
searchQuery = $bindable(""),
|
||||||
|
guessedIds,
|
||||||
|
submitGuess,
|
||||||
|
guessCount = 0,
|
||||||
|
}: {
|
||||||
|
searchQuery: string;
|
||||||
|
guessedIds: SvelteSet<string>;
|
||||||
|
submitGuess: (id: string) => void;
|
||||||
|
guessCount: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let filteredBooks = $derived(
|
type DisplayMode = "simple" | "testament" | "sections";
|
||||||
|
|
||||||
|
const displayMode = $derived<DisplayMode>(
|
||||||
|
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple"
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredBooks = $derived(
|
||||||
bibleBooks.filter((book) =>
|
bibleBooks.filter((book) =>
|
||||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type SimpleGroup = { books: BibleBook[] };
|
||||||
|
|
||||||
|
type TestamentGroup = {
|
||||||
|
testament: Testament;
|
||||||
|
label: string;
|
||||||
|
books: BibleBook[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SectionGroup = {
|
||||||
|
testament: Testament;
|
||||||
|
testamentLabel: string;
|
||||||
|
showTestamentHeader: boolean;
|
||||||
|
section: BibleSection;
|
||||||
|
books: BibleBook[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const simpleGroup = $derived.by<SimpleGroup>(() => {
|
||||||
|
const sorted = [...filteredBooks].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
);
|
||||||
|
return { books: sorted };
|
||||||
|
});
|
||||||
|
|
||||||
|
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
|
||||||
|
const old = filteredBooks
|
||||||
|
.filter((b) => b.testament === "old")
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const newT = filteredBooks
|
||||||
|
.filter((b) => b.testament === "new")
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const groups: TestamentGroup[] = [];
|
||||||
|
if (old.length > 0) {
|
||||||
|
groups.push({ testament: "old", label: "Old Testament", books: old });
|
||||||
|
}
|
||||||
|
if (newT.length > 0) {
|
||||||
|
groups.push({ testament: "new", label: "New Testament", books: newT });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectionGroups = $derived.by<SectionGroup[]>(() => {
|
||||||
|
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
|
||||||
|
const seenKeys: Record<string, true> = {};
|
||||||
|
const orderedPairs: { testament: Testament; section: BibleSection }[] = [];
|
||||||
|
|
||||||
|
for (const book of bibleBooks) {
|
||||||
|
const key = `${book.testament}:${book.section}`;
|
||||||
|
if (!seenKeys[key]) {
|
||||||
|
seenKeys[key] = true;
|
||||||
|
orderedPairs.push({ testament: book.testament, section: book.section });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: SectionGroup[] = [];
|
||||||
|
let lastTestament: Testament | null = null;
|
||||||
|
|
||||||
|
for (const pair of orderedPairs) {
|
||||||
|
const books = filteredBooks.filter(
|
||||||
|
(b) => b.testament === pair.testament && b.section === pair.section
|
||||||
|
);
|
||||||
|
if (books.length === 0) continue;
|
||||||
|
|
||||||
|
const showTestamentHeader = pair.testament !== lastTestament;
|
||||||
|
lastTestament = pair.testament;
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
testament: pair.testament,
|
||||||
|
testamentLabel:
|
||||||
|
pair.testament === "old" ? "Old Testament" : "New Testament",
|
||||||
|
showTestamentHeader,
|
||||||
|
section: pair.section,
|
||||||
|
books,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
// First book in display order for Enter key submission
|
||||||
|
const firstBookId = $derived.by<string | null>(() => {
|
||||||
|
if (filteredBooks.length === 0) return null;
|
||||||
|
if (displayMode === "simple") {
|
||||||
|
return simpleGroup.books[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
if (displayMode === "testament") {
|
||||||
|
return testamentGroups[0]?.books[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
return sectionGroups[0]?.books[0]?.id ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && filteredBooks.length > 0) {
|
if (e.key === "Enter" && firstBookId) {
|
||||||
submitGuess(filteredBooks[0].id);
|
submitGuess(firstBookId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showBanner = $derived(guessCount >= 3);
|
||||||
|
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if showBanner}
|
||||||
|
<p
|
||||||
|
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{#if bannerIsIndigo}
|
||||||
|
Testament & section groups now visible
|
||||||
|
{:else}
|
||||||
|
Old & New Testament groups now visible
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<svg
|
<svg
|
||||||
class="absolute left-4 sm:left-6 top-1/2 transform -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -35,13 +160,13 @@
|
|||||||
<input
|
<input
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
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}
|
onkeydown={handleKeydown}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
{#if searchQuery}
|
{#if searchQuery}
|
||||||
<button
|
<button
|
||||||
class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
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 = "")}
|
onclick={() => (searchQuery = "")}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
@@ -51,6 +176,7 @@
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -62,31 +188,122 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if searchQuery && filteredBooks.length > 0}
|
{#if searchQuery && filteredBooks.length > 0}
|
||||||
<ul
|
<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"
|
||||||
>
|
>
|
||||||
{#each filteredBooks as book (book.id)}
|
{#if displayMode === "simple"}
|
||||||
<li>
|
{#each simpleGroup.books as book (book.id)}
|
||||||
|
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||||
<button
|
<button
|
||||||
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id)
|
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||||
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
|
{guessedIds.has(book.id)
|
||||||
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
|
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||||
onclick={() => submitGuess(book.id)}
|
onclick={() => submitGuess(book.id)}
|
||||||
|
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-semibold dark:text-gray-100 {guessedIds.has(book.id)
|
||||||
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{book.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{:else if displayMode === "testament"}
|
||||||
|
{#each testamentGroups as group (group.testament)}
|
||||||
|
<li role="presentation">
|
||||||
|
<div
|
||||||
|
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 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<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 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 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
|
<span
|
||||||
class="font-semibold {guessedIds.has(book.id)
|
class="font-semibold {guessedIds.has(book.id)
|
||||||
? 'line-through text-gray-500'
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
: ''}">{book.name}</span
|
: ''}"
|
||||||
>
|
|
||||||
<span class="ml-auto text-sm opacity-75"
|
|
||||||
>({book.testament.toUpperCase()})</span
|
|
||||||
>
|
>
|
||||||
|
{book.name}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
|
||||||
|
<li role="presentation">
|
||||||
|
{#if group.showTestamentHeader}
|
||||||
|
<div
|
||||||
|
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 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{group.testamentLabel}
|
||||||
|
</span>
|
||||||
|
<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 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 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
{group.section}
|
||||||
|
</span>
|
||||||
|
<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 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 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 dark:text-gray-500'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{book.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
{:else if searchQuery}
|
{: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}
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
31
src/lib/components/StreakCounter.svelte
Normal file
31
src/lib/components/StreakCounter.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
streak,
|
||||||
|
streakPercentile = null,
|
||||||
|
}: {
|
||||||
|
streak: number;
|
||||||
|
streakPercentile?: number | null;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{streak}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
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 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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
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}
|
||||||
@@ -15,15 +15,16 @@
|
|||||||
? dailyVerse.reference
|
? dailyVerse.reference
|
||||||
.replace(/^Psalms /, "Psalm ")
|
.replace(/^Psalms /, "Psalm ")
|
||||||
.replace(/\s(\d+):/, " ?:")
|
.replace(/\s(\d+):/, " ?:")
|
||||||
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
: dailyVerse.reference.replace(/^Psalms /, "Psalm "),
|
||||||
);
|
);
|
||||||
let displayVerseText = $derived(
|
let displayVerseText = $derived(
|
||||||
dailyVerse.verseText
|
dailyVerse.verseText
|
||||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||||
.replace(/[,:;-—]$/, "...")
|
.replace(/[,:;-—]$/, "..."),
|
||||||
);
|
);
|
||||||
|
|
||||||
let showReference = $state(false);
|
let showReference = $state(false);
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
// Delay showing reference until GuessesTable animation completes
|
// Delay showing reference until GuessesTable animation completes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
|
|
||||||
// Check if user already won today (page reload case)
|
// Check if user already won today (page reload case)
|
||||||
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||||
const alreadyWonToday = browser && localStorage.getItem(winTrackedKey) === "true";
|
const alreadyWonToday =
|
||||||
|
browser && localStorage.getItem(winTrackedKey) === "true";
|
||||||
|
|
||||||
if (alreadyWonToday) {
|
if (alreadyWonToday) {
|
||||||
// User already won and is refreshing - show immediately
|
// User already won and is refreshing - show immediately
|
||||||
@@ -49,11 +51,23 @@
|
|||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function copyVerse() {
|
||||||
|
navigator.clipboard.writeText(displayVerseText).then(() => {
|
||||||
|
copied = true;
|
||||||
|
(window as any).rybbit?.event("Copy Verse");
|
||||||
|
setTimeout(() => {
|
||||||
|
copied = false;
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</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
|
<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}
|
{displayVerseText}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
@@ -64,7 +78,7 @@
|
|||||||
{#if showReference}
|
{#if showReference}
|
||||||
<p
|
<p
|
||||||
transition:fade={{ duration: 400 }}
|
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}
|
{displayReference}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
|
import { getBookById, toOrdinal } from "$lib/utils/game";
|
||||||
import {
|
import {
|
||||||
getBookById,
|
getVerseSnippet,
|
||||||
toOrdinal,
|
shareResult,
|
||||||
getNextGradeMessage,
|
copyToClipboard as clipboardCopy,
|
||||||
} from "$lib/utils/game";
|
} from "$lib/utils/share";
|
||||||
import { onMount } from "svelte";
|
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
import CountdownTimer from "./CountdownTimer.svelte";
|
import CountdownTimer from "./CountdownTimer.svelte";
|
||||||
|
import StreakCounter from "./StreakCounter.svelte";
|
||||||
import ChapterGuess from "./ChapterGuess.svelte";
|
import ChapterGuess from "./ChapterGuess.svelte";
|
||||||
|
|
||||||
interface StatsData {
|
interface StatsData {
|
||||||
@@ -25,7 +26,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
grade,
|
|
||||||
statsData,
|
statsData,
|
||||||
correctBookId,
|
correctBookId,
|
||||||
handleShare,
|
handleShare,
|
||||||
@@ -35,6 +35,28 @@
|
|||||||
guessCount,
|
guessCount,
|
||||||
reference,
|
reference,
|
||||||
onChapterGuessCompleted,
|
onChapterGuessCompleted,
|
||||||
|
shareText,
|
||||||
|
verseText,
|
||||||
|
streak = 0,
|
||||||
|
streakPercentile = null,
|
||||||
|
isLoggedIn = false,
|
||||||
|
anonymousId = '',
|
||||||
|
}: {
|
||||||
|
statsData: StatsData | null;
|
||||||
|
correctBookId: string;
|
||||||
|
handleShare: () => void;
|
||||||
|
copyToClipboard: () => void;
|
||||||
|
copied: boolean;
|
||||||
|
statsSubmitted: boolean;
|
||||||
|
guessCount: number;
|
||||||
|
reference: string;
|
||||||
|
onChapterGuessCompleted: () => void;
|
||||||
|
shareText: string;
|
||||||
|
verseText: string;
|
||||||
|
streak?: number;
|
||||||
|
streakPercentile?: number | null;
|
||||||
|
isLoggedIn?: boolean;
|
||||||
|
anonymousId?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||||
@@ -42,6 +64,24 @@
|
|||||||
typeof navigator !== "undefined" && "share" in navigator,
|
typeof navigator !== "undefined" && "share" in navigator,
|
||||||
);
|
);
|
||||||
let copySuccess = $state(false);
|
let copySuccess = $state(false);
|
||||||
|
let bubbleCopied = $state(false);
|
||||||
|
let copyTracked = $state(false);
|
||||||
|
let showSnippetOption = $state(false);
|
||||||
|
let includeSnippet = $state(false);
|
||||||
|
|
||||||
|
let effectiveShareText = $derived(
|
||||||
|
includeSnippet
|
||||||
|
? (() => {
|
||||||
|
const snippet = getVerseSnippet(verseText);
|
||||||
|
const lines = shareText.split("\n");
|
||||||
|
return [
|
||||||
|
...lines.slice(0, -1),
|
||||||
|
snippet,
|
||||||
|
lines[lines.length - 1],
|
||||||
|
].join("\n");
|
||||||
|
})()
|
||||||
|
: shareText,
|
||||||
|
);
|
||||||
|
|
||||||
// List of congratulations messages with weights
|
// List of congratulations messages with weights
|
||||||
const congratulationsMessages: WeightedMessage[] = [
|
const congratulationsMessages: WeightedMessage[] = [
|
||||||
@@ -57,9 +97,9 @@
|
|||||||
if (guessCount === 1) {
|
if (guessCount === 1) {
|
||||||
const n = Math.random();
|
const n = Math.random();
|
||||||
if (n < 0.99) {
|
if (n < 0.99) {
|
||||||
return "🌟 First try! 🌟";
|
return "First try!";
|
||||||
} else {
|
} else {
|
||||||
return "🗣️ Axios! 🗣️";
|
return "Axios!";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,66 +126,27 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<Container
|
<Container
|
||||||
class="w-full p-8 sm:p-12 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
|
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"
|
||||||
>
|
>
|
||||||
<p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed">
|
<div class="flex flex-col gap-3">
|
||||||
|
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
||||||
{congratulationsMessage} The verse is from
|
{congratulationsMessage} The verse is from
|
||||||
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
|
<span class="font-black font-triodion text-3xl md:text-4xl"
|
||||||
|
>{bookName}</span
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-lg sm:text-xl md:text-2xl mt-4">
|
<p class="text-lg sm:text-xl md:text-2xl">
|
||||||
You guessed correctly after {guessCount}
|
You guessed correctly after {guessCount}
|
||||||
{guessCount === 1 ? "guess" : "guesses"}.
|
{guessCount === 1 ? "guess" : "guesses"}.
|
||||||
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
|
|
||||||
>{grade}</span
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
|
<!-- {#if streak >= 7}
|
||||||
<div class="flex justify-center mt-6">
|
<p
|
||||||
{#if hasWebShare}
|
class="italic tracking-wider px-8 font-semibold text-gray-500"
|
||||||
<!-- mobile and arc in production -->
|
|
||||||
<button
|
|
||||||
onclick={handleShare}
|
|
||||||
data-umami-event="Share"
|
|
||||||
class="text-2xl font-bold p-4 bg-white/70 hover:bg-white/80 rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none"
|
|
||||||
>
|
>
|
||||||
📤 Share
|
Thank you for making BIBDLE part of your daily routine!
|
||||||
</button>
|
</p>
|
||||||
<button
|
{/if} -->
|
||||||
onclick={() => {
|
|
||||||
copyToClipboard();
|
|
||||||
copySuccess = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
copySuccess = false;
|
|
||||||
}, 3000);
|
|
||||||
}}
|
|
||||||
data-umami-event="Copy to Clipboard"
|
|
||||||
class={`text-2xl font-bold p-4 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
|
|
||||||
copySuccess
|
|
||||||
? "bg-white/30"
|
|
||||||
: "bg-white/70 hover:bg-white/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{copySuccess ? "✅ Copied!" : "📋 Copy"}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<!-- dev mode and desktop browsers -->
|
|
||||||
<button
|
|
||||||
onclick={handleShare}
|
|
||||||
data-umami-event="Copy to Clipboard"
|
|
||||||
class={`text-2xl font-bold p-4 ${
|
|
||||||
copied ? "bg-white/30" : "bg-white/70 hover:bg-white/80"
|
|
||||||
} rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
|
|
||||||
>
|
|
||||||
{copied ? "✅ Copied!" : "📋 Share"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if guessCount !== 1}
|
|
||||||
<p class="pt-6 big-text text-gray-700!">
|
|
||||||
{getNextGradeMessage(guessCount)}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<!-- S++ Bonus Challenge for first try -->
|
<!-- S++ Bonus Challenge for first try -->
|
||||||
@@ -157,12 +158,21 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-row gap-3 items-stretch w-full">
|
||||||
|
<div class="flex-2 min-w-0 flex flex-col">
|
||||||
<CountdownTimer />
|
<CountdownTimer />
|
||||||
|
</div>
|
||||||
|
{#if streak > 0}
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col">
|
||||||
|
<StreakCounter {streak} {streakPercentile} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Statistics Display -->
|
<!-- Statistics Display -->
|
||||||
{#if statsData}
|
{#if statsData}
|
||||||
<Container
|
<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
|
<div
|
||||||
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
||||||
@@ -171,7 +181,7 @@
|
|||||||
<!-- Solve Rank Column -->
|
<!-- Solve Rank Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<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}
|
#{statsData.solveRank}
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +194,7 @@
|
|||||||
<!-- Guess Rank Column -->
|
<!-- Guess Rank Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<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)}
|
{toOrdinal(statsData.guessRank)}
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +216,7 @@
|
|||||||
<!-- Average Column -->
|
<!-- Average Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<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}
|
{statsData.averageGuesses}
|
||||||
</div>
|
</div>
|
||||||
@@ -220,11 +230,117 @@
|
|||||||
</Container>
|
</Container>
|
||||||
{:else if !statsSubmitted}
|
{:else if !statsSubmitted}
|
||||||
<Container
|
<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>
|
<div class="text-sm opacity-80">Submitting stats...</div>
|
||||||
</Container>
|
</Container>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
|
||||||
|
<div class="big-text font-black! text-center">Share your result</div>
|
||||||
|
<div class="chat-window">
|
||||||
|
<!-- Received bubble: primary action (share / copy) -->
|
||||||
|
<div class="bubble-wrapper received-wrapper">
|
||||||
|
<button
|
||||||
|
class="bubble bubble-received"
|
||||||
|
class:success={copySuccess}
|
||||||
|
aria-label={hasWebShare ? "Share" : "Copy to clipboard"}
|
||||||
|
data-umami-event={hasWebShare
|
||||||
|
? "Share"
|
||||||
|
: "Copy to Clipboard"}
|
||||||
|
onclick={() => {
|
||||||
|
if (hasWebShare) {
|
||||||
|
(window as any).rybbit?.event("Share");
|
||||||
|
shareResult(effectiveShareText);
|
||||||
|
} else {
|
||||||
|
if (!copyTracked) {
|
||||||
|
(window as any).rybbit?.event(
|
||||||
|
"Copy to Clipboard",
|
||||||
|
);
|
||||||
|
copyTracked = true;
|
||||||
|
}
|
||||||
|
clipboardCopy(effectiveShareText);
|
||||||
|
copySuccess = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copySuccess = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if hasWebShare}
|
||||||
|
📤 Tap here to share
|
||||||
|
{:else if copySuccess}
|
||||||
|
✅ Copied!
|
||||||
|
{:else}
|
||||||
|
📋 Copy to clipboard
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sent bubble: share text preview -->
|
||||||
|
<div class="bubble-wrapper">
|
||||||
|
<button
|
||||||
|
class="bubble bubble-sent"
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
data-umami-event="Copy to Clipboard"
|
||||||
|
onclick={() => {
|
||||||
|
if (!copyTracked) {
|
||||||
|
(window as any).rybbit?.event("Copy to Clipboard");
|
||||||
|
copyTracked = true;
|
||||||
|
}
|
||||||
|
clipboardCopy(effectiveShareText);
|
||||||
|
showSnippetOption = true;
|
||||||
|
bubbleCopied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
bubbleCopied = false;
|
||||||
|
}, 2000);
|
||||||
|
}}>{effectiveShareText}</button
|
||||||
|
>
|
||||||
|
{#if hasWebShare}
|
||||||
|
<span class="copy-hint"
|
||||||
|
>{bubbleCopied ? "copied!" : "(tap to copy)"}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="copy-hint"
|
||||||
|
>{bubbleCopied ? "copied!" : ""}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showSnippetOption}
|
||||||
|
<div class="snippet-toggle-row mr-4" in:fly={{ y: -8, duration: 220 }}>
|
||||||
|
<span class="snippet-label">Show verse snippet in share?</span>
|
||||||
|
<button
|
||||||
|
class="snippet-toggle"
|
||||||
|
class:on={includeSnippet}
|
||||||
|
onclick={() => (includeSnippet = !includeSnippet)}
|
||||||
|
aria-pressed={includeSnippet}
|
||||||
|
aria-label="Show snippet in share"
|
||||||
|
>
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -239,7 +355,336 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-in {
|
:global(.fade-in) {
|
||||||
animation: fadeIn 0.5s ease-out;
|
animation: fadeIn 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Share card ── */
|
||||||
|
.share-card {
|
||||||
|
background: oklch(94% 0.028 298.626);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
position: relative;
|
||||||
|
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;
|
||||||
|
inset: 0;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
|
||||||
|
opacity: 0.04;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chat window ── */
|
||||||
|
.chat-window {
|
||||||
|
--sent-color: #0b93f6;
|
||||||
|
--received-color: #3a3a3c;
|
||||||
|
--bg: oklch(94% 0.028 298.626);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 0.5rem 0;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.chat-window {
|
||||||
|
--bg: oklch(22% 0.025 298.626);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bubble wrappers ── */
|
||||||
|
.bubble-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-wrapper {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared bubble base ── */
|
||||||
|
.bubble {
|
||||||
|
position: relative;
|
||||||
|
max-width: 255px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 10px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-wrap: break-word;
|
||||||
|
border-radius: 25px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
filter 80ms ease,
|
||||||
|
transform 80ms ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sent bubble (share text preview) ── */
|
||||||
|
.bubble-sent {
|
||||||
|
color: white;
|
||||||
|
background: var(--sent-color);
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:hover {
|
||||||
|
background-color: #2ea8ff;
|
||||||
|
transform: rotate(-2deg) translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:hover::before {
|
||||||
|
background-color: #2ea8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:active {
|
||||||
|
background-color: #0878d4;
|
||||||
|
transform: rotate(-2deg) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent:active::before {
|
||||||
|
background-color: #0878d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sent tail: bottom-right */
|
||||||
|
.bubble-sent::before,
|
||||||
|
.bubble-sent::after {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
height: 25px;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent::before {
|
||||||
|
width: 20px;
|
||||||
|
right: -7px;
|
||||||
|
background-color: var(--sent-color);
|
||||||
|
border-bottom-left-radius: 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-sent::after {
|
||||||
|
width: 26px;
|
||||||
|
right: -26px;
|
||||||
|
border-bottom-left-radius: 10px;
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Received bubble (action button) ── */
|
||||||
|
.bubble-received {
|
||||||
|
color: #f5f5f7;
|
||||||
|
background: var(--received-color);
|
||||||
|
transform: rotate(2deg);
|
||||||
|
padding: 14px 24px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 14rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:hover {
|
||||||
|
background-color: #4a4a4e;
|
||||||
|
transform: rotate(2deg) translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:hover::before {
|
||||||
|
background-color: #4a4a4e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:active {
|
||||||
|
background-color: #2a2a2c;
|
||||||
|
transform: rotate(2deg) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received:active::before {
|
||||||
|
background-color: #2a2a2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received.success {
|
||||||
|
background: #c7f7d4;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Received tail: bottom-left (mirror of sent) */
|
||||||
|
.bubble-received::before,
|
||||||
|
.bubble-received::after {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
height: 25px;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received::before {
|
||||||
|
width: 20px;
|
||||||
|
left: -7px;
|
||||||
|
background-color: var(--received-color);
|
||||||
|
border-bottom-right-radius: 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received::after {
|
||||||
|
width: 26px;
|
||||||
|
left: -26px;
|
||||||
|
border-bottom-right-radius: 10px;
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-received.success::before {
|
||||||
|
background-color: #c7f7d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Copy hints ── */
|
||||||
|
.copy-hint {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #444;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
padding-right: 32px;
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
transform-origin: right center;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.copy-hint {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Snippet toggle row ── */
|
||||||
|
.snippet-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #666;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.snippet-label {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ccc;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 200ms ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snippet-toggle.on {
|
||||||
|
background: #34c759;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: transform 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { encodeBase64url } from '@oslojs/encoding';
|
|
||||||
|
|
||||||
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
||||||
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
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!
|
sub: Bun.env.APPLE_ID!
|
||||||
};
|
};
|
||||||
|
|
||||||
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
|
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
|
||||||
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
|
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
|
||||||
// Import PEM private key
|
// 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
|
// 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)
|
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
||||||
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
||||||
const encodedSignature = encodeBase64url(rawSignature);
|
const encodedSignature = Buffer.from(rawSignature).toString('base64url');
|
||||||
|
|
||||||
return `${signingInput}.${encodedSignature}`;
|
return `${signingInput}.${encodedSignature}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
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 { testDb as db } from '$lib/server/db/test';
|
||||||
import * as table from '$lib/server/db/schema';
|
import * as table from '$lib/server/db/schema';
|
||||||
|
|
||||||
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
|
|||||||
|
|
||||||
export function generateSessionToken() {
|
export function generateSessionToken() {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||||
const token = encodeBase64url(bytes);
|
return Buffer.from(bytes).toString('base64url');
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(token: string, userId: string) {
|
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 = {
|
const session: table.Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
userId,
|
userId,
|
||||||
@@ -27,7 +24,7 @@ export async function createSession(token: string, userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validateSessionToken(token: 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
|
const [result] = await db
|
||||||
.select({
|
.select({
|
||||||
// Adjust user table here to tweak returned data
|
// Adjust user table here to tweak returned data
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { sha256 } from '@oslojs/crypto/sha2';
|
|
||||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import * as table from '$lib/server/db/schema';
|
import * as table from '$lib/server/db/schema';
|
||||||
|
|
||||||
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
|
|||||||
|
|
||||||
export function generateSessionToken() {
|
export function generateSessionToken() {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||||
const token = encodeBase64url(bytes);
|
return Buffer.from(bytes).toString('base64url');
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(token: string, userId: string) {
|
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 = {
|
const session: table.Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
userId,
|
userId,
|
||||||
@@ -27,7 +24,7 @@ export async function createSession(token: string, userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validateSessionToken(token: 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
|
const [result] = await db
|
||||||
.select({
|
.select({
|
||||||
// Adjust user table here to tweak returned data
|
// Adjust user table here to tweak returned data
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
|
|
||||||
export const user = sqliteTable('user', {
|
export const user = sqliteTable('user', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
|
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
|
||||||
|
|
||||||
|
// Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet.
|
||||||
|
// Used to attribute stats to a player who hasn't signed in.
|
||||||
function getOrCreateAnonymousId(): string {
|
function getOrCreateAnonymousId(): string {
|
||||||
if (!browser) return "";
|
if (!browser) return "";
|
||||||
const key = "bibdle-anonymous-id";
|
const key = "bibdle-anonymous-id";
|
||||||
@@ -12,11 +14,21 @@ function getOrCreateAnonymousId(): string {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reactive store that keeps in-memory game state in sync with localStorage.
|
||||||
|
// Accepts getter functions (rather than plain values) so Svelte's reactivity
|
||||||
|
// system can track dependencies and re-run effects when they change.
|
||||||
|
type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function createGamePersistence(
|
export function createGamePersistence(
|
||||||
getDate: () => string,
|
getDate: () => string,
|
||||||
getReference: () => string,
|
getReference: () => string,
|
||||||
getCorrectBookId: () => string,
|
getCorrectBookId: () => string,
|
||||||
getUserId: () => string | undefined,
|
getUser: () => AuthUser | null | undefined,
|
||||||
) {
|
) {
|
||||||
let guesses = $state<Guess[]>([]);
|
let guesses = $state<Guess[]>([]);
|
||||||
let anonymousId = $state("");
|
let anonymousId = $state("");
|
||||||
@@ -24,27 +36,41 @@ export function createGamePersistence(
|
|||||||
let chapterGuessCompleted = $state(false);
|
let chapterGuessCompleted = $state(false);
|
||||||
let chapterCorrect = $state(false);
|
let chapterCorrect = $state(false);
|
||||||
|
|
||||||
// Initialize anonymous ID and load persisted flags
|
// On mount (and if the user logs in/out), resolve the player's identity and
|
||||||
|
// restore per-day flags from localStorage.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
const userId = getUserId();
|
const user = getUser();
|
||||||
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
||||||
if (userId) {
|
if (user) {
|
||||||
anonymousId = userId;
|
anonymousId = user.id;
|
||||||
} else {
|
} else {
|
||||||
anonymousId = getOrCreateAnonymousId();
|
anonymousId = getOrCreateAnonymousId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tell analytics which player this is so events are grouped correctly.
|
||||||
if ((window as any).umami) {
|
if ((window as any).umami) {
|
||||||
(window as any).umami.identify(anonymousId);
|
(window as any).umami.identify(anonymousId);
|
||||||
}
|
}
|
||||||
|
if (user) {
|
||||||
|
const nameParts = [user.firstName, user.lastName].filter(Boolean);
|
||||||
|
(window as any).rybbit?.identify(user.id, {
|
||||||
|
...(nameParts.length ? { name: nameParts.join(' ') } : {}),
|
||||||
|
...(user.email ? { email: user.email } : {}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
(window as any).rybbit?.identify(anonymousId);
|
||||||
|
}
|
||||||
|
|
||||||
const date = getDate();
|
const date = getDate();
|
||||||
const reference = getReference();
|
const reference = getReference();
|
||||||
|
|
||||||
|
// Restore whether today's completion was already submitted to the server.
|
||||||
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
|
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
|
||||||
|
|
||||||
|
// Restore the chapter bonus guess result. The stored value includes the
|
||||||
|
// chapter the player selected, so we can re-derive whether it was correct.
|
||||||
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||||
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
||||||
if (chapterGuessCompleted) {
|
if (chapterGuessCompleted) {
|
||||||
@@ -58,7 +84,9 @@ export function createGamePersistence(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load saved guesses from localStorage
|
// On mount (and if the date or correct answer changes), load today's guesses
|
||||||
|
// from localStorage and reconstruct them as typed Guess objects by re-evaluating
|
||||||
|
// each stored book ID against the correct answer.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
@@ -72,13 +100,14 @@ export function createGamePersistence(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let savedIds: string[] = JSON.parse(saved);
|
let savedIds: string[] = JSON.parse(saved);
|
||||||
savedIds = Array.from(new Set(savedIds));
|
savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case
|
||||||
guesses = savedIds
|
guesses = savedIds
|
||||||
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||||
.filter((g): g is Guess => g !== null);
|
.filter((g): g is Guess => g !== null);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save guesses to localStorage whenever they change
|
// Persist guesses to localStorage whenever they change. Only the book IDs are
|
||||||
|
// stored — the full Guess shape is re-derived on load (see effect above).
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
const date = getDate();
|
const date = getDate();
|
||||||
@@ -88,12 +117,16 @@ export function createGamePersistence(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Called after stats are successfully submitted to the server so that
|
||||||
|
// returning to the page doesn't trigger a duplicate submission.
|
||||||
function markStatsSubmitted() {
|
function markStatsSubmitted() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
statsSubmitted = true;
|
statsSubmitted = true;
|
||||||
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
|
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Marks the win as tracked for analytics. Returns true the first time (new
|
||||||
|
// win), false on subsequent calls so the analytics event fires exactly once.
|
||||||
function markWinTracked() {
|
function markWinTracked() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
const key = `bibdle-win-tracked-${getDate()}`;
|
const key = `bibdle-win-tracked-${getDate()}`;
|
||||||
@@ -102,11 +135,16 @@ export function createGamePersistence(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns true if the win has already been tracked in a previous render/session.
|
||||||
|
// Used to skip the animation delay when returning to an already-won game.
|
||||||
function isWinAlreadyTracked(): boolean {
|
function isWinAlreadyTracked(): boolean {
|
||||||
if (!browser) return false;
|
if (!browser) return false;
|
||||||
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
|
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overwrites local state with the server's authoritative guess record.
|
||||||
|
// Called when a logged-in user opens the game on a new device so their
|
||||||
|
// progress from another device is restored.
|
||||||
function hydrateFromServer(guessIds: string[]) {
|
function hydrateFromServer(guessIds: string[]) {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
const correctBookId = getCorrectBookId();
|
const correctBookId = getCorrectBookId();
|
||||||
@@ -114,10 +152,10 @@ export function createGamePersistence(
|
|||||||
guesses = guessIds
|
guesses = guessIds
|
||||||
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||||
.filter((g): g is Guess => g !== null);
|
.filter((g): g is Guess => g !== null);
|
||||||
// Persist to localStorage so subsequent loads on this device skip the server check
|
|
||||||
localStorage.setItem(`bibdle-guesses-${date}`, JSON.stringify(guessIds));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called by the WinScreen after the player submits their chapter bonus guess.
|
||||||
|
// Reads the result written to localStorage by WinScreen and updates reactive state.
|
||||||
function onChapterGuessCompleted() {
|
function onChapterGuessCompleted() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
chapterGuessCompleted = true;
|
chapterGuessCompleted = true;
|
||||||
|
|||||||
@@ -17,75 +17,73 @@ export interface BibleBook {
|
|||||||
testament: Testament;
|
testament: Testament;
|
||||||
section: BibleSection;
|
section: BibleSection;
|
||||||
order: number;
|
order: number;
|
||||||
url: string;
|
|
||||||
popularity: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bibleBooks: BibleBook[] = [
|
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: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1 },
|
||||||
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2, url: 'https://bible-api.com/data/web/EXO', popularity: 3 },
|
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2 },
|
||||||
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3, url: 'https://bible-api.com/data/web/LEV', popularity: 2 },
|
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3 },
|
||||||
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4, url: 'https://bible-api.com/data/web/NUM', popularity: 2 },
|
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4 },
|
||||||
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5, url: 'https://bible-api.com/data/web/DEU', popularity: 2 },
|
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5 },
|
||||||
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6, url: 'https://bible-api.com/data/web/JOS', popularity: 2 },
|
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6 },
|
||||||
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7, url: 'https://bible-api.com/data/web/JDG', popularity: 2 },
|
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7 },
|
||||||
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8, url: 'https://bible-api.com/data/web/RUT', popularity: 2 },
|
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8 },
|
||||||
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9, url: 'https://bible-api.com/data/web/1SA', popularity: 1 },
|
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9 },
|
||||||
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10, url: 'https://bible-api.com/data/web/2SA', popularity: 0 },
|
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10 },
|
||||||
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11, url: 'https://bible-api.com/data/web/1KI', popularity: 1 },
|
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11 },
|
||||||
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12, url: 'https://bible-api.com/data/web/2KI', popularity: 0 },
|
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12 },
|
||||||
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13, url: 'https://bible-api.com/data/web/1CH', popularity: 1 },
|
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13 },
|
||||||
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14, url: 'https://bible-api.com/data/web/2CH', popularity: 0 },
|
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14 },
|
||||||
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15, url: 'https://bible-api.com/data/web/EZR', popularity: 1 },
|
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15 },
|
||||||
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16, url: 'https://bible-api.com/data/web/NEH', popularity: 1 },
|
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16 },
|
||||||
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17, url: 'https://bible-api.com/data/web/EST', popularity: 1 },
|
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17 },
|
||||||
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18, url: 'https://bible-api.com/data/web/JOB', popularity: 2 },
|
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18 },
|
||||||
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19, url: 'https://bible-api.com/data/web/PSA', popularity: 7 },
|
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19 },
|
||||||
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20, url: 'https://bible-api.com/data/web/PRO', popularity: 7 },
|
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20 },
|
||||||
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21, url: 'https://bible-api.com/data/web/ECC', popularity: 2 },
|
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21 },
|
||||||
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22, url: 'https://bible-api.com/data/web/SNG', popularity: 2 },
|
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22 },
|
||||||
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23, url: 'https://bible-api.com/data/web/ISA', popularity: 2 },
|
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23 },
|
||||||
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24, url: 'https://bible-api.com/data/web/JER', popularity: 2 },
|
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24 },
|
||||||
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25, url: 'https://bible-api.com/data/web/LAM', popularity: 2 },
|
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25 },
|
||||||
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26, url: 'https://bible-api.com/data/web/EZK', popularity: 2 },
|
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26 },
|
||||||
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27, url: 'https://bible-api.com/data/web/DAN', popularity: 2 },
|
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27 },
|
||||||
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28, url: 'https://bible-api.com/data/web/HOS', popularity: 2 },
|
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28 },
|
||||||
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29, url: 'https://bible-api.com/data/web/JOL', popularity: 2 },
|
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29 },
|
||||||
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30, url: 'https://bible-api.com/data/web/AMO', popularity: 2 },
|
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30 },
|
||||||
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31, url: 'https://bible-api.com/data/web/OBA', popularity: 2 },
|
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31 },
|
||||||
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32, url: 'https://bible-api.com/data/web/JON', popularity: 2 },
|
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32 },
|
||||||
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33, url: 'https://bible-api.com/data/web/MIC', popularity: 2 },
|
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33 },
|
||||||
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34, url: 'https://bible-api.com/data/web/NAM', popularity: 2 },
|
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34 },
|
||||||
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35, url: 'https://bible-api.com/data/web/HAB', popularity: 2 },
|
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35 },
|
||||||
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36, url: 'https://bible-api.com/data/web/ZEP', popularity: 2 },
|
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36 },
|
||||||
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37, url: 'https://bible-api.com/data/web/HAG', popularity: 2 },
|
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37 },
|
||||||
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38, url: 'https://bible-api.com/data/web/ZEC', popularity: 2 },
|
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38 },
|
||||||
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39, url: 'https://bible-api.com/data/web/MAL', popularity: 2 },
|
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39 },
|
||||||
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40, url: 'https://bible-api.com/data/web/MAT', popularity: 8 },
|
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40 },
|
||||||
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41, url: 'https://bible-api.com/data/web/MRK', popularity: 8 },
|
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41 },
|
||||||
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42, url: 'https://bible-api.com/data/web/LUK', popularity: 8 },
|
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42 },
|
||||||
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43, url: 'https://bible-api.com/data/web/JHN', popularity: 8 },
|
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43 },
|
||||||
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44, url: 'https://bible-api.com/data/web/ACT', popularity: 2 },
|
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44 },
|
||||||
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45, url: 'https://bible-api.com/data/web/ROM', popularity: 6 },
|
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45 },
|
||||||
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46, url: 'https://bible-api.com/data/web/1CO', popularity: 5 },
|
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46 },
|
||||||
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47, url: 'https://bible-api.com/data/web/2CO', popularity: 5 },
|
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47 },
|
||||||
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48, url: 'https://bible-api.com/data/web/GAL', popularity: 5 },
|
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48 },
|
||||||
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49, url: 'https://bible-api.com/data/web/EPH', popularity: 5 },
|
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49 },
|
||||||
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50, url: 'https://bible-api.com/data/web/PHP', popularity: 5 },
|
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50 },
|
||||||
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51, url: 'https://bible-api.com/data/web/COL', popularity: 5 },
|
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51 },
|
||||||
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52, url: 'https://bible-api.com/data/web/1TH', popularity: 5 },
|
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52 },
|
||||||
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53, url: 'https://bible-api.com/data/web/2TH', popularity: 5 },
|
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53 },
|
||||||
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54, url: 'https://bible-api.com/data/web/1TI', popularity: 5 },
|
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54 },
|
||||||
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55, url: 'https://bible-api.com/data/web/2TI', popularity: 5 },
|
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55 },
|
||||||
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56, url: 'https://bible-api.com/data/web/TIT', popularity: 5 },
|
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56 },
|
||||||
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57, url: 'https://bible-api.com/data/web/PHM', popularity: 5 },
|
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57 },
|
||||||
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58, url: 'https://bible-api.com/data/web/HEB', popularity: 4 },
|
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58 },
|
||||||
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59, url: 'https://bible-api.com/data/web/JAS', popularity: 4 },
|
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59 },
|
||||||
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60, url: 'https://bible-api.com/data/web/1PE', popularity: 4 },
|
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60 },
|
||||||
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61, url: 'https://bible-api.com/data/web/2PE', popularity: 4 },
|
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61 },
|
||||||
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62, url: 'https://bible-api.com/data/web/1JN', popularity: 4 },
|
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62 },
|
||||||
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63, url: 'https://bible-api.com/data/web/2JN', popularity: 4 },
|
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63 },
|
||||||
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64, url: 'https://bible-api.com/data/web/3JN', popularity: 4 },
|
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64 },
|
||||||
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65, url: 'https://bible-api.com/data/web/JUD', popularity: 4 },
|
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65 },
|
||||||
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66, url: 'https://bible-api.com/data/web/REV', popularity: 2 }
|
{ 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 {
|
export function toOrdinal(n: number): string {
|
||||||
if (n >= 11 && n <= 13) {
|
if (n % 100 >= 11 && n % 100 <= 13) {
|
||||||
return `${n}th`;
|
return `${n}th`;
|
||||||
}
|
}
|
||||||
const mod = n % 10;
|
const mod = n % 10;
|
||||||
|
|||||||
@@ -1,16 +1,53 @@
|
|||||||
import type { Guess } from './game';
|
import type { Guess } from './game';
|
||||||
|
|
||||||
|
export function getVerseSnippet(verseText: string): string {
|
||||||
|
const words = verseText.trim().split(/\s+/);
|
||||||
|
const slice = words.slice(0, 25);
|
||||||
|
const text = slice.join(' ');
|
||||||
|
|
||||||
|
// Returns character index immediately after the Nth word (1-indexed)
|
||||||
|
function posAfterWord(n: number): number {
|
||||||
|
let pos = 0;
|
||||||
|
for (let w = 0; w < Math.min(n, slice.length); w++) {
|
||||||
|
if (w > 0) pos++; // space between words
|
||||||
|
pos += slice[w].length;
|
||||||
|
}
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = posAfterWord(9);
|
||||||
|
const end = posAfterWord(25);
|
||||||
|
|
||||||
|
// Find first punctuation mark between words 10 and 25
|
||||||
|
const range = text.substring(start, end);
|
||||||
|
const match = range.match(/[,;:.!?—–-]/);
|
||||||
|
|
||||||
|
function withClosedQuotes(snippet: string): string {
|
||||||
|
const opens = (snippet.match(/\u201C/g) ?? []).length;
|
||||||
|
const closes = (snippet.match(/\u201D/g) ?? []).length;
|
||||||
|
const closeQuote = opens > closes ? '\u201D' : '';
|
||||||
|
return `\u201C${snippet}...${closeQuote}\u201D`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match && match.index !== undefined) {
|
||||||
|
const cutPos = start + match.index;
|
||||||
|
return withClosedQuotes(text.substring(0, cutPos).trimEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
return withClosedQuotes(text);
|
||||||
|
}
|
||||||
|
|
||||||
export function generateShareText(params: {
|
export function generateShareText(params: {
|
||||||
guesses: Guess[];
|
guesses: Guess[];
|
||||||
correctBookId: string;
|
correctBookId: string;
|
||||||
dailyVerseDate: string;
|
dailyVerseDate: string;
|
||||||
grade: string;
|
|
||||||
chapterCorrect: boolean;
|
chapterCorrect: boolean;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
userStreak?: number;
|
streak?: number;
|
||||||
origin: string;
|
origin: string;
|
||||||
|
verseText: string;
|
||||||
}): string {
|
}): string {
|
||||||
const { guesses, correctBookId, dailyVerseDate, grade, chapterCorrect, isLoggedIn, userStreak, origin } = params;
|
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin, verseText } = params;
|
||||||
|
|
||||||
const emojis = guesses
|
const emojis = guesses
|
||||||
.slice()
|
.slice()
|
||||||
@@ -35,20 +72,16 @@ export function generateShareText(params: {
|
|||||||
|
|
||||||
const bookEmoji = isLoggedIn ? "📜" : "📖";
|
const bookEmoji = isLoggedIn ? "📜" : "📖";
|
||||||
|
|
||||||
|
const guessWord = guesses.length === 1 ? "guess" : "guesses";
|
||||||
|
const streakPart = streak !== undefined && streak > 1 ? ` ${streak} days 🔥` : "";
|
||||||
|
const chapterStar = guesses.length === 1 && chapterCorrect ? " ⭐" : "";
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
||||||
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
|
`${guesses.length} ${guessWord}${streakPart ? `,${streakPart}` : ""}`,
|
||||||
|
`${emojis}${chapterStar}`
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLoggedIn && userStreak !== undefined) {
|
|
||||||
lines.push(`🔥 ${userStreak} day streak`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(
|
|
||||||
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
|
||||||
origin,
|
|
||||||
);
|
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
src/lib/utils/streak.ts
Normal file
15
src/lib/utils/streak.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export async function fetchStreak(anonymousId: string, localDate: string): Promise<number> {
|
||||||
|
const params = new URLSearchParams({ anonymousId, localDate });
|
||||||
|
const res = await fetch(`/api/streak?${params}`);
|
||||||
|
if (!res.ok) return 0;
|
||||||
|
const data = await res.json();
|
||||||
|
return typeof data.streak === 'number' ? data.streak : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStreakPercentile(streak: number, localDate: string): Promise<number | null> {
|
||||||
|
const params = new URLSearchParams({ streak: String(streak), localDate });
|
||||||
|
const res = await fetch(`/api/streak-percentile?${params}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return typeof data.percentile === 'number' ? data.percentile : null;
|
||||||
|
}
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
|
||||||
|
|
||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
import favicon from "$lib/assets/favicon.ico";
|
import favicon from "$lib/assets/favicon.ico";
|
||||||
|
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (browser) {
|
// Inject analytics script
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.defer = true;
|
script.defer = true;
|
||||||
script.src = 'https://umami.snail.city/script.js';
|
script.src = 'https://umami.snail.city/script.js';
|
||||||
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
|
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
|
||||||
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
|
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
|
||||||
document.body.appendChild(script);
|
document.body.appendChild(script);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
@@ -22,5 +21,15 @@
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||||
|
<meta name="description" content="A daily Bible game" />
|
||||||
</svelte:head>
|
</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,22 +8,24 @@
|
|||||||
import GuessesTable from "$lib/components/GuessesTable.svelte";
|
import GuessesTable from "$lib/components/GuessesTable.svelte";
|
||||||
import WinScreen from "$lib/components/WinScreen.svelte";
|
import WinScreen from "$lib/components/WinScreen.svelte";
|
||||||
import Credits from "$lib/components/Credits.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 DevButtons from "$lib/components/DevButtons.svelte";
|
||||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
|
|
||||||
import { evaluateGuess, getGrade } from "$lib/utils/game";
|
import { evaluateGuess } from "$lib/utils/game";
|
||||||
import {
|
import {
|
||||||
generateShareText,
|
generateShareText,
|
||||||
shareResult,
|
shareResult,
|
||||||
copyToClipboard as clipboardCopy,
|
copyToClipboard as clipboardCopy,
|
||||||
} from "$lib/utils/share";
|
} from "$lib/utils/share";
|
||||||
|
import { fetchStreak, fetchStreakPercentile } from "$lib/utils/streak";
|
||||||
import {
|
import {
|
||||||
submitCompletion,
|
submitCompletion,
|
||||||
fetchExistingStats,
|
fetchExistingStats,
|
||||||
type StatsData,
|
type StatsData,
|
||||||
} from "$lib/utils/stats-client";
|
} from "$lib/utils/stats-client";
|
||||||
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
|
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
|
||||||
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
@@ -33,24 +35,6 @@
|
|||||||
let user = $derived(data.user);
|
let user = $derived(data.user);
|
||||||
let session = $derived(data.session);
|
let session = $derived(data.session);
|
||||||
|
|
||||||
let searchQuery = $state("");
|
|
||||||
let copied = $state(false);
|
|
||||||
let isDev = $state(false);
|
|
||||||
let authModalOpen = $state(false);
|
|
||||||
let showWinScreen = $state(false);
|
|
||||||
let statsData = $state<StatsData | null>(null);
|
|
||||||
|
|
||||||
const persistence = createGamePersistence(
|
|
||||||
() => dailyVerse.date,
|
|
||||||
() => dailyVerse.reference,
|
|
||||||
() => correctBookId,
|
|
||||||
() => user?.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
let guessedIds = $derived(
|
|
||||||
new Set(persistence.guesses.map((g) => g.book.id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentDate = $derived(
|
const currentDate = $derived(
|
||||||
new Date().toLocaleDateString("en-US", {
|
new Date().toLocaleDateString("en-US", {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
@@ -60,16 +44,29 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let searchQuery = $state("");
|
||||||
|
let copied = $state(false);
|
||||||
|
let isDev = $state(false);
|
||||||
|
let authModalOpen = $state(false);
|
||||||
|
let showWinScreen = $state(false);
|
||||||
|
let statsData = $state<StatsData | null>(null);
|
||||||
|
let streak = $state(0);
|
||||||
|
let streakPercentile = $state<number | null>(null);
|
||||||
|
|
||||||
|
const persistence = createGamePersistence(
|
||||||
|
() => dailyVerse.date,
|
||||||
|
() => dailyVerse.reference,
|
||||||
|
() => correctBookId,
|
||||||
|
() => user,
|
||||||
|
);
|
||||||
|
|
||||||
|
let guessedIds = $derived(
|
||||||
|
new SvelteSet(persistence.guesses.map((g) => g.book.id)),
|
||||||
|
);
|
||||||
|
|
||||||
let isWon = $derived(
|
let isWon = $derived(
|
||||||
persistence.guesses.some((g) => g.book.id === correctBookId),
|
persistence.guesses.some((g) => g.book.id === correctBookId),
|
||||||
);
|
);
|
||||||
let grade = $derived(
|
|
||||||
isWon
|
|
||||||
? persistence.guesses.length === 1 && persistence.chapterCorrect
|
|
||||||
? "S++"
|
|
||||||
: getGrade(persistence.guesses.length)
|
|
||||||
: "",
|
|
||||||
);
|
|
||||||
let blurChapter = $derived(
|
let blurChapter = $derived(
|
||||||
isWon &&
|
isWon &&
|
||||||
persistence.guesses.length === 1 &&
|
persistence.guesses.length === 1 &&
|
||||||
@@ -90,6 +87,7 @@
|
|||||||
(window as any).umami
|
(window as any).umami
|
||||||
) {
|
) {
|
||||||
(window as any).umami.track("First guess");
|
(window as any).umami.track("First guess");
|
||||||
|
(window as any).rybbit?.event("First guess");
|
||||||
localStorage.setItem(key, "true");
|
localStorage.setItem(key, "true");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,31 +112,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If server date doesn't match client's local date, fetch timezone-correct verse
|
|
||||||
$effect(() => {
|
|
||||||
if (!browser) return;
|
|
||||||
|
|
||||||
const localDate = new Date().toLocaleDateString("en-CA");
|
|
||||||
if (dailyVerse.date === localDate) return;
|
|
||||||
|
|
||||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
||||||
|
|
||||||
fetch("/api/daily-verse", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ date: localDate, timezone }),
|
|
||||||
})
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((result) => {
|
|
||||||
dailyVerse = result.dailyVerse;
|
|
||||||
correctBookId = result.correctBookId;
|
|
||||||
correctBook = result.correctBook;
|
|
||||||
})
|
|
||||||
.catch((err) =>
|
|
||||||
console.error("Failed to fetch timezone-correct verse:", err),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reload when the user returns to a stale tab on a new calendar day
|
// Reload when the user returns to a stale tab on a new calendar day
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
@@ -238,19 +211,36 @@
|
|||||||
(window as any).umami.track("Guessed correctly", {
|
(window as any).umami.track("Guessed correctly", {
|
||||||
totalGuesses: persistence.guesses.length,
|
totalGuesses: persistence.guesses.length,
|
||||||
});
|
});
|
||||||
|
(window as any).rybbit?.event("Guessed correctly", {
|
||||||
|
totalGuesses: persistence.guesses.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch streak when the player wins
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser || !isWon || !persistence.anonymousId) return;
|
||||||
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
|
fetchStreak(persistence.anonymousId, localDate).then((result) => {
|
||||||
|
streak = result;
|
||||||
|
if (result >= 2) {
|
||||||
|
fetchStreakPercentile(result, localDate).then((p) => {
|
||||||
|
streakPercentile = p;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function getShareText(): string {
|
function getShareText(): string {
|
||||||
return generateShareText({
|
return generateShareText({
|
||||||
guesses: persistence.guesses,
|
guesses: persistence.guesses,
|
||||||
correctBookId,
|
correctBookId,
|
||||||
dailyVerseDate: dailyVerse.date,
|
dailyVerseDate: dailyVerse.date,
|
||||||
grade,
|
|
||||||
chapterCorrect: persistence.chapterCorrect,
|
chapterCorrect: persistence.chapterCorrect,
|
||||||
isLoggedIn: !!user,
|
isLoggedIn: !!user,
|
||||||
userStreak: user ? (user as any).streak : undefined,
|
streak,
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
|
verseText: dailyVerse.verseText,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,20 +283,13 @@
|
|||||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
|
<div class="pb-8">
|
||||||
<div class="w-full max-w-3xl mx-auto px-4">
|
<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">
|
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
|
||||||
<span class="big-text"
|
<span class="big-text"
|
||||||
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="animate-fade-in-up animate-delay-200">
|
<div class="animate-fade-in-up animate-delay-200">
|
||||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||||
@@ -314,12 +297,16 @@
|
|||||||
|
|
||||||
{#if !isWon}
|
{#if !isWon}
|
||||||
<div class="animate-fade-in-up animate-delay-400">
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
<SearchInput
|
||||||
|
bind:searchQuery
|
||||||
|
{guessedIds}
|
||||||
|
{submitGuess}
|
||||||
|
guessCount={persistence.guesses.length}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if showWinScreen}
|
{:else if showWinScreen}
|
||||||
<div class="animate-fade-in-up animate-delay-400">
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
<WinScreen
|
<WinScreen
|
||||||
{grade}
|
|
||||||
{statsData}
|
{statsData}
|
||||||
{correctBookId}
|
{correctBookId}
|
||||||
{handleShare}
|
{handleShare}
|
||||||
@@ -329,6 +316,12 @@
|
|||||||
guessCount={persistence.guesses.length}
|
guessCount={persistence.guesses.length}
|
||||||
reference={dailyVerse.reference}
|
reference={dailyVerse.reference}
|
||||||
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
||||||
|
shareText={getShareText()}
|
||||||
|
verseText={dailyVerse.verseText}
|
||||||
|
{streak}
|
||||||
|
{streakPercentile}
|
||||||
|
isLoggedIn={!!user}
|
||||||
|
anonymousId={persistence.anonymousId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -342,47 +335,16 @@
|
|||||||
<Credits />
|
<Credits />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{#if isDev}
|
{#if isDev}
|
||||||
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
|
<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
|
<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><strong>Debug Info:</strong></div>
|
||||||
<div>
|
<div>
|
||||||
@@ -412,19 +374,33 @@
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>Daily Verse Date: {dailyVerse.date}</div>
|
<div>Daily Verse Date: {dailyVerse.date}</div>
|
||||||
|
<div>Streak: {streak}</div>
|
||||||
</div>
|
</div>
|
||||||
<DevButtons />
|
<DevButtons anonymousId={persistence.anonymousId} {user} onSignIn={() => (authModalOpen = true)} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if user && session}
|
{#if user && session}
|
||||||
<div class="mt-6 pt-4 border-t border-gray-200 text-center text-xs text-gray-400">
|
<div
|
||||||
Signed in as {[user.firstName, user.lastName].filter(Boolean).join(" ")}{user.email ? ` (${user.email})` : ""}{user.appleId ? " using Apple" : ""}
|
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"
|
||||||
<form method="POST" action="/auth/logout" use:enhance class="inline">
|
>
|
||||||
|
Signed in as {[user.firstName, user.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}{user.email
|
||||||
|
? ` (${user.email})`
|
||||||
|
: ""}{user.appleId ? " using Apple" : ""} |
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="/auth/logout"
|
||||||
|
use:enhance
|
||||||
|
class="inline"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="ml-2 underline hover:text-gray-600 transition-colors cursor-pointer"
|
class="ml-2 underline hover:text-gray-600 transition-colors cursor-pointer"
|
||||||
>Sign out</button>
|
>Sign out</button
|
||||||
|
>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
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>
|
||||||
66
src/routes/api/dev/seed-history/+server.ts
Normal file
66
src/routes/api/dev/seed-history/+server.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const DEV_HOSTS = ['localhost:5173', 'test.bibdle.com'];
|
||||||
|
|
||||||
|
// A spread of book IDs to use as fake guesses
|
||||||
|
const SAMPLE_BOOK_IDS = [
|
||||||
|
'GEN', 'EXO', 'PSA', 'PRO', 'ISA', 'JER', 'MAT', 'MRK', 'LUK', 'JHN',
|
||||||
|
'ROM', 'GAL', 'EPH', 'PHP', 'REV', 'ACT', 'HEB', 'JAS', '1CO', '2CO',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const host = request.headers.get('host') ?? '';
|
||||||
|
if (!DEV_HOSTS.includes(host)) {
|
||||||
|
return json({ error: 'Not allowed in production' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { anonymousId, days = 10 } = await request.json();
|
||||||
|
|
||||||
|
if (!anonymousId || typeof anonymousId !== 'string') {
|
||||||
|
return json({ error: 'anonymousId required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const inserted: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= days; i++) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const date = d.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||||
|
|
||||||
|
const guessCount = Math.floor(Math.random() * 6) + 1; // 1-6 guesses
|
||||||
|
// Pick `guessCount` random books (last one is the "correct" answer)
|
||||||
|
const shuffled = [...SAMPLE_BOOK_IDS].sort(() => Math.random() - 0.5);
|
||||||
|
const guesses = shuffled.slice(0, guessCount);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(dailyCompletions).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId,
|
||||||
|
date,
|
||||||
|
guessCount,
|
||||||
|
guesses: JSON.stringify(guesses),
|
||||||
|
completedAt: new Date(d.getTime() + 12 * 60 * 60 * 1000), // noon on that day
|
||||||
|
});
|
||||||
|
inserted.push(date);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
|
||||||
|
skipped.push(date);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true, inserted, skipped });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error seeding history:', err);
|
||||||
|
return json({ error: 'Failed to seed history' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
103
src/routes/api/streak-percentile/+server.ts
Normal file
103
src/routes/api/streak-percentile/+server.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const streakParam = url.searchParams.get('streak');
|
||||||
|
const localDate = url.searchParams.get('localDate');
|
||||||
|
|
||||||
|
if (!streakParam || !localDate) {
|
||||||
|
error(400, 'Missing streak or localDate');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStreak = parseInt(streakParam, 10);
|
||||||
|
if (isNaN(targetStreak) || targetStreak < 1) {
|
||||||
|
error(400, 'Invalid streak');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all completions ordered by anonymous_id and date desc
|
||||||
|
// so we can walk each user's history to compute their current streak.
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
anonymousId: dailyCompletions.anonymousId,
|
||||||
|
date: dailyCompletions.date,
|
||||||
|
})
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.orderBy(desc(dailyCompletions.date));
|
||||||
|
|
||||||
|
// Group dates by user
|
||||||
|
const byUser = new Map<string, string[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byUser.get(row.anonymousId);
|
||||||
|
if (list) {
|
||||||
|
list.push(row.date);
|
||||||
|
} else {
|
||||||
|
byUser.set(row.anonymousId, [row.date]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the current streak for each user.
|
||||||
|
// Start from today; if the user hasn't played today yet, try yesterday so
|
||||||
|
// that streaks aren't zeroed out mid-day before the player has had a chance
|
||||||
|
// to complete today's puzzle.
|
||||||
|
const yesterday = new Date(`${localDate}T00:00:00`);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const yesterdayStr = yesterday.toLocaleDateString('en-CA');
|
||||||
|
|
||||||
|
const thirtyDaysAgo = new Date(`${localDate}T00:00:00`);
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
const thirtyDaysAgoStr = thirtyDaysAgo.toLocaleDateString('en-CA');
|
||||||
|
|
||||||
|
// For each user, compute their current streak and whether they've played
|
||||||
|
// within the last 30 days. "Eligible players" = active streak OR recent play.
|
||||||
|
const userStats: { streak: number; isEligible: boolean }[] = [];
|
||||||
|
for (const [, dates] of byUser) {
|
||||||
|
// dates are already desc-sorted
|
||||||
|
const dateSet = new Set(dates);
|
||||||
|
|
||||||
|
// Pick the most recent anchor: today if played, otherwise yesterday
|
||||||
|
const anchor = dateSet.has(localDate) ? localDate : yesterdayStr;
|
||||||
|
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = new Date(`${anchor}T00:00:00`);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const dateStr = cursor.toLocaleDateString('en-CA');
|
||||||
|
if (!dateSet.has(dateStr)) break;
|
||||||
|
streak++;
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRecentPlay = dates.some((d) => d >= thirtyDaysAgoStr);
|
||||||
|
userStats.push({ streak, isEligible: streak >= 1 || hasRecentPlay });
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligiblePlayers = userStats.filter((u) => u.isEligible);
|
||||||
|
|
||||||
|
if (eligiblePlayers.length === 0) {
|
||||||
|
console.log('[streak-percentile] No eligible players found, returning 100th percentile');
|
||||||
|
return json({ percentile: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percentage of eligible players who have a streak >= targetStreak
|
||||||
|
const atOrAbove = eligiblePlayers.filter((u) => u.streak >= targetStreak).length;
|
||||||
|
const raw = (atOrAbove / eligiblePlayers.length) * 100;
|
||||||
|
const percentile = raw < 1 ? Math.round(raw * 100) / 100 : Math.round(raw);
|
||||||
|
|
||||||
|
console.log('[streak-percentile]', {
|
||||||
|
localDate,
|
||||||
|
targetStreak,
|
||||||
|
totalUsers: byUser.size,
|
||||||
|
totalRows: rows.length,
|
||||||
|
eligiblePlayers: eligiblePlayers.length,
|
||||||
|
activeStreaks: userStats.filter((u) => u.streak >= 1).length,
|
||||||
|
recentPlayers: userStats.filter((u) => u.isEligible).length,
|
||||||
|
atOrAbove,
|
||||||
|
raw,
|
||||||
|
percentile,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ percentile });
|
||||||
|
};
|
||||||
42
src/routes/api/streak/+server.ts
Normal file
42
src/routes/api/streak/+server.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const anonymousId = url.searchParams.get('anonymousId');
|
||||||
|
const localDate = url.searchParams.get('localDate');
|
||||||
|
|
||||||
|
if (!anonymousId || !localDate) {
|
||||||
|
error(400, 'Missing anonymousId or localDate');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all completion dates for this user (stored as the user's local date)
|
||||||
|
const rows = await db
|
||||||
|
.select({ date: dailyCompletions.date })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, anonymousId))
|
||||||
|
.orderBy(desc(dailyCompletions.date));
|
||||||
|
|
||||||
|
const completedDates = new Set(rows.map((r) => r.date));
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 { redirect } from '@sveltejs/kit';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
||||||
import { encodeBase64url } from '@oslojs/encoding';
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({ cookies, request }) => {
|
default: async ({ cookies, request }) => {
|
||||||
@@ -10,7 +9,7 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
// Generate CSRF state
|
// Generate CSRF state
|
||||||
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
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
|
// Store state + anonymousId in a short-lived cookie
|
||||||
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
}
|
}
|
||||||
cookies.delete('apple_oauth_state', { path: '/' });
|
cookies.delete('apple_oauth_state', { path: '/' });
|
||||||
|
|
||||||
|
const anonId = stored.anonymousId;
|
||||||
|
if (!anonId) {
|
||||||
|
console.error('[Apple auth] Missing anonymousId in state cookie');
|
||||||
|
throw error(400, 'Missing anonymous ID — please return to the game and try again');
|
||||||
|
}
|
||||||
|
|
||||||
// Exchange authorization code for tokens
|
// Exchange authorization code for tokens
|
||||||
const tokens = await exchangeAppleCode(code, `${publicEnv.PUBLIC_SITE_URL}/auth/apple/callback`);
|
const tokens = await exchangeAppleCode(code, `${publicEnv.PUBLIC_SITE_URL}/auth/apple/callback`);
|
||||||
const claims = decodeAppleIdToken(tokens.id_token);
|
const claims = decodeAppleIdToken(tokens.id_token);
|
||||||
@@ -51,7 +57,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
|
|
||||||
if (existingAppleUser) {
|
if (existingAppleUser) {
|
||||||
userId = existingAppleUser.id;
|
userId = existingAppleUser.id;
|
||||||
await auth.migrateAnonymousStats(stored.anonymousId, userId);
|
console.log(`[Apple auth] Returning Apple user: userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
} else if (claims.email) {
|
} else if (claims.email) {
|
||||||
// 2. Check if email matches an existing email/password user
|
// 2. Check if email matches an existing email/password user
|
||||||
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
||||||
@@ -59,10 +66,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
// Link Apple account to existing user
|
// Link Apple account to existing user
|
||||||
await db.update(userTable).set({ appleId }).where(eq(userTable.id, existingEmailUser.id));
|
await db.update(userTable).set({ appleId }).where(eq(userTable.id, existingEmailUser.id));
|
||||||
userId = existingEmailUser.id;
|
userId = existingEmailUser.id;
|
||||||
await auth.migrateAnonymousStats(stored.anonymousId, userId);
|
console.log(`[Apple auth] Linked Apple to existing email user: userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
} else {
|
} else {
|
||||||
// 3. Brand new user — use anonymousId as user ID to preserve local stats
|
// 3. Brand new user — use anonymousId as user ID to preserve local stats
|
||||||
userId = stored.anonymousId || crypto.randomUUID();
|
userId = anonId;
|
||||||
|
console.log(`[Apple auth] New user (has email): userId=${userId}`);
|
||||||
try {
|
try {
|
||||||
await db.insert(userTable).values({
|
await db.insert(userTable).values({
|
||||||
id: userId,
|
id: userId,
|
||||||
@@ -79,6 +88,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
const retryUser = await auth.getUserByAppleId(appleId);
|
const retryUser = await auth.getUserByAppleId(appleId);
|
||||||
if (retryUser) {
|
if (retryUser) {
|
||||||
userId = retryUser.id;
|
userId = retryUser.id;
|
||||||
|
console.log(`[Apple auth] Race condition (has email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
} else {
|
} else {
|
||||||
throw error(500, 'Failed to create user');
|
throw error(500, 'Failed to create user');
|
||||||
}
|
}
|
||||||
@@ -89,7 +100,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No email from Apple — create account with appleId only
|
// No email from Apple — create account with appleId only
|
||||||
userId = stored.anonymousId || crypto.randomUUID();
|
userId = anonId;
|
||||||
|
console.log(`[Apple auth] New user (no email): userId=${userId}`);
|
||||||
try {
|
try {
|
||||||
await db.insert(userTable).values({
|
await db.insert(userTable).values({
|
||||||
id: userId,
|
id: userId,
|
||||||
@@ -105,6 +117,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
const retryUser = await auth.getUserByAppleId(appleId);
|
const retryUser = await auth.getUserByAppleId(appleId);
|
||||||
if (retryUser) {
|
if (retryUser) {
|
||||||
userId = retryUser.id;
|
userId = retryUser.id;
|
||||||
|
console.log(`[Apple auth] Race condition (no email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
} else {
|
} else {
|
||||||
throw error(500, 'Failed to create user');
|
throw error(500, 'Failed to create user');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from "$app/state";
|
||||||
import AuthModal from '$lib/components/AuthModal.svelte';
|
import { browser } from "$app/environment";
|
||||||
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
|
|
||||||
let isOpen = $state(true);
|
let isOpen = $state(true);
|
||||||
const user = $derived(page.data.user);
|
const user = $derived(page.data.user);
|
||||||
const anonymousId = crypto.randomUUID();
|
let anonymousId = $state("");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (browser) {
|
||||||
|
anonymousId = localStorage.getItem("bibdle-anonymous-id") ?? "";
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
<div class="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||||
{#if user}
|
{#if user}
|
||||||
<div class="text-white text-center space-y-4">
|
<div class="text-white text-center space-y-4">
|
||||||
<p class="text-lg">Signed in as <strong>{user.email ?? 'no email'}</strong></p>
|
<p class="text-lg">
|
||||||
|
Signed in as <strong>{user.email ?? "no email"}</strong>
|
||||||
|
</p>
|
||||||
<form method="POST" action="/auth/logout">
|
<form method="POST" action="/auth/logout">
|
||||||
<button class="px-4 py-2 bg-red-600 rounded-md hover:bg-red-700 transition-colors">
|
<button
|
||||||
|
class="px-4 py-2 bg-red-600 rounded-md hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
onclick={() => isOpen = true}
|
onclick={() => (isOpen = true)}
|
||||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
Open Auth Modal
|
Open Auth Modal
|
||||||
|
|||||||
@@ -2,12 +2,29 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-triodion: "PT Serif", serif;
|
--font-triodion: "PT Serif", serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
background: oklch(89.126% 0.06134 298.626);
|
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 {
|
.big-text {
|
||||||
@@ -18,6 +35,20 @@ html, body {
|
|||||||
font-weight: 700;
|
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 */
|
/* Page load animations */
|
||||||
@keyframes fadeInUp {
|
@keyframes fadeInUp {
|
||||||
from {
|
from {
|
||||||
|
|||||||
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
|
// Note: userToday is used only for the initial server-side streak estimate.
|
||||||
const timezone = url.searchParams.get('tz') || 'UTC';
|
// The client overrides this with a precise local-date calculation via /api/streak.
|
||||||
const userToday = new Date().toLocaleDateString('en-CA', { timeZone: timezone });
|
const userToday = new Date().toISOString().slice(0, 10); // UTC date as safe fallback
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all completions for this user
|
// 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
|
'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
|
const sortedDates = completions
|
||||||
.map((c: DailyCompletion) => c.date)
|
.map((c: DailyCompletion) => c.date)
|
||||||
.sort();
|
.sort();
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { enhance } from "$app/forms";
|
|
||||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
import Container from "$lib/components/Container.svelte";
|
import Container from "$lib/components/Container.svelte";
|
||||||
import { bibleBooks } from "$lib/types/bible";
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
import {
|
import {
|
||||||
getGradeColor,
|
|
||||||
formatDate,
|
formatDate,
|
||||||
getStreakMessage,
|
|
||||||
getPerformanceMessage,
|
|
||||||
type UserStats,
|
type UserStats,
|
||||||
} from "$lib/utils/stats";
|
} from "$lib/utils/stats";
|
||||||
|
import { fetchStreak } from "$lib/utils/streak";
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
stats: UserStats | null;
|
stats: UserStats | null;
|
||||||
@@ -24,8 +20,10 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
let authModalOpen = $state(false);
|
let authModalOpen = $state(false);
|
||||||
|
let anonymousId = $state("");
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let currentStreak = $state(0);
|
||||||
|
|
||||||
function getOrCreateAnonymousId(): string {
|
function getOrCreateAnonymousId(): string {
|
||||||
if (!browser) return "";
|
if (!browser) return "";
|
||||||
@@ -39,13 +37,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
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;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getGradePercentage(count: number, total: number): number {
|
|
||||||
return total > 0 ? Math.round((count / total) * 100) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBookName(bookId: string): string {
|
function getBookName(bookId: string): string {
|
||||||
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
||||||
}
|
}
|
||||||
@@ -158,7 +159,7 @@
|
|||||||
<div
|
<div
|
||||||
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
||||||
>
|
>
|
||||||
{stats.currentStreak}
|
{currentStreak}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||||
@@ -331,85 +332,9 @@
|
|||||||
</Container>
|
</Container>
|
||||||
</div>
|
</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}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AuthModal bind:isOpen={authModalOpen} anonymousId="" />
|
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
|
||||||
|
|||||||
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 -->
|
||||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
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
|
# allow crawling everything by default
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow:
|
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
|
// 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
57
todo.md
57
todo.md
@@ -59,10 +59,61 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
|
|
||||||
# done
|
# 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
|
||||||
|
- Share rate jumped from ~17% to ~27% (n=200) after share button redesign
|
||||||
|
- Updated streak-percentile to count all players from last 30 days and all streaks (in case there are streaks >30 days)
|
||||||
|
- Added copy verse button
|
||||||
|
- Refactored book search input to show progressively more info based on guess count
|
||||||
|
|
||||||
|
## february 21st
|
||||||
|
|
||||||
|
- Added streak counter and streak percentage
|
||||||
|
- Added Rybbit analytics alongside Umami
|
||||||
|
|
||||||
|
## february 18th-19th
|
||||||
|
|
||||||
|
- Refactored game logic into utility modules
|
||||||
|
- Small fixes to Sign In with Apple migrations
|
||||||
|
|
||||||
|
## february 13th
|
||||||
|
|
||||||
|
- Added Sign In with Apple
|
||||||
|
- Added animations on win and guess
|
||||||
|
- Various Apple auth bug fixes
|
||||||
|
|
||||||
|
## february 11th-12th
|
||||||
|
|
||||||
|
- Client-side timezone handling for daily verses (was using server time)
|
||||||
|
- Staggered page load animations
|
||||||
|
- Reordered guesses table with emphasis
|
||||||
|
- Redesigned stats page with dark theme and enhanced statistics
|
||||||
|
|
||||||
|
## february 5th-10th
|
||||||
|
|
||||||
|
- Added login modal and auth infrastructure
|
||||||
|
- Switched to `bun:sqlite`
|
||||||
|
- Support authenticated users in stats and page loading
|
||||||
|
- Anonymous stats migration on sign-in
|
||||||
|
- Test infrastructure and sign-in migration tests
|
||||||
|
|
||||||
## february 2nd
|
## february 2nd
|
||||||
|
|
||||||
- created rss feed
|
- created rss feed
|
||||||
- fixed "first letter" clue edge cases
|
- fixed "first letter" clue edge cases / easter egg
|
||||||
- updated ranking formula
|
- updated ranking formula
|
||||||
|
|
||||||
## january 28th
|
## january 28th
|
||||||
@@ -82,6 +133,10 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
- added "first letter" column
|
- added "first letter" column
|
||||||
- added imposter mode, v0.1 (mom likes it) but needs work
|
- added imposter mode, v0.1 (mom likes it) but needs work
|
||||||
|
|
||||||
|
## january 8th
|
||||||
|
|
||||||
|
- posted on Hacker News and LinkedIn, got 960 visitors in one day
|
||||||
|
|
||||||
## january 5th
|
## january 5th
|
||||||
|
|
||||||
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
|
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
|
||||||
|
|||||||
Reference in New Issue
Block a user