This commit is contained in:
George Powell
2025-12-16 20:44:52 -05:00
parent 427d1dc918
commit 32a078dd98
10 changed files with 634 additions and 277 deletions

View File

@@ -1,4 +1,4 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
@@ -24,3 +24,17 @@ export const dailyVerses = sqliteTable('daily_verses', {
});
export type DailyVerse = typeof dailyVerses.$inferSelect;
export const dailyCompletions = sqliteTable('daily_completions', {
id: text('id').primaryKey(),
anonymousId: text('anonymous_id').notNull(),
date: text('date').notNull(),
guessCount: integer('guess_count').notNull(),
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
uniqueCompletion: unique().on(table.anonymousId, table.date),
dateIndex: index('date_idx').on(table.date),
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
}));
export type DailyCompletion = typeof dailyCompletions.$inferSelect;

View File

@@ -1,7 +1,8 @@
import type { PageServerLoad } from './$types';
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { dailyVerses } from '$lib/server/db/schema';
import { eq, sql } from 'drizzle-orm';
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
import { eq, sql, 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';
@@ -43,3 +44,60 @@ export const load: PageServerLoad = async () => {
correctBook
};
};
export const actions: Actions = {
submitCompletion: async ({ request }) => {
const formData = await request.formData();
const anonymousId = formData.get('anonymousId') as string;
const date = formData.get('date') as string;
const guessCount = parseInt(formData.get('guessCount') as string, 10);
// Validation
if (!anonymousId || !date || isNaN(guessCount) || guessCount < 1) {
return fail(400, { error: 'Invalid data' });
}
const completedAt = new Date();
try {
// Insert with duplicate prevention
await db.insert(dailyCompletions).values({
id: crypto.randomUUID(),
anonymousId,
date,
guessCount,
completedAt,
});
} catch (err: any) {
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
return fail(409, { error: 'Already submitted' });
}
throw err;
}
// 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;
// Average guesses
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
return {
success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses }
};
}
};

View File

@@ -23,6 +23,15 @@
let copied = $state(false);
let anonymousId = $state("");
let statsSubmitted = $state(false);
let statsData = $state<{
solveRank: number;
guessRank: number;
totalSolves: number;
averageGuesses: number;
} | null>(null);
let filteredBooks = $derived(
bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
@@ -90,6 +99,41 @@
return "🔴 C-";
}
function generateUUID(): string {
// Try native randomUUID if available
if (typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
// Fallback UUID v4 generator for older browsers
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r =
window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = generateUUID();
localStorage.setItem(key, id);
}
return id;
}
// Initialize anonymous ID
$effect(() => {
if (!browser) return;
anonymousId = getOrCreateAnonymousId();
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
statsSubmitted = localStorage.getItem(statsKey) === "true";
});
// Load saved guesses
$effect(() => {
if (!browser) return;
@@ -122,6 +166,96 @@
);
});
// Auto-submit stats when user wins
$effect(() => {
console.log("Stats effect triggered:", {
browser,
isWon,
anonymousId,
statsSubmitted,
statsData,
});
if (!browser || !isWon || !anonymousId) {
console.log("Basic conditions not met");
return;
}
if (statsSubmitted && !statsData) {
console.log("Fetching existing stats...");
(async () => {
try {
const response = await fetch(
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`,
);
const result = await response.json();
console.log("Stats response:", result);
if (result.success && result.stats) {
console.log("Setting stats data:", result.stats);
statsData = result.stats;
localStorage.setItem(
`bibdle-stats-submitted-${dailyVerse.date}`,
"true",
);
} else if (result.error) {
console.error("Server error:", result.error);
} else {
console.error("Unexpected response format:", result);
}
} catch (err) {
console.error("Stats fetch failed:", err);
}
})();
return;
}
console.log("Submitting stats...");
async function submitStats() {
try {
const payload = {
anonymousId,
date: dailyVerse.date,
guessCount: guesses.length,
};
console.log("Sending POST request with:", payload);
const response = await fetch("/api/submit-completion", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const result = await response.json();
console.log("Stats response:", result);
if (result.success && result.stats) {
console.log("Setting stats data:", result.stats);
statsData = result.stats;
statsSubmitted = true;
localStorage.setItem(
`bibdle-stats-submitted-${dailyVerse.date}`,
"true",
);
} else if (result.error) {
console.error("Server error:", result.error);
} else {
console.error("Unexpected response format:", result);
}
} catch (err) {
console.error("Stats submission failed:", err);
}
}
submitStats();
});
async function share() {
if (!browser) return;
@@ -269,6 +403,7 @@
>
Your grade: {grade}
</p>
<button
onclick={handleShare}
data-umami-event="Share"
@@ -278,8 +413,40 @@
: "bg-white/20 hover:bg-white/30"
} rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
>
{copied ? "shared!" : "📤 Share"}
{copied ? "Copied to clipboard!" : "📤 Share"}
</button>
<!-- Statistics Display -->
{#if statsData}
<div
class="mt-6 space-y-2 text-lg"
in:fade={{ delay: 800 }}
>
<p class="font-semibold">
You were the <span class="text-2xl font-black"
>#{statsData.solveRank}</span
> person to solve today!
</p>
<p class="font-semibold">
You ranked <span class="text-2xl font-black"
>#{statsData.guessRank}</span
> by number of guesses
</p>
<p class="opacity-90">
{statsData.totalSolves}
{statsData.totalSolves === 1
? "person has"
: "people have"} solved today
</p>
<p class="opacity-90">
Average guesses: {statsData.averageGuesses}
</p>
</div>
{:else if !statsSubmitted}
<div class="mt-6 text-sm opacity-80">
Submitting stats...
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,120 @@
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';
import crypto from 'node:crypto';
export const POST: RequestHandler = async ({ request }) => {
try {
const { anonymousId, date, guessCount } = await request.json();
// Validation
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
return json({ error: 'Invalid data' }, { status: 400 });
}
const completedAt = new Date();
try {
// Insert with duplicate prevention
await db.insert(dailyCompletions).values({
id: crypto.randomUUID(),
anonymousId,
date,
guessCount,
completedAt,
});
} catch (err: any) {
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
return json({ error: 'Already submitted' }, { status: 409 });
}
throw err;
}
// 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;
// Average guesses
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
return json({
success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses }
});
} catch (err) {
console.error('Error submitting completion:', err);
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;
// Average guesses
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
return json({
success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses }
});
} catch (err) {
console.error('Error fetching stats:', err);
return json({ error: 'Failed to fetch stats' }, { status: 500 });
}
};