mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
2 Commits
1ae2b2ac6c
...
3de55ba216
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3de55ba216 | ||
|
|
6e74fffb65 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,3 +32,4 @@ embeddings*
|
|||||||
engwebu_usfx.xml
|
engwebu_usfx.xml
|
||||||
|
|
||||||
deploy.log
|
deploy.log
|
||||||
|
bibdle.socket
|
||||||
|
|||||||
132
CLAUDE.md
132
CLAUDE.md
@@ -27,19 +27,14 @@ After calling the list-sections tool, you MUST analyze the returned documentatio
|
|||||||
Analyzes Svelte code and returns issues and suggestions.
|
Analyzes Svelte code and returns issues and suggestions.
|
||||||
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||||
|
|
||||||
### 4. playground-link
|
|
||||||
|
|
||||||
Generates a Svelte Playground link with the provided code.
|
|
||||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
|
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
|
||||||
- **Styling**: Tailwind CSS 4
|
- **Styling**: Tailwind CSS 4
|
||||||
- **Database**: SQLite with Drizzle ORM
|
- **Database**: SQLite with Drizzle ORM
|
||||||
- **Auth**: Session-based authentication using @oslojs/crypto (SHA-256 hashed tokens)
|
- **Auth**: Session-based authentication using Bun's built-in cryptographically secure functions
|
||||||
- **Deployment**: Node.js adapter for production builds
|
- **Deployment**: Node.js adapter for production builds
|
||||||
- **External API**: bible-api.com for fetching random verses
|
- **ML**: `@xenova/transformers` for verse embeddings (initialized in server hook) (currently disabled, was a test for a cancelled project)
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
@@ -51,6 +46,11 @@ bun run dev
|
|||||||
bun run check
|
bun run check
|
||||||
bun run check:watch
|
bun run check:watch
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
bun test
|
||||||
|
bun test --watch
|
||||||
|
bun test tests/timezone-handling.test.ts # Run a single test file
|
||||||
|
|
||||||
# Build for production
|
# Build for production
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
@@ -58,92 +58,108 @@ bun run build
|
|||||||
bun run preview
|
bun run preview
|
||||||
|
|
||||||
# Database operations
|
# Database operations
|
||||||
bun run db:push # Push schema changes to database
|
bun run db:push # Push schema changes directly (avoid in prod)
|
||||||
bun run db:generate # Generate migrations (DO NOT RUN)
|
bun run db:generate # Generate migrations
|
||||||
bun run db:migrate # Run migrations (DO NOT RUN)
|
bun run db:migrate # Run migrations
|
||||||
bun run db:studio # Open Drizzle Studio GUI
|
bun run db:studio # Open Drizzle Studio GUI
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Critical: Date/Time Handling
|
||||||
|
|
||||||
|
**Bibdle is played by users across many timezones worldwide. The verse shown to a player must always be the verse for the calendar date at *their* location — not the server's timezone, not UTC. A user in Tokyo on Wednesday must see Wednesday's verse, even if the server (or a user in New York) is still on Tuesday.**
|
||||||
|
|
||||||
|
**NEVER use server time or UTC time for user-facing date calculations.**
|
||||||
|
|
||||||
|
- Get today's date client-side: `new Date().toLocaleDateString("en-CA")` → `YYYY-MM-DD`
|
||||||
|
- Pass the date to the server as a query param or POST body (`localDate`)
|
||||||
|
- Server-side date arithmetic must use UTC methods on the client-provided date string: `new Date(dateStr + 'T00:00:00Z')` + `setUTCDate`/`getUTCDate`
|
||||||
|
- `src/routes/+page.ts` has `ssr = false` so the load runs client-side with the true local date
|
||||||
|
- Never set the user-facing URL to include their date as a parameter. It should always be passed to an API route behind the scenes if needed.
|
||||||
|
|
||||||
|
### Streak Calculation
|
||||||
|
|
||||||
|
A streak counts consecutive calendar days (in the user's local timezone) on which the user completed the puzzle. The rules:
|
||||||
|
|
||||||
|
- The client passes its local date (`localDate`) to the streak API. The server never uses its own clock.
|
||||||
|
- A streak is **active** if the user has completed today's puzzle *or* yesterday's puzzle (they still have time to play today).
|
||||||
|
- Walk backwards from `localDate` through the `dailyCompletions` records, counting each day that has a completion. Stop as soon as a day is missing.
|
||||||
|
- A streak of 1 (completed only today or only yesterday, with no prior consecutive days) is **not displayed** — the minimum shown streak is 2.
|
||||||
|
- "Yesterday" and all date arithmetic on the server must use UTC methods on the client-provided date string to avoid timezone drift: `new Date(localDate + 'T00:00:00Z')`, then `setUTCDate`/`getUTCDate`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Database Schema (`src/lib/server/db/schema.ts`)
|
### Database Schema (`src/lib/server/db/schema.ts`)
|
||||||
|
|
||||||
- **user**: User accounts with id and age
|
- **user**: `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `isPrivate`
|
||||||
- **session**: Auth sessions linked to users with expiration timestamps
|
- **session**: `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
|
||||||
- **daily_verses**: Cached daily verses with book ID, verse text, reference, and date
|
- **daily_verses**: Cached daily verses with book ID, verse text, reference, and date
|
||||||
|
- **dailyCompletions**: Game results per user/date with guess count, grade, book; unique on `(userId, date)`
|
||||||
|
|
||||||
Sessions expire after 30 days and are automatically renewed when less than 15 days remain.
|
Sessions expire after 30 days and auto-renew when < 15 days remain.
|
||||||
|
|
||||||
### Bible Data (`src/lib/types/bible.ts`)
|
### Bible Data (`src/lib/types/bible.ts`)
|
||||||
|
|
||||||
The `bibleBooks` array contains all 66 Bible books with metadata:
|
The `bibleBooks` array contains all 66 Bible books with metadata:
|
||||||
- Testament (old/new)
|
- Testament (old/new), Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
|
||||||
- Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
|
|
||||||
- Order (1-66, used for adjacency detection)
|
- Order (1-66, used for adjacency detection)
|
||||||
- Popularity (2-10, affects grading - higher is more popular)
|
|
||||||
|
|
||||||
### Daily Verse System (`src/routes/+page.server.ts`)
|
### Daily Verse System (`src/routes/+page.server.ts`)
|
||||||
|
|
||||||
The `getTodayVerse()` function:
|
`getTodayVerse()` checks the database for today's date, fetches a verse if missing, caches permanently, and returns verse with book metadata.
|
||||||
1. Checks database for existing verse for today's date
|
|
||||||
2. If none exists, fetches from bible-api.com (random verse + 2 consecutive verses)
|
|
||||||
3. Caches in database with UTC date key
|
|
||||||
4. Returns verse with book metadata for the game
|
|
||||||
|
|
||||||
### Game Logic (`src/routes/+page.svelte`)
|
### Game Logic (`src/routes/+page.svelte`)
|
||||||
|
|
||||||
**State Management:**
|
**State Management:**
|
||||||
- `guesses` array stores game state in localStorage keyed by date
|
- `guesses` array stored in localStorage keyed by date: `bibdle-guesses-${date}`
|
||||||
- Each guess tracks: book, testamentMatch, sectionMatch, adjacent
|
- Each guess tracks: book, testamentMatch, sectionMatch, adjacent
|
||||||
- `isWon` is derived from whether any guess matches the correct book
|
- `isWon` derived from whether any guess matches the correct book
|
||||||
|
|
||||||
**Grading System:**
|
**Hint System, for share grid:**
|
||||||
```javascript
|
- ✅ Exact match | 🟩 Section match | 🟧 Testament match | ‼️ Adjacent book | 🟥 No match
|
||||||
// Grade formula combines performance + difficulty
|
|
||||||
performanceScore = max(0, 10 - numGuesses)
|
|
||||||
difficulty = 14 - popularity
|
|
||||||
totalScore = performanceScore + difficulty * 0.8
|
|
||||||
|
|
||||||
// S: 14+, A: 11+, B: 8+, C: 5+, C-: <5
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hint System:**
|
|
||||||
- ✅ Green checkmark: Exact match
|
|
||||||
- 🟩 Green square: Section matches
|
|
||||||
- 🟧 Orange square: Testament matches (shared results)
|
|
||||||
- ‼️ Double exclamation: Adjacent book in Bible order
|
|
||||||
- 🟥 Red square: No match
|
|
||||||
|
|
||||||
### Authentication System (`src/lib/server/auth.ts`)
|
### Authentication System (`src/lib/server/auth.ts`)
|
||||||
|
|
||||||
- Token-based sessions with SHA-256 hashing
|
- Token generation: base64-encoded random bytes; stored as SHA-256 hash in DB
|
||||||
- Cookies store session tokens, validated on each request
|
- Cookie name: `auth-session`
|
||||||
- Hook in `src/hooks.server.ts` populates `event.locals.user` and `event.locals.session`
|
- Anonymous users: identified by a client-generated ID; stats migrate on sign-up via `migrateAnonymousStats()`
|
||||||
- Note: Currently the schema includes user table but auth UI is not yet implemented
|
- Apple Sign-In supported via `appleId` field
|
||||||
|
|
||||||
|
### Stats & Streak (`src/routes/stats/`)
|
||||||
|
|
||||||
|
- Stats page requires auth; returns `requiresAuth: true` if unauthenticated
|
||||||
|
- Streak calculated client-side by calling `GET /api/streak?userId=X&localDate=Y`
|
||||||
|
- Streak walk-back: counts consecutive days backwards from `localDate` through completed dates
|
||||||
|
- Minimum displayed streak is 2 (single-day streaks suppressed)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `POST /api/daily-verse` — Fetch verse for a specific date
|
||||||
|
- `POST /api/submit-completion` — Submit game result with stats
|
||||||
|
- `GET /api/streak?userId=X&localDate=Y` — Current streak for user
|
||||||
|
- `GET /api/streak-percentile` — Streak percentile ranking
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
- `src/routes/+page.svelte` - Main game UI and client-side logic
|
- `src/routes/+page.svelte` — Main game UI and client-side logic
|
||||||
- `src/routes/+page.server.ts` - Server load function, fetches/caches daily verse
|
- `src/routes/+page.server.ts` / `+page.ts` — Server load (verse) + client load (`ssr: false`)
|
||||||
- `src/lib/server/bible-api.ts` - External API integration for verse fetching
|
- `src/routes/stats/+page.svelte` / `+page.server.ts` — Stats UI and server calculations
|
||||||
- `src/lib/server/bible.ts` - Bible book utility functions
|
- `src/lib/server/auth.ts` — Session management, password hashing, anonymous migration
|
||||||
- `src/lib/types/bible.ts` - Bible books data and TypeScript types
|
- `src/lib/server/bible-api.ts` — Random verse fetching from local XML Bible
|
||||||
- `src/lib/server/db/schema.ts` - Drizzle ORM schema definitions
|
- `src/lib/server/bible.ts` — Bible book utility functions
|
||||||
- `src/hooks.server.ts` - SvelteKit server hook for session validation
|
- `src/lib/types/bible.ts` — Bible books data and TypeScript types
|
||||||
|
- `src/lib/server/db/schema.ts` — Drizzle ORM schema
|
||||||
|
- `src/hooks.server.ts` — Session validation hook; initializes ML embeddings
|
||||||
|
- `tests/` — Bun test suites (timezone, game, bible, stats, share, auth migration)
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
Required in `.env`:
|
Required in `.env`:
|
||||||
- `DATABASE_URL` - Path to SQLite database file (e.g., `./local.db`)
|
- `DATABASE_URL` — Path to SQLite database file (e.g., `./local.db`)
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
The project uses `@sveltejs/adapter-node` for deployment. The build output is a Node.js server that can be run with systemd or similar process managers. See `bibdle.service` and `bibdle.socket` for systemd configuration.
|
Uses `@sveltejs/adapter-node`. See `bibdle.service` systemd configuration.
|
||||||
|
|
||||||
## Important Notes
|
## A Note
|
||||||
|
|
||||||
- Uses Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`) - not stores or reactive declarations
|
The main developer of this project is still learning a lot about developing full-stack applications. If they ask you to do something, make sure they understand how it will be implemented before proceeding.
|
||||||
- The schema includes authentication tables but the login/signup UI is not yet implemented
|
|
||||||
- Daily verses are cached permanently in the database to ensure consistency
|
|
||||||
- LocalStorage persists guesses per day using the key pattern `bibdle-guesses-${date}`
|
|
||||||
- The game validates book IDs from the API against the hardcoded `bibleBooks` array
|
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Bibdle SvelteKit App
|
Description=Bibdle SvelteKit App
|
||||||
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
||||||
Requires=bibdle.socket
|
After=network-online.target
|
||||||
After=network-online.target bibdle.socket
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
Environment=ORIGIN=https://bibdle.com
|
Environment=ORIGIN=https://bibdle.com
|
||||||
Environment=DATABASE_URL=local.db
|
Environment=DATABASE_URL=prod.db
|
||||||
Environment=IDLE_TIMEOUT=60
|
Environment=IDLE_TIMEOUT=300
|
||||||
WorkingDirectory=/home/george/projects/bibdle
|
Environment=PORT=5173
|
||||||
ExecStart=/home/george/.nvm/versions/node/v24.12.0/bin/node build/index.js
|
WorkingDirectory=/home/xenia/projects/bibdle
|
||||||
|
#ExecStart=/home/xenia/.nvm/versions/node/v24.13.0/bin/node build/index.js
|
||||||
|
ExecStart=/home/xenia/.bun/bin/bun --bun build/index.js
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
[Socket]
|
|
||||||
ListenStream=5173
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=sockets.target
|
|
||||||
10
bun.lock
10
bun.lock
@@ -10,8 +10,6 @@
|
|||||||
"xml2js": "^0.6.2",
|
"xml2js": "^0.6.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@oslojs/crypto": "^1.0.1",
|
|
||||||
"@oslojs/encoding": "^1.1.0",
|
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
@@ -100,14 +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=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
|
||||||
|
|
||||||
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
|
|
||||||
|
|
||||||
"@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
|
|
||||||
|
|
||||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
|
||||||
|
|
||||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||||
|
|
||||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||||
|
|||||||
@@ -18,8 +18,6 @@
|
|||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@oslojs/crypto": "^1.0.1",
|
|
||||||
"@oslojs/encoding": "^1.1.0",
|
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
|||||||
@@ -32,4 +32,4 @@ export const handle: Handle = handleAuth;
|
|||||||
|
|
||||||
// Initialize embeddings on server start (runs once on module load)
|
// Initialize embeddings on server start (runs once on module load)
|
||||||
const verses = getAllNKJVVerses();
|
const verses = getAllNKJVVerses();
|
||||||
await initializeEmbeddings(verses);
|
// await initializeEmbeddings(verses);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { encodeBase64url } from '@oslojs/encoding';
|
|
||||||
|
|
||||||
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
||||||
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
||||||
|
|
||||||
@@ -26,8 +24,8 @@ export async function generateAppleClientSecret(): Promise<string> {
|
|||||||
sub: Bun.env.APPLE_ID!
|
sub: Bun.env.APPLE_ID!
|
||||||
};
|
};
|
||||||
|
|
||||||
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
|
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
|
||||||
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
|
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
|
||||||
// Import PEM private key
|
// Import PEM private key
|
||||||
@@ -55,7 +53,7 @@ export async function generateAppleClientSecret(): Promise<string> {
|
|||||||
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
||||||
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
||||||
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
||||||
const encodedSignature = encodeBase64url(rawSignature);
|
const encodedSignature = Buffer.from(rawSignature).toString('base64url');
|
||||||
|
|
||||||
return `${signingInput}.${encodedSignature}`;
|
return `${signingInput}.${encodedSignature}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { sha256 } from '@oslojs/crypto/sha2';
|
|
||||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
|
||||||
import { testDb as db } from '$lib/server/db/test';
|
import { testDb as db } from '$lib/server/db/test';
|
||||||
import * as table from '$lib/server/db/schema';
|
import * as table from '$lib/server/db/schema';
|
||||||
|
|
||||||
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
|
|||||||
|
|
||||||
export function generateSessionToken() {
|
export function generateSessionToken() {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||||
const token = encodeBase64url(bytes);
|
return Buffer.from(bytes).toString('base64url');
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(token: string, userId: string) {
|
export async function createSession(token: string, userId: string) {
|
||||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||||
const session: table.Session = {
|
const session: table.Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
userId,
|
userId,
|
||||||
@@ -27,7 +24,7 @@ export async function createSession(token: string, userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validateSessionToken(token: string) {
|
export async function validateSessionToken(token: string) {
|
||||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select({
|
.select({
|
||||||
// Adjust user table here to tweak returned data
|
// Adjust user table here to tweak returned data
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { sha256 } from '@oslojs/crypto/sha2';
|
|
||||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import * as table from '$lib/server/db/schema';
|
import * as table from '$lib/server/db/schema';
|
||||||
|
|
||||||
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
|
|||||||
|
|
||||||
export function generateSessionToken() {
|
export function generateSessionToken() {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||||
const token = encodeBase64url(bytes);
|
return Buffer.from(bytes).toString('base64url');
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(token: string, userId: string) {
|
export async function createSession(token: string, userId: string) {
|
||||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||||
const session: table.Session = {
|
const session: table.Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
userId,
|
userId,
|
||||||
@@ -27,7 +24,7 @@ export async function createSession(token: string, userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validateSessionToken(token: string) {
|
export async function validateSessionToken(token: string) {
|
||||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select({
|
.select({
|
||||||
// Adjust user table here to tweak returned data
|
// Adjust user table here to tweak returned data
|
||||||
|
|||||||
@@ -17,75 +17,73 @@ export interface BibleBook {
|
|||||||
testament: Testament;
|
testament: Testament;
|
||||||
section: BibleSection;
|
section: BibleSection;
|
||||||
order: number;
|
order: number;
|
||||||
url: string;
|
|
||||||
popularity: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bibleBooks: BibleBook[] = [
|
export const bibleBooks: BibleBook[] = [
|
||||||
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1, url: 'https://bible-api.com/data/web/GEN', popularity: 8 },
|
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1 },
|
||||||
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2, url: 'https://bible-api.com/data/web/EXO', popularity: 3 },
|
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2 },
|
||||||
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3, url: 'https://bible-api.com/data/web/LEV', popularity: 2 },
|
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3 },
|
||||||
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4, url: 'https://bible-api.com/data/web/NUM', popularity: 2 },
|
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4 },
|
||||||
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5, url: 'https://bible-api.com/data/web/DEU', popularity: 2 },
|
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5 },
|
||||||
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6, url: 'https://bible-api.com/data/web/JOS', popularity: 2 },
|
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6 },
|
||||||
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7, url: 'https://bible-api.com/data/web/JDG', popularity: 2 },
|
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7 },
|
||||||
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8, url: 'https://bible-api.com/data/web/RUT', popularity: 2 },
|
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8 },
|
||||||
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9, url: 'https://bible-api.com/data/web/1SA', popularity: 1 },
|
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9 },
|
||||||
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10, url: 'https://bible-api.com/data/web/2SA', popularity: 0 },
|
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10 },
|
||||||
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11, url: 'https://bible-api.com/data/web/1KI', popularity: 1 },
|
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11 },
|
||||||
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12, url: 'https://bible-api.com/data/web/2KI', popularity: 0 },
|
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12 },
|
||||||
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13, url: 'https://bible-api.com/data/web/1CH', popularity: 1 },
|
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13 },
|
||||||
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14, url: 'https://bible-api.com/data/web/2CH', popularity: 0 },
|
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14 },
|
||||||
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15, url: 'https://bible-api.com/data/web/EZR', popularity: 1 },
|
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15 },
|
||||||
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16, url: 'https://bible-api.com/data/web/NEH', popularity: 1 },
|
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16 },
|
||||||
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17, url: 'https://bible-api.com/data/web/EST', popularity: 1 },
|
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17 },
|
||||||
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18, url: 'https://bible-api.com/data/web/JOB', popularity: 2 },
|
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18 },
|
||||||
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19, url: 'https://bible-api.com/data/web/PSA', popularity: 7 },
|
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19 },
|
||||||
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20, url: 'https://bible-api.com/data/web/PRO', popularity: 7 },
|
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20 },
|
||||||
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21, url: 'https://bible-api.com/data/web/ECC', popularity: 2 },
|
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21 },
|
||||||
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22, url: 'https://bible-api.com/data/web/SNG', popularity: 2 },
|
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22 },
|
||||||
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23, url: 'https://bible-api.com/data/web/ISA', popularity: 2 },
|
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23 },
|
||||||
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24, url: 'https://bible-api.com/data/web/JER', popularity: 2 },
|
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24 },
|
||||||
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25, url: 'https://bible-api.com/data/web/LAM', popularity: 2 },
|
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25 },
|
||||||
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26, url: 'https://bible-api.com/data/web/EZK', popularity: 2 },
|
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26 },
|
||||||
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27, url: 'https://bible-api.com/data/web/DAN', popularity: 2 },
|
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27 },
|
||||||
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28, url: 'https://bible-api.com/data/web/HOS', popularity: 2 },
|
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28 },
|
||||||
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29, url: 'https://bible-api.com/data/web/JOL', popularity: 2 },
|
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29 },
|
||||||
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30, url: 'https://bible-api.com/data/web/AMO', popularity: 2 },
|
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30 },
|
||||||
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31, url: 'https://bible-api.com/data/web/OBA', popularity: 2 },
|
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31 },
|
||||||
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32, url: 'https://bible-api.com/data/web/JON', popularity: 2 },
|
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32 },
|
||||||
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33, url: 'https://bible-api.com/data/web/MIC', popularity: 2 },
|
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33 },
|
||||||
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34, url: 'https://bible-api.com/data/web/NAM', popularity: 2 },
|
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34 },
|
||||||
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35, url: 'https://bible-api.com/data/web/HAB', popularity: 2 },
|
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35 },
|
||||||
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36, url: 'https://bible-api.com/data/web/ZEP', popularity: 2 },
|
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36 },
|
||||||
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37, url: 'https://bible-api.com/data/web/HAG', popularity: 2 },
|
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37 },
|
||||||
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38, url: 'https://bible-api.com/data/web/ZEC', popularity: 2 },
|
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38 },
|
||||||
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39, url: 'https://bible-api.com/data/web/MAL', popularity: 2 },
|
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39 },
|
||||||
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40, url: 'https://bible-api.com/data/web/MAT', popularity: 8 },
|
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40 },
|
||||||
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41, url: 'https://bible-api.com/data/web/MRK', popularity: 8 },
|
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41 },
|
||||||
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42, url: 'https://bible-api.com/data/web/LUK', popularity: 8 },
|
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42 },
|
||||||
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43, url: 'https://bible-api.com/data/web/JHN', popularity: 8 },
|
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43 },
|
||||||
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44, url: 'https://bible-api.com/data/web/ACT', popularity: 2 },
|
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44 },
|
||||||
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45, url: 'https://bible-api.com/data/web/ROM', popularity: 6 },
|
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45 },
|
||||||
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46, url: 'https://bible-api.com/data/web/1CO', popularity: 5 },
|
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46 },
|
||||||
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47, url: 'https://bible-api.com/data/web/2CO', popularity: 5 },
|
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47 },
|
||||||
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48, url: 'https://bible-api.com/data/web/GAL', popularity: 5 },
|
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48 },
|
||||||
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49, url: 'https://bible-api.com/data/web/EPH', popularity: 5 },
|
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49 },
|
||||||
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50, url: 'https://bible-api.com/data/web/PHP', popularity: 5 },
|
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50 },
|
||||||
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51, url: 'https://bible-api.com/data/web/COL', popularity: 5 },
|
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51 },
|
||||||
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52, url: 'https://bible-api.com/data/web/1TH', popularity: 5 },
|
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52 },
|
||||||
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53, url: 'https://bible-api.com/data/web/2TH', popularity: 5 },
|
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53 },
|
||||||
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54, url: 'https://bible-api.com/data/web/1TI', popularity: 5 },
|
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54 },
|
||||||
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55, url: 'https://bible-api.com/data/web/2TI', popularity: 5 },
|
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55 },
|
||||||
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56, url: 'https://bible-api.com/data/web/TIT', popularity: 5 },
|
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56 },
|
||||||
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57, url: 'https://bible-api.com/data/web/PHM', popularity: 5 },
|
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57 },
|
||||||
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58, url: 'https://bible-api.com/data/web/HEB', popularity: 4 },
|
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58 },
|
||||||
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59, url: 'https://bible-api.com/data/web/JAS', popularity: 4 },
|
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59 },
|
||||||
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60, url: 'https://bible-api.com/data/web/1PE', popularity: 4 },
|
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60 },
|
||||||
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61, url: 'https://bible-api.com/data/web/2PE', popularity: 4 },
|
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61 },
|
||||||
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62, url: 'https://bible-api.com/data/web/1JN', popularity: 4 },
|
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62 },
|
||||||
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63, url: 'https://bible-api.com/data/web/2JN', popularity: 4 },
|
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63 },
|
||||||
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64, url: 'https://bible-api.com/data/web/3JN', popularity: 4 },
|
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64 },
|
||||||
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65, url: 'https://bible-api.com/data/web/JUD', popularity: 4 },
|
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65 },
|
||||||
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66, url: 'https://bible-api.com/data/web/REV', popularity: 2 }
|
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66 }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
error(400, 'Missing anonymousId or localDate');
|
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
|
const rows = await db
|
||||||
.select({ date: dailyCompletions.date })
|
.select({ date: dailyCompletions.date })
|
||||||
.from(dailyCompletions)
|
.from(dailyCompletions)
|
||||||
@@ -21,15 +21,21 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
|
|
||||||
const completedDates = new Set(rows.map((r) => r.date));
|
const completedDates = new Set(rows.map((r) => r.date));
|
||||||
|
|
||||||
// Walk backwards from localDate, counting consecutive completed days
|
// Subtract one calendar day from a YYYY-MM-DD string using UTC arithmetic —
|
||||||
let streak = 0;
|
// this avoids any dependence on the server's local timezone or DST offsets.
|
||||||
let cursor = new Date(`${localDate}T00:00:00`);
|
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) {
|
// Walk backwards from the user's local date, counting consecutive completed days
|
||||||
const dateStr = cursor.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
let streak = 0;
|
||||||
if (!completedDates.has(dateStr)) break;
|
let cursor = localDate;
|
||||||
|
|
||||||
|
while (completedDates.has(cursor)) {
|
||||||
streak++;
|
streak++;
|
||||||
cursor.setDate(cursor.getDate() - 1);
|
cursor = prevDay(cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ streak: streak < 2 ? 0 : streak });
|
return json({ streak: streak < 2 ? 0 : streak });
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
||||||
import { encodeBase64url } from '@oslojs/encoding';
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({ cookies, request }) => {
|
default: async ({ cookies, request }) => {
|
||||||
@@ -10,7 +9,7 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
// Generate CSRF state
|
// Generate CSRF state
|
||||||
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
const state = encodeBase64url(stateBytes);
|
const state = Buffer.from(stateBytes).toString('base64url');
|
||||||
|
|
||||||
// Store state + anonymousId in a short-lived cookie
|
// Store state + anonymousId in a short-lived cookie
|
||||||
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user's current date from timezone query param
|
// Note: userToday is used only for the initial server-side streak estimate.
|
||||||
const timezone = url.searchParams.get('tz') || 'UTC';
|
// The client overrides this with a precise local-date calculation via /api/streak.
|
||||||
const userToday = new Date().toLocaleDateString('en-CA', { timeZone: timezone });
|
const userToday = new Date().toISOString().slice(0, 10); // UTC date as safe fallback
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all completions for this user
|
// Get all completions for this user
|
||||||
@@ -85,7 +85,7 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
|||||||
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
|
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate streaks
|
// Calculate streaks — dates are stored as the user's local date
|
||||||
const sortedDates = completions
|
const sortedDates = completions
|
||||||
.map((c: DailyCompletion) => c.date)
|
.map((c: DailyCompletion) => c.date)
|
||||||
.sort();
|
.sort();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
formatDate,
|
formatDate,
|
||||||
type UserStats,
|
type UserStats,
|
||||||
} from "$lib/utils/stats";
|
} from "$lib/utils/stats";
|
||||||
|
import { fetchStreak } from "$lib/utils/streak";
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
stats: UserStats | null;
|
stats: UserStats | null;
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
let anonymousId = $state("");
|
let anonymousId = $state("");
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let currentStreak = $state(0);
|
||||||
|
|
||||||
function getOrCreateAnonymousId(): string {
|
function getOrCreateAnonymousId(): string {
|
||||||
if (!browser) return "";
|
if (!browser) return "";
|
||||||
@@ -36,6 +38,12 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
anonymousId = getOrCreateAnonymousId();
|
anonymousId = getOrCreateAnonymousId();
|
||||||
|
if (data.user?.id) {
|
||||||
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
|
currentStreak = await fetchStreak(data.user.id, localDate);
|
||||||
|
} else {
|
||||||
|
currentStreak = data.stats?.currentStreak ?? 0;
|
||||||
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,7 +159,7 @@
|
|||||||
<div
|
<div
|
||||||
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
||||||
>
|
>
|
||||||
{stats.currentStreak}
|
{currentStreak}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||||
|
|||||||
@@ -496,3 +496,149 @@ describe('Timezone-aware daily verse system', () => {
|
|||||||
// See /api/submit-completion for the unique constraint enforcement
|
// See /api/submit-completion for the unique constraint enforcement
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Streak walk-back logic — mirrors /api/streak/+server.ts
|
||||||
|
// UTC is NEVER used for date comparison; all walk-back is pure string arithmetic.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function prevDay(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() - 1);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcStreak(completedDates: Set<string>, localDate: string): number {
|
||||||
|
let streak = 0;
|
||||||
|
let cursor = localDate;
|
||||||
|
while (completedDates.has(cursor)) {
|
||||||
|
streak++;
|
||||||
|
cursor = prevDay(cursor);
|
||||||
|
}
|
||||||
|
return streak < 2 ? 0 : streak;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Streak walk-back — local time always used, UTC never', () => {
|
||||||
|
test('prevDay uses UTC arithmetic, never server local time', () => {
|
||||||
|
// Regardless of server timezone, prevDay("2024-03-10") must always be "2024-03-09"
|
||||||
|
// (including across DST boundaries where local midnight ≠ UTC midnight)
|
||||||
|
expect(prevDay('2024-03-10')).toBe('2024-03-09'); // DST spring-forward in US
|
||||||
|
expect(prevDay('2024-11-03')).toBe('2024-11-02'); // DST fall-back in US
|
||||||
|
expect(prevDay('2024-01-01')).toBe('2023-12-31'); // year boundary
|
||||||
|
expect(prevDay('2024-03-01')).toBe('2024-02-29'); // leap year
|
||||||
|
expect(prevDay('2023-03-01')).toBe('2023-02-28'); // non-leap year
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UTC+9 user: plays at 01:00 local (still previous UTC date) — streak uses local date', () => {
|
||||||
|
// Scenario: It is 2024-01-16 01:00 in Tokyo (UTC+9) = 2024-01-15 16:00 UTC.
|
||||||
|
// The verse served is for 2024-01-15 (UTC), but the user's LOCAL date is 2024-01-16.
|
||||||
|
// Completions are stored as the user's local date (as returned by dailyVerse.date
|
||||||
|
// which is set from the client's new Date().toLocaleDateString("en-CA")).
|
||||||
|
// The streak walk-back must use local dates, not UTC dates.
|
||||||
|
|
||||||
|
// Four consecutive local dates for a Tokyo user
|
||||||
|
const completedLocalDates = new Set(['2024-01-13', '2024-01-14', '2024-01-15', '2024-01-16']);
|
||||||
|
|
||||||
|
// If we (incorrectly) walked back from the UTC date "2024-01-15" instead of
|
||||||
|
// the local date "2024-01-16", we would miss the most recent completion.
|
||||||
|
const wrongStreakIfUTC = calcStreak(completedLocalDates, '2024-01-15');
|
||||||
|
const correctStreakWithLocalDate = calcStreak(completedLocalDates, '2024-01-16');
|
||||||
|
|
||||||
|
// UTC walk-back misses the local "2024-01-16" entry → only finds 3 consecutive days
|
||||||
|
// (but actually it finds 2024-01-15, 2024-01-14, 2024-01-13 = 3, returned as 3)
|
||||||
|
// The point is it does NOT include 2024-01-16 which is "today" for the user.
|
||||||
|
expect(wrongStreakIfUTC).toBe(3); // stale — missing today's local entry
|
||||||
|
|
||||||
|
// Local walk-back correctly finds all four entries
|
||||||
|
expect(correctStreakWithLocalDate).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UTC-8 user: plays at 23:00 local (next UTC date) — streak uses local date', () => {
|
||||||
|
// Scenario: It is 2024-01-15 23:00 in Los Angeles (UTC-8) = 2024-01-16 07:00 UTC.
|
||||||
|
// The verse served is for 2024-01-16 (UTC), but the user's LOCAL date is still 2024-01-15.
|
||||||
|
// Completion is stored as local date "2024-01-15".
|
||||||
|
// Walk-back from "2024-01-15" (local) must find it; walk-back from "2024-01-16" (UTC)
|
||||||
|
// would NOT find it (the entry is stored as "2024-01-15").
|
||||||
|
|
||||||
|
const completedLocalDates = new Set(['2024-01-12', '2024-01-13', '2024-01-14', '2024-01-15']);
|
||||||
|
|
||||||
|
// If we (incorrectly) walked back from the UTC date "2024-01-16" instead of
|
||||||
|
// the local date "2024-01-15", "2024-01-16" is not in the set → streak = 0.
|
||||||
|
const wrongStreakIfUTC = calcStreak(completedLocalDates, '2024-01-16');
|
||||||
|
const correctStreakWithLocalDate = calcStreak(completedLocalDates, '2024-01-15');
|
||||||
|
|
||||||
|
expect(wrongStreakIfUTC).toBe(0); // broken — UTC date not in DB
|
||||||
|
expect(correctStreakWithLocalDate).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UTC+13 user (Samoa): local date is two days ahead of UTC-11', () => {
|
||||||
|
// Extreme case: UTC+13 user on 2024-01-16 local = 2024-01-15 UTC.
|
||||||
|
const completedLocalDates = new Set(['2024-01-14', '2024-01-15', '2024-01-16']);
|
||||||
|
|
||||||
|
expect(calcStreak(completedLocalDates, '2024-01-14')).toBe(0); // only 1 day (< 2)
|
||||||
|
expect(calcStreak(completedLocalDates, '2024-01-16')).toBe(3); // correct local streak
|
||||||
|
expect(calcStreak(completedLocalDates, '2024-01-15')).toBe(0); // UTC date misses Jan 16
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streak is 0 when today is missing even if yesterday exists', () => {
|
||||||
|
// User missed today — streak must reset regardless of timezone
|
||||||
|
const completedLocalDates = new Set(['2024-01-12', '2024-01-13', '2024-01-14']);
|
||||||
|
// "Today" is 2024-01-16 — they missed the 15th and 16th
|
||||||
|
expect(calcStreak(completedLocalDates, '2024-01-16')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streak suppressed below 2 — single day returns 0', () => {
|
||||||
|
const completedLocalDates = new Set(['2024-01-15']);
|
||||||
|
expect(calcStreak(completedLocalDates, '2024-01-15')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DB entries with local date store correctly alongside UTC completion timestamp', async () => {
|
||||||
|
// Verify DB round-trip: storing completions with local dates (not UTC)
|
||||||
|
// ensures streak calculation with local dates always works.
|
||||||
|
const userId = 'tz-test-user-' + crypto.randomUUID();
|
||||||
|
|
||||||
|
// UTC+9 scenario: played at 01:00 local on Jan 16 (= UTC Jan 15)
|
||||||
|
// Local date is Jan 16, stored as "2024-01-16" in the DB.
|
||||||
|
const completions = [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-14', // local date
|
||||||
|
guessCount: 2,
|
||||||
|
completedAt: new Date('2024-01-13T15:00:00Z'), // UTC
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-15', // local date
|
||||||
|
guessCount: 3,
|
||||||
|
completedAt: new Date('2024-01-14T15:00:00Z'), // UTC
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
anonymousId: userId,
|
||||||
|
date: '2024-01-16', // local date — UTC equivalent is 2024-01-15
|
||||||
|
guessCount: 1,
|
||||||
|
completedAt: new Date('2024-01-15T16:00:00Z'), // 01:00 Tokyo time
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(dailyCompletions).values(completions);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ date: dailyCompletions.date })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
|
||||||
|
const storedDates = new Set(rows.map((r) => r.date));
|
||||||
|
|
||||||
|
// Walk-back from LOCAL date "2024-01-16" finds all three entries
|
||||||
|
expect(calcStreak(storedDates, '2024-01-16')).toBe(3);
|
||||||
|
|
||||||
|
// Walk-back from UTC date "2024-01-15" misses the "2024-01-16" local entry
|
||||||
|
// (demonstrates the bug that was fixed: UTC walk-back gives wrong result)
|
||||||
|
expect(calcStreak(storedDates, '2024-01-15')).toBe(0);
|
||||||
|
|
||||||
|
await db.delete(dailyCompletions).where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user