mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
Added verse-submission infrastructure
This commit is contained in:
1 parent
99552c57ad
commit
be0d7ad297
17 files changed
+1293
-26
No files matched your search
+1
-2
@@ -3,8 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<script src="https://rybbit.snail.city/api/script.js" data-site-id="9abf0e81d024" defer></script>
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Bluesky"
|
||||
data-umami-event="Bluesky clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Bluesky clicked")}
|
||||
>
|
||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||
</a>
|
||||
@@ -25,7 +24,6 @@
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Twitter"
|
||||
data-umami-event="Twitter clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Twitter clicked")}
|
||||
>
|
||||
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
|
||||
</a>
|
||||
@@ -37,7 +35,6 @@
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Send email"
|
||||
data-umami-event="Email clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Email clicked")}
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-700 dark:text-gray-300"
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
function copyVerse() {
|
||||
navigator.clipboard.writeText(displayVerseText).then(() => {
|
||||
copied = true;
|
||||
(window as any).rybbit?.event("Copy Verse");
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 2000);
|
||||
|
||||
@@ -253,13 +253,9 @@
|
||||
: "Copy to Clipboard"}
|
||||
onclick={() => {
|
||||
if (hasWebShare) {
|
||||
(window as any).rybbit?.event("Share");
|
||||
shareResult(effectiveShareText);
|
||||
} else {
|
||||
if (!copyTracked) {
|
||||
(window as any).rybbit?.event(
|
||||
"Copy to Clipboard",
|
||||
);
|
||||
copyTracked = true;
|
||||
}
|
||||
clipboardCopy(effectiveShareText);
|
||||
@@ -288,7 +284,6 @@
|
||||
data-umami-event="Copy to Clipboard"
|
||||
onclick={() => {
|
||||
if (!copyTracked) {
|
||||
(window as any).rybbit?.event("Copy to Clipboard");
|
||||
copyTracked = true;
|
||||
}
|
||||
clipboardCopy(effectiveShareText);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Single source of truth for the admin account that may view submitter
|
||||
// attribution on /scheduled-verses. The public game never surfaces submitter
|
||||
// identity; this email is the sole exception (see spec "Admin /scheduled-verses").
|
||||
export const ADMIN_EMAIL = 'geohpowell@gmail.com';
|
||||
|
||||
export function isAdmin(email: string | null | undefined): boolean {
|
||||
return !!email && email.toLowerCase() === ADMIN_EMAIL;
|
||||
}
|
||||
@@ -48,3 +48,20 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
||||
]);
|
||||
|
||||
export type DailyCompletion = typeof dailyCompletions.$inferSelect;
|
||||
|
||||
export const verseSubmissions = sqliteTable('verse_submissions', {
|
||||
id: text('id').primaryKey(),
|
||||
// Nullable so ON DELETE SET NULL can clear attribution when a user is deleted;
|
||||
// the scheduled verse still plays out (canonical text lives on daily_verses).
|
||||
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
|
||||
scheduledDate: text('scheduled_date').notNull().unique(),
|
||||
selectedBookId: text('selected_book_id').notNull(),
|
||||
selectedChapter: integer('selected_chapter').notNull(),
|
||||
selectedVerse: integer('selected_verse').notNull(),
|
||||
// Raw integer of server UTC milliseconds — drives the 7-day rolling cooldown.
|
||||
submittedAt: integer('submitted_at').notNull(),
|
||||
}, (table) => [
|
||||
index('verse_submissions_user_id_idx').on(table.userId),
|
||||
]);
|
||||
|
||||
export type VerseSubmission = typeof verseSubmissions.$inferSelect;
|
||||
+126
-2
@@ -135,7 +135,7 @@ function getGreekChapter(bookNumber: number, chapterNumber: number): ChapterData
|
||||
/**
|
||||
* Get the number of verses in a specific chapter
|
||||
*/
|
||||
function getVerseCount(bookNumber: number, chapterNumber: number): number {
|
||||
export function getVerseCount(bookNumber: number, chapterNumber: number): number {
|
||||
const chapter = getChapter(bookNumber, chapterNumber);
|
||||
return chapter ? chapter.verse.length : 0;
|
||||
}
|
||||
@@ -151,7 +151,7 @@ function getGreekVerseCount(bookNumber: number, chapterNumber: number): number {
|
||||
/**
|
||||
* Get the number of chapters in a specific book
|
||||
*/
|
||||
function getChapterCount(bookNumber: number): number {
|
||||
export function getChapterCount(bookNumber: number): number {
|
||||
const book = getBook(bookNumber);
|
||||
return book ? book.chapter.length : 0;
|
||||
}
|
||||
@@ -439,3 +439,127 @@ export function getAllNKJVVerses(): Array<{ text: string; book: string; chapter:
|
||||
}
|
||||
return verses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a 1-based book-wide verse index to { chapter, verse }.
|
||||
* The index is contiguous across all chapters of the book.
|
||||
*/
|
||||
function resolveBookVerseIndex(
|
||||
bookNumber: number,
|
||||
index: number
|
||||
): { chapter: number; verse: number } | null {
|
||||
const chapterCount = getChapterCount(bookNumber);
|
||||
let cumulative = 0;
|
||||
for (let c = 1; c <= chapterCount; c++) {
|
||||
const vCount = getVerseCount(bookNumber, c);
|
||||
if (index <= cumulative + vCount) {
|
||||
return { chapter: c, verse: index - cumulative };
|
||||
}
|
||||
cumulative += vCount;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ComposedWindow {
|
||||
bookId: string;
|
||||
bookName: string;
|
||||
verses: string[]; // the 3 verse texts in order
|
||||
startChapter: number;
|
||||
startVerse: number;
|
||||
endChapter: number;
|
||||
endVerse: number;
|
||||
selectedVerse: number; // the anchor verse number within its chapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a 3-verse window anchored on (book, chapter, verse), constrained to
|
||||
* a single book. Default window is [v-2, v-1, v] in book-wide verse indices;
|
||||
* if that would run before the book's start (v is 1 or 2), fall forward to
|
||||
* [v, v+1, v+2]. Cross-chapter windows within the same book are allowed.
|
||||
*
|
||||
* Mirrors the spec's "Verse Windowing" section.
|
||||
*/
|
||||
export function composeVerseWindow(
|
||||
bookNumber: number,
|
||||
chapter: number,
|
||||
verse: number
|
||||
): ComposedWindow | null {
|
||||
const book = getBookByNumber(bookNumber);
|
||||
if (!book) return null;
|
||||
|
||||
const chapterCount = getChapterCount(bookNumber);
|
||||
if (chapter < 1 || chapter > chapterCount) return null;
|
||||
const verseCount = getVerseCount(bookNumber, chapter);
|
||||
if (verse < 1 || verse > verseCount) return null;
|
||||
|
||||
// Book-wide 1-based index of the selected verse.
|
||||
let selectedIndex = 0;
|
||||
for (let c = 1; c < chapter; c++) selectedIndex += getVerseCount(bookNumber, c);
|
||||
selectedIndex += verse;
|
||||
|
||||
let startIdx: number;
|
||||
let endIdx: number;
|
||||
if (selectedIndex - 2 < 1) {
|
||||
// Fall forward: [v, v+1, v+2]
|
||||
startIdx = selectedIndex;
|
||||
endIdx = selectedIndex + 2;
|
||||
} else {
|
||||
startIdx = selectedIndex - 2;
|
||||
endIdx = selectedIndex;
|
||||
}
|
||||
|
||||
const start = resolveBookVerseIndex(bookNumber, startIdx);
|
||||
const end = resolveBookVerseIndex(bookNumber, endIdx);
|
||||
if (!start || !end) return null;
|
||||
|
||||
// Extract the contiguous window, crossing chapter boundaries within the book as needed.
|
||||
const verses: string[] = [];
|
||||
let curChapter = start.chapter;
|
||||
let curVerse = start.verse;
|
||||
while (verses.length < 3 && curChapter <= chapterCount) {
|
||||
const vCount = getVerseCount(bookNumber, curChapter);
|
||||
while (curVerse <= vCount && verses.length < 3) {
|
||||
const ex = extractVerses(bookNumber, curChapter, curVerse, 1);
|
||||
if (ex.length === 1) {
|
||||
verses.push(ex[0]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
curVerse++;
|
||||
}
|
||||
curChapter++;
|
||||
curVerse = 1;
|
||||
}
|
||||
|
||||
if (verses.length < 3) return null;
|
||||
|
||||
return {
|
||||
bookId: book.id,
|
||||
bookName: book.name,
|
||||
verses,
|
||||
startChapter: start.chapter,
|
||||
startVerse: start.verse,
|
||||
endChapter: end.chapter,
|
||||
endVerse: end.verse,
|
||||
selectedVerse: verse
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a reference for a window that may span chapters within a book.
|
||||
* Same-chapter: "Genesis 1:1-3" (hyphen)
|
||||
* Cross-chapter: "Genesis 1:31–2:1" (en-dash)
|
||||
*/
|
||||
export function formatWindowReference(
|
||||
bookName: string,
|
||||
startChapter: number,
|
||||
startVerse: number,
|
||||
endChapter: number,
|
||||
endVerse: number
|
||||
): string {
|
||||
if (startChapter === endChapter) {
|
||||
if (startVerse === endVerse) return `${bookName} ${startChapter}:${startVerse}`;
|
||||
return `${bookName} ${startChapter}:${startVerse}-${endVerse}`;
|
||||
}
|
||||
return `${bookName} ${startChapter}:${startVerse}\u2013${endChapter}:${endVerse}`;
|
||||
}
|
||||
@@ -53,15 +53,7 @@ export function createGamePersistence(
|
||||
if ((window as any).umami) {
|
||||
(window as any).umami.identify(anonymousId);
|
||||
}
|
||||
if (user) {
|
||||
const nameParts = [user.firstName, user.lastName].filter(Boolean);
|
||||
(window as any).rybbit?.identify(user.id, {
|
||||
...(nameParts.length ? { name: nameParts.join(' ') } : {}),
|
||||
...(user.email ? { email: user.email } : {}),
|
||||
});
|
||||
} else {
|
||||
(window as any).rybbit?.identify(anonymousId);
|
||||
}
|
||||
|
||||
|
||||
const date = getDate();
|
||||
const reference = getReference();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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">
|
||||
← 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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
← 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} />
|
||||
Reference in new issue
Block a user