mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
10 Commits
auth
...
67d9757f98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d9757f98 | ||
|
|
b6b41b6ba9 | ||
|
|
bdc08bc58e | ||
|
|
83cfcc66c0 | ||
|
|
e878dea235 | ||
|
|
252edc3a6d | ||
|
|
75b13280ef | ||
|
|
7007df2966 | ||
|
|
61673a646d | ||
|
|
1eb8eb2f04 |
385
bibdle_logo.svg
Normal file
385
bibdle_logo.svg
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="680"
|
||||||
|
viewBox="0 0 680 520"
|
||||||
|
version="1.1"
|
||||||
|
id="svg41"
|
||||||
|
sodipodi:docname="bibdle_logo.svg"
|
||||||
|
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview41"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:zoom="0.91550428"
|
||||||
|
inkscape:cx="321.68064"
|
||||||
|
inkscape:cy="144.18283"
|
||||||
|
inkscape:window-width="1512"
|
||||||
|
inkscape:window-height="921"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="33"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg41">
|
||||||
|
<inkscape:grid
|
||||||
|
id="grid41"
|
||||||
|
units="px"
|
||||||
|
originx="0"
|
||||||
|
originy="0"
|
||||||
|
spacingx="1"
|
||||||
|
spacingy="1"
|
||||||
|
empcolor="#0099e5"
|
||||||
|
empopacity="0.30196078"
|
||||||
|
color="#0099e5"
|
||||||
|
opacity="0.14901961"
|
||||||
|
empspacing="5"
|
||||||
|
enabled="true"
|
||||||
|
visible="true" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs32">
|
||||||
|
<linearGradient
|
||||||
|
id="bgSq"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1">
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stop-color="rgb(110,154,202)"
|
||||||
|
id="stop1" />
|
||||||
|
<stop
|
||||||
|
offset="3.23%"
|
||||||
|
stop-color="rgb(111,155,203)"
|
||||||
|
id="stop2" />
|
||||||
|
<stop
|
||||||
|
offset="6.45%"
|
||||||
|
stop-color="rgb(112,156,203)"
|
||||||
|
id="stop3" />
|
||||||
|
<stop
|
||||||
|
offset="9.68%"
|
||||||
|
stop-color="rgb(114,158,204)"
|
||||||
|
id="stop4" />
|
||||||
|
<stop
|
||||||
|
offset="12.9%"
|
||||||
|
stop-color="rgb(115,159,205)"
|
||||||
|
id="stop5" />
|
||||||
|
<stop
|
||||||
|
offset="16.13%"
|
||||||
|
stop-color="rgb(116,160,205)"
|
||||||
|
id="stop6" />
|
||||||
|
<stop
|
||||||
|
offset="19.35%"
|
||||||
|
stop-color="rgb(118,162,206)"
|
||||||
|
id="stop7" />
|
||||||
|
<stop
|
||||||
|
offset="22.58%"
|
||||||
|
stop-color="rgb(119,163,207)"
|
||||||
|
id="stop8" />
|
||||||
|
<stop
|
||||||
|
offset="25.81%"
|
||||||
|
stop-color="rgb(121,165,208)"
|
||||||
|
id="stop9" />
|
||||||
|
<stop
|
||||||
|
offset="29.03%"
|
||||||
|
stop-color="rgb(123,167,209)"
|
||||||
|
id="stop10" />
|
||||||
|
<stop
|
||||||
|
offset="32.26%"
|
||||||
|
stop-color="rgb(125,168,210)"
|
||||||
|
id="stop11" />
|
||||||
|
<stop
|
||||||
|
offset="35.48%"
|
||||||
|
stop-color="rgb(127,170,211)"
|
||||||
|
id="stop12" />
|
||||||
|
<stop
|
||||||
|
offset="38.71%"
|
||||||
|
stop-color="rgb(130,172,212)"
|
||||||
|
id="stop13" />
|
||||||
|
<stop
|
||||||
|
offset="41.94%"
|
||||||
|
stop-color="rgb(132,175,213)"
|
||||||
|
id="stop14" />
|
||||||
|
<stop
|
||||||
|
offset="45.16%"
|
||||||
|
stop-color="rgb(135,177,214)"
|
||||||
|
id="stop15" />
|
||||||
|
<stop
|
||||||
|
offset="48.39%"
|
||||||
|
stop-color="rgb(138,180,215)"
|
||||||
|
id="stop16" />
|
||||||
|
<stop
|
||||||
|
offset="51.61%"
|
||||||
|
stop-color="rgb(141,182,216)"
|
||||||
|
id="stop17" />
|
||||||
|
<stop
|
||||||
|
offset="54.84%"
|
||||||
|
stop-color="rgb(145,185,218)"
|
||||||
|
id="stop18" />
|
||||||
|
<stop
|
||||||
|
offset="58.06%"
|
||||||
|
stop-color="rgb(149,188,219)"
|
||||||
|
id="stop19" />
|
||||||
|
<stop
|
||||||
|
offset="61.29%"
|
||||||
|
stop-color="rgb(153,191,220)"
|
||||||
|
id="stop20" />
|
||||||
|
<stop
|
||||||
|
offset="64.52%"
|
||||||
|
stop-color="rgb(158,195,222)"
|
||||||
|
id="stop21" />
|
||||||
|
<stop
|
||||||
|
offset="67.74%"
|
||||||
|
stop-color="rgb(163,198,223)"
|
||||||
|
id="stop22" />
|
||||||
|
<stop
|
||||||
|
offset="70.97%"
|
||||||
|
stop-color="rgb(169,202,224)"
|
||||||
|
id="stop23" />
|
||||||
|
<stop
|
||||||
|
offset="74.19%"
|
||||||
|
stop-color="rgb(174,206,226)"
|
||||||
|
id="stop24" />
|
||||||
|
<stop
|
||||||
|
offset="77.42%"
|
||||||
|
stop-color="rgb(181,209,227)"
|
||||||
|
id="stop25" />
|
||||||
|
<stop
|
||||||
|
offset="80.65%"
|
||||||
|
stop-color="rgb(188,213,228)"
|
||||||
|
id="stop26" />
|
||||||
|
<stop
|
||||||
|
offset="83.87%"
|
||||||
|
stop-color="rgb(195,217,229)"
|
||||||
|
id="stop27" />
|
||||||
|
<stop
|
||||||
|
offset="87.1%"
|
||||||
|
stop-color="rgb(203,221,230)"
|
||||||
|
id="stop28" />
|
||||||
|
<stop
|
||||||
|
offset="90.32%"
|
||||||
|
stop-color="rgb(210,225,230)"
|
||||||
|
id="stop29" />
|
||||||
|
<stop
|
||||||
|
offset="93.55%"
|
||||||
|
stop-color="rgb(218,228,229)"
|
||||||
|
id="stop30" />
|
||||||
|
<stop
|
||||||
|
offset="96.77%"
|
||||||
|
stop-color="rgb(224,230,227)"
|
||||||
|
id="stop31" />
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stop-color="rgb(227,228,223)"
|
||||||
|
id="stop32" />
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath
|
||||||
|
id="sqClip">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect32" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="ciClip">
|
||||||
|
<circle
|
||||||
|
cx="510"
|
||||||
|
cy="170"
|
||||||
|
r="130"
|
||||||
|
id="circle32" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="sqClip-9">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect32-8" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="clipPath1">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect1" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="clipPath2">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect2" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="clipPath3">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect3" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="sqClip-9-1">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect32-8-7" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="clipPath1-1">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect1-8" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="clipPath2-7">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect2-8" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
id="clipPath3-7">
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
id="rect3-7" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<!-- Rounded square (favicon / app icon) -->
|
||||||
|
<rect
|
||||||
|
x="40"
|
||||||
|
y="40"
|
||||||
|
width="260"
|
||||||
|
height="260"
|
||||||
|
rx="44"
|
||||||
|
fill="url(#bgSq)"
|
||||||
|
id="rect33"
|
||||||
|
inkscape:export-filename="../Coding/bibdle/static/favicon.png"
|
||||||
|
inkscape:export-xdpi="23.63077"
|
||||||
|
inkscape:export-ydpi="23.63077" />
|
||||||
|
<!-- Circle (Discord server icon) -->
|
||||||
|
<circle
|
||||||
|
cx="510"
|
||||||
|
cy="170"
|
||||||
|
r="130"
|
||||||
|
fill="url(#bgSq)"
|
||||||
|
id="circle37"
|
||||||
|
inkscape:export-filename="../Coding/bibdle/static/bibdle-logo-circle.png"
|
||||||
|
inkscape:export-xdpi="378.09232"
|
||||||
|
inkscape:export-ydpi="378.09232" />
|
||||||
|
<rect
|
||||||
|
x="152"
|
||||||
|
y="78"
|
||||||
|
width="36"
|
||||||
|
height="184"
|
||||||
|
rx="5"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9)"
|
||||||
|
id="rect34-6"
|
||||||
|
transform="matrix(0.89748134,0,0,1,357.18847,2.5366858)" />
|
||||||
|
<rect
|
||||||
|
x="128"
|
||||||
|
y="88"
|
||||||
|
width="84"
|
||||||
|
height="26"
|
||||||
|
rx="5"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9)"
|
||||||
|
id="rect35-5"
|
||||||
|
transform="matrix(1,0,0,0.80796134,339.90604,30.104693)" />
|
||||||
|
<rect
|
||||||
|
x="96"
|
||||||
|
y="140"
|
||||||
|
width="148"
|
||||||
|
height="30"
|
||||||
|
rx="5"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9)"
|
||||||
|
id="rect36-7"
|
||||||
|
transform="matrix(1,0,0,0.82878095,339.90604,26.507353)" />
|
||||||
|
<rect
|
||||||
|
x="128"
|
||||||
|
y="210"
|
||||||
|
width="84"
|
||||||
|
height="20"
|
||||||
|
rx="4"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9)"
|
||||||
|
transform="rotate(15,330.33996,1512.3487)"
|
||||||
|
id="rect37-6" />
|
||||||
|
<rect
|
||||||
|
x="152"
|
||||||
|
y="78"
|
||||||
|
width="36"
|
||||||
|
height="184"
|
||||||
|
rx="5"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9-1)"
|
||||||
|
id="rect34-6-4"
|
||||||
|
transform="matrix(0.89748134,0,0,1,17.264653,0.15299483)" />
|
||||||
|
<rect
|
||||||
|
x="128"
|
||||||
|
y="88"
|
||||||
|
width="84"
|
||||||
|
height="26"
|
||||||
|
rx="5"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9-1)"
|
||||||
|
id="rect35-5-4"
|
||||||
|
transform="matrix(1,0,0,0.80796134,0.28036275,27.721002)" />
|
||||||
|
<rect
|
||||||
|
x="96"
|
||||||
|
y="140"
|
||||||
|
width="148"
|
||||||
|
height="30"
|
||||||
|
rx="5"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9-1)"
|
||||||
|
id="rect36-7-9"
|
||||||
|
transform="matrix(1,0,0,0.82878095,-0.01777671,24.123662)" />
|
||||||
|
<rect
|
||||||
|
x="128"
|
||||||
|
y="210"
|
||||||
|
width="84"
|
||||||
|
height="20"
|
||||||
|
rx="4"
|
||||||
|
fill="#ffffff"
|
||||||
|
clip-path="url(#sqClip-9-1)"
|
||||||
|
transform="rotate(15,169.4311,220.168)"
|
||||||
|
id="rect37-6-1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.6 KiB |
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<script src="https://rybbit.snail.city/api/script.js" data-site-id="9abf0e81d024" defer></script>
|
<script src="https://rybbit.snail.city/api/script.js" data-site-id="9abf0e81d024" defer></script>
|
||||||
|
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
BIN
src/lib/assets/bibdle-logo-square.png
Normal file
BIN
src/lib/assets/bibdle-logo-square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
@@ -96,6 +96,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium"
|
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium"
|
||||||
|
data-umami-event="Sign in with Apple"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||||
|
|||||||
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,14 +1,16 @@
|
|||||||
<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,
|
||||||
correctBookId,
|
correctBookId,
|
||||||
}: { guesses: Guess[]; correctBookId: string } = $props();
|
isWon = false,
|
||||||
|
}: { guesses: Guess[]; correctBookId: string; isWon?: boolean } = $props();
|
||||||
|
|
||||||
let hasGuesses = $derived(guesses.length > 0);
|
let hasGuesses = $derived(guesses.length > 0);
|
||||||
|
let showMinimized = $derived(isWon && guesses.length > 3);
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
function getBoxColor(isCorrect: boolean, isAdjacent?: boolean): string {
|
function getBoxColor(isCorrect: boolean, isAdjacent?: boolean): string {
|
||||||
if (isCorrect) return "bg-green-500 border-green-600";
|
if (isCorrect) return "bg-green-500 border-green-600";
|
||||||
@@ -16,6 +18,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 +79,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
|
||||||
@@ -102,14 +107,85 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each guesses as guess, rowIndex (guess.book.id)}
|
{#if showMinimized && !expanded}
|
||||||
|
<!-- Minimized view: first guess, divider, last two guesses -->
|
||||||
|
|
||||||
|
<!-- First guess (no animation since post-win) -->
|
||||||
|
{@const firstGuess = guesses[0]}
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<!-- Testament Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
firstGuess.testamentMatch,
|
||||||
|
)}"
|
||||||
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(firstGuess, "testament")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Column -->
|
||||||
|
<div
|
||||||
|
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
firstGuess.sectionMatch,
|
||||||
|
firstGuess.adjacent,
|
||||||
|
)}"
|
||||||
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(firstGuess, "section")}
|
||||||
|
{#if firstGuess.adjacent}
|
||||||
|
‼️
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- First Letter Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
firstGuess.firstLetterMatch,
|
||||||
|
)}"
|
||||||
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(firstGuess, "firstLetter")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Book Column -->
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
style="animation: none; opacity: 1; transform: none; {getBookBoxStyle(firstGuess)}"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||||
|
>{getBoxContent(firstGuess, "book")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expand/collapse divider -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 py-1 cursor-pointer group"
|
||||||
|
onclick={() => (expanded = true)}
|
||||||
|
>
|
||||||
|
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||||
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors whitespace-nowrap select-none">
|
||||||
|
expand guesses ▼
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Last two guesses (no animation since post-win) -->
|
||||||
|
{#each guesses.slice(-2) as guess (guess.book.id)}
|
||||||
<div class="flex gap-2 justify-center">
|
<div class="flex gap-2 justify-center">
|
||||||
<!-- Testament Column -->
|
<!-- Testament Column -->
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
guess.testamentMatch,
|
guess.testamentMatch,
|
||||||
)}"
|
)}"
|
||||||
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
>
|
>
|
||||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "testament")}</span
|
>{getBoxContent(guess, "testament")}</span
|
||||||
@@ -122,7 +198,7 @@
|
|||||||
guess.sectionMatch,
|
guess.sectionMatch,
|
||||||
guess.adjacent,
|
guess.adjacent,
|
||||||
)}"
|
)}"
|
||||||
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
>
|
>
|
||||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "section")}
|
>{getBoxContent(guess, "section")}
|
||||||
@@ -137,7 +213,7 @@
|
|||||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
guess.firstLetterMatch,
|
guess.firstLetterMatch,
|
||||||
)}"
|
)}"
|
||||||
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
>
|
>
|
||||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "firstLetter")}</span
|
>{getBoxContent(guess, "firstLetter")}</span
|
||||||
@@ -146,10 +222,8 @@
|
|||||||
|
|
||||||
<!-- 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: none; opacity: 1; transform: none; {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
|
||||||
@@ -157,8 +231,86 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<!-- Full view: all guesses -->
|
||||||
|
|
||||||
|
{#each guesses as guess, rowIndex (guess.book.id)}
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<!-- Testament Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
guess.testamentMatch,
|
||||||
|
)}"
|
||||||
|
style={showMinimized
|
||||||
|
? "animation: none; opacity: 1; transform: none;"
|
||||||
|
: `animation-delay: ${rowIndex * 1000 + 0 * 500}ms`}
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(guess, "testament")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Column -->
|
||||||
|
<div
|
||||||
|
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
guess.sectionMatch,
|
||||||
|
guess.adjacent,
|
||||||
|
)}"
|
||||||
|
style={showMinimized
|
||||||
|
? "animation: none; opacity: 1; transform: none;"
|
||||||
|
: `animation-delay: ${rowIndex * 1000 + 1 * 500}ms`}
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(guess, "section")}
|
||||||
|
{#if guess.adjacent}
|
||||||
|
‼️
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- First Letter Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
guess.firstLetterMatch,
|
||||||
|
)}"
|
||||||
|
style={showMinimized
|
||||||
|
? "animation: none; opacity: 1; transform: none;"
|
||||||
|
: `animation-delay: ${rowIndex * 1000 + 2 * 500}ms`}
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(guess, "firstLetter")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Book Column -->
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
style={showMinimized
|
||||||
|
? `animation: none; opacity: 1; transform: none; ${getBookBoxStyle(guess)}`
|
||||||
|
: `animation-delay: ${rowIndex * 1000 + 3 * 500}ms; ${getBookBoxStyle(guess)}`}
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||||
|
>{getBoxContent(guess, "book")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if showMinimized && expanded && rowIndex === 0}
|
||||||
|
<!-- Collapse divider shown right below the final (correct) guess -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 py-1 cursor-pointer group"
|
||||||
|
onclick={() => (expanded = false)}
|
||||||
|
>
|
||||||
|
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||||
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors whitespace-nowrap select-none">
|
||||||
|
collapse guesses ▲
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- </div> -->
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks, type BibleBook, type BibleSection, type Testament } from "$lib/types/bible";
|
import {
|
||||||
|
bibleBooks,
|
||||||
|
type BibleBook,
|
||||||
|
type BibleSection,
|
||||||
|
type Testament,
|
||||||
|
} from "$lib/types/bible";
|
||||||
import { SvelteSet } from "svelte/reactivity";
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -17,13 +22,13 @@
|
|||||||
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[] };
|
||||||
@@ -44,7 +49,7 @@
|
|||||||
|
|
||||||
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 };
|
||||||
});
|
});
|
||||||
@@ -58,10 +63,18 @@
|
|||||||
.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",
|
||||||
|
label: "Old Testament",
|
||||||
|
books: old,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (newT.length > 0) {
|
if (newT.length > 0) {
|
||||||
groups.push({ testament: "new", label: "New Testament", books: newT });
|
groups.push({
|
||||||
|
testament: "new",
|
||||||
|
label: "New Testament",
|
||||||
|
books: newT,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
});
|
});
|
||||||
@@ -69,13 +82,17 @@
|
|||||||
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +101,9 @@
|
|||||||
|
|
||||||
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 &&
|
||||||
|
b.section === pair.section,
|
||||||
);
|
);
|
||||||
if (books.length === 0) continue;
|
if (books.length === 0) continue;
|
||||||
|
|
||||||
@@ -94,7 +113,9 @@
|
|||||||
groups.push({
|
groups.push({
|
||||||
testament: pair.testament,
|
testament: pair.testament,
|
||||||
testamentLabel:
|
testamentLabel:
|
||||||
pair.testament === "old" ? "Old Testament" : "New Testament",
|
pair.testament === "old"
|
||||||
|
? "Old Testament"
|
||||||
|
: "New Testament",
|
||||||
showTestamentHeader,
|
showTestamentHeader,
|
||||||
section: pair.section,
|
section: pair.section,
|
||||||
books,
|
books,
|
||||||
@@ -122,7 +143,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showBanner = $derived(guessCount >= 3);
|
// const showBanner = $derived(guessCount >= 3);
|
||||||
|
const showBanner = false;
|
||||||
const bannerIsIndigo = $derived(guessCount >= 9);
|
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -206,7 +228,9 @@
|
|||||||
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(
|
||||||
|
book.id,
|
||||||
|
)
|
||||||
? 'line-through text-gray-400 dark:text-gray-500'
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
@@ -226,21 +250,30 @@
|
|||||||
>
|
>
|
||||||
{group.label}
|
{group.label}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
|
<div
|
||||||
|
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
{#each group.books as book (book.id)}
|
{#each group.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 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
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
|
||||||
|
: 0}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="font-semibold {guessedIds.has(book.id)
|
class="font-semibold {guessedIds.has(
|
||||||
|
book.id,
|
||||||
|
)
|
||||||
? 'line-through text-gray-400 dark:text-gray-500'
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
@@ -264,7 +297,9 @@
|
|||||||
>
|
>
|
||||||
{group.testamentLabel}
|
{group.testamentLabel}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
|
<div
|
||||||
|
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
@@ -275,21 +310,30 @@
|
|||||||
>
|
>
|
||||||
{group.section}
|
{group.section}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1 h-px bg-gray-100 dark:bg-gray-600"></div>
|
<div
|
||||||
|
class="flex-1 h-px bg-gray-100 dark:bg-gray-600"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
{#each group.books as book (book.id)}
|
{#each group.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 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
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
|
||||||
|
: 0}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="font-semibold {guessedIds.has(book.id)
|
class="font-semibold {guessedIds.has(
|
||||||
|
book.id,
|
||||||
|
)
|
||||||
? 'line-through text-gray-400 dark:text-gray-500'
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
@@ -304,6 +348,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
{:else if searchQuery}
|
{:else if searchQuery}
|
||||||
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">No books found</p>
|
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">
|
||||||
|
No books found
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
streak = 0,
|
streak = 0,
|
||||||
streakPercentile = null,
|
streakPercentile = null,
|
||||||
isLoggedIn = false,
|
isLoggedIn = false,
|
||||||
anonymousId = '',
|
anonymousId = "",
|
||||||
}: {
|
}: {
|
||||||
statsData: StatsData | null;
|
statsData: StatsData | null;
|
||||||
correctBookId: string;
|
correctBookId: string;
|
||||||
@@ -307,6 +307,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if streak >= 7}
|
||||||
|
<div class="big-text tracking-widest! font-black! text-center mt-4">
|
||||||
|
Thank you for making Bibdle part of your daily routine! —George
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showSnippetOption}
|
{#if showSnippetOption}
|
||||||
@@ -326,15 +331,24 @@
|
|||||||
|
|
||||||
{#if !isLoggedIn}
|
{#if !isLoggedIn}
|
||||||
<div class="signin-prompt">
|
<div class="signin-prompt">
|
||||||
<p class="signin-text">Sign in to save your streak & see your stats</p>
|
<p class="signin-text">
|
||||||
<form method="POST" action="/auth/apple">
|
Sign in to save your streak & track your progress
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="/auth/apple" class="w-full">
|
||||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="apple-signin-btn"
|
class="apple-signin-btn"
|
||||||
|
data-umami-event="Sign in with Apple"
|
||||||
>
|
>
|
||||||
<svg class="apple-icon" viewBox="0 0 24 24" fill="currentColor">
|
<svg
|
||||||
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
class="apple-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Sign in with Apple
|
Sign in with Apple
|
||||||
</button>
|
</button>
|
||||||
@@ -627,7 +641,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem 0 0.25rem;
|
/*padding: 1rem 0 0.25rem;*/
|
||||||
}
|
}
|
||||||
|
|
||||||
.signin-text {
|
.signin-text {
|
||||||
@@ -648,7 +662,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.6rem 1.5rem;
|
padding: 0.6rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
background: #000;
|
background: #000;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
@@ -656,7 +672,9 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 150ms ease, transform 80ms ease;
|
transition:
|
||||||
|
background 150ms ease,
|
||||||
|
transform 80ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.apple-signin-btn:hover {
|
.apple-signin-btn:hover {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
import favicon from "$lib/assets/favicon.ico";
|
import favicon from "$lib/assets/favicon.ico";
|
||||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||||
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Inject analytics script
|
// Inject analytics script
|
||||||
@@ -31,5 +32,6 @@
|
|||||||
<TitleAnimation />
|
<TitleAnimation />
|
||||||
<div class="font-normal"></div>
|
<div class="font-normal"></div>
|
||||||
</h1>
|
</h1>
|
||||||
|
<div class="hidden"><ThemeToggle /></div>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
import GuessesTable from "$lib/components/GuessesTable.svelte";
|
import GuessesTable from "$lib/components/GuessesTable.svelte";
|
||||||
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 ThemeToggle from "$lib/components/ThemeToggle.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}
|
||||||
@@ -327,19 +330,39 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="animate-fade-in-up animate-delay-600">
|
<div class="animate-fade-in-up animate-delay-600">
|
||||||
<GuessesTable guesses={persistence.guesses} {correctBookId} />
|
<GuessesTable
|
||||||
|
guesses={persistence.guesses}
|
||||||
|
{correctBookId}
|
||||||
|
{isWon}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isWon}
|
{#if isWon}
|
||||||
|
<hr class="animate-fade-in-up animate-delay-800 border-gray-300 dark:border-gray-600" />
|
||||||
|
<div class="animate-fade-in-up animate-delay-800">
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/yWQXbGK8SD"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center justify-center gap-2 w-full px-5 py-2.5 bg-[#5865F2] hover:bg-[#4752C4] text-white font-semibold rounded-lg shadow-md transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 127.14 96.36"
|
||||||
|
class="w-5 h-5 fill-white"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Join the BIBDLE Discord!
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div class="animate-fade-in-up animate-delay-800">
|
<div class="animate-fade-in-up animate-delay-800">
|
||||||
<Credits />
|
<Credits />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- We will just go with the user's system color theme for now. -->
|
|
||||||
<div class="flex justify-center hidden mt-4">
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</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">
|
||||||
@@ -376,7 +399,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}
|
||||||
|
|
||||||
|
|||||||
484
src/routes/global/+page.server.ts
Normal file
484
src/routes/global/+page.server.ts
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions, user } from '$lib/server/db/schema';
|
||||||
|
import { eq, gte, count, countDistinct, avg, asc, min } from 'drizzle-orm';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
function estDateStr(daysAgo = 0): string {
|
||||||
|
const estNow = new Date(Date.now() - 5 * 60 * 60 * 1000); // UTC-5
|
||||||
|
estNow.setUTCDate(estNow.getUTCDate() - daysAgo);
|
||||||
|
return estNow.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevDay(d: string): string {
|
||||||
|
const dt = new Date(d + 'T00:00:00Z');
|
||||||
|
dt.setUTCDate(dt.getUTCDate() - 1);
|
||||||
|
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 () => {
|
||||||
|
const todayEst = estDateStr(0);
|
||||||
|
const yesterdayEst = estDateStr(1);
|
||||||
|
const sevenDaysAgo = estDateStr(7);
|
||||||
|
|
||||||
|
// Three weekly windows for first + second derivative calculations
|
||||||
|
// Week A: last 7 days (indices 0–6)
|
||||||
|
// Week B: 7–13 days ago (indices 7–13)
|
||||||
|
// Week C: 14–20 days ago (indices 14–20)
|
||||||
|
const weekAStart = estDateStr(6);
|
||||||
|
const weekBEnd = estDateStr(7);
|
||||||
|
const weekBStart = estDateStr(13);
|
||||||
|
const weekCEnd = estDateStr(14);
|
||||||
|
const weekCStart = estDateStr(20);
|
||||||
|
|
||||||
|
// ── Scalar stats ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [{ todayCount }] = await db
|
||||||
|
.select({ todayCount: count() })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.date, todayEst));
|
||||||
|
|
||||||
|
const [{ totalCount }] = await db
|
||||||
|
.select({ totalCount: count() })
|
||||||
|
.from(dailyCompletions);
|
||||||
|
|
||||||
|
const [{ uniquePlayers }] = await db
|
||||||
|
.select({ uniquePlayers: countDistinct(dailyCompletions.anonymousId) })
|
||||||
|
.from(dailyCompletions);
|
||||||
|
|
||||||
|
const [{ weeklyPlayers }] = await db
|
||||||
|
.select({ weeklyPlayers: countDistinct(dailyCompletions.anonymousId) })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(gte(dailyCompletions.date, sevenDaysAgo));
|
||||||
|
|
||||||
|
const thirtyDaysAgo = estDateStr(30);
|
||||||
|
const [{ monthlyPlayers }] = await db
|
||||||
|
.select({ monthlyPlayers: countDistinct(dailyCompletions.anonymousId) })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(gte(dailyCompletions.date, thirtyDaysAgo));
|
||||||
|
|
||||||
|
const todayPlayers = await db
|
||||||
|
.selectDistinct({ id: dailyCompletions.anonymousId })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.date, todayEst));
|
||||||
|
|
||||||
|
const yesterdayPlayers = await db
|
||||||
|
.selectDistinct({ id: dailyCompletions.anonymousId })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.date, yesterdayEst));
|
||||||
|
|
||||||
|
const todaySet = new Set(todayPlayers.map((r) => r.id));
|
||||||
|
const activeStreaks = yesterdayPlayers.filter((r) => todaySet.has(r.id)).length;
|
||||||
|
|
||||||
|
const [{ avgGuessesRaw }] = await db
|
||||||
|
.select({ avgGuessesRaw: avg(dailyCompletions.guessCount) })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.date, todayEst));
|
||||||
|
|
||||||
|
const avgGuessesToday = avgGuessesRaw != null ? parseFloat(avgGuessesRaw) : null;
|
||||||
|
|
||||||
|
const [{ registeredUsers }] = await db
|
||||||
|
.select({ registeredUsers: count() })
|
||||||
|
.from(user);
|
||||||
|
|
||||||
|
const avgCompletionsPerPlayer =
|
||||||
|
uniquePlayers > 0 ? Math.round((totalCount / uniquePlayers) * 100) / 100 : null;
|
||||||
|
|
||||||
|
// ── 21-day completions per day (covers all three weekly windows) ──────────
|
||||||
|
|
||||||
|
const rawPerDay21 = await db
|
||||||
|
.select({ date: dailyCompletions.date, dayCount: count() })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(gte(dailyCompletions.date, weekCStart))
|
||||||
|
.groupBy(dailyCompletions.date)
|
||||||
|
.orderBy(asc(dailyCompletions.date));
|
||||||
|
|
||||||
|
const counts21 = new Map(rawPerDay21.map((r) => [r.date, r.dayCount]));
|
||||||
|
|
||||||
|
// Build indexed array: index 0 = today, index 20 = 20 days ago
|
||||||
|
const completionsPerDay: number[] = [];
|
||||||
|
for (let i = 0; i <= 20; i++) {
|
||||||
|
completionsPerDay.push(counts21.get(estDateStr(i)) ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// last14Days for the trend chart (most recent first)
|
||||||
|
const last14Days: { date: string; count: number }[] = [];
|
||||||
|
for (let i = 0; i <= 13; i++) {
|
||||||
|
last14Days.push({ date: estDateStr(i), count: completionsPerDay[i] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly totals from the indexed array
|
||||||
|
const weekATotal = completionsPerDay.slice(0, 7).reduce((a, b) => a + b, 0);
|
||||||
|
const weekBTotal = completionsPerDay.slice(7, 14).reduce((a, b) => a + b, 0);
|
||||||
|
const weekCTotal = completionsPerDay.slice(14, 21).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// First derivative: avg daily completions change (week A vs week B)
|
||||||
|
const completionsVelocity = Math.round(((weekATotal - weekBTotal) / 7) * 10) / 10;
|
||||||
|
// Second derivative: is velocity itself increasing or decreasing?
|
||||||
|
const completionsAcceleration =
|
||||||
|
Math.round((((weekATotal - weekBTotal) - (weekBTotal - weekCTotal)) / 7) * 10) / 10;
|
||||||
|
|
||||||
|
// ── 90-day per-user data (reused for streaks + weekly user sets) ──────────
|
||||||
|
|
||||||
|
const ninetyDaysAgo = estDateStr(90);
|
||||||
|
const recentCompletions = await db
|
||||||
|
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(gte(dailyCompletions.date, ninetyDaysAgo))
|
||||||
|
.orderBy(asc(dailyCompletions.date));
|
||||||
|
|
||||||
|
// Group dates by user (ascending) and users by date
|
||||||
|
const userDatesMap = new Map<string, string[]>();
|
||||||
|
const dateUsersMap = new Map<string, Set<string>>();
|
||||||
|
for (const row of recentCompletions) {
|
||||||
|
const arr = userDatesMap.get(row.anonymousId);
|
||||||
|
if (arr) arr.push(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 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const streakDistribution = new Map<number, number>();
|
||||||
|
for (const dates of userDatesMap.values()) {
|
||||||
|
const desc = dates.slice().reverse();
|
||||||
|
if (desc[0] !== todayEst && desc[0] !== yesterdayEst) continue;
|
||||||
|
let streak = 1;
|
||||||
|
let cur = desc[0];
|
||||||
|
for (let i = 1; i < desc.length; i++) {
|
||||||
|
if (desc[i] === prevDay(cur)) {
|
||||||
|
streak++;
|
||||||
|
cur = desc[i];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (streak >= 2) {
|
||||||
|
streakDistribution.set(streak, (streakDistribution.get(streak) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const streakChart = Array.from(streakDistribution.entries())
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([days, userCount]) => ({ days, count: userCount }));
|
||||||
|
|
||||||
|
// ── Weekly user sets (for user-based velocity + churn) ───────────────────
|
||||||
|
|
||||||
|
const weekAUsers = new Set<string>();
|
||||||
|
const weekBUsers = new Set<string>();
|
||||||
|
const weekCUsers = new Set<string>();
|
||||||
|
|
||||||
|
for (const [userId, dates] of userDatesMap) {
|
||||||
|
if (dates.some((d) => d >= weekAStart)) weekAUsers.add(userId);
|
||||||
|
if (dates.some((d) => d >= weekBStart && d <= weekBEnd)) weekBUsers.add(userId);
|
||||||
|
if (dates.some((d) => d >= weekCStart && d <= weekCEnd)) weekCUsers.add(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First derivative: weekly unique users change
|
||||||
|
const userVelocity = weekAUsers.size - weekBUsers.size;
|
||||||
|
// Second derivative: is user growth speeding up or slowing down?
|
||||||
|
const userAcceleration =
|
||||||
|
weekAUsers.size - weekBUsers.size - (weekBUsers.size - weekCUsers.size);
|
||||||
|
|
||||||
|
// ── New players + churn ───────────────────────────────────────────────────
|
||||||
|
// New players: anonymousIds whose first-ever completion falls in the last 7 days.
|
||||||
|
// Checking against all-time data (not just the 90-day window) ensures accuracy.
|
||||||
|
const firstDates = await db
|
||||||
|
.select({
|
||||||
|
anonymousId: dailyCompletions.anonymousId,
|
||||||
|
firstDate: min(dailyCompletions.date),
|
||||||
|
totalCompletions: count()
|
||||||
|
})
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.groupBy(dailyCompletions.anonymousId);
|
||||||
|
|
||||||
|
const newUsers7d = firstDates.filter((r) => r.firstDate != null && r.firstDate >= weekAStart).length;
|
||||||
|
|
||||||
|
// Churned: played in week B but not at all in week A
|
||||||
|
const churned7d = [...weekBUsers].filter((id) => !weekAUsers.has(id)).length;
|
||||||
|
|
||||||
|
// Net growth = truly new arrivals minus departures
|
||||||
|
const netGrowth7d = newUsers7d - churned7d;
|
||||||
|
|
||||||
|
// ── Session depth funnel ──────────────────────────────────────────────────
|
||||||
|
// For each depth d, count players with >= d completions.
|
||||||
|
// returnRate at depth d = (players with >= d+1) / (players with >= d).
|
||||||
|
const depthCounts = new Map<number, number>();
|
||||||
|
for (const r of firstDates) {
|
||||||
|
const n = r.totalCompletions;
|
||||||
|
for (let d = 1; d <= n; d++) {
|
||||||
|
depthCounts.set(d, (depthCounts.get(d) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sessionDepthCards = [2, 3, 4, 5, 7].map((d) => {
|
||||||
|
const atD = depthCounts.get(d) ?? 0;
|
||||||
|
const atDplus1 = depthCounts.get(d + 1) ?? 0;
|
||||||
|
return {
|
||||||
|
depth: d,
|
||||||
|
players: atD,
|
||||||
|
returnRate: atD >= 3 ? Math.round((atDplus1 / atD) * 1000) / 10 : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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) {
|
||||||
|
if (dateUsersMap.get(addDays(dateD, windowDays))?.has(userId)) {
|
||||||
|
retained++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
|
// ── Weekly Active Users history (12 weeks) ────────────────────────────────
|
||||||
|
|
||||||
|
const wauWeeks: { weekStart: string; weekEnd: string; wau: number; changePct: number | null }[] = [];
|
||||||
|
|
||||||
|
for (let w = 0; w < 12; w++) {
|
||||||
|
const weekEnd = estDateStr(w * 7);
|
||||||
|
const weekStart = estDateStr(w * 7 + 6);
|
||||||
|
const users = new Set<string>();
|
||||||
|
for (const row of recentCompletions) {
|
||||||
|
if (row.date >= weekStart && row.date <= weekEnd) {
|
||||||
|
users.add(row.anonymousId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wauWeeks.push({ weekEnd, weekStart, wau: users.size, changePct: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change % vs prior week (index i+1 is the older week)
|
||||||
|
for (let i = 0; i < wauWeeks.length - 1; i++) {
|
||||||
|
const prev = wauWeeks[i + 1].wau;
|
||||||
|
if (prev > 0) {
|
||||||
|
wauWeeks[i].changePct = Math.round(((wauWeeks[i].wau - prev) / prev) * 1000) / 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgWau = Math.round(wauWeeks.reduce((sum, w) => sum + w.wau, 0) / wauWeeks.length);
|
||||||
|
|
||||||
|
// ── Monthly Active Users history (6 months) ───────────────────────────────
|
||||||
|
|
||||||
|
const sixMonthsAgo = estDateStr(185);
|
||||||
|
const mauCompletions = await db
|
||||||
|
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(gte(dailyCompletions.date, sixMonthsAgo));
|
||||||
|
|
||||||
|
// Rolling 30-day windows
|
||||||
|
const mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[] = [];
|
||||||
|
for (let m = 0; m < 6; m++) {
|
||||||
|
const monthEnd = estDateStr(m * 30);
|
||||||
|
const monthStart = estDateStr(m * 30 + 29);
|
||||||
|
const users = new Set<string>();
|
||||||
|
for (const row of mauCompletions) {
|
||||||
|
if (row.date >= monthStart && row.date <= monthEnd) {
|
||||||
|
users.add(row.anonymousId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mauMonths.push({ monthStart, monthEnd, mau: users.size, changePct: null });
|
||||||
|
}
|
||||||
|
for (let i = 0; i < mauMonths.length - 1; i++) {
|
||||||
|
const prev = mauMonths[i + 1].mau;
|
||||||
|
if (prev > 0) {
|
||||||
|
mauMonths[i].changePct = Math.round(((mauMonths[i].mau - prev) / prev) * 1000) / 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar month windows
|
||||||
|
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
const [todayYear, todayMonth, todayDay] = todayEst.split('-').map(Number);
|
||||||
|
|
||||||
|
const calendarMauMonths: {
|
||||||
|
label: string;
|
||||||
|
monthStart: string;
|
||||||
|
monthEnd: string;
|
||||||
|
mau: number;
|
||||||
|
daysElapsed: number;
|
||||||
|
daysInMonth: number;
|
||||||
|
projectedMau: number | null;
|
||||||
|
changePct: number | null;
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
let mo = todayMonth - i;
|
||||||
|
let yr = todayYear;
|
||||||
|
if (mo <= 0) { mo += 12; yr--; }
|
||||||
|
|
||||||
|
// new Date(yr, mo, 0) gives last day of month mo (1-indexed) in local time
|
||||||
|
const daysInMonth = new Date(yr, mo, 0).getDate();
|
||||||
|
const monthStart = `${yr}-${String(mo).padStart(2, '0')}-01`;
|
||||||
|
const monthEnd = `${yr}-${String(mo).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
|
||||||
|
const isCurrentMonth = i === 0;
|
||||||
|
const daysElapsed = isCurrentMonth ? todayDay : daysInMonth;
|
||||||
|
const queryEnd = isCurrentMonth ? todayEst : monthEnd;
|
||||||
|
|
||||||
|
const users = new Set<string>();
|
||||||
|
for (const row of mauCompletions) {
|
||||||
|
if (row.date >= monthStart && row.date <= queryEnd) {
|
||||||
|
users.add(row.anonymousId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectedMau = isCurrentMonth && daysElapsed > 0
|
||||||
|
? Math.round(users.size * (daysInMonth / daysElapsed))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
calendarMauMonths.push({
|
||||||
|
label: `${MONTH_NAMES[mo - 1]} ${yr}`,
|
||||||
|
monthStart,
|
||||||
|
monthEnd,
|
||||||
|
mau: users.size,
|
||||||
|
daysElapsed,
|
||||||
|
daysInMonth,
|
||||||
|
projectedMau,
|
||||||
|
changePct: null,
|
||||||
|
isCurrentMonth
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < calendarMauMonths.length - 1; i++) {
|
||||||
|
const curr = calendarMauMonths[i];
|
||||||
|
const prev = calendarMauMonths[i + 1];
|
||||||
|
if (prev.mau > 0) {
|
||||||
|
const compareVal = curr.projectedMau ?? curr.mau;
|
||||||
|
curr.changePct = Math.round(((compareVal - prev.mau) / prev.mau) * 1000) / 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
todayEst,
|
||||||
|
sessionDepthCards,
|
||||||
|
stats: {
|
||||||
|
todayCount,
|
||||||
|
totalCount,
|
||||||
|
uniquePlayers,
|
||||||
|
weeklyPlayers,
|
||||||
|
activeStreaks,
|
||||||
|
avgGuessesToday,
|
||||||
|
registeredUsers,
|
||||||
|
monthlyPlayers
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
completionsVelocity,
|
||||||
|
completionsAcceleration,
|
||||||
|
userVelocity,
|
||||||
|
userAcceleration,
|
||||||
|
newUsers7d,
|
||||||
|
churned7d,
|
||||||
|
netGrowth7d
|
||||||
|
},
|
||||||
|
last14Days,
|
||||||
|
streakChart,
|
||||||
|
retention7dSeries,
|
||||||
|
retention30dSeries,
|
||||||
|
overallReturnRate,
|
||||||
|
newPlayerReturnSeries: newPlayerReturnSeries.slice(-30).reverse(),
|
||||||
|
newPlayerReturnVelocity: {
|
||||||
|
current7dAvg: current7dReturnAvg,
|
||||||
|
prior7dAvg: prior7dReturnAvg,
|
||||||
|
change: returnRateChange
|
||||||
|
},
|
||||||
|
wauWeeks,
|
||||||
|
avgWau,
|
||||||
|
mauMonths,
|
||||||
|
calendarMauMonths
|
||||||
|
};
|
||||||
|
};
|
||||||
883
src/routes/global/+page.svelte
Normal file
883
src/routes/global/+page.svelte
Normal file
@@ -0,0 +1,883 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from "$lib/components/Container.svelte";
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
todayCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
uniquePlayers: number;
|
||||||
|
weeklyPlayers: number;
|
||||||
|
activeStreaks: number;
|
||||||
|
avgGuessesToday: number | null;
|
||||||
|
registeredUsers: number;
|
||||||
|
monthlyPlayers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageData {
|
||||||
|
todayEst: string;
|
||||||
|
stats: Stats;
|
||||||
|
last14Days: { date: string; count: number }[];
|
||||||
|
streakChart: { days: number; count: number }[];
|
||||||
|
growth: {
|
||||||
|
completionsVelocity: number;
|
||||||
|
completionsAcceleration: number;
|
||||||
|
userVelocity: number;
|
||||||
|
userAcceleration: number;
|
||||||
|
newUsers7d: number;
|
||||||
|
churned7d: 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;
|
||||||
|
};
|
||||||
|
wauWeeks: {
|
||||||
|
weekStart: string;
|
||||||
|
weekEnd: string;
|
||||||
|
wau: number;
|
||||||
|
changePct: number | null;
|
||||||
|
}[];
|
||||||
|
avgWau: number;
|
||||||
|
mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[];
|
||||||
|
calendarMauMonths: {
|
||||||
|
label: string;
|
||||||
|
monthStart: string;
|
||||||
|
monthEnd: string;
|
||||||
|
mau: number;
|
||||||
|
daysElapsed: number;
|
||||||
|
daysInMonth: number;
|
||||||
|
projectedMau: number | null;
|
||||||
|
changePct: number | null;
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
}[];
|
||||||
|
sessionDepthCards: { depth: number; players: number; returnRate: number | null }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
const {
|
||||||
|
stats,
|
||||||
|
last14Days,
|
||||||
|
todayEst,
|
||||||
|
streakChart,
|
||||||
|
growth,
|
||||||
|
retention7dSeries,
|
||||||
|
retention30dSeries,
|
||||||
|
overallReturnRate,
|
||||||
|
newPlayerReturnSeries,
|
||||||
|
newPlayerReturnVelocity,
|
||||||
|
wauWeeks,
|
||||||
|
avgWau,
|
||||||
|
mauMonths,
|
||||||
|
calendarMauMonths,
|
||||||
|
sessionDepthCards,
|
||||||
|
} = $derived(data);
|
||||||
|
|
||||||
|
// Collapsible table state
|
||||||
|
let returnExpanded = $state(false);
|
||||||
|
let wauExpanded = $state(false);
|
||||||
|
let completionsExpanded = $state(false);
|
||||||
|
let streakExpanded = $state(false);
|
||||||
|
let ret7dExpanded = $state(false);
|
||||||
|
let ret30dExpanded = $state(false);
|
||||||
|
let mauExpanded = $state(false);
|
||||||
|
let mauMode = $state<'rolling' | 'calendar'>('rolling');
|
||||||
|
|
||||||
|
function signed(n: number, unit = ""): string {
|
||||||
|
if (n > 0) return `+${n}${unit}`;
|
||||||
|
if (n < 0) return `${n}${unit}`;
|
||||||
|
return `0${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendColor(n: number): string {
|
||||||
|
if (n > 0) return "text-green-400";
|
||||||
|
if (n < 0) return "text-red-400";
|
||||||
|
return "text-gray-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
|
||||||
|
|
||||||
|
const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau)));
|
||||||
|
|
||||||
|
const maxStreakCount = $derived(
|
||||||
|
Math.max(1, ...streakChart.map((r) => r.count)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const statCards = $derived([
|
||||||
|
{ label: "Completions Today", value: String(stats.todayCount) },
|
||||||
|
{ label: "All-Time Completions", value: String(stats.totalCount) },
|
||||||
|
{ label: "Unique Players", value: String(stats.uniquePlayers) },
|
||||||
|
{ label: "Players This Week", value: String(stats.weeklyPlayers) },
|
||||||
|
{ label: "Active Streaks", value: String(stats.activeStreaks) },
|
||||||
|
{ label: "Registered Users", value: String(stats.registeredUsers) },
|
||||||
|
{ label: "Players This Month", value: String(stats.monthlyPlayers) },
|
||||||
|
{
|
||||||
|
label: "Overall Return Rate",
|
||||||
|
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Global Stats | Bibdle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 text-gray-100"
|
||||||
|
>
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center gap-1 text-gray-400 hover:text-gray-100 text-sm mb-6 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Game
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<header class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-100">Global Stats</h1>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">
|
||||||
|
EST reference date: {todayEst}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-10"
|
||||||
|
>
|
||||||
|
{#each statCards as card (card.label)}
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>{card.label}</span
|
||||||
|
>
|
||||||
|
<span class="text-2xl md:text-3xl font-bold text-gray-100"
|
||||||
|
>{card.value}</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||||
|
Traffic & Growth <span
|
||||||
|
class="text-xs font-normal text-gray-400"
|
||||||
|
>(7-day windows)</span
|
||||||
|
>
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>Completions Velocity</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
growth.completionsVelocity,
|
||||||
|
)}">{signed(growth.completionsVelocity, "/day")}</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"
|
||||||
|
>Completions Accel.</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
growth.completionsAcceleration,
|
||||||
|
)}"
|
||||||
|
>{signed(growth.completionsAcceleration, "/day")}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>rate of change of velocity</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>User Velocity</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
growth.userVelocity,
|
||||||
|
)}">{signed(growth.userVelocity)}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>unique players, wk/wk</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>User Acceleration</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
growth.userAcceleration,
|
||||||
|
)}">{signed(growth.userAcceleration)}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>rate of change of user velocity</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>New Players (7d)</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
growth.newUsers7d,
|
||||||
|
)}">{String(growth.newUsers7d)}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>first-time players</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>Churned (7d)</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
0,
|
||||||
|
)}">{String(growth.churned7d)}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>played wk prior, not this wk</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>Net Growth (7d)</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
growth.netGrowth7d,
|
||||||
|
)}">{signed(growth.netGrowth7d)}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>new minus churned</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-1">Survival Curve</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-4">
|
||||||
|
Of players who completed N sessions, what % came back for N+1?
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
|
{#each sessionDepthCards as card (card.depth)}
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>After {card.depth} plays</span
|
||||||
|
>
|
||||||
|
<span class="text-2xl md:text-3xl font-bold text-gray-100">
|
||||||
|
{card.returnRate != null ? `${card.returnRate}%` : "N/A"}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>{card.players} players</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</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 (returnExpanded ? newPlayerReturnSeries : newPlayerReturnSeries.slice(0, 3)) 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>
|
||||||
|
{#if newPlayerReturnSeries.length > 3}
|
||||||
|
<button
|
||||||
|
onclick={() => (returnExpanded = !returnExpanded)}
|
||||||
|
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||||
|
>
|
||||||
|
<span>{returnExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="text-gray-400 text-sm px-4 py-6">
|
||||||
|
Not enough data yet.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-1">
|
||||||
|
Weekly Active Users
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-4">
|
||||||
|
Unique players per 7-day window. Most recent week first. Avg
|
||||||
|
WAU: <span class="text-gray-100 font-medium">{avgWau}</span>
|
||||||
|
</p>
|
||||||
|
<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">Week</th>
|
||||||
|
<th class="text-right px-4 py-3">Active Users</th>
|
||||||
|
<th class="text-right px-4 py-3">Wk/Wk Change</th>
|
||||||
|
<th class="px-4 py-3 w-48"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each (wauExpanded ? wauWeeks : wauWeeks.slice(0, 3)) as row (row.weekEnd)}
|
||||||
|
{@const barPct = Math.round(
|
||||||
|
(row.wau / maxWau) * 100,
|
||||||
|
)}
|
||||||
|
<tr
|
||||||
|
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 text-gray-300 text-xs"
|
||||||
|
>{row.weekStart} – {row.weekEnd}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="px-4 py-3 text-right text-gray-100 font-medium"
|
||||||
|
>{row.wau}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="px-4 py-3 text-right text-xs font-medium {row.changePct !=
|
||||||
|
null
|
||||||
|
? row.changePct > 0
|
||||||
|
? 'text-green-400'
|
||||||
|
: row.changePct < 0
|
||||||
|
? 'text-red-400'
|
||||||
|
: 'text-gray-400'
|
||||||
|
: 'text-gray-500'}"
|
||||||
|
>
|
||||||
|
{row.changePct != null
|
||||||
|
? signed(row.changePct, "%")
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-24">
|
||||||
|
<div
|
||||||
|
class="bg-indigo-500 h-4 rounded"
|
||||||
|
style="width: {barPct}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{#if wauWeeks.length > 3}
|
||||||
|
<button
|
||||||
|
onclick={() => (wauExpanded = !wauExpanded)}
|
||||||
|
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||||
|
>
|
||||||
|
<span>{wauExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100">Monthly Active Users</h2>
|
||||||
|
<div class="flex gap-1 bg-white/5 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onclick={() => (mauMode = 'rolling')}
|
||||||
|
class="px-3 py-1 text-xs rounded-md transition-colors {mauMode === 'rolling' ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
|
||||||
|
>Rolling 30d</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (mauMode = 'calendar')}
|
||||||
|
class="px-3 py-1 text-xs rounded-md transition-colors {mauMode === 'calendar' ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
|
||||||
|
>Calendar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-sm mb-4">
|
||||||
|
{mauMode === 'rolling' ? 'Unique players per 30-day window. Most recent first.' : 'Unique players per calendar month. Current month projected to end of month.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if mauMode === 'rolling'}
|
||||||
|
{@const displayedMauMonths = mauExpanded ? mauMonths : mauMonths.slice(0, 3)}
|
||||||
|
{@const maxMau = Math.max(1, ...mauMonths.map((m) => m.mau))}
|
||||||
|
<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">Period</th>
|
||||||
|
<th class="text-right px-4 py-3">Active Users</th>
|
||||||
|
<th class="text-right px-4 py-3">Mo/Mo Change</th>
|
||||||
|
<th class="px-4 py-3 w-48"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each displayedMauMonths as row (row.monthEnd)}
|
||||||
|
{@const barPct = Math.round((row.mau / maxMau) * 100)}
|
||||||
|
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-gray-300 text-xs">{row.monthStart} – {row.monthEnd}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.mau}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-xs font-medium {row.changePct != null ? row.changePct > 0 ? 'text-green-400' : row.changePct < 0 ? 'text-red-400' : 'text-gray-400' : 'text-gray-500'}">
|
||||||
|
{row.changePct != null ? signed(row.changePct, '%') : '—'}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-24">
|
||||||
|
<div class="bg-teal-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{#if mauMonths.length > 3}
|
||||||
|
<button
|
||||||
|
onclick={() => (mauExpanded = !mauExpanded)}
|
||||||
|
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||||
|
>
|
||||||
|
<span>{mauExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{@const displayedCalMau = mauExpanded ? calendarMauMonths : calendarMauMonths.slice(0, 3)}
|
||||||
|
{@const maxCalMau = Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau))}
|
||||||
|
<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">Month</th>
|
||||||
|
<th class="text-right px-4 py-3">Active Users</th>
|
||||||
|
<th class="text-right px-4 py-3">Mo/Mo Change</th>
|
||||||
|
<th class="px-4 py-3 w-48"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each displayedCalMau as row (row.monthStart)}
|
||||||
|
{@const displayMau = row.projectedMau ?? row.mau}
|
||||||
|
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
|
||||||
|
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-gray-300">
|
||||||
|
{row.label}
|
||||||
|
{#if row.isCurrentMonth}
|
||||||
|
<span class="text-xs text-gray-500 ml-1">(projected)</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right font-medium {row.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
|
||||||
|
{#if row.isCurrentMonth}
|
||||||
|
<span class="text-gray-500 text-xs">{row.mau} → </span>{row.projectedMau ?? row.mau}
|
||||||
|
{:else}
|
||||||
|
{row.mau}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right text-xs font-medium {row.changePct != null ? row.changePct > 0 ? 'text-green-400' : row.changePct < 0 ? 'text-red-400' : 'text-gray-400' : 'text-gray-500'}">
|
||||||
|
{#if row.changePct != null}
|
||||||
|
{row.isCurrentMonth ? '~' : ''}{signed(row.changePct, '%')}
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-24">
|
||||||
|
<div class="bg-teal-500 h-4 rounded {row.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{#if calendarMauMonths.length > 3}
|
||||||
|
<button
|
||||||
|
onclick={() => (mauExpanded = !mauExpanded)}
|
||||||
|
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||||
|
>
|
||||||
|
<span>{mauExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<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">
|
||||||
|
<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">Completions</th>
|
||||||
|
<th class="px-4 py-3 w-48"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each (completionsExpanded ? last14Days : last14Days.slice(0, 3)) as row (row.date)}
|
||||||
|
{@const barPct = Math.round(
|
||||||
|
(row.count / maxCount) * 100,
|
||||||
|
)}
|
||||||
|
<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-100 font-medium"
|
||||||
|
>{row.count}</td
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-24">
|
||||||
|
<div
|
||||||
|
class="bg-amber-500 h-4 rounded"
|
||||||
|
style="width: {barPct}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{#if last14Days.length > 3}
|
||||||
|
<button
|
||||||
|
onclick={() => (completionsExpanded = !completionsExpanded)}
|
||||||
|
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||||
|
>
|
||||||
|
<span>{completionsExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||||
|
Active Streak Distribution
|
||||||
|
</h2>
|
||||||
|
{#if streakChart.length === 0}
|
||||||
|
<p class="text-gray-400 text-sm px-4 py-6">
|
||||||
|
No active streaks 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">Days</th>
|
||||||
|
<th class="text-right px-4 py-3">Players</th>
|
||||||
|
<th class="px-4 py-3 w-48"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each (streakExpanded ? streakChart : streakChart.slice(0, 3)) as row (row.days)}
|
||||||
|
{@const barPct = Math.round(
|
||||||
|
(row.count / maxStreakCount) * 100,
|
||||||
|
)}
|
||||||
|
<tr
|
||||||
|
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 text-gray-300"
|
||||||
|
>{row.days}</td
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="px-4 py-3 text-right text-gray-100 font-medium"
|
||||||
|
>{row.count}</td
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-24">
|
||||||
|
<div
|
||||||
|
class="bg-blue-500 h-4 rounded"
|
||||||
|
style="width: {barPct}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{#if streakChart.length > 3}
|
||||||
|
<button
|
||||||
|
onclick={() => (streakExpanded = !streakExpanded)}
|
||||||
|
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||||
|
>
|
||||||
|
<span>{streakExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</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 played again exactly 7 or 30 days later (regardless of activity in between). 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 (ret7dExpanded ? retention7dSeries : retention7dSeries.slice(0, 3)) 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 retention7dSeries.length > 3}
|
||||||
|
<button
|
||||||
|
onclick={() => (ret7dExpanded = !ret7dExpanded)}
|
||||||
|
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||||
|
>
|
||||||
|
<span>{ret7dExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/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 (ret30dExpanded ? retention30dSeries : retention30dSeries.slice(0, 3)) 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 retention30dSeries.length > 3}
|
||||||
|
<button
|
||||||
|
onclick={() => (ret30dExpanded = !ret30dExpanded)}
|
||||||
|
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||||
|
>
|
||||||
|
<span>{ret30dExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
BIN
static/bibdle-logo-circle.png
Normal file
BIN
static/bibdle-logo-circle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
8
todo.md
8
todo.md
@@ -59,6 +59,14 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
|
|
||||||
# done
|
# done
|
||||||
|
|
||||||
|
## march 14th
|
||||||
|
|
||||||
|
- Added /global public dashboard with 8 stat cards: completions today, all-time, unique players, players this week, active streaks, avg guesses today, registered users, avg completions per player
|
||||||
|
- Added traffic & growth analytics section: completions velocity + acceleration, user velocity + acceleration, new players (7d), churned players (7d), net growth (7d)
|
||||||
|
- Added active streak distribution chart (bar chart by streak length)
|
||||||
|
- Added 14-day completions trend table with inline bar chart
|
||||||
|
- Fixed BIBDLE header color in dark mode
|
||||||
|
|
||||||
## march 12th
|
## march 12th
|
||||||
|
|
||||||
- Added about page with social buttons and XML sitemap for SEO
|
- Added about page with social buttons and XML sitemap for SEO
|
||||||
|
|||||||
Reference in New Issue
Block a user