diff --git a/deploy-staging.sh b/deploy-staging.sh
new file mode 100755
index 0000000..5e8b76d
--- /dev/null
+++ b/deploy-staging.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+set -e
+
+cd "$(dirname "$0")"
+
+echo "Pulling latest changes..."
+git pull
+
+echo "Installing dependencies..."
+bun i
+
+echo "Pushing database changes..."
+bun run db:generate
+bun run db:migrate
+
+echo "Building..."
+bun --bun run build
+
+echo "Restarting service..."
+sudo systemctl restart bibdle-test
+
+echo "Done!"
diff --git a/scripts/dedup-completions.ts b/scripts/dedup-completions.ts
new file mode 100644
index 0000000..6ef9966
--- /dev/null
+++ b/scripts/dedup-completions.ts
@@ -0,0 +1,41 @@
+import { Database } from 'bun:sqlite';
+import path from 'path';
+
+const dbUrl = process.env.DATABASE_URL;
+if (!dbUrl) throw new Error('DATABASE_URL is not set');
+
+const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice(5) : dbUrl;
+const db = new Database(path.resolve(dbPath));
+
+const duplicates = db.query(`
+ SELECT anonymous_id, date, COUNT(*) as count
+ FROM daily_completions
+ GROUP BY anonymous_id, date
+ HAVING count > 1
+`).all() as { anonymous_id: string; date: string; count: number }[];
+
+if (duplicates.length === 0) {
+ console.log('No duplicates found.');
+ process.exit(0);
+}
+
+console.log(`Found ${duplicates.length} duplicate group(s):`);
+
+const deleteStmt = db.query(`
+ DELETE FROM daily_completions
+ WHERE anonymous_id = $anonymous_id AND date = $date
+ AND id NOT IN (
+ SELECT id FROM daily_completions
+ WHERE anonymous_id = $anonymous_id AND date = $date
+ ORDER BY completed_at ASC
+ LIMIT 1
+ )
+`);
+
+for (const { anonymous_id, date, count } of duplicates) {
+ deleteStmt.run({ $anonymous_id: anonymous_id, $date: date });
+ console.log(` ${anonymous_id} / ${date}: kept earliest, deleted ${count - 1} row(s) (had ${count})`);
+}
+
+console.log('Done.');
+db.close();
diff --git a/src/lib/components/GuessesTable.svelte b/src/lib/components/GuessesTable.svelte
index 0f2974f..a5103e1 100644
--- a/src/lib/components/GuessesTable.svelte
+++ b/src/lib/components/GuessesTable.svelte
@@ -1,20 +1,8 @@
A daily bible game{isDev ? " (dev)" : ""}
-
@@ -598,32 +322,18 @@
{statsData}
{correctBookId}
{handleShare}
- {copyToClipboard}
+ copyToClipboard={handleCopyToClipboard}
bind:copied
- {statsSubmitted}
- guessCount={guesses.length}
+ statsSubmitted={persistence.statsSubmitted}
+ guessCount={persistence.guesses.length}
reference={dailyVerse.reference}
- onChapterGuessCompleted={() => {
- chapterGuessCompleted = true;
- const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
- const saved = localStorage.getItem(key);
- if (saved) {
- const data = JSON.parse(saved);
- const match =
- dailyVerse.reference.match(/\s(\d+):/);
- const correctChapter = match
- ? parseInt(match[1], 10)
- : 1;
- chapterCorrect =
- data.selectedChapter === correctChapter;
- }
- }}
+ onChapterGuessCompleted={persistence.onChapterGuessCompleted}
/>
{/if}
- Anonymous ID: {anonymousId || "Not set"}
+
+ Anonymous ID: {persistence.anonymousId || "Not set"}
+
Client Local Time: {new Date().toLocaleString("en-US", {
timeZone:
@@ -706,4 +418,4 @@
-
+
diff --git a/src/routes/+page.ts b/src/routes/+page.ts
new file mode 100644
index 0000000..5027f6b
--- /dev/null
+++ b/src/routes/+page.ts
@@ -0,0 +1,23 @@
+import type { PageLoad } from './$types';
+
+// Disable SSR so the load function runs on the client with the correct local date
+export const ssr = false;
+
+export const load: PageLoad = async ({ fetch, data }) => {
+ const localDate = new Date().toLocaleDateString("en-CA");
+
+ const res = await fetch('/api/daily-verse', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ date: localDate }),
+ });
+
+ const result = await res.json();
+
+ return {
+ ...data,
+ dailyVerse: result.dailyVerse,
+ correctBookId: result.correctBookId,
+ correctBook: result.correctBook,
+ };
+};
diff --git a/src/routes/api/daily-verse/+server.ts b/src/routes/api/daily-verse/+server.ts
index a0b43e2..955809b 100644
--- a/src/routes/api/daily-verse/+server.ts
+++ b/src/routes/api/daily-verse/+server.ts
@@ -7,8 +7,11 @@ 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];
+ if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
+ return json({ error: 'A valid date (YYYY-MM-DD) is required' }, { status: 400 });
+ }
+
+ const dateStr = date;
const dailyVerse = await getVerseForDate(dateStr);
const correctBook = getBookById(dailyVerse.bookId) ?? null;
diff --git a/src/routes/api/stats/+server.ts b/src/routes/api/stats/+server.ts
new file mode 100644
index 0000000..6865ff1
--- /dev/null
+++ b/src/routes/api/stats/+server.ts
@@ -0,0 +1,63 @@
+import type { RequestHandler } from './$types';
+import { db } from '$lib/server/db';
+import { dailyCompletions } from '$lib/server/db/schema';
+import { and, eq, asc } from 'drizzle-orm';
+import { json } from '@sveltejs/kit';
+
+export const GET: RequestHandler = async ({ url }) => {
+ try {
+ const anonymousId = url.searchParams.get('anonymousId');
+ const date = url.searchParams.get('date');
+
+ if (!anonymousId || !date) {
+ return json({ error: 'Invalid data' }, { status: 400 });
+ }
+
+ const userCompletions = await db
+ .select()
+ .from(dailyCompletions)
+ .where(and(
+ eq(dailyCompletions.anonymousId, anonymousId),
+ eq(dailyCompletions.date, date)
+ ))
+ .limit(1);
+
+ if (userCompletions.length === 0) {
+ return json({ error: 'No completion found' }, { status: 404 });
+ }
+
+ const userCompletion = userCompletions[0];
+ const guessCount = userCompletion.guessCount;
+
+ const allCompletions = await db
+ .select()
+ .from(dailyCompletions)
+ .where(eq(dailyCompletions.date, date))
+ .orderBy(asc(dailyCompletions.completedAt));
+
+ const totalSolves = allCompletions.length;
+
+ const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
+
+ const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
+ const guessRank = betterGuesses + 1;
+
+ const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
+
+ const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
+ const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
+
+ const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
+ const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
+
+ const guesses = userCompletion.guesses ? JSON.parse(userCompletion.guesses) : undefined;
+
+ return json({
+ success: true,
+ stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile, guesses }
+ });
+ } catch (err) {
+ console.error('Error fetching stats:', err);
+ return json({ error: 'Failed to fetch stats' }, { status: 500 });
+ }
+};
diff --git a/src/routes/api/submit-completion/+server.ts b/src/routes/api/submit-completion/+server.ts
index b4e6dd7..b0f2591 100644
--- a/src/routes/api/submit-completion/+server.ts
+++ b/src/routes/api/submit-completion/+server.ts
@@ -1,13 +1,13 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyCompletions } from '$lib/server/db/schema';
-import { and, eq, asc } from 'drizzle-orm';
+import { eq, asc } from 'drizzle-orm';
import { json } from '@sveltejs/kit';
import crypto from 'node:crypto';
export const POST: RequestHandler = async ({ request }) => {
try {
- const { anonymousId, date, guessCount } = await request.json();
+ const { anonymousId, date, guessCount, guesses } = await request.json();
// Validation
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
@@ -23,6 +23,7 @@ export const POST: RequestHandler = async ({ request }) => {
anonymousId,
date,
guessCount,
+ guesses: Array.isArray(guesses) ? JSON.stringify(guesses) : null,
completedAt,
});
} catch (err: any) {
@@ -68,67 +69,3 @@ export const POST: RequestHandler = async ({ request }) => {
return json({ error: 'Failed to submit completion' }, { status: 500 });
}
};
-
-
-
-export const GET: RequestHandler = async ({ url }) => {
- try {
- const anonymousId = url.searchParams.get('anonymousId');
- const date = url.searchParams.get('date');
-
- if (!anonymousId || !date) {
- return json({ error: 'Invalid data' }, { status: 400 });
- }
-
- const userCompletions = await db
- .select()
- .from(dailyCompletions)
- .where(and(
- eq(dailyCompletions.anonymousId, anonymousId),
- eq(dailyCompletions.date, date)
- ))
- .limit(1);
-
- if (userCompletions.length === 0) {
- return json({ error: 'No completion found' }, { status: 404 });
- }
-
- const userCompletion = userCompletions[0];
- const guessCount = userCompletion.guessCount;
-
- // Calculate statistics
- const allCompletions = await db
- .select()
- .from(dailyCompletions)
- .where(eq(dailyCompletions.date, date))
- .orderBy(asc(dailyCompletions.completedAt));
-
- const totalSolves = allCompletions.length;
-
- // Solve rank: position in time-ordered list
- const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
-
- // Guess rank: count how many had FEWER guesses (ties get same rank)
- const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
- const guessRank = betterGuesses + 1;
-
- // Count ties: how many have the SAME guessCount (excluding self)
- const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
-
- // Average guesses
- const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
- const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
-
- // Percentile: what percentage of people you beat (100 - your rank percentage)
- const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
- const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
-
- return json({
- success: true,
- stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
- });
- } catch (err) {
- console.error('Error fetching stats:', err);
- return json({ error: 'Failed to fetch stats' }, { status: 500 });
- }
-};
diff --git a/svelte.config.js b/svelte.config.js
index 2b6f324..afcfb42 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -10,9 +10,8 @@ const config = {
kit: {
adapter: adapter(),
csrf: {
- // Disabled because Apple Sign In uses cross-origin form_post.
- // The Apple callback route has its own CSRF protection via state + cookie.
- checkOrigin: false
+ // Allow Apple Sign In cross-origin form_post callback
+ trustedOrigins: ['https://appleid.apple.com']
}
}
};