mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
4 Commits
592fa917cd
...
a188be167b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a188be167b | ||
|
|
e550965086 | ||
|
|
03429b17cc | ||
|
|
3ee7331510 |
@@ -6,14 +6,14 @@
|
||||
|
||||
let seeding = $state(false);
|
||||
|
||||
async function seedHistory() {
|
||||
async function seedHistory(days: number = 10) {
|
||||
if (!browser || !anonymousId || seeding) return;
|
||||
seeding = true;
|
||||
try {
|
||||
const response = await fetch("/api/dev/seed-history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ anonymousId })
|
||||
body: JSON.stringify({ anonymousId, days })
|
||||
});
|
||||
const result = await response.json();
|
||||
alert(
|
||||
@@ -113,7 +113,15 @@
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={seedHistory}
|
||||
onclick={() => seedHistory(1)}
|
||||
disabled={seeding}
|
||||
class="w-full py-4 md:py-2"
|
||||
>
|
||||
{seeding ? "Seeding..." : "Add 1 Day of History"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={() => seedHistory(10)}
|
||||
disabled={seeding}
|
||||
class="w-full py-4 md:py-2"
|
||||
>
|
||||
|
||||
@@ -79,7 +79,7 @@ export function getNextGradeMessage(numGuesses: number): string {
|
||||
}
|
||||
|
||||
export function toOrdinal(n: number): string {
|
||||
if (n >= 11 && n <= 13) {
|
||||
if (n % 100 >= 11 && n % 100 <= 13) {
|
||||
return `${n}th`;
|
||||
}
|
||||
const mod = n % 10;
|
||||
|
||||
@@ -19,7 +19,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const { anonymousId } = await request.json();
|
||||
const { anonymousId, days = 10 } = await request.json();
|
||||
|
||||
if (!anonymousId || typeof anonymousId !== 'string') {
|
||||
return json({ error: 'anonymousId required' }, { status: 400 });
|
||||
@@ -29,7 +29,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
const inserted: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
for (let i = 1; i <= days; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const date = d.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
|
||||
@@ -32,5 +32,5 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
return json({ streak });
|
||||
return json({ streak: streak < 2 ? 0 : streak });
|
||||
};
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import { enhance } from "$app/forms";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
import {
|
||||
getGradeColor,
|
||||
formatDate,
|
||||
getStreakMessage,
|
||||
getPerformanceMessage,
|
||||
type UserStats,
|
||||
} from "$lib/utils/stats";
|
||||
|
||||
@@ -44,10 +39,6 @@
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function getGradePercentage(count: number, total: number): number {
|
||||
return total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
}
|
||||
|
||||
function getBookName(bookId: string): string {
|
||||
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
||||
}
|
||||
@@ -333,82 +324,6 @@
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<!-- Grade Distribution -->
|
||||
<Container class="p-5 md:p-6 mb-6">
|
||||
<h2 class="text-lg md:text-xl font-bold text-gray-100 mb-4">
|
||||
Grade Distribution
|
||||
</h2>
|
||||
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 md:gap-3">
|
||||
{#each Object.entries(stats.gradeDistribution) as [grade, count] (grade)}
|
||||
{@const percentage = getGradePercentage(
|
||||
count,
|
||||
stats.totalSolves,
|
||||
)}
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<span
|
||||
class="inline-block px-2 md:px-3 py-1 rounded-full text-xs md:text-sm font-semibold {getGradeColor(
|
||||
grade,
|
||||
)}"
|
||||
>
|
||||
{grade}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-lg md:text-2xl font-bold text-gray-100"
|
||||
>
|
||||
{count}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
{percentage}%
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Recent Performance -->
|
||||
{#if stats.recentCompletions.length > 0}
|
||||
<Container class="p-5 md:p-6">
|
||||
<h2
|
||||
class="text-lg md:text-xl font-bold text-gray-100 mb-4"
|
||||
>
|
||||
Recent Performance
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
{#each stats.recentCompletions as completion, idx (`${completion.date}-${idx}`)}
|
||||
<div
|
||||
class="flex justify-between items-center py-2 border-b border-white/10 last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="text-sm md:text-base font-medium text-gray-200"
|
||||
>{formatDate(completion.date)}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 md:gap-3"
|
||||
>
|
||||
<span
|
||||
class="text-xs md:text-sm text-gray-300"
|
||||
>{completion.guessCount} guess{completion.guessCount ===
|
||||
1
|
||||
? ""
|
||||
: "es"}</span
|
||||
>
|
||||
<span
|
||||
class="px-2 py-0.5 md:py-1 rounded text-xs md:text-sm font-semibold {getGradeColor(
|
||||
completion.grade,
|
||||
)}"
|
||||
>
|
||||
{completion.grade}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
234
tests/bible.test.ts
Normal file
234
tests/bible.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
getBookById,
|
||||
getBookByNumber,
|
||||
getBooksByTestament,
|
||||
getBooksBySection,
|
||||
isAdjacent,
|
||||
bookNumberToId,
|
||||
bookIdToNumber,
|
||||
bibleBooks,
|
||||
} from "$lib/server/bible";
|
||||
|
||||
describe("bibleBooks data integrity", () => {
|
||||
test("contains exactly 66 books", () => {
|
||||
expect(bibleBooks).toHaveLength(66);
|
||||
});
|
||||
|
||||
test("order numbers are 1 through 66 with no gaps or duplicates", () => {
|
||||
const orders = bibleBooks.map((b) => b.order).sort((a, b) => a - b);
|
||||
for (let i = 0; i < 66; i++) {
|
||||
expect(orders[i]).toBe(i + 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("book IDs are unique", () => {
|
||||
const ids = bibleBooks.map((b) => b.id);
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(66);
|
||||
});
|
||||
|
||||
test("every book has a non-empty name", () => {
|
||||
for (const book of bibleBooks) {
|
||||
expect(book.name.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("Old Testament has 39 books", () => {
|
||||
expect(bibleBooks.filter((b) => b.testament === "old")).toHaveLength(39);
|
||||
});
|
||||
|
||||
test("New Testament has 27 books", () => {
|
||||
expect(bibleBooks.filter((b) => b.testament === "new")).toHaveLength(27);
|
||||
});
|
||||
|
||||
test("Genesis is first and Revelation is last", () => {
|
||||
const sorted = [...bibleBooks].sort((a, b) => a.order - b.order);
|
||||
expect(sorted[0].id).toBe("GEN");
|
||||
expect(sorted[65].id).toBe("REV");
|
||||
});
|
||||
|
||||
test("Matthew is the first New Testament book", () => {
|
||||
const nt = bibleBooks
|
||||
.filter((b) => b.testament === "new")
|
||||
.sort((a, b) => a.order - b.order);
|
||||
expect(nt[0].id).toBe("MAT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("section counts", () => {
|
||||
test("Law: 5 books", () => {
|
||||
expect(getBooksBySection("Law")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("History: 13 books (12 OT + Acts)", () => {
|
||||
expect(getBooksBySection("History")).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("Wisdom: 5 books", () => {
|
||||
expect(getBooksBySection("Wisdom")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("Major Prophets: 5 books", () => {
|
||||
expect(getBooksBySection("Major Prophets")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("Minor Prophets: 12 books", () => {
|
||||
expect(getBooksBySection("Minor Prophets")).toHaveLength(12);
|
||||
});
|
||||
|
||||
test("Gospels: 4 books", () => {
|
||||
expect(getBooksBySection("Gospels")).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("Pauline Epistles: 13 books", () => {
|
||||
expect(getBooksBySection("Pauline Epistles")).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("General Epistles: 8 books", () => {
|
||||
expect(getBooksBySection("General Epistles")).toHaveLength(8);
|
||||
});
|
||||
|
||||
test("Apocalyptic: 1 book (Revelation)", () => {
|
||||
const books = getBooksBySection("Apocalyptic");
|
||||
expect(books).toHaveLength(1);
|
||||
expect(books[0].id).toBe("REV");
|
||||
});
|
||||
|
||||
test("all sections sum to 66", () => {
|
||||
const total =
|
||||
getBooksBySection("Law").length +
|
||||
getBooksBySection("History").length +
|
||||
getBooksBySection("Wisdom").length +
|
||||
getBooksBySection("Major Prophets").length +
|
||||
getBooksBySection("Minor Prophets").length +
|
||||
getBooksBySection("Gospels").length +
|
||||
getBooksBySection("Pauline Epistles").length +
|
||||
getBooksBySection("General Epistles").length +
|
||||
getBooksBySection("Apocalyptic").length;
|
||||
expect(total).toBe(66);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBookById", () => {
|
||||
test("returns Genesis for GEN", () => {
|
||||
const book = getBookById("GEN");
|
||||
expect(book).toBeDefined();
|
||||
expect(book!.name).toBe("Genesis");
|
||||
});
|
||||
|
||||
test("returns Revelation for REV", () => {
|
||||
const book = getBookById("REV");
|
||||
expect(book!.name).toBe("Revelation");
|
||||
});
|
||||
|
||||
test("returns undefined for unknown ID", () => {
|
||||
expect(getBookById("UNKNOWN")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBookByNumber", () => {
|
||||
test("1 → Genesis", () => {
|
||||
expect(getBookByNumber(1)!.id).toBe("GEN");
|
||||
});
|
||||
|
||||
test("66 → Revelation", () => {
|
||||
expect(getBookByNumber(66)!.id).toBe("REV");
|
||||
});
|
||||
|
||||
test("40 → Matthew (first NT book)", () => {
|
||||
expect(getBookByNumber(40)!.id).toBe("MAT");
|
||||
});
|
||||
|
||||
test("0 → undefined", () => {
|
||||
expect(getBookByNumber(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("67 → undefined", () => {
|
||||
expect(getBookByNumber(67)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBooksByTestament", () => {
|
||||
test("old returns 39 books", () => {
|
||||
expect(getBooksByTestament("old")).toHaveLength(39);
|
||||
});
|
||||
|
||||
test("new returns 27 books", () => {
|
||||
expect(getBooksByTestament("new")).toHaveLength(27);
|
||||
});
|
||||
|
||||
test("all OT books have testament = old", () => {
|
||||
for (const book of getBooksByTestament("old")) {
|
||||
expect(book.testament).toBe("old");
|
||||
}
|
||||
});
|
||||
|
||||
test("all NT books have testament = new", () => {
|
||||
for (const book of getBooksByTestament("new")) {
|
||||
expect(book.testament).toBe("new");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAdjacent", () => {
|
||||
test("Genesis and Exodus are adjacent", () => {
|
||||
expect(isAdjacent("GEN", "EXO")).toBe(true);
|
||||
});
|
||||
|
||||
test("adjacency is symmetric", () => {
|
||||
expect(isAdjacent("EXO", "GEN")).toBe(true);
|
||||
});
|
||||
|
||||
test("Malachi and Matthew are adjacent across testament boundary", () => {
|
||||
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
|
||||
});
|
||||
|
||||
test("Jude and Revelation are adjacent", () => {
|
||||
expect(isAdjacent("JUD", "REV")).toBe(true); // 65, 66
|
||||
});
|
||||
|
||||
test("same book is not adjacent to itself", () => {
|
||||
expect(isAdjacent("GEN", "GEN")).toBe(false);
|
||||
});
|
||||
|
||||
test("books two apart are not adjacent", () => {
|
||||
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
|
||||
});
|
||||
|
||||
test("returns false for invalid IDs", () => {
|
||||
expect(isAdjacent("FAKE", "GEN")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bookNumberToId / bookIdToNumber lookup tables", () => {
|
||||
test("bookNumberToId[1] is GEN", () => {
|
||||
expect(bookNumberToId[1]).toBe("GEN");
|
||||
});
|
||||
|
||||
test("bookNumberToId[66] is REV", () => {
|
||||
expect(bookNumberToId[66]).toBe("REV");
|
||||
});
|
||||
|
||||
test("bookIdToNumber['GEN'] is 1", () => {
|
||||
expect(bookIdToNumber["GEN"]).toBe(1);
|
||||
});
|
||||
|
||||
test("bookIdToNumber['REV'] is 66", () => {
|
||||
expect(bookIdToNumber["REV"]).toBe(66);
|
||||
});
|
||||
|
||||
test("round-trip: number → ID → number", () => {
|
||||
for (let i = 1; i <= 66; i++) {
|
||||
const id = bookNumberToId[i];
|
||||
expect(bookIdToNumber[id]).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
test("round-trip: ID → number → ID", () => {
|
||||
for (const book of bibleBooks) {
|
||||
const num = bookIdToNumber[book.id];
|
||||
expect(bookNumberToId[num]).toBe(book.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
271
tests/game.test.ts
Normal file
271
tests/game.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
evaluateGuess,
|
||||
getBookById,
|
||||
getFirstLetter,
|
||||
getGrade,
|
||||
getNextGradeMessage,
|
||||
isAdjacent,
|
||||
toOrdinal,
|
||||
} from "$lib/utils/game";
|
||||
|
||||
describe("getBookById", () => {
|
||||
test("returns correct book for a valid ID", () => {
|
||||
const book = getBookById("GEN");
|
||||
expect(book).toBeDefined();
|
||||
expect(book!.name).toBe("Genesis");
|
||||
expect(book!.order).toBe(1);
|
||||
});
|
||||
|
||||
test("returns the last book by ID", () => {
|
||||
const book = getBookById("REV");
|
||||
expect(book).toBeDefined();
|
||||
expect(book!.name).toBe("Revelation");
|
||||
expect(book!.order).toBe(66);
|
||||
});
|
||||
|
||||
test("returns undefined for an invalid ID", () => {
|
||||
expect(getBookById("INVALID")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for an empty string", () => {
|
||||
expect(getBookById("")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("is case-sensitive", () => {
|
||||
expect(getBookById("gen")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAdjacent", () => {
|
||||
test("consecutive books are adjacent", () => {
|
||||
expect(isAdjacent("GEN", "EXO")).toBe(true); // 1, 2
|
||||
});
|
||||
|
||||
test("adjacency is symmetric", () => {
|
||||
expect(isAdjacent("EXO", "GEN")).toBe(true);
|
||||
});
|
||||
|
||||
test("books two apart are not adjacent", () => {
|
||||
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
|
||||
});
|
||||
|
||||
test("the same book is not adjacent to itself", () => {
|
||||
expect(isAdjacent("GEN", "GEN")).toBe(false); // diff = 0
|
||||
});
|
||||
|
||||
test("works across testament boundary (Malachi / Matthew)", () => {
|
||||
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
|
||||
});
|
||||
|
||||
test("far-apart books are not adjacent", () => {
|
||||
expect(isAdjacent("GEN", "REV")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for unknown IDs", () => {
|
||||
expect(isAdjacent("FAKE", "GEN")).toBe(false);
|
||||
expect(isAdjacent("GEN", "FAKE")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFirstLetter", () => {
|
||||
test("returns first letter of a normal book name", () => {
|
||||
expect(getFirstLetter("Genesis")).toBe("G");
|
||||
expect(getFirstLetter("Revelation")).toBe("R");
|
||||
});
|
||||
|
||||
test("skips leading digits and returns first letter", () => {
|
||||
expect(getFirstLetter("1 Samuel")).toBe("S");
|
||||
expect(getFirstLetter("2 Kings")).toBe("K");
|
||||
expect(getFirstLetter("1 Corinthians")).toBe("C");
|
||||
expect(getFirstLetter("3 John")).toBe("J");
|
||||
});
|
||||
|
||||
test("returns first letter of multi-word names", () => {
|
||||
expect(getFirstLetter("Song of Solomon")).toBe("S");
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateGuess", () => {
|
||||
test("returns null for an invalid guess ID", () => {
|
||||
expect(evaluateGuess("INVALID", "GEN")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for an invalid correct ID", () => {
|
||||
expect(evaluateGuess("GEN", "INVALID")).toBeNull();
|
||||
});
|
||||
|
||||
test("exact book match: testamentMatch and sectionMatch are true, adjacent is false", () => {
|
||||
const result = evaluateGuess("GEN", "GEN");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.book.id).toBe("GEN");
|
||||
expect(result!.testamentMatch).toBe(true);
|
||||
expect(result!.sectionMatch).toBe(true);
|
||||
expect(result!.adjacent).toBe(false);
|
||||
});
|
||||
|
||||
test("same section implies same testament", () => {
|
||||
// Genesis and Exodus are both OT Law
|
||||
const result = evaluateGuess("GEN", "EXO");
|
||||
expect(result!.testamentMatch).toBe(true);
|
||||
expect(result!.sectionMatch).toBe(true);
|
||||
expect(result!.adjacent).toBe(true);
|
||||
});
|
||||
|
||||
test("same testament, different section", () => {
|
||||
// Genesis (Law) vs Joshua (History) — both OT
|
||||
const result = evaluateGuess("GEN", "JOS");
|
||||
expect(result!.testamentMatch).toBe(true);
|
||||
expect(result!.sectionMatch).toBe(false);
|
||||
expect(result!.adjacent).toBe(false);
|
||||
});
|
||||
|
||||
test("different testament, no match", () => {
|
||||
// Genesis (OT) vs Matthew (NT)
|
||||
const result = evaluateGuess("GEN", "MAT");
|
||||
expect(result!.testamentMatch).toBe(false);
|
||||
expect(result!.sectionMatch).toBe(false);
|
||||
expect(result!.adjacent).toBe(false);
|
||||
});
|
||||
|
||||
test("adjacent books across testament boundary", () => {
|
||||
// Malachi (OT, 39) and Matthew (NT, 40)
|
||||
const result = evaluateGuess("MAL", "MAT");
|
||||
expect(result!.adjacent).toBe(true);
|
||||
expect(result!.testamentMatch).toBe(false);
|
||||
expect(result!.sectionMatch).toBe(false);
|
||||
});
|
||||
|
||||
test("adjacent books within same testament and section", () => {
|
||||
// Hosea (28) and Joel (29), both Minor Prophets
|
||||
const result = evaluateGuess("HOS", "JOL");
|
||||
expect(result!.adjacent).toBe(true);
|
||||
expect(result!.testamentMatch).toBe(true);
|
||||
expect(result!.sectionMatch).toBe(true);
|
||||
});
|
||||
|
||||
test("firstLetterMatch: same first letter", () => {
|
||||
// Genesis and Galatians both start with G
|
||||
const result = evaluateGuess("GEN", "GAL");
|
||||
expect(result!.firstLetterMatch).toBe(true);
|
||||
});
|
||||
|
||||
test("firstLetterMatch: different first letter", () => {
|
||||
// Genesis (G) vs Matthew (M)
|
||||
const result = evaluateGuess("GEN", "MAT");
|
||||
expect(result!.firstLetterMatch).toBe(false);
|
||||
});
|
||||
|
||||
test("firstLetterMatch is case-insensitive", () => {
|
||||
// Both start with J but from different contexts — Jeremiah vs Joel
|
||||
const result = evaluateGuess("JER", "JOL");
|
||||
expect(result!.firstLetterMatch).toBe(true);
|
||||
});
|
||||
|
||||
test("special case: two Epistle '1' books always firstLetterMatch", () => {
|
||||
// 1 Corinthians (Pauline) vs 1 John (General) — both Epistles starting with "1"
|
||||
const result = evaluateGuess("1CO", "1JN");
|
||||
expect(result!.firstLetterMatch).toBe(true);
|
||||
});
|
||||
|
||||
test("special case: Epistle '1' book vs non-Epistle '1' book — no special treatment", () => {
|
||||
// 1 Corinthians (Pauline Epistles) vs 1 Samuel (History)
|
||||
// Correct is NOT Epistles, so special case doesn't apply
|
||||
const result = evaluateGuess("1CO", "1SA");
|
||||
// getFirstLetter("1 Corinthians") = "C", getFirstLetter("1 Samuel") = "S" → false
|
||||
expect(result!.firstLetterMatch).toBe(false);
|
||||
});
|
||||
|
||||
test("special case only triggers when BOTH are Epistle '1' books", () => {
|
||||
// 2 Corinthians (Pauline, starts with "2") vs 1 John (General, starts with "1")
|
||||
// guessIsEpistlesWithNumber requires name[0] === "1", so 2CO fails
|
||||
const result = evaluateGuess("2CO", "1JN");
|
||||
// getFirstLetter("2 Corinthians") = "C", getFirstLetter("1 John") = "J" → false
|
||||
expect(result!.firstLetterMatch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGrade", () => {
|
||||
test("1 guess → S+", () => expect(getGrade(1)).toBe("S+"));
|
||||
test("2 guesses → A+", () => expect(getGrade(2)).toBe("A+"));
|
||||
test("3 guesses → A", () => expect(getGrade(3)).toBe("A"));
|
||||
test("4 guesses → B+", () => expect(getGrade(4)).toBe("B+"));
|
||||
test("6 guesses → B+", () => expect(getGrade(6)).toBe("B+"));
|
||||
test("7 guesses → B", () => expect(getGrade(7)).toBe("B"));
|
||||
test("10 guesses → B", () => expect(getGrade(10)).toBe("B"));
|
||||
test("11 guesses → C+", () => expect(getGrade(11)).toBe("C+"));
|
||||
test("15 guesses → C+", () => expect(getGrade(15)).toBe("C+"));
|
||||
test("16 guesses → C", () => expect(getGrade(16)).toBe("C"));
|
||||
test("100 guesses → C", () => expect(getGrade(100)).toBe("C"));
|
||||
});
|
||||
|
||||
describe("getNextGradeMessage", () => {
|
||||
test("returns empty string at top grade", () => {
|
||||
expect(getNextGradeMessage(1)).toBe("");
|
||||
});
|
||||
|
||||
test("grade A+ shows 1 guess threshold", () => {
|
||||
expect(getNextGradeMessage(2)).toBe("Next grade: 1 guess or less");
|
||||
});
|
||||
|
||||
test("grade A shows 2 guess threshold", () => {
|
||||
expect(getNextGradeMessage(3)).toBe("Next grade: 2 guesses or less");
|
||||
});
|
||||
|
||||
test("grade B+ shows 3 guess threshold", () => {
|
||||
expect(getNextGradeMessage(4)).toBe("Next grade: 3 guesses or less");
|
||||
expect(getNextGradeMessage(6)).toBe("Next grade: 3 guesses or less");
|
||||
});
|
||||
|
||||
test("grade B shows 6 guess threshold", () => {
|
||||
expect(getNextGradeMessage(7)).toBe("Next grade: 6 guesses or less");
|
||||
expect(getNextGradeMessage(10)).toBe("Next grade: 6 guesses or less");
|
||||
});
|
||||
|
||||
test("grade C+ shows 10 guess threshold", () => {
|
||||
expect(getNextGradeMessage(11)).toBe("Next grade: 10 guesses or less");
|
||||
expect(getNextGradeMessage(15)).toBe("Next grade: 10 guesses or less");
|
||||
});
|
||||
|
||||
test("grade C shows 15 guess threshold", () => {
|
||||
expect(getNextGradeMessage(16)).toBe("Next grade: 15 guesses or less");
|
||||
expect(getNextGradeMessage(50)).toBe("Next grade: 15 guesses or less");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toOrdinal", () => {
|
||||
test("1st, 2nd, 3rd", () => {
|
||||
expect(toOrdinal(1)).toBe("1st");
|
||||
expect(toOrdinal(2)).toBe("2nd");
|
||||
expect(toOrdinal(3)).toBe("3rd");
|
||||
});
|
||||
|
||||
test("4-10 use th", () => {
|
||||
expect(toOrdinal(4)).toBe("4th");
|
||||
expect(toOrdinal(10)).toBe("10th");
|
||||
});
|
||||
|
||||
test("11, 12, 13 use th (not st/nd/rd)", () => {
|
||||
expect(toOrdinal(11)).toBe("11th");
|
||||
expect(toOrdinal(12)).toBe("12th");
|
||||
expect(toOrdinal(13)).toBe("13th");
|
||||
});
|
||||
|
||||
test("21, 22, 23 use st/nd/rd", () => {
|
||||
expect(toOrdinal(21)).toBe("21st");
|
||||
expect(toOrdinal(22)).toBe("22nd");
|
||||
expect(toOrdinal(23)).toBe("23rd");
|
||||
});
|
||||
|
||||
test("101, 102, 103 use st/nd/rd", () => {
|
||||
expect(toOrdinal(101)).toBe("101st");
|
||||
expect(toOrdinal(102)).toBe("102nd");
|
||||
expect(toOrdinal(103)).toBe("103rd");
|
||||
});
|
||||
|
||||
test("111, 112, 113 use th", () => {
|
||||
expect(toOrdinal(111)).toBe("111th");
|
||||
expect(toOrdinal(112)).toBe("112th");
|
||||
expect(toOrdinal(113)).toBe("113th");
|
||||
});
|
||||
});
|
||||
339
tests/share.test.ts
Normal file
339
tests/share.test.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { generateShareText, getVerseSnippet } from "$lib/utils/share";
|
||||
import { getBookById } from "$lib/utils/game";
|
||||
import type { Guess } from "$lib/utils/game";
|
||||
|
||||
// Helpers to build Guess objects without calling evaluateGuess
|
||||
function makeGuess(bookId: string, overrides: Partial<Omit<Guess, "book">> = {}): Guess {
|
||||
const book = getBookById(bookId)!;
|
||||
return {
|
||||
book,
|
||||
testamentMatch: false,
|
||||
sectionMatch: false,
|
||||
adjacent: false,
|
||||
firstLetterMatch: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const CORRECT_BOOK_ID = "GEN";
|
||||
|
||||
const exactGuess = makeGuess("GEN", {
|
||||
testamentMatch: true,
|
||||
sectionMatch: true,
|
||||
});
|
||||
|
||||
const adjacentGuess = makeGuess("EXO", {
|
||||
testamentMatch: true,
|
||||
sectionMatch: true,
|
||||
adjacent: true,
|
||||
});
|
||||
|
||||
const sectionGuess = makeGuess("LEV", {
|
||||
testamentMatch: true,
|
||||
sectionMatch: true,
|
||||
});
|
||||
|
||||
const testamentGuess = makeGuess("JOS", {
|
||||
testamentMatch: true,
|
||||
sectionMatch: false,
|
||||
});
|
||||
|
||||
const noMatchGuess = makeGuess("MAT", {
|
||||
testamentMatch: false,
|
||||
sectionMatch: false,
|
||||
});
|
||||
|
||||
describe("generateShareText — emoji mapping", () => {
|
||||
test("exact match → ✅", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("✅");
|
||||
});
|
||||
|
||||
test("adjacent book → ‼️", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [adjacentGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("‼️");
|
||||
});
|
||||
|
||||
test("section match → 🟩", () => {
|
||||
// LEV matches section (Law) but is not adjacent to GEN (order 1 vs 3)
|
||||
const text = generateShareText({
|
||||
guesses: [sectionGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("🟩");
|
||||
});
|
||||
|
||||
test("testament match only → 🟧", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [testamentGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("🟧");
|
||||
});
|
||||
|
||||
test("no match → 🟥", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [noMatchGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("🟥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — guess count wording", () => {
|
||||
test("1 guess uses singular 'guess'", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("1 guess,");
|
||||
});
|
||||
|
||||
test("multiple guesses uses plural 'guesses'", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [noMatchGuess, testamentGuess, exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("3 guesses,");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — streak display", () => {
|
||||
test("streak > 1 is shown with fire emoji", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
streak: 5,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("5 days 🔥");
|
||||
});
|
||||
|
||||
test("streak of 1 is not shown", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
streak: 1,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).not.toContain("🔥");
|
||||
});
|
||||
|
||||
test("undefined streak is not shown", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).not.toContain("🔥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — chapter star", () => {
|
||||
test("1 guess + chapterCorrect → ⭐ appended to emoji line", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: true,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("✅ ⭐");
|
||||
});
|
||||
|
||||
test("multiple guesses + chapterCorrect → no star (only awarded for hole-in-one)", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [noMatchGuess, exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: true,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).not.toContain("⭐");
|
||||
});
|
||||
|
||||
test("1 guess + chapterCorrect false → no star", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).not.toContain("⭐");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — login book emoji", () => {
|
||||
test("logged in uses 📜", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: true,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("📜");
|
||||
expect(text).not.toContain("📖");
|
||||
});
|
||||
|
||||
test("not logged in uses 📖", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("📖");
|
||||
expect(text).not.toContain("📜");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — date formatting", () => {
|
||||
test("date is formatted as 'Mon DD, YYYY'", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("Jan 15, 2025");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — guess order", () => {
|
||||
test("guesses are reversed in the emoji line (first guess last)", () => {
|
||||
// noMatchGuess first, then exactGuess — reversed output: ✅🟥
|
||||
const text = generateShareText({
|
||||
guesses: [noMatchGuess, exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
const lines = text.split("\n");
|
||||
expect(lines[2]).toBe("✅🟥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVerseSnippet", () => {
|
||||
test("wraps output in curly double quotes", () => {
|
||||
const result = getVerseSnippet("Hello world");
|
||||
expect(result.startsWith("\u201C")).toBe(true);
|
||||
expect(result.endsWith("\u201D")).toBe(true);
|
||||
});
|
||||
|
||||
test("short verse (fewer than 10 words) returns full text", () => {
|
||||
const result = getVerseSnippet("For God so loved");
|
||||
// No punctuation search happens, returns all words
|
||||
expect(result).toContain("For God so loved");
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
test("verse with no punctuation in range returns first 25 words", () => {
|
||||
const words = Array.from({ length: 30 }, (_, i) => `word${i + 1}`);
|
||||
const verse = words.join(" ");
|
||||
const result = getVerseSnippet(verse);
|
||||
// Should contain up to 25 words
|
||||
expect(result).toContain("word25");
|
||||
expect(result).not.toContain("word26");
|
||||
});
|
||||
|
||||
test("truncates at punctuation between words 10 and 25", () => {
|
||||
// 12 words before comma, rest after
|
||||
const verse =
|
||||
"one two three four five six seven eight nine ten eleven twelve, thirteen fourteen fifteen twenty";
|
||||
const result = getVerseSnippet(verse);
|
||||
// The comma is after word 12, which is between word 10 and 25
|
||||
expect(result).toContain("twelve");
|
||||
expect(result).not.toContain("thirteen");
|
||||
});
|
||||
|
||||
test("punctuation before word 10 does not trigger truncation", () => {
|
||||
// Comma is after word 5 — before the search window starts at word 10
|
||||
const verse =
|
||||
"one two three four five, six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen";
|
||||
const result = getVerseSnippet(verse);
|
||||
// The comma at word 5 is before start of search range, so we continue
|
||||
// The snippet should contain word 10 at minimum
|
||||
expect(result).toContain("ten");
|
||||
});
|
||||
|
||||
test("does not include trailing whitespace before ellipsis", () => {
|
||||
const verse =
|
||||
"one two three four five six seven eight nine ten eleven twelve, rest of verse here";
|
||||
const result = getVerseSnippet(verse);
|
||||
// trimEnd is applied before adding ..., so no space before ...
|
||||
expect(result).not.toMatch(/\s\.\.\./);
|
||||
});
|
||||
});
|
||||
129
tests/stats.test.ts
Normal file
129
tests/stats.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
formatDate,
|
||||
getGradeColor,
|
||||
getPerformanceMessage,
|
||||
getStreakMessage,
|
||||
} from "$lib/utils/stats";
|
||||
|
||||
describe("getGradeColor", () => {
|
||||
test("S++ → purple", () => {
|
||||
expect(getGradeColor("S++")).toBe("text-purple-600 bg-purple-100");
|
||||
});
|
||||
|
||||
test("S+ → yellow", () => {
|
||||
expect(getGradeColor("S+")).toBe("text-yellow-600 bg-yellow-100");
|
||||
});
|
||||
|
||||
test("A+ → green", () => {
|
||||
expect(getGradeColor("A+")).toBe("text-green-600 bg-green-100");
|
||||
});
|
||||
|
||||
test("A → light green", () => {
|
||||
expect(getGradeColor("A")).toBe("text-green-500 bg-green-50");
|
||||
});
|
||||
|
||||
test("B+ → blue", () => {
|
||||
expect(getGradeColor("B+")).toBe("text-blue-600 bg-blue-100");
|
||||
});
|
||||
|
||||
test("B → light blue", () => {
|
||||
expect(getGradeColor("B")).toBe("text-blue-500 bg-blue-50");
|
||||
});
|
||||
|
||||
test("C+ → orange", () => {
|
||||
expect(getGradeColor("C+")).toBe("text-orange-600 bg-orange-100");
|
||||
});
|
||||
|
||||
test("C → red", () => {
|
||||
expect(getGradeColor("C")).toBe("text-red-600 bg-red-100");
|
||||
});
|
||||
|
||||
test("unknown grade → gray fallback", () => {
|
||||
expect(getGradeColor("X")).toBe("text-gray-600 bg-gray-100");
|
||||
expect(getGradeColor("")).toBe("text-gray-600 bg-gray-100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
test("formats a mid-year date", () => {
|
||||
expect(formatDate("2024-07-04")).toBe("Jul 4");
|
||||
});
|
||||
|
||||
test("formats a January date", () => {
|
||||
expect(formatDate("2024-01-15")).toBe("Jan 15");
|
||||
});
|
||||
|
||||
test("formats the last day of the year", () => {
|
||||
expect(formatDate("2023-12-31")).toBe("Dec 31");
|
||||
});
|
||||
|
||||
test("formats a single-digit day without leading zero", () => {
|
||||
expect(formatDate("2025-03-01")).toBe("Mar 1");
|
||||
});
|
||||
|
||||
test("year in input does not appear in output", () => {
|
||||
expect(formatDate("2024-06-20")).not.toContain("2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStreakMessage", () => {
|
||||
test("0 → prompt to start", () => {
|
||||
expect(getStreakMessage(0)).toBe("Start your streak today!");
|
||||
});
|
||||
|
||||
test("1 → encouragement", () => {
|
||||
expect(getStreakMessage(1)).toBe("Keep it going!");
|
||||
});
|
||||
|
||||
test("2 → X days strong", () => {
|
||||
expect(getStreakMessage(2)).toBe("2 days strong!");
|
||||
});
|
||||
|
||||
test("6 → X days strong (upper bound of that range)", () => {
|
||||
expect(getStreakMessage(6)).toBe("6 days strong!");
|
||||
});
|
||||
|
||||
test("7 → week streak message", () => {
|
||||
expect(getStreakMessage(7)).toBe("7 day streak - amazing!");
|
||||
});
|
||||
|
||||
test("29 → upper bound of week-streak range", () => {
|
||||
expect(getStreakMessage(29)).toBe("29 day streak - amazing!");
|
||||
});
|
||||
|
||||
test("30 → unstoppable message", () => {
|
||||
expect(getStreakMessage(30)).toBe("30 days - you're unstoppable!");
|
||||
});
|
||||
|
||||
test("100 → unstoppable message", () => {
|
||||
expect(getStreakMessage(100)).toBe("100 days - you're unstoppable!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPerformanceMessage", () => {
|
||||
test("≤ 2 guesses → exceptional", () => {
|
||||
expect(getPerformanceMessage(1)).toBe("Exceptional performance!");
|
||||
expect(getPerformanceMessage(2)).toBe("Exceptional performance!");
|
||||
});
|
||||
|
||||
test("≤ 4 guesses → great", () => {
|
||||
expect(getPerformanceMessage(2.1)).toBe("Great performance!");
|
||||
expect(getPerformanceMessage(4)).toBe("Great performance!");
|
||||
});
|
||||
|
||||
test("≤ 6 guesses → good", () => {
|
||||
expect(getPerformanceMessage(4.1)).toBe("Good performance!");
|
||||
expect(getPerformanceMessage(6)).toBe("Good performance!");
|
||||
});
|
||||
|
||||
test("≤ 8 guesses → room for improvement", () => {
|
||||
expect(getPerformanceMessage(6.1)).toBe("Room for improvement!");
|
||||
expect(getPerformanceMessage(8)).toBe("Room for improvement!");
|
||||
});
|
||||
|
||||
test("> 8 guesses → keep practicing", () => {
|
||||
expect(getPerformanceMessage(8.1)).toBe("Keep practicing!");
|
||||
expect(getPerformanceMessage(20)).toBe("Keep practicing!");
|
||||
});
|
||||
});
|
||||
6
todo.md
6
todo.md
@@ -59,6 +59,12 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
||||
|
||||
# done
|
||||
|
||||
## feb 26th
|
||||
|
||||
- Added dark mode
|
||||
- Removed URL from share text (Wordle said it was ratchet)
|
||||
- added option for sharing with verse snippet (hidden on share text first copy)
|
||||
|
||||
## february 22nd
|
||||
|
||||
- New share button design; speech bubbles
|
||||
|
||||
Reference in New Issue
Block a user