mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-06-25 08:45:22 -04:00
Add card components, cards route, xml-bible tests, and progress page update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
<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 observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
fanned = entry.isIntersecting;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "-40% 0px -40% 0px", threshold: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(node);
|
||||||
|
|
||||||
|
const onEnter = () => (fanned = true);
|
||||||
|
const onLeave = () => (fanned = false);
|
||||||
|
|
||||||
|
node.addEventListener("mouseenter", onEnter);
|
||||||
|
node.addEventListener("mouseleave", onLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
node.removeEventListener("mouseenter", onEnter);
|
||||||
|
node.removeEventListener("mouseleave", onLeave);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
{@attach cardDeck}
|
||||||
|
class="relative h-64 w-48 cursor-pointer"
|
||||||
|
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(85px) rotate(5deg)" : "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(-85px) rotate(-5deg)" : "rotate(2deg)"}
|
||||||
|
style:z-index="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FrontBack from "$lib/components/cards/FrontBack.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>BIBDLE Cards</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="text-center pb-72">Collectible Bible Verse Trading Cards</div>
|
||||||
|
<div class="min-h-dvh py-10 px-4">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<FrontBack
|
||||||
|
front="/cards/Esther_4_front.png"
|
||||||
|
back="/cards/Esther_4_back.png"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<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";
|
||||||
@@ -98,6 +99,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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", {
|
||||||
@@ -514,8 +523,15 @@
|
|||||||
<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.filter(m => m.achieved) as milestone (milestone.id)}
|
{#each prog.milestones
|
||||||
<Container class="p-3 min-h-[130px]">
|
.filter(m => m.achieved)
|
||||||
|
.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">
|
||||||
@@ -625,3 +641,17 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user