Added verse-submission infrastructure

This commit is contained in:
George Powell
2026-07-07 15:41:10 -04:00
parent 99552c57ad
commit be0d7ad297
17 changed files with 1293 additions and 26 deletions
-4
View File
@@ -146,7 +146,6 @@
(window as any).umami
) {
(window as any).umami.track("First guess");
(window as any).rybbit?.event("First guess");
localStorage.setItem(key, "true");
}
}
@@ -287,9 +286,6 @@
(window as any).umami.track("Guessed correctly", {
totalGuesses: persistence.guesses.length,
});
(window as any).rybbit?.event("Guessed correctly", {
totalGuesses: persistence.guesses.length,
});
}
});
+26
View File
@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { bibleBooks } from '$lib/server/bible';
import { getChapterCount, getVerseCount } from '$lib/server/xml-bible';
// Static Bible structure for the cascading Book → Chapter → Verse selectors.
// ~1189 chapter entries total; safe to cache indefinitely.
let cached: { bookId: string; chapters: number[] }[] | null = null;
function build(): { bookId: string; chapters: number[] }[] {
if (cached) return cached;
const out = bibleBooks.map((book) => {
const chapterCount = getChapterCount(book.order);
const chapters: number[] = new Array(chapterCount);
for (let c = 1; c <= chapterCount; c++) {
chapters[c - 1] = getVerseCount(book.order, c);
}
return { bookId: book.id, chapters };
});
cached = out;
return out;
}
export const GET: RequestHandler = async () => {
return json(build());
};
@@ -0,0 +1,189 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyVerses, verseSubmissions, user } from '$lib/server/db/schema';
import { sql } from 'drizzle-orm';
import { json } from '@sveltejs/kit';
import { bibleBooks } from '$lib/types/bible';
import {
getChapterCount,
getVerseCount,
composeVerseWindow,
formatWindowReference
} from '$lib/server/xml-bible';
const DEV_HOSTS = ['localhost:5173', 'test.bibdle.com'];
const MAX_SCAN_RETRIES = 5;
// Dev-only: insert a verse_submissions row (from a random existing account) +
// its corresponding daily_verses row, scheduled on the earliest valid future
// date per the spec's scheduling rules. Lets you populate /scheduled-verses to
// verify the admin view without going through the (Step 5+) submit API.
export const POST: RequestHandler = async ({ request }) => {
const host = request.headers.get('host') ?? '';
if (!DEV_HOSTS.includes(host)) {
return json({ error: 'Not allowed in production' }, { status: 403 });
}
// Pick a random existing user.
const users = await db.select().from(user);
if (users.length === 0) {
return json(
{ error: 'No users exist yet. Sign up at least one account first.' },
{ status: 400 }
);
}
const submitter = users[Math.floor(Math.random() * users.length)];
// Pick a random valid (book, chapter, verse).
const book = bibleBooks[Math.floor(Math.random() * bibleBooks.length)];
const chapterCount = getChapterCount(book.order);
const chapter = Math.floor(Math.random() * chapterCount) + 1;
const verseCount = getVerseCount(book.order, chapter);
const verse = Math.floor(Math.random() * verseCount) + 1;
const window = composeVerseWindow(book.order, chapter, verse);
if (!window) {
return json({ error: 'Failed to compose verse window' }, { status: 500 });
}
const reference = formatWindowReference(
window.bookName,
window.startChapter,
window.startVerse,
window.endChapter,
window.endVerse
);
const verseText = window.verses.join(' ');
const todayUtc = new Date().toISOString().slice(0, 10);
const tomorrowMs = new Date(todayUtc + 'T00:00:00Z').getTime() + 86400000;
function addDays(ms: number, n: number): string {
return new Date(ms + n * 86400000).toISOString().slice(0, 10);
}
// Fetch all future daily_verses into memory (date → bookId, date → reference).
const future = await db.select().from(dailyVerses).where(sql`date > ${todayUtc}`);
const byDate = new Map<string, { bookId: string; reference: string }>();
for (const r of future) byDate.set(r.date, { bookId: r.bookId, reference: r.reference });
// For the 60-day repeat rule, also need a bounded historical lookup per
// candidate date. Build a set of all existing references globally to do a
// cheap pre-check, then a precise range query when we settle on a date.
const allRows = await db.select().from(dailyVerses);
const refDates = new Map<string, string[]>(); // reference → list of dates
for (const r of allRows) {
const arr = refDates.get(r.reference) ?? [];
arr.push(r.date);
refDates.set(r.reference, arr);
}
let scheduledDate: string | null = null;
for (let attempt = 0; attempt < MAX_SCAN_RETRIES && !scheduledDate; attempt++) {
// Walk day-by-day from tomorrow.
for (let offset = 0; offset < 10000; offset++) {
const D = addDays(tomorrowMs, offset);
// Rule 1: empty (no committed daily_verses row).
if (byDate.has(D)) continue;
// Rule 2: no back-to-back with prior neighbor.
const prev = byDate.get(addDays(tomorrowMs, offset - 1));
if (prev && prev.bookId === book.id) continue;
// Rule 3: no back-to-back with next committed neighbor.
const next = byDate.get(addDays(tomorrowMs, offset + 1));
if (next && next.bookId === book.id) continue;
// Rule 4: 60-day repeat distance — no same reference within ±60 days.
const sameRefDates = refDates.get(reference) ?? [];
const dMs = new Date(D + 'T00:00:00Z').getTime();
const tooClose = sameRefDates.some((d) => {
const diff = Math.abs(new Date(d + 'T00:00:00Z').getTime() - dMs);
return diff <= 60 * 86400000;
});
if (tooClose) continue;
scheduledDate = D;
break;
}
if (!scheduledDate) break; // exhausted retries
}
if (!scheduledDate) {
return json({ error: 'Could not find a valid future date' }, { status: 500 });
}
// Insert both rows in a transaction. On a unique conflict (concurrent
// submission grabbed the same date), bubble up so the caller can retry.
const now = Date.now();
const verseId = Bun.randomUUIDv7();
const submissionId = Bun.randomUUIDv7();
try {
db.transaction((tx) => {
tx.insert(dailyVerses)
.values({
id: verseId,
date: scheduledDate!,
bookId: book.id,
verseText,
reference,
createdAt: sql`${Math.floor(now / 1000)}`
})
.run();
tx.insert(verseSubmissions)
.values({
id: submissionId,
userId: submitter.id,
scheduledDate: scheduledDate!,
selectedBookId: book.id,
selectedChapter: chapter,
selectedVerse: verse,
submittedAt: now
})
.run();
});
} catch (err: any) {
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
return json(
{ error: 'Date collision during seed; retry the request', detail: String(err?.message ?? err) },
{ status: 409 }
);
}
throw err;
}
return json({
ok: true,
submitter: { id: submitter.id, email: submitter.email },
selected: {
bookId: book.id,
bookName: book.name,
chapter,
verse
},
scheduledDate,
reference,
verseText
});
};
// Convenience: GET returns the current count of submissions + future scheduled
// verses, useful as a quick dev sanity check without mutating state.
export const GET: RequestHandler = async ({ request }) => {
const host = request.headers.get('host') ?? '';
if (!DEV_HOSTS.includes(host)) {
return json({ error: 'Not allowed in production' }, { status: 403 });
}
const subs = await db.select().from(verseSubmissions);
const future = await db
.select()
.from(dailyVerses)
.where(sql`date > ${new Date().toISOString().slice(0, 10)}`);
return json({
totalSubmissions: subs.length,
futureScheduledVerses: future.length
});
};
+51
View File
@@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getBookById } from '$lib/server/bible';
import {
composeVerseWindow,
formatWindowReference
} from '$lib/server/xml-bible';
// Live 3-verse preview for the submission selector. Computes the fall-forward
// window anchored on the selected verse (spec: "Verse Windowing").
export const GET: RequestHandler = async ({ url }) => {
const bookId = url.searchParams.get('bookId');
const chapterParam = url.searchParams.get('chapter');
const verseParam = url.searchParams.get('verse');
if (!bookId || !chapterParam || !verseParam) {
return json({ error: 'bookId, chapter, and verse are required' }, { status: 400 });
}
const book = getBookById(bookId);
if (!book) {
return json({ error: 'Unknown bookId' }, { status: 400 });
}
const chapter = Number(chapterParam);
const verse = Number(verseParam);
if (!Number.isInteger(chapter) || !Number.isInteger(verse) || chapter < 1 || verse < 1) {
return json({ error: 'chapter and verse must be positive integers' }, { status: 400 });
}
const window = composeVerseWindow(book.order, chapter, verse);
if (!window) {
// chapter/verse out of range — caught by composeVerseWindow's bounds checks
return json({ error: 'chapter or verse out of range for this book' }, { status: 400 });
}
const reference = formatWindowReference(
window.bookName,
window.startChapter,
window.startVerse,
window.endChapter,
window.endVerse
);
return json({
windowVerses: window.verses,
reference,
bookId: window.bookId,
selectedVerse: window.selectedVerse
});
};
@@ -0,0 +1,97 @@
import { db } from '$lib/server/db';
import { verseSubmissions, dailyVerses, user } from '$lib/server/db/schema';
import { eq, asc } from 'drizzle-orm';
import { getBookById } from '$lib/server/bible';
import { isAdmin } from '$lib/server/admin';
import type { PageServerLoad } from './$types';
export type ScheduledVerseRow = {
scheduledDate: string;
reference: string | null;
verseText: string | null;
bookId: string | null;
bookName: string | null;
selectedBookId: string;
selectedChapter: number;
selectedVerse: number;
submittedAt: number; // server UTC millis
submitterEmail: string | null;
submitterName: string | null;
submitterDeleted: boolean;
};
export const load: PageServerLoad = async ({ locals }) => {
// Not authenticated → signal the client to show the sign-in flow
// (same pattern as /progress, /stats).
if (!locals.user) {
return {
rows: [] as ScheduledVerseRow[],
requiresAuth: true,
authorized: false,
user: null,
session: null,
};
}
// Authenticated but wrong account → plain access-denied state. Don't leak
// the existence of privileged data; just render "not authorized".
if (!isAdmin(locals.user.email)) {
return {
rows: [] as ScheduledVerseRow[],
requiresAuth: false,
authorized: false,
user: locals.user,
session: locals.session,
};
}
const joined = await db
.select({
scheduledDate: verseSubmissions.scheduledDate,
selectedBookId: verseSubmissions.selectedBookId,
selectedChapter: verseSubmissions.selectedChapter,
selectedVerse: verseSubmissions.selectedVerse,
submittedAt: verseSubmissions.submittedAt,
dailyDate: dailyVerses.date,
bookId: dailyVerses.bookId,
reference: dailyVerses.reference,
verseText: dailyVerses.verseText,
userId: verseSubmissions.userId,
userEmail: user.email,
userFirstName: user.firstName,
userLastName: user.lastName,
})
.from(verseSubmissions)
// LEFT JOINs so deleted accounts (ON DELETE SET NULL) still render —
// the scheduled verse plays out regardless.
.leftJoin(dailyVerses, eq(verseSubmissions.scheduledDate, dailyVerses.date))
.leftJoin(user, eq(verseSubmissions.userId, user.id))
.orderBy(asc(verseSubmissions.scheduledDate));
const rows: ScheduledVerseRow[] = joined.map((r) => {
const book = r.bookId ? getBookById(r.bookId) : undefined;
return {
scheduledDate: r.scheduledDate,
reference: r.reference,
verseText: r.verseText,
bookId: r.bookId,
bookName: book?.name ?? null,
selectedBookId: r.selectedBookId,
selectedChapter: r.selectedChapter,
selectedVerse: r.selectedVerse,
submittedAt: r.submittedAt,
submitterEmail: r.userEmail,
submitterName: [r.userFirstName, r.userLastName].filter(Boolean).join(' ') || null,
// userId is null when the account was deleted (ON DELETE SET NULL)
submitterDeleted: r.userId === null,
};
});
return {
rows,
requiresAuth: false,
authorized: true,
user: locals.user,
session: locals.session,
};
};
+270
View File
@@ -0,0 +1,270 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import AuthModal from '$lib/components/AuthModal.svelte';
import Container from '$lib/components/Container.svelte';
import { bibleBooks } from '$lib/types/bible';
type ScheduledVerseRow = {
scheduledDate: string;
reference: string | null;
verseText: string | null;
bookId: string | null;
bookName: string | null;
selectedBookId: string;
selectedChapter: number;
selectedVerse: number;
submittedAt: number;
submitterEmail: string | null;
submitterName: string | null;
submitterDeleted: boolean;
};
interface PageData {
rows: ScheduledVerseRow[];
requiresAuth: boolean;
authorized: boolean;
user?: any;
session?: any;
}
let { data }: { data: PageData } = $props();
let authModalOpen = $state(false);
let anonymousId = $state('');
let filter = $state<'all' | 'upcoming' | 'past'>('all');
const filters = ['all', 'upcoming', 'past'] as const;
function getOrCreateAnonymousId(): string {
if (!browser) return '';
const key = 'bibdle-anonymous-id';
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
}
onMount(() => {
anonymousId = getOrCreateAnonymousId();
});
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
}
function formatTimestamp(ms: number): string {
return new Date(ms).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
timeZoneName: 'short',
});
}
const todayUtc = $derived(new Date().toISOString().slice(0, 10));
const filteredRows = $derived.by(() => {
if (filter === 'all') return data.rows;
if (filter === 'upcoming') return data.rows.filter((r) => r.scheduledDate >= todayUtc);
return data.rows.filter((r) => r.scheduledDate < todayUtc);
});
function selectedBookName(bookId: string): string {
return bibleBooks.find((b) => b.id === bookId)?.name ?? bookId;
}
</script>
<svelte:head>
<title>Scheduled Verses | Bibdle</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
>
<div class="max-w-5xl mx-auto">
<div class="text-center mb-6 md:mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-4">
Scheduled Verses
</h1>
<a href="/" class="p-2 px-20 w-full items-center text-gray-300">
&larr; Back to Game
</a>
</div>
{#if data.requiresAuth}
<div class="text-center py-12">
<div
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
>
<h2 class="text-2xl font-bold text-blue-200 mb-4">
Authentication Required
</h2>
<p class="text-blue-300 mb-6">
You must be signed in to view this page.
</p>
<div class="flex flex-col gap-3">
<button
onclick={() => (authModalOpen = true)}
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Sign In
</button>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
&larr; Back to Game
</a>
</div>
</div>
</div>
{:else if !data.authorized}
<div class="text-center py-12">
<div
class="bg-red-950/50 border border-red-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
>
<h2 class="text-2xl font-bold text-red-200 mb-4">Not Authorized</h2>
<p class="text-red-300 mb-6">You do not have access to this page.</p>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
&larr; Back to Game
</a>
</div>
</div>
{:else}
<!-- Filter toggle -->
<div class="flex items-center justify-between gap-2 mb-4 flex-wrap">
<div class="flex gap-1 bg-white/5 rounded-lg p-1 border border-white/10">
{#each filters as f}
<button
onclick={() => (filter = f)}
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors {filter ===
f
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-white/5'}"
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
{/each}
</div>
<span class="text-xs text-gray-500">
{filteredRows.length} {filteredRows.length === 1 ? 'row' : 'rows'}
</span>
</div>
{#if filteredRows.length === 0}
<Container class="p-8 w-full text-center">
<p class="text-gray-400">No submissions yet.</p>
</Container>
{:else}
<Container class="p-2 md:p-4 w-full overflow-x-auto">
<table class="w-full text-sm text-left text-gray-200">
<thead
class="text-xs uppercase text-gray-400 border-b border-white/10"
>
<tr>
<th class="px-2 md:px-3 py-2">Scheduled</th>
<th class="px-2 md:px-3 py-2">Reference</th>
<th class="px-2 md:px-3 py-2 hidden md:table-cell">
Window text
</th>
<th class="px-2 md:px-3 py-2">Submitted by</th>
<th class="px-2 md:px-3 py-2 hidden sm:table-cell">
Submitted at
</th>
<th class="px-2 md:px-3 py-2 hidden lg:table-cell">
Selected
</th>
</tr>
</thead>
<tbody>
{#each filteredRows as row (row.scheduledDate)}
<tr
class="border-b border-white/5 hover:bg-white/5 align-top"
>
<td class="px-2 md:px-3 py-2 whitespace-nowrap">
<div class="font-medium text-gray-100">
{formatDate(row.scheduledDate)}
</div>
{#if row.scheduledDate < todayUtc}
<span class="text-[10px] text-gray-500"
>played</span
>
{:else if row.scheduledDate === todayUtc}
<span
class="text-[10px] text-emerald-400"
>today</span
>
{/if}
</td>
<td class="px-2 md:px-3 py-2">
{#if row.reference}
<div class="font-medium">
{row.reference}
</div>
<div class="text-[10px] text-gray-500">
{row.bookName ?? row.bookId}
</div>
{:else}
<span class="text-gray-500"
>— no daily_verse row —</span
>
{/if}
</td>
<td
class="px-2 md:px-3 py-2 hidden md:table-cell max-w-xs"
>
<span class="text-xs text-gray-400 line-clamp-4"
>{row.verseText ?? ''}</span
>
</td>
<td class="px-2 md:px-3 py-2">
{#if row.submitterDeleted}
<span class="text-gray-500 italic"
>(deleted user)</span
>
{:else}
{#if row.submitterName}
<div>{row.submitterName}</div>
{/if}
<div class="text-[11px] text-gray-400">
{row.submitterEmail ?? '—'}
</div>
{/if}
</td>
<td
class="px-2 md:px-3 py-2 hidden sm:table-cell whitespace-nowrap text-xs text-gray-400"
>
{formatTimestamp(row.submittedAt)}
</td>
<td
class="px-2 md:px-3 py-2 hidden lg:table-cell whitespace-nowrap text-xs text-gray-400"
>
{selectedBookName(row.selectedBookId)}
{row.selectedChapter}:{row.selectedVerse}
</td>
</tr>
{/each}
</tbody>
</table>
</Container>
{/if}
{/if}
</div>
</div>
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />