14 Commits

Author SHA1 Message Date
George Powell
ae4482a551 refactor: move View Stats and Sign In/Out buttons into DevButtons
Consolidates the dev-only stats and auth buttons into the DevButtons
component, passing user and onSignIn as props. Also comments out the
Twitter link in SocialLinks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:47:03 -04:00
George Powell
884bbe65c7 feat: add about page, sitemap, social links component, Apple sign-in
prompt on win screen, and layout/theme improvements

  ## New features

  - **About page** (`src/routes/about/`): New static about page rendered
    from `static/about.md` using the `marked` library (added as a
    dependency). Includes the project backstory content.

  - **XML sitemap** (`src/routes/sitemap.xml/`): Dynamic sitemap
    endpoint for SEO, registered in `static/robots.txt` via `Sitemap:`
    directive.

  - **Apple Sign In prompt on win screen** (`WinScreen.svelte`): When
    the game is won and the user is not logged in, a "Sign in to save
    your streak & see your stats" prompt with an Apple Sign In button is
    shown below the share card. Passes `anonymousId` so stats migrate on
    sign-up. Driven by new `isLoggedIn` and `anonymousId` props, passed
    from `+page.svelte`.

  ## Refactoring

  - **`SocialLinks` component**
    (`src/lib/components/SocialLinks.svelte`): Extracted the Bluesky,
    Twitter/X, and email social link icons from `Credits.svelte` into a
    reusable component. `Credits.svelte` now imports and renders
    `<SocialLinks />`.

  - **`ThemeToggle` component**
    (`src/lib/components/ThemeToggle.svelte`): New component for
    toggling light/dark mode, persisted to `localStorage`. Currently
    rendered but hidden (`hidden` class) in `+page.svelte` —
    infrastructure is in place for future use.

  ## Layout changes

  - **`+layout.svelte`**: Moved the page title/header (`<h1>` with
    `TitleAnimation`) and the gradient background wrapper from
    `+page.svelte` into the root layout, so it applies across all
    routes. Also removed the `browser` guard around the analytics script
    injection (it's
    already inside `onMount` which is client-only). Added `<meta
    name="description">`.

  - **`+page.svelte`**: Removed the title/header and gradient wrapper
    (now in layout). Minor formatting cleanup (reformatted `SearchInput`
    props, moved `currentDate` derived state earlier). `ThemeToggle`
    import swapped in place of `TitleAnimation` (which moved to layout).

  ## Styling

  - **`layout.css`**: Added `@custom-variant dark` for class-based dark
    mode toggling (supports `.dark` class on `<html>`). Added explicit
    `html.dark` / `html.light` rules alongside the existing
    `prefers-color-scheme` media query, so the `ThemeToggle` component
    can
    override the system preference. Added background transition
    animation.
2026-03-12 18:22:59 -04:00
George Powell
3de55ba216 removed large xml and systemd service 2026-02-28 03:06:49 -05:00
George Powell
6e74fffb65 no longer initializes embeddings model on startup 2026-02-28 02:48:46 -05:00
George Powell
1ae2b2ac6c fix: remove trailing comma in share text when there is no streak
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 15:13:13 -05:00
George Powell
a188be167b added single day history button 2026-02-26 15:04:34 -05:00
George Powell
e550965086 add unit tests for core game, bible, share, and stats utilities
146 tests covering evaluateGuess, grading, ordinals, bible data
integrity, section counts, share text generation, and stat
string helpers. Also fixes toOrdinal for 111-113 (was using
>= 11 && <= 13 instead of % 100 check).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 15:02:46 -05:00
George Powell
03429b17cc fix: prevent single-day streaks from being shown
A streak requires at least 2 consecutive days. Return 0 when the
count is less than 2 so the streak counter is not displayed after
completing only today's puzzle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:57:44 -05:00
George Powell
3ee7331510 remove grade distribution and recent performance sections from stats page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:40:25 -05:00
George Powell
592fa917cd added device-dependent dark mode 2026-02-26 07:27:30 -05:00
George Powell
ad1774e6b0 removed url from share, like wordle 2026-02-26 06:55:04 -05:00
George Powell
e1a665ba63 added toggle verse display and fixed timer spacing 2026-02-26 01:23:13 -05:00
George Powell
f3c9feaf97 removed verse snippet from share 2026-02-26 00:52:00 -05:00
George Powell
a5cf248e29 added streak container 2026-02-26 00:51:48 -05:00
48 changed files with 2169 additions and 522 deletions

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ embeddings*
engwebu_usfx.xml
deploy.log
bibdle.socket

132
CLAUDE.md
View File

@@ -27,19 +27,14 @@ After calling the list-sections tool, you MUST analyze the returned documentatio
Analyzes Svelte code and returns issues and suggestions.
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
### 4. playground-link
Generates a Svelte Playground link with the provided code.
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
## Tech Stack
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
- **Styling**: Tailwind CSS 4
- **Database**: SQLite with Drizzle ORM
- **Auth**: Session-based authentication using @oslojs/crypto (SHA-256 hashed tokens)
- **Auth**: Session-based authentication using Bun's built-in cryptographically secure functions
- **Deployment**: Node.js adapter for production builds
- **External API**: bible-api.com for fetching random verses
- **ML**: `@xenova/transformers` for verse embeddings (initialized in server hook) (currently disabled, was a test for a cancelled project)
## Development Commands
@@ -51,6 +46,11 @@ bun run dev
bun run check
bun run check:watch
# Run tests
bun test
bun test --watch
bun test tests/timezone-handling.test.ts # Run a single test file
# Build for production
bun run build
@@ -58,92 +58,108 @@ bun run build
bun run preview
# Database operations
bun run db:push # Push schema changes to database
bun run db:generate # Generate migrations (DO NOT RUN)
bun run db:migrate # Run migrations (DO NOT RUN)
bun run db:push # Push schema changes directly (avoid in prod)
bun run db:generate # Generate migrations
bun run db:migrate # Run migrations
bun run db:studio # Open Drizzle Studio GUI
```
## Critical: Date/Time Handling
**Bibdle is played by users across many timezones worldwide. The verse shown to a player must always be the verse for the calendar date at *their* location — not the server's timezone, not UTC. A user in Tokyo on Wednesday must see Wednesday's verse, even if the server (or a user in New York) is still on Tuesday.**
**NEVER use server time or UTC time for user-facing date calculations.**
- Get today's date client-side: `new Date().toLocaleDateString("en-CA")``YYYY-MM-DD`
- Pass the date to the server as a query param or POST body (`localDate`)
- Server-side date arithmetic must use UTC methods on the client-provided date string: `new Date(dateStr + 'T00:00:00Z')` + `setUTCDate`/`getUTCDate`
- `src/routes/+page.ts` has `ssr = false` so the load runs client-side with the true local date
- Never set the user-facing URL to include their date as a parameter. It should always be passed to an API route behind the scenes if needed.
### Streak Calculation
A streak counts consecutive calendar days (in the user's local timezone) on which the user completed the puzzle. The rules:
- The client passes its local date (`localDate`) to the streak API. The server never uses its own clock.
- A streak is **active** if the user has completed today's puzzle *or* yesterday's puzzle (they still have time to play today).
- Walk backwards from `localDate` through the `dailyCompletions` records, counting each day that has a completion. Stop as soon as a day is missing.
- A streak of 1 (completed only today or only yesterday, with no prior consecutive days) is **not displayed** — the minimum shown streak is 2.
- "Yesterday" and all date arithmetic on the server must use UTC methods on the client-provided date string to avoid timezone drift: `new Date(localDate + 'T00:00:00Z')`, then `setUTCDate`/`getUTCDate`.
## Architecture
### Database Schema (`src/lib/server/db/schema.ts`)
- **user**: User accounts with id and age
- **session**: Auth sessions linked to users with expiration timestamps
- **user**: `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `isPrivate`
- **session**: `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
- **daily_verses**: Cached daily verses with book ID, verse text, reference, and date
- **dailyCompletions**: Game results per user/date with guess count, grade, book; unique on `(userId, date)`
Sessions expire after 30 days and are automatically renewed when less than 15 days remain.
Sessions expire after 30 days and auto-renew when < 15 days remain.
### Bible Data (`src/lib/types/bible.ts`)
The `bibleBooks` array contains all 66 Bible books with metadata:
- Testament (old/new)
- Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
- Testament (old/new), Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
- Order (1-66, used for adjacency detection)
- Popularity (2-10, affects grading - higher is more popular)
### Daily Verse System (`src/routes/+page.server.ts`)
The `getTodayVerse()` function:
1. Checks database for existing verse for today's date
2. If none exists, fetches from bible-api.com (random verse + 2 consecutive verses)
3. Caches in database with UTC date key
4. Returns verse with book metadata for the game
`getTodayVerse()` checks the database for today's date, fetches a verse if missing, caches permanently, and returns verse with book metadata.
### Game Logic (`src/routes/+page.svelte`)
**State Management:**
- `guesses` array stores game state in localStorage keyed by date
- `guesses` array stored in localStorage keyed by date: `bibdle-guesses-${date}`
- Each guess tracks: book, testamentMatch, sectionMatch, adjacent
- `isWon` is derived from whether any guess matches the correct book
- `isWon` derived from whether any guess matches the correct book
**Grading System:**
```javascript
// Grade formula combines performance + difficulty
performanceScore = max(0, 10 - numGuesses)
difficulty = 14 - popularity
totalScore = performanceScore + difficulty * 0.8
// S: 14+, A: 11+, B: 8+, C: 5+, C-: <5
```
**Hint System:**
- ✅ Green checkmark: Exact match
- 🟩 Green square: Section matches
- 🟧 Orange square: Testament matches (shared results)
- ‼️ Double exclamation: Adjacent book in Bible order
- 🟥 Red square: No match
**Hint System, for share grid:**
- ✅ Exact match | 🟩 Section match | 🟧 Testament match | ‼️ Adjacent book | 🟥 No match
### Authentication System (`src/lib/server/auth.ts`)
- Token-based sessions with SHA-256 hashing
- Cookies store session tokens, validated on each request
- Hook in `src/hooks.server.ts` populates `event.locals.user` and `event.locals.session`
- Note: Currently the schema includes user table but auth UI is not yet implemented
- Token generation: base64-encoded random bytes; stored as SHA-256 hash in DB
- Cookie name: `auth-session`
- Anonymous users: identified by a client-generated ID; stats migrate on sign-up via `migrateAnonymousStats()`
- Apple Sign-In supported via `appleId` field
### Stats & Streak (`src/routes/stats/`)
- Stats page requires auth; returns `requiresAuth: true` if unauthenticated
- Streak calculated client-side by calling `GET /api/streak?userId=X&localDate=Y`
- Streak walk-back: counts consecutive days backwards from `localDate` through completed dates
- Minimum displayed streak is 2 (single-day streaks suppressed)
## API Endpoints
- `POST /api/daily-verse` — Fetch verse for a specific date
- `POST /api/submit-completion` — Submit game result with stats
- `GET /api/streak?userId=X&localDate=Y` — Current streak for user
- `GET /api/streak-percentile` — Streak percentile ranking
## Key Files
- `src/routes/+page.svelte` - Main game UI and client-side logic
- `src/routes/+page.server.ts` - Server load function, fetches/caches daily verse
- `src/lib/server/bible-api.ts` - External API integration for verse fetching
- `src/lib/server/bible.ts` - Bible book utility functions
- `src/lib/types/bible.ts` - Bible books data and TypeScript types
- `src/lib/server/db/schema.ts` - Drizzle ORM schema definitions
- `src/hooks.server.ts` - SvelteKit server hook for session validation
- `src/routes/+page.svelte` Main game UI and client-side logic
- `src/routes/+page.server.ts` / `+page.ts` — Server load (verse) + client load (`ssr: false`)
- `src/routes/stats/+page.svelte` / `+page.server.ts` — Stats UI and server calculations
- `src/lib/server/auth.ts` — Session management, password hashing, anonymous migration
- `src/lib/server/bible-api.ts` — Random verse fetching from local XML Bible
- `src/lib/server/bible.ts` — Bible book utility functions
- `src/lib/types/bible.ts` — Bible books data and TypeScript types
- `src/lib/server/db/schema.ts` — Drizzle ORM schema
- `src/hooks.server.ts` — Session validation hook; initializes ML embeddings
- `tests/` — Bun test suites (timezone, game, bible, stats, share, auth migration)
## Environment Variables
Required in `.env`:
- `DATABASE_URL` - Path to SQLite database file (e.g., `./local.db`)
- `DATABASE_URL` Path to SQLite database file (e.g., `./local.db`)
## Deployment
The project uses `@sveltejs/adapter-node` for deployment. The build output is a Node.js server that can be run with systemd or similar process managers. See `bibdle.service` and `bibdle.socket` for systemd configuration.
Uses `@sveltejs/adapter-node`. See `bibdle.service` systemd configuration.
## Important Notes
## A Note
- Uses Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`) - not stores or reactive declarations
- The schema includes authentication tables but the login/signup UI is not yet implemented
- Daily verses are cached permanently in the database to ensure consistency
- LocalStorage persists guesses per day using the key pattern `bibdle-guesses-${date}`
- The game validates book IDs from the API against the hardcoded `bibleBooks` array
The main developer of this project is still learning a lot about developing full-stack applications. If they ask you to do something, make sure they understand how it will be implemented before proceeding.

View File

@@ -1,18 +1,19 @@
[Unit]
Description=Bibdle SvelteKit App
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
Requires=bibdle.socket
After=network-online.target bibdle.socket
After=network-online.target
[Service]
Environment=NODE_ENV=production
Environment=ORIGIN=https://bibdle.com
Environment=DATABASE_URL=local.db
Environment=IDLE_TIMEOUT=60
WorkingDirectory=/home/george/projects/bibdle
ExecStart=/home/george/.nvm/versions/node/v24.12.0/bin/node build/index.js
Environment=DATABASE_URL=prod.db
Environment=IDLE_TIMEOUT=300
Environment=PORT=5173
WorkingDirectory=/home/xenia/projects/bibdle
#ExecStart=/home/xenia/.nvm/versions/node/v24.13.0/bin/node build/index.js
ExecStart=/home/xenia/.bun/bin/bun --bun build/index.js
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target

View File

@@ -1,5 +0,0 @@
[Socket]
ListenStream=5173
[Install]
WantedBy=sockets.target

View File

@@ -7,11 +7,10 @@
"dependencies": {
"@xenova/transformers": "^2.17.2",
"fast-xml-parser": "^5.3.3",
"marked": "^17.0.4",
"xml2js": "^0.6.2",
},
"devDependencies": {
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
@@ -100,14 +99,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
"@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
@@ -404,6 +395,8 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@17.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],

View File

@@ -18,8 +18,6 @@
"db:studio": "drizzle-kit studio"
},
"devDependencies": {
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.50.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
@@ -38,6 +36,7 @@
"dependencies": {
"@xenova/transformers": "^2.17.2",
"fast-xml-parser": "^5.3.3",
"marked": "^17.0.4",
"xml2js": "^0.6.2"
}
}

View 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);
}

View 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));
}

View File

@@ -32,4 +32,4 @@ export const handle: Handle = handleAuth;
// Initialize embeddings on server start (runs once on module load)
const verses = getAllNKJVVerses();
await initializeEmbeddings(verses);
// await initializeEmbeddings(verses);

View File

@@ -167,7 +167,7 @@
</script>
<Container
class="w-full p-3 sm:p-4 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
class="w-full p-3 sm:p-4 bg-linear-to-br from-yellow-100/80 to-amber-200/80 dark:from-amber-900/40 dark:to-yellow-900/30 text-gray-800 dark:text-gray-100 shadow-md"
>
<div class="text-center">
<p class="font-bold mb-3 text-lg sm:text-xl">
@@ -193,8 +193,8 @@
? isCorrect
? "bg-green-500 text-white border-green-600 shadow-lg"
: "bg-red-400 text-white border-red-500"
: "bg-white/30 text-gray-400 border-gray-300 opacity-40"
: "bg-white/80 hover:bg-white text-gray-800 border-gray-300 hover:border-amber-400 hover:shadow-md cursor-pointer"
: "bg-white/30 dark:bg-white/10 text-gray-400 border-gray-300 dark:border-gray-600 opacity-40"
: "bg-white/80 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 text-gray-800 dark:text-gray-100 border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500 hover:shadow-md cursor-pointer"
}
`}
>

View File

@@ -10,7 +10,7 @@
</script>
<div
class="inline-flex flex-col items-center bg-white/10 backdrop-blur-sm rounded-2xl border border-white/20 shadow-sm {className}"
class="inline-flex flex-col items-center bg-white/10 dark:bg-white/5 backdrop-blur-sm rounded-2xl border border-white/20 dark:border-white/10 shadow-sm {className}"
>
{@render children()}
</div>

View File

@@ -50,30 +50,30 @@
});
</script>
<div class="w-full">
<div class="w-full flex flex-col flex-1">
<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}
<p
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
>
Next Verse In
</p>
<p class="text-4xl font-triodion font-black text-gray-800">Now</p>
<p
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mt-2"
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mt-2"
>
(refresh page to see the new verse)
</p>
{:else}
<p
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
>
Next Verse In
</p>
<p
class="text-4xl font-triodion font-black text-gray-800 tabular-nums"
class="text-4xl font-triodion font-black text-gray-800 dark:text-gray-100 tabular-nums whitespace-nowrap"
>
{timeUntilNext}
</p>

View File

@@ -1,14 +1,15 @@
<script lang="ts">
import { fade } from "svelte/transition";
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
import SocialLinks from "$lib/components/SocialLinks.svelte";
</script>
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
<div
class="flex flex-col items-center gap-2 bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
class="flex flex-col items-center gap-2 bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm"
>
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
<p
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-300 font-bold"
>
A project by George Powell & Silent Summit Co.
</p>
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
@@ -26,56 +27,8 @@
<!-- Bluesky Social Media Button -->
</div>
<div class="mt-8 flex items-center justify-center gap-6">
<a
href="https://bsky.app/profile/snail.city"
target="_blank"
rel="noopener noreferrer"
class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Bluesky"
data-umami-event="Bluesky clicked"
onclick={() => (window as any).rybbit?.event("Bluesky clicked")}
>
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
</a>
<div class="w-0.5 h-8 bg-gray-400"></div>
<a
href="https://x.com/pupperpowell"
target="_blank"
rel="noopener noreferrer"
class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Twitter"
data-umami-event="Twitter clicked"
onclick={() => (window as any).rybbit?.event("Twitter clicked")}
>
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
</a>
<div class="w-0.5 h-8 bg-gray-400"></div>
<a
href="mailto:george+bibdle@silentsummit.co"
class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Send email"
data-umami-event="Email clicked"
onclick={() => (window as any).rybbit?.event("Email clicked")}
>
<svg
class="w-8 h-8 text-gray-700"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
></path>
</svg>
</a>
<div class="mt-8">
<SocialLinks />
</div>
</div>

View File

@@ -1,19 +1,32 @@
<script lang="ts">
import { browser } from "$app/environment";
import { enhance } from "$app/forms";
import Button from "$lib/components/Button.svelte";
let { anonymousId }: { anonymousId: string | null } = $props();
type User = {
id: string;
email?: string | null;
firstName?: string | null;
lastName?: string | null;
appleId?: string | null;
} | null;
let {
anonymousId,
user,
onSignIn,
}: { anonymousId: string | null; user: User; onSignIn: () => void } = $props();
let seeding = $state(false);
async function seedHistory() {
async function seedHistory(days: number = 10) {
if (!browser || !anonymousId || seeding) return;
seeding = true;
try {
const response = await fetch("/api/dev/seed-history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ anonymousId })
body: JSON.stringify({ anonymousId, days })
});
const result = await response.json();
alert(
@@ -46,6 +59,35 @@
<div class="border-t-2 border-gray-400"></div>
</div>
<div class="flex flex-col gap-3">
<a
href="/stats?{user
? `userId=${user.id}`
: `anonymousId=${anonymousId}`}&tz={encodeURIComponent(
Intl.DateTimeFormat().resolvedOptions().timeZone,
)}"
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
>
📊 View Stats
</a>
{#if user}
<form method="POST" action="/auth/logout" use:enhance class="w-full">
<button
type="submit"
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
>
🚪 Sign Out
</button>
</form>
{:else}
<button
onclick={onSignIn}
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
>
🔐 Sign In
</button>
{/if}
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
<Button
variant="secondary"
@@ -113,7 +155,15 @@
<Button
variant="secondary"
onclick={seedHistory}
onclick={() => seedHistory(1)}
disabled={seeding}
class="w-full py-4 md:py-2"
>
{seeding ? "Seeding..." : "Add 1 Day of History"}
</Button>
<Button
variant="secondary"
onclick={() => seedHistory(10)}
disabled={seeding}
class="w-full py-4 md:py-2"
>

View File

@@ -66,10 +66,10 @@
{#if !hasGuesses}
<Container class="p-6 text-center">
<h2 class="font-triodion text-xl italic mb-3 text-gray-800">
<h2 class="font-triodion text-xl italic mb-3 text-gray-800 dark:text-gray-100">
Instructions
</h2>
<p class="text-gray-700 leading-relaxed italic">
<p class="text-gray-700 dark:text-gray-300 leading-relaxed italic">
Guess what book of the bible you think the verse is from. You will
get clues to help you after each guess.
</p>
@@ -78,25 +78,25 @@
<div class="space-y-3">
<!-- Column Headers -->
<div
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400 dark:border-gray-600"
>
<div
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
>
Testament
</div>
<div
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
>
Section
</div>
<div
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
>
First Letter
</div>
<div
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700"
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
>
Book
</div>

View File

@@ -127,21 +127,17 @@
</script>
{#if showBanner}
<div
class="mb-3 flex items-center gap-2 px-4 py-2 rounded-full text-xs font-medium border w-fit transition-all duration-300
{bannerIsIndigo
? 'bg-indigo-50 border-indigo-200 text-indigo-700'
: 'bg-amber-50 border-amber-200 text-amber-700'}"
<p
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
role="status"
aria-live="polite"
>
<span aria-hidden="true" class="text-[10px] leading-none"></span>
{#if bannerIsIndigo}
Testament &amp; section groups now visible
{:else}
Old &amp; New Testament groups now visible
{/if}
</div>
</p>
{/if}
<div class="relative">
@@ -164,13 +160,13 @@
<input
bind:value={searchQuery}
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-200 transition-all bg-white"
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 dark:border-gray-600 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 dark:focus:border-blue-400 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-900/50 transition-all bg-white dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
onkeydown={handleKeydown}
autocomplete="off"
/>
{#if searchQuery}
<button
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
onclick={() => (searchQuery = "")}
aria-label="Clear search"
>
@@ -195,7 +191,7 @@
{#if searchQuery && filteredBooks.length > 0}
<ul
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-2xl shadow-xl"
role="listbox"
>
{#if displayMode === "simple"}
@@ -210,8 +206,8 @@
tabindex={guessedIds.has(book.id) ? -1 : 0}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400'
class="font-semibold dark:text-gray-100 {guessedIds.has(book.id)
? 'line-through text-gray-400 dark:text-gray-500'
: ''}"
>
{book.name}
@@ -223,29 +219,29 @@
{#each testamentGroups as group (group.testament)}
<li role="presentation">
<div
class="px-5 py-2 flex items-center gap-3 bg-gray-50 border-b border-gray-100"
class="px-5 py-2 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
>
<span
class="text-xs font-semibold uppercase tracking-wider text-gray-400"
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
>
{group.label}
</span>
<div class="flex-1 h-px bg-gray-200"></div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
</div>
<ul>
{#each group.books as book (book.id)}
<li role="option" aria-selected={guessedIds.has(book.id)}>
<button
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
{guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 hover:text-blue-700'}"
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400'
? 'line-through text-gray-400 dark:text-gray-500'
: ''}"
>
{book.name}
@@ -261,40 +257,40 @@
<li role="presentation">
{#if group.showTestamentHeader}
<div
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 border-b border-gray-100"
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
>
<span
class="text-xs font-bold uppercase tracking-wider text-gray-500"
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
{group.testamentLabel}
</span>
<div class="flex-1 h-px bg-gray-200"></div>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
</div>
{/if}
<div
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 border-b border-gray-100"
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
>
<span
class="text-[11px] font-medium uppercase tracking-wider text-gray-400"
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
>
{group.section}
</span>
<div class="flex-1 h-px bg-gray-100"></div>
<div class="flex-1 h-px bg-gray-100 dark:bg-gray-600"></div>
</div>
<ul>
{#each group.books as book (book.id)}
<li role="option" aria-selected={guessedIds.has(book.id)}>
<button
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
{guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 hover:text-blue-700'}"
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400'
? 'line-through text-gray-400 dark:text-gray-500'
: ''}"
>
{book.name}
@@ -308,6 +304,6 @@
{/if}
</ul>
{:else if searchQuery}
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">No books found</p>
{/if}
</div>

View 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>

View 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>

View 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}

View File

@@ -63,9 +63,11 @@
}
</script>
<Container class="w-full p-8 sm:p-12 bg-white/70 overflow-hidden">
<Container
class="w-full p-8 sm:p-12 bg-white/70 dark:bg-black/30 overflow-hidden"
>
<blockquote
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 dark:text-gray-200 text-center"
>
{displayVerseText}
</blockquote>
@@ -76,23 +78,10 @@
{#if showReference}
<p
transition:fade={{ duration: 400 }}
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
class="text-center text-lg! big-text text-green-600! dark:text-green-400! font-bold mt-8 bg-white/70 dark:bg-black/50 rounded-xl px-4 py-2"
>
{displayReference}
</p>
<div
transition:fade={{ duration: 300 }}
class="flex justify-center mt-3"
>
<button
onclick={copyVerse}
data-umami-event="Copy Verse"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs big-text text-gray-600 bg-white/50 hover:bg-white/70 border border-gray-300 rounded-lg transition-colors cursor-pointer"
>
{copied ? "✅" : "📋"}
{copied ? "Copied!" : "Copy verse to clipboard"}
</button>
</div>
{/if}
</div>
</Container>

View File

@@ -1,8 +1,14 @@
<script lang="ts">
import { fade, fly } from "svelte/transition";
import { getBookById, toOrdinal } from "$lib/utils/game";
import {
getVerseSnippet,
shareResult,
copyToClipboard as clipboardCopy,
} from "$lib/utils/share";
import Container from "./Container.svelte";
import CountdownTimer from "./CountdownTimer.svelte";
import StreakCounter from "./StreakCounter.svelte";
import ChapterGuess from "./ChapterGuess.svelte";
interface StatsData {
@@ -30,8 +36,11 @@
reference,
onChapterGuessCompleted,
shareText,
verseText,
streak = 0,
streakPercentile = null,
isLoggedIn = false,
anonymousId = '',
}: {
statsData: StatsData | null;
correctBookId: string;
@@ -43,8 +52,11 @@
reference: string;
onChapterGuessCompleted: () => void;
shareText: string;
verseText: string;
streak?: number;
streakPercentile?: number | null;
isLoggedIn?: boolean;
anonymousId?: string;
} = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
@@ -53,6 +65,23 @@
);
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
const congratulationsMessages: WeightedMessage[] = [
@@ -97,37 +126,27 @@
<div class="flex flex-col gap-6">
<Container
class="w-full px-2 sm:px-4 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
class="w-full px-4 sm:px-6 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 dark:text-gray-100 shadow-2xl text-center fade-in"
>
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
{congratulationsMessage} The verse is from<br />
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
</p>
<p class="text-lg sm:text-xl md:text-2xl mt-2">
You guessed correctly after {guessCount}
{guessCount === 1 ? "guess" : "guesses"}.
</p>
{#if streak > 1}
<div class="flex flex-col gap-4 my-4">
<p class="big-text text-orange-500! text-lg!">
🔥 {streak} days in a row!
<div class="flex flex-col gap-3">
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
{congratulationsMessage} The verse is from
<span class="font-black font-triodion text-3xl md:text-4xl"
>{bookName}</span
>.
</p>
<p class="text-lg sm:text-xl md:text-2xl">
You guessed correctly after {guessCount}
{guessCount === 1 ? "guess" : "guesses"}.
</p>
<!-- {#if streak >= 7}
<p
class="italic tracking-wider px-8 font-semibold text-gray-500"
>
Thank you for making BIBDLE part of your daily routine!
</p>
{#if streak >= 7}
<p class="font-black text-lg font-triodion">
Thank you for making Bibdle part of your daily routine!
</p>
{/if}
{#if streakPercentile !== null}
<p class="text-sm text-gray-700 font-triodion">
{streakPercentile <= 50
? "Only "
: ""}{streakPercentile}% of players have a streak of {streak}
or greater.
</p>
{/if}
</div>
{/if}
{/if} -->
</div>
</Container>
<!-- S++ Bonus Challenge for first try -->
@@ -139,12 +158,21 @@
/>
{/if}
<CountdownTimer />
<div class="flex flex-row gap-3 items-stretch w-full">
<div class="flex-2 min-w-0 flex flex-col">
<CountdownTimer />
</div>
{#if streak > 0}
<div class="flex-1 min-w-0 flex flex-col">
<StreakCounter {streak} {streakPercentile} />
</div>
{/if}
</div>
<!-- Statistics Display -->
{#if statsData}
<Container
class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
class="w-full p-4 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
>
<div
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
@@ -153,7 +181,7 @@
<!-- Solve Rank Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
>
#{statsData.solveRank}
</div>
@@ -166,7 +194,7 @@
<!-- Guess Rank Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
>
{toOrdinal(statsData.guessRank)}
</div>
@@ -188,7 +216,7 @@
<!-- Average Column -->
<div class="flex flex-col">
<div
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
>
{statsData.averageGuesses}
</div>
@@ -202,7 +230,7 @@
</Container>
{:else if !statsSubmitted}
<Container
class="w-full p-6 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
class="w-full p-6 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
>
<div class="text-sm opacity-80">Submitting stats...</div>
</Container>
@@ -223,10 +251,15 @@
onclick={() => {
if (hasWebShare) {
(window as any).rybbit?.event("Share");
handleShare();
shareResult(effectiveShareText);
} else {
(window as any).rybbit?.event("Copy to Clipboard");
copyToClipboard();
if (!copyTracked) {
(window as any).rybbit?.event(
"Copy to Clipboard",
);
copyTracked = true;
}
clipboardCopy(effectiveShareText);
copySuccess = true;
setTimeout(() => {
copySuccess = false;
@@ -251,13 +284,17 @@
aria-label="Copy to clipboard"
data-umami-event="Copy to Clipboard"
onclick={() => {
(window as any).rybbit?.event("Copy to Clipboard");
copyToClipboard();
if (!copyTracked) {
(window as any).rybbit?.event("Copy to Clipboard");
copyTracked = true;
}
clipboardCopy(effectiveShareText);
showSnippetOption = true;
bubbleCopied = true;
setTimeout(() => {
bubbleCopied = false;
}, 2000);
}}>{shareText}</button
}}>{effectiveShareText}</button
>
{#if hasWebShare}
<span class="copy-hint"
@@ -271,6 +308,39 @@
</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 &amp; see your stats</p>
<form method="POST" action="/auth/apple">
<input type="hidden" name="anonymousId" value={anonymousId} />
<button
type="submit"
class="apple-signin-btn"
>
<svg class="apple-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
</svg>
Sign in with Apple
</button>
</form>
</div>
{/if}
</div>
<style>
@@ -304,6 +374,13 @@
overflow: hidden;
}
@media (prefers-color-scheme: dark) {
.share-card {
background: oklch(22% 0.025 298.626);
border-color: rgba(255, 255, 255, 0.1);
}
}
.share-card::before {
content: "";
position: absolute;
@@ -324,6 +401,12 @@
gap: 0.6rem;
}
@media (prefers-color-scheme: dark) {
.chat-window {
--bg: oklch(22% 0.025 298.626);
}
}
/* ── Bubble wrappers ── */
.bubble-wrapper {
display: flex;
@@ -476,4 +559,132 @@
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>

View File

@@ -1,5 +1,3 @@
import { encodeBase64url } from '@oslojs/encoding';
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
@@ -26,8 +24,8 @@ export async function generateAppleClientSecret(): Promise<string> {
sub: Bun.env.APPLE_ID!
};
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signingInput = `${encodedHeader}.${encodedPayload}`;
// Import PEM private key
@@ -55,7 +53,7 @@ export async function generateAppleClientSecret(): Promise<string> {
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
const encodedSignature = encodeBase64url(rawSignature);
const encodedSignature = Buffer.from(rawSignature).toString('base64url');
return `${signingInput}.${encodedSignature}`;
}

View File

@@ -1,7 +1,5 @@
import type { RequestEvent } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { testDb as db } from '$lib/server/db/test';
import * as table from '$lib/server/db/schema';
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(18));
const token = encodeBase64url(bytes);
return token;
return Buffer.from(bytes).toString('base64url');
}
export async function createSession(token: string, userId: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
const session: table.Session = {
id: sessionId,
userId,
@@ -27,7 +24,7 @@ export async function createSession(token: string, userId: string) {
}
export async function validateSessionToken(token: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
const [result] = await db
.select({
// Adjust user table here to tweak returned data

View File

@@ -1,7 +1,5 @@
import type { RequestEvent } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(18));
const token = encodeBase64url(bytes);
return token;
return Buffer.from(bytes).toString('base64url');
}
export async function createSession(token: string, userId: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
const session: table.Session = {
id: sessionId,
userId,
@@ -27,7 +24,7 @@ export async function createSession(token: string, userId: string) {
}
export async function validateSessionToken(token: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
const [result] = await db
.select({
// Adjust user table here to tweak returned data

View File

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

View File

@@ -17,75 +17,73 @@ export interface BibleBook {
testament: Testament;
section: BibleSection;
order: number;
url: string;
popularity: number;
}
export const bibleBooks: BibleBook[] = [
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1, url: 'https://bible-api.com/data/web/GEN', popularity: 8 },
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2, url: 'https://bible-api.com/data/web/EXO', popularity: 3 },
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3, url: 'https://bible-api.com/data/web/LEV', popularity: 2 },
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4, url: 'https://bible-api.com/data/web/NUM', popularity: 2 },
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5, url: 'https://bible-api.com/data/web/DEU', popularity: 2 },
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6, url: 'https://bible-api.com/data/web/JOS', popularity: 2 },
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7, url: 'https://bible-api.com/data/web/JDG', popularity: 2 },
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8, url: 'https://bible-api.com/data/web/RUT', popularity: 2 },
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9, url: 'https://bible-api.com/data/web/1SA', popularity: 1 },
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10, url: 'https://bible-api.com/data/web/2SA', popularity: 0 },
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11, url: 'https://bible-api.com/data/web/1KI', popularity: 1 },
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12, url: 'https://bible-api.com/data/web/2KI', popularity: 0 },
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13, url: 'https://bible-api.com/data/web/1CH', popularity: 1 },
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14, url: 'https://bible-api.com/data/web/2CH', popularity: 0 },
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15, url: 'https://bible-api.com/data/web/EZR', popularity: 1 },
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16, url: 'https://bible-api.com/data/web/NEH', popularity: 1 },
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17, url: 'https://bible-api.com/data/web/EST', popularity: 1 },
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18, url: 'https://bible-api.com/data/web/JOB', popularity: 2 },
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19, url: 'https://bible-api.com/data/web/PSA', popularity: 7 },
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20, url: 'https://bible-api.com/data/web/PRO', popularity: 7 },
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21, url: 'https://bible-api.com/data/web/ECC', popularity: 2 },
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22, url: 'https://bible-api.com/data/web/SNG', popularity: 2 },
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23, url: 'https://bible-api.com/data/web/ISA', popularity: 2 },
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24, url: 'https://bible-api.com/data/web/JER', popularity: 2 },
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25, url: 'https://bible-api.com/data/web/LAM', popularity: 2 },
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26, url: 'https://bible-api.com/data/web/EZK', popularity: 2 },
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27, url: 'https://bible-api.com/data/web/DAN', popularity: 2 },
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28, url: 'https://bible-api.com/data/web/HOS', popularity: 2 },
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29, url: 'https://bible-api.com/data/web/JOL', popularity: 2 },
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30, url: 'https://bible-api.com/data/web/AMO', popularity: 2 },
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31, url: 'https://bible-api.com/data/web/OBA', popularity: 2 },
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32, url: 'https://bible-api.com/data/web/JON', popularity: 2 },
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33, url: 'https://bible-api.com/data/web/MIC', popularity: 2 },
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34, url: 'https://bible-api.com/data/web/NAM', popularity: 2 },
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35, url: 'https://bible-api.com/data/web/HAB', popularity: 2 },
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36, url: 'https://bible-api.com/data/web/ZEP', popularity: 2 },
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37, url: 'https://bible-api.com/data/web/HAG', popularity: 2 },
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38, url: 'https://bible-api.com/data/web/ZEC', popularity: 2 },
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39, url: 'https://bible-api.com/data/web/MAL', popularity: 2 },
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40, url: 'https://bible-api.com/data/web/MAT', popularity: 8 },
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41, url: 'https://bible-api.com/data/web/MRK', popularity: 8 },
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42, url: 'https://bible-api.com/data/web/LUK', popularity: 8 },
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43, url: 'https://bible-api.com/data/web/JHN', popularity: 8 },
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44, url: 'https://bible-api.com/data/web/ACT', popularity: 2 },
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45, url: 'https://bible-api.com/data/web/ROM', popularity: 6 },
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46, url: 'https://bible-api.com/data/web/1CO', popularity: 5 },
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47, url: 'https://bible-api.com/data/web/2CO', popularity: 5 },
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48, url: 'https://bible-api.com/data/web/GAL', popularity: 5 },
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49, url: 'https://bible-api.com/data/web/EPH', popularity: 5 },
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50, url: 'https://bible-api.com/data/web/PHP', popularity: 5 },
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51, url: 'https://bible-api.com/data/web/COL', popularity: 5 },
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52, url: 'https://bible-api.com/data/web/1TH', popularity: 5 },
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53, url: 'https://bible-api.com/data/web/2TH', popularity: 5 },
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54, url: 'https://bible-api.com/data/web/1TI', popularity: 5 },
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55, url: 'https://bible-api.com/data/web/2TI', popularity: 5 },
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56, url: 'https://bible-api.com/data/web/TIT', popularity: 5 },
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57, url: 'https://bible-api.com/data/web/PHM', popularity: 5 },
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58, url: 'https://bible-api.com/data/web/HEB', popularity: 4 },
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59, url: 'https://bible-api.com/data/web/JAS', popularity: 4 },
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60, url: 'https://bible-api.com/data/web/1PE', popularity: 4 },
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61, url: 'https://bible-api.com/data/web/2PE', popularity: 4 },
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62, url: 'https://bible-api.com/data/web/1JN', popularity: 4 },
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63, url: 'https://bible-api.com/data/web/2JN', popularity: 4 },
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64, url: 'https://bible-api.com/data/web/3JN', popularity: 4 },
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65, url: 'https://bible-api.com/data/web/JUD', popularity: 4 },
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66, url: 'https://bible-api.com/data/web/REV', popularity: 2 }
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1 },
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2 },
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3 },
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4 },
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5 },
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6 },
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7 },
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8 },
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9 },
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10 },
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11 },
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12 },
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13 },
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14 },
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15 },
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16 },
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17 },
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18 },
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19 },
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20 },
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21 },
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22 },
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23 },
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24 },
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25 },
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26 },
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27 },
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28 },
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29 },
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30 },
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31 },
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32 },
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33 },
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34 },
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35 },
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36 },
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37 },
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38 },
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39 },
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40 },
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41 },
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42 },
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43 },
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44 },
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45 },
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46 },
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47 },
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48 },
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49 },
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50 },
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51 },
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52 },
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53 },
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54 },
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55 },
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56 },
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57 },
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58 },
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59 },
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60 },
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61 },
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62 },
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63 },
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64 },
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65 },
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66 }
];

View File

@@ -79,7 +79,7 @@ export function getNextGradeMessage(numGuesses: number): string {
}
export function toOrdinal(n: number): string {
if (n >= 11 && n <= 13) {
if (n % 100 >= 11 && n % 100 <= 13) {
return `${n}th`;
}
const mod = n % 10;

View File

@@ -1,5 +1,42 @@
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: {
guesses: Guess[];
correctBookId: string;
@@ -8,8 +45,9 @@ export function generateShareText(params: {
isLoggedIn: boolean;
streak?: number;
origin: string;
verseText: string;
}): string {
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin } = params;
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin, verseText } = params;
const emojis = guesses
.slice()
@@ -35,18 +73,15 @@ export function generateShareText(params: {
const bookEmoji = isLoggedIn ? "📜" : "📖";
const guessWord = guesses.length === 1 ? "guess" : "guesses";
const streakPart = streak !== undefined && streak > 1 ? `, ${streak} days 🔥` : "";
const streakPart = streak !== undefined && streak > 1 ? ` ${streak} days 🔥` : "";
const chapterStar = guesses.length === 1 && chapterCorrect ? " ⭐" : "";
const lines = [
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
`${guesses.length} ${guessWord}${streakPart}`,
`${guesses.length} ${guessWord}${streakPart ? `,${streakPart}` : ""}`,
`${emojis}${chapterStar}`
];
lines.push(
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
origin,
);
return lines.join("\n");
}

View File

@@ -1,26 +1,35 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import "./layout.css";
import favicon from "$lib/assets/favicon.ico";
import { onMount } from 'svelte';
onMount(() => {
if (browser) {
const script = document.createElement('script');
script.defer = true;
script.src = 'https://umami.snail.city/script.js';
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
document.body.appendChild(script);
}
});
import "./layout.css";
import favicon from "$lib/assets/favicon.ico";
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
let { children } = $props();
onMount(() => {
// Inject analytics script
const script = document.createElement('script');
script.defer = true;
script.src = 'https://umami.snail.city/script.js';
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
document.body.appendChild(script);
});
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
<link rel="icon" href={favicon} />
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
<meta name="description" content="A daily Bible game" />
</svelte:head>
{@render children()}
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950">
<h1
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 dark:text-gray-300 drop-shadow-2xl tracking-widest p-4 pt-12 animate-fade-in-up"
>
<TitleAnimation />
<div class="font-normal"></div>
</h1>
{@render children()}
</div>

View File

@@ -8,7 +8,7 @@
import GuessesTable from "$lib/components/GuessesTable.svelte";
import WinScreen from "$lib/components/WinScreen.svelte";
import Credits from "$lib/components/Credits.svelte";
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import DevButtons from "$lib/components/DevButtons.svelte";
import AuthModal from "$lib/components/AuthModal.svelte";
@@ -35,6 +35,15 @@
let user = $derived(data.user);
let session = $derived(data.session);
const currentDate = $derived(
new Date().toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}),
);
let searchQuery = $state("");
let copied = $state(false);
let isDev = $state(false);
@@ -55,15 +64,6 @@
new SvelteSet(persistence.guesses.map((g) => g.book.id)),
);
const currentDate = $derived(
new Date().toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}),
);
let isWon = $derived(
persistence.guesses.some((g) => g.book.id === correctBookId),
);
@@ -240,6 +240,7 @@
isLoggedIn: !!user,
streak,
origin: window.location.origin,
verseText: dailyVerse.verseText,
});
}
@@ -282,20 +283,13 @@
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
</svelte:head>
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
<div class="pb-8">
<div class="w-full max-w-3xl mx-auto px-4">
<h1
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4 animate-fade-in-up"
>
<TitleAnimation />
<div class="font-normal"></div>
</h1>
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
<span class="big-text"
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
>
</div>
<div class="flex flex-col gap-6">
<div class="animate-fade-in-up animate-delay-200">
<VerseDisplay {data} {isWon} {blurChapter} />
@@ -303,7 +297,12 @@
{#if !isWon}
<div class="animate-fade-in-up animate-delay-400">
<SearchInput bind:searchQuery {guessedIds} {submitGuess} guessCount={persistence.guesses.length} />
<SearchInput
bind:searchQuery
{guessedIds}
{submitGuess}
guessCount={persistence.guesses.length}
/>
</div>
{:else if showWinScreen}
<div class="animate-fade-in-up animate-delay-400">
@@ -318,8 +317,11 @@
reference={dailyVerse.reference}
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
shareText={getShareText()}
verseText={dailyVerse.verseText}
{streak}
{streakPercentile}
isLoggedIn={!!user}
anonymousId={persistence.anonymousId}
/>
</div>
{/if}
@@ -333,47 +335,16 @@
<Credits />
</div>
{/if}
<!-- We will just go with the user's system color theme for now. -->
<div class="flex justify-center hidden mt-4">
<ThemeToggle />
</div>
</div>
{#if isDev}
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
<div class="flex flex-col md:flex-row gap-3">
<a
href="/stats?{user
? `userId=${user.id}`
: `anonymousId=${persistence.anonymousId}`}&tz={encodeURIComponent(
Intl.DateTimeFormat().resolvedOptions().timeZone,
)}"
class="inline-flex items-center justify-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
>
📊 View Stats
</a>
{#if user}
<form
method="POST"
action="/auth/logout"
use:enhance
class="w-full md:w-auto"
>
<button
type="submit"
class="inline-flex items-center justify-center w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
>
🚪 Sign Out
</button>
</form>
{:else}
<button
onclick={() => (authModalOpen = true)}
class="inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
>
🔐 Sign In
</button>
{/if}
</div>
<div
class="text-xs text-gray-600 bg-gray-100 px-3 py-2 rounded border"
class="text-xs text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded border dark:border-gray-700"
>
<div><strong>Debug Info:</strong></div>
<div>
@@ -405,13 +376,13 @@
<div>Daily Verse Date: {dailyVerse.date}</div>
<div>Streak: {streak}</div>
</div>
<DevButtons anonymousId={persistence.anonymousId} />
<DevButtons anonymousId={persistence.anonymousId} {user} onSignIn={() => (authModalOpen = true)} />
</div>
{/if}
{#if user && session}
<div
class="mt-6 pt-4 border-t border-gray-200 text-center text-xs text-gray-400"
class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center text-xs text-gray-400 dark:text-gray-500"
>
Signed in as {[user.firstName, user.lastName]
.filter(Boolean)

View 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)
};
}

View 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>

View File

@@ -19,7 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
}
try {
const { anonymousId } = await request.json();
const { anonymousId, days = 10 } = await request.json();
if (!anonymousId || typeof anonymousId !== 'string') {
return json({ error: 'anonymousId required' }, { status: 400 });
@@ -29,7 +29,7 @@ export const POST: RequestHandler = async ({ request }) => {
const inserted: string[] = [];
const skipped: string[] = [];
for (let i = 1; i <= 10; i++) {
for (let i = 1; i <= days; i++) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const date = d.toLocaleDateString('en-CA'); // YYYY-MM-DD

View File

@@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ url }) => {
error(400, 'Missing anonymousId or localDate');
}
// Fetch all completion dates for this user, newest first
// Fetch all completion dates for this user (stored as the user's local date)
const rows = await db
.select({ date: dailyCompletions.date })
.from(dailyCompletions)
@@ -21,16 +21,22 @@ export const GET: RequestHandler = async ({ url }) => {
const completedDates = new Set(rows.map((r) => r.date));
// Walk backwards from localDate, counting consecutive completed days
let streak = 0;
let cursor = new Date(`${localDate}T00:00:00`);
while (true) {
const dateStr = cursor.toLocaleDateString('en-CA'); // YYYY-MM-DD
if (!completedDates.has(dateStr)) break;
streak++;
cursor.setDate(cursor.getDate() - 1);
// Subtract one calendar day from a YYYY-MM-DD string using UTC arithmetic —
// this avoids any dependence on the server's local timezone or DST offsets.
function prevDay(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}
return json({ streak });
// Walk backwards from the user's local date, counting consecutive completed days
let streak = 0;
let cursor = localDate;
while (completedDates.has(cursor)) {
streak++;
cursor = prevDay(cursor);
}
return json({ streak: streak < 2 ? 0 : streak });
};

View File

@@ -1,7 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { getAppleAuthUrl } from '$lib/server/apple-auth';
import { encodeBase64url } from '@oslojs/encoding';
export const actions: Actions = {
default: async ({ cookies, request }) => {
@@ -10,7 +9,7 @@ export const actions: Actions = {
// Generate CSRF state
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
const state = encodeBase64url(stateBytes);
const state = Buffer.from(stateBytes).toString('base64url');
// Store state + anonymousId in a short-lived cookie
// sameSite 'none' + secure required because Apple POSTs cross-origin

View File

@@ -2,12 +2,29 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-triodion: "PT Serif", serif;
}
html, body {
background: oklch(89.126% 0.06134 298.626);
transition: background 0.3s ease;
}
@media (prefers-color-scheme: dark) {
html:not(.light), body:not(.light) {
background: oklch(18% 0.03 298.626);
}
}
html.dark, html.dark body {
background: oklch(18% 0.03 298.626);
}
html.light, html.light body {
background: oklch(89.126% 0.06134 298.626);
}
.big-text {
@@ -18,6 +35,20 @@ html, body {
font-weight: 700;
}
@media (prefers-color-scheme: dark) {
html:not(.light) .big-text {
color: rgb(156 163 175);
}
}
html.dark .big-text {
color: rgb(156 163 175);
}
html.light .big-text {
color: rgb(107 114 128);
}
/* Page load animations */
@keyframes fadeInUp {
from {
@@ -48,4 +79,4 @@ html, body {
.animate-delay-800 {
animation-delay: 0.8s;
}
}

View 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'
}
});
};

View File

@@ -27,9 +27,9 @@ export const load: PageServerLoad = async ({ url, locals }) => {
};
}
// Get user's current date from timezone query param
const timezone = url.searchParams.get('tz') || 'UTC';
const userToday = new Date().toLocaleDateString('en-CA', { timeZone: timezone });
// Note: userToday is used only for the initial server-side streak estimate.
// The client overrides this with a precise local-date calculation via /api/streak.
const userToday = new Date().toISOString().slice(0, 10); // UTC date as safe fallback
try {
// Get all completions for this user
@@ -85,7 +85,7 @@ export const load: PageServerLoad = async ({ url, locals }) => {
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
};
// Calculate streaks
// Calculate streaks — dates are stored as the user's local date
const sortedDates = completions
.map((c: DailyCompletion) => c.date)
.sort();

View File

@@ -1,18 +1,14 @@
<script lang="ts">
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { enhance } from "$app/forms";
import AuthModal from "$lib/components/AuthModal.svelte";
import Container from "$lib/components/Container.svelte";
import { bibleBooks } from "$lib/types/bible";
import {
getGradeColor,
formatDate,
getStreakMessage,
getPerformanceMessage,
type UserStats,
} from "$lib/utils/stats";
import { fetchStreak } from "$lib/utils/streak";
interface PageData {
stats: UserStats | null;
@@ -27,6 +23,7 @@
let anonymousId = $state("");
let loading = $state(true);
let currentStreak = $state(0);
function getOrCreateAnonymousId(): string {
if (!browser) return "";
@@ -41,13 +38,15 @@
onMount(async () => {
anonymousId = getOrCreateAnonymousId();
if (data.user?.id) {
const localDate = new Date().toLocaleDateString("en-CA");
currentStreak = await fetchStreak(data.user.id, localDate);
} else {
currentStreak = data.stats?.currentStreak ?? 0;
}
loading = false;
});
function getGradePercentage(count: number, total: number): number {
return total > 0 ? Math.round((count / total) * 100) : 0;
}
function getBookName(bookId: string): string {
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
}
@@ -160,7 +159,7 @@
<div
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
>
{stats.currentStreak}
{currentStreak}
</div>
<div
class="text-xs md:text-sm text-gray-300 font-medium"
@@ -333,83 +332,7 @@
</Container>
</div>
<!-- Grade Distribution -->
<Container class="p-5 md:p-6 mb-6">
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">
Grade Distribution
</h2>
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
{@const percentage = getGradePercentage(
count,
stats.totalSolves,
)}
<div class="text-center">
<div class="mb-2">
<span
class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md:text-sm font-semibold {getGradeColor(
grade,
)}"
>
{grade}
</span>
</div>
<div
class="text-lg md:text-2xl font-bold text-gray-100"
>
{count}
</div>
<div class="text-xs text-gray-400">
{percentage}%
</div>
</div>
{/each}
</div>
</Container>
<!-- Recent Performance -->
{#if stats.recentCompletions.length > 0}
<Container class="p-5 md:p-6">
<h2
class="text-lg md:text-xl font-bold text-gray-100 mb-4"
>
Recent Performance
</h2>
<div class="space-y-2">
{#each stats.recentCompletions as completion, idx (`${completion.date}-${idx}`)}
<div
class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0"
>
<div>
<span
class="text-sm md:text-base font-medium text-gray-200"
>{formatDate(completion.date)}</span
>
</div>
<div
class="flex items-center gap-2 md:gap-3"
>
<span
class="text-xs md:text-sm text-gray-300"
>{completion.guessCount} guess{completion.guessCount ===
1
? ""
: "es"}</span
>
<span
class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(
completion.grade,
)}"
>
{completion.grade}
</span>
</div>
</div>
{/each}
</div>
</Container>
{/if}
{/if}
{/if}
</div>
</div>

15
static/about.md Normal file
View File

@@ -0,0 +1,15 @@
# About Bibdle
Bibdle is a daily Bible guessing game. Every day, a random verse is posted to the website. Try to figure out which book of the Bible it comes from, in as few guesses as possible. That's it!
---
The game was built with the hope that it would be a small, delightful thing people can make part of their day. It's not a Bible study course. You don't need to know the Bible inside and out to enjoy it or to learn something from it.
If you're someone who grew up in church and can name all 66 books in order, great. If you're someone who can barely tell the Old Testament from the New, that's great too. The game meets you where you are.
It is completely free. If you'd like to support the developer (who works solely on small projects like this one) or express your thanks, you can become a Bibdle patron.
If you use Bibdle, I would love to hear from you! I can be reached via email or through Bluesky.
<!-- social -->

47
static/how-to-play.md Normal file
View 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!

View File

@@ -1,3 +1,5 @@
# allow crawling everything by default
User-agent: *
Disallow:
Sitemap: https://bibdle.com/sitemap.xml

234
tests/bible.test.ts Normal file
View 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
View 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
View 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
View 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!");
});
});

View File

@@ -496,3 +496,149 @@ describe('Timezone-aware daily verse system', () => {
// See /api/submit-completion for the unique constraint enforcement
});
});
// ---------------------------------------------------------------------------
// Streak walk-back logic — mirrors /api/streak/+server.ts
// UTC is NEVER used for date comparison; all walk-back is pure string arithmetic.
// ---------------------------------------------------------------------------
function prevDay(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}
function calcStreak(completedDates: Set<string>, localDate: string): number {
let streak = 0;
let cursor = localDate;
while (completedDates.has(cursor)) {
streak++;
cursor = prevDay(cursor);
}
return streak < 2 ? 0 : streak;
}
describe('Streak walk-back — local time always used, UTC never', () => {
test('prevDay uses UTC arithmetic, never server local time', () => {
// Regardless of server timezone, prevDay("2024-03-10") must always be "2024-03-09"
// (including across DST boundaries where local midnight ≠ UTC midnight)
expect(prevDay('2024-03-10')).toBe('2024-03-09'); // DST spring-forward in US
expect(prevDay('2024-11-03')).toBe('2024-11-02'); // DST fall-back in US
expect(prevDay('2024-01-01')).toBe('2023-12-31'); // year boundary
expect(prevDay('2024-03-01')).toBe('2024-02-29'); // leap year
expect(prevDay('2023-03-01')).toBe('2023-02-28'); // non-leap year
});
test('UTC+9 user: plays at 01:00 local (still previous UTC date) — streak uses local date', () => {
// Scenario: It is 2024-01-16 01:00 in Tokyo (UTC+9) = 2024-01-15 16:00 UTC.
// The verse served is for 2024-01-15 (UTC), but the user's LOCAL date is 2024-01-16.
// Completions are stored as the user's local date (as returned by dailyVerse.date
// which is set from the client's new Date().toLocaleDateString("en-CA")).
// The streak walk-back must use local dates, not UTC dates.
// Four consecutive local dates for a Tokyo user
const completedLocalDates = new Set(['2024-01-13', '2024-01-14', '2024-01-15', '2024-01-16']);
// If we (incorrectly) walked back from the UTC date "2024-01-15" instead of
// the local date "2024-01-16", we would miss the most recent completion.
const wrongStreakIfUTC = calcStreak(completedLocalDates, '2024-01-15');
const correctStreakWithLocalDate = calcStreak(completedLocalDates, '2024-01-16');
// UTC walk-back misses the local "2024-01-16" entry → only finds 3 consecutive days
// (but actually it finds 2024-01-15, 2024-01-14, 2024-01-13 = 3, returned as 3)
// The point is it does NOT include 2024-01-16 which is "today" for the user.
expect(wrongStreakIfUTC).toBe(3); // stale — missing today's local entry
// Local walk-back correctly finds all four entries
expect(correctStreakWithLocalDate).toBe(4);
});
test('UTC-8 user: plays at 23:00 local (next UTC date) — streak uses local date', () => {
// Scenario: It is 2024-01-15 23:00 in Los Angeles (UTC-8) = 2024-01-16 07:00 UTC.
// The verse served is for 2024-01-16 (UTC), but the user's LOCAL date is still 2024-01-15.
// Completion is stored as local date "2024-01-15".
// Walk-back from "2024-01-15" (local) must find it; walk-back from "2024-01-16" (UTC)
// would NOT find it (the entry is stored as "2024-01-15").
const completedLocalDates = new Set(['2024-01-12', '2024-01-13', '2024-01-14', '2024-01-15']);
// If we (incorrectly) walked back from the UTC date "2024-01-16" instead of
// the local date "2024-01-15", "2024-01-16" is not in the set → streak = 0.
const wrongStreakIfUTC = calcStreak(completedLocalDates, '2024-01-16');
const correctStreakWithLocalDate = calcStreak(completedLocalDates, '2024-01-15');
expect(wrongStreakIfUTC).toBe(0); // broken — UTC date not in DB
expect(correctStreakWithLocalDate).toBe(4);
});
test('UTC+13 user (Samoa): local date is two days ahead of UTC-11', () => {
// Extreme case: UTC+13 user on 2024-01-16 local = 2024-01-15 UTC.
const completedLocalDates = new Set(['2024-01-14', '2024-01-15', '2024-01-16']);
expect(calcStreak(completedLocalDates, '2024-01-14')).toBe(0); // only 1 day (< 2)
expect(calcStreak(completedLocalDates, '2024-01-16')).toBe(3); // correct local streak
expect(calcStreak(completedLocalDates, '2024-01-15')).toBe(0); // UTC date misses Jan 16
});
test('streak is 0 when today is missing even if yesterday exists', () => {
// User missed today — streak must reset regardless of timezone
const completedLocalDates = new Set(['2024-01-12', '2024-01-13', '2024-01-14']);
// "Today" is 2024-01-16 — they missed the 15th and 16th
expect(calcStreak(completedLocalDates, '2024-01-16')).toBe(0);
});
test('streak suppressed below 2 — single day returns 0', () => {
const completedLocalDates = new Set(['2024-01-15']);
expect(calcStreak(completedLocalDates, '2024-01-15')).toBe(0);
});
test('DB entries with local date store correctly alongside UTC completion timestamp', async () => {
// Verify DB round-trip: storing completions with local dates (not UTC)
// ensures streak calculation with local dates always works.
const userId = 'tz-test-user-' + crypto.randomUUID();
// UTC+9 scenario: played at 01:00 local on Jan 16 (= UTC Jan 15)
// Local date is Jan 16, stored as "2024-01-16" in the DB.
const completions = [
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-14', // local date
guessCount: 2,
completedAt: new Date('2024-01-13T15:00:00Z'), // UTC
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-15', // local date
guessCount: 3,
completedAt: new Date('2024-01-14T15:00:00Z'), // UTC
},
{
id: crypto.randomUUID(),
anonymousId: userId,
date: '2024-01-16', // local date — UTC equivalent is 2024-01-15
guessCount: 1,
completedAt: new Date('2024-01-15T16:00:00Z'), // 01:00 Tokyo time
},
];
await db.insert(dailyCompletions).values(completions);
const rows = await db
.select({ date: dailyCompletions.date })
.from(dailyCompletions)
.where(eq(dailyCompletions.anonymousId, userId));
const storedDates = new Set(rows.map((r) => r.date));
// Walk-back from LOCAL date "2024-01-16" finds all three entries
expect(calcStreak(storedDates, '2024-01-16')).toBe(3);
// Walk-back from UTC date "2024-01-15" misses the "2024-01-16" local entry
// (demonstrates the bug that was fixed: UTC walk-back gives wrong result)
expect(calcStreak(storedDates, '2024-01-15')).toBe(0);
await db.delete(dailyCompletions).where(eq(dailyCompletions.anonymousId, userId));
});
});

12
todo.md
View File

@@ -59,6 +59,18 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
# done
## march 12th
- Added about page with social buttons and XML sitemap for SEO
- Fixed incorrect header background color on Desktop
- Added color theme toggle button (commented out for now)
## feb 26th
- Added dark mode
- Removed URL from share text (Wordle said it was ratchet)
- added option for sharing with verse snippet (hidden on share text first copy)
## february 22nd
- New share button design; speech bubbles