mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-06 01:43:32 -04:00
Compare commits
6 Commits
rss
...
6bced13543
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bced13543 | ||
|
|
7d93ead70c | ||
|
|
4c82aa078b | ||
|
|
2058149207 | ||
|
|
2bd86d37a1 | ||
|
|
33d6fae446 |
@@ -5,6 +5,7 @@
|
|||||||
"Read(./secrets/**)",
|
"Read(./secrets/**)",
|
||||||
"Read(./config/credentials.json)",
|
"Read(./config/credentials.json)",
|
||||||
"Read(./build)",
|
"Read(./build)",
|
||||||
|
"Read(./**.xml)",
|
||||||
"Read(./embeddings**)"
|
"Read(./embeddings**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
13
.env.example
13
.env.example
@@ -1,18 +1,5 @@
|
|||||||
DATABASE_URL=example.db
|
DATABASE_URL=example.db
|
||||||
PUBLIC_SITE_URL=https://bibdle.com
|
|
||||||
|
|
||||||
# nodemailer
|
|
||||||
SMTP_USERNAME=email@example.com
|
|
||||||
SMTP_TOKEN=TOKEN
|
|
||||||
SMTP_SERVER=smtp.example.com
|
|
||||||
SMTP_PORT=port
|
|
||||||
# note from mail provider: Enable TLS or SSL on the external service if it is supported.
|
|
||||||
|
|
||||||
# sign in with Discord
|
|
||||||
|
|
||||||
# sign in with google
|
|
||||||
|
|
||||||
# sign in with apple
|
|
||||||
AUTH_SECRET=your-random-secret-here
|
AUTH_SECRET=your-random-secret-here
|
||||||
APPLE_ID=com.yourcompany.yourapp.client
|
APPLE_ID=com.yourcompany.yourapp.client
|
||||||
APPLE_TEAM_ID=your-team-id
|
APPLE_TEAM_ID=your-team-id
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,5 +28,4 @@ vite.config.ts.timestamp-*
|
|||||||
llms-*
|
llms-*
|
||||||
|
|
||||||
embeddings*
|
embeddings*
|
||||||
*bible.xml
|
*.xml
|
||||||
engwebu_usfx.xml
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
EnglishNKJBible.xml
|
|
||||||
GreekModern1904Bible.xml
|
|
||||||
engwebu_usfx.xml
|
|
||||||
46
CLAUDE.md
46
CLAUDE.md
@@ -4,7 +4,33 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
|
Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book, etc.) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
|
||||||
|
|
||||||
|
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||||
|
|
||||||
|
(Make sure you use the Svelte agent to execute these commands)
|
||||||
|
|
||||||
|
## Available MCP Tools:
|
||||||
|
|
||||||
|
### 1. list-sections
|
||||||
|
|
||||||
|
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
||||||
|
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
|
||||||
|
|
||||||
|
### 2. get-documentation
|
||||||
|
|
||||||
|
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
|
||||||
|
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
|
||||||
|
|
||||||
|
### 3. svelte-autofixer
|
||||||
|
|
||||||
|
Analyzes Svelte code and returns issues and suggestions.
|
||||||
|
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||||
|
|
||||||
|
### 4. playground-link
|
||||||
|
|
||||||
|
Generates a Svelte Playground link with the provided code.
|
||||||
|
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
@@ -19,23 +45,23 @@ Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start development server
|
# Start development server
|
||||||
npm run dev
|
bun run dev
|
||||||
|
|
||||||
# Type checking
|
# Type checking
|
||||||
npm run check
|
bun run check
|
||||||
npm run check:watch
|
bun run check:watch
|
||||||
|
|
||||||
# Build for production
|
# Build for production
|
||||||
npm run build
|
bun run build
|
||||||
|
|
||||||
# Preview production build
|
# Preview production build
|
||||||
npm run preview
|
bun run preview
|
||||||
|
|
||||||
# Database operations
|
# Database operations
|
||||||
npm run db:push # Push schema changes to database
|
bun run db:push # Push schema changes to database
|
||||||
npm run db:generate # Generate migrations
|
bun run db:generate # Generate migrations
|
||||||
npm run db:migrate # Run migrations
|
bun run db:migrate # Run migrations
|
||||||
npm run db:studio # Open Drizzle Studio GUI
|
bun run db:studio # Open Drizzle Studio GUI
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|||||||
@@ -24143,7 +24143,7 @@
|
|||||||
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
|
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
|
||||||
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, ‘Where is their God?’ ”</verse>
|
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, ‘Where is their God?’ ”</verse>
|
||||||
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
|
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
|
||||||
<verse number="19">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.”</verse>
|
<verse number="19">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.</verse>
|
||||||
<verse number="20">“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.”</verse>
|
<verse number="20">“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.”</verse>
|
||||||
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
|
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
|
||||||
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
|
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# analyze_top_users.sh
|
|
||||||
# Analyzes the daily_completions table to find the top 10 anonymous IDs by completion count
|
|
||||||
|
|
||||||
# Set database path from argument or default to dev.db
|
|
||||||
DB_PATH="${1:-dev.db}"
|
|
||||||
|
|
||||||
# Check if database file exists
|
|
||||||
if [ ! -f "$DB_PATH" ]; then
|
|
||||||
echo "Error: Database file not found: $DB_PATH"
|
|
||||||
echo "Usage: $0 [database_path]"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run the analysis query
|
|
||||||
sqlite3 "$DB_PATH" <<EOF
|
|
||||||
.mode column
|
|
||||||
.headers on
|
|
||||||
.width 36 16 16 17
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
anonymous_id,
|
|
||||||
COUNT(*) as completion_count,
|
|
||||||
MIN(date) as first_completion,
|
|
||||||
MAX(date) as latest_completion
|
|
||||||
FROM daily_completions
|
|
||||||
GROUP BY anonymous_id
|
|
||||||
ORDER BY completion_count DESC
|
|
||||||
LIMIT 10;
|
|
||||||
EOF
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/bin/zsh
|
|
||||||
|
|
||||||
# Clear today's verse from daily_verses table
|
|
||||||
DB_PATH="dev.db"
|
|
||||||
TODAY=$(date +%Y-%m-%d)
|
|
||||||
|
|
||||||
echo "Deleting verse for date: $TODAY"
|
|
||||||
|
|
||||||
sqlite3 "$DB_PATH" "DELETE FROM daily_verses WHERE date = '$TODAY';"
|
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo "✓ Successfully deleted verse for $TODAY"
|
|
||||||
|
|
||||||
# Show remaining verses in table
|
|
||||||
COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_verses;")
|
|
||||||
echo "Remaining verses in database: $COUNT"
|
|
||||||
else
|
|
||||||
echo "✗ Failed to delete verse"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/usr/bin/env zsh
|
|
||||||
|
|
||||||
DB_PATH="./local.db"
|
|
||||||
|
|
||||||
# Check if database exists
|
|
||||||
if [ ! -f "$DB_PATH" ]; then
|
|
||||||
echo "Error: Database not found at $DB_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Query for daily completions on 2026-02-01 with ranking
|
|
||||||
echo "Daily Completions for 2026-02-01"
|
|
||||||
echo "================================="
|
|
||||||
echo ""
|
|
||||||
printf "%-12s %-10s %-6s\n" "Anonymous ID" "Guesses" "Rank"
|
|
||||||
printf "%-12s %-10s %-6s\n" "------------" "-------" "----"
|
|
||||||
|
|
||||||
# Execute query with custom column mode
|
|
||||||
sqlite3 "$DB_PATH" <<SQL
|
|
||||||
.mode column
|
|
||||||
.headers off
|
|
||||||
.width 12 10 6
|
|
||||||
SELECT
|
|
||||||
SUBSTR(anonymous_id, 1, 10) as anon_id,
|
|
||||||
guess_count,
|
|
||||||
RANK() OVER (ORDER BY guess_count ASC) as rank
|
|
||||||
FROM daily_completions
|
|
||||||
WHERE date = '2026-02-01'
|
|
||||||
ORDER BY rank, guess_count;
|
|
||||||
SQL
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Total entries:"
|
|
||||||
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '2026-02-01';"
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bibdle",
|
"name": "bibdle",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.5.0",
|
"version": "3.0.0alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -28,11 +28,6 @@
|
|||||||
return "bg-red-500 border-red-600";
|
return "bg-red-500 border-red-600";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstLetter(bookName: string): string {
|
|
||||||
const match = bookName.match(/[a-zA-Z]/);
|
|
||||||
return match ? match[0] : bookName[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBoxContent(
|
function getBoxContent(
|
||||||
guess: Guess,
|
guess: Guess,
|
||||||
column: "book" | "firstLetter" | "testament" | "section",
|
column: "book" | "firstLetter" | "testament" | "section",
|
||||||
@@ -49,27 +44,16 @@
|
|||||||
(correctBook?.section === "Pauline Epistles" ||
|
(correctBook?.section === "Pauline Epistles" ||
|
||||||
correctBook?.section === "General Epistles") &&
|
correctBook?.section === "General Epistles") &&
|
||||||
correctBook.name[0] === "1";
|
correctBook.name[0] === "1";
|
||||||
const guessIsEpistlesWithNumber =
|
const guessStartsWithNumber = guess.book.name[0] === "1";
|
||||||
(guess.book.section === "Pauline Epistles" ||
|
|
||||||
guess.book.section === "General Epistles") &&
|
|
||||||
guess.book.name[0] === "1";
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
correctIsEpistlesWithNumber &&
|
correctIsEpistlesWithNumber &&
|
||||||
guessIsEpistlesWithNumber &&
|
guessStartsWithNumber &&
|
||||||
guess.firstLetterMatch
|
guess.firstLetterMatch
|
||||||
) {
|
) {
|
||||||
const words = [
|
return "Yes"; // Special wordplay case
|
||||||
"Exactly",
|
|
||||||
"Right",
|
|
||||||
"Yes",
|
|
||||||
"Naturally",
|
|
||||||
"Of course",
|
|
||||||
"Sure",
|
|
||||||
];
|
|
||||||
return words[Math.floor(Math.random() * words.length)]; // Special wordplay case
|
|
||||||
}
|
}
|
||||||
return getFirstLetter(guess.book.name); // Normal case: show first letter, ignoring numbers
|
return guess.book.name[0]; // Normal case: just show the first letter
|
||||||
case "testament":
|
case "testament":
|
||||||
return (
|
return (
|
||||||
guess.book.testament.charAt(0).toUpperCase() +
|
guess.book.testament.charAt(0).toUpperCase() +
|
||||||
|
|||||||
@@ -1,246 +1,241 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
interface ImposterData {
|
interface ImposterData {
|
||||||
verses: string[];
|
verses: string[];
|
||||||
refs: string[];
|
refs: string[];
|
||||||
imposterIndex: number;
|
imposterIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: ImposterData | null = null;
|
let data: ImposterData | null = null;
|
||||||
let clicked: boolean[] = [];
|
let clicked: boolean[] = [];
|
||||||
let gameOver = false;
|
let gameOver = false;
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
|
|
||||||
async function loadGame() {
|
async function loadGame() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/imposter");
|
const res = await fetch("/api/imposter");
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
}
|
}
|
||||||
data = (await res.json()) as ImposterData;
|
data = (await res.json()) as ImposterData;
|
||||||
clicked = new Array(data.verses.length).fill(false);
|
clicked = new Array(data.verses.length).fill(false);
|
||||||
gameOver = false;
|
gameOver = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : "Unknown error";
|
error = e instanceof Error ? e.message : "Unknown error";
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClick(index: number) {
|
function handleClick(index: number) {
|
||||||
if (gameOver || !data || clicked[index]) return;
|
if (gameOver || !data || clicked[index]) return;
|
||||||
clicked[index] = true;
|
clicked[index] = true;
|
||||||
if (index !== data.imposterIndex) {
|
if (index !== data.imposterIndex) {
|
||||||
clicked[data.imposterIndex] = true;
|
clicked[data.imposterIndex] = true;
|
||||||
}
|
}
|
||||||
gameOver = true;
|
gameOver = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function newGame() {
|
function newGame() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
data = null;
|
data = null;
|
||||||
loadGame();
|
loadGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(loadGame);
|
onMount(loadGame);
|
||||||
|
|
||||||
function formatVerse(verse: string): string {
|
function formatVerse(verse: string): string {
|
||||||
let formatted = verse;
|
let formatted = verse;
|
||||||
|
|
||||||
// Handle unbalanced opening/closing punctuation
|
// Handle unbalanced opening/closing punctuation
|
||||||
const pairs: [string, string][] = [
|
const pairs: [string, string][] = [
|
||||||
["(", ")"],
|
["(", ")"],
|
||||||
["[", "]"],
|
["[", "]"],
|
||||||
["{", "}"],
|
["{", "}"],
|
||||||
['"', '"'],
|
['"', '"'],
|
||||||
["'", "'"],
|
["'", "'"],
|
||||||
["\u201C", "\u201D"], // \u201C
|
["\u201C", "\u201D"], // \u201C
|
||||||
["\u2018", "\u2019"], // \u2018
|
["\u2018", "\u2019"], // \u2018
|
||||||
];
|
];
|
||||||
for (const [open, close] of pairs) {
|
for (const [open, close] of pairs) {
|
||||||
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||||
formatted += "..." + close;
|
formatted += "..." + close;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [open, close] of pairs) {
|
for (const [open, close] of pairs) {
|
||||||
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||||
formatted = open + "..." + formatted;
|
formatted = open + "..." + formatted;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^[a-z]/.test(formatted)) {
|
if (/^[a-z]/.test(formatted)) {
|
||||||
formatted = "..." + formatted;
|
formatted = "..." + formatted;
|
||||||
}
|
}
|
||||||
// Replace trailing punctuation with ellipsis
|
formatted = formatted.replace(/[,:;-—]$/, "...");
|
||||||
// Preserve closing quotes/brackets that may have been added
|
return formatted;
|
||||||
formatted = formatted.replace(
|
}
|
||||||
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
|
|
||||||
"...$1",
|
|
||||||
);
|
|
||||||
return formatted;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="imposter-game">
|
<div class="imposter-game">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="loading">Loading verses...</p>
|
<p class="loading">Loading verses...</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">
|
<div class="error">
|
||||||
<p>Error: {error}</p>
|
<p>Error: {error}</p>
|
||||||
<button on:click={newGame}>Retry</button>
|
<button on:click={newGame}>Retry</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if data}
|
{:else if data}
|
||||||
<!-- <div class="instructions">
|
<!-- <div class="instructions">
|
||||||
<p>Click the verse that doesn't belong (from a different book).</p>
|
<p>Click the verse that doesn't belong (from a different book).</p>
|
||||||
</div> -->
|
</div> -->
|
||||||
<div class="verses">
|
<div class="verses">
|
||||||
{#each data.verses as verse, i}
|
{#each data.verses as verse, i}
|
||||||
<div class="verse-item">
|
<div class="verse-item">
|
||||||
<button
|
<button
|
||||||
class="verse-button"
|
class="verse-button"
|
||||||
class:clicked={clicked[i]}
|
class:clicked={clicked[i]}
|
||||||
class:correct={clicked[i] && i === data.imposterIndex}
|
class:correct={clicked[i] && i === data.imposterIndex}
|
||||||
class:wrong={clicked[i] && i !== data.imposterIndex}
|
class:wrong={clicked[i] && i !== data.imposterIndex}
|
||||||
on:click={() => handleClick(i)}
|
on:click={() => handleClick(i)}
|
||||||
disabled={gameOver}
|
disabled={gameOver}
|
||||||
>
|
>
|
||||||
{formatVerse(verse)}
|
{formatVerse(verse)}
|
||||||
</button>
|
</button>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<div class="ref">{data.refs[i]}</div>
|
<div class="ref">{data.refs[i]}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<div class="result">
|
<div class="result">
|
||||||
<button on:click={newGame}>New Game</button>
|
<button on:click={newGame}>New Game</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.imposter-game {
|
.imposter-game {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading,
|
.loading,
|
||||||
.error {
|
.error {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*.instructions {
|
.instructions {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: #666;
|
color: #666;
|
||||||
}*/
|
}
|
||||||
|
|
||||||
.verses {
|
.verses {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-item {
|
.verse-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-button {
|
.verse-button {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
border: 3px solid #ddd;
|
border: 3px solid #ddd;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-button:hover:not(.clicked):not(:disabled) {
|
.verse-button:hover:not(.clicked):not(:disabled) {
|
||||||
border-color: #007bff;
|
border-color: #007bff;
|
||||||
background: #f8f9ff;
|
background: #f8f9ff;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-button:disabled {
|
.verse-button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-button.clicked {
|
.verse-button.clicked {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.correct {
|
.correct {
|
||||||
background: #d4edda !important;
|
background: #d4edda !important;
|
||||||
border-color: #28a745 !important;
|
border-color: #28a745 !important;
|
||||||
color: #155724;
|
color: #155724;
|
||||||
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
|
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrong {
|
.wrong {
|
||||||
background: #f8d7da !important;
|
background: #f8d7da !important;
|
||||||
border-color: #dc3545 !important;
|
border-color: #dc3545 !important;
|
||||||
color: #721c24;
|
color: #721c24;
|
||||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
|
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ref {
|
.ref {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #555;
|
color: #555;
|
||||||
padding-top: 0.25rem;
|
padding-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-button.correct ~ .ref {
|
.verse-button.correct ~ .ref {
|
||||||
color: #28a745;
|
color: #28a745;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-button.wrong ~ .ref {
|
.verse-button.wrong ~ .ref {
|
||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result {
|
.result {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result button,
|
.result button,
|
||||||
.error button {
|
.error button {
|
||||||
padding: 0.75rem 2rem;
|
padding: 0.75rem 2rem;
|
||||||
background: #007bff;
|
background: #007bff;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result button:hover,
|
.result button:hover,
|
||||||
.error button:hover {
|
.error button:hover {
|
||||||
background: #0056b3;
|
background: #0056b3;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
||||||
|
|
||||||
export const session = sqliteTable('session', {
|
export const session = sqliteTable('session', {
|
||||||
|
|||||||
71
src/lib/utils/stats.ts
Normal file
71
src/lib/utils/stats.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
export interface UserStats {
|
||||||
|
totalSolves: number;
|
||||||
|
avgGuesses: number;
|
||||||
|
gradeDistribution: {
|
||||||
|
'S++': number;
|
||||||
|
'S+': number;
|
||||||
|
'A+': number;
|
||||||
|
'A': number;
|
||||||
|
'B+': number;
|
||||||
|
'B': number;
|
||||||
|
'C+': number;
|
||||||
|
'C': number;
|
||||||
|
};
|
||||||
|
currentStreak: number;
|
||||||
|
bestStreak: number;
|
||||||
|
recentCompletions: Array<{
|
||||||
|
date: string;
|
||||||
|
guessCount: number;
|
||||||
|
grade: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGradeColor(grade: string): string {
|
||||||
|
switch (grade) {
|
||||||
|
case 'S++': return 'text-purple-600 bg-purple-100';
|
||||||
|
case 'S+': return 'text-yellow-600 bg-yellow-100';
|
||||||
|
case 'A+': return 'text-green-600 bg-green-100';
|
||||||
|
case 'A': return 'text-green-500 bg-green-50';
|
||||||
|
case 'B+': return 'text-blue-600 bg-blue-100';
|
||||||
|
case 'B': return 'text-blue-500 bg-blue-50';
|
||||||
|
case 'C+': return 'text-orange-600 bg-orange-100';
|
||||||
|
case 'C': return 'text-red-600 bg-red-100';
|
||||||
|
default: return 'text-gray-600 bg-gray-100';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr + 'T00:00:00');
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStreakMessage(currentStreak: number): string {
|
||||||
|
if (currentStreak === 0) {
|
||||||
|
return "Start your streak today!";
|
||||||
|
} else if (currentStreak === 1) {
|
||||||
|
return "Keep it going!";
|
||||||
|
} else if (currentStreak < 7) {
|
||||||
|
return `${currentStreak} days strong!`;
|
||||||
|
} else if (currentStreak < 30) {
|
||||||
|
return `${currentStreak} day streak - amazing!`;
|
||||||
|
} else {
|
||||||
|
return `${currentStreak} days - you're unstoppable!`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPerformanceMessage(avgGuesses: number): string {
|
||||||
|
if (avgGuesses <= 2) {
|
||||||
|
return "Exceptional performance!";
|
||||||
|
} else if (avgGuesses <= 4) {
|
||||||
|
return "Great performance!";
|
||||||
|
} else if (avgGuesses <= 6) {
|
||||||
|
return "Good performance!";
|
||||||
|
} else if (avgGuesses <= 8) {
|
||||||
|
return "Room for improvement!";
|
||||||
|
} else {
|
||||||
|
return "Keep practicing!";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
import "./layout.css";
|
import "./layout.css";
|
||||||
import favicon from "$lib/assets/favicon.ico";
|
import favicon from "$lib/assets/favicon.ico";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (browser) {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.defer = true;
|
||||||
|
script.src = 'https://umami.snail.city/script.js';
|
||||||
|
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
|
||||||
|
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
|
||||||
|
document.body.appendChild(script);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
<!-- <script
|
||||||
<script
|
|
||||||
defer
|
defer
|
||||||
src="https://umami.snail.city/script.js"
|
src="https://umami.snail.city/script.js"
|
||||||
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
||||||
data-domains="bibdle.com,www.bibdle.com"
|
data-domains="bibdle.com,www.bibdle.com"
|
||||||
></script>
|
></script> -->
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -91,20 +91,13 @@ export const actions: Actions = {
|
|||||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
const guessRank = betterGuesses + 1;
|
const guessRank = betterGuesses + 1;
|
||||||
|
|
||||||
// Count ties: how many have the SAME guessCount (excluding self)
|
|
||||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
|
||||||
|
|
||||||
// Average guesses
|
// Average guesses
|
||||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||||
|
|
||||||
// Percentile: what percentage of people you beat (100 - your rank percentage)
|
|
||||||
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
|
||||||
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -82,11 +82,6 @@
|
|||||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstLetter(bookName: string): string {
|
|
||||||
const match = bookName.match(/[a-zA-Z]/);
|
|
||||||
return match ? match[0] : bookName[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitGuess(bookId: string) {
|
function submitGuess(bookId: string) {
|
||||||
if (guesses.some((g) => g.book.id === bookId)) return;
|
if (guesses.some((g) => g.book.id === bookId)) return;
|
||||||
|
|
||||||
@@ -103,19 +98,15 @@
|
|||||||
// Special case: if correct book is in the Epistles + starts with "1",
|
// Special case: if correct book is in the Epistles + starts with "1",
|
||||||
// any guess starting with "1" counts as first letter match
|
// any guess starting with "1" counts as first letter match
|
||||||
const correctIsEpistlesWithNumber =
|
const correctIsEpistlesWithNumber =
|
||||||
(correctBook.section === "Pauline Epistles" ||
|
correctBook.section === "Pauline Epistles" &&
|
||||||
correctBook.section === "General Epistles") &&
|
|
||||||
correctBook.name[0] === "1";
|
correctBook.name[0] === "1";
|
||||||
const guessIsEpistlesWithNumber =
|
const guessStartsWithNumber = book.name[0] === "1";
|
||||||
(book.section === "Pauline Epistles" ||
|
|
||||||
book.section === "General Epistles") &&
|
|
||||||
book.name[0] === "1";
|
|
||||||
|
|
||||||
const firstLetterMatch =
|
const firstLetterMatch =
|
||||||
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
correctIsEpistlesWithNumber && guessStartsWithNumber
|
||||||
? true
|
? true
|
||||||
: getFirstLetter(book.name).toUpperCase() ===
|
: book.name[0].toUpperCase() ===
|
||||||
getFirstLetter(correctBook.name).toUpperCase();
|
correctBook.name[0].toUpperCase();
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
|
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
|
||||||
@@ -177,6 +168,9 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
anonymousId = getOrCreateAnonymousId();
|
anonymousId = getOrCreateAnonymousId();
|
||||||
|
if ((window as any).umami) {
|
||||||
|
(window as any).umami.identify(anonymousId);
|
||||||
|
}
|
||||||
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
||||||
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
||||||
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||||
@@ -215,19 +209,15 @@
|
|||||||
|
|
||||||
// Apply same first letter logic as in submitGuess
|
// Apply same first letter logic as in submitGuess
|
||||||
const correctIsEpistlesWithNumber =
|
const correctIsEpistlesWithNumber =
|
||||||
(correctBook.section === "Pauline Epistles" ||
|
correctBook.section === "Pauline Epistles" &&
|
||||||
correctBook.section === "General Epistles") &&
|
|
||||||
correctBook.name[0] === "1";
|
correctBook.name[0] === "1";
|
||||||
const guessIsEpistlesWithNumber =
|
const guessStartsWithNumber = book.name[0] === "1";
|
||||||
(book.section === "Pauline Epistles" ||
|
|
||||||
book.section === "General Epistles") &&
|
|
||||||
book.name[0] === "1";
|
|
||||||
|
|
||||||
const firstLetterMatch =
|
const firstLetterMatch =
|
||||||
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
correctIsEpistlesWithNumber && guessStartsWithNumber
|
||||||
? true
|
? true
|
||||||
: getFirstLetter(book.name).toUpperCase() ===
|
: book.name[0].toUpperCase() ===
|
||||||
getFirstLetter(correctBook.name).toUpperCase();
|
correctBook.name[0].toUpperCase();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
book,
|
book,
|
||||||
@@ -437,6 +427,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
<!-- <title>Bibdle — A daily bible game{isDev ? " (dev)" : ""}</title> -->
|
||||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||||
<!-- <meta
|
<!-- <meta
|
||||||
name="description"
|
name="description"
|
||||||
@@ -456,6 +447,14 @@
|
|||||||
<span class="big-text"
|
<span class="big-text"
|
||||||
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
||||||
>
|
>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a
|
||||||
|
href="/stats?anonymousId={anonymousId}"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||||
|
>
|
||||||
|
📊 View Stats
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
// Solve rank: position in time-ordered list
|
// Solve rank: position in time-ordered list
|
||||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||||
|
|
||||||
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
|
||||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
const uniqueBetterGuessCounts = new Set(
|
||||||
const guessRank = betterGuesses + 1;
|
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
|
||||||
|
);
|
||||||
|
const guessRank = uniqueBetterGuessCounts.size + 1;
|
||||||
|
|
||||||
// Count ties: how many have the SAME guessCount (excluding self)
|
// Count ties: how many have the SAME guessCount (excluding self)
|
||||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
@@ -108,9 +110,11 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
// Solve rank: position in time-ordered list
|
// Solve rank: position in time-ordered list
|
||||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||||
|
|
||||||
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
|
||||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
const uniqueBetterGuessCounts = new Set(
|
||||||
const guessRank = betterGuesses + 1;
|
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
|
||||||
|
);
|
||||||
|
const guessRank = uniqueBetterGuessCounts.size + 1;
|
||||||
|
|
||||||
// Count ties: how many have the SAME guessCount (excluding self)
|
// Count ties: how many have the SAME guessCount (excluding self)
|
||||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
import type { RequestHandler } from './$types';
|
|
||||||
import { db } from '$lib/server/db';
|
|
||||||
import { dailyVerses } from '$lib/server/db/schema';
|
|
||||||
import { desc } from 'drizzle-orm';
|
|
||||||
|
|
||||||
// Helper: Escape XML special characters
|
|
||||||
function escapeXml(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Format YYYY-MM-DD to RFC 822 date string
|
|
||||||
function formatRFC822(dateStr: string): string {
|
|
||||||
// Parse date in America/New_York timezone (EST/EDT)
|
|
||||||
// Assuming midnight ET
|
|
||||||
const date = new Date(dateStr + 'T00:00:00-05:00');
|
|
||||||
return date.toUTCString().replace('GMT', 'EST');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Format YYYY-MM-DD to readable date
|
|
||||||
function formatReadableDate(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr + 'T00:00:00');
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
timeZone: 'America/New_York'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
|
|
||||||
function formatVerseText(text: string): string {
|
|
||||||
let formatted = text;
|
|
||||||
|
|
||||||
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
|
|
||||||
const pairs: [string, string][] = [
|
|
||||||
['(', ')'],
|
|
||||||
['[', ']'],
|
|
||||||
['{', '}'],
|
|
||||||
['"', '"'],
|
|
||||||
["'", "'"],
|
|
||||||
['\u201C', '\u201D'], // " "
|
|
||||||
['\u2018', '\u2019'] // ' '
|
|
||||||
];
|
|
||||||
|
|
||||||
// Check if text starts with opening punctuation without closing
|
|
||||||
for (const [open, close] of pairs) {
|
|
||||||
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
|
||||||
formatted += '...' + close;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if text ends with closing punctuation without opening
|
|
||||||
for (const [open, close] of pairs) {
|
|
||||||
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
|
||||||
formatted = open + '...' + formatted;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if text contains unbalanced opening quotes (not at start) without closing
|
|
||||||
for (const [open, close] of pairs) {
|
|
||||||
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
|
|
||||||
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
|
|
||||||
if (openCount > closeCount) {
|
|
||||||
formatted += close;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
|
|
||||||
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
|
|
||||||
|
|
||||||
// Replace trailing punctuation with ellipsis
|
|
||||||
// Preserve closing quotes/brackets that may have been added
|
|
||||||
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
|
|
||||||
|
|
||||||
return formatted;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ request }) => {
|
|
||||||
try {
|
|
||||||
// Query last 30 verses, ordered by date descending
|
|
||||||
const verses = await db
|
|
||||||
.select()
|
|
||||||
.from(dailyVerses)
|
|
||||||
.orderBy(desc(dailyVerses.date))
|
|
||||||
.limit(30);
|
|
||||||
|
|
||||||
// Generate ETag based on latest verse date
|
|
||||||
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
|
|
||||||
|
|
||||||
// Check if client has cached version
|
|
||||||
if (request.headers.get('If-None-Match') === etag) {
|
|
||||||
return new Response(null, { status: 304 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get site URL from environment or use default
|
|
||||||
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
|
|
||||||
|
|
||||||
// Build RSS XML
|
|
||||||
const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
|
|
||||||
|
|
||||||
const items = verses
|
|
||||||
.map(
|
|
||||||
(verse) => `
|
|
||||||
<item>
|
|
||||||
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
|
|
||||||
<description>${escapeXml(formatVerseText(verse.verseText))}</description>
|
|
||||||
<link>${SITE_URL}</link>
|
|
||||||
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
|
|
||||||
<pubDate>${formatRFC822(verse.date)}</pubDate>
|
|
||||||
</item>`
|
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
||||||
<rss version="2.0">
|
|
||||||
<channel>
|
|
||||||
<title>Bibdle</title>
|
|
||||||
<link>${SITE_URL}</link>
|
|
||||||
<description>A daily Bible game</description>
|
|
||||||
<language>en-us</language>
|
|
||||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
||||||
<ttl>720</ttl>${items}
|
|
||||||
</channel>
|
|
||||||
</rss>`;
|
|
||||||
|
|
||||||
return new Response(xml, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
|
||||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
|
||||||
ETag: etag
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('RSS feed generation error:', error);
|
|
||||||
return new Response('Internal Server Error', { status: 500 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
149
src/routes/stats/+page.server.ts
Normal file
149
src/routes/stats/+page.server.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions, type DailyCompletion } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const anonymousId = url.searchParams.get('anonymousId');
|
||||||
|
|
||||||
|
if (!anonymousId) {
|
||||||
|
return {
|
||||||
|
stats: null,
|
||||||
|
error: 'No anonymous ID provided'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all completions for this user
|
||||||
|
const completions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, anonymousId))
|
||||||
|
.orderBy(desc(dailyCompletions.date));
|
||||||
|
|
||||||
|
if (completions.length === 0) {
|
||||||
|
return {
|
||||||
|
stats: {
|
||||||
|
totalSolves: 0,
|
||||||
|
avgGuesses: 0,
|
||||||
|
gradeDistribution: {
|
||||||
|
'S++': 0,
|
||||||
|
'S+': 0,
|
||||||
|
'A+': 0,
|
||||||
|
'A': 0,
|
||||||
|
'B+': 0,
|
||||||
|
'B': 0,
|
||||||
|
'C+': 0,
|
||||||
|
'C': 0
|
||||||
|
},
|
||||||
|
currentStreak: 0,
|
||||||
|
bestStreak: 0,
|
||||||
|
recentCompletions: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate basic stats
|
||||||
|
const totalSolves = completions.length;
|
||||||
|
const totalGuesses = completions.reduce((sum: number, c: DailyCompletion) => sum + c.guessCount, 0);
|
||||||
|
const avgGuesses = Math.round((totalGuesses / totalSolves) * 100) / 100;
|
||||||
|
|
||||||
|
// Calculate grade distribution
|
||||||
|
const gradeDistribution = {
|
||||||
|
'S++': 0, // This will be calculated differently since we don't store chapter correctness
|
||||||
|
'S+': completions.filter((c: DailyCompletion) => c.guessCount === 1).length,
|
||||||
|
'A+': completions.filter((c: DailyCompletion) => c.guessCount === 2).length,
|
||||||
|
'A': completions.filter((c: DailyCompletion) => c.guessCount === 3).length,
|
||||||
|
'B+': completions.filter((c: DailyCompletion) => c.guessCount >= 4 && c.guessCount <= 6).length,
|
||||||
|
'B': completions.filter((c: DailyCompletion) => c.guessCount >= 7 && c.guessCount <= 10).length,
|
||||||
|
'C+': completions.filter((c: DailyCompletion) => c.guessCount >= 11 && c.guessCount <= 15).length,
|
||||||
|
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate streaks
|
||||||
|
const sortedDates = completions
|
||||||
|
.map((c: DailyCompletion) => c.date)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
let currentStreak = 0;
|
||||||
|
let bestStreak = 0;
|
||||||
|
let tempStreak = 1;
|
||||||
|
|
||||||
|
if (sortedDates.length > 0) {
|
||||||
|
// Check if current streak is active (includes today or yesterday)
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||||
|
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||||
|
|
||||||
|
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
|
||||||
|
currentStreak = 1;
|
||||||
|
|
||||||
|
// Count backwards from the most recent date
|
||||||
|
for (let i = sortedDates.length - 2; i >= 0; i--) {
|
||||||
|
const currentDate = new Date(sortedDates[i + 1]);
|
||||||
|
const prevDate = new Date(sortedDates[i]);
|
||||||
|
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (daysDiff === 1) {
|
||||||
|
currentStreak++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate best streak
|
||||||
|
bestStreak = 1;
|
||||||
|
for (let i = 1; i < sortedDates.length; i++) {
|
||||||
|
const currentDate = new Date(sortedDates[i]);
|
||||||
|
const prevDate = new Date(sortedDates[i - 1]);
|
||||||
|
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (daysDiff === 1) {
|
||||||
|
tempStreak++;
|
||||||
|
} else {
|
||||||
|
bestStreak = Math.max(bestStreak, tempStreak);
|
||||||
|
tempStreak = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bestStreak = Math.max(bestStreak, tempStreak);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent completions (last 7 days)
|
||||||
|
const recentCompletions = completions
|
||||||
|
.slice(0, 7)
|
||||||
|
.map((c: DailyCompletion) => ({
|
||||||
|
date: c.date,
|
||||||
|
guessCount: c.guessCount,
|
||||||
|
grade: getGradeFromGuesses(c.guessCount)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: {
|
||||||
|
totalSolves,
|
||||||
|
avgGuesses,
|
||||||
|
gradeDistribution,
|
||||||
|
currentStreak,
|
||||||
|
bestStreak,
|
||||||
|
recentCompletions
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user stats:', error);
|
||||||
|
return {
|
||||||
|
stats: null,
|
||||||
|
error: 'Failed to fetch stats'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getGradeFromGuesses(guessCount: number): string {
|
||||||
|
if (guessCount === 1) return "S+";
|
||||||
|
if (guessCount === 2) return "A+";
|
||||||
|
if (guessCount === 3) return "A";
|
||||||
|
if (guessCount >= 4 && guessCount <= 6) return "B+";
|
||||||
|
if (guessCount >= 7 && guessCount <= 10) return "B";
|
||||||
|
if (guessCount >= 11 && guessCount <= 15) return "C+";
|
||||||
|
return "C";
|
||||||
|
}
|
||||||
206
src/routes/stats/+page.svelte
Normal file
206
src/routes/stats/+page.svelte
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import {
|
||||||
|
getGradeColor,
|
||||||
|
formatDate,
|
||||||
|
getStreakMessage,
|
||||||
|
getPerformanceMessage,
|
||||||
|
type UserStats
|
||||||
|
} from "$lib/utils/stats";
|
||||||
|
|
||||||
|
interface PageData {
|
||||||
|
stats: UserStats | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const anonymousId = getOrCreateAnonymousId();
|
||||||
|
if (!anonymousId) {
|
||||||
|
goto("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no anonymousId in URL, redirect with it
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (!url.searchParams.get('anonymousId')) {
|
||||||
|
url.searchParams.set('anonymousId', anonymousId);
|
||||||
|
goto(url.pathname + url.search);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getGradePercentage(count: number, total: number): number {
|
||||||
|
return total > 0 ? Math.round((count / total) * 100) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inspect(data);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Stats | Bibdle</title>
|
||||||
|
<meta name="description" content="View your Bibdle game statistics and performance" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-amber-50 to-orange-100 p-4">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-800 mb-2">Your Stats</h1>
|
||||||
|
<p class="text-gray-600">Track your Bibdle performance over time</p>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Game
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="inline-block w-8 h-8 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
<p class="mt-4 text-gray-600">Loading your stats...</p>
|
||||||
|
</div>
|
||||||
|
{:else if data.error}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="bg-red-100 border border-red-300 rounded-lg p-6 max-w-md mx-auto">
|
||||||
|
<p class="text-red-700">{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.stats}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="bg-yellow-100 border border-yellow-300 rounded-lg p-6 max-w-md mx-auto">
|
||||||
|
<p class="text-yellow-700">No stats available.</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="mt-4 inline-block px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
|
||||||
|
>
|
||||||
|
Start Playing
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@const stats = data.stats}
|
||||||
|
|
||||||
|
<!-- Overview Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<!-- Total Solves -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold text-amber-600 mb-2">{stats.totalSolves}</div>
|
||||||
|
<div class="text-gray-600">Total Solves</div>
|
||||||
|
{#if stats.totalSolves > 0}
|
||||||
|
<div class="text-sm text-gray-500 mt-1">
|
||||||
|
{getPerformanceMessage(stats.avgGuesses)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Average Guesses -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold text-blue-600 mb-2">{stats.avgGuesses}</div>
|
||||||
|
<div class="text-gray-600">Avg. Guesses</div>
|
||||||
|
<div class="text-sm text-gray-500 mt-1">per solve</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Streak -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
|
||||||
|
<div class="text-gray-600">Current Streak</div>
|
||||||
|
<div class="text-sm text-gray-500 mt-1">
|
||||||
|
{getStreakMessage(stats.currentStreak)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grade Distribution -->
|
||||||
|
{#if stats.totalSolves > 0}
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-4">Grade Distribution</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{#each Object.entries(stats.gradeDistribution) as [grade, count]}
|
||||||
|
{@const percentage = getGradePercentage(count, stats.totalSolves)}
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mb-2">
|
||||||
|
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold {getGradeColor(grade)}">
|
||||||
|
{grade}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-800">{count}</div>
|
||||||
|
<div class="text-sm text-gray-500">{percentage}%</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Streak Info -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-4">Streak Information</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
|
||||||
|
<div class="text-gray-600">Current Streak</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold text-purple-600 mb-2">{stats.bestStreak}</div>
|
||||||
|
<div class="text-gray-600">Best Streak</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Performance -->
|
||||||
|
{#if stats.recentCompletions.length > 0}
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-4">Recent Performance</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each stats.recentCompletions as completion}
|
||||||
|
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">{formatDate(completion.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-gray-600">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span>
|
||||||
|
<span class="px-2 py-1 rounded text-sm font-semibold {getGradeColor(completion.grade)}">
|
||||||
|
{completion.grade}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
40
todo.md
40
todo.md
@@ -1,17 +1,22 @@
|
|||||||
# in progress
|
# in progress
|
||||||
|
|
||||||
- Show new/old testament after 3 guesses and section after 7 guesses
|
|
||||||
- Add sections for "first letter", "Canonical/deutero", etc...
|
|
||||||
- Make the UI more "wordle-like" ()
|
|
||||||
- How do you balance rewarding knowledge vs incentivising learning?
|
|
||||||
|
|
||||||
# todo
|
# todo
|
||||||
|
|
||||||
|
- login
|
||||||
|
- login route
|
||||||
|
|
||||||
- impossible mode (1904 greek bible) three guesses only.
|
- impossible mode (1904 greek bible) three guesses only.
|
||||||
|
|
||||||
- share both classic and impossible mode with both buttons
|
- share both classic and impossible mode with both buttons
|
||||||
|
|
||||||
- add imposter mode
|
- improve imposter mode
|
||||||
|
|
||||||
|
- Show new/old testament after 3 guesses and section after 7 guesses
|
||||||
|
- Add sections for "first letter", "Canonical/deutero", etc...
|
||||||
|
|
||||||
|
- How do you balance rewarding knowledge vs incentivising learning?
|
||||||
|
|
||||||
|
|
||||||
- instructions
|
- instructions
|
||||||
|
|
||||||
@@ -54,6 +59,29 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
|
|
||||||
# done
|
# done
|
||||||
|
|
||||||
|
## february 2nd
|
||||||
|
|
||||||
|
- created rss feed
|
||||||
|
- fixed "first letter" clue edge cases
|
||||||
|
- updated ranking formula
|
||||||
|
|
||||||
|
## january 28th
|
||||||
|
|
||||||
|
- add percentile stats, update chapter guess UI
|
||||||
|
- fixed middle statline (removed meaningless %)
|
||||||
|
- added instructions
|
||||||
|
- added email button
|
||||||
|
- added test buttons for 3.0 UI/UX
|
||||||
|
- package upgrades
|
||||||
|
|
||||||
|
## january 26th
|
||||||
|
|
||||||
|
- Make the UI more "wordle-like"
|
||||||
|
- added deployment script (./deploy.sh)
|
||||||
|
- added bluesky button
|
||||||
|
- added "first letter" column
|
||||||
|
- added imposter mode, v0.1 (mom likes it) but needs work
|
||||||
|
|
||||||
## january 5th
|
## january 5th
|
||||||
|
|
||||||
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
|
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...
|
||||||
@@ -64,6 +92,8 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
|
|||||||
- For bonus points: guess the verse/psalm number
|
- For bonus points: guess the verse/psalm number
|
||||||
- major UI styling revamp
|
- major UI styling revamp
|
||||||
|
|
||||||
|
-- 2026 --
|
||||||
|
|
||||||
## december 30th
|
## december 30th
|
||||||
|
|
||||||
- merged the embeddings/similarity route into production
|
- merged the embeddings/similarity route into production
|
||||||
|
|||||||
Reference in New Issue
Block a user