added first-guess and correct-guess umami event tracking

This commit is contained in:
George Powell
2025-12-27 11:42:15 -05:00
parent 54c7e3cdbb
commit c71170e6b3
3 changed files with 392 additions and 385 deletions

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { getBookById, toOrdinal, getNextGradeMessage } from "$lib/utils/game";
import { onMount } from "svelte";
interface StatsData {
solveRank: number;
@@ -33,10 +34,10 @@
// List of congratulations messages with weights
const congratulationsMessages: WeightedMessage[] = [
{ text: "🎉 Congratulations! 🎉", weight: 1000 },
{ text: "You got it!", weight: 10 },
{ text: "🎉 Yup 🎉", weight: 5 },
{ text: "👍🏻 Very nice! 👍🏻", weight: 1 },
{ text: "Congratulations!", weight: 10 },
{ text: "You got it!", weight: 1000 },
{ text: "Yup,", weight: 100 },
{ text: "Very nice!", weight: 1 },
];
// Function to select a random message based on weights
@@ -45,9 +46,9 @@
if (guessCount === 1) {
const n = Math.random();
if (n < 0.99) {
return "🤯 First try! 🤯";
return "🌟 First try! 🌟";
} else {
return " Axios";
return "🗣 Axios! 🗣";
}
}
@@ -70,18 +71,25 @@
// Generate the congratulations message
let congratulationsMessage = $derived(getRandomCongratulationsMessage());
onMount(() => {
if (typeof window !== "undefined" && (window as any).umami) {
(window as any).umami.track("guessed-correctly", {
totalGuesses: guessCount,
});
}
});
</script>
<div
class="p-8 sm:p-12 w-full bg-linear-to-r from-green-400 to-green-600 text-white rounded-2xl shadow-2xl text-center fade-in"
>
<h2 class="text-2xl sm:text-4xl font-black mb-4 drop-shadow-lg">
<!-- <h2 class="text-2xl sm:text-4xl font-black mb-4 drop-shadow-lg">
{congratulationsMessage}
</h2>
<p class="text-lg sm:text-xl md:text-2xl">
The verse is from <span class="font-black text-xl sm:text-2xl md:text-3xl"
>{bookName}</span
>
</h2> -->
<p class="text-xl sm:text-3xl md:text-4xl">
{congratulationsMessage} The verse is from
<span class="font-black text-xl sm:text-2xl md:text-3xl">{bookName}</span>.
</p>
<p
class="text-2xl font-bold mt-6 p-2 mx-2 bg-black/20 rounded-lg inline-block"

View File

@@ -1,421 +1,421 @@
<script lang="ts">
import { bibleBooks, type BibleBook } from "$lib/types/bible";
import { bibleBooks, type BibleBook } from "$lib/types/bible";
import type { PageProps } from "./$types";
import { browser } from "$app/environment";
import type { PageProps } from "./$types";
import { browser } from "$app/environment";
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
import SearchInput from "$lib/components/SearchInput.svelte";
import GuessesTable from "$lib/components/GuessesTable.svelte";
import CountdownTimer from "$lib/components/CountdownTimer.svelte";
import WinScreen from "$lib/components/WinScreen.svelte";
import Feedback from "$lib/components/Feedback.svelte";
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import { getGrade } from "$lib/utils/game";
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
import SearchInput from "$lib/components/SearchInput.svelte";
import GuessesTable from "$lib/components/GuessesTable.svelte";
import CountdownTimer from "$lib/components/CountdownTimer.svelte";
import WinScreen from "$lib/components/WinScreen.svelte";
import Feedback from "$lib/components/Feedback.svelte";
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import { getGrade } from "$lib/utils/game";
interface Guess {
book: BibleBook;
testamentMatch: boolean;
sectionMatch: boolean;
adjacent: boolean;
}
interface Guess {
book: BibleBook;
testamentMatch: boolean;
sectionMatch: boolean;
adjacent: boolean;
}
let { data }: PageProps = $props();
let { data }: PageProps = $props();
let dailyVerse = $derived(data.dailyVerse);
let correctBookId = $derived(data.correctBookId);
let dailyVerse = $derived(data.dailyVerse);
let correctBookId = $derived(data.correctBookId);
let guesses = $state<Guess[]>([]);
let guesses = $state<Guess[]>([]);
let searchQuery = $state("");
let searchQuery = $state("");
let copied = $state(false);
let isDev = $state(false);
let copied = $state(false);
let isDev = $state(false);
let anonymousId = $state("");
let statsSubmitted = $state(false);
let statsData = $state<{
solveRank: number;
guessRank: number;
totalSolves: number;
averageGuesses: number;
} | null>(null);
let anonymousId = $state("");
let statsSubmitted = $state(false);
let statsData = $state<{
solveRank: number;
guessRank: number;
totalSolves: number;
averageGuesses: number;
} | null>(null);
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
const currentDate = $derived(
new Date().toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}),
);
const currentDate = $derived(
new Date().toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})
);
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
let grade = $derived(
isWon
? getGrade(
guesses.length,
getBookById(correctBookId)?.popularity ?? 0,
)
: "",
);
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
let grade = $derived(
isWon
? getGrade(guesses.length, getBookById(correctBookId)?.popularity ?? 0)
: ""
);
function getBookById(id: string): BibleBook | undefined {
return bibleBooks.find((b) => b.id === id);
}
function getBookById(id: string): BibleBook | undefined {
return bibleBooks.find((b) => b.id === id);
}
function isAdjacent(id1: string, id2: string): boolean {
const b1 = getBookById(id1);
const b2 = getBookById(id2);
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
}
function isAdjacent(id1: string, id2: string): boolean {
const b1 = getBookById(id1);
const b2 = getBookById(id2);
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
}
function submitGuess(bookId: string) {
if (guesses.some((g) => g.book.id === bookId)) return;
function submitGuess(bookId: string) {
if (guesses.some((g) => g.book.id === bookId)) return;
const book = getBookById(bookId);
if (!book) return;
const book = getBookById(bookId);
if (!book) return;
const correctBook = getBookById(correctBookId);
if (!correctBook) return;
const correctBook = getBookById(correctBookId);
if (!correctBook) return;
const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(book.id, correctBookId);
const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(book.id, correctBookId);
console.log(
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
);
console.log(
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`
);
guesses = [
{
book,
testamentMatch,
sectionMatch,
adjacent,
},
...guesses,
];
if (guesses.length === 0 && browser && (window as any).umami) {
(window as any).umami.track("first-guess");
}
searchQuery = "";
}
guesses = [
{
book,
testamentMatch,
sectionMatch,
adjacent,
},
...guesses,
];
function generateUUID(): string {
// Try native randomUUID if available
if (typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
searchQuery = "";
}
// Fallback UUID v4 generator for older browsers
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r =
window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function generateUUID(): string {
// Try native randomUUID if available
if (typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = generateUUID();
localStorage.setItem(key, id);
}
return id;
}
// Fallback UUID v4 generator for older browsers
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
// Initialize anonymous ID
$effect(() => {
if (!browser) return;
anonymousId = getOrCreateAnonymousId();
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
statsSubmitted = localStorage.getItem(statsKey) === "true";
});
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = generateUUID();
localStorage.setItem(key, id);
}
return id;
}
$effect(() => {
if (!browser) return;
isDev = window.location.host === "localhost:5173";
});
// Initialize anonymous ID
$effect(() => {
if (!browser) return;
anonymousId = getOrCreateAnonymousId();
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
statsSubmitted = localStorage.getItem(statsKey) === "true";
});
// Load saved guesses
$effect(() => {
if (!browser) return;
$effect(() => {
if (!browser) return;
isDev = window.location.host === "localhost:5173";
});
const key = `bibdle-guesses-${dailyVerse.date}`;
const saved = localStorage.getItem(key);
if (saved) {
let savedIds: string[] = JSON.parse(saved);
savedIds = Array.from(new Set(savedIds));
guesses = savedIds.map((bookId: string) => {
const book = getBookById(bookId)!;
const correctBook = getBookById(correctBookId)!;
const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(bookId, correctBookId);
return {
book,
testamentMatch,
sectionMatch,
adjacent,
};
});
}
});
// Load saved guesses
$effect(() => {
if (!browser) return;
$effect(() => {
if (!browser) return;
localStorage.setItem(
`bibdle-guesses-${dailyVerse.date}`,
JSON.stringify(guesses.map((g) => g.book.id)),
);
});
const key = `bibdle-guesses-${dailyVerse.date}`;
const saved = localStorage.getItem(key);
if (saved) {
let savedIds: string[] = JSON.parse(saved);
savedIds = Array.from(new Set(savedIds));
guesses = savedIds.map((bookId: string) => {
const book = getBookById(bookId)!;
const correctBook = getBookById(correctBookId)!;
const testamentMatch = book.testament === correctBook.testament;
const sectionMatch = book.section === correctBook.section;
const adjacent = isAdjacent(bookId, correctBookId);
return {
book,
testamentMatch,
sectionMatch,
adjacent,
};
});
}
});
// Auto-submit stats when user wins
$effect(() => {
console.log("Stats effect triggered:", {
browser,
isWon,
anonymousId,
statsSubmitted,
statsData,
});
$effect(() => {
if (!browser) return;
localStorage.setItem(
`bibdle-guesses-${dailyVerse.date}`,
JSON.stringify(guesses.map((g) => g.book.id))
);
});
if (!browser || !isWon || !anonymousId) {
console.log("Basic conditions not met");
return;
}
// Auto-submit stats when user wins
$effect(() => {
console.log("Stats effect triggered:", {
browser,
isWon,
anonymousId,
statsSubmitted,
statsData,
});
if (statsSubmitted && !statsData) {
console.log("Fetching existing stats...");
if (!browser || !isWon || !anonymousId) {
console.log("Basic conditions not met");
return;
}
(async () => {
try {
const response = await fetch(
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`,
);
const result = await response.json();
console.log("Stats response:", result);
if (statsSubmitted && !statsData) {
console.log("Fetching existing stats...");
if (result.success && result.stats) {
console.log("Setting stats data:", result.stats);
statsData = result.stats;
localStorage.setItem(
`bibdle-stats-submitted-${dailyVerse.date}`,
"true",
);
} else if (result.error) {
console.error("Server error:", result.error);
} else {
console.error("Unexpected response format:", result);
}
} catch (err) {
console.error("Stats fetch failed:", err);
}
})();
(async () => {
try {
const response = await fetch(
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`
);
const result = await response.json();
console.log("Stats response:", result);
return;
}
if (result.success && result.stats) {
console.log("Setting stats data:", result.stats);
statsData = result.stats;
localStorage.setItem(
`bibdle-stats-submitted-${dailyVerse.date}`,
"true"
);
} else if (result.error) {
console.error("Server error:", result.error);
} else {
console.error("Unexpected response format:", result);
}
} catch (err) {
console.error("Stats fetch failed:", err);
}
})();
console.log("Submitting stats...");
return;
}
async function submitStats() {
try {
const payload = {
anonymousId,
date: dailyVerse.date,
guessCount: guesses.length,
};
console.log("Submitting stats...");
console.log("Sending POST request with:", payload);
async function submitStats() {
try {
const payload = {
anonymousId,
date: dailyVerse.date,
guessCount: guesses.length,
};
const response = await fetch("/api/submit-completion", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
console.log("Sending POST request with:", payload);
const result = await response.json();
console.log("Stats response:", result);
const response = await fetch("/api/submit-completion", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (result.success && result.stats) {
console.log("Setting stats data:", result.stats);
statsData = result.stats;
statsSubmitted = true;
localStorage.setItem(
`bibdle-stats-submitted-${dailyVerse.date}`,
"true",
);
} else if (result.error) {
console.error("Server error:", result.error);
} else {
console.error("Unexpected response format:", result);
}
} catch (err) {
console.error("Stats submission failed:", err);
}
}
const result = await response.json();
console.log("Stats response:", result);
submitStats();
});
if (result.success && result.stats) {
console.log("Setting stats data:", result.stats);
statsData = result.stats;
statsSubmitted = true;
localStorage.setItem(
`bibdle-stats-submitted-${dailyVerse.date}`,
"true"
);
} else if (result.error) {
console.error("Server error:", result.error);
} else {
console.error("Unexpected response format:", result);
}
} catch (err) {
console.error("Stats submission failed:", err);
}
}
function generateShareText(): string {
const emojis = guesses
.slice()
.reverse()
.map((guess) => {
if (guess.book.id === correctBookId) return "✅";
if (guess.adjacent) return "‼️";
if (guess.sectionMatch) return "🟩";
if (guess.testamentMatch) return "🟧";
return "🟥";
})
.join("");
submitStats();
});
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const formattedDate = dateFormatter.format(
new Date(`${dailyVerse.date}T00:00:00`),
);
const siteUrl = window.location.origin;
return [
`📖 Bibdle | ${formattedDate} 📖`,
`${grade} (${guesses.length} ${guesses.length == 1 ? "guess" : "guesses"})`,
`${emojis}`,
siteUrl,
].join("\n");
}
function generateShareText(): string {
const emojis = guesses
.slice()
.reverse()
.map((guess) => {
if (guess.book.id === correctBookId) return "✅";
if (guess.adjacent) return "‼️";
if (guess.sectionMatch) return "🟩";
if (guess.testamentMatch) return "🟧";
return "🟥";
})
.join("");
async function share() {
if (!browser) return;
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const formattedDate = dateFormatter.format(
new Date(`${dailyVerse.date}T00:00:00`)
);
const siteUrl = window.location.origin;
return [
`📖 Bibdle | ${formattedDate} 📖`,
`${grade} (${guesses.length} ${guesses.length == 1 ? "guess" : "guesses"})`,
`${emojis}`,
siteUrl,
].join("\n");
}
const shareText = generateShareText();
async function share() {
if (!browser) return;
try {
if ("share" in navigator) {
await (navigator as any).share({ text: shareText });
} else {
await (navigator as any).clipboard.writeText(shareText);
}
} catch (err) {
console.error("Share failed:", err);
throw err;
}
}
const shareText = generateShareText();
async function copyToClipboard() {
if (!browser) return;
try {
if ("share" in navigator) {
await (navigator as any).share({ text: shareText });
} else {
await (navigator as any).clipboard.writeText(shareText);
}
} catch (err) {
console.error("Share failed:", err);
throw err;
}
}
const shareText = generateShareText();
async function copyToClipboard() {
if (!browser) return;
try {
await (navigator as any).clipboard.writeText(shareText);
copied = true;
setTimeout(() => {
copied = false;
}, 5000);
} catch (err) {
console.error("Copy to clipboard failed:", err);
throw err;
}
}
const shareText = generateShareText();
function handleShare() {
if (copied || !browser) return;
const useClipboard = !("share" in navigator);
if (useClipboard) {
copied = true;
}
share()
.then(() => {
if (useClipboard) {
setTimeout(() => {
copied = false;
}, 5000);
}
})
.catch(() => {
if (useClipboard) {
copied = false;
}
});
}
try {
await (navigator as any).clipboard.writeText(shareText);
copied = true;
setTimeout(() => {
copied = false;
}, 5000);
} catch (err) {
console.error("Copy to clipboard failed:", err);
throw err;
}
}
function clearLocalStorage() {
if (!browser) return;
// Clear all bibdle-related localStorage items
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("bibdle-")) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
// Reload the page to reset state
window.location.reload();
}
function handleShare() {
if (copied || !browser) return;
const useClipboard = !("share" in navigator);
if (useClipboard) {
copied = true;
}
share()
.then(() => {
if (useClipboard) {
setTimeout(() => {
copied = false;
}, 5000);
}
})
.catch(() => {
if (useClipboard) {
copied = false;
}
});
}
function clearLocalStorage() {
if (!browser) return;
// Clear all bibdle-related localStorage items
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("bibdle-")) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
// Reload the page to reset state
window.location.reload();
}
</script>
<svelte:head>
<title>Bibdle &mdash; A daily bible game{isDev ? " (dev)" : ""}</title>
<meta
name="description"
content="Guess which book of the Bible a verse comes from."
/>
<title>Bibdle &mdash; A daily bible game{isDev ? " (dev)" : ""}</title>
<meta
name="description"
content="Guess which book of the Bible a verse comes from."
/>
</svelte:head>
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
<div class="w-full max-w-3xl mx-auto px-4">
<h1
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4"
>
<TitleAnimation />
<div class="font-normal"></div>
</h1>
<div class="text-center mb-8">
<span class="big-text"
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
>
</div>
<div class="w-full max-w-3xl mx-auto px-4">
<h1
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4"
>
<TitleAnimation />
<div class="font-normal"></div>
</h1>
<div class="text-center mb-8">
<span class="big-text"
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
>
</div>
<VerseDisplay {data} {isWon} />
<VerseDisplay {data} {isWon} />
{#if !isWon}
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
{:else}
<WinScreen
{grade}
{statsData}
{correctBookId}
{handleShare}
{copyToClipboard}
bind:copied
{statsSubmitted}
guessCount={guesses.length}
/>
<CountdownTimer />
{/if}
{#if !isWon}
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
{:else}
<WinScreen
{grade}
{statsData}
{correctBookId}
{handleShare}
{copyToClipboard}
bind:copied
{statsSubmitted}
guessCount={guesses.length}
/>
<CountdownTimer />
{/if}
<GuessesTable {guesses} {correctBookId} />
{#if isWon}
<Feedback />
{/if}
{#if isDev}
<button
onclick={clearLocalStorage}
class="mt-4 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-bold transition-colors"
>
Clear LocalStorage
</button>
{/if}
</div>
<GuessesTable {guesses} {correctBookId} />
{#if isWon}
<Feedback />
{/if}
{#if isDev}
<button
onclick={clearLocalStorage}
class="mt-4 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg text-sm font-bold transition-colors"
>
Clear LocalStorage
</button>
{/if}
</div>
</div>

29
todo.md
View File

@@ -1,43 +1,38 @@
# in progress
- root menu: classic / imposter mode / impossible mode (complete today's classic and imposter modes to unlock)
# todo
- impossible mode (1904 greek bible) three guesses only.
- share both classic and impossible mode with both buttons
- share both classic and impossible mode with both buttons
- add imposter mode
- instructions
- classic mode: identify what book the verse is from (e.g. Genesis, John, Revelations...) in as few guesses as possible.
- imposter mode: out of four options, identify the verse that is not in the Bible
- impossible mode: identify which book of the bible the verse is from in less than three guesses.
- classic mode: identify what book the verse is from (e.g. Genesis, John, Revelations...) in as few guesses as possible.
- imposter mode: out of four options, identify the verse that is not in the Bible
- impossible mode: identify which book of the bible the verse is from in less than three guesses.
- add login + saved stats + streak etc.
- add deuterocanonical books
<!-- Login features -->
- Practice mode: Unlimited verses
- Create public or private leaderboards
- Passport book with badges:
- Passport book with awards:
- Guess each Gospel first try
- "Guessed all Gospels", "Perfect week", "Old Testament expert"
- Theologian: Guess each book first try
- If chapter is 6 and verse 7, earn award "Six seven"
- difficult mode (guess old or new testament, first try _only_) (???)
# places to send
- linkedin post
- ocf discord server ✅
- nick makiej ✅
# About this game
As a young camper at the Metropolis of Boston Camp, I remember His Eminence Metropolitan Methodios would visit every Sunday. He was often surrounded by important people for his entire time there, so I never gathered the courage to introduce myself, but his homilies during Liturgy always stood out to me. In some ways, they differed year after year, but a majority of his message remained strikingly familiar. "Take ten minutes to read the Bible every day," he asked. "Just ten minutes. Go somewhere quiet, turn off the TV (then iPod, then cell phone), and read in peace and quiet."
@@ -52,6 +47,10 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
# done
## december 27th
- add event log to submitting first-guess or correct-guess to umami (to make bounce rate more accurate)
## december 26th
- created embeddings for every bible verse (verse similarity finder)