mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
3 Commits
7007df2966
...
e878dea235
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e878dea235 | ||
|
|
252edc3a6d | ||
|
|
75b13280ef |
41
src/lib/components/GamePrompt.svelte
Normal file
41
src/lib/components/GamePrompt.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { guessCount }: { guessCount: number } = $props();
|
||||||
|
|
||||||
|
let promptText = $state("What book of the Bible is this verse from?");
|
||||||
|
let visible = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
let fadeOutId: ReturnType<typeof setTimeout>;
|
||||||
|
let fadeInId: ReturnType<typeof setTimeout>;
|
||||||
|
let changeId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
function animateTo(newText: string, delay = 0) {
|
||||||
|
fadeOutId = setTimeout(() => {
|
||||||
|
visible = false;
|
||||||
|
changeId = setTimeout(() => {
|
||||||
|
promptText = newText;
|
||||||
|
visible = true;
|
||||||
|
}, 300);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guessCount === 0) {
|
||||||
|
animateTo("What book of the Bible is this verse from?");
|
||||||
|
} else {
|
||||||
|
animateTo("Guess again", 2100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(fadeOutId);
|
||||||
|
clearTimeout(fadeInId);
|
||||||
|
clearTimeout(changeId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="big-text text-center mb-6 px-4"
|
||||||
|
style="transition: opacity 0.3s ease; opacity: {visible ? 1 : 0};"
|
||||||
|
>
|
||||||
|
{promptText}
|
||||||
|
</p>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks } from "$lib/types/bible";
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
import { getFirstLetter, type Guess } from "$lib/utils/game";
|
import { getFirstLetter, type Guess } from "$lib/utils/game";
|
||||||
import Container from "./Container.svelte";
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
guesses,
|
guesses,
|
||||||
@@ -16,6 +15,19 @@
|
|||||||
return "bg-red-500 border-red-600";
|
return "bg-red-500 border-red-600";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBookBoxStyle(guess: Guess): string {
|
||||||
|
if (guess.book.id === correctBookId) {
|
||||||
|
return "background-color: #22c55e; border-color: #16a34a;";
|
||||||
|
}
|
||||||
|
const correctBook = bibleBooks.find((b) => b.id === correctBookId);
|
||||||
|
if (!correctBook)
|
||||||
|
return "background-color: #ef4444; border-color: #dc2626;";
|
||||||
|
const t = Math.abs(guess.book.order - correctBook.order) / 65;
|
||||||
|
const hue = 120 * Math.pow(1 - t, 3);
|
||||||
|
const lightness = 55 - (hue / 120) * 15;
|
||||||
|
return `background-color: #ef4444; border-color: hsl(${hue}, 80%, ${lightness}%);`;
|
||||||
|
}
|
||||||
|
|
||||||
function getBoxContent(
|
function getBoxContent(
|
||||||
guess: Guess,
|
guess: Guess,
|
||||||
column: "book" | "firstLetter" | "testament" | "section",
|
column: "book" | "firstLetter" | "testament" | "section",
|
||||||
@@ -64,17 +76,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !hasGuesses}
|
{#if hasGuesses}
|
||||||
<Container class="p-6 text-center">
|
|
||||||
<h2 class="font-triodion text-xl italic mb-3 text-gray-800 dark:text-gray-100">
|
|
||||||
Instructions
|
|
||||||
</h2>
|
|
||||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed italic">
|
|
||||||
Guess what book of the bible you think the verse is from. You will
|
|
||||||
get clues to help you after each guess.
|
|
||||||
</p>
|
|
||||||
</Container>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<!-- Column Headers -->
|
<!-- Column Headers -->
|
||||||
<div
|
<div
|
||||||
@@ -146,10 +148,9 @@
|
|||||||
|
|
||||||
<!-- Book Column -->
|
<!-- Book Column -->
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 border-opacity-100 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
|
||||||
guess.book.id === correctBookId,
|
style="animation-delay: {rowIndex * 1000 +
|
||||||
)}"
|
3 * 500}ms; {getBookBoxStyle(guess)}"
|
||||||
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
|
|
||||||
>
|
>
|
||||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||||
>{getBoxContent(guess, "book")}</span
|
>{getBoxContent(guess, "book")}</span
|
||||||
|
|||||||
@@ -1,309 +1,355 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks, type BibleBook, type BibleSection, type Testament } from "$lib/types/bible";
|
import {
|
||||||
import { SvelteSet } from "svelte/reactivity";
|
bibleBooks,
|
||||||
|
type BibleBook,
|
||||||
|
type BibleSection,
|
||||||
|
type Testament,
|
||||||
|
} from "$lib/types/bible";
|
||||||
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
searchQuery = $bindable(""),
|
searchQuery = $bindable(""),
|
||||||
guessedIds,
|
guessedIds,
|
||||||
submitGuess,
|
submitGuess,
|
||||||
guessCount = 0,
|
guessCount = 0,
|
||||||
}: {
|
}: {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
guessedIds: SvelteSet<string>;
|
guessedIds: SvelteSet<string>;
|
||||||
submitGuess: (id: string) => void;
|
submitGuess: (id: string) => void;
|
||||||
guessCount: number;
|
guessCount: number;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
type DisplayMode = "simple" | "testament" | "sections";
|
type DisplayMode = "simple" | "testament" | "sections";
|
||||||
|
|
||||||
const displayMode = $derived<DisplayMode>(
|
const displayMode = $derived<DisplayMode>(
|
||||||
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple"
|
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple",
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredBooks = $derived(
|
const filteredBooks = $derived(
|
||||||
bibleBooks.filter((book) =>
|
bibleBooks.filter((book) =>
|
||||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
type SimpleGroup = { books: BibleBook[] };
|
type SimpleGroup = { books: BibleBook[] };
|
||||||
|
|
||||||
type TestamentGroup = {
|
type TestamentGroup = {
|
||||||
testament: Testament;
|
testament: Testament;
|
||||||
label: string;
|
label: string;
|
||||||
books: BibleBook[];
|
books: BibleBook[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type SectionGroup = {
|
type SectionGroup = {
|
||||||
testament: Testament;
|
testament: Testament;
|
||||||
testamentLabel: string;
|
testamentLabel: string;
|
||||||
showTestamentHeader: boolean;
|
showTestamentHeader: boolean;
|
||||||
section: BibleSection;
|
section: BibleSection;
|
||||||
books: BibleBook[];
|
books: BibleBook[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const simpleGroup = $derived.by<SimpleGroup>(() => {
|
const simpleGroup = $derived.by<SimpleGroup>(() => {
|
||||||
const sorted = [...filteredBooks].sort((a, b) =>
|
const sorted = [...filteredBooks].sort((a, b) =>
|
||||||
a.name.localeCompare(b.name)
|
a.name.localeCompare(b.name),
|
||||||
);
|
);
|
||||||
return { books: sorted };
|
return { books: sorted };
|
||||||
});
|
});
|
||||||
|
|
||||||
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
|
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
|
||||||
const old = filteredBooks
|
const old = filteredBooks
|
||||||
.filter((b) => b.testament === "old")
|
.filter((b) => b.testament === "old")
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
const newT = filteredBooks
|
const newT = filteredBooks
|
||||||
.filter((b) => b.testament === "new")
|
.filter((b) => b.testament === "new")
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
const groups: TestamentGroup[] = [];
|
const groups: TestamentGroup[] = [];
|
||||||
if (old.length > 0) {
|
if (old.length > 0) {
|
||||||
groups.push({ testament: "old", label: "Old Testament", books: old });
|
groups.push({
|
||||||
}
|
testament: "old",
|
||||||
if (newT.length > 0) {
|
label: "Old Testament",
|
||||||
groups.push({ testament: "new", label: "New Testament", books: newT });
|
books: old,
|
||||||
}
|
});
|
||||||
return groups;
|
}
|
||||||
});
|
if (newT.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
testament: "new",
|
||||||
|
label: "New Testament",
|
||||||
|
books: newT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
const sectionGroups = $derived.by<SectionGroup[]>(() => {
|
const sectionGroups = $derived.by<SectionGroup[]>(() => {
|
||||||
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
|
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
|
||||||
const seenKeys: Record<string, true> = {};
|
const seenKeys: Record<string, true> = {};
|
||||||
const orderedPairs: { testament: Testament; section: BibleSection }[] = [];
|
const orderedPairs: { testament: Testament; section: BibleSection }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
for (const book of bibleBooks) {
|
for (const book of bibleBooks) {
|
||||||
const key = `${book.testament}:${book.section}`;
|
const key = `${book.testament}:${book.section}`;
|
||||||
if (!seenKeys[key]) {
|
if (!seenKeys[key]) {
|
||||||
seenKeys[key] = true;
|
seenKeys[key] = true;
|
||||||
orderedPairs.push({ testament: book.testament, section: book.section });
|
orderedPairs.push({
|
||||||
}
|
testament: book.testament,
|
||||||
}
|
section: book.section,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const groups: SectionGroup[] = [];
|
const groups: SectionGroup[] = [];
|
||||||
let lastTestament: Testament | null = null;
|
let lastTestament: Testament | null = null;
|
||||||
|
|
||||||
for (const pair of orderedPairs) {
|
for (const pair of orderedPairs) {
|
||||||
const books = filteredBooks.filter(
|
const books = filteredBooks.filter(
|
||||||
(b) => b.testament === pair.testament && b.section === pair.section
|
(b) =>
|
||||||
);
|
b.testament === pair.testament &&
|
||||||
if (books.length === 0) continue;
|
b.section === pair.section,
|
||||||
|
);
|
||||||
|
if (books.length === 0) continue;
|
||||||
|
|
||||||
const showTestamentHeader = pair.testament !== lastTestament;
|
const showTestamentHeader = pair.testament !== lastTestament;
|
||||||
lastTestament = pair.testament;
|
lastTestament = pair.testament;
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
testament: pair.testament,
|
testament: pair.testament,
|
||||||
testamentLabel:
|
testamentLabel:
|
||||||
pair.testament === "old" ? "Old Testament" : "New Testament",
|
pair.testament === "old"
|
||||||
showTestamentHeader,
|
? "Old Testament"
|
||||||
section: pair.section,
|
: "New Testament",
|
||||||
books,
|
showTestamentHeader,
|
||||||
});
|
section: pair.section,
|
||||||
}
|
books,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
});
|
});
|
||||||
|
|
||||||
// First book in display order for Enter key submission
|
// First book in display order for Enter key submission
|
||||||
const firstBookId = $derived.by<string | null>(() => {
|
const firstBookId = $derived.by<string | null>(() => {
|
||||||
if (filteredBooks.length === 0) return null;
|
if (filteredBooks.length === 0) return null;
|
||||||
if (displayMode === "simple") {
|
if (displayMode === "simple") {
|
||||||
return simpleGroup.books[0]?.id ?? null;
|
return simpleGroup.books[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
if (displayMode === "testament") {
|
if (displayMode === "testament") {
|
||||||
return testamentGroups[0]?.books[0]?.id ?? null;
|
return testamentGroups[0]?.books[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
return sectionGroups[0]?.books[0]?.id ?? null;
|
return sectionGroups[0]?.books[0]?.id ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && firstBookId) {
|
if (e.key === "Enter" && firstBookId) {
|
||||||
submitGuess(firstBookId);
|
submitGuess(firstBookId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showBanner = $derived(guessCount >= 3);
|
// const showBanner = $derived(guessCount >= 3);
|
||||||
const bannerIsIndigo = $derived(guessCount >= 9);
|
const showBanner = false;
|
||||||
|
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showBanner}
|
{#if showBanner}
|
||||||
<p
|
<p
|
||||||
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
|
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
{#if bannerIsIndigo}
|
{#if bannerIsIndigo}
|
||||||
Testament & section groups now visible
|
Testament & section groups now visible
|
||||||
{:else}
|
{:else}
|
||||||
Old & New Testament groups now visible
|
Old & New Testament groups now visible
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<svg
|
<svg
|
||||||
class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||||
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 dark:border-gray-600 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 dark:focus:border-blue-400 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-900/50 transition-all bg-white dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 dark:border-gray-600 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 dark:focus:border-blue-400 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-900/50 transition-all bg-white dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
{#if searchQuery}
|
{#if searchQuery}
|
||||||
<button
|
<button
|
||||||
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||||
onclick={() => (searchQuery = "")}
|
onclick={() => (searchQuery = "")}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-5 h-5 sm:w-6 sm:h-6"
|
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M6 18L18 6M6 6l12 12"
|
d="M6 18L18 6M6 6l12 12"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if searchQuery && filteredBooks.length > 0}
|
{#if searchQuery && filteredBooks.length > 0}
|
||||||
<ul
|
<ul
|
||||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-2xl shadow-xl"
|
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-2xl shadow-xl"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
>
|
>
|
||||||
{#if displayMode === "simple"}
|
{#if displayMode === "simple"}
|
||||||
{#each simpleGroup.books as book (book.id)}
|
{#each simpleGroup.books as book (book.id)}
|
||||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||||
<button
|
<button
|
||||||
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||||
{guessedIds.has(book.id)
|
{guessedIds.has(book.id)
|
||||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||||
onclick={() => submitGuess(book.id)}
|
onclick={() => submitGuess(book.id)}
|
||||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="font-semibold dark:text-gray-100 {guessedIds.has(book.id)
|
class="font-semibold dark:text-gray-100 {guessedIds.has(
|
||||||
? 'line-through text-gray-400 dark:text-gray-500'
|
book.id,
|
||||||
: ''}"
|
)
|
||||||
>
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
{book.name}
|
: ''}"
|
||||||
</span>
|
>
|
||||||
</button>
|
{book.name}
|
||||||
</li>
|
</span>
|
||||||
{/each}
|
</button>
|
||||||
{:else if displayMode === "testament"}
|
</li>
|
||||||
{#each testamentGroups as group (group.testament)}
|
{/each}
|
||||||
<li role="presentation">
|
{:else if displayMode === "testament"}
|
||||||
<div
|
{#each testamentGroups as group (group.testament)}
|
||||||
class="px-5 py-2 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
<li role="presentation">
|
||||||
>
|
<div
|
||||||
<span
|
class="px-5 py-2 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||||
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
|
>
|
||||||
>
|
<span
|
||||||
{group.label}
|
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
|
||||||
</span>
|
>
|
||||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
|
{group.label}
|
||||||
</div>
|
</span>
|
||||||
<ul>
|
<div
|
||||||
{#each group.books as book (book.id)}
|
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
|
||||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
></div>
|
||||||
<button
|
</div>
|
||||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
<ul>
|
||||||
|
{#each group.books as book (book.id)}
|
||||||
|
<li
|
||||||
|
role="option"
|
||||||
|
aria-selected={guessedIds.has(book.id)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||||
{guessedIds.has(book.id)
|
{guessedIds.has(book.id)
|
||||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||||
onclick={() => submitGuess(book.id)}
|
onclick={() => submitGuess(book.id)}
|
||||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
tabindex={guessedIds.has(book.id)
|
||||||
>
|
? -1
|
||||||
<span
|
: 0}
|
||||||
class="font-semibold {guessedIds.has(book.id)
|
>
|
||||||
? 'line-through text-gray-400 dark:text-gray-500'
|
<span
|
||||||
: ''}"
|
class="font-semibold {guessedIds.has(
|
||||||
>
|
book.id,
|
||||||
{book.name}
|
)
|
||||||
</span>
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
</button>
|
: ''}"
|
||||||
</li>
|
>
|
||||||
{/each}
|
{book.name}
|
||||||
</ul>
|
</span>
|
||||||
</li>
|
</button>
|
||||||
{/each}
|
</li>
|
||||||
{:else}
|
{/each}
|
||||||
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
|
</ul>
|
||||||
<li role="presentation">
|
</li>
|
||||||
{#if group.showTestamentHeader}
|
{/each}
|
||||||
<div
|
{:else}
|
||||||
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
|
||||||
>
|
<li role="presentation">
|
||||||
<span
|
{#if group.showTestamentHeader}
|
||||||
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
<div
|
||||||
>
|
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||||
{group.testamentLabel}
|
>
|
||||||
</span>
|
<span
|
||||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
|
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||||
</div>
|
>
|
||||||
{/if}
|
{group.testamentLabel}
|
||||||
<div
|
</span>
|
||||||
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
|
<div
|
||||||
>
|
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
|
||||||
<span
|
></div>
|
||||||
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
</div>
|
||||||
>
|
{/if}
|
||||||
{group.section}
|
<div
|
||||||
</span>
|
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
|
||||||
<div class="flex-1 h-px bg-gray-100 dark:bg-gray-600"></div>
|
>
|
||||||
</div>
|
<span
|
||||||
<ul>
|
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||||
{#each group.books as book (book.id)}
|
>
|
||||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
{group.section}
|
||||||
<button
|
</span>
|
||||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
<div
|
||||||
|
class="flex-1 h-px bg-gray-100 dark:bg-gray-600"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{#each group.books as book (book.id)}
|
||||||
|
<li
|
||||||
|
role="option"
|
||||||
|
aria-selected={guessedIds.has(book.id)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||||
{guessedIds.has(book.id)
|
{guessedIds.has(book.id)
|
||||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||||
onclick={() => submitGuess(book.id)}
|
onclick={() => submitGuess(book.id)}
|
||||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
tabindex={guessedIds.has(book.id)
|
||||||
>
|
? -1
|
||||||
<span
|
: 0}
|
||||||
class="font-semibold {guessedIds.has(book.id)
|
>
|
||||||
? 'line-through text-gray-400 dark:text-gray-500'
|
<span
|
||||||
: ''}"
|
class="font-semibold {guessedIds.has(
|
||||||
>
|
book.id,
|
||||||
{book.name}
|
)
|
||||||
</span>
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
</button>
|
: ''}"
|
||||||
</li>
|
>
|
||||||
{/each}
|
{book.name}
|
||||||
</ul>
|
</span>
|
||||||
</li>
|
</button>
|
||||||
{/each}
|
</li>
|
||||||
{/if}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else if searchQuery}
|
</li>
|
||||||
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">No books found</p>
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{:else if searchQuery}
|
||||||
|
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">
|
||||||
|
No books found
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import WinScreen from "$lib/components/WinScreen.svelte";
|
import WinScreen from "$lib/components/WinScreen.svelte";
|
||||||
import Credits from "$lib/components/Credits.svelte";
|
import Credits from "$lib/components/Credits.svelte";
|
||||||
|
|
||||||
|
import GamePrompt from "$lib/components/GamePrompt.svelte";
|
||||||
import DevButtons from "$lib/components/DevButtons.svelte";
|
import DevButtons from "$lib/components/DevButtons.svelte";
|
||||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
|
|
||||||
@@ -297,6 +298,8 @@
|
|||||||
|
|
||||||
{#if !isWon}
|
{#if !isWon}
|
||||||
<div class="animate-fade-in-up animate-delay-400">
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
|
<GamePrompt guessCount={persistence.guesses.length} />
|
||||||
|
|
||||||
<SearchInput
|
<SearchInput
|
||||||
bind:searchQuery
|
bind:searchQuery
|
||||||
{guessedIds}
|
{guessedIds}
|
||||||
@@ -335,8 +338,6 @@
|
|||||||
<Credits />
|
<Credits />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{#if isDev}
|
{#if isDev}
|
||||||
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
|
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
|
||||||
@@ -373,7 +374,11 @@
|
|||||||
<div>Daily Verse Date: {dailyVerse.date}</div>
|
<div>Daily Verse Date: {dailyVerse.date}</div>
|
||||||
<div>Streak: {streak}</div>
|
<div>Streak: {streak}</div>
|
||||||
</div>
|
</div>
|
||||||
<DevButtons anonymousId={persistence.anonymousId} {user} onSignIn={() => (authModalOpen = true)} />
|
<DevButtons
|
||||||
|
anonymousId={persistence.anonymousId}
|
||||||
|
{user}
|
||||||
|
onSignIn={() => (authModalOpen = true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ function prevDay(d: string): string {
|
|||||||
return dt.toISOString().slice(0, 10);
|
return dt.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addDays(d: string, n: number): string {
|
||||||
|
const dt = new Date(d + 'T00:00:00Z');
|
||||||
|
dt.setUTCDate(dt.getUTCDate() + n);
|
||||||
|
return dt.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const todayEst = estDateStr(0);
|
const todayEst = estDateStr(0);
|
||||||
const yesterdayEst = estDateStr(1);
|
const yesterdayEst = estDateStr(1);
|
||||||
@@ -120,12 +126,17 @@ export const load: PageServerLoad = async () => {
|
|||||||
.where(gte(dailyCompletions.date, ninetyDaysAgo))
|
.where(gte(dailyCompletions.date, ninetyDaysAgo))
|
||||||
.orderBy(asc(dailyCompletions.date));
|
.orderBy(asc(dailyCompletions.date));
|
||||||
|
|
||||||
// Group dates by user (ascending)
|
// Group dates by user (ascending) and users by date
|
||||||
const userDatesMap = new Map<string, string[]>();
|
const userDatesMap = new Map<string, string[]>();
|
||||||
|
const dateUsersMap = new Map<string, Set<string>>();
|
||||||
for (const row of recentCompletions) {
|
for (const row of recentCompletions) {
|
||||||
const arr = userDatesMap.get(row.anonymousId);
|
const arr = userDatesMap.get(row.anonymousId);
|
||||||
if (arr) arr.push(row.date);
|
if (arr) arr.push(row.date);
|
||||||
else userDatesMap.set(row.anonymousId, [row.date]);
|
else userDatesMap.set(row.anonymousId, [row.date]);
|
||||||
|
|
||||||
|
let s = dateUsersMap.get(row.date);
|
||||||
|
if (!s) { s = new Set(); dateUsersMap.set(row.date, s); }
|
||||||
|
s.add(row.anonymousId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Streak distribution ───────────────────────────────────────────────────
|
// ── Streak distribution ───────────────────────────────────────────────────
|
||||||
@@ -177,7 +188,8 @@ export const load: PageServerLoad = async () => {
|
|||||||
const firstDates = await db
|
const firstDates = await db
|
||||||
.select({
|
.select({
|
||||||
anonymousId: dailyCompletions.anonymousId,
|
anonymousId: dailyCompletions.anonymousId,
|
||||||
firstDate: min(dailyCompletions.date)
|
firstDate: min(dailyCompletions.date),
|
||||||
|
totalCompletions: count()
|
||||||
})
|
})
|
||||||
.from(dailyCompletions)
|
.from(dailyCompletions)
|
||||||
.groupBy(dailyCompletions.anonymousId);
|
.groupBy(dailyCompletions.anonymousId);
|
||||||
@@ -190,6 +202,108 @@ export const load: PageServerLoad = async () => {
|
|||||||
// Net growth = truly new arrivals minus departures
|
// Net growth = truly new arrivals minus departures
|
||||||
const netGrowth7d = newUsers7d - churned7d;
|
const netGrowth7d = newUsers7d - churned7d;
|
||||||
|
|
||||||
|
// ── Return rate ───────────────────────────────────────────────────────────
|
||||||
|
// "Return rate": % of all-time unique players who have ever played more than once.
|
||||||
|
const playersWithReturn = firstDates.filter((r) => r.totalCompletions >= 2).length;
|
||||||
|
const overallReturnRate =
|
||||||
|
firstDates.length > 0
|
||||||
|
? Math.round((playersWithReturn / firstDates.length) * 1000) / 10
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Daily new-player return rate: for each day D, what % of first-time players
|
||||||
|
// on D ever came back (i.e. totalCompletions >= 2)?
|
||||||
|
const dailyNewPlayerReturn = new Map<string, { cohort: number; returned: number }>();
|
||||||
|
for (const r of firstDates) {
|
||||||
|
if (!r.firstDate) continue;
|
||||||
|
const existing = dailyNewPlayerReturn.get(r.firstDate) ?? { cohort: 0, returned: 0 };
|
||||||
|
existing.cohort++;
|
||||||
|
if (r.totalCompletions >= 2) existing.returned++;
|
||||||
|
dailyNewPlayerReturn.set(r.firstDate, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build chronological array of daily rates (oldest first, days 60→1 ago)
|
||||||
|
// Days with fewer than 3 new players get rate=null to exclude from rolling avg
|
||||||
|
const dailyReturnRates: { date: string; cohort: number; rate: number | null }[] = [];
|
||||||
|
for (let i = 60; i >= 1; i--) {
|
||||||
|
const dateD = estDateStr(i);
|
||||||
|
const d = dailyNewPlayerReturn.get(dateD);
|
||||||
|
dailyReturnRates.push({
|
||||||
|
date: dateD,
|
||||||
|
cohort: d?.cohort ?? 0,
|
||||||
|
rate: d && d.cohort >= 3 ? Math.round((d.returned / d.cohort) * 1000) / 10 : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7-day trailing rolling average of the daily rates
|
||||||
|
// Index 0 = 60 days ago, index 59 = yesterday
|
||||||
|
const newPlayerReturnSeries = dailyReturnRates.map((r, idx) => {
|
||||||
|
const window = dailyReturnRates
|
||||||
|
.slice(Math.max(0, idx - 6), idx + 1)
|
||||||
|
.filter((d) => d.rate !== null);
|
||||||
|
const avg =
|
||||||
|
window.length > 0
|
||||||
|
? Math.round((window.reduce((sum, d) => sum + (d.rate ?? 0), 0) / window.length) * 10) /
|
||||||
|
10
|
||||||
|
: null;
|
||||||
|
return { date: r.date, cohort: r.cohort, rate: r.rate, rollingAvg: avg };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Velocity: avg of last 7 complete days (idx 53–59) vs prior 7 (idx 46–52)
|
||||||
|
const recentWindow = newPlayerReturnSeries.slice(53).filter((d) => d.rate !== null);
|
||||||
|
const priorWindow = newPlayerReturnSeries.slice(46, 53).filter((d) => d.rate !== null);
|
||||||
|
const current7dReturnAvg =
|
||||||
|
recentWindow.length > 0
|
||||||
|
? Math.round(
|
||||||
|
(recentWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / recentWindow.length) * 10
|
||||||
|
) / 10
|
||||||
|
: null;
|
||||||
|
const prior7dReturnAvg =
|
||||||
|
priorWindow.length > 0
|
||||||
|
? Math.round(
|
||||||
|
(priorWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / priorWindow.length) * 10
|
||||||
|
) / 10
|
||||||
|
: null;
|
||||||
|
const returnRateChange =
|
||||||
|
current7dReturnAvg !== null && prior7dReturnAvg !== null
|
||||||
|
? Math.round((current7dReturnAvg - prior7dReturnAvg) * 10) / 10
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// ── Retention over time ───────────────────────────────────────────────────
|
||||||
|
// For each cohort day D, retention = % of that day's players who played
|
||||||
|
// again within the next N days. Only compute for days where D+N is in the past.
|
||||||
|
|
||||||
|
function retentionSeries(
|
||||||
|
windowDays: number,
|
||||||
|
seriesLength: number
|
||||||
|
): { date: string; rate: number; cohortSize: number }[] {
|
||||||
|
// Earliest computable cohort day: today - (windowDays + 1)
|
||||||
|
// We use index windowDays+1 through windowDays+seriesLength
|
||||||
|
const series: { date: string; rate: number; cohortSize: number }[] = [];
|
||||||
|
for (let i = windowDays + 1; i <= windowDays + seriesLength; i++) {
|
||||||
|
const dateD = estDateStr(i);
|
||||||
|
const cohort = dateUsersMap.get(dateD);
|
||||||
|
if (!cohort || cohort.size < 3) continue; // skip tiny cohorts
|
||||||
|
let retained = 0;
|
||||||
|
for (const userId of cohort) {
|
||||||
|
for (let j = 1; j <= windowDays; j++) {
|
||||||
|
if (dateUsersMap.get(addDays(dateD, j))?.has(userId)) {
|
||||||
|
retained++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
series.push({
|
||||||
|
date: dateD,
|
||||||
|
rate: Math.round((retained / cohort.size) * 1000) / 10,
|
||||||
|
cohortSize: cohort.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return series; // newest first (loop iterates i from smallest = most recent)
|
||||||
|
}
|
||||||
|
|
||||||
|
const retention7dSeries = retentionSeries(7, 30);
|
||||||
|
const retention30dSeries = retentionSeries(30, 30);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
todayEst,
|
todayEst,
|
||||||
stats: {
|
stats: {
|
||||||
@@ -212,6 +326,15 @@ export const load: PageServerLoad = async () => {
|
|||||||
netGrowth7d
|
netGrowth7d
|
||||||
},
|
},
|
||||||
last14Days,
|
last14Days,
|
||||||
streakChart
|
streakChart,
|
||||||
|
retention7dSeries,
|
||||||
|
retention30dSeries,
|
||||||
|
overallReturnRate,
|
||||||
|
newPlayerReturnSeries: newPlayerReturnSeries.slice(-30).reverse(),
|
||||||
|
newPlayerReturnVelocity: {
|
||||||
|
current7dAvg: current7dReturnAvg,
|
||||||
|
prior7dAvg: prior7dReturnAvg,
|
||||||
|
change: returnRateChange
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,11 +26,20 @@
|
|||||||
churned7d: number;
|
churned7d: number;
|
||||||
netGrowth7d: number;
|
netGrowth7d: number;
|
||||||
};
|
};
|
||||||
|
retention7dSeries: { date: string; rate: number; cohortSize: number }[];
|
||||||
|
retention30dSeries: { date: string; rate: number; cohortSize: number }[];
|
||||||
|
overallReturnRate: number | null;
|
||||||
|
newPlayerReturnSeries: { date: string; cohort: number; rate: number | null; rollingAvg: number | null }[];
|
||||||
|
newPlayerReturnVelocity: {
|
||||||
|
current7dAvg: number | null;
|
||||||
|
prior7dAvg: number | null;
|
||||||
|
change: number | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const { stats, last14Days, todayEst, streakChart, growth } = $derived(data);
|
const { stats, last14Days, todayEst, streakChart, growth, retention7dSeries, retention30dSeries, overallReturnRate, newPlayerReturnSeries, newPlayerReturnVelocity } = $derived(data);
|
||||||
|
|
||||||
function signed(n: number, unit = ''): string {
|
function signed(n: number, unit = ''): string {
|
||||||
if (n > 0) return `+${n}${unit}`;
|
if (n > 0) return `+${n}${unit}`;
|
||||||
@@ -63,6 +72,10 @@
|
|||||||
label: 'Avg Completions/Player',
|
label: 'Avg Completions/Player',
|
||||||
value: stats.avgCompletionsPerPlayer != null ? stats.avgCompletionsPerPlayer.toFixed(2) : 'N/A',
|
value: stats.avgCompletionsPerPlayer != null ? stats.avgCompletionsPerPlayer.toFixed(2) : 'N/A',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Overall Return Rate',
|
||||||
|
value: overallReturnRate != null ? `${overallReturnRate}%` : 'N/A',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -132,8 +145,70 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-4">New Player Return Rate <span class="text-xs font-normal text-gray-400">(7-day rolling avg)</span></h2>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Return Rate (7d avg)</span>
|
||||||
|
<span class="text-2xl md:text-3xl font-bold text-center text-gray-100">
|
||||||
|
{newPlayerReturnVelocity.current7dAvg != null ? `${newPlayerReturnVelocity.current7dAvg}%` : 'N/A'}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 text-center">new players who came back</span>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Return Rate Change</span>
|
||||||
|
<span class="text-2xl md:text-3xl font-bold text-center {newPlayerReturnVelocity.change != null ? trendColor(newPlayerReturnVelocity.change) : 'text-gray-400'}">
|
||||||
|
{newPlayerReturnVelocity.change != null ? signed(newPlayerReturnVelocity.change, 'pp') : 'N/A'}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 text-center">vs prior 7 days</span>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span class="text-gray-400 text-xs uppercase tracking-wide text-center">Prior 7d Avg</span>
|
||||||
|
<span class="text-2xl md:text-3xl font-bold text-center text-gray-100">
|
||||||
|
{newPlayerReturnVelocity.prior7dAvg != null ? `${newPlayerReturnVelocity.prior7dAvg}%` : 'N/A'}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 text-center">days 8–14 ago</span>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if newPlayerReturnSeries.length > 0}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
|
||||||
|
<th class="text-left px-4 py-3">Date</th>
|
||||||
|
<th class="text-right px-4 py-3">New Players</th>
|
||||||
|
<th class="text-right px-4 py-3">Return Rate</th>
|
||||||
|
<th class="text-right px-4 py-3">7d Avg</th>
|
||||||
|
<th class="px-4 py-3 w-32"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each newPlayerReturnSeries as row (row.date)}
|
||||||
|
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-gray-300">{row.date}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-400 text-xs">{row.cohort}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-400">{row.rate != null ? `${row.rate}%` : '—'}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.rollingAvg != null ? `${row.rollingAvg}%` : '—'}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-20">
|
||||||
|
{#if row.rollingAvg != null}
|
||||||
|
<div class="bg-sky-500 h-4 rounded" style="width: {row.rollingAvg}%"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="mt-8">
|
<section class="mt-8">
|
||||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">Last 14 Days</h2>
|
<h2 class="text-lg font-semibold text-gray-100 mb-4">Last 14 Days — Completions</h2>
|
||||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -194,5 +269,82 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-1">Retention Over Time</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-6">% of each day's players who returned within the window. Cohorts with fewer than 3 players are excluded.</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- 7-day retention -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-200 mb-3">7-Day Retention</h3>
|
||||||
|
{#if retention7dSeries.length === 0}
|
||||||
|
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
|
||||||
|
<th class="text-left px-4 py-3">Cohort Date</th>
|
||||||
|
<th class="text-right px-4 py-3">n</th>
|
||||||
|
<th class="text-right px-4 py-3">Ret. %</th>
|
||||||
|
<th class="px-4 py-3 w-32"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each retention7dSeries as row (row.date)}
|
||||||
|
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-gray-300">{row.date}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-400 text-xs">{row.cohortSize}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.rate}%</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-20">
|
||||||
|
<div class="bg-emerald-500 h-4 rounded" style="width: {row.rate}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 30-day retention -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-200 mb-3">30-Day Retention</h3>
|
||||||
|
{#if retention30dSeries.length === 0}
|
||||||
|
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
|
||||||
|
<th class="text-left px-4 py-3">Cohort Date</th>
|
||||||
|
<th class="text-right px-4 py-3">n</th>
|
||||||
|
<th class="text-right px-4 py-3">Ret. %</th>
|
||||||
|
<th class="px-4 py-3 w-32"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each retention30dSeries as row (row.date)}
|
||||||
|
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-gray-300">{row.date}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-400 text-xs">{row.cohortSize}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.rate}%</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-20">
|
||||||
|
<div class="bg-violet-500 h-4 rounded" style="width: {row.rate}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user