1 Commits

Author SHA1 Message Date
George Powell e3ca264c54 new email link 2026-05-26 17:59:56 -04:00
14 changed files with 3 additions and 324 deletions
+1 -1
View File
@@ -33,7 +33,7 @@
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div> --> <div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div> -->
<a <a
href="mailto:george+bibdle@silentsummit.co" href="mailto:george@snail.city"
class="inline-flex hover:opacity-80 transition-opacity" class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Send email" aria-label="Send email"
data-umami-event="Email clicked" data-umami-event="Email clicked"
-49
View File
@@ -1,49 +0,0 @@
<script lang="ts">
interface Card {
front: string;
back: string;
}
interface Props {
cards: Card[];
fanDelay?: number;
}
let { cards, fanDelay = 2500 }: Props = $props();
// Extract 4 cards (or fewer if not enough provided)
let displayCards = $derived(cards.slice(0, 4));
let totalCards = $derived(displayCards.length);
let fanned = $state(false);
$effect(() => {
const timer = setTimeout(() => {
fanned = true;
}, fanDelay);
return () => clearTimeout(timer);
});
</script>
<div class="relative h-64 w-96">
<!-- Cards start piled on left, fan out to right -->
{#each displayCards as card, i (i)}
{@const fanOffset = (totalCards - 1 - i) * 75}
<div
class="absolute inset-0 flex items-center justify-center transition-all duration-700 ease-out"
style:transform={fanned
? `translateX(${fanOffset-100}px) rotate(${8 + i * (-16 / (totalCards - 1))}deg)`
: "translateX(-100px) rotate(-8deg)"}
style:z-index={totalCards - i}
style:transition-delay={fanned ? `${i * 100}ms` : "0ms"}
>
<img
src={card.front}
alt="Card {i + 1}"
class="max-h-64 w-auto object-contain drop-shadow-lg"
/>
</div>
{/each}
</div>
-49
View File
@@ -1,49 +0,0 @@
<script lang="ts">
import type { Attachment } from "svelte/attachments";
interface Props {
front: string;
back: string;
}
let { front, back }: Props = $props();
let fanned = $state(false);
const cardDeck: Attachment<HTMLDivElement> = (node) => {
const check = () => {
const rect = node.getBoundingClientRect();
const cardCenter = rect.top + rect.height / 2;
fanned = cardCenter <= window.innerHeight / 2;
};
window.addEventListener("scroll", check, { passive: true });
check();
return () => window.removeEventListener("scroll", check);
};
</script>
<div
{@attach cardDeck}
class="relative h-64 w-48"
role="img"
aria-label="Card deck"
>
<!-- Back card -->
<img
src={back}
alt="Back"
class="absolute inset-0 max-h-64 w-full object-contain drop-shadow-md transition-all duration-500 ease-in-out"
style:transform={fanned ? "translateX(90px) rotate(4deg)" : "rotate(-2deg)"}
style:z-index="1"
/>
<!-- Front card -->
<img
src={front}
alt="Front"
class="absolute inset-0 max-h-64 w-full object-contain drop-shadow-md transition-all duration-500 ease-in-out"
style:transform={fanned ? "translateX(-90px) rotate(-4deg)" : "rotate(2deg)"}
style:z-index="2"
/>
</div>
-35
View File
@@ -1,35 +0,0 @@
<script lang="ts">
import FrontBack from "$lib/components/cards/FrontBack.svelte";
import CardFan from "$lib/components/cards/CardFan.svelte";
const sampleCards = [
{ front: "/cards/1_Corinthians_13_front.png", back: "/cards/1_Corinthians_13_back.png" },
{ front: "/cards/Esther_4_front.png", back: "/cards/Esther_4_back.png" },
{ front: "/cards/Psalms_front.png", back: "/cards/Psalms_back.png" },
{ front: "/cards/Revelation_12_13_15_front.png", back: "/cards/Revelation_12_13_15_back.png" }
];
</script>
<svelte:head>
<title>BIBDLE Cards</title>
</svelte:head>
<div class="text-center pb-96">Collectible Bible Verse Trading Cards</div>
<div class="min-h-dvh py-10 px-4 overflow-x-hidden">
<div class="max-w-3xl mx-auto">
<h2 class="text-xl font-semibold mb-6">Card Fan Demo</h2>
<p class="text-gray-400 mb-4">Cards will fan out after a few seconds...</p>
<div class="flex justify-center mb-96">
<CardFan cards={sampleCards} />
</div>
<div class="flex justify-center mb-96">
<FrontBack
front="/cards/Esther_4_front.png"
back="/cards/Esther_4_back.png"
/>
</div>
</div>
</div>
+2 -32
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { SvelteDate } from "svelte/reactivity";
import { onMount } from "svelte"; import { onMount } from "svelte";
import AuthModal from "$lib/components/AuthModal.svelte"; import AuthModal from "$lib/components/AuthModal.svelte";
import Container from "$lib/components/Container.svelte"; import Container from "$lib/components/Container.svelte";
@@ -99,14 +98,6 @@
} }
} }
function isRecent(dateStr: string | null): boolean {
if (!dateStr || !browser) return false;
const sevenDaysAgo = new SvelteDate();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const achievedDate = new Date(dateStr + "T00:00:00Z");
return achievedDate >= sevenDaysAgo;
}
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00Z"); const d = new Date(dateStr + "T00:00:00Z");
return d.toLocaleDateString("en-US", { return d.toLocaleDateString("en-US", {
@@ -523,15 +514,8 @@
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-bold text-gray-100 mb-3">🏆 Achievements</h2> <h2 class="text-xl font-bold text-gray-100 mb-3">🏆 Achievements</h2>
<div class="grid grid-cols-3 md:grid-cols-4 gap-2 md:gap-3"> <div class="grid grid-cols-3 md:grid-cols-4 gap-2 md:gap-3">
{#each prog.milestones {#each prog.milestones.filter(m => m.achieved) as milestone (milestone.id)}
.filter(m => m.achieved) <Container class="p-3 min-h-[130px]">
.sort((a, b) => {
if (!a.achievedDate && !b.achievedDate) return 0;
if (!a.achievedDate) return 1;
if (!b.achievedDate) return -1;
return a.achievedDate.localeCompare(b.achievedDate);
}) as milestone (milestone.id)}
<Container class="p-3 min-h-[130px] {isRecent(milestone.achievedDate) ? 'recent-achievement' : ''}">
<div class="text-center flex flex-col items-center justify-center h-full"> <div class="text-center flex flex-col items-center justify-center h-full">
<div class="text-2xl mb-1">{milestone.emoji}</div> <div class="text-2xl mb-1">{milestone.emoji}</div>
<div class="text-sm font-bold text-yellow-300 leading-tight mb-1"> <div class="text-sm font-bold text-yellow-300 leading-tight mb-1">
@@ -641,17 +625,3 @@
</div> </div>
<AuthModal bind:isOpen={authModalOpen} {anonymousId} /> <AuthModal bind:isOpen={authModalOpen} {anonymousId} />
<style>
@keyframes breathe-glow {
0%, 100% {
box-shadow: 0 0 6px 2px rgba(251, 146, 60, 0.3);
}
50% {
box-shadow: 0 0 18px 6px rgba(251, 146, 60, 0.65);
}
}
:global(.recent-achievement) {
animation: breathe-glow 2.5s ease-in-out infinite;
}
</style>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

-158
View File
@@ -1,158 +0,0 @@
import { describe, test, expect } from "bun:test";
import {
getRandomVerses,
getRandomVersesFromBook,
extractVerses,
formatReference,
getAllNKJVVerses,
} from "$lib/server/xml-bible";
import { bibleBooks } from "$lib/server/bible";
// Build a map of bookName → chapters → verse numbers from the full XML dump.
// This is the ground truth of what the XML actually contains.
function buildVerseMap(): Map<string, Map<number, number[]>> {
const allVerses = getAllNKJVVerses();
const map = new Map<string, Map<number, number[]>>();
for (const { book, chapter, verse } of allVerses) {
if (!map.has(book)) map.set(book, new Map());
const chapters = map.get(book)!;
if (!chapters.has(chapter)) chapters.set(chapter, []);
chapters.get(chapter)!.push(verse);
}
return map;
}
const verseMap = buildVerseMap();
// ─── 1. XML data completeness ────────────────────────────────────────────────
describe("XML data completeness — all 66 books present", () => {
test("getAllNKJVVerses returns a non-empty array", () => {
const verses = getAllNKJVVerses();
expect(verses.length).toBeGreaterThan(30_000); // NKJV has ~31,102 verses
});
test("every Bible book appears at least once", () => {
const booksInXml = new Set(verseMap.keys());
const missing: string[] = [];
for (const book of bibleBooks) {
if (!booksInXml.has(book.name)) missing.push(book.name);
}
expect(missing).toEqual([]);
});
test("no book has zero chapters", () => {
for (const book of bibleBooks) {
const chapters = verseMap.get(book.name);
expect(chapters).toBeDefined();
expect(chapters!.size).toBeGreaterThan(0);
}
});
test("no chapter has zero verses", () => {
for (const [bookName, chapters] of verseMap) {
for (const [chapterNum, verses] of chapters) {
expect(verses.length).toBeGreaterThan(0);
}
}
});
test("verse numbers within each chapter are sequential with no gaps", () => {
const gaps: string[] = [];
for (const [bookName, chapters] of verseMap) {
for (const [chapterNum, verses] of chapters) {
const sorted = [...verses].sort((a, b) => a - b);
for (let i = 0; i < sorted.length; i++) {
if (sorted[i] !== i + 1) {
gaps.push(`${bookName} ${chapterNum}: expected verse ${i + 1}, got ${sorted[i]}`);
}
}
}
}
expect(gaps).toEqual([]);
});
});
// ─── 2. Every book can be returned as a daily verse ──────────────────────────
describe("getRandomVersesFromBook — every book can produce a daily verse", () => {
for (let bookNumber = 1; bookNumber <= 66; bookNumber++) {
const book = bibleBooks.find((b) => b.order === bookNumber)!;
test(`book ${bookNumber} (${book.name}) returns 3 consecutive verses`, () => {
const result = getRandomVersesFromBook(bookNumber, 3);
expect(result).not.toBeNull();
expect(result!.bookId).toBe(book.id);
expect(result!.bookName).toBe(book.name);
expect(result!.chapter).toBeGreaterThan(0);
expect(result!.startVerse).toBeGreaterThan(0);
expect(result!.endVerse).toBe(result!.startVerse + 2);
expect(result!.verses).toHaveLength(3);
for (const verse of result!.verses) {
expect(verse.length).toBeGreaterThan(0);
}
});
}
});
// ─── 3. Every chapter with ≥ 3 verses is reachable via extractVerses ─────────
describe("extractVerses — every eligible chapter is reachable", () => {
for (let bookNumber = 1; bookNumber <= 66; bookNumber++) {
const book = bibleBooks.find((b) => b.order === bookNumber)!;
const chapters = verseMap.get(book.name);
if (!chapters) continue;
for (const [chapterNum, verses] of chapters) {
if (verses.length < 3) continue; // getRandomVerses skips these — that's expected
test(`${book.name} ${chapterNum} (${verses.length} verses) — extractVerses returns 3`, () => {
const result = extractVerses(bookNumber, chapterNum, 1, 3);
expect(result).toHaveLength(3);
for (const v of result) {
expect(v.length).toBeGreaterThan(0);
}
});
}
}
});
// ─── 4. getRandomVerses (the actual daily-verse function) ────────────────────
describe("getRandomVerses", () => {
test("returns a well-formed result", () => {
const result = getRandomVerses(3);
expect(result).not.toBeNull();
expect(bibleBooks.some((b) => b.id === result!.bookId)).toBe(true);
expect(result!.chapter).toBeGreaterThan(0);
expect(result!.startVerse).toBeGreaterThan(0);
expect(result!.endVerse).toBe(result!.startVerse + 2);
expect(result!.verses).toHaveLength(3);
for (const v of result!.verses) {
expect(v.length).toBeGreaterThan(0);
}
});
test("bookId is always a known Bible book", () => {
const knownIds = new Set(bibleBooks.map((b) => b.id));
// Run several times to increase confidence
for (let i = 0; i < 20; i++) {
const result = getRandomVerses(3);
expect(result).not.toBeNull();
expect(knownIds.has(result!.bookId)).toBe(true);
}
});
});
// ─── 5. formatReference ──────────────────────────────────────────────────────
describe("formatReference", () => {
test("single verse: Book C:V", () => {
expect(formatReference("Genesis", 1, 1, 1)).toBe("Genesis 1:1");
});
test("verse range: Book C:V1-V2", () => {
expect(formatReference("Matthew", 5, 3, 5)).toBe("Matthew 5:3-5");
});
});