Added donation button

This commit is contained in:
George Powell
2026-07-07 13:12:17 -04:00
parent efc9900de1
commit 99552c57ad
4 changed files with 171 additions and 263 deletions
-199
View File
@@ -1,199 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Bibdle is a daily Bible verse guessing game built with SvelteKit 2 / Svelte 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book, First letter, etc.) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
(Make sure you use the Svelte agent to execute these commands)
## Available MCP Tools:
### 1. list-sections
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
### 2. get-documentation
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
### 3. svelte-autofixer
Analyzes Svelte code and returns issues and suggestions.
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
## Tech Stack
- **Framework**: SvelteKit 2 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
- **Styling**: Tailwind CSS 4
- **Database**: SQLite (`bun:sqlite`) with Drizzle ORM
- **Auth**: Session-based — email/password (argon2id via `Bun.password`), plus Apple and Google OAuth
- **Bible text**: Local NKJV XML (`EnglishNKJBible.xml`), parsed with `fast-xml-parser`; a Greek 1904 and Swedish 2000 translation are also bundled for alternate modes
- **Deployment**: `@sveltejs/adapter-node` run under Bun, managed by a systemd service (`bibdle.service`)
- **ML** (currently disabled): `@xenova/transformers` verse embeddings for a similarity search route
The package version is `3.0.0alpha`.
## Development Commands
```bash
# Start development server
bun run dev
# Type checking
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
# Preview production build
bun run preview
# Database operations
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.
- `/api/streak` walks backwards from `localDate` through the `dailyCompletions` records, counting each completed day. It stops at the first missing day. It's called from the win screen, so today is typically completed.
- `/api/streak-percentile` (which ranks all players) anchors on today-if-played-else-yesterday, so a player's streak isn't zeroed mid-day before they've had a chance to complete today's puzzle.
- A streak of 1 (completed only today, with no prior consecutive days) is **not displayed**`/api/streak` returns `0` for any streak < 2, and the minimum shown streak is 2.
- 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**: `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `googleId` (unique), `isPrivate`
- **session**: `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
- **dailyVerses** (table `daily_verses`): cached daily verse — `date` (unique), `bookId`, `verseText`, `reference`, `createdAt`
- **dailyCompletions** (table `daily_completions`): one row per player/date — `anonymousId`, `date`, `guessCount`, `guesses` (JSON array of book IDs; nullable), `completedAt`. Unique on `(anonymousId, date)` to prevent duplicate submissions.
Sessions expire after 30 days and auto-renew when < 15 days remain.
**Identity model:** logged-in users' `anonymousId` *is* their `user.id``createUser()` inserts the user with `id = anonymousId` so existing stats carry over. Anonymous users get a client-generated UUID stored in `localStorage` (`bibdle-anonymous-id`).
### Bible Data (`src/lib/types/bible.ts`)
The `bibleBooks` array contains all 66 Bible books with metadata:
- `testament`: `old` | `new`
- `section`: `Law`, `History`, `Wisdom`, `Major Prophets`, `Minor Prophets`, `Gospels`, `Pauline Epistles`, `General Epistles`, `Apocalyptic`
- `order` (1-66, used for adjacency detection)
### Daily Verse System
`src/lib/server/daily-verse.ts``getVerseForDate(date)`: returns the cached verse for a date if present, otherwise fetches a random verse from the local XML Bible (`src/lib/server/xml-bible.ts`, wrapped by `src/lib/server/bible-api.ts`) and stores it permanently. The client calls `POST /api/daily-verse` with its local date; `src/routes/+page.server.ts` only loads `user`/`session` (the verse is fetched client-side because `+page.ts` sets `ssr = false`).
### Game Logic
Core logic lives in `src/lib/utils/game.ts` and the reactive store `src/lib/stores/game-persistence.svelte.ts`; `src/routes/+page.svelte` wires them into the UI.
**State Management:**
- `guesses` array stored in `localStorage` keyed by date: `bibdle-guesses-${date}`
- Each `Guess` tracks: `book`, `testamentMatch`, `sectionMatch`, `adjacent`, `firstLetterMatch`
- `evaluateGuess()` includes a special case: numbered Epistles (e.g. "1 John") match on first letter against any other numbered Epistle
- `isWon` derived from whether any guess matches the correct book
- `getGrade()` maps guess count to a letter grade (S+ → C)
**Hint System, for share grid:**
- ✅ Exact match | 🟩 Section match | 🟧 Testament match | ‼️ Adjacent book | 🟥 No match
### Authentication System (`src/lib/server/auth.ts`)
- Token generation: base64url-encoded random bytes; stored as SHA-256 hash in DB. Cookie name: `auth-session`.
- Anonymous users: identified by a client-generated UUID in `localStorage`; stats migrate on sign-up via `migrateAnonymousStats()` (re-attributes `dailyCompletions` rows from the anonymous ID to the new user ID; overlapping dates are dropped).
- Three sign-in methods: email/password (argon2id via `Bun.password`), Apple Sign-In (`src/lib/server/apple-auth.ts`, `appleId` field), and Google Sign-In (`src/lib/server/google-auth.ts`, `googleId` field). The SvelteKit CSRF config trusts `https://appleid.apple.com` for the cross-origin `form_post` callback.
### Stats & Streak (`src/routes/stats/`, `src/routes/progress/`)
- `/stats` and `/progress` require auth; the server load returns `requiresAuth: true` for unauthenticated visitors, and the page renders a sign-in modal.
- The current streak is fetched from the server via `GET /api/streak?anonymousId=X&localDate=Y` (the server never uses its own clock).
- Streak walk-back: counts consecutive days backwards from `localDate` through `dailyCompletions`; stops at the first missing day. Single-day streaks are reported as `0` — the minimum displayed streak is 2.
- `/api/streak` walks from `localDate` only (it's called after a win, so today is completed). `/api/streak-percentile`, which ranks all players, anchors on today-if-played-else-yesterday so mid-day streaks aren't zeroed.
- Achievements/milestones are computed server-side in `src/lib/server/milestones.ts`.
## API Endpoints
- `POST /api/daily-verse` — Fetch (and cache) the verse for a given `YYYY-MM-DD` date
- `POST /api/submit-completion` — Submit a game result (`anonymousId`, `date`, `guessCount`, `guesses`); returns solve rank, guess rank, total solves, average guesses, ties, percentile. Unique on `(anonymousId, date)`.
- `GET /api/streak?anonymousId=X&localDate=Y` — Current streak for a player
- `GET /api/streak-percentile?streak=N&localDate=Y` — Streak percentile ranking across all players
- `GET /api/stats` — Aggregated stats for the `/global` dashboard
- `GET /api/imposter` — Generate a four-verse imposter-mode round
- `POST /api/similar-verses` — Semantic verse search via embeddings
- `POST /api/send-daily-verse` — Cron-only (bearer `CRON_SECRET`); posts today's verse to the Discord webhook
- `POST /api/dev/seed-history` — Dev seeding helper
Other endpoints: `GET /feed.xml` (RSS) and `GET /sitemap.xml` (SEO).
## Key Files
- `src/routes/+page.svelte` — Main game UI and client-side logic
- `src/routes/+page.server.ts` / `+page.ts` — Server load (user/session) + client load (`ssr: false`, fetches the daily verse)
- `src/routes/+layout.svelte` — App shell, title animation, theme toggle, analytics injection
- `src/routes/imposter/`, `/random`, `/greek-random`, `/similarity` — Alternate game/debug modes
- `src/routes/about/`, `/global/`, `/progress/`, `/stats/`, `/dev/` — Supporting pages
- `src/lib/server/auth.ts` — Session management, password hashing, anonymous→user migration
- `src/lib/server/apple-auth.ts`, `google-auth.ts` — OAuth providers
- `src/lib/server/daily-verse.ts` — Per-date verse caching/lookup
- `src/lib/server/xml-bible.ts` — Local XML Bible parsing (NKJV / Greek / Swedish)
- `src/lib/server/bible-api.ts` — Random verse fetching on top of the XML parser
- `src/lib/server/bible.ts` — Bible book utility functions
- `src/lib/server/milestones.ts` — Achievement/milestone calculation
- `src/lib/utils/game.ts` — Guess evaluation and grading
- `src/lib/utils/share.ts` — Share grid/text generation
- `src/lib/utils/streak.ts`, `stats-client.ts`, `stats.ts` — Client-side streak/stats fetching and formatting
- `src/lib/stores/game-persistence.svelte.ts` — Reactive localStorage-backed game state
- `src/lib/types/bible.ts` — 66-book metadata and TypeScript types
- `src/lib/server/db/schema.ts` — Drizzle ORM schema
- `src/hooks.server.ts` — Session validation hook; (commented-out) embeddings init
- `tests/` — Bun test suites: timezone, game, bible, stats, share, sign-in migration
## Environment Variables
See `.env.example`. Required/used variables:
- `DATABASE_URL` — Path to the SQLite database file (e.g., `prod.db`)
- `PUBLIC_SITE_URL` — Canonical site URL
- `CRON_SECRET` — Bearer token protecting the cron-only `/api/send-daily-verse` endpoint
- `DISCORD_DAILY_WEBHOOK` — Webhook for posting the daily verse to Discord
- `AUTH_SECRET` — Secret for Apple Sign-In
- `APPLE_ID` / `APPLE_TEAM_ID` / `APPLE_KEY_ID` / `APPLE_PRIVATE_KEY` — Apple Sign-In credentials
- `SMTP_USERNAME` / `SMTP_TOKEN` / `SMTP_SERVER` / `SMTP_PORT` — Email (nodemailer)
## Deployment
Production uses `@sveltejs/adapter-node` run under Bun via a systemd service. `deploy.sh` pulls latest, installs deps, builds, and restarts `bibdle.service` (which runs `bun --bun build/index.js` on port 5173 with `DATABASE_URL=prod.db`). See `bibdle.service` for the unit file.
## A Note
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.
+163 -36
View File
@@ -68,6 +68,7 @@
let copyTracked = $state(false); let copyTracked = $state(false);
let showSnippetOption = $state(false); let showSnippetOption = $state(false);
let includeSnippet = $state(false); let includeSnippet = $state(false);
let showProgressDropdown = $state(false);
let effectiveShareText = $derived( let effectiveShareText = $derived(
includeSnippet includeSnippet
@@ -333,17 +334,32 @@
{#if isLoggedIn} {#if isLoggedIn}
<div class="signin-prompt"> <div class="signin-prompt">
<div class="rainbow-glow w-full">
<a <a
href="/progress" href="/progress"
class="flex flex-col items-center justify-center gap-1 w-full p-4 mb-2 bg-white dark:bg-gray-900 border-2 border-black/40 dark:border-white/40 rounded-2xl shadow-sm text-gray-800 dark:text-gray-100 text-base font-semibold no-underline transition-transform duration-100 hover:-translate-y-px active:scale-[0.98]" class="progress-btn w-full"
data-umami-event="See your progress (logged in)"
> >
📈 See your progress <span>📈 See your progress</span>
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</a> </a>
</div> </div>
</div>
{:else} {:else}
<div class="signin-prompt"> <div class="signin-prompt">
<button
type="button"
class="progress-btn w-full"
aria-expanded={showProgressDropdown}
onclick={() => (showProgressDropdown = !showProgressDropdown)}
data-umami-event="See your progress (logged out)"
>
<span>📈 See your progress</span>
<svg class="progress-chev" class:open={showProgressDropdown} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{#if showProgressDropdown}
<p class="signin-text text-gray-800 dark:text-gray-300"> <p class="signin-text text-gray-800 dark:text-gray-300">
Create an account (or sign in) to track your progress Create an account (or sign in) to track your progress
</p> </p>
@@ -398,8 +414,52 @@
Sign in with Google Sign in with Google
</button> </button>
</form> </form>
{/if}
</div> </div>
{/if} {/if}
<div class="signin-prompt">
<a
href="https://discord.gg/yWQXbGK8SD"
target="_blank"
rel="noopener noreferrer"
class="discord-btn w-full"
data-umami-event="Join the BIBDLE Discord"
>
<svg
class="discord-icon"
viewBox="0 0 127.14 96.36"
fill="currentColor"
aria-hidden="true"
>
<path
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
/>
</svg>
<span>Join the BIBDLE Discord!</span>
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</a>
</div>
<div class="signin-prompt">
<a
href="https://donate.stripe.com/9B6aEZ9WgeZF3qYfF987K00"
target="_blank"
rel="noopener noreferrer"
class="progress-btn w-full"
data-umami-event="Support bibdle's development"
>
<span>❤️ Support bibdle's development</span>
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</a>
<div class="big-text font-black! text-center text-gray-300! mt-2">
BIBDLE will be ad-free forever.
</div>
</div>
</div> </div>
<style> <style>
@@ -680,45 +740,59 @@
transform: translateX(16px); transform: translateX(16px);
} }
/* ── Apple Sign In prompt ── */ /* ── See your progress button (neobrutalist) ── */
.rainbow-glow { .progress-btn {
position: relative; display: flex;
border-radius: 1rem; align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.85rem 1.25rem;
background: #fff;
color: #111;
border: 2px solid #000;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 700;
text-decoration: none;
cursor: pointer;
box-shadow: 6px 6px 0 0 #000;
transition:
transform 80ms ease,
box-shadow 80ms ease;
} }
.rainbow-glow::before { .progress-btn:hover {
content: ""; transform: translate(-2px, -2px);
position: absolute; box-shadow: 8px 8px 0 0 #000;
inset: 0px;
border-radius: 1.25rem;
background: conic-gradient(
from var(--angle, 0deg),
#ff0080,
#ff8c00,
#ffd700,
#00ff88,
#00cfff,
#a855f7,
#ff0080
);
animation: rainbow-rotate 6s linear infinite;
filter: blur(8px);
opacity: 0.75;
z-index: -1;
} }
@property --angle { .progress-btn:active {
syntax: "<angle>"; transform: translate(2px, 2px);
initial-value: 0deg; box-shadow: 2px 2px 0 0 #000;
inherits: false;
} }
@keyframes rainbow-rotate { .progress-chev {
0% { width: 1.25rem;
--angle: 0deg; height: 1.25rem;
transition: transform 200ms ease;
} }
100% {
--angle: 360deg; .progress-chev.open {
transform: rotate(180deg);
}
@media (prefers-color-scheme: dark) {
.progress-btn {
background: #111827;
color: #f9fafb;
border-color: #fff;
box-shadow: 6px 6px 0 0 #fff;
}
.progress-btn:hover {
box-shadow: 8px 8px 0 0 #fff;
}
.progress-btn:active {
box-shadow: 2px 2px 0 0 #fff;
} }
} }
@@ -730,6 +804,59 @@
/*padding: 1rem 0 0.25rem;*/ /*padding: 1rem 0 0.25rem;*/
} }
/* ── Discord button (neobrutalist, discord purple) ── */
.discord-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.85rem 1.25rem;
background: #5865f2;
color: #fff;
border: 2px solid #000;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 700;
text-decoration: none;
cursor: pointer;
box-shadow: 6px 6px 0 0 #000;
transition:
transform 80ms ease,
box-shadow 80ms ease,
background-color 120ms ease;
}
.discord-btn:hover {
transform: translate(-2px, -2px);
box-shadow: 8px 8px 0 0 #000;
background: #4752c4;
}
.discord-btn:active {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 0 #000;
background: #3c45a5;
}
@media (prefers-color-scheme: dark) {
.discord-btn {
border-color: #fff;
box-shadow: 6px 6px 0 0 #fff;
}
.discord-btn:hover {
box-shadow: 8px 8px 0 0 #fff;
}
.discord-btn:active {
box-shadow: 2px 2px 0 0 #fff;
}
}
.discord-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.signin-text { .signin-text {
font-size: 0.85rem; font-size: 0.85rem;
text-align: center; text-align: center;
+1 -1
View File
@@ -37,7 +37,7 @@
title="Bibdle RSS Feed" title="Bibdle RSS Feed"
href="/feed.xml" href="/feed.xml"
/> />
<meta name="description" content="A daily Bible game" /> <meta name="description" content="A Wordle-inspired daily Bible game" />
</svelte:head> </svelte:head>
<div <div
-20
View File
@@ -452,26 +452,6 @@
<hr <hr
class="animate-fade-in-up animate-delay-800 border-gray-300 dark:border-gray-600" class="animate-fade-in-up animate-delay-800 border-gray-300 dark:border-gray-600"
/> />
<div class="animate-fade-in-up animate-delay-800">
<a
href="https://discord.gg/yWQXbGK8SD"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 w-full px-5 py-2.5 bg-[#5865F2] hover:bg-[#4752C4] text-white font-semibold rounded-lg shadow-md transition-colors duration-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 127.14 96.36"
class="w-5 h-5 fill-white"
aria-hidden="true"
>
<path
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
/>
</svg>
Join the BIBDLE Discord!
</a>
</div>
<div class="animate-fade-in-up animate-delay-800"> <div class="animate-fade-in-up animate-delay-800">
<Credits /> <Credits />
</div> </div>