mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
New scheduling UI!
This commit is contained in:
1 parent
be0d7ad297
commit
21efdd4eef
19 files changed
+2127
-39
No files matched your search
+2
-2
@@ -17,9 +17,9 @@ const handleAuth: Handle = async ({ event, resolve }) => {
|
||||
const { session, user } = await auth.validateSessionToken(sessionToken);
|
||||
|
||||
if (session) {
|
||||
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
|
||||
auth.setSessionTokenCookie({ cookies: event.cookies }, sessionToken, session.expiresAt);
|
||||
} else {
|
||||
auth.deleteSessionTokenCookie(event);
|
||||
auth.deleteSessionTokenCookie({ cookies: event.cookies });
|
||||
}
|
||||
|
||||
event.locals.user = user;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
onclick?: () => void;
|
||||
class?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -15,6 +16,7 @@
|
||||
onclick,
|
||||
class: className = "",
|
||||
type = "button",
|
||||
disabled = false,
|
||||
}: Props = $props();
|
||||
|
||||
const variantClasses = {
|
||||
@@ -31,6 +33,7 @@
|
||||
<button
|
||||
{type}
|
||||
{onclick}
|
||||
{disabled}
|
||||
class="inline-flex items-center justify-center px-4 py-2 rounded-lg border-2 font-bold text-sm transition-all duration-200 {variantClasses[
|
||||
variant
|
||||
]} {className}"
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
$effect(() => {
|
||||
let fadeOutId: ReturnType<typeof setTimeout>;
|
||||
let fadeInId: ReturnType<typeof setTimeout>;
|
||||
let changeId: ReturnType<typeof setTimeout>;
|
||||
|
||||
function animateTo(newText: string, delay = 0) {
|
||||
@@ -27,7 +26,6 @@
|
||||
|
||||
return () => {
|
||||
clearTimeout(fadeOutId);
|
||||
clearTimeout(fadeInId);
|
||||
clearTimeout(changeId);
|
||||
};
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { fade } from "svelte/transition";
|
||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||
import Container from "./Container.svelte";
|
||||
|
||||
interface VerseDisplayData {
|
||||
dailyVerse: {
|
||||
date: string;
|
||||
reference: string;
|
||||
verseText: string;
|
||||
};
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
isWon,
|
||||
blurChapter = false,
|
||||
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||
}: { data: VerseDisplayData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let displayReference = $derived(
|
||||
blurChapter
|
||||
@@ -19,7 +26,7 @@
|
||||
);
|
||||
let displayVerseText = $derived(
|
||||
dailyVerse.verseText
|
||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||
.replace(/^([a-z])/, (c: string) => c.toUpperCase())
|
||||
.replace(/[,:;-—]$/, "..."),
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import CountdownTimer from "./CountdownTimer.svelte";
|
||||
import StreakCounter from "./StreakCounter.svelte";
|
||||
import ChapterGuess from "./ChapterGuess.svelte";
|
||||
import SubmitVerse from "./SubmitVerse.svelte";
|
||||
|
||||
interface StatsData {
|
||||
solveRank: number;
|
||||
@@ -41,6 +42,7 @@
|
||||
streakPercentile = null,
|
||||
isLoggedIn = false,
|
||||
anonymousId = "",
|
||||
localDate = "",
|
||||
}: {
|
||||
statsData: StatsData | null;
|
||||
correctBookId: string;
|
||||
@@ -57,6 +59,7 @@
|
||||
streakPercentile?: number | null;
|
||||
isLoggedIn?: boolean;
|
||||
anonymousId?: string;
|
||||
localDate?: string;
|
||||
} = $props();
|
||||
|
||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||
@@ -408,11 +411,17 @@
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
</form>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SubmitVerse
|
||||
{isLoggedIn}
|
||||
{anonymousId}
|
||||
{localDate}
|
||||
/>
|
||||
|
||||
<div class="signin-prompt">
|
||||
<a
|
||||
href="https://discord.gg/yWQXbGK8SD"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { testDb as db } from '$lib/server/db/test';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
@@ -64,15 +64,15 @@ export async function invalidateSession(sessionId: string) {
|
||||
await db.delete(table.session).where(eq(table.session.id, sessionId));
|
||||
}
|
||||
|
||||
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
|
||||
event.cookies.set(sessionCookieName, token, {
|
||||
export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: Date) {
|
||||
cookies.set(sessionCookieName, token, {
|
||||
expires: expiresAt,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSessionTokenCookie(event: RequestEvent) {
|
||||
event.cookies.delete(sessionCookieName, {
|
||||
export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }) {
|
||||
cookies.delete(sessionCookieName, {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import type { Cookies, RequestEvent } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
@@ -64,15 +64,15 @@ export async function invalidateSession(sessionId: string) {
|
||||
await db.delete(table.session).where(eq(table.session.id, sessionId));
|
||||
}
|
||||
|
||||
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
|
||||
event.cookies.set(sessionCookieName, token, {
|
||||
export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: Date) {
|
||||
cookies.set(sessionCookieName, token, {
|
||||
expires: expiresAt,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSessionTokenCookie(event: RequestEvent) {
|
||||
event.cookies.delete(sessionCookieName, {
|
||||
export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }) {
|
||||
cookies.delete(sessionCookieName, {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,3 +31,22 @@ export async function fetchRandomVerse(): Promise<ApiVerse> {
|
||||
verseText
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Like fetchRandomVerse, but re-rolls until the picked book is not in
|
||||
* `avoidBookIds` (the no-back-to-back neighbors for lazy gap-fill).
|
||||
* 66 books with ≤2 excluded gives ~97% success per try; capped at 20 tries
|
||||
* before accepting whatever was last drawn (spec: "Lazy Gap-Fill").
|
||||
*/
|
||||
export async function fetchRandomVerseAvoiding(
|
||||
avoidBookIds: string[] = []
|
||||
): Promise<ApiVerse> {
|
||||
const avoid = new Set(avoidBookIds.filter(Boolean));
|
||||
let last: ApiVerse | null = null;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const v = await fetchRandomVerse();
|
||||
last = v;
|
||||
if (!avoid.has(v.bookId)) return v;
|
||||
}
|
||||
return last as ApiVerse;
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses } from '$lib/server/db/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||
import { fetchRandomVerse, fetchRandomVerseAvoiding } from '$lib/server/bible-api';
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
|
||||
/** Add `n` days to a YYYY-MM-DD string using pure UTC arithmetic. */
|
||||
function addDays(dateStr: string, n: number): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function getVerseForDate(dateStr: string): Promise<DailyVerse> {
|
||||
// Validate date format (YYYY-MM-DD)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
@@ -16,8 +23,28 @@ export async function getVerseForDate(dateStr: string): Promise<DailyVerse> {
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// Otherwise get a new random verse for this date
|
||||
const apiVerse = await fetchRandomVerse();
|
||||
// Otherwise get a new random verse for this date. Gap-fill is extended to
|
||||
// respect the no-back-to-back rule: the random verse's book must differ from
|
||||
// any committed neighbor (D-1 / D+1). This preserves the global invariant
|
||||
// that no two consecutive calendar days ever feature the same book
|
||||
// (spec: "Lazy Gap-Fill").
|
||||
const [prev] = await db
|
||||
.select({ bookId: dailyVerses.bookId })
|
||||
.from(dailyVerses)
|
||||
.where(eq(dailyVerses.date, addDays(dateStr, -1)))
|
||||
.limit(1);
|
||||
const [next] = await db
|
||||
.select({ bookId: dailyVerses.bookId })
|
||||
.from(dailyVerses)
|
||||
.where(eq(dailyVerses.date, addDays(dateStr, 1)))
|
||||
.limit(1);
|
||||
|
||||
const avoid = [prev?.bookId, next?.bookId].filter((b): b is string => !!b);
|
||||
const apiVerse =
|
||||
avoid.length > 0
|
||||
? await fetchRandomVerseAvoiding(avoid)
|
||||
: await fetchRandomVerse();
|
||||
|
||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
||||
|
||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { db as defaultDb } from '$lib/server/db';
|
||||
import { dailyVerses, verseSubmissions } from '$lib/server/db/schema';
|
||||
import { eq, desc, sql } from 'drizzle-orm';
|
||||
import { getBookById } from '$lib/server/bible';
|
||||
|
||||
// Drizzle instance type alias (the production db or the test db).
|
||||
export type Db = typeof defaultDb;
|
||||
import {
|
||||
composeVerseWindow,
|
||||
formatWindowReference,
|
||||
getChapterCount,
|
||||
getVerseCount
|
||||
} from '$lib/server/xml-bible';
|
||||
|
||||
// Server-side constants for the community-verse-submission feature
|
||||
// (spec: "Rate Limiting & Cooldown", "Scheduling Algorithm").
|
||||
const DAY_MS = 1000 * 60 * 60 * 24;
|
||||
export const COOLDOWN_MS = 7 * DAY_MS;
|
||||
export const REPEAT_WINDOW_DAYS = 60;
|
||||
const MAX_SCHEDULE_RETRIES = 5;
|
||||
// Safety cap on the day-by-day forward scan. 10k days (~27 years) is well
|
||||
// beyond any realistic calendar density; prevents an accidental infinite loop.
|
||||
const MAX_SCAN_DAYS = 10_000;
|
||||
|
||||
export interface SubmissionInput {
|
||||
bookId: string;
|
||||
chapter: number;
|
||||
verse: number;
|
||||
}
|
||||
|
||||
export interface ScheduleResult {
|
||||
scheduledDate: string;
|
||||
reference: string;
|
||||
windowText: string;
|
||||
bookId: string;
|
||||
}
|
||||
|
||||
/** Add `n` days to a YYYY-MM-DD string using pure UTC arithmetic. */
|
||||
export function addDays(dateStr: string, n: number): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Whole-day difference (b - a) between two YYYY-MM-DD strings, UTC. */
|
||||
export function dayDiff(a: string, b: string): number {
|
||||
const ta = new Date(a + 'T00:00:00Z').getTime();
|
||||
const tb = new Date(b + 'T00:00:00Z').getTime();
|
||||
return Math.round((tb - ta) / DAY_MS);
|
||||
}
|
||||
|
||||
/** Current server UTC date as YYYY-MM-DD. */
|
||||
export function todayUtcStr(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical identity of a 3-verse window, used by the 60-day repeat rule.
|
||||
* Combines bookId + formatted reference so identical windows collide regardless
|
||||
* of how the underlying row was produced (submission vs. random gap-fill).
|
||||
*/
|
||||
function windowRef(bookId: string, reference: string): string {
|
||||
return `${bookId}|${reference}`;
|
||||
}
|
||||
|
||||
/** Is this a SQLite unique-constraint violation (used for retry-on-conflict)? */
|
||||
export function isUniqueConstraintError(err: unknown): boolean {
|
||||
const e = err as { code?: string; message?: string } | null;
|
||||
return !!e && (
|
||||
e.code === 'SQLITE_CONSTRAINT_UNIQUE' ||
|
||||
e.code === 'SQLITE_CONSTRAINT' ||
|
||||
!!(e.message && /UNIQUE/i.test(e.message))
|
||||
);
|
||||
}
|
||||
|
||||
export interface CooldownState {
|
||||
/** True if the user submitted within the last 7 days. */
|
||||
onCooldown: boolean;
|
||||
/** Server UTC millis when the cooldown expires, or null. */
|
||||
cooldownEndsAt: number | null;
|
||||
}
|
||||
|
||||
/** Compute the rolling 7-day cooldown state for a user (server UTC). */
|
||||
export async function getCooldownState(
|
||||
db: Db,
|
||||
userId: string,
|
||||
now: number = Date.now()
|
||||
): Promise<CooldownState> {
|
||||
const [last] = await db
|
||||
.select({ submittedAt: verseSubmissions.submittedAt })
|
||||
.from(verseSubmissions)
|
||||
.where(eq(verseSubmissions.userId, userId))
|
||||
.orderBy(desc(verseSubmissions.submittedAt))
|
||||
.limit(1);
|
||||
|
||||
if (!last) {
|
||||
return { onCooldown: false, cooldownEndsAt: null };
|
||||
}
|
||||
|
||||
const cooldownEndsAt = last.submittedAt + COOLDOWN_MS;
|
||||
if (now < cooldownEndsAt) {
|
||||
return { onCooldown: true, cooldownEndsAt };
|
||||
}
|
||||
return { onCooldown: false, cooldownEndsAt: null };
|
||||
}
|
||||
|
||||
/** Structural validation of a user-supplied (bookId, chapter, verse) selection. */
|
||||
export function validateSelection(input: SubmissionInput): string | null {
|
||||
const book = getBookById(input.bookId);
|
||||
if (!book) return 'Unknown bookId';
|
||||
|
||||
if (!Number.isInteger(input.chapter) || input.chapter < 1) {
|
||||
return 'chapter must be a positive integer';
|
||||
}
|
||||
const chapterCount = getChapterCount(book.order);
|
||||
if (input.chapter > chapterCount) {
|
||||
return `chapter out of range (1-${chapterCount})`;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(input.verse) || input.verse < 1) {
|
||||
return 'verse must be a positive integer';
|
||||
}
|
||||
const verseCount = getVerseCount(book.order, input.chapter);
|
||||
if (input.verse > verseCount) {
|
||||
return `verse out of range (1-${verseCount})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface LoadedCalendar {
|
||||
byDate: Map<string, { bookId: string; windowRef: string }>;
|
||||
datesByWindowRef: Map<string, string[]>;
|
||||
}
|
||||
|
||||
/** Load every committed daily_verses row into in-memory indexes for the scan. */
|
||||
async function loadCalendar(db: Db): Promise<LoadedCalendar> {
|
||||
const rows = await db
|
||||
.select({
|
||||
date: dailyVerses.date,
|
||||
bookId: dailyVerses.bookId,
|
||||
reference: dailyVerses.reference
|
||||
})
|
||||
.from(dailyVerses);
|
||||
|
||||
const byDate = new Map<string, { bookId: string; windowRef: string }>();
|
||||
const datesByWindowRef = new Map<string, string[]>();
|
||||
|
||||
for (const r of rows) {
|
||||
const wr = windowRef(r.bookId, r.reference);
|
||||
byDate.set(r.date, { bookId: r.bookId, windowRef: wr });
|
||||
const arr = datesByWindowRef.get(wr);
|
||||
if (arr) arr.push(r.date);
|
||||
else datesByWindowRef.set(wr, [r.date]);
|
||||
}
|
||||
|
||||
return { byDate, datesByWindowRef };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the earliest valid candidate date `D` (scanning forward from tomorrow,
|
||||
* server UTC) for a submission with the given book + window identity.
|
||||
*
|
||||
* Validity (spec "Scheduling Algorithm"):
|
||||
* 1. Empty: no daily_verses row at D.
|
||||
* 2. No back-to-back: D-1 (if committed) has a different book.
|
||||
* 3. No back-to-back: D+1 (if committed) has a different book.
|
||||
* 4. 60-day repeat: no identical window within [D-60, D+60] inclusive.
|
||||
*/
|
||||
async function findCandidateDate(
|
||||
db: Db,
|
||||
bookId: string,
|
||||
submissionWindowRef: string,
|
||||
opts?: { startFrom?: string; today?: string }
|
||||
): Promise<string | null> {
|
||||
const { byDate, datesByWindowRef } = await loadCalendar(db);
|
||||
|
||||
let cursor = opts?.startFrom ?? addDays(opts?.today ?? todayUtcStr(), 1);
|
||||
for (let i = 0; i < MAX_SCAN_DAYS; i++) {
|
||||
// Rule 1: must be empty.
|
||||
if (!byDate.has(cursor)) {
|
||||
// Rules 2 & 3: no back-to-back with committed neighbors.
|
||||
const prev = byDate.get(addDays(cursor, -1));
|
||||
const next = byDate.get(addDays(cursor, 1));
|
||||
const backToBack =
|
||||
(!!prev && prev.bookId === bookId) ||
|
||||
(!!next && next.bookId === bookId);
|
||||
|
||||
if (!backToBack) {
|
||||
// Rule 4: 60-day repeat distance for the identical window.
|
||||
const sameWindowDates = datesByWindowRef.get(submissionWindowRef) ?? [];
|
||||
const tooClose = sameWindowDates.some(
|
||||
(d) => Math.abs(dayDiff(d, cursor)) <= REPEAT_WINDOW_DAYS
|
||||
);
|
||||
if (!tooClose) {
|
||||
return cursor;
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the scheduling scan and write the daily_verses + verse_submissions rows
|
||||
* in a transaction. On a unique-constraint conflict (concurrent submission
|
||||
* picked the same date), re-run the scan from the next day; retry up to a
|
||||
* small bound.
|
||||
*/
|
||||
export async function scheduleSubmission(
|
||||
db: Db,
|
||||
input: SubmissionInput,
|
||||
userId: string,
|
||||
now: number = Date.now(),
|
||||
opts?: { today?: string }
|
||||
): Promise<ScheduleResult> {
|
||||
const book = getBookById(input.bookId);
|
||||
if (!book) throw new Error('Invalid bookId');
|
||||
|
||||
const window = composeVerseWindow(book.order, input.chapter, input.verse);
|
||||
if (!window) throw new Error('Invalid chapter/verse for this book');
|
||||
|
||||
const reference = formatWindowReference(
|
||||
window.bookName,
|
||||
window.startChapter,
|
||||
window.startVerse,
|
||||
window.endChapter,
|
||||
window.endVerse
|
||||
);
|
||||
const windowText = window.verses.join(' ');
|
||||
const submissionWindowRef = windowRef(book.id, reference);
|
||||
let lastCandidate = '';
|
||||
|
||||
for (let attempt = 0; attempt < MAX_SCHEDULE_RETRIES; attempt++) {
|
||||
// Re-compute the candidate each attempt — a concurrent submission may
|
||||
// have claimed the previous candidate between scan and insert. After a
|
||||
// conflict, re-scan from the day *after* the contested candidate.
|
||||
const candidate = await findCandidateDate(
|
||||
db,
|
||||
book.id,
|
||||
submissionWindowRef,
|
||||
attempt === 0
|
||||
? { today: opts?.today }
|
||||
: { startFrom: addDays(lastCandidate, 1) }
|
||||
);
|
||||
if (!candidate) {
|
||||
throw new Error('No valid candidate date found within the scan window');
|
||||
}
|
||||
lastCandidate = candidate;
|
||||
|
||||
try {
|
||||
db.transaction((tx) => {
|
||||
tx.insert(dailyVerses)
|
||||
.values({
|
||||
id: Bun.randomUUIDv7(),
|
||||
date: candidate,
|
||||
bookId: book.id,
|
||||
verseText: windowText,
|
||||
reference,
|
||||
createdAt: sql`${Math.floor(now / 1000)}`
|
||||
})
|
||||
.run();
|
||||
tx.insert(verseSubmissions)
|
||||
.values({
|
||||
id: Bun.randomUUIDv7(),
|
||||
userId,
|
||||
scheduledDate: candidate,
|
||||
selectedBookId: input.bookId,
|
||||
selectedChapter: input.chapter,
|
||||
selectedVerse: input.verse,
|
||||
submittedAt: now
|
||||
})
|
||||
.run();
|
||||
});
|
||||
|
||||
return {
|
||||
scheduledDate: candidate,
|
||||
reference,
|
||||
windowText,
|
||||
bookId: book.id
|
||||
};
|
||||
} catch (err) {
|
||||
if (isUniqueConstraintError(err)) {
|
||||
continue; // retry — re-scan from tomorrow
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to schedule submission after retries');
|
||||
}
|
||||
@@ -432,6 +432,7 @@
|
||||
{streakPercentile}
|
||||
isLoggedIn={!!user}
|
||||
anonymousId={persistence.anonymousId}
|
||||
localDate={new Date().toLocaleDateString("en-CA")}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import {
|
||||
scheduleSubmission,
|
||||
validateSelection,
|
||||
getCooldownState
|
||||
} from '$lib/server/verse-submission';
|
||||
|
||||
/**
|
||||
* POST /api/submit-verse
|
||||
*
|
||||
* Accepts a user-chosen verse (bookId/chapter/verse) and reserves a concrete
|
||||
* future date for it via the scheduling scan. Auth required, solved-today gate,
|
||||
* 7-day rolling cooldown, and structural validation all enforced here.
|
||||
*
|
||||
* Body: { bookId, chapter, verse, localDate }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
// 1. Auth.
|
||||
if (!locals.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const userId = locals.user.id;
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { bookId, chapter, verse, localDate } = body ?? {};
|
||||
|
||||
// 2. Solved-today gate (engagement gate, not security).
|
||||
if (typeof localDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(localDate)) {
|
||||
return json({ error: 'A valid localDate (YYYY-MM-DD) is required' }, { status: 400 });
|
||||
}
|
||||
const [completion] = await db
|
||||
.select({ id: dailyCompletions.id })
|
||||
.from(dailyCompletions)
|
||||
.where(
|
||||
and(
|
||||
eq(dailyCompletions.anonymousId, userId),
|
||||
eq(dailyCompletions.date, localDate)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!completion) {
|
||||
return json({ error: "Solve today's puzzle first" }, { status: 403 });
|
||||
}
|
||||
|
||||
// 3. Cooldown (rolling 7×24h, server UTC).
|
||||
const cooldown = await getCooldownState(db, userId);
|
||||
if (cooldown.onCooldown) {
|
||||
return json(
|
||||
{ error: 'Cooldown active', cooldownEndsAt: cooldown.cooldownEndsAt },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Structural validation.
|
||||
const validationError = validateSelection({ bookId, chapter, verse });
|
||||
if (validationError) {
|
||||
return json({ error: validationError }, { status: 400 });
|
||||
}
|
||||
|
||||
// 5–7. Compute window + scheduling scan + transactional insert (with retry).
|
||||
try {
|
||||
const result = await scheduleSubmission(
|
||||
db,
|
||||
{ bookId, chapter, verse },
|
||||
userId
|
||||
);
|
||||
return json(
|
||||
{
|
||||
scheduledDate: result.scheduledDate,
|
||||
reference: result.reference,
|
||||
windowText: result.windowText
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('submit-verse failed:', err);
|
||||
return json({ error: 'Failed to schedule submission' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { verseSubmissions, dailyVerses } from '$lib/server/db/schema';
|
||||
import { eq, desc, asc } from 'drizzle-orm';
|
||||
import { getCooldownState, todayUtcStr } from '$lib/server/verse-submission';
|
||||
|
||||
/**
|
||||
* GET /api/submit-verse/status?localDate=YYYY-MM-DD
|
||||
*
|
||||
* Returns the state needed to render the win-screen submit button:
|
||||
* whether the user can submit, the active cooldown (if any), their most
|
||||
* recent submission, and their not-yet-reached upcoming submissions.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const userId = locals.user.id;
|
||||
|
||||
const cooldown = await getCooldownState(db, userId);
|
||||
|
||||
// Most recent submission (by submitted_at) — joined to daily_verses for the
|
||||
// canonical reference/window text that will actually play on its day.
|
||||
const [lastRow] = await db
|
||||
.select({
|
||||
scheduledDate: verseSubmissions.scheduledDate,
|
||||
reference: dailyVerses.reference
|
||||
})
|
||||
.from(verseSubmissions)
|
||||
.leftJoin(dailyVerses, eq(verseSubmissions.scheduledDate, dailyVerses.date))
|
||||
.where(eq(verseSubmissions.userId, userId))
|
||||
.orderBy(desc(verseSubmissions.submittedAt))
|
||||
.limit(1);
|
||||
|
||||
const lastSubmission = lastRow
|
||||
? {
|
||||
scheduledDate: lastRow.scheduledDate,
|
||||
reference: lastRow.reference
|
||||
}
|
||||
: null;
|
||||
|
||||
// Upcoming = this user's submissions whose scheduled date hasn't been
|
||||
// reached yet (server UTC today).
|
||||
const today = todayUtcStr();
|
||||
const upcomingRows = await db
|
||||
.select({
|
||||
scheduledDate: verseSubmissions.scheduledDate,
|
||||
reference: dailyVerses.reference
|
||||
})
|
||||
.from(verseSubmissions)
|
||||
.leftJoin(dailyVerses, eq(verseSubmissions.scheduledDate, dailyVerses.date))
|
||||
.where(eq(verseSubmissions.userId, userId))
|
||||
.orderBy(asc(verseSubmissions.scheduledDate));
|
||||
|
||||
const upcoming = upcomingRows
|
||||
.filter((r) => r.scheduledDate > today)
|
||||
.map((r) => ({
|
||||
scheduledDate: r.scheduledDate,
|
||||
reference: r.reference
|
||||
}));
|
||||
|
||||
return json({
|
||||
canSubmit: !cooldown.onCooldown,
|
||||
cooldownEndsAt: cooldown.cooldownEndsAt,
|
||||
lastSubmission,
|
||||
upcoming
|
||||
});
|
||||
};
|
||||
@@ -131,7 +131,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
// Create session
|
||||
const sessionToken = auth.generateSessionToken();
|
||||
const session = await auth.createSession(sessionToken, userId);
|
||||
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt);
|
||||
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
|
||||
|
||||
redirect(302, '/');
|
||||
};
|
||||
@@ -125,7 +125,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
// Create session
|
||||
const sessionToken = auth.generateSessionToken();
|
||||
const session = await auth.createSession(sessionToken, userId);
|
||||
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt);
|
||||
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
|
||||
|
||||
redirect(302, '/');
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import AuthModal from '$lib/components/AuthModal.svelte';
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import { bibleBooks } from '$lib/types/bible';
|
||||
@@ -35,6 +36,41 @@
|
||||
let filter = $state<'all' | 'upcoming' | 'past'>('all');
|
||||
const filters = ['all', 'upcoming', 'past'] as const;
|
||||
|
||||
// Dev-only seed button (calls POST /api/dev/seed-submission, which is
|
||||
// host-gated to localhost:5173 / test.bibdle.com). Hidden in prod.
|
||||
const isDevHost = $derived(
|
||||
browser &&
|
||||
['localhost:5173', 'test.bibdle.com'].includes(window.location.host)
|
||||
);
|
||||
let seeding = $state(false);
|
||||
let seedMessage = $state<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
async function seedSubmission() {
|
||||
seeding = true;
|
||||
seedMessage = null;
|
||||
try {
|
||||
const res = await fetch('/api/dev/seed-submission', { method: 'POST' });
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
seedMessage = {
|
||||
ok: false,
|
||||
text: body?.error ?? `Failed (${res.status})`
|
||||
};
|
||||
} else {
|
||||
seedMessage = {
|
||||
ok: true,
|
||||
text: `Seeded ${body?.scheduledDate ?? ''} — ${body?.reference ?? ''}`
|
||||
};
|
||||
// Reload server load data so the new row appears in the table.
|
||||
await invalidateAll();
|
||||
}
|
||||
} catch (err) {
|
||||
seedMessage = { ok: false, text: String(err) };
|
||||
} finally {
|
||||
seeding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateAnonymousId(): string {
|
||||
if (!browser) return '';
|
||||
const key = 'bibdle-anonymous-id';
|
||||
@@ -148,22 +184,45 @@
|
||||
{: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}
|
||||
<div class="flex items-center gap-2 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>
|
||||
|
||||
{#if isDevHost}
|
||||
<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'}"
|
||||
onclick={seedSubmission}
|
||||
disabled={seeding}
|
||||
class="px-3 py-1.5 rounded-md text-xs font-medium border border-white/10 bg-white/5 text-gray-200 hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
{seeding ? 'Seeding…' : '+ Seed test submission'}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if seedMessage}
|
||||
<span
|
||||
class="text-xs {seedMessage.ok
|
||||
? 'text-emerald-400'
|
||||
: 'text-red-400'}"
|
||||
>
|
||||
{seedMessage.text}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-xs text-gray-500">
|
||||
{filteredRows.length} {filteredRows.length === 1 ? 'row' : 'rows'}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">
|
||||
{filteredRows.length} {filteredRows.length === 1 ? 'row' : 'rows'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if filteredRows.length === 0}
|
||||
|
||||
Reference in new issue
Block a user