mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
no longer initializes embeddings model on startup
This commit is contained in:
111
CLAUDE.md
111
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,99 @@ 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
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
## 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), Popularity (2-10, affects grading)
|
||||||
- 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 from bible-api.com if missing (random verse + 2 consecutive), 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:**
|
**Grading System:**
|
||||||
```javascript
|
```javascript
|
||||||
// Grade formula combines performance + difficulty
|
|
||||||
performanceScore = max(0, 10 - numGuesses)
|
performanceScore = max(0, 10 - numGuesses)
|
||||||
difficulty = 14 - popularity
|
difficulty = 14 - popularity
|
||||||
totalScore = performanceScore + difficulty * 0.8
|
totalScore = performanceScore + difficulty * 0.8
|
||||||
|
|
||||||
// S: 14+, A: 11+, B: 8+, C: 5+, C-: <5
|
// S: 14+, A: 11+, B: 8+, C: 5+, C-: <5
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hint System:**
|
**Hint System:**
|
||||||
- ✅ Green checkmark: Exact match
|
- ✅ Exact match | 🟩 Section match | 🟧 Testament match | ‼️ Adjacent book | 🟥 No 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` — External API integration
|
||||||
- `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` and `bibdle.socket` for systemd configuration.
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@@ -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