Merge branch 'main' into auth

Brought in latest changes from main including:
- RSS feed implementation
- First letter edge case fixes
- Updated ranking formula

Resolved conflicts by:
- Combining .env.example variables from both branches
- Keeping auth version (3.0.0alpha)
- Preserving extended user schema from auth
- Keeping onMount umami approach and adding RSS feed link

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
George Powell
2026-02-09 12:03:31 -05:00
15 changed files with 516 additions and 242 deletions

View File

@@ -21,11 +21,6 @@
<svelte:head>
<link rel="icon" href={favicon} />
<!-- <script
defer
src="https://umami.snail.city/script.js"
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
data-domains="bibdle.com,www.bibdle.com"
></script> -->
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
</svelte:head>
{@render children()}

View File

@@ -93,13 +93,20 @@ export const actions: Actions = {
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
const guessRank = betterGuesses + 1;
// Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
// Average guesses
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
// Percentile: what percentage of people you beat (100 - your rank percentage)
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
return {
success: true,
stats: { solveRank, guessRank, totalSolves, averageGuesses }
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
};
}
};

View File

@@ -87,6 +87,11 @@
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
}
function getFirstLetter(bookName: string): string {
const match = bookName.match(/[a-zA-Z]/);
return match ? match[0] : bookName[0];
}
function submitGuess(bookId: string) {
if (guesses.some((g) => g.book.id === bookId)) return;
@@ -103,15 +108,19 @@
// Special case: if correct book is in the Epistles + starts with "1",
// any guess starting with "1" counts as first letter match
const correctIsEpistlesWithNumber =
correctBook.section === "Pauline Epistles" &&
(correctBook.section === "Pauline Epistles" ||
correctBook.section === "General Epistles") &&
correctBook.name[0] === "1";
const guessStartsWithNumber = book.name[0] === "1";
const guessIsEpistlesWithNumber =
(book.section === "Pauline Epistles" ||
book.section === "General Epistles") &&
book.name[0] === "1";
const firstLetterMatch =
correctIsEpistlesWithNumber && guessStartsWithNumber
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
? true
: book.name[0].toUpperCase() ===
correctBook.name[0].toUpperCase();
: getFirstLetter(book.name).toUpperCase() ===
getFirstLetter(correctBook.name).toUpperCase();
console.log(
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
@@ -215,15 +224,19 @@
// Apply same first letter logic as in submitGuess
const correctIsEpistlesWithNumber =
correctBook.section === "Pauline Epistles" &&
(correctBook.section === "Pauline Epistles" ||
correctBook.section === "General Epistles") &&
correctBook.name[0] === "1";
const guessStartsWithNumber = book.name[0] === "1";
const guessIsEpistlesWithNumber =
(book.section === "Pauline Epistles" ||
book.section === "General Epistles") &&
book.name[0] === "1";
const firstLetterMatch =
correctIsEpistlesWithNumber && guessStartsWithNumber
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
? true
: book.name[0].toUpperCase() ===
correctBook.name[0].toUpperCase();
: getFirstLetter(book.name).toUpperCase() ===
getFirstLetter(correctBook.name).toUpperCase();
return {
book,
@@ -433,7 +446,6 @@
</script>
<svelte:head>
<!-- <title>Bibdle &mdash; A daily bible game{isDev ? " (dev)" : ""}</title> -->
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
<!-- <meta
name="description"

View File

@@ -44,11 +44,9 @@ export const POST: RequestHandler = async ({ request }) => {
// Solve rank: position in time-ordered list
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
const uniqueBetterGuessCounts = new Set(
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
);
const guessRank = uniqueBetterGuessCounts.size + 1;
// Guess rank: count how many had FEWER guesses (ties get same rank)
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
const guessRank = betterGuesses + 1;
// Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
@@ -110,11 +108,9 @@ export const GET: RequestHandler = async ({ url }) => {
// Solve rank: position in time-ordered list
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
const uniqueBetterGuessCounts = new Set(
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
);
const guessRank = uniqueBetterGuessCounts.size + 1;
// Guess rank: count how many had FEWER guesses (ties get same rank)
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
const guessRank = betterGuesses + 1;
// Count ties: how many have the SAME guessCount (excluding self)
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;

View File

@@ -0,0 +1,145 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyVerses } from '$lib/server/db/schema';
import { desc } from 'drizzle-orm';
// Helper: Escape XML special characters
function escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// Helper: Format YYYY-MM-DD to RFC 822 date string
function formatRFC822(dateStr: string): string {
// Parse date in America/New_York timezone (EST/EDT)
// Assuming midnight ET
const date = new Date(dateStr + 'T00:00:00-05:00');
return date.toUTCString().replace('GMT', 'EST');
}
// Helper: Format YYYY-MM-DD to readable date
function formatReadableDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'America/New_York'
});
}
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
function formatVerseText(text: string): string {
let formatted = text;
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
const pairs: [string, string][] = [
['(', ')'],
['[', ']'],
['{', '}'],
['"', '"'],
["'", "'"],
['\u201C', '\u201D'], // " "
['\u2018', '\u2019'] // ' '
];
// Check if text starts with opening punctuation without closing
for (const [open, close] of pairs) {
if (formatted.startsWith(open) && !formatted.includes(close)) {
formatted += '...' + close;
break;
}
}
// Check if text ends with closing punctuation without opening
for (const [open, close] of pairs) {
if (formatted.endsWith(close) && !formatted.includes(open)) {
formatted = open + '...' + formatted;
break;
}
}
// Check if text contains unbalanced opening quotes (not at start) without closing
for (const [open, close] of pairs) {
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
if (openCount > closeCount) {
formatted += close;
break;
}
}
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
// Replace trailing punctuation with ellipsis
// Preserve closing quotes/brackets that may have been added
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
return formatted;
}
export const GET: RequestHandler = async ({ request }) => {
try {
// Query last 30 verses, ordered by date descending
const verses = await db
.select()
.from(dailyVerses)
.orderBy(desc(dailyVerses.date))
.limit(30);
// Generate ETag based on latest verse date
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
// Check if client has cached version
if (request.headers.get('If-None-Match') === etag) {
return new Response(null, { status: 304 });
}
// Get site URL from environment or use default
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
// Build RSS XML
const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
const items = verses
.map(
(verse) => `
<item>
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
<description>${escapeXml(formatVerseText(verse.verseText))}</description>
<link>${SITE_URL}</link>
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
<pubDate>${formatRFC822(verse.date)}</pubDate>
</item>`
)
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Bibdle</title>
<link>${SITE_URL}</link>
<description>A daily Bible game</description>
<language>en-us</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<ttl>720</ttl>${items}
</channel>
</rss>`;
return new Response(xml, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
ETag: etag
}
});
} catch (error) {
console.error('RSS feed generation error:', error);
return new Response('Internal Server Error', { status: 500 });
}
};