mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-08-22 22:32:28 -04:00
New scheduling UI!
This commit is contained in:
@@ -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