Compare commits
4 Commits
e45ac28169
...
cards
| Author | SHA1 | Date | |
|---|---|---|---|
| a372db9b2c | |||
| 1c2f214963 | |||
| f7efe6738d | |||
| f5e16c7e71 |
@@ -0,0 +1,49 @@
|
||||
<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>
|
||||
@@ -0,0 +1,49 @@
|
||||
<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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.ico";
|
||||
@@ -11,15 +11,18 @@
|
||||
|
||||
onMount(() => {
|
||||
isDev =
|
||||
window.location.host === 'localhost:5173' ||
|
||||
window.location.host === 'test.bibdle.com';
|
||||
window.location.host === "localhost:5173" ||
|
||||
window.location.host === "test.bibdle.com";
|
||||
|
||||
// Inject analytics script
|
||||
const script = document.createElement('script');
|
||||
const script = document.createElement("script");
|
||||
script.defer = true;
|
||||
script.src = 'https://umami.snail.city/script.js';
|
||||
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
|
||||
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
|
||||
script.src = "https://umami.snail.city/script.js";
|
||||
script.setAttribute(
|
||||
"data-website-id",
|
||||
"5b8c31ad-71cd-4317-940b-6bccea732acc",
|
||||
);
|
||||
script.setAttribute("data-domains", "bibdle.com,www.bibdle.com");
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
|
||||
@@ -28,11 +31,18 @@
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title="Bibdle RSS Feed"
|
||||
href="/feed.xml"
|
||||
/>
|
||||
<meta name="description" content="A daily Bible game" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950">
|
||||
<div
|
||||
class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950"
|
||||
>
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 dark:text-gray-300 drop-shadow-2xl tracking-widest p-4 pt-12 animate-fade-in-up"
|
||||
>
|
||||
@@ -41,6 +51,8 @@
|
||||
</h1>
|
||||
{#if isDev}
|
||||
<div class="flex justify-center pb-2"><ThemeToggle /></div>
|
||||
{:else}
|
||||
<div class="justify-center hidden pb-2"><ThemeToggle /></div>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<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>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { SvelteDate } from "svelte/reactivity";
|
||||
import { onMount } from "svelte";
|
||||
import AuthModal from "$lib/components/AuthModal.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 {
|
||||
const d = new Date(dateStr + "T00:00:00Z");
|
||||
return d.toLocaleDateString("en-US", {
|
||||
@@ -514,8 +523,15 @@
|
||||
<div class="mb-6">
|
||||
<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">
|
||||
{#each prog.milestones.filter(m => m.achieved) as milestone (milestone.id)}
|
||||
<Container class="p-3 min-h-[130px]">
|
||||
{#each prog.milestones
|
||||
.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-2xl mb-1">{milestone.emoji}</div>
|
||||
<div class="text-sm font-bold text-yellow-300 leading-tight mb-1">
|
||||
@@ -625,3 +641,17 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 5.4 MiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 4.3 MiB |
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,42 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
||||
|
||||
# done
|
||||
|
||||
## march 25th
|
||||
|
||||
- Added Sign In with Google (OAuth)
|
||||
- Added Google sign-in button to win screen and footer provider label
|
||||
- Added rainbow glow effect
|
||||
|
||||
## march 24th
|
||||
|
||||
- Added achievements system, hint overlay, and progress page polish
|
||||
|
||||
## march 23rd
|
||||
|
||||
- Extracted CollapsibleTable component and fixed show more behavior
|
||||
|
||||
## march 22nd
|
||||
|
||||
- Added `/api/send-daily-verse` endpoint for daily Discord verse posting
|
||||
- Improved guesses collapse timing, win screen CTA, and progress page polish
|
||||
- Fixed Discord message format (italic date + bold verse)
|
||||
|
||||
## march 21st
|
||||
|
||||
- Added progress page with activity calendar, book grid, and insights
|
||||
|
||||
## march 19th
|
||||
|
||||
- Added Discord link and shrunk guesses grid for more than three guesses
|
||||
- Added MAU section with projection to global stats
|
||||
- Added survival curve metrics and table minimizing to global stats
|
||||
|
||||
## march 15th–16th
|
||||
|
||||
- Fixed instructions, added color border based on closeness between guess and target
|
||||
- Added return rate and retention metrics to global stats
|
||||
- Added WAU history table, fixed retention metric, added new logos and favicon
|
||||
|
||||
## march 14th
|
||||
|
||||
- Added /global public dashboard with 8 stat cards: completions today, all-time, unique players, players this week, active streaks, avg guesses today, registered users, avg completions per player
|
||||
|
||||