Compare commits

..

2 Commits

Author SHA1 Message Date
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
16 changed files with 333 additions and 183 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

@@ -10,8 +10,6 @@
"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 +98,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=="],

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",

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

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

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

@@ -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,15 +21,21 @@ 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`);
// 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);
}
while (true) {
const dateStr = cursor.toLocaleDateString('en-CA'); // YYYY-MM-DD
if (!completedDates.has(dateStr)) break;
// Walk backwards from the user's local date, counting consecutive completed days
let streak = 0;
let cursor = localDate;
while (completedDates.has(cursor)) {
streak++;
cursor.setDate(cursor.getDate() - 1);
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

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

@@ -8,6 +8,7 @@
formatDate,
type UserStats,
} from "$lib/utils/stats";
import { fetchStreak } from "$lib/utils/streak";
interface PageData {
stats: UserStats | null;
@@ -22,6 +23,7 @@
let anonymousId = $state("");
let loading = $state(true);
let currentStreak = $state(0);
function getOrCreateAnonymousId(): string {
if (!browser) return "";
@@ -36,6 +38,12 @@
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;
});
@@ -151,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"

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