no longer initializes embeddings model on startup

This commit is contained in:
George Powell
2026-02-28 02:48:46 -05:00
parent 1ae2b2ac6c
commit 6e74fffb65
12 changed files with 243 additions and 97 deletions

111
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. 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

View File

@@ -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=="],

View File

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

View File

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

View File

@@ -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}`;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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