diff --git a/src/lib/server/daily-verse.ts b/src/lib/server/daily-verse.ts new file mode 100644 index 0000000..5ea7ce4 --- /dev/null +++ b/src/lib/server/daily-verse.ts @@ -0,0 +1,33 @@ +import { db } from '$lib/server/db'; +import { dailyVerses } from '$lib/server/db/schema'; +import { eq, sql } from 'drizzle-orm'; +import { fetchRandomVerse } from '$lib/server/bible-api'; +import type { DailyVerse } from '$lib/server/db/schema'; + +export async function getVerseForDate(dateStr: string): Promise { + // Validate date format (YYYY-MM-DD) + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + throw new Error('Invalid date format'); + } + + // If there's an existing verse for this date, return it + const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1); + if (existing.length > 0) { + return existing[0]; + } + + // Otherwise get a new random verse for this date + const apiVerse = await fetchRandomVerse(); + const createdAt = sql`${Math.floor(Date.now() / 1000)}`; + + const newVerse: Omit = { + id: Bun.randomUUIDv7(), + date: dateStr, + bookId: apiVerse.bookId, + verseText: apiVerse.verseText, + reference: apiVerse.reference, + }; + + const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning(); + return inserted; +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 238b383..90c42bc 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,41 +1,17 @@ import type { PageServerLoad, Actions } from './$types'; import { db } from '$lib/server/db'; -import { dailyVerses, dailyCompletions } from '$lib/server/db/schema'; -import { eq, sql, asc } from 'drizzle-orm'; +import { dailyCompletions } from '$lib/server/db/schema'; +import { eq, asc } from 'drizzle-orm'; import { fail } from '@sveltejs/kit'; -import { fetchRandomVerse } from '$lib/server/bible-api'; import { getBookById } from '$lib/server/bible'; -import type { DailyVerse } from '$lib/server/db/schema'; +import { getVerseForDate } from '$lib/server/daily-verse'; import crypto from 'node:crypto'; -async function getTodayVerse(): Promise { - // Get the current date (server-side) - const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' }); - - // If there's an existing verse for the current date, return it - const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1); - if (existing.length > 0) { - return existing[0]; - } - - // Otherwise get a new random verse - const apiVerse = await fetchRandomVerse(); - const createdAt = sql`${Math.floor(Date.now() / 1000)}`; - - const newVerse: Omit = { - id: crypto.randomUUID(), - date: dateStr, - bookId: apiVerse.bookId, - verseText: apiVerse.verseText, - reference: apiVerse.reference, - }; - - const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning(); - return inserted; -} - export const load: PageServerLoad = async ({ locals }) => { - const dailyVerse = await getTodayVerse(); + // Use UTC date for initial SSR; client will fetch timezone-correct verse if needed + const dateStr = new Date().toISOString().split('T')[0]; + + const dailyVerse = await getVerseForDate(dateStr); const correctBook = getBookById(dailyVerse.bookId) ?? null; return { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 533c888..038d438 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -25,8 +25,9 @@ let { data }: PageProps = $props(); - let dailyVerse = $derived(data.dailyVerse); - let correctBookId = $derived(data.correctBookId); + let dailyVerse = $state(data.dailyVerse); + let correctBookId = $state(data.correctBookId); + let correctBook = $state(data.correctBook); let user = $derived(data.user); let session = $derived(data.session); @@ -178,6 +179,56 @@ return id; } + // If server date doesn't match client's local date, fetch timezone-correct verse + $effect(() => { + if (!browser) return; + + const localDate = new Date().toLocaleDateString('en-CA'); + console.log('Date check:', { localDate, verseDate: dailyVerse.date, match: dailyVerse.date === localDate }); + + if (dailyVerse.date === localDate) return; + + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + console.log('Fetching timezone-correct verse:', { localDate, timezone }); + + fetch('/api/daily-verse', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + date: localDate, + timezone, + }), + }) + .then((res) => res.json()) + .then((result) => { + console.log('Received verse data:', result); + dailyVerse = result.dailyVerse; + correctBookId = result.correctBookId; + correctBook = result.correctBook; + }) + .catch((err) => console.error('Failed to fetch timezone-correct verse:', err)); + }); + + // Reload when the user returns to a stale tab on a new calendar day + $effect(() => { + if (!browser) return; + + const loadedDate = new Date().toLocaleDateString('en-CA'); + + function onVisibilityChange() { + if (document.hidden) return; + const now = new Date().toLocaleDateString('en-CA'); + if (now !== loadedDate) { + window.location.reload(); + } + } + + document.addEventListener('visibilitychange', onVisibilityChange); + return () => document.removeEventListener('visibilitychange', onVisibilityChange); + }); + // Initialize anonymous ID $effect(() => { if (!browser) return; @@ -388,12 +439,26 @@ new Date(`${dailyVerse.date}T00:00:00`), ); const siteUrl = window.location.origin; - return [ - `📖 Bibdle | ${formattedDate} 📖`, + + // Use scroll emoji for logged-in users, book emoji for anonymous + const bookEmoji = user ? "📜" : "📖"; + + const lines = [ + `${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`, `${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`, + ]; + + // Add streak for logged-in users (requires streak field in user data) + if (user && (user as any).streak !== undefined) { + lines.push(`🔥 ${(user as any).streak} day streak`); + } + + lines.push( `${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`, - siteUrl, - ].join("\n"); + siteUrl + ); + + return lines.join("\n"); } async function share() { @@ -527,7 +592,7 @@
{/if} diff --git a/src/routes/api/daily-verse/+server.ts b/src/routes/api/daily-verse/+server.ts new file mode 100644 index 0000000..a0b43e2 --- /dev/null +++ b/src/routes/api/daily-verse/+server.ts @@ -0,0 +1,21 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getVerseForDate } from '$lib/server/daily-verse'; +import { getBookById } from '$lib/server/bible'; + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json(); + const { date } = body; + + // Use the date provided by the client (already calculated in their timezone) + const dateStr = date || new Date().toISOString().split('T')[0]; + + const dailyVerse = await getVerseForDate(dateStr); + const correctBook = getBookById(dailyVerse.bookId) ?? null; + + return json({ + dailyVerse, + correctBookId: dailyVerse.bookId, + correctBook, + }); +}; diff --git a/src/routes/stats/+page.server.ts b/src/routes/stats/+page.server.ts index 20c3004..ee1ef86 100644 --- a/src/routes/stats/+page.server.ts +++ b/src/routes/stats/+page.server.ts @@ -15,9 +15,9 @@ export const load: PageServerLoad = async ({ url, locals }) => { requiresAuth: true }; } - + const userId = locals.user.id; - + if (!userId) { return { stats: null, @@ -27,6 +27,10 @@ export const load: PageServerLoad = async ({ url, locals }) => { }; } + // Get user's current date from timezone query param + const timezone = url.searchParams.get('tz') || 'UTC'; + const userToday = new Date().toLocaleDateString('en-CA', { timeZone: timezone }); + try { // Get all completions for this user const completions = await db @@ -85,26 +89,29 @@ export const load: PageServerLoad = async ({ url, locals }) => { const sortedDates = completions .map((c: DailyCompletion) => c.date) .sort(); - + let currentStreak = 0; let bestStreak = 0; let tempStreak = 1; - + if (sortedDates.length > 0) { // Check if current streak is active (includes today or yesterday) - const today = new Date().toISOString().split('T')[0]; - const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + // Use the user's local date passed from the client + const today = userToday; + const yesterdayDate = new Date(userToday); + yesterdayDate.setDate(yesterdayDate.getDate() - 1); + const yesterday = yesterdayDate.toISOString().split('T')[0]; const lastPlayedDate = sortedDates[sortedDates.length - 1]; - + if (lastPlayedDate === today || lastPlayedDate === yesterday) { currentStreak = 1; - + // Count backwards from the most recent date for (let i = sortedDates.length - 2; i >= 0; i--) { const currentDate = new Date(sortedDates[i + 1]); const prevDate = new Date(sortedDates[i]); const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)); - + if (daysDiff === 1) { currentStreak++; } else { @@ -112,14 +119,14 @@ export const load: PageServerLoad = async ({ url, locals }) => { } } } - + // Calculate best streak bestStreak = 1; for (let i = 1; i < sortedDates.length; i++) { const currentDate = new Date(sortedDates[i]); const prevDate = new Date(sortedDates[i - 1]); const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)); - + if (daysDiff === 1) { tempStreak++; } else { @@ -246,4 +253,4 @@ function getGradeFromGuesses(guessCount: number): string { if (guessCount >= 7 && guessCount <= 10) return "B"; if (guessCount >= 11 && guessCount <= 15) return "C+"; return "C"; -} \ No newline at end of file +} diff --git a/tests/timezone-handling.test.ts b/tests/timezone-handling.test.ts new file mode 100644 index 0000000..b9367cd --- /dev/null +++ b/tests/timezone-handling.test.ts @@ -0,0 +1,498 @@ +import { describe, test, expect, beforeEach, mock } from 'bun:test'; +import { testDb as db } from '$lib/server/db/test'; +import { dailyVerses, dailyCompletions } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import crypto from 'node:crypto'; + +describe('Timezone-aware daily verse system', () => { + beforeEach(async () => { + // Clean up test data before each test + await db.delete(dailyVerses); + await db.delete(dailyCompletions); + }); + + describe('Daily verse retrieval', () => { + test('users in different timezones can see different verses at the same UTC moment', async () => { + // Simulate: It's 2024-01-15 23:00 UTC + // - Tokyo (UTC+9): 2024-01-16 08:00 + // - New York (UTC-5): 2024-01-15 18:00 + + const tokyoDate = '2024-01-16'; + const newYorkDate = '2024-01-15'; + + // Create verses for both dates + const tokyoVerse = { + id: crypto.randomUUID(), + date: tokyoDate, + bookId: 'GEN', + verseText: 'Tokyo verse', + reference: 'Genesis 1:1', + }; + + const newYorkVerse = { + id: crypto.randomUUID(), + date: newYorkDate, + bookId: 'EXO', + verseText: 'New York verse', + reference: 'Exodus 1:1', + }; + + await db.insert(dailyVerses).values([tokyoVerse, newYorkVerse]); + + // Verify Tokyo user gets Jan 16 verse + const tokyoResult = await db + .select() + .from(dailyVerses) + .where(eq(dailyVerses.date, tokyoDate)) + .limit(1); + + expect(tokyoResult).toHaveLength(1); + expect(tokyoResult[0].bookId).toBe('GEN'); + expect(tokyoResult[0].verseText).toBe('Tokyo verse'); + + // Verify New York user gets Jan 15 verse + const newYorkResult = await db + .select() + .from(dailyVerses) + .where(eq(dailyVerses.date, newYorkDate)) + .limit(1); + + expect(newYorkResult).toHaveLength(1); + expect(newYorkResult[0].bookId).toBe('EXO'); + expect(newYorkResult[0].verseText).toBe('New York verse'); + }); + + test('verse dates are stored in YYYY-MM-DD format', async () => { + const verse = { + id: crypto.randomUUID(), + date: '2024-01-15', + bookId: 'GEN', + verseText: 'Test verse', + reference: 'Genesis 1:1', + }; + + await db.insert(dailyVerses).values(verse); + + const result = await db + .select() + .from(dailyVerses) + .where(eq(dailyVerses.id, verse.id)) + .limit(1); + + expect(result[0].date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + }); + + describe('Completion tracking', () => { + test('completions are stored with user local date', async () => { + const userId = 'test-user-1'; + const localDate = '2024-01-16'; // User's local date + + const completion = { + id: crypto.randomUUID(), + anonymousId: userId, + date: localDate, + guessCount: 3, + completedAt: new Date(), + }; + + await db.insert(dailyCompletions).values(completion); + + const result = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, userId)) + .limit(1); + + expect(result).toHaveLength(1); + expect(result[0].date).toBe(localDate); + }); + + test('users in different timezones can complete different date verses simultaneously', async () => { + const tokyoUser = 'tokyo-user'; + const newYorkUser = 'newyork-user'; + + const completions = [ + { + id: crypto.randomUUID(), + anonymousId: tokyoUser, + date: '2024-01-16', // Tokyo: Jan 16 + guessCount: 2, + completedAt: new Date('2024-01-15T23:00:00Z'), // 23:00 UTC + }, + { + id: crypto.randomUUID(), + anonymousId: newYorkUser, + date: '2024-01-15', // New York: Jan 15 + guessCount: 4, + completedAt: new Date('2024-01-15T23:00:00Z'), // 23:00 UTC + }, + ]; + + await db.insert(dailyCompletions).values(completions); + + const tokyoResult = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, tokyoUser)) + .limit(1); + + const newYorkResult = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, newYorkUser)) + .limit(1); + + expect(tokyoResult[0].date).toBe('2024-01-16'); + expect(newYorkResult[0].date).toBe('2024-01-15'); + }); + }); + + describe('Streak calculation', () => { + test('consecutive days count as a streak', async () => { + const userId = 'streak-user'; + + const completions = [ + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-13', + guessCount: 2, + completedAt: new Date('2024-01-13T12:00:00Z'), + }, + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-14', + guessCount: 3, + completedAt: new Date('2024-01-14T12:00:00Z'), + }, + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-15', + guessCount: 1, + completedAt: new Date('2024-01-15T12:00:00Z'), + }, + ]; + + await db.insert(dailyCompletions).values(completions); + + const allCompletions = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, userId)); + + const sortedDates = allCompletions.map((c) => c.date).sort(); + + // Verify consecutive dates + expect(sortedDates).toEqual(['2024-01-13', '2024-01-14', '2024-01-15']); + + // Calculate streak + let streak = 1; + for (let i = 1; i < sortedDates.length; i++) { + const currentDate = new Date(sortedDates[i]); + const prevDate = new Date(sortedDates[i - 1]); + const daysDiff = Math.floor( + (currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (daysDiff === 1) { + streak++; + } else { + break; + } + } + + expect(streak).toBe(3); + }); + + test('current streak is active if last completion was today', async () => { + const userId = 'current-streak-user'; + const userToday = '2024-01-16'; + + const completions = [ + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-14', + guessCount: 2, + completedAt: new Date('2024-01-14T12:00:00Z'), + }, + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-15', + guessCount: 3, + completedAt: new Date('2024-01-15T12:00:00Z'), + }, + { + id: crypto.randomUUID(), + anonymousId: userId, + date: userToday, + guessCount: 1, + completedAt: new Date('2024-01-16T12:00:00Z'), + }, + ]; + + await db.insert(dailyCompletions).values(completions); + + const allCompletions = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, userId)); + + const sortedDates = allCompletions.map((c) => c.date).sort(); + const lastPlayedDate = sortedDates[sortedDates.length - 1]; + + const yesterdayDate = new Date(userToday); + yesterdayDate.setDate(yesterdayDate.getDate() - 1); + const yesterday = yesterdayDate.toISOString().split('T')[0]; + + const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday; + + expect(isStreakActive).toBe(true); + expect(lastPlayedDate).toBe(userToday); + }); + + test('current streak is active if last completion was yesterday', async () => { + const userId = 'yesterday-streak-user'; + const userToday = '2024-01-16'; + + const yesterdayDate = new Date(userToday); + yesterdayDate.setDate(yesterdayDate.getDate() - 1); + const yesterday = yesterdayDate.toISOString().split('T')[0]; + + const completions = [ + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-13', + guessCount: 2, + completedAt: new Date('2024-01-13T12:00:00Z'), + }, + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-14', + guessCount: 3, + completedAt: new Date('2024-01-14T12:00:00Z'), + }, + { + id: crypto.randomUUID(), + anonymousId: userId, + date: yesterday, + guessCount: 1, + completedAt: new Date(yesterday + 'T12:00:00Z'), + }, + ]; + + await db.insert(dailyCompletions).values(completions); + + const allCompletions = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, userId)); + + const sortedDates = allCompletions.map((c) => c.date).sort(); + const lastPlayedDate = sortedDates[sortedDates.length - 1]; + + const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday; + + expect(isStreakActive).toBe(true); + expect(lastPlayedDate).toBe(yesterday); + }); + + test('current streak is not active if last completion was 2+ days ago', async () => { + const userId = 'broken-streak-user'; + const userToday = '2024-01-16'; + + const completions = [ + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-13', + guessCount: 2, + completedAt: new Date('2024-01-13T12:00:00Z'), + }, + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-14', + guessCount: 3, + completedAt: new Date('2024-01-14T12:00:00Z'), + }, + ]; + + await db.insert(dailyCompletions).values(completions); + + const allCompletions = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, userId)); + + const sortedDates = allCompletions.map((c) => c.date).sort(); + const lastPlayedDate = sortedDates[sortedDates.length - 1]; + + const yesterdayDate = new Date(userToday); + yesterdayDate.setDate(yesterdayDate.getDate() - 1); + const yesterday = yesterdayDate.toISOString().split('T')[0]; + + const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday; + + expect(isStreakActive).toBe(false); + expect(lastPlayedDate).toBe('2024-01-14'); // 2 days ago + }); + + test('gap in dates breaks the streak', async () => { + const userId = 'gap-user'; + + const completions = [ + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-10', + guessCount: 2, + completedAt: new Date('2024-01-10T12:00:00Z'), + }, + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-11', + guessCount: 3, + completedAt: new Date('2024-01-11T12:00:00Z'), + }, + // Gap here (no 01-12) + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-13', + guessCount: 1, + completedAt: new Date('2024-01-13T12:00:00Z'), + }, + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-14', + guessCount: 2, + completedAt: new Date('2024-01-14T12:00:00Z'), + }, + ]; + + await db.insert(dailyCompletions).values(completions); + + const allCompletions = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, userId)); + + const sortedDates = allCompletions.map((c) => c.date).sort(); + + // Calculate best streak (should be 2, not 4) + let bestStreak = 1; + let tempStreak = 1; + + for (let i = 1; i < sortedDates.length; i++) { + const currentDate = new Date(sortedDates[i]); + const prevDate = new Date(sortedDates[i - 1]); + const daysDiff = Math.floor( + (currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (daysDiff === 1) { + tempStreak++; + } else { + bestStreak = Math.max(bestStreak, tempStreak); + tempStreak = 1; + } + } + bestStreak = Math.max(bestStreak, tempStreak); + + expect(bestStreak).toBe(2); // Longest streak is Jan 13-14 or Jan 10-11 + }); + }); + + describe('Date validation', () => { + test('date must be in YYYY-MM-DD format', () => { + const validDates = ['2024-01-15', '2023-12-31', '2024-02-29']; + + validDates.forEach((date) => { + expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + }); + + test('invalid date formats are rejected', () => { + const invalidDates = [ + '2024/01/15', // Wrong separator + '01-15-2024', // Wrong order + '2024-1-15', // Missing leading zero + '2024-01-15T12:00:00Z', // Includes time + ]; + + invalidDates.forEach((date) => { + if (date.includes('T')) { + expect(date).not.toMatch(/^\d{4}-\d{2}-\d{2}$/); + } else { + expect(date).not.toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + }); + }); + }); + + describe('Edge cases', () => { + test('crossing year boundary maintains streak', async () => { + const userId = 'year-boundary-user'; + + const completions = [ + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2023-12-30', + guessCount: 2, + completedAt: new Date('2023-12-30T12:00:00Z'), + }, + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2023-12-31', + guessCount: 3, + completedAt: new Date('2023-12-31T12:00:00Z'), + }, + { + id: crypto.randomUUID(), + anonymousId: userId, + date: '2024-01-01', + guessCount: 1, + completedAt: new Date('2024-01-01T12:00:00Z'), + }, + ]; + + await db.insert(dailyCompletions).values(completions); + + const allCompletions = await db + .select() + .from(dailyCompletions) + .where(eq(dailyCompletions.anonymousId, userId)); + + const sortedDates = allCompletions.map((c) => c.date).sort(); + + let streak = 1; + for (let i = 1; i < sortedDates.length; i++) { + const currentDate = new Date(sortedDates[i]); + const prevDate = new Date(sortedDates[i - 1]); + const daysDiff = Math.floor( + (currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (daysDiff === 1) { + streak++; + } + } + + expect(streak).toBe(3); + }); + + // Note: Duplicate prevention is handled by the API endpoint, not at the DB level in these tests + // See /api/submit-completion for the unique constraint enforcement + }); +});