mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
18 Commits
auth
...
321fac9aa8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
321fac9aa8 | ||
|
|
4a5aef5a3d | ||
|
|
f98ab24d2e | ||
|
|
c5b333bbb3 | ||
|
|
e842923d81 | ||
|
|
51bfb53a39 | ||
|
|
45d33b6bad | ||
|
|
3eb3a968dc | ||
|
|
67d9757f98 | ||
|
|
b6b41b6ba9 | ||
|
|
bdc08bc58e | ||
|
|
83cfcc66c0 | ||
|
|
e878dea235 | ||
|
|
252edc3a6d | ||
|
|
75b13280ef | ||
|
|
7007df2966 | ||
|
|
61673a646d | ||
|
|
1eb8eb2f04 |
@@ -1,5 +1,11 @@
|
||||
DATABASE_URL=example.db
|
||||
|
||||
# Cron job secret for protected endpoints (e.g. send-daily-verse)
|
||||
CRON_SECRET=your-cron-secret-here
|
||||
|
||||
# Discord webhook URL for posting the daily verse
|
||||
DISCORD_DAILY_WEBHOOK=https://discord.com/api/webhooks/your-webhook-url
|
||||
|
||||
PUBLIC_SITE_URL=https://bibdle.com
|
||||
|
||||
# nodemailer
|
||||
|
||||
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 |
87
export-verses.sh
Executable file
87
export-verses.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# Export all daily verses to JSON
|
||||
# Usage: ./export-verses.sh [path/to/database.db] [output.json]
|
||||
|
||||
DB="${1:-./local.db}"
|
||||
OUT="${2:-verses.json}"
|
||||
|
||||
sqlite3 "$DB" <<'SQL' > "$OUT"
|
||||
.mode json
|
||||
SELECT
|
||||
CASE book_id
|
||||
WHEN 'GEN' THEN 'Genesis'
|
||||
WHEN 'EXO' THEN 'Exodus'
|
||||
WHEN 'LEV' THEN 'Leviticus'
|
||||
WHEN 'NUM' THEN 'Numbers'
|
||||
WHEN 'DEU' THEN 'Deuteronomy'
|
||||
WHEN 'JOS' THEN 'Joshua'
|
||||
WHEN 'JDG' THEN 'Judges'
|
||||
WHEN 'RUT' THEN 'Ruth'
|
||||
WHEN '1SA' THEN '1 Samuel'
|
||||
WHEN '2SA' THEN '2 Samuel'
|
||||
WHEN '1KI' THEN '1 Kings'
|
||||
WHEN '2KI' THEN '2 Kings'
|
||||
WHEN '1CH' THEN '1 Chronicles'
|
||||
WHEN '2CH' THEN '2 Chronicles'
|
||||
WHEN 'EZR' THEN 'Ezra'
|
||||
WHEN 'NEH' THEN 'Nehemiah'
|
||||
WHEN 'EST' THEN 'Esther'
|
||||
WHEN 'JOB' THEN 'Job'
|
||||
WHEN 'PSA' THEN 'Psalms'
|
||||
WHEN 'PRO' THEN 'Proverbs'
|
||||
WHEN 'ECC' THEN 'Ecclesiastes'
|
||||
WHEN 'SNG' THEN 'Song of Solomon'
|
||||
WHEN 'ISA' THEN 'Isaiah'
|
||||
WHEN 'JER' THEN 'Jeremiah'
|
||||
WHEN 'LAM' THEN 'Lamentations'
|
||||
WHEN 'EZK' THEN 'Ezekiel'
|
||||
WHEN 'DAN' THEN 'Daniel'
|
||||
WHEN 'HOS' THEN 'Hosea'
|
||||
WHEN 'JOL' THEN 'Joel'
|
||||
WHEN 'AMO' THEN 'Amos'
|
||||
WHEN 'OBA' THEN 'Obadiah'
|
||||
WHEN 'JON' THEN 'Jonah'
|
||||
WHEN 'MIC' THEN 'Micah'
|
||||
WHEN 'NAM' THEN 'Nahum'
|
||||
WHEN 'HAB' THEN 'Habakkuk'
|
||||
WHEN 'ZEP' THEN 'Zephaniah'
|
||||
WHEN 'HAG' THEN 'Haggai'
|
||||
WHEN 'ZEC' THEN 'Zechariah'
|
||||
WHEN 'MAL' THEN 'Malachi'
|
||||
WHEN 'MAT' THEN 'Matthew'
|
||||
WHEN 'MRK' THEN 'Mark'
|
||||
WHEN 'LUK' THEN 'Luke'
|
||||
WHEN 'JHN' THEN 'John'
|
||||
WHEN 'ACT' THEN 'Acts'
|
||||
WHEN 'ROM' THEN 'Romans'
|
||||
WHEN '1CO' THEN '1 Corinthians'
|
||||
WHEN '2CO' THEN '2 Corinthians'
|
||||
WHEN 'GAL' THEN 'Galatians'
|
||||
WHEN 'EPH' THEN 'Ephesians'
|
||||
WHEN 'PHP' THEN 'Philippians'
|
||||
WHEN 'COL' THEN 'Colossians'
|
||||
WHEN '1TH' THEN '1 Thessalonians'
|
||||
WHEN '2TH' THEN '2 Thessalonians'
|
||||
WHEN '1TI' THEN '1 Timothy'
|
||||
WHEN '2TI' THEN '2 Timothy'
|
||||
WHEN 'TIT' THEN 'Titus'
|
||||
WHEN 'PHM' THEN 'Philemon'
|
||||
WHEN 'HEB' THEN 'Hebrews'
|
||||
WHEN 'JAS' THEN 'James'
|
||||
WHEN '1PE' THEN '1 Peter'
|
||||
WHEN '2PE' THEN '2 Peter'
|
||||
WHEN '1JN' THEN '1 John'
|
||||
WHEN '2JN' THEN '2 John'
|
||||
WHEN '3JN' THEN '3 John'
|
||||
WHEN 'JUD' THEN 'Jude'
|
||||
WHEN 'REV' THEN 'Revelation'
|
||||
ELSE book_id
|
||||
END AS book,
|
||||
verse_text AS verse,
|
||||
reference AS citation,
|
||||
date
|
||||
FROM daily_verses
|
||||
ORDER BY date;
|
||||
SQL
|
||||
|
||||
echo "Exported to $OUT"
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<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>
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<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 |
200
src/lib/components/ActivityCalendar.svelte
Normal file
200
src/lib/components/ActivityCalendar.svelte
Normal file
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
|
||||
interface Props {
|
||||
completions: Array<{ date: string; guessCount: number }>;
|
||||
}
|
||||
|
||||
let { completions }: Props = $props();
|
||||
|
||||
type CalendarCell = {
|
||||
date: string;
|
||||
dayNum: number;
|
||||
played: boolean;
|
||||
guessCount: number | null;
|
||||
} | null;
|
||||
|
||||
type CalendarRow = {
|
||||
cells: CalendarCell[];
|
||||
monthLabel: string | null;
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
function buildCalendar(
|
||||
completionList: Array<{ date: string; guessCount: number }>,
|
||||
localDate: string,
|
||||
): CalendarRow[] {
|
||||
const completionMap = new Map(
|
||||
completionList.map((c) => [c.date, c.guessCount]),
|
||||
);
|
||||
const today = new Date(localDate + "T00:00:00Z");
|
||||
|
||||
const days: Array<{
|
||||
date: string;
|
||||
dayNum: number;
|
||||
month: string;
|
||||
dayOfWeek: number;
|
||||
guessCount: number | null;
|
||||
}> = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setUTCDate(d.getUTCDate() - i);
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
days.push({
|
||||
date: dateStr,
|
||||
dayNum: d.getUTCDate(),
|
||||
month: dateStr.slice(0, 7),
|
||||
dayOfWeek: d.getUTCDay(),
|
||||
guessCount: completionMap.get(dateStr) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const rows: CalendarRow[] = [];
|
||||
let currentRow: CalendarCell[] = [];
|
||||
let currentMonth = "";
|
||||
let firstRowOfMonth = true;
|
||||
|
||||
for (const day of days) {
|
||||
if (day.month !== currentMonth) {
|
||||
if (currentRow.length > 0) {
|
||||
while (currentRow.length < 7) currentRow.push(null);
|
||||
rows.push({ cells: currentRow, monthLabel: null });
|
||||
currentRow = [];
|
||||
}
|
||||
for (let j = 0; j < day.dayOfWeek; j++) currentRow.push(null);
|
||||
currentMonth = day.month;
|
||||
firstRowOfMonth = true;
|
||||
}
|
||||
|
||||
currentRow.push({
|
||||
date: day.date,
|
||||
dayNum: day.dayNum,
|
||||
played: day.guessCount !== null,
|
||||
guessCount: day.guessCount ?? null,
|
||||
});
|
||||
|
||||
if (currentRow.length === 7) {
|
||||
const [year, monthIdx] = currentMonth.split("-").map(Number);
|
||||
const label = firstRowOfMonth
|
||||
? `${MONTH_NAMES[monthIdx - 1]} ${year}`
|
||||
: null;
|
||||
rows.push({ cells: currentRow, monthLabel: label });
|
||||
currentRow = [];
|
||||
firstRowOfMonth = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
while (currentRow.length < 7) currentRow.push(null);
|
||||
const [year, monthIdx] = currentMonth.split("-").map(Number);
|
||||
rows.push({
|
||||
cells: currentRow,
|
||||
monthLabel: firstRowOfMonth
|
||||
? `${MONTH_NAMES[monthIdx - 1]} ${year}`
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function calendarColor(day: {
|
||||
played: boolean;
|
||||
guessCount: number | null;
|
||||
}): string {
|
||||
if (!day.played) return "bg-gray-800/60 text-gray-400";
|
||||
const g = day.guessCount!;
|
||||
if (g === 1) return "bg-emerald-300";
|
||||
if (g <= 3) return "bg-emerald-500";
|
||||
if (g <= 5) return "bg-amber-400";
|
||||
if (g <= 7) return "bg-orange-500";
|
||||
return "bg-red-600";
|
||||
}
|
||||
|
||||
let calendarRows = $state<CalendarRow[]>([]);
|
||||
|
||||
onMount(() => {
|
||||
const localDate = new Date().toLocaleDateString("en-CA");
|
||||
calendarRows = buildCalendar(completions, localDate);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Container class="p-4 md:p-6 w-full">
|
||||
<h2 class="text-xl font-bold text-gray-100 mb-3 w-full text-left">
|
||||
Activity
|
||||
</h2>
|
||||
<!-- Day-of-week headers -->
|
||||
<div class="flex gap-1 mb-1">
|
||||
{#each ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as d (d)}
|
||||
<div
|
||||
class="w-10 h-5 text-center text-[10px] text-gray-500 font-medium shrink-0"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Calendar rows -->
|
||||
{#each calendarRows as row, rowIdx (rowIdx)}
|
||||
{#if row.monthLabel}
|
||||
<div class="text-xs text-gray-400 font-semibold mt-3 mb-1">
|
||||
{row.monthLabel}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-1 mb-1">
|
||||
{#each row.cells as cell, cellIdx (cellIdx)}
|
||||
{#if cell}
|
||||
<div
|
||||
class="w-10 h-10 rounded flex items-center justify-center text-sm font-semibold shrink-0 {calendarColor(
|
||||
cell,
|
||||
)}"
|
||||
title={cell.played
|
||||
? `${cell.date}: ${cell.guessCount} guess${cell.guessCount === 1 ? "" : "es"}`
|
||||
: cell.date}
|
||||
>
|
||||
{cell.dayNum}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-10 h-10 shrink-0"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-3 mt-3 text-xs text-gray-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-emerald-300"></span>
|
||||
1 guess
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-emerald-500"></span>
|
||||
2–3 guesses
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-amber-400"></span>
|
||||
4–5 guesses
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-orange-500"></span>
|
||||
6–7 guesses
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-red-600"></span>
|
||||
8+ guesses
|
||||
</span>
|
||||
</div>
|
||||
</Container>
|
||||
@@ -96,6 +96,7 @@
|
||||
<button
|
||||
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"
|
||||
data-umami-event="Sign in with Apple"
|
||||
>
|
||||
<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"/>
|
||||
|
||||
102
src/lib/components/CollapsibleTable.svelte
Normal file
102
src/lib/components/CollapsibleTable.svelte
Normal file
@@ -0,0 +1,102 @@
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Header {
|
||||
label: string;
|
||||
align?: 'left' | 'right';
|
||||
width?: string;
|
||||
}
|
||||
|
||||
interface Mode {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: T[];
|
||||
headers: Header[];
|
||||
row: Snippet<[item: T]>;
|
||||
empty?: Snippet;
|
||||
initialRows?: number;
|
||||
modes?: Mode[];
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
rows,
|
||||
headers,
|
||||
row: rowSnippet,
|
||||
empty,
|
||||
initialRows = 3,
|
||||
modes,
|
||||
mode = $bindable(modes && modes.length > 0 ? modes[0].value : undefined),
|
||||
}: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
// Reset expanded when mode changes (e.g. switching Rolling 30d ↔ Calendar)
|
||||
$effect(() => {
|
||||
mode;
|
||||
expanded = false;
|
||||
});
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded = !expanded;
|
||||
}
|
||||
|
||||
const displayedRows = $derived(expanded ? rows : rows.slice(0, initialRows));
|
||||
</script>
|
||||
|
||||
{#if modes && modes.length > 1}
|
||||
<div class="flex gap-1 bg-white/5 rounded-lg p-1 w-fit ml-auto mb-3">
|
||||
{#each modes as m (m.value)}
|
||||
{@const active = mode === m.value}
|
||||
<button
|
||||
onclick={() => (mode = m.value)}
|
||||
class="px-3 py-1 text-xs rounded-md transition-colors {active ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rows.length === 0}
|
||||
{#if empty}
|
||||
{@render empty()}
|
||||
{:else}
|
||||
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
|
||||
{/if}
|
||||
{: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">
|
||||
{#each headers as header (header.label)}
|
||||
<th
|
||||
class="{header.align === 'right' ? 'text-right' : 'text-left'} px-4 py-3{header.width ? ' ' + header.width : ''}"
|
||||
>
|
||||
{header.label}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each displayedRows as item, i (i)}
|
||||
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||
{@render rowSnippet(item)}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if rows.length > initialRows}
|
||||
<button
|
||||
onclick={toggleExpanded}
|
||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||
>
|
||||
<span>{expanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
let timeUntilNext = $state("");
|
||||
let newVerseReady = $state(false);
|
||||
let showEncouragement = $state(false);
|
||||
let intervalId: number | null = null;
|
||||
let targetTime = 0;
|
||||
|
||||
@@ -41,6 +42,13 @@
|
||||
initTarget();
|
||||
updateTimer();
|
||||
intervalId = window.setInterval(updateTimer, 1000);
|
||||
|
||||
const winCount = Object.keys(localStorage).filter(
|
||||
(k) =>
|
||||
k.startsWith("bibdle-win-tracked-") &&
|
||||
localStorage.getItem(k) === "true",
|
||||
).length;
|
||||
showEncouragement = winCount < 3;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -77,6 +85,13 @@
|
||||
>
|
||||
{timeUntilNext}
|
||||
</p>
|
||||
{#if showEncouragement}
|
||||
<p
|
||||
class="text-xs text-center uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold px-4 mt-3"
|
||||
>
|
||||
Come back tomorrow for a new verse!
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,12 @@
|
||||
>
|
||||
📊 View Stats
|
||||
</a>
|
||||
<a
|
||||
href="/progress"
|
||||
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
📈 View Progress
|
||||
</a>
|
||||
|
||||
{#if user}
|
||||
<form method="POST" action="/auth/logout" use:enhance class="w-full">
|
||||
|
||||
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 text-gray-100! mb-6 px-4"
|
||||
style="transition: opacity 0.3s ease; opacity: {visible ? 1 : 0};"
|
||||
>
|
||||
{promptText}
|
||||
</p>
|
||||
@@ -1,14 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
import { getFirstLetter, type Guess } from "$lib/utils/game";
|
||||
import Container from "./Container.svelte";
|
||||
|
||||
let {
|
||||
guesses,
|
||||
correctBookId,
|
||||
}: { guesses: Guess[]; correctBookId: string } = $props();
|
||||
minimized = false,
|
||||
}: { guesses: Guess[]; correctBookId: string; minimized?: boolean } = $props();
|
||||
|
||||
let hasGuesses = $derived(guesses.length > 0);
|
||||
let showMinimized = $derived(minimized);
|
||||
let expanded = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!minimized) expanded = false;
|
||||
});
|
||||
|
||||
function getBoxColor(isCorrect: boolean, isAdjacent?: boolean): string {
|
||||
if (isCorrect) return "bg-green-500 border-green-600";
|
||||
@@ -16,6 +22,13 @@
|
||||
return "bg-red-500 border-red-600";
|
||||
}
|
||||
|
||||
function getBookBoxStyle(guess: Guess): string {
|
||||
if (guess.book.id === correctBookId) {
|
||||
return "background-color: #22c55e; border-color: #16a34a;";
|
||||
}
|
||||
return "background-color: #ef4444; border-color: #dc2626;";
|
||||
}
|
||||
|
||||
function getBoxContent(
|
||||
guess: Guess,
|
||||
column: "book" | "firstLetter" | "testament" | "section",
|
||||
@@ -64,17 +77,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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}
|
||||
{#if hasGuesses}
|
||||
<div class="space-y-3">
|
||||
<!-- Column Headers -->
|
||||
<div
|
||||
@@ -102,31 +105,35 @@
|
||||
</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(
|
||||
guess.testamentMatch,
|
||||
firstGuess.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"
|
||||
>{getBoxContent(guess, "testament")}</span
|
||||
>{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(
|
||||
guess.sectionMatch,
|
||||
guess.adjacent,
|
||||
firstGuess.sectionMatch,
|
||||
firstGuess.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"
|
||||
>{getBoxContent(guess, "section")}
|
||||
{#if guess.adjacent}
|
||||
>{getBoxContent(firstGuess, "section")}
|
||||
{#if firstGuess.adjacent}
|
||||
‼️
|
||||
{/if}
|
||||
</span>
|
||||
@@ -135,30 +142,173 @@
|
||||
<!-- 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,
|
||||
firstGuess.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"
|
||||
>{getBoxContent(guess, "firstLetter")}</span
|
||||
>{getBoxContent(firstGuess, "firstLetter")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Book Column -->
|
||||
<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(
|
||||
guess.book.id === correctBookId,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
|
||||
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(guess, "book")}</span
|
||||
>{getBoxContent(firstGuess, "book")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- 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">
|
||||
<!-- 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="animation: none; opacity: 1; transform: none;"
|
||||
>
|
||||
<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="animation: none; opacity: 1; transform: none;"
|
||||
>
|
||||
<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="animation: none; opacity: 1; transform: none;"
|
||||
>
|
||||
<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="animation: none; opacity: 1; transform: none; {getBookBoxStyle(guess)}"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||
>{getBoxContent(guess, "book")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/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> -->
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
||||
25
src/lib/components/ProgressStatCard.svelte
Normal file
25
src/lib/components/ProgressStatCard.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
|
||||
interface Props {
|
||||
emoji: string;
|
||||
value: string;
|
||||
label: string;
|
||||
colorClass: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
let { emoji, value, label, colorClass, suffix }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Container class="p-4 md:p-6 w-full">
|
||||
<div class="text-center w-full">
|
||||
<div class="text-2xl md:text-3xl mb-1">{emoji}</div>
|
||||
<div class="text-2xl md:text-3xl font-bold {colorClass} mb-1">
|
||||
{value}{#if suffix}<span class="text-base font-normal text-gray-400"
|
||||
> {suffix}</span
|
||||
>{/if}
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-gray-300 font-medium">{label}</div>
|
||||
</div>
|
||||
</Container>
|
||||
@@ -1,309 +1,355 @@
|
||||
<script lang="ts">
|
||||
import { bibleBooks, type BibleBook, type BibleSection, type Testament } from "$lib/types/bible";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import {
|
||||
bibleBooks,
|
||||
type BibleBook,
|
||||
type BibleSection,
|
||||
type Testament,
|
||||
} from "$lib/types/bible";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
|
||||
let {
|
||||
searchQuery = $bindable(""),
|
||||
guessedIds,
|
||||
submitGuess,
|
||||
guessCount = 0,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
guessedIds: SvelteSet<string>;
|
||||
submitGuess: (id: string) => void;
|
||||
guessCount: number;
|
||||
} = $props();
|
||||
let {
|
||||
searchQuery = $bindable(""),
|
||||
guessedIds,
|
||||
submitGuess,
|
||||
guessCount = 0,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
guessedIds: SvelteSet<string>;
|
||||
submitGuess: (id: string) => void;
|
||||
guessCount: number;
|
||||
} = $props();
|
||||
|
||||
type DisplayMode = "simple" | "testament" | "sections";
|
||||
type DisplayMode = "simple" | "testament" | "sections";
|
||||
|
||||
const displayMode = $derived<DisplayMode>(
|
||||
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple"
|
||||
);
|
||||
const displayMode = $derived<DisplayMode>(
|
||||
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple",
|
||||
);
|
||||
|
||||
const filteredBooks = $derived(
|
||||
bibleBooks.filter((book) =>
|
||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
const filteredBooks = $derived(
|
||||
bibleBooks.filter((book) =>
|
||||
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
type SimpleGroup = { books: BibleBook[] };
|
||||
type SimpleGroup = { books: BibleBook[] };
|
||||
|
||||
type TestamentGroup = {
|
||||
testament: Testament;
|
||||
label: string;
|
||||
books: BibleBook[];
|
||||
};
|
||||
type TestamentGroup = {
|
||||
testament: Testament;
|
||||
label: string;
|
||||
books: BibleBook[];
|
||||
};
|
||||
|
||||
type SectionGroup = {
|
||||
testament: Testament;
|
||||
testamentLabel: string;
|
||||
showTestamentHeader: boolean;
|
||||
section: BibleSection;
|
||||
books: BibleBook[];
|
||||
};
|
||||
type SectionGroup = {
|
||||
testament: Testament;
|
||||
testamentLabel: string;
|
||||
showTestamentHeader: boolean;
|
||||
section: BibleSection;
|
||||
books: BibleBook[];
|
||||
};
|
||||
|
||||
const simpleGroup = $derived.by<SimpleGroup>(() => {
|
||||
const sorted = [...filteredBooks].sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
return { books: sorted };
|
||||
});
|
||||
const simpleGroup = $derived.by<SimpleGroup>(() => {
|
||||
const sorted = [...filteredBooks].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
return { books: sorted };
|
||||
});
|
||||
|
||||
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
|
||||
const old = filteredBooks
|
||||
.filter((b) => b.testament === "old")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const newT = filteredBooks
|
||||
.filter((b) => b.testament === "new")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const groups: TestamentGroup[] = [];
|
||||
if (old.length > 0) {
|
||||
groups.push({ testament: "old", label: "Old Testament", books: old });
|
||||
}
|
||||
if (newT.length > 0) {
|
||||
groups.push({ testament: "new", label: "New Testament", books: newT });
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
|
||||
const old = filteredBooks
|
||||
.filter((b) => b.testament === "old")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const newT = filteredBooks
|
||||
.filter((b) => b.testament === "new")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const groups: TestamentGroup[] = [];
|
||||
if (old.length > 0) {
|
||||
groups.push({
|
||||
testament: "old",
|
||||
label: "Old Testament",
|
||||
books: old,
|
||||
});
|
||||
}
|
||||
if (newT.length > 0) {
|
||||
groups.push({
|
||||
testament: "new",
|
||||
label: "New Testament",
|
||||
books: newT,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const sectionGroups = $derived.by<SectionGroup[]>(() => {
|
||||
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
|
||||
const seenKeys: Record<string, true> = {};
|
||||
const orderedPairs: { testament: Testament; section: BibleSection }[] = [];
|
||||
const sectionGroups = $derived.by<SectionGroup[]>(() => {
|
||||
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
|
||||
const seenKeys: Record<string, true> = {};
|
||||
const orderedPairs: { testament: Testament; section: BibleSection }[] =
|
||||
[];
|
||||
|
||||
for (const book of bibleBooks) {
|
||||
const key = `${book.testament}:${book.section}`;
|
||||
if (!seenKeys[key]) {
|
||||
seenKeys[key] = true;
|
||||
orderedPairs.push({ testament: book.testament, section: book.section });
|
||||
}
|
||||
}
|
||||
for (const book of bibleBooks) {
|
||||
const key = `${book.testament}:${book.section}`;
|
||||
if (!seenKeys[key]) {
|
||||
seenKeys[key] = true;
|
||||
orderedPairs.push({
|
||||
testament: book.testament,
|
||||
section: book.section,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const groups: SectionGroup[] = [];
|
||||
let lastTestament: Testament | null = null;
|
||||
const groups: SectionGroup[] = [];
|
||||
let lastTestament: Testament | null = null;
|
||||
|
||||
for (const pair of orderedPairs) {
|
||||
const books = filteredBooks.filter(
|
||||
(b) => b.testament === pair.testament && b.section === pair.section
|
||||
);
|
||||
if (books.length === 0) continue;
|
||||
for (const pair of orderedPairs) {
|
||||
const books = filteredBooks.filter(
|
||||
(b) =>
|
||||
b.testament === pair.testament &&
|
||||
b.section === pair.section,
|
||||
);
|
||||
if (books.length === 0) continue;
|
||||
|
||||
const showTestamentHeader = pair.testament !== lastTestament;
|
||||
lastTestament = pair.testament;
|
||||
const showTestamentHeader = pair.testament !== lastTestament;
|
||||
lastTestament = pair.testament;
|
||||
|
||||
groups.push({
|
||||
testament: pair.testament,
|
||||
testamentLabel:
|
||||
pair.testament === "old" ? "Old Testament" : "New Testament",
|
||||
showTestamentHeader,
|
||||
section: pair.section,
|
||||
books,
|
||||
});
|
||||
}
|
||||
groups.push({
|
||||
testament: pair.testament,
|
||||
testamentLabel:
|
||||
pair.testament === "old"
|
||||
? "Old Testament"
|
||||
: "New Testament",
|
||||
showTestamentHeader,
|
||||
section: pair.section,
|
||||
books,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
|
||||
// First book in display order for Enter key submission
|
||||
const firstBookId = $derived.by<string | null>(() => {
|
||||
if (filteredBooks.length === 0) return null;
|
||||
if (displayMode === "simple") {
|
||||
return simpleGroup.books[0]?.id ?? null;
|
||||
}
|
||||
if (displayMode === "testament") {
|
||||
return testamentGroups[0]?.books[0]?.id ?? null;
|
||||
}
|
||||
return sectionGroups[0]?.books[0]?.id ?? null;
|
||||
});
|
||||
// First book in display order for Enter key submission
|
||||
const firstBookId = $derived.by<string | null>(() => {
|
||||
if (filteredBooks.length === 0) return null;
|
||||
if (displayMode === "simple") {
|
||||
return simpleGroup.books[0]?.id ?? null;
|
||||
}
|
||||
if (displayMode === "testament") {
|
||||
return testamentGroups[0]?.books[0]?.id ?? null;
|
||||
}
|
||||
return sectionGroups[0]?.books[0]?.id ?? null;
|
||||
});
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && firstBookId) {
|
||||
submitGuess(firstBookId);
|
||||
}
|
||||
}
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && firstBookId) {
|
||||
submitGuess(firstBookId);
|
||||
}
|
||||
}
|
||||
|
||||
const showBanner = $derived(guessCount >= 3);
|
||||
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||
// const showBanner = $derived(guessCount >= 3);
|
||||
const showBanner = false;
|
||||
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||
</script>
|
||||
|
||||
{#if showBanner}
|
||||
<p
|
||||
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if bannerIsIndigo}
|
||||
Testament & section groups now visible
|
||||
{:else}
|
||||
Old & New Testament groups now visible
|
||||
{/if}
|
||||
</p>
|
||||
<p
|
||||
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if bannerIsIndigo}
|
||||
Testament & section groups now visible
|
||||
{:else}
|
||||
Old & New Testament groups now visible
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
bind:value={searchQuery}
|
||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 dark:border-gray-600 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 dark:focus:border-blue-400 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-900/50 transition-all bg-white dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
||||
onkeydown={handleKeydown}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
onclick={() => (searchQuery = "")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
bind:value={searchQuery}
|
||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 dark:border-gray-600 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 dark:focus:border-blue-400 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-900/50 transition-all bg-white dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
||||
onkeydown={handleKeydown}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
onclick={() => (searchQuery = "")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if searchQuery && filteredBooks.length > 0}
|
||||
<ul
|
||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-2xl shadow-xl"
|
||||
role="listbox"
|
||||
>
|
||||
{#if displayMode === "simple"}
|
||||
{#each simpleGroup.books as book (book.id)}
|
||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||
{#if searchQuery && filteredBooks.length > 0}
|
||||
<ul
|
||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-2xl shadow-xl"
|
||||
role="listbox"
|
||||
>
|
||||
{#if displayMode === "simple"}
|
||||
{#each simpleGroup.books as book (book.id)}
|
||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||
{guessedIds.has(book.id)
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold dark:text-gray-100 {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{:else if displayMode === "testament"}
|
||||
{#each testamentGroups as group (group.testament)}
|
||||
<li role="presentation">
|
||||
<div
|
||||
class="px-5 py-2 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each group.books as book (book.id)}
|
||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold dark:text-gray-100 {guessedIds.has(
|
||||
book.id,
|
||||
)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{:else if displayMode === "testament"}
|
||||
{#each testamentGroups as group (group.testament)}
|
||||
<li role="presentation">
|
||||
<div
|
||||
class="px-5 py-2 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div
|
||||
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
|
||||
></div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each group.books as book (book.id)}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={guessedIds.has(book.id)}
|
||||
>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||
{guessedIds.has(book.id)
|
||||
? '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'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
|
||||
<li role="presentation">
|
||||
{#if group.showTestamentHeader}
|
||||
<div
|
||||
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{group.testamentLabel}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{group.section}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-100 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each group.books as book (book.id)}
|
||||
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||
? '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'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id)
|
||||
? -1
|
||||
: 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(
|
||||
book.id,
|
||||
)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
|
||||
<li role="presentation">
|
||||
{#if group.showTestamentHeader}
|
||||
<div
|
||||
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{group.testamentLabel}
|
||||
</span>
|
||||
<div
|
||||
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{group.section}
|
||||
</span>
|
||||
<div
|
||||
class="flex-1 h-px bg-gray-100 dark:bg-gray-600"
|
||||
></div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each group.books as book (book.id)}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={guessedIds.has(book.id)}
|
||||
>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||
{guessedIds.has(book.id)
|
||||
? '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'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
{:else if searchQuery}
|
||||
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">No books found</p>
|
||||
{/if}
|
||||
? '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'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id)
|
||||
? -1
|
||||
: 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(
|
||||
book.id,
|
||||
)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
{:else if searchQuery}
|
||||
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">
|
||||
No books found
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
streak = 0,
|
||||
streakPercentile = null,
|
||||
isLoggedIn = false,
|
||||
anonymousId = '',
|
||||
anonymousId = "",
|
||||
}: {
|
||||
statsData: StatsData | null;
|
||||
correctBookId: string;
|
||||
@@ -307,6 +307,11 @@
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
{#if showSnippetOption}
|
||||
@@ -324,17 +329,30 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isLoggedIn}
|
||||
{#if isLoggedIn}
|
||||
<div class="signin-prompt">
|
||||
<p class="signin-text">Sign in to save your streak & see your stats</p>
|
||||
<form method="POST" action="/auth/apple">
|
||||
<a href="/progress" class="progress-btn"> 📈 See your progress </a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="signin-prompt">
|
||||
<p class="signin-text">
|
||||
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} />
|
||||
<button
|
||||
type="submit"
|
||||
class="apple-signin-btn"
|
||||
data-umami-event="Sign in with Apple"
|
||||
>
|
||||
<svg 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
|
||||
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>
|
||||
Sign in with Apple
|
||||
</button>
|
||||
@@ -627,7 +645,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0 0.25rem;
|
||||
/*padding: 1rem 0 0.25rem;*/
|
||||
}
|
||||
|
||||
.signin-text {
|
||||
@@ -648,7 +666,9 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.6rem;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
@@ -656,7 +676,9 @@
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, transform 80ms ease;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
transform 80ms ease;
|
||||
}
|
||||
|
||||
.apple-signin-btn:hover {
|
||||
@@ -687,4 +709,43 @@
|
||||
height: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.6rem;
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
transform 80ms ease;
|
||||
}
|
||||
.progress-btn:hover {
|
||||
background: #047857;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.progress-btn:active {
|
||||
background: #065f46;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.progress-btn {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
.progress-btn:hover {
|
||||
background: #059669;
|
||||
}
|
||||
.progress-btn:active {
|
||||
background: #047857;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
282
src/lib/server/milestones.ts
Normal file
282
src/lib/server/milestones.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { bibleBooks } from '$lib/types/bible';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import type { DailyCompletion } from '$lib/server/db/schema';
|
||||
|
||||
export type Milestone = {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
description: string;
|
||||
achieved: boolean;
|
||||
achievedDate: string | null; // YYYY-MM-DD of first achievement, or null
|
||||
};
|
||||
|
||||
export type ClassicMilestoneInputs = {
|
||||
bestSingleGame: { date: string; bookName: string } | null;
|
||||
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
|
||||
};
|
||||
|
||||
export async function calculateMilestones(
|
||||
completions: DailyCompletion[],
|
||||
dateToBookId: Map<string, string>,
|
||||
classic: ClassicMilestoneInputs,
|
||||
): Promise<Milestone[]> {
|
||||
const sorted = [...completions].sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
// Helper: returns the date when all books in targetIds were first solved
|
||||
function findSetDate(targetIds: Set<string>): string | null {
|
||||
const solved = new Set<string>();
|
||||
for (const c of sorted) {
|
||||
const bookId = dateToBookId.get(c.date);
|
||||
if (bookId && targetIds.has(bookId)) {
|
||||
solved.add(bookId);
|
||||
if (solved.size === targetIds.size) return c.date;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Book sets
|
||||
const ntIds = new Set(bibleBooks.filter(b => b.testament === 'new').map(b => b.id));
|
||||
const otIds = new Set(bibleBooks.filter(b => b.testament === 'old').map(b => b.id));
|
||||
const allIds = new Set(bibleBooks.map(b => b.id));
|
||||
const gospelIds = new Set(['MAT', 'MRK', 'LUK', 'JHN']);
|
||||
const pentateuchIds = new Set(['GEN', 'EXO', 'LEV', 'NUM', 'DEU']);
|
||||
|
||||
// Set-completion milestones
|
||||
const ntScholarDate = findSetDate(ntIds);
|
||||
const otScholarDate = findSetDate(otIds);
|
||||
const theologianDate = findSetDate(allIds);
|
||||
const fantasticFourDate = findSetDate(gospelIds);
|
||||
const pentatonixDate = findSetDate(pentateuchIds);
|
||||
|
||||
// With God, All Things Are Possible — solved in 1 guess for at least one puzzle from each of the 66 books
|
||||
const booksInOne = new Set<string>();
|
||||
let withGodDate: string | null = null;
|
||||
for (const c of sorted) {
|
||||
if (c.guessCount === 1) {
|
||||
const bookId = dateToBookId.get(c.date);
|
||||
if (bookId) {
|
||||
booksInOne.add(bookId);
|
||||
if (withGodDate === null && booksInOne.size === allIds.size) {
|
||||
withGodDate = c.date;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const allInOne = booksInOne.size === allIds.size;
|
||||
|
||||
// Is This A Joke To You? — guessed all 65 other books first (66 guesses total)
|
||||
const jokeCompletion = sorted.find(c => c.guessCount >= 66);
|
||||
|
||||
// Prodigal Son — returned after a 30+ day gap
|
||||
let prodigalDate: string | null = null;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = new Date(sorted[i - 1].date + 'T00:00:00Z');
|
||||
const curr = new Date(sorted[i].date + 'T00:00:00Z');
|
||||
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
|
||||
if (diff >= 30) {
|
||||
prodigalDate = sorted[i].date;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extra Credit — solved on a Sunday
|
||||
const sundayCompletion = sorted.find(c => {
|
||||
const d = new Date(c.date + 'T00:00:00Z');
|
||||
return d.getUTCDay() === 0;
|
||||
});
|
||||
|
||||
// Cross-user milestones: Overachiever, Procrastinator, Outlier
|
||||
let overachieverDate: string | null = null;
|
||||
let procrastinatorDate: string | null = null;
|
||||
let outlierDate: string | null = null;
|
||||
|
||||
if (sorted.length > 0) {
|
||||
const userDates = sorted.map(c => c.date);
|
||||
const allOnDates = await db
|
||||
.select({
|
||||
date: dailyCompletions.date,
|
||||
completedAt: dailyCompletions.completedAt,
|
||||
guessCount: dailyCompletions.guessCount,
|
||||
anonymousId: dailyCompletions.anonymousId,
|
||||
})
|
||||
.from(dailyCompletions)
|
||||
.where(inArray(dailyCompletions.date, userDates));
|
||||
|
||||
// Group all completions by date
|
||||
const byDate = new Map<string, typeof allOnDates>();
|
||||
for (const c of allOnDates) {
|
||||
const arr = byDate.get(c.date) ?? [];
|
||||
arr.push(c);
|
||||
byDate.set(c.date, arr);
|
||||
}
|
||||
|
||||
const userByDate = new Map(sorted.map(c => [c.date, c]));
|
||||
|
||||
for (const userComp of sorted) {
|
||||
const allForDate = byDate.get(userComp.date) ?? [];
|
||||
if (allForDate.length < 2) continue; // need multiple players
|
||||
|
||||
const validTimes = allForDate
|
||||
.filter(c => c.completedAt != null)
|
||||
.map(c => c.completedAt!.getTime());
|
||||
|
||||
if (!overachieverDate && userComp.completedAt && validTimes.length > 0) {
|
||||
const earliest = Math.min(...validTimes);
|
||||
if (userComp.completedAt.getTime() === earliest) {
|
||||
overachieverDate = userComp.date;
|
||||
}
|
||||
}
|
||||
|
||||
if (!procrastinatorDate && userComp.completedAt && validTimes.length > 0) {
|
||||
const latest = Math.max(...validTimes);
|
||||
if (userComp.completedAt.getTime() === latest) {
|
||||
procrastinatorDate = userComp.date;
|
||||
}
|
||||
}
|
||||
|
||||
if (!outlierDate && allForDate.length >= 10) {
|
||||
const sortedGuesses = allForDate.map(c => c.guessCount).sort((a, b) => a - b);
|
||||
const cutoffIndex = Math.ceil(sortedGuesses.length * 0.1) - 1;
|
||||
const cutoff = sortedGuesses[cutoffIndex];
|
||||
if (userComp.guessCount <= cutoff) {
|
||||
outlierDate = userComp.date;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'first-1-guess',
|
||||
name: 'Lightning Strike',
|
||||
emoji: '⚡',
|
||||
description: `First 1-guess solve${classic.bestSingleGame ? ` — ${classic.bestSingleGame.bookName}` : ''}`,
|
||||
achieved: classic.bestSingleGame !== null,
|
||||
achievedDate: classic.bestSingleGame?.date ?? null,
|
||||
},
|
||||
{
|
||||
id: 'streak-7',
|
||||
name: '7-Day Streak',
|
||||
emoji: '🔥',
|
||||
description: 'Solve Bibdle 7 days in a row',
|
||||
achieved: classic.streakMilestones.days7 !== null,
|
||||
achievedDate: classic.streakMilestones.days7,
|
||||
},
|
||||
{
|
||||
id: 'streak-14',
|
||||
name: '14-Day Streak',
|
||||
emoji: '💥',
|
||||
description: 'Solve Bibdle 14 days in a row',
|
||||
achieved: classic.streakMilestones.days14 !== null,
|
||||
achievedDate: classic.streakMilestones.days14,
|
||||
},
|
||||
{
|
||||
id: 'streak-30',
|
||||
name: '30-Day Streak',
|
||||
emoji: '🏅',
|
||||
description: 'Solve Bibdle 30 days in a row',
|
||||
achieved: classic.streakMilestones.days30 !== null,
|
||||
achievedDate: classic.streakMilestones.days30,
|
||||
},
|
||||
{
|
||||
id: 'nt-scholar',
|
||||
name: 'NT Scholar',
|
||||
emoji: '✝️',
|
||||
description: 'Solve for every New Testament book',
|
||||
achieved: ntScholarDate !== null,
|
||||
achievedDate: ntScholarDate,
|
||||
},
|
||||
{
|
||||
id: 'ot-scholar',
|
||||
name: 'OT Scholar',
|
||||
emoji: '📜',
|
||||
description: 'Solve for every Old Testament book',
|
||||
achieved: otScholarDate !== null,
|
||||
achievedDate: otScholarDate,
|
||||
},
|
||||
{
|
||||
id: 'theologian',
|
||||
name: 'Theologian',
|
||||
emoji: '🎓',
|
||||
description: 'Solve for all 66 books of the Bible',
|
||||
achieved: theologianDate !== null,
|
||||
achievedDate: theologianDate,
|
||||
},
|
||||
{
|
||||
id: 'fantastic-four',
|
||||
name: 'The Fantastic Four',
|
||||
emoji: '4️⃣',
|
||||
description: 'Solve a puzzle for all four Gospels',
|
||||
achieved: fantasticFourDate !== null,
|
||||
achievedDate: fantasticFourDate,
|
||||
},
|
||||
{
|
||||
id: 'pentatonix',
|
||||
name: 'Pentatonix',
|
||||
emoji: '📃',
|
||||
description: 'Solve a puzzle for all five books of the Pentateuch',
|
||||
achieved: pentatonixDate !== null,
|
||||
achievedDate: pentatonixDate,
|
||||
},
|
||||
{
|
||||
id: 'with-god',
|
||||
name: 'With God, All Things Are Possible',
|
||||
emoji: '🙏',
|
||||
description: 'Solve in 1 guess for each of the 66 books at least once',
|
||||
achieved: allInOne,
|
||||
achievedDate: withGodDate,
|
||||
},
|
||||
{
|
||||
id: 'is-this-a-joke',
|
||||
name: 'Is This A Joke To You?',
|
||||
emoji: '😤',
|
||||
description: 'Guess all 65 other books before getting the right one',
|
||||
achieved: jokeCompletion !== undefined,
|
||||
achievedDate: jokeCompletion?.date ?? null,
|
||||
},
|
||||
{
|
||||
id: 'overachiever',
|
||||
name: 'Overachiever',
|
||||
emoji: '⚡',
|
||||
description: 'Be the first person to solve Bibdle on a day',
|
||||
achieved: overachieverDate !== null,
|
||||
achievedDate: overachieverDate,
|
||||
},
|
||||
{
|
||||
id: 'procrastinator',
|
||||
name: 'Procrastinator',
|
||||
emoji: '🐢',
|
||||
description: 'Be the last person to solve Bibdle on a day',
|
||||
achieved: procrastinatorDate !== null,
|
||||
achievedDate: procrastinatorDate,
|
||||
},
|
||||
{
|
||||
id: 'prodigal-son',
|
||||
name: 'Prodigal Son',
|
||||
emoji: '🏠',
|
||||
description: 'Return to Bibdle after at least 30 days away',
|
||||
achieved: prodigalDate !== null,
|
||||
achievedDate: prodigalDate,
|
||||
},
|
||||
{
|
||||
id: 'extra-credit',
|
||||
name: 'Extra Credit',
|
||||
emoji: '📅',
|
||||
description: 'Solve Bibdle on a Sunday',
|
||||
achieved: sundayCompletion !== undefined,
|
||||
achievedDate: sundayCompletion?.date ?? null,
|
||||
},
|
||||
{
|
||||
id: 'outlier',
|
||||
name: 'Outlier',
|
||||
emoji: '📊',
|
||||
description: 'Finish in the top 10% of solves on a day (fewest guesses)',
|
||||
achieved: outlierDate !== null,
|
||||
achievedDate: outlierDate,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.ico";
|
||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
|
||||
onMount(() => {
|
||||
// Inject analytics script
|
||||
@@ -31,5 +32,6 @@
|
||||
<TitleAnimation />
|
||||
<div class="font-normal"></div>
|
||||
</h1>
|
||||
<div class="hidden"><ThemeToggle /></div>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
import type { PageProps } from "./$types";
|
||||
import { browser } from "$app/environment";
|
||||
import { enhance } from "$app/forms";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
||||
import SearchInput from "$lib/components/SearchInput.svelte";
|
||||
import GuessesTable from "$lib/components/GuessesTable.svelte";
|
||||
import WinScreen from "$lib/components/WinScreen.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 AuthModal from "$lib/components/AuthModal.svelte";
|
||||
|
||||
import { evaluateGuess } from "$lib/utils/game";
|
||||
import { evaluateGuess, getFirstLetter } from "$lib/utils/game";
|
||||
import {
|
||||
generateShareText,
|
||||
shareResult,
|
||||
@@ -52,6 +54,7 @@
|
||||
let statsData = $state<StatsData | null>(null);
|
||||
let streak = $state(0);
|
||||
let streakPercentile = $state<number | null>(null);
|
||||
let guessesMinimized = $state(false);
|
||||
|
||||
const persistence = createGamePersistence(
|
||||
() => dailyVerse.date,
|
||||
@@ -73,6 +76,62 @@
|
||||
!persistence.chapterGuessCompleted,
|
||||
);
|
||||
|
||||
let knownTestament = $derived(
|
||||
persistence.guesses.some((g) => g.testamentMatch)
|
||||
? correctBook?.testament
|
||||
: null,
|
||||
);
|
||||
let knownSection = $derived(
|
||||
persistence.guesses.some((g) => g.sectionMatch)
|
||||
? correctBook?.section
|
||||
: null,
|
||||
);
|
||||
let knownFirstLetter = $derived(
|
||||
persistence.guesses.some((g) => g.firstLetterMatch)
|
||||
? getFirstLetter(correctBook?.name ?? "").toUpperCase()
|
||||
: null,
|
||||
);
|
||||
|
||||
let testamentVisible = $state(false);
|
||||
let sectionVisible = $state(false);
|
||||
let firstLetterVisible = $state(false);
|
||||
let showHints = $state(false);
|
||||
|
||||
// On page load, show hints that are already known without animation
|
||||
onMount(() => {
|
||||
if (knownTestament) testamentVisible = true;
|
||||
if (knownSection) sectionVisible = true;
|
||||
if (knownFirstLetter) firstLetterVisible = true;
|
||||
|
||||
const winCount = Object.keys(localStorage).filter(
|
||||
(k) => k.startsWith("bibdle-win-tracked-") && localStorage.getItem(k) === "true"
|
||||
).length;
|
||||
showHints = winCount < 3;
|
||||
});
|
||||
|
||||
// Fade in newly revealed hints after the guess animation completes
|
||||
$effect(() => {
|
||||
if (!knownTestament || testamentVisible) return;
|
||||
const id = setTimeout(() => {
|
||||
testamentVisible = true;
|
||||
}, 2800);
|
||||
return () => clearTimeout(id);
|
||||
});
|
||||
$effect(() => {
|
||||
if (!knownSection || sectionVisible) return;
|
||||
const id = setTimeout(() => {
|
||||
sectionVisible = true;
|
||||
}, 2800);
|
||||
return () => clearTimeout(id);
|
||||
});
|
||||
$effect(() => {
|
||||
if (!knownFirstLetter || firstLetterVisible) return;
|
||||
const id = setTimeout(() => {
|
||||
firstLetterVisible = true;
|
||||
}, 2800);
|
||||
return () => clearTimeout(id);
|
||||
});
|
||||
|
||||
async function submitGuess(bookId: string) {
|
||||
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
|
||||
|
||||
@@ -203,6 +262,23 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Delay collapsing the guesses grid until animations complete (mirrors showWinScreen delay)
|
||||
$effect(() => {
|
||||
if (!isWon || persistence.guesses.length <= 3) {
|
||||
guessesMinimized = false;
|
||||
return;
|
||||
}
|
||||
if (persistence.isWinAlreadyTracked()) {
|
||||
guessesMinimized = true;
|
||||
} else {
|
||||
const animationDelay = 1800;
|
||||
const timeoutId = setTimeout(() => {
|
||||
guessesMinimized = true;
|
||||
}, animationDelay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
// Track win analytics
|
||||
$effect(() => {
|
||||
if (!browser || !isWon) return;
|
||||
@@ -297,6 +373,44 @@
|
||||
|
||||
{#if !isWon}
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
<GamePrompt guessCount={persistence.guesses.length} />
|
||||
|
||||
{#if showHints && (knownTestament || knownSection || knownFirstLetter)}
|
||||
<div
|
||||
class="text-xs uppercase tracking-widest font-bold text-center text-gray-500 dark:text-gray-400 flex flex-col gap-1 mb-4 -mt-2"
|
||||
>
|
||||
{#if knownTestament}
|
||||
<p
|
||||
style="transition: opacity 0.5s ease; opacity: {testamentVisible
|
||||
? 1
|
||||
: 0};"
|
||||
>
|
||||
It is in the {knownTestament === "old"
|
||||
? "Old"
|
||||
: "New"} Testament.
|
||||
</p>
|
||||
{/if}
|
||||
{#if knownSection}
|
||||
<p
|
||||
style="transition: opacity 0.5s ease; opacity: {sectionVisible
|
||||
? 1
|
||||
: 0};"
|
||||
>
|
||||
It is in the {knownSection} section.
|
||||
</p>
|
||||
{/if}
|
||||
{#if knownFirstLetter}
|
||||
<p
|
||||
style="transition: opacity 0.5s ease; opacity: {firstLetterVisible
|
||||
? 1
|
||||
: 0};"
|
||||
>
|
||||
The book's name starts with "{knownFirstLetter}".
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SearchInput
|
||||
bind:searchQuery
|
||||
{guessedIds}
|
||||
@@ -327,19 +441,41 @@
|
||||
{/if}
|
||||
|
||||
<div class="animate-fade-in-up animate-delay-600">
|
||||
<GuessesTable guesses={persistence.guesses} {correctBookId} />
|
||||
<GuessesTable
|
||||
guesses={persistence.guesses}
|
||||
{correctBookId}
|
||||
minimized={guessesMinimized}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#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">
|
||||
<Credits />
|
||||
</div>
|
||||
{/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>
|
||||
{#if isDev}
|
||||
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
|
||||
@@ -376,7 +512,11 @@
|
||||
<div>Daily Verse Date: {dailyVerse.date}</div>
|
||||
<div>Streak: {streak}</div>
|
||||
</div>
|
||||
<DevButtons anonymousId={persistence.anonymousId} {user} onSignIn={() => (authModalOpen = true)} />
|
||||
<DevButtons
|
||||
anonymousId={persistence.anonymousId}
|
||||
{user}
|
||||
onSignIn={() => (authModalOpen = true)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
42
src/routes/api/send-daily-verse/+server.ts
Normal file
42
src/routes/api/send-daily-verse/+server.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getVerseForDate } from '$lib/server/daily-verse';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const cronSecret = Bun.env.CRON_SECRET;
|
||||
const discordWebhook = Bun.env.DISCORD_DAILY_WEBHOOK;
|
||||
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader || !cronSecret || authHeader !== `Bearer ${cronSecret}`) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
|
||||
|
||||
const verse = await getVerseForDate(dateStr);
|
||||
|
||||
const fullDate = new Date(dateStr + 'T00:00:00Z').toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
const message = `*${fullDate}*\n**"${verse.verseText}"**`;
|
||||
|
||||
if (!discordWebhook) {
|
||||
return json({ error: 'Discord webhook not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const discordResponse = await fetch(discordWebhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: message }),
|
||||
});
|
||||
|
||||
if (!discordResponse.ok) {
|
||||
return json({ error: 'Failed to post to Discord' }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
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
|
||||
};
|
||||
};
|
||||
627
src/routes/global/+page.svelte
Normal file
627
src/routes/global/+page.svelte
Normal file
@@ -0,0 +1,627 @@
|
||||
<script lang="ts">
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
import CollapsibleTable from "$lib/components/CollapsibleTable.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);
|
||||
|
||||
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 maxMau = $derived(Math.max(1, ...mauMonths.map((m) => m.mau)));
|
||||
const maxCalMau = $derived(Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau)));
|
||||
|
||||
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",
|
||||
},
|
||||
]);
|
||||
|
||||
const mauModes = [
|
||||
{ value: 'rolling', label: 'Rolling 30d' },
|
||||
{ value: 'calendar', label: 'Calendar' },
|
||||
];
|
||||
</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>
|
||||
|
||||
<CollapsibleTable
|
||||
rows={newPlayerReturnSeries}
|
||||
headers={[
|
||||
{ label: 'Date' },
|
||||
{ label: 'New Players', align: 'right' },
|
||||
{ label: 'Return Rate', align: 'right' },
|
||||
{ label: '7d Avg', align: 'right' },
|
||||
{ label: '', width: 'w-32' },
|
||||
]}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohort}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-400">
|
||||
{item.rate != null ? `${item.rate}%` : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">
|
||||
{item.rollingAvg != null ? `${item.rollingAvg}%` : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-20">
|
||||
{#if item.rollingAvg != null}
|
||||
<div class="bg-sky-500 h-4 rounded" style="width: {item.rollingAvg}%"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
</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>
|
||||
<CollapsibleTable
|
||||
rows={wauWeeks}
|
||||
headers={[
|
||||
{ label: 'Week' },
|
||||
{ label: 'Active Users', align: 'right' },
|
||||
{ label: 'Wk/Wk Change', align: 'right' },
|
||||
{ label: '', width: 'w-48' },
|
||||
]}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
{@const barPct = Math.round((item.wau / maxWau) * 100)}
|
||||
<td class="px-4 py-3 text-gray-300 text-xs">{item.weekStart} – {item.weekEnd}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.wau}</td>
|
||||
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
|
||||
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
|
||||
: 'text-gray-500'}">
|
||||
{item.changePct != null ? signed(item.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>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-1">Monthly Active Users</h2>
|
||||
<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'}
|
||||
<CollapsibleTable
|
||||
rows={mauMonths}
|
||||
headers={[
|
||||
{ label: 'Period' },
|
||||
{ label: 'Active Users', align: 'right' },
|
||||
{ label: 'Mo/Mo Change', align: 'right' },
|
||||
{ label: '', width: 'w-48' },
|
||||
]}
|
||||
modes={mauModes}
|
||||
bind:mode={mauMode}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
{@const barPct = Math.round((item.mau / maxMau) * 100)}
|
||||
<td class="px-4 py-3 text-gray-300 text-xs">{item.monthStart} – {item.monthEnd}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.mau}</td>
|
||||
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
|
||||
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
|
||||
: 'text-gray-500'}">
|
||||
{item.changePct != null ? signed(item.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>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
{:else}
|
||||
<CollapsibleTable
|
||||
rows={calendarMauMonths}
|
||||
headers={[
|
||||
{ label: 'Month' },
|
||||
{ label: 'Active Users', align: 'right' },
|
||||
{ label: 'Mo/Mo Change', align: 'right' },
|
||||
{ label: '', width: 'w-48' },
|
||||
]}
|
||||
modes={mauModes}
|
||||
bind:mode={mauMode}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
{@const displayMau = item.projectedMau ?? item.mau}
|
||||
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
|
||||
<td class="px-4 py-3 text-gray-300">
|
||||
{item.label}
|
||||
{#if item.isCurrentMonth}
|
||||
<span class="text-xs text-gray-500 ml-1">(projected)</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-medium {item.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
|
||||
{#if item.isCurrentMonth}
|
||||
<span class="text-gray-500 text-xs">{item.mau} → </span>{item.projectedMau ?? item.mau}
|
||||
{:else}
|
||||
{item.mau}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
|
||||
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
|
||||
: 'text-gray-500'}">
|
||||
{#if item.changePct != null}
|
||||
{item.isCurrentMonth ? '~' : ''}{signed(item.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 {item.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||
Last 14 Days — Completions
|
||||
</h2>
|
||||
<CollapsibleTable
|
||||
rows={last14Days}
|
||||
headers={[
|
||||
{ label: 'Date' },
|
||||
{ label: 'Completions', align: 'right' },
|
||||
{ label: '', width: 'w-48' },
|
||||
]}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
{@const barPct = Math.round((item.count / maxCount) * 100)}
|
||||
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.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>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||
Active Streak Distribution
|
||||
</h2>
|
||||
<CollapsibleTable
|
||||
rows={streakChart}
|
||||
headers={[
|
||||
{ label: 'Days' },
|
||||
{ label: 'Players', align: 'right' },
|
||||
{ label: '', width: 'w-48' },
|
||||
]}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
{@const barPct = Math.round((item.count / maxStreakCount) * 100)}
|
||||
<td class="px-4 py-3 text-gray-300">{item.days}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.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>
|
||||
{/snippet}
|
||||
{#snippet empty()}
|
||||
<p class="text-gray-400 text-sm px-4 py-6">No active streaks yet.</p>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
</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>
|
||||
<CollapsibleTable
|
||||
rows={retention7dSeries}
|
||||
headers={[
|
||||
{ label: 'Cohort Date' },
|
||||
{ label: 'n', align: 'right' },
|
||||
{ label: 'Ret. %', align: 'right' },
|
||||
{ label: '', width: 'w-32' },
|
||||
]}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.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: {item.rate}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
</div>
|
||||
|
||||
<!-- 30-day retention -->
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-200 mb-3">
|
||||
30-Day Retention
|
||||
</h3>
|
||||
<CollapsibleTable
|
||||
rows={retention30dSeries}
|
||||
headers={[
|
||||
{ label: 'Cohort Date' },
|
||||
{ label: 'n', align: 'right' },
|
||||
{ label: 'Ret. %', align: 'right' },
|
||||
{ label: '', width: 'w-32' },
|
||||
]}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.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: {item.rate}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
287
src/routes/progress/+page.server.ts
Normal file
287
src/routes/progress/+page.server.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions, dailyVerses } from '$lib/server/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { bibleBooks } from '$lib/types/bible';
|
||||
import { calculateMilestones } from '$lib/server/milestones';
|
||||
import type { Milestone } from '$lib/server/milestones';
|
||||
|
||||
export type BookTier = 'unseen' | 'explored' | 'mastered' | 'perfect';
|
||||
|
||||
export type BookGridEntry = {
|
||||
bookId: string;
|
||||
tier: BookTier;
|
||||
avgGuesses: number | null;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type ChartPoint = {
|
||||
label: string;
|
||||
avgGuesses: number;
|
||||
};
|
||||
|
||||
export type SectionStat = {
|
||||
section: string;
|
||||
avgGuesses: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type TestamentStat = {
|
||||
avgGuesses: number;
|
||||
count: number;
|
||||
} | null;
|
||||
|
||||
export type { Milestone };
|
||||
|
||||
export type ProgressData = {
|
||||
completions: Array<{ date: string; guessCount: number }>;
|
||||
chartPoints: ChartPoint[];
|
||||
bookGrid: BookGridEntry[];
|
||||
sectionStats: SectionStat[];
|
||||
testamentStats: { old: TestamentStat; new: TestamentStat };
|
||||
totalSolves: number;
|
||||
bestStreak: number;
|
||||
currentStreak: number;
|
||||
booksExplored: number;
|
||||
booksMastered: number;
|
||||
booksPerfect: number;
|
||||
bestSingleGame: { date: string; bookName: string } | null;
|
||||
totalWords: number;
|
||||
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
|
||||
milestones: Milestone[];
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
return {
|
||||
progress: null,
|
||||
requiresAuth: true,
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
|
||||
const userId = locals.user.id;
|
||||
|
||||
try {
|
||||
const completions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId))
|
||||
.orderBy(desc(dailyCompletions.date));
|
||||
|
||||
if (completions.length === 0) {
|
||||
return {
|
||||
progress: {
|
||||
completions: [],
|
||||
chartPoints: [],
|
||||
bookGrid: bibleBooks.map(b => ({ bookId: b.id, tier: 'unseen' as BookTier, avgGuesses: null, count: 0 })),
|
||||
sectionStats: [],
|
||||
testamentStats: { old: null, new: null },
|
||||
totalSolves: 0,
|
||||
bestStreak: 0,
|
||||
currentStreak: 0,
|
||||
booksExplored: 0,
|
||||
booksMastered: 0,
|
||||
booksPerfect: 0,
|
||||
bestSingleGame: null,
|
||||
totalWords: 0,
|
||||
streakMilestones: { days7: null, days14: null, days30: null },
|
||||
milestones: [],
|
||||
} satisfies ProgressData,
|
||||
requiresAuth: false,
|
||||
user: locals.user,
|
||||
session: locals.session,
|
||||
};
|
||||
}
|
||||
|
||||
// Map dates to book IDs and verse text via cached daily_verses
|
||||
const allVerses = await db.select().from(dailyVerses);
|
||||
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
|
||||
const dateToVerseText = new Map(allVerses.map(v => [v.date, v.verseText]));
|
||||
|
||||
// Total words across all played verses
|
||||
let totalWords = 0;
|
||||
for (const c of completions) {
|
||||
const verseText = dateToVerseText.get(c.date);
|
||||
if (verseText) {
|
||||
totalWords += verseText.trim().split(/\s+/).length;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-book stats
|
||||
const bookStatsMap = new Map<string, { count: number; totalGuesses: number; everGuessedIn1: boolean }>();
|
||||
for (const c of completions) {
|
||||
const bookId = dateToBookId.get(c.date);
|
||||
if (!bookId) continue;
|
||||
const existing = bookStatsMap.get(bookId) ?? { count: 0, totalGuesses: 0, everGuessedIn1: false };
|
||||
bookStatsMap.set(bookId, {
|
||||
count: existing.count + 1,
|
||||
totalGuesses: existing.totalGuesses + c.guessCount,
|
||||
everGuessedIn1: existing.everGuessedIn1 || c.guessCount === 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Book grid (all 66 in canonical order)
|
||||
const bookGrid: BookGridEntry[] = bibleBooks.map(book => {
|
||||
const stats = bookStatsMap.get(book.id);
|
||||
if (!stats) return { bookId: book.id, tier: 'unseen', avgGuesses: null, count: 0 };
|
||||
const avgGuesses = stats.totalGuesses / stats.count;
|
||||
let tier: BookTier = 'explored';
|
||||
if (stats.count >= 2 && avgGuesses <= 3) {
|
||||
tier = stats.everGuessedIn1 ? 'perfect' : 'mastered';
|
||||
}
|
||||
return { bookId: book.id, tier, avgGuesses: Math.round(avgGuesses * 10) / 10, count: stats.count };
|
||||
});
|
||||
|
||||
// Section stats
|
||||
const sectionMap = new Map<string, { totalGuesses: number; count: number }>();
|
||||
for (const c of completions) {
|
||||
const bookId = dateToBookId.get(c.date);
|
||||
if (!bookId) continue;
|
||||
const book = bibleBooks.find(b => b.id === bookId);
|
||||
if (!book) continue;
|
||||
const existing = sectionMap.get(book.section) ?? { totalGuesses: 0, count: 0 };
|
||||
sectionMap.set(book.section, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
|
||||
}
|
||||
const sectionStats: SectionStat[] = Array.from(sectionMap.entries())
|
||||
.filter(([, s]) => s.count >= 3)
|
||||
.map(([section, s]) => ({ section, avgGuesses: Math.round((s.totalGuesses / s.count) * 10) / 10, count: s.count }))
|
||||
.sort((a, b) => a.avgGuesses - b.avgGuesses);
|
||||
|
||||
// Testament stats (only show if ≥5 games per testament)
|
||||
let otTotal = 0, otCount = 0, ntTotal = 0, ntCount = 0;
|
||||
for (const c of completions) {
|
||||
const bookId = dateToBookId.get(c.date);
|
||||
if (!bookId) continue;
|
||||
const book = bibleBooks.find(b => b.id === bookId);
|
||||
if (!book) continue;
|
||||
if (book.testament === 'old') { otTotal += c.guessCount; otCount++; }
|
||||
else { ntTotal += c.guessCount; ntCount++; }
|
||||
}
|
||||
const testamentStats = {
|
||||
old: otCount >= 5 ? { avgGuesses: Math.round((otTotal / otCount) * 10) / 10, count: otCount } : null,
|
||||
new: ntCount >= 5 ? { avgGuesses: Math.round((ntTotal / ntCount) * 10) / 10, count: ntCount } : null,
|
||||
};
|
||||
|
||||
// Chart points — monthly averages sorted ascending
|
||||
const sortedCompletions = [...completions].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const monthMap = new Map<string, { totalGuesses: number; count: number }>();
|
||||
for (const c of sortedCompletions) {
|
||||
const month = c.date.slice(0, 7); // YYYY-MM
|
||||
const existing = monthMap.get(month) ?? { totalGuesses: 0, count: 0 };
|
||||
monthMap.set(month, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
|
||||
}
|
||||
let chartPoints: ChartPoint[] = Array.from(monthMap.entries())
|
||||
.map(([label, m]) => ({ label, avgGuesses: Math.round((m.totalGuesses / m.count) * 10) / 10 }));
|
||||
|
||||
// Fall back to weekly if fewer than 3 months of data
|
||||
if (chartPoints.length < 3 && sortedCompletions.length >= 5) {
|
||||
const weekMap = new Map<string, { totalGuesses: number; count: number }>();
|
||||
for (const c of sortedCompletions) {
|
||||
const d = new Date(c.date + 'T00:00:00Z');
|
||||
const year = d.getUTCFullYear();
|
||||
const week = getISOWeek(d);
|
||||
const key = `${year}-W${String(week).padStart(2, '0')}`;
|
||||
const existing = weekMap.get(key) ?? { totalGuesses: 0, count: 0 };
|
||||
weekMap.set(key, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
|
||||
}
|
||||
chartPoints = Array.from(weekMap.entries())
|
||||
.map(([label, m]) => ({ label, avgGuesses: Math.round((m.totalGuesses / m.count) * 10) / 10 }));
|
||||
}
|
||||
|
||||
// Best streak (all-time) + streak milestones
|
||||
const sortedDates = completions.map(c => c.date).sort();
|
||||
let bestStreak = sortedDates.length > 0 ? 1 : 0;
|
||||
let tempStreak = 1;
|
||||
const streakMilestones: { days7: string | null; days14: string | null; days30: string | null } = { days7: null, days14: null, days30: null };
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const curr = new Date(sortedDates[i] + 'T00:00:00Z');
|
||||
const prev = new Date(sortedDates[i - 1] + 'T00:00:00Z');
|
||||
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
|
||||
if (diff === 1) { tempStreak++; }
|
||||
else { bestStreak = Math.max(bestStreak, tempStreak); tempStreak = 1; }
|
||||
if (tempStreak >= 7 && !streakMilestones.days7) streakMilestones.days7 = sortedDates[i];
|
||||
if (tempStreak >= 14 && !streakMilestones.days14) streakMilestones.days14 = sortedDates[i];
|
||||
if (tempStreak >= 30 && !streakMilestones.days30) streakMilestones.days30 = sortedDates[i];
|
||||
}
|
||||
bestStreak = Math.max(bestStreak, tempStreak);
|
||||
|
||||
// Server-side current streak estimate (client overrides via /api/streak)
|
||||
const userToday = new Date().toISOString().slice(0, 10);
|
||||
const yesterday = new Date(new Date(userToday + 'T00:00:00Z').getTime() - 86400000).toISOString().slice(0, 10);
|
||||
const lastDate = sortedDates[sortedDates.length - 1] ?? '';
|
||||
let currentStreak = 0;
|
||||
if (lastDate === userToday || lastDate === yesterday) {
|
||||
currentStreak = 1;
|
||||
for (let i = sortedDates.length - 2; i >= 0; i--) {
|
||||
const curr = new Date(sortedDates[i + 1] + 'T00:00:00Z');
|
||||
const prev = new Date(sortedDates[i] + 'T00:00:00Z');
|
||||
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
|
||||
if (diff === 1) currentStreak++;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
// Milestone counts
|
||||
const booksExplored = bookStatsMap.size;
|
||||
const booksMastered = bookGrid.filter(b => b.tier === 'mastered' || b.tier === 'perfect').length;
|
||||
const booksPerfect = bookGrid.filter(b => b.tier === 'perfect').length;
|
||||
|
||||
// Best single game (earliest 1-guess solve)
|
||||
let bestSingleGame: { date: string; bookName: string } | null = null;
|
||||
for (const c of sortedCompletions) {
|
||||
if (c.guessCount === 1) {
|
||||
const bookId = dateToBookId.get(c.date);
|
||||
const book = bookId ? bibleBooks.find(b => b.id === bookId) : null;
|
||||
if (book) {
|
||||
bestSingleGame = { date: c.date, bookName: book.name };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const milestones = await calculateMilestones(completions, dateToBookId, { bestSingleGame, streakMilestones });
|
||||
|
||||
return {
|
||||
progress: {
|
||||
completions: completions.map(c => ({ date: c.date, guessCount: c.guessCount })),
|
||||
chartPoints,
|
||||
bookGrid,
|
||||
sectionStats,
|
||||
testamentStats,
|
||||
totalSolves: completions.length,
|
||||
bestStreak,
|
||||
currentStreak,
|
||||
booksExplored,
|
||||
booksMastered,
|
||||
booksPerfect,
|
||||
bestSingleGame,
|
||||
totalWords,
|
||||
streakMilestones,
|
||||
milestones,
|
||||
} satisfies ProgressData,
|
||||
requiresAuth: false,
|
||||
user: locals.user,
|
||||
session: locals.session,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching progress data:', error);
|
||||
return {
|
||||
progress: null,
|
||||
error: 'Failed to load progress data',
|
||||
requiresAuth: false,
|
||||
user: locals.user,
|
||||
session: locals.session,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function getISOWeek(d: Date): number {
|
||||
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
const dayNum = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||
}
|
||||
627
src/routes/progress/+page.svelte
Normal file
627
src/routes/progress/+page.svelte
Normal file
@@ -0,0 +1,627 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { onMount } from "svelte";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
import ProgressStatCard from "$lib/components/ProgressStatCard.svelte";
|
||||
import ActivityCalendar from "$lib/components/ActivityCalendar.svelte";
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
|
||||
type BookTier = "unseen" | "explored" | "mastered" | "perfect";
|
||||
|
||||
type BookGridEntry = {
|
||||
bookId: string;
|
||||
tier: BookTier;
|
||||
avgGuesses: number | null;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type ChartPoint = {
|
||||
label: string;
|
||||
avgGuesses: number;
|
||||
};
|
||||
|
||||
type SectionStat = {
|
||||
section: string;
|
||||
avgGuesses: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type Milestone = {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
description: string;
|
||||
achieved: boolean;
|
||||
achievedDate: string | null;
|
||||
};
|
||||
|
||||
type ProgressData = {
|
||||
completions: Array<{ date: string; guessCount: number }>;
|
||||
chartPoints: ChartPoint[];
|
||||
bookGrid: BookGridEntry[];
|
||||
sectionStats: SectionStat[];
|
||||
testamentStats: {
|
||||
old: { avgGuesses: number; count: number } | null;
|
||||
new: { avgGuesses: number; count: number } | null;
|
||||
};
|
||||
totalSolves: number;
|
||||
bestStreak: number;
|
||||
currentStreak: number;
|
||||
booksExplored: number;
|
||||
booksMastered: number;
|
||||
booksPerfect: number;
|
||||
bestSingleGame: { date: string; bookName: string } | null;
|
||||
totalWords: number;
|
||||
streakMilestones: {
|
||||
days7: string | null;
|
||||
days14: string | null;
|
||||
days30: string | null;
|
||||
};
|
||||
milestones: Milestone[];
|
||||
};
|
||||
|
||||
interface PageData {
|
||||
progress: ProgressData | null;
|
||||
error?: string;
|
||||
user?: any;
|
||||
session?: any;
|
||||
requiresAuth?: boolean;
|
||||
}
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let authModalOpen = $state(false);
|
||||
let anonymousId = $state("");
|
||||
|
||||
function getOrCreateAnonymousId(): string {
|
||||
if (!browser) return "";
|
||||
const key = "bibdle-anonymous-id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function bookTileClass(tier: BookTier): string {
|
||||
switch (tier) {
|
||||
case "perfect":
|
||||
return "bg-emerald-500 text-white";
|
||||
case "mastered":
|
||||
return "bg-purple-600 text-white";
|
||||
case "explored":
|
||||
return "bg-blue-700 text-blue-100";
|
||||
default:
|
||||
return "bg-gray-700/50 text-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T00:00:00Z");
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
});
|
||||
|
||||
// Derived SVG chart values
|
||||
const chartPoints = $derived(data.progress?.chartPoints ?? []);
|
||||
const showChart = $derived(chartPoints.length >= 3);
|
||||
const maxGuesses = $derived(
|
||||
showChart ? Math.max(...chartPoints.map((p) => p.avgGuesses)) : 6,
|
||||
);
|
||||
const chartImproving = $derived(
|
||||
showChart &&
|
||||
chartPoints[chartPoints.length - 1].avgGuesses <
|
||||
chartPoints[0].avgGuesses,
|
||||
);
|
||||
|
||||
function svgX(index: number, total: number): number {
|
||||
return (index / (total - 1)) * 360 + 20;
|
||||
}
|
||||
|
||||
function svgY(avgGuesses: number, maxG: number): number {
|
||||
return 100 - ((maxG - avgGuesses) / (maxG - 1)) * 90 + 10;
|
||||
}
|
||||
|
||||
const polylinePoints = $derived(
|
||||
showChart
|
||||
? chartPoints
|
||||
.map(
|
||||
(p, i) =>
|
||||
`${svgX(i, chartPoints.length)},${svgY(p.avgGuesses, maxGuesses)}`,
|
||||
)
|
||||
.join(" ")
|
||||
: "",
|
||||
);
|
||||
|
||||
// Insights helpers
|
||||
const progress = $derived(data.progress);
|
||||
const bestSection = $derived(
|
||||
progress?.sectionStats.find((s) => s.count >= 3) ?? null,
|
||||
);
|
||||
const hardestSection = $derived.by(() => {
|
||||
if (!progress) return null;
|
||||
const eligible = progress.sectionStats.filter((s) => s.count >= 3);
|
||||
if (eligible.length === 0) return null;
|
||||
const last = eligible[eligible.length - 1];
|
||||
if (bestSection && last.section === bestSection.section) return null;
|
||||
return last;
|
||||
});
|
||||
|
||||
const showInsights = $derived(
|
||||
progress !== null &&
|
||||
((progress.testamentStats.old !== null &&
|
||||
progress.testamentStats.new !== null) ||
|
||||
bestSection !== null),
|
||||
);
|
||||
|
||||
function testamentComparison(
|
||||
old_: { avgGuesses: number; count: number } | null,
|
||||
new_: { avgGuesses: number; count: number } | null,
|
||||
): string | null {
|
||||
if (!old_ || !new_) return null;
|
||||
const ratio = old_.avgGuesses / new_.avgGuesses;
|
||||
if (ratio < 0.85) {
|
||||
const x = (new_.avgGuesses / old_.avgGuesses).toFixed(1);
|
||||
return `You're ${x}x faster at Old Testament books`;
|
||||
}
|
||||
if (ratio > 1.18) {
|
||||
const x = (old_.avgGuesses / new_.avgGuesses).toFixed(1);
|
||||
return `You're ${x}x faster at New Testament books`;
|
||||
}
|
||||
return "Your speed is similar for Old and New Testament books";
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Your Progress | Bibdle</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Track your Bible knowledge journey with Bibdle"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
|
||||
>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6 md:mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-4">
|
||||
Your Progress
|
||||
</h1>
|
||||
|
||||
<a href="/" class="p-2 px-20 w-full items-center text-gray-300">
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if data.requiresAuth}
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
|
||||
>
|
||||
<h2 class="text-2xl font-bold text-blue-200 mb-4">
|
||||
Authentication Required
|
||||
</h2>
|
||||
<p class="text-blue-300 mb-6">
|
||||
You must be logged in to see your progress.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
onclick={() => (authModalOpen = true)}
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
Sign In / Sign Up
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if data.error}
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm"
|
||||
>
|
||||
<p class="text-red-300">{data.error}</p>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Return to Game
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !data.progress}
|
||||
<div class="text-center py-12">
|
||||
<Container class="p-8 max-w-md mx-auto">
|
||||
<div class="text-yellow-400 mb-4 text-lg">
|
||||
No progress yet.
|
||||
</div>
|
||||
<p class="text-gray-300 mb-6">
|
||||
Start playing to build your Bible knowledge journey!
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-6 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium shadow-md"
|
||||
>
|
||||
Start Playing
|
||||
</a>
|
||||
</Container>
|
||||
</div>
|
||||
{:else}
|
||||
{@const prog = data.progress}
|
||||
|
||||
<!-- Key Stats Row -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 mb-6">
|
||||
<ProgressStatCard
|
||||
emoji="📅"
|
||||
value={String(prog.totalSolves)}
|
||||
label="Total Played"
|
||||
colorClass="text-blue-400"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="📖"
|
||||
value={String(prog.booksExplored)}
|
||||
label="Books Explored"
|
||||
colorClass="text-teal-400"
|
||||
suffix="/ 66"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="✍️"
|
||||
value={prog.totalWords.toLocaleString()}
|
||||
label="Words Read"
|
||||
colorClass="text-violet-400"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="✝️"
|
||||
value={(((prog.totalSolves * 3) / 31102) * 100).toFixed(2) +
|
||||
"%"}
|
||||
label="Bible Read"
|
||||
colorClass="text-amber-400"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="🏆"
|
||||
value={String(prog.booksMastered)}
|
||||
label="Books Mastered"
|
||||
colorClass="text-purple-400"
|
||||
suffix="/ 66"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="⭐"
|
||||
value={String(prog.booksPerfect)}
|
||||
label="Books Perfected"
|
||||
colorClass="text-emerald-400"
|
||||
suffix="/ 66"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bible Book Grid -->
|
||||
<div class="mb-6">
|
||||
<Container class="p-4 md:p-6 w-full">
|
||||
<h2
|
||||
class="text-xl font-bold text-gray-100 mb-3 w-full text-left"
|
||||
>
|
||||
Bible Books
|
||||
</h2>
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<span
|
||||
class="flex items-center gap-1 text-xs text-gray-400"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-5 h-5 rounded bg-blue-700"
|
||||
></span>
|
||||
Explored
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1 text-xs text-gray-400"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-5 h-5 rounded bg-purple-600"
|
||||
></span>
|
||||
Mastered
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1 text-xs text-gray-400"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-5 h-5 rounded bg-emerald-500"
|
||||
></span>
|
||||
Perfect
|
||||
</span>
|
||||
</div>
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-8 md:grid-cols-11 gap-1 w-full">
|
||||
{#each prog.bookGrid as entry (entry.bookId)}
|
||||
{@const bookMeta = bibleBooks.find(
|
||||
(b) => b.id === entry.bookId,
|
||||
)}
|
||||
<div
|
||||
class="aspect-square flex items-center justify-center rounded text-[9px] md:text-[10px] font-bold cursor-default {bookTileClass(
|
||||
entry.tier,
|
||||
)}"
|
||||
title="{bookMeta?.name ??
|
||||
entry.bookId} — {entry.tier}{entry.avgGuesses !==
|
||||
null
|
||||
? ` (avg ${entry.avgGuesses})`
|
||||
: ''}"
|
||||
>
|
||||
{entry.bookId}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-3 leading-relaxed">
|
||||
<span class="text-blue-400 font-medium">Explored</span>
|
||||
— played at least once<br />
|
||||
<span class="text-purple-400 font-medium"
|
||||
>Mastered</span
|
||||
>
|
||||
— avg ≤ 3 guesses over 2+ plays<br />
|
||||
<span class="text-emerald-400 font-medium">Perfect</span> —
|
||||
mastered and guessed in 1 at least once
|
||||
</p>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<!-- Activity Calendar -->
|
||||
<div class="mb-6">
|
||||
<ActivityCalendar completions={prog.completions} />
|
||||
</div>
|
||||
|
||||
<!-- Skill Growth Chart (hidden, needs rework) -->
|
||||
{#if false && showChart}
|
||||
<div class="mb-6">
|
||||
<Container class="p-4 md:p-6 w-full">
|
||||
<div class="w-full">
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<h2 class="text-xl font-bold text-gray-100">
|
||||
Skill Growth
|
||||
</h2>
|
||||
<span class="text-xs text-gray-400">
|
||||
Lower is better
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 400 135"
|
||||
class="w-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="chartFill"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stop-color="#10b981"
|
||||
stop-opacity="0.3"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stop-color="#10b981"
|
||||
stop-opacity="0"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Y-axis label -->
|
||||
<text
|
||||
transform="translate(8, 60) rotate(-90)"
|
||||
text-anchor="middle"
|
||||
font-size="8"
|
||||
fill="#9ca3af"
|
||||
>Guesses</text>
|
||||
<!-- Fill polygon -->
|
||||
<polygon
|
||||
points="{polylinePoints} {svgX(
|
||||
chartPoints.length - 1,
|
||||
chartPoints.length,
|
||||
)},110 {svgX(0, chartPoints.length)},110"
|
||||
fill="url(#chartFill)"
|
||||
/>
|
||||
<!-- Line -->
|
||||
<polyline
|
||||
points={polylinePoints}
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<!-- Dots -->
|
||||
{#each chartPoints as point, i (i)}
|
||||
<circle
|
||||
cx={svgX(i, chartPoints.length)}
|
||||
cy={svgY(point.avgGuesses, maxGuesses)}
|
||||
r="3"
|
||||
fill="#10b981"
|
||||
/>
|
||||
<text
|
||||
x={svgX(i, chartPoints.length)}
|
||||
y={svgY(point.avgGuesses, maxGuesses) - 6}
|
||||
font-size="7"
|
||||
fill="#6ee7b7"
|
||||
text-anchor="middle"
|
||||
>{point.avgGuesses}</text>
|
||||
{/each}
|
||||
<!-- X-axis labels -->
|
||||
<text
|
||||
x={svgX(0, chartPoints.length)}
|
||||
y="118"
|
||||
font-size="8"
|
||||
fill="#9ca3af"
|
||||
text-anchor="middle"
|
||||
>
|
||||
{chartPoints[0].label}
|
||||
</text>
|
||||
<text
|
||||
x={svgX(
|
||||
chartPoints.length - 1,
|
||||
chartPoints.length,
|
||||
)}
|
||||
y="118"
|
||||
font-size="8"
|
||||
fill="#9ca3af"
|
||||
text-anchor="middle"
|
||||
>
|
||||
{chartPoints[chartPoints.length - 1].label}
|
||||
</text>
|
||||
<!-- X-axis title -->
|
||||
<text
|
||||
x="200"
|
||||
y="132"
|
||||
font-size="8"
|
||||
fill="#9ca3af"
|
||||
text-anchor="middle"
|
||||
>Date</text>
|
||||
</svg>
|
||||
{#if chartImproving}
|
||||
<p class="text-xs text-emerald-400 mt-1">
|
||||
You're getting better!
|
||||
</p>
|
||||
{/if}
|
||||
<p
|
||||
class="text-xs text-gray-500 mt-2 leading-relaxed"
|
||||
>
|
||||
Each point is your average guesses over a
|
||||
rolling window of games. A downward trend means
|
||||
you're improving.
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Achievements -->
|
||||
{#if prog.milestones.length > 0}
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-100 mb-3">🏆 Achievements</h2>
|
||||
<div class="grid grid-cols-3 md:grid-cols-4 gap-2 md:gap-3">
|
||||
{#each prog.milestones.filter(m => m.achieved) as milestone (milestone.id)}
|
||||
<Container class="p-3 min-h-[130px]">
|
||||
<div class="text-center flex flex-col items-center justify-center h-full">
|
||||
<div class="text-2xl mb-1">{milestone.emoji}</div>
|
||||
<div class="text-sm font-bold text-yellow-300 leading-tight mb-1">
|
||||
{milestone.name}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 leading-tight">
|
||||
{milestone.description}
|
||||
</div>
|
||||
{#if milestone.achievedDate}
|
||||
<div class="text-[10px] text-gray-500 mt-1">
|
||||
{formatDate(milestone.achievedDate)}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-[10px] text-emerald-500 mt-1">Earned</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Container>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">{prog.milestones.filter(m => m.achieved).length} / {prog.milestones.length} achievements unlocked</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Insights -->
|
||||
{#if showInsights}
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-100 mb-3">
|
||||
Insights
|
||||
</h2>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if prog.testamentStats.old && prog.testamentStats.new}
|
||||
{@const comparison = testamentComparison(
|
||||
prog.testamentStats.old,
|
||||
prog.testamentStats.new,
|
||||
)}
|
||||
{#if comparison}
|
||||
<Container class="p-4 w-full">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<span class="text-2xl">📊</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-gray-100 font-medium text-sm"
|
||||
>
|
||||
{comparison}
|
||||
</p>
|
||||
<p
|
||||
class="text-gray-400 text-xs mt-0.5"
|
||||
>
|
||||
OT avg: {prog.testamentStats.old
|
||||
.avgGuesses} guesses • NT
|
||||
avg: {prog.testamentStats.new
|
||||
.avgGuesses} guesses
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if bestSection && bestSection.count >= 3}
|
||||
<Container class="p-4 w-full">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<span class="text-2xl">🌟</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-gray-100 font-medium text-sm"
|
||||
>
|
||||
Your strongest section: {bestSection.section}
|
||||
</p>
|
||||
<p class="text-gray-400 text-xs mt-0.5">
|
||||
{bestSection.avgGuesses} avg guesses
|
||||
across {bestSection.count} games
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
{#if hardestSection}
|
||||
{@const hard = hardestSection}
|
||||
{#if hard && hard.count >= 3}
|
||||
<Container class="p-4 w-full">
|
||||
<div class="flex items-start gap-3 w-full">
|
||||
<span class="text-2xl">💪</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-gray-100 font-medium text-sm"
|
||||
>
|
||||
Room to grow: {hard.section}
|
||||
</p>
|
||||
<p
|
||||
class="text-gray-400 text-xs mt-0.5"
|
||||
>
|
||||
{hard.avgGuesses} avg guesses across
|
||||
{hard.count} games
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
|
||||
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
|
||||
|
||||
## 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
|
||||
|
||||
- Added about page with social buttons and XML sitemap for SEO
|
||||
|
||||
94
verses.json
Normal file
94
verses.json
Normal file
@@ -0,0 +1,94 @@
|
||||
[{"book":"1 Chronicles","verse":"Of Mahli: Eleazar, who had no sons.\n Of Kish, the son of Kish: Jerahmeel.\n The sons of Mushi: Mahli, Eder, and Jerimoth. These were the sons of the Levites after their fathers’ houses.\n","citation":"1 Chronicles 24:28-30","date":"2025-12-17"},
|
||||
{"book":"Deuteronomy","verse":"Yahweh spoke these words to all your assembly on the mountain out of the middle of the fire, of the cloud, and of the thick darkness, with a great voice. He added no more. He wrote them on two stone tablets, and gave them to me.\n When you heard the voice out of the middle of the darkness, while the mountain was burning with fire, you came near to me, even all the heads of your tribes, and your elders;\n and you said, “Behold, Yahweh our God has shown us his glory and his greatness, and we have heard his voice out of the middle of the fire. We have seen today that God does speak with man, and he lives.\n","citation":"Deuteronomy 5:22-24","date":"2025-12-18"},
|
||||
{"book":"Esther","verse":"Haman also said, “Yes, Esther the queen let no man come in with the king to the banquet that she had prepared but myself; and tomorrow I am also invited by her together with the king.\n Yet all this avails me nothing, so long as I see Mordecai the Jew sitting at the king’s gate.”\n Then Zeresh his wife and all his friends said to him, “Let a gallows be made fifty cubits high, and in the morning speak to the king about hanging Mordecai on it. Then go in merrily with the king to the banquet.” This pleased Haman, so he had the gallows made.\n","citation":"Esther 5:12-14","date":"2025-12-19"},
|
||||
{"book":"Nehemiah","verse":"I discerned, and behold, God had not sent him; but he pronounced this prophecy against me. Tobiah and Sanballat had hired him.\n He hired so that I would be afraid, do so, and sin, and that they might have material for an evil report, that they might reproach me.\n “Remember, my God, Tobiah and Sanballat according to these their works, and also the prophetess Noadiah, and the rest of the prophets, that would have put me in fear.”\n","citation":"Nehemiah 6:12-14","date":"2025-12-20"},
|
||||
{"book":"Micah","verse":"Therefore he will abandon them until the time that she who is in labor gives birth.\nThen the rest of his brothers will return to the children of Israel.\n He shall stand, and shall shepherd in the strength of Yahweh,\nin the majesty of the name of Yahweh his God:\nand they will live, for then he will be great to the ends of the earth.\n He will be our peace when Assyria invades our land,\nand when he marches through our fortresses,\nthen we will raise against him seven shepherds,\nand eight leaders of men.\n","citation":"Micah 5:3-5","date":"2025-12-21"},
|
||||
{"book":"Psalms","verse":"Yahweh is your keeper.\nYahweh is your shade on your right hand.\n The sun will not harm you by day,\nnor the moon by night.\n Yahweh will keep you from all evil.\nHe will keep your soul.\n","citation":"Psalms 121:5-7","date":"2025-12-22"},
|
||||
{"book":"Genesis","verse":"Leah conceived again, and bore a sixth son to Jacob.\n Leah said, “God has endowed me with a good dowry. Now my husband will live with me, because I have borne him six sons.” She named him Zebulun.\n Afterwards, she bore a daughter, and named her Dinah.\n","citation":"Genesis 30:19-21","date":"2025-12-23"},
|
||||
{"book":"Luke","verse":"And it came to pass in those days that a decree went out from Caesar Augustus that all the world should be registered. This census first took place while Quirinius was governing Syria. So all went to be registered, everyone to his own city.\n\nJoseph also went up from Galilee, out of the city of Nazareth, into Judea, to the city of David, which is called Bethlehem, because he was of the house and lineage of David, to be registered with Mary, his betrothed wife, who was with child. So it was, that while they were there, the days were completed for her to be delivered.","citation":"Luke 2:1-6","date":"2025-12-24"},
|
||||
{"book":"Luke","verse":"Now there were in the same country shepherds living out in the fields, keeping watch over their flock by night. And behold, an angel of the Lord stood before them, and the glory of the Lord shone around them, and they were greatly afraid. Then the angel said to them, “Do not be afraid, for behold, I bring you good tidings of great joy which will be to all people. For there is born to you this day in the city of David a Savior, who is Christ the Lord. And this will be the sign to you: You will find a Babe wrapped in swaddling cloths, lying in a manger.”","citation":"Luke 2:8-12","date":"2025-12-25"},
|
||||
{"book":"Jude","verse":"Jude, a bondservant of Jesus Christ, and brother of James, To those who are called, sanctified by God the Father, and preserved in Jesus Christ: Mercy, peace, and love be multiplied to you. Beloved, while I was very diligent to write to you concerning our common salvation, I found it necessary to write to you exhorting you to contend earnestly for the faith which was once for all delivered to the saints.","citation":"Jude 1:1-3","date":"2025-12-26"},
|
||||
{"book":"Isaiah","verse":"Do not let the son of the foreigner Who has joined himself to the Lord speak, saying, \"The Lord has utterly separated me from His people\"; Nor let the eunuch say, \"Here I am, a dry tree.\" For thus says the Lord: \"To the eunuchs who keep My Sabbaths, and choose what pleases Me, And hold fast My covenant, Even to them I will give in My house And within My walls a place and a name better than that of sons and daughters; I will give them an everlasting name that shall not be cut off.","citation":"Isaiah 56:3-5","date":"2025-12-27"},
|
||||
{"book":"Matthew","verse":"But Jesus answered and said to them, “I also will ask you one thing, which if you tell Me, I likewise will tell you by what authority I do these things: The baptism of John—where was it from? From heaven or from men?” And they reasoned among themselves, saying, “If we say, ‘From heaven,’ He will say to us, ‘Why then did you not believe him?’ But if we say, ‘From men,’ we fear the multitude, for all count John as a prophet.”","citation":"Matthew 21:24-26","date":"2025-12-28"},
|
||||
{"book":"2 Chronicles","verse":"Then Hezekiah humbled himself for the pride of his heart, he and the inhabitants of Jerusalem, so that the wrath of the Lord did not come upon them in the days of Hezekiah. Hezekiah had very great riches and honor. And he made himself treasuries for silver, for gold, for precious stones, for spices, for shields, and for all kinds of desirable items; storehouses for the harvest of grain, wine, and oil; and stalls for all kinds of livestock, and folds for flocks.","citation":"2 Chronicles 32:26-28","date":"2025-12-29"},
|
||||
{"book":"1 Thessalonians","verse":"therefore, brethren, in all our affliction and distress we were comforted concerning you by your faith. For now we live, if you stand fast in the Lord. For what thanks can we render to God for you, for all the joy with which we rejoice for your sake before our God,","citation":"1 Thessalonians 3:7-9","date":"2025-12-30"},
|
||||
{"book":"Psalms","verse":"The moon and stars to rule by night, For His mercy endures forever. To Him who struck Egypt in their firstborn, For His mercy endures forever; And brought out Israel from among them, For His mercy endures forever;","citation":"Psalms 136:9-11","date":"2025-12-31"},
|
||||
{"book":"Daniel","verse":"And the male goat is the kingdom of Greece. The large horn that is between its eyes is the first king. As for the broken horn and the four that stood up in its place, four kingdoms shall arise out of that nation, but not with its power. “And in the latter time of their kingdom, When the transgressors have reached their fullness, A king shall arise, Having fierce features, Who understands sinister schemes.","citation":"Daniel 8:21-23","date":"2026-01-01"},
|
||||
{"book":"Jude","verse":"But you, beloved, remember the words which were spoken before by the apostles of our Lord Jesus Christ: how they told you that there would be mockers in the last time who would walk according to their own ungodly lusts. These are sensual persons, who cause divisions, not having the Spirit.","citation":"Jude 1:17-19","date":"2026-01-02"},
|
||||
{"book":"Exodus","verse":"And Moses said, “We will go with our young and our old; with our sons and our daughters, with our flocks and our herds we will go, for we must hold a feast to the Lord.” Then he said to them, “The Lord had better be with you when I let you and your little ones go! Beware, for evil is ahead of you. Not so! Go now, you who are men, and serve the Lord, for that is what you desired.” And they were driven out from Pharaoh’s presence.","citation":"Exodus 10:9-11","date":"2026-01-03"},
|
||||
{"book":"Deuteronomy","verse":"Gather the people together, men and women and little ones, and the stranger who is within your gates, that they may hear and that they may learn to fear the Lord your God and carefully observe all the words of this law, and that their children, who have not known it, may hear and learn to fear the Lord your God as long as you live in the land which you cross the Jordan to possess.” Then the Lord said to Moses, “Behold, the days approach when you must die; call Joshua, and present yourselves in the tabernacle of meeting, that I may inaugurate him.” So Moses and Joshua went and presented themselves in the tabernacle of meeting.","citation":"Deuteronomy 31:12-14","date":"2026-01-04"},
|
||||
{"book":"Colossians","verse":"and by Him to reconcile all things to Himself, by Him, whether things on earth or things in heaven, having made peace through the blood of His cross. And you, who once were alienated and enemies in your mind by wicked works, yet now He has reconciled in the body of His flesh through death, to present you holy, and blameless, and above reproach in His sight—","citation":"Colossians 1:20-22","date":"2026-01-05"},
|
||||
{"book":"2 Thessalonians","verse":"We are bound to thank God always for you, brethren, as it is fitting, because your faith grows exceedingly, and the love of every one of you all abounds toward each other, so that we ourselves boast of you among the churches of God for your patience and faith in all your persecutions and tribulations that you endure, which is manifest evidence of the righteous judgment of God, that you may be counted worthy of the kingdom of God, for which you also suffer;","citation":"2 Thessalonians 1:3-5","date":"2026-01-06"},
|
||||
{"book":"2 Timothy","verse":"The Lord grant mercy to the household of Onesiphorus, for he often refreshed me, and was not ashamed of my chain; but when he arrived in Rome, he sought me out very zealously and found me. The Lord grant to him that he may find mercy from the Lord in that Day—and you know very well how many ways he ministered to me at Ephesus.","citation":"2 Timothy 1:16-18","date":"2026-01-07"},
|
||||
{"book":"Micah","verse":"Do not trust in a friend; Do not put your confidence in a companion; Guard the doors of your mouth From her who lies in your bosom. For son dishonors father, Daughter rises against her mother, Daughter-in-law against her mother-in-law; A man’s enemies are the men of his own household. Therefore I will look to the Lord; I will wait for the God of my salvation; My God will hear me.","citation":"Micah 7:5-7","date":"2026-01-08"},
|
||||
{"book":"Amos","verse":"And He said, “Amos, what do you see?” So I said, “A basket of summer fruit.” Then the Lord said to me: “The end has come upon My people Israel; I will not pass by them anymore. And the songs of the temple Shall be wailing in that day,” Says the Lord God— “Many dead bodies everywhere, They shall be thrown out in silence.” Hear this, you who swallow up the needy, And make the poor of the land fail,","citation":"Amos 8:2-4","date":"2026-01-09"},
|
||||
{"book":"Acts","verse":"But do not yield to them, for more than forty of them lie in wait for him, men who have bound themselves by an oath that they will neither eat nor drink till they have killed him; and now they are ready, waiting for the promise from you.” So the commander let the young man depart, and commanded him, “Tell no one that you have revealed these things to me.” And he called for two centurions, saying, “Prepare two hundred soldiers, seventy horsemen, and two hundred spearmen to go to Caesarea at the third hour of the night;","citation":"Acts 23:21-23","date":"2026-01-10"},
|
||||
{"book":"Hebrews","verse":"that by two immutable things, in which it is impossible for God to lie, we might have strong consolation, who have fled for refuge to lay hold of the hope set before us. This hope we have as an anchor of the soul, both sure and steadfast, and which enters the Presence behind the veil, where the forerunner has entered for us, even Jesus, having become High Priest forever according to the order of Melchizedek.","citation":"Hebrews 6:18-20","date":"2026-01-11"},
|
||||
{"book":"Acts","verse":"So when Paul’s sister’s son heard of their ambush, he went and entered the barracks and told Paul. Then Paul called one of the centurions to him and said, “Take this young man to the commander, for he has something to tell him.” So he took him and brought him to the commander and said, “Paul the prisoner called me to him and asked me to bring this young man to you. He has something to say to you.”","citation":"Acts 23:16-18","date":"2026-01-12"},
|
||||
{"book":"Habakkuk","verse":"You went forth for the salvation of Your people, For salvation with Your Anointed. You struck the head from the house of the wicked, By laying bare from foundation to neck. You thrust through with his own arrows The head of his villages. They came out like a whirlwind to scatter me; Their rejoicing was like feasting on the poor in secret. You walked through the sea with Your horses, Through the heap of great waters.","citation":"Habakkuk 3:13-15","date":"2026-01-13"},
|
||||
{"book":"Zephaniah","verse":"In that day you shall not be shamed for any of your deeds In which you transgress against Me; For then I will take away from your midst Those who rejoice in your pride, And you shall no longer be haughty In My holy mountain. I will leave in your midst A meek and humble people, And they shall trust in the name of the Lord. The remnant of Israel shall do no unrighteousness And speak no lies, Nor shall a deceitful tongue be found in their mouth; For they shall feed their flocks and lie down, And no one shall make them afraid.”","citation":"Zephaniah 3:11-13","date":"2026-01-14"},
|
||||
{"book":"Ezra","verse":"However, in the first year of Cyrus king of Babylon, King Cyrus issued a decree to build this house of God. Also, the gold and silver articles of the house of God, which Nebuchadnezzar had taken from the temple that was in Jerusalem and carried into the temple of Babylon—those King Cyrus took from the temple of Babylon, and they were given to one named Sheshbazzar, whom he had made governor. And he said to him, ‘Take these articles; go, carry them to the temple site that is in Jerusalem, and let the house of God be rebuilt on its former site.’","citation":"Ezra 5:13-15","date":"2026-01-15"},
|
||||
{"book":"Numbers","verse":"They departed from Succoth and camped at Etham, which is on the edge of the wilderness. They moved from Etham and turned back to Pi Hahiroth, which is east of Baal Zephon; and they camped near Migdol. They departed from before Hahiroth and passed through the midst of the sea into the wilderness, went three days’ journey in the Wilderness of Etham, and camped at Marah.","citation":"Numbers 33:6-8","date":"2026-01-16"},
|
||||
{"book":"Ruth","verse":"When she saw that she was determined to go with her, she stopped speaking to her. Now the two of them went until they came to Bethlehem. And it happened, when they had come to Bethlehem, that all the city was excited because of them; and the women said, “ Is this Naomi?” But she said to them, “Do not call me Naomi; call me Mara, for the Almighty has dealt very bitterly with me.","citation":"Ruth 1:18-20","date":"2026-01-17"},
|
||||
{"book":"Matthew","verse":"But as the days of Noah were, so also will the coming of the Son of Man be. For as in the days before the flood, they were eating and drinking, marrying and giving in marriage, until the day that Noah entered the ark, and did not know until the flood came and took them all away, so also will the coming of the Son of Man be.","citation":"Matthew 24:37-39","date":"2026-01-18"},
|
||||
{"book":"1 Timothy","verse":"Now the purpose of the commandment is love from a pure heart, from a good conscience, and from sincere faith, from which some, having strayed, have turned aside to idle talk, desiring to be teachers of the law, understanding neither what they say nor the things which they affirm.","citation":"1 Timothy 1:5-7","date":"2026-01-19"},
|
||||
{"book":"1 Peter","verse":"To them it was revealed that, not to themselves, but to us they were ministering the things which now have been reported to you through those who have preached the gospel to you by the Holy Spirit sent from heaven—things which angels desire to look into. Therefore gird up the loins of your mind, be sober, and rest your hope fully upon the grace that is to be brought to you at the revelation of Jesus Christ; as obedient children, not conforming yourselves to the former lusts, as in your ignorance;","citation":"1 Peter 1:12-14","date":"2026-01-20"},
|
||||
{"book":"Ruth","verse":"Now the two of them went until they came to Bethlehem. And it happened, when they had come to Bethlehem, that all the city was excited because of them; and the women said, “ Is this Naomi?” But she said to them, “Do not call me Naomi; call me Mara, for the Almighty has dealt very bitterly with me. I went out full, and the Lord has brought me home again empty. Why do you call me Naomi, since the Lord has testified against me, and the Almighty has afflicted me?”","citation":"Ruth 1:19-21","date":"2026-01-21"},
|
||||
{"book":"Ezra","verse":"Then the prophet Haggai and Zechariah the son of Iddo, prophets, prophesied to the Jews who were in Judah and Jerusalem, in the name of the God of Israel, who was over them. So Zerubbabel the son of Shealtiel and Jeshua the son of Jozadak rose up and began to build the house of God which is in Jerusalem; and the prophets of God were with them, helping them. At the same time Tattenai the governor of the region beyond the River and Shethar-Boznai and their companions came to them and spoke thus to them: “Who has commanded you to build this temple and finish this wall?”","citation":"Ezra 5:1-3","date":"2026-01-22"},
|
||||
{"book":"2 Chronicles","verse":"For now I have chosen and sanctified this house, that My name may be there forever; and My eyes and My heart will be there perpetually. As for you, if you walk before Me as your father David walked, and do according to all that I have commanded you, and if you keep My statutes and My judgments, then I will establish the throne of your kingdom, as I covenanted with David your father, saying, ‘You shall not fail to have a man as ruler in Israel.’","citation":"2 Chronicles 7:16-18","date":"2026-01-23"},
|
||||
{"book":"Joel","verse":"Then the Lord will be zealous for His land, And pity His people. The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations. “But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”","citation":"Joel 2:18-20","date":"2026-01-24"},
|
||||
{"book":"1 Samuel","verse":"And now this present which your maidservant has brought to my lord, let it be given to the young men who follow my lord. Please forgive the trespass of your maidservant. For the Lord will certainly make for my lord an enduring house, because my lord fights the battles of the Lord, and evil is not found in you throughout your days. Yet a man has risen to pursue you and seek your life, but the life of my lord shall be bound in the bundle of the living with the Lord your God; and the lives of your enemies He shall sling out, as from the pocket of a sling.","citation":"1 Samuel 25:27-29","date":"2026-01-25"},
|
||||
{"book":"Galatians","verse":"But I saw none of the other apostles except James, the Lord’s brother. (Now concerning the things which I write to you, indeed, before God, I do not lie.) Afterward I went into the regions of Syria and Cilicia.","citation":"Galatians 1:19-21","date":"2026-01-26"},
|
||||
{"book":"Lamentations","verse":"How the gold has become dim! How changed the fine gold! The stones of the sanctuary are scattered At the head of every street. The precious sons of Zion, Valuable as fine gold, How they are regarded as clay pots, The work of the hands of the potter! Even the jackals present their breasts To nurse their young; But the daughter of my people is cruel, Like ostriches in the wilderness.","citation":"Lamentations 4:1-3","date":"2026-01-27"},
|
||||
{"book":"James","verse":"Draw near to God and He will draw near to you. Cleanse your hands, you sinners; and purify your hearts, you double-minded. Lament and mourn and weep! Let your laughter be turned to mourning and your joy to gloom. Humble yourselves in the sight of the Lord, and He will lift you up.","citation":"James 4:8-10","date":"2026-01-28"},
|
||||
{"book":"2 Kings","verse":"And he called Gehazi and said, “Call this Shunammite woman.” So he called her. And when she came in to him, he said, “Pick up your son.” So she went in, fell at his feet, and bowed to the ground; then she picked up her son and went out. And Elisha returned to Gilgal, and there was a famine in the land. Now the sons of the prophets were sitting before him; and he said to his servant, “Put on the large pot, and boil stew for the sons of the prophets.”","citation":"2 Kings 4:36-38","date":"2026-01-29"},
|
||||
{"book":"Deuteronomy","verse":"You should know in your heart that as a man chastens his son, so the Lord your God chastens you. “Therefore you shall keep the commandments of the Lord your God, to walk in His ways and to fear Him. For the Lord your God is bringing you into a good land, a land of brooks of water, of fountains and springs, that flow out of valleys and hills;","citation":"Deuteronomy 8:5-7","date":"2026-01-30"},
|
||||
{"book":"Isaiah","verse":"He burns half of it in the fire; With this half he eats meat; He roasts a roast, and is satisfied. He even warms himself and says, “Ah! I am warm, I have seen the fire.” And the rest of it he makes into a god, His carved image. He falls down before it and worships it, Prays to it and says, “Deliver me, for you are my god!” They do not know nor understand; For He has shut their eyes, so that they cannot see, And their hearts, so that they cannot understand.","citation":"Isaiah 44:16-18","date":"2026-01-31"},
|
||||
{"book":"3 John","verse":"Beloved, you do faithfully whatever you do for the brethren and for strangers, who have borne witness of your love before the church. If you send them forward on their journey in a manner worthy of God, you will do well, because they went forth for His name’s sake, taking nothing from the Gentiles.","citation":"3 John 1:5-7","date":"2026-02-01"},
|
||||
{"book":"1 Thessalonians","verse":"Greet all the brethren with a holy kiss. I charge you by the Lord that this epistle be read to all the holy brethren. The grace of our Lord Jesus Christ be with you. Amen.","citation":"1 Thessalonians 5:26-28","date":"2026-02-02"},
|
||||
{"book":"Joshua","verse":"Then the manna ceased on the day after they had eaten the produce of the land; and the children of Israel no longer had manna, but they ate the food of the land of Canaan that year. And it came to pass, when Joshua was by Jericho, that he lifted his eyes and looked, and behold, a Man stood opposite him with His sword drawn in His hand. And Joshua went to Him and said to Him, “ Are You for us or for our adversaries?” So He said, “No, but as Commander of the army of the Lord I have now come.” And Joshua fell on his face to the earth and worshiped, and said to Him, “What does my Lord say to His servant?”","citation":"Joshua 5:12-14","date":"2026-02-03"},
|
||||
{"book":"Song of Solomon","verse":"Your neck is like the tower of David, Built for an armory, On which hang a thousand bucklers, All shields of mighty men. Your two breasts are like two fawns, Twins of a gazelle, Which feed among the lilies. Until the day breaks And the shadows flee away, I will go my way to the mountain of myrrh And to the hill of frankincense.","citation":"Song of Solomon 4:4-6","date":"2026-02-04"},
|
||||
{"book":"Genesis","verse":"But his father refused and said, “I know, my son, I know. He also shall become a people, and he also shall be great; but truly his younger brother shall be greater than he, and his descendants shall become a multitude of nations.” So he blessed them that day, saying, “By you Israel will bless, saying, ‘May God make you as Ephraim and as Manasseh!’ ” And thus he set Ephraim before Manasseh. Then Israel said to Joseph, “Behold, I am dying, but God will be with you and bring you back to the land of your fathers.","citation":"Genesis 48:19-21","date":"2026-02-05"},
|
||||
{"book":"2 Peter","verse":"whereas angels, who are greater in power and might, do not bring a reviling accusation against them before the Lord. But these, like natural brute beasts made to be caught and destroyed, speak evil of the things they do not understand, and will utterly perish in their own corruption, and will receive the wages of unrighteousness, as those who count it pleasure to carouse in the daytime. They are spots and blemishes, carousing in their own deceptions while they feast with you,","citation":"2 Peter 2:11-13","date":"2026-02-06"},
|
||||
{"book":"2 Chronicles","verse":"Now after the death of Jehoiada the leaders of Judah came and bowed down to the king. And the king listened to them. Therefore they left the house of the Lord God of their fathers, and served wooden images and idols; and wrath came upon Judah and Jerusalem because of their trespass. Yet He sent prophets to them, to bring them back to the Lord; and they testified against them, but they would not listen.","citation":"2 Chronicles 24:17-19","date":"2026-02-07"},
|
||||
{"book":"Psalms","verse":"Man goes out to his work And to his labor until the evening. O Lord, how manifold are Your works! In wisdom You have made them all. The earth is full of Your possessions— This great and wide sea, In which are innumerable teeming things, Living things both small and great.","citation":"Psalms 104:23-25","date":"2026-02-08"},
|
||||
{"book":"1 Kings","verse":"Thus Zimri destroyed all the household of Baasha, according to the word of the Lord, which He spoke against Baasha by Jehu the prophet, for all the sins of Baasha and the sins of Elah his son, by which they had sinned and by which they had made Israel sin, in provoking the Lord God of Israel to anger with their idols. Now the rest of the acts of Elah, and all that he did, are they not written in the book of the chronicles of the kings of Israel?","citation":"1 Kings 16:12-14","date":"2026-02-09"},
|
||||
{"book":"Psalms","verse":"Blessed is the man Who walks not in the counsel of the ungodly, Nor stands in the path of sinners, Nor sits in the seat of the scornful; But his delight is in the law of the Lord, And in His law he meditates day and night. He shall be like a tree Planted by the rivers of water, That brings forth its fruit in its season, Whose leaf also shall not wither; And whatever he does shall prosper.","citation":"Psalms 1:1-3","date":"2026-02-10"},
|
||||
{"book":"Romans","verse":"And not only that, but we also rejoice in God through our Lord Jesus Christ, through whom we have now received the reconciliation. Therefore, just as through one man sin entered the world, and death through sin, and thus death spread to all men, because all sinned— (For until the law sin was in the world, but sin is not imputed when there is no law.","citation":"Romans 5:11-13","date":"2026-02-11"},
|
||||
{"book":"1 Peter","verse":"For in this manner, in former times, the holy women who trusted in God also adorned themselves, being submissive to their own husbands, as Sarah obeyed Abraham, calling him lord, whose daughters you are if you do good and are not afraid with any terror. Husbands, likewise, dwell with them with understanding, giving honor to the wife, as to the weaker vessel, and as being heirs together of the grace of life, that your prayers may not be hindered.","citation":"1 Peter 3:5-7","date":"2026-02-12"},
|
||||
{"book":"Micah","verse":"Will the Lord be pleased with thousands of rams, Ten thousand rivers of oil? Shall I give my firstborn for my transgression, The fruit of my body for the sin of my soul? He has shown you, O man, what is good; And what does the Lord require of you But to do justly, To love mercy, And to walk humbly with your God? The Lord’s voice cries to the city— Wisdom shall see Your name: “Hear the rod! Who has appointed it?","citation":"Micah 6:7-9","date":"2026-02-13"},
|
||||
{"book":"Micah","verse":"For all people walk each in the name of his god, But we will walk in the name of the Lord our God Forever and ever. “In that day,” says the Lord, “I will assemble the lame, I will gather the outcast And those whom I have afflicted; I will make the lame a remnant, And the outcast a strong nation; So the Lord will reign over them in Mount Zion From now on, even forever.","citation":"Micah 4:5-7","date":"2026-02-14"},
|
||||
{"book":"1 John","verse":"And whatever we ask we receive from Him, because we keep His commandments and do those things that are pleasing in His sight. And this is His commandment: that we should believe on the name of His Son Jesus Christ and love one another, as He gave us commandment. Now he who keeps His commandments abides in Him, and He in him. And by this we know that He abides in us, by the Spirit whom He has given us.","citation":"1 John 3:22-24","date":"2026-02-15"},
|
||||
{"book":"Joel","verse":"Tell your children about it, Let your children tell their children, And their children another generation. What the chewing locust left, the swarming locust has eaten; What the swarming locust left, the crawling locust has eaten; And what the crawling locust left, the consuming locust has eaten. Awake, you drunkards, and weep; And wail, all you drinkers of wine, Because of the new wine, For it has been cut off from your mouth.","citation":"Joel 1:3-5","date":"2026-02-16"},
|
||||
{"book":"Isaiah","verse":"The Syrians before and the Philistines behind; And they shall devour Israel with an open mouth. For all this His anger is not turned away, But His hand is stretched out still. For the people do not turn to Him who strikes them, Nor do they seek the Lord of hosts. Therefore the Lord will cut off head and tail from Israel, Palm branch and bulrush in one day.","citation":"Isaiah 9:12-14","date":"2026-02-17"},
|
||||
{"book":"1 Thessalonians","verse":"Abstain from every form of evil. Now may the God of peace Himself sanctify you completely; and may your whole spirit, soul, and body be preserved blameless at the coming of our Lord Jesus Christ. He who calls you is faithful, who also will do it.","citation":"1 Thessalonians 5:22-24","date":"2026-02-18"},
|
||||
{"book":"Jonah","verse":"Then the mariners were afraid; and every man cried out to his god, and threw the cargo that was in the ship into the sea, to lighten the load. But Jonah had gone down into the lowest parts of the ship, had lain down, and was fast asleep. So the captain came to him, and said to him, “What do you mean, sleeper? Arise, call on your God; perhaps your God will consider us, so that we may not perish.” And they said to one another, “Come, let us cast lots, that we may know for whose cause this trouble has come upon us.” So they cast lots, and the lot fell on Jonah.","citation":"Jonah 1:5-7","date":"2026-02-19"},
|
||||
{"book":"Judges","verse":"And the Angel of the Lord appeared to the woman and said to her, “Indeed now, you are barren and have borne no children, but you shall conceive and bear a son. Now therefore, please be careful not to drink wine or similar drink, and not to eat anything unclean. For behold, you shall conceive and bear a son. And no razor shall come upon his head, for the child shall be a Nazirite to God from the womb; and he shall begin to deliver Israel out of the hand of the Philistines.”","citation":"Judges 13:3-5","date":"2026-02-20"},
|
||||
{"book":"1 Thessalonians","verse":"For if we believe that Jesus died and rose again, even so God will bring with Him those who sleep in Jesus. For this we say to you by the word of the Lord, that we who are alive and remain until the coming of the Lord will by no means precede those who are asleep. For the Lord Himself will descend from heaven with a shout, with the voice of an archangel, and with the trumpet of God. And the dead in Christ will rise first.","citation":"1 Thessalonians 4:14-16","date":"2026-02-21"},
|
||||
{"book":"Daniel","verse":"And suddenly, one having the likeness of the sons of men touched my lips; then I opened my mouth and spoke, saying to him who stood before me, “My lord, because of the vision my sorrows have overwhelmed me, and I have retained no strength. For how can this servant of my lord talk with you, my lord? As for me, no strength remains in me now, nor is any breath left in me.” Then again, the one having the likeness of a man touched me and strengthened me.","citation":"Daniel 10:16-18","date":"2026-02-22"},
|
||||
{"book":"Exodus","verse":"Then the daughter of Pharaoh came down to bathe at the river. And her maidens walked along the riverside; and when she saw the ark among the reeds, she sent her maid to get it. And when she opened it, she saw the child, and behold, the baby wept. So she had compassion on him, and said, “This is one of the Hebrews’ children.” Then his sister said to Pharaoh’s daughter, “Shall I go and call a nurse for you from the Hebrew women, that she may nurse the child for you?”","citation":"Exodus 2:5-7","date":"2026-02-23"},
|
||||
{"book":"Philippians","verse":"and not in any way terrified by your adversaries, which is to them a proof of perdition, but to you of salvation, and that from God. For to you it has been granted on behalf of Christ, not only to believe in Him, but also to suffer for His sake, having the same conflict which you saw in me and now hear is in me.","citation":"Philippians 1:28-30","date":"2026-02-24"},
|
||||
{"book":"Hosea","verse":"As they called them, So they went from them; They sacrificed to the Baals, And burned incense to carved images. “I taught Ephraim to walk, Taking them by their arms; But they did not know that I healed them. I drew them with gentle cords, With bands of love, And I was to them as those who take the yoke from their neck. I stooped and fed them.","citation":"Hosea 11:2-4","date":"2026-02-25"},
|
||||
{"book":"Habakkuk","verse":"You are filled with shame instead of glory. You also—drink! And be exposed as uncircumcised! The cup of the Lord’s right hand will be turned against you, And utter shame will be on your glory. For the violence done to Lebanon will cover you, And the plunder of beasts which made them afraid, Because of men’s blood And the violence of the land and the city, And of all who dwell in it. “What profit is the image, that its maker should carve it, The molded image, a teacher of lies, That the maker of its mold should trust in it, To make mute idols?","citation":"Habakkuk 2:16-18","date":"2026-02-26"},
|
||||
{"book":"Genesis","verse":"I am the God of Bethel, where you anointed the pillar and where you made a vow to Me. Now arise, get out of this land, and return to the land of your family.’ ” Then Rachel and Leah answered and said to him, “Is there still any portion or inheritance for us in our father’s house? Are we not considered strangers by him? For he has sold us, and also completely consumed our money.","citation":"Genesis 31:13-15","date":"2026-02-27"},
|
||||
{"book":"Galatians","verse":"to whom we did not yield submission even for an hour, that the truth of the gospel might continue with you. But from those who seemed to be something—whatever they were, it makes no difference to me; God shows personal favoritism to no man—for those who seemed to be something added nothing to me. But on the contrary, when they saw that the gospel for the uncircumcised had been committed to me, as the gospel for the circumcised was to Peter","citation":"Galatians 2:5-7","date":"2026-02-28"},
|
||||
{"book":"Revelation","verse":"Now when the dragon saw that he had been cast to the earth, he persecuted the woman who gave birth to the male Child. But the woman was given two wings of a great eagle, that she might fly into the wilderness to her place, where she is nourished for a time and times and half a time, from the presence of the serpent. So the serpent spewed water out of his mouth like a flood after the woman, that he might cause her to be carried away by the flood.","citation":"Revelation 12:13-15","date":"2026-03-01"},
|
||||
{"book":"Zephaniah","verse":"“I will utterly consume everything From the face of the land,” Says the Lord; “I will consume man and beast; I will consume the birds of the heavens, The fish of the sea, And the stumbling blocks along with the wicked. I will cut off man from the face of the land,” Says the Lord. “I will stretch out My hand against Judah, And against all the inhabitants of Jerusalem. I will cut off every trace of Baal from this place, The names of the idolatrous priests with the pagan priests—","citation":"Zephaniah 1:2-4","date":"2026-03-02"},
|
||||
{"book":"Malachi","verse":"“But you profane it, In that you say, ‘The table of the Lord is defiled; And its fruit, its food, is contemptible.’ You also say, ‘Oh, what a weariness!’ And you sneer at it,” Says the Lord of hosts. “And you bring the stolen, the lame, and the sick; Thus you bring an offering! Should I accept this from your hand?” Says the Lord. “But cursed be the deceiver Who has in his flock a male, And takes a vow, But sacrifices to the Lord what is blemished— For I am a great King,” Says the Lord of hosts, “And My name is to be feared among the nations.","citation":"Malachi 1:12-14","date":"2026-03-03"},
|
||||
{"book":"Job","verse":"Why does your heart carry you away, And what do your eyes wink at, That you turn your spirit against God, And let such words go out of your mouth? “What is man, that he could be pure? And he who is born of a woman, that he could be righteous?","citation":"Job 15:12-14","date":"2026-03-04"},
|
||||
{"book":"Acts","verse":"Also, many of those who had practiced magic brought their books together and burned them in the sight of all. And they counted up the value of them, and it totaled fifty thousand pieces of silver. So the word of the Lord grew mightily and prevailed. When these things were accomplished, Paul purposed in the Spirit, when he had passed through Macedonia and Achaia, to go to Jerusalem, saying, “After I have been there, I must also see Rome.”","citation":"Acts 19:19-21","date":"2026-03-05"},
|
||||
{"book":"2 Peter","verse":"For we did not follow cunningly devised fables when we made known to you the power and coming of our Lord Jesus Christ, but were eyewitnesses of His majesty. For He received from God the Father honor and glory when such a voice came to Him from the Excellent Glory: “This is My beloved Son, in whom I am well pleased.” And we heard this voice which came from heaven when we were with Him on the holy mountain.","citation":"2 Peter 1:16-18","date":"2026-03-06"},
|
||||
{"book":"James","verse":"If any of you lacks wisdom, let him ask of God, who gives to all liberally and without reproach, and it will be given to him. But let him ask in faith, with no doubting, for he who doubts is like a wave of the sea driven and tossed by the wind. For let not that man suppose that he will receive anything from the Lord;","citation":"James 1:5-7","date":"2026-03-07"},
|
||||
{"book":"1 Samuel","verse":"And watch: if it goes up the road to its own territory, to Beth Shemesh, then He has done us this great evil. But if not, then we shall know that it is not His hand that struck us—it happened to us by chance.” Then the men did so; they took two milk cows and hitched them to the cart, and shut up their calves at home. And they set the ark of the Lord on the cart, and the chest with the gold rats and the images of their tumors.","citation":"1 Samuel 6:9-11","date":"2026-03-08"},
|
||||
{"book":"2 Corinthians","verse":"For our boasting is this: the testimony of our conscience that we conducted ourselves in the world in simplicity and godly sincerity, not with fleshly wisdom but by the grace of God, and more abundantly toward you. For we are not writing any other things to you than what you read or understand. Now I trust you will understand, even to the end (as also you have understood us in part), that we are your boast as you also are ours, in the day of the Lord Jesus.","citation":"2 Corinthians 1:12-14","date":"2026-03-09"},
|
||||
{"book":"1 Kings","verse":"Benaiah the son of Jehoiada answered the king and said, “Amen! May the Lord God of my lord the king say so too. As the Lord has been with my lord the king, even so may He be with Solomon, and make his throne greater than the throne of my lord King David.” So Zadok the priest, Nathan the prophet, Benaiah the son of Jehoiada, the Cherethites, and the Pelethites went down and had Solomon ride on King David’s mule, and took him to Gihon.","citation":"1 Kings 1:36-38","date":"2026-03-10"},
|
||||
{"book":"Proverbs","verse":"So that your trust may be in the Lord; I have instructed you today, even you. Have I not written to you excellent things Of counsels and knowledge, That I may make you know the certainty of the words of truth, That you may answer words of truth To those who send to you?","citation":"Proverbs 22:19-21","date":"2026-03-11"},
|
||||
{"book":"2 Samuel","verse":"Then Hadadezer sent and brought out the Syrians who were beyond the River, and they came to Helam. And Shobach the commander of Hadadezer’s army went before them. When it was told David, he gathered all Israel, crossed over the Jordan, and came to Helam. And the Syrians set themselves in battle array against David and fought with him. Then the Syrians fled before Israel; and David killed seven hundred charioteers and forty thousand horsemen of the Syrians, and struck Shobach the commander of their army, who died there.","citation":"2 Samuel 10:16-18","date":"2026-03-12"},
|
||||
{"book":"Amos","verse":"“I overthrew some of you, As God overthrew Sodom and Gomorrah, And you were like a firebrand plucked from the burning; Yet you have not returned to Me,” Says the Lord. “Therefore thus will I do to you, O Israel; Because I will do this to you, Prepare to meet your God, O Israel!” For behold, He who forms mountains, And creates the wind, Who declares to man what his thought is, And makes the morning darkness, Who treads the high places of the earth— The Lord God of hosts is His name.","citation":"Amos 4:11-13","date":"2026-03-13"},
|
||||
{"book":"Hosea","verse":"“Therefore, behold, I will hedge up your way with thorns, And wall her in, So that she cannot find her paths. She will chase her lovers, But not overtake them; Yes, she will seek them, but not find them. Then she will say, ‘I will go and return to my first husband, For then it was better for me than now.’ For she did not know That I gave her grain, new wine, and oil, And multiplied her silver and gold— Which they prepared for Baal.","citation":"Hosea 2:6-8","date":"2026-03-14"},
|
||||
{"book":"Jeremiah","verse":"Behold, I will bring fear upon you,” Says the Lord God of hosts, “From all those who are around you; You shall be driven out, everyone headlong, And no one will gather those who wander off. But afterward I will bring back The captives of the people of Ammon,” says the Lord. Against Edom. Thus says the Lord of hosts: “ Is wisdom no more in Teman? Has counsel perished from the prudent? Has their wisdom vanished?","citation":"Jeremiah 49:5-7","date":"2026-03-15"},
|
||||
{"book":"Genesis","verse":"Now Jacob went out from Beersheba and went toward Haran. So he came to a certain place and stayed there all night, because the sun had set. And he took one of the stones of that place and put it at his head, and he lay down in that place to sleep. Then he dreamed, and behold, a ladder was set up on the earth, and its top reached to heaven; and there the angels of God were ascending and descending on it.","citation":"Genesis 28:10-12","date":"2026-03-16"},
|
||||
{"book":"1 Kings","verse":"In the twenty-seventh year of Asa king of Judah, Zimri had reigned in Tirzah seven days. And the people were encamped against Gibbethon, which belonged to the Philistines. Now the people who were encamped heard it said, “Zimri has conspired and also has killed the king.” So all Israel made Omri, the commander of the army, king over Israel that day in the camp. Then Omri and all Israel with him went up from Gibbethon, and they besieged Tirzah.","citation":"1 Kings 16:15-17","date":"2026-03-17"},
|
||||
{"book":"1 John","verse":"And He Himself is the propitiation for our sins, and not for ours only but also for the whole world. Now by this we know that we know Him, if we keep His commandments. He who says, “I know Him,” and does not keep His commandments, is a liar, and the truth is not in him.","citation":"1 John 2:2-4","date":"2026-03-18"},
|
||||
{"book":"Esther","verse":"And King Ahasuerus imposed tribute on the land and on the islands of the sea. Now all the acts of his power and his might, and the account of the greatness of Mordecai, to which the king advanced him, are they not written in the book of the chronicles of the kings of Media and Persia? For Mordecai the Jew was second to King Ahasuerus, and was great among the Jews and well received by the multitude of his brethren, seeking the good of his people and speaking peace to all his countrymen.","citation":"Esther 10:1-3","date":"2026-03-19"},
|
||||
{"book":"1 Corinthians","verse":"When I was a child, I spoke as a child, I understood as a child, I thought as a child; but when I became a man, I put away childish things. For now we see in a mirror, dimly, but then face to face. Now I know in part, but then I shall know just as I also am known. And now abide faith, hope, love, these three; but the greatest of these is love.","citation":"1 Corinthians 13:11-13","date":"2026-03-20"}]
|
||||
Reference in New Issue
Block a user