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
+8
View File
@@ -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;
}
+17
View File
@@ -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
View File
@@ -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:312: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}`;
}