mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Compare commits
88 Commits
stats
...
f98ab24d2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f98ab24d2e | ||
|
|
c5b333bbb3 | ||
|
|
e842923d81 | ||
|
|
51bfb53a39 | ||
|
|
45d33b6bad | ||
|
|
3eb3a968dc | ||
|
|
67d9757f98 | ||
|
|
b6b41b6ba9 | ||
|
|
bdc08bc58e | ||
|
|
83cfcc66c0 | ||
|
|
e878dea235 | ||
|
|
252edc3a6d | ||
|
|
75b13280ef | ||
|
|
7007df2966 | ||
|
|
61673a646d | ||
|
|
1eb8eb2f04 | ||
|
|
ae4482a551 | ||
|
|
884bbe65c7 | ||
|
|
3de55ba216 | ||
|
|
6e74fffb65 | ||
|
|
1ae2b2ac6c | ||
|
|
a188be167b | ||
|
|
e550965086 | ||
|
|
03429b17cc | ||
|
|
3ee7331510 | ||
|
|
592fa917cd | ||
|
|
ad1774e6b0 | ||
|
|
e1a665ba63 | ||
|
|
f3c9feaf97 | ||
|
|
a5cf248e29 | ||
|
|
f9f3f3de12 | ||
|
|
abab886d1a | ||
|
|
acc82af7cd | ||
|
|
fc674d6008 | ||
|
|
087a476df8 | ||
|
|
ba45cbdc37 | ||
|
|
1de436534c | ||
|
|
3bcd7ea266 | ||
|
|
7ecc84ffbc | ||
|
|
3d78353a90 | ||
|
|
bd36f29419 | ||
|
|
3036264d44 | ||
|
|
6554ef8f41 | ||
|
|
c3307b3920 | ||
|
|
19646c72ca | ||
|
|
e592751a1c | ||
|
|
77cc83841d | ||
|
|
e8b2d2e35e | ||
|
|
c50cccd3d3 | ||
|
|
638a789a0f | ||
|
|
e815e15ce5 | ||
|
|
e6081c28f1 | ||
|
|
2de4e9e2a7 | ||
|
|
ea7a848125 | ||
|
|
1719e0bbbf | ||
|
|
885adad756 | ||
|
|
1b96919acd | ||
|
|
8ef2a41a69 | ||
|
|
ac6ec051d4 | ||
|
|
a12c7d011a | ||
|
|
77ffd6fbee | ||
|
|
f6652e59a7 | ||
|
|
290fb06fe9 | ||
|
|
df8a9e62bb | ||
|
|
730b65201a | ||
|
|
78440cfbc3 | ||
|
|
482ee0a83a | ||
|
|
342bd323a1 | ||
|
|
95725ab4fe | ||
|
|
06ff0820ce | ||
|
|
3cf95152e6 | ||
|
|
c04899d419 | ||
|
|
6161ef75a1 | ||
|
|
9d7399769a | ||
|
|
b1591229ba | ||
|
|
96024d5048 | ||
|
|
86f81cf9dd | ||
|
|
24a5fdbb80 | ||
|
|
dfe1c40a8a | ||
|
|
dfe784b744 | ||
|
|
6bced13543 | ||
|
|
9406498cc9 | ||
|
|
3947e8adb0 | ||
|
|
244113671e | ||
|
|
5b9b2f76f4 | ||
|
|
f7ec0742e1 | ||
|
|
d797b980ea | ||
|
|
ff228fb547 |
@@ -5,7 +5,6 @@
|
||||
"Read(./secrets/**)",
|
||||
"Read(./config/credentials.json)",
|
||||
"Read(./build)",
|
||||
"Read(./**.xml)",
|
||||
"Read(./embeddings**)"
|
||||
]
|
||||
}
|
||||
|
||||
20
.env.example
20
.env.example
@@ -1,5 +1,25 @@
|
||||
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
|
||||
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
|
||||
APPLE_ID=com.yourcompany.yourapp.client
|
||||
APPLE_TEAM_ID=your-team-id
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -28,4 +28,8 @@ vite.config.ts.timestamp-*
|
||||
llms-*
|
||||
|
||||
embeddings*
|
||||
*.xml
|
||||
*bible.xml
|
||||
engwebu_usfx.xml
|
||||
|
||||
deploy.log
|
||||
bibdle.socket
|
||||
|
||||
128
CLAUDE.md
128
CLAUDE.md
@@ -27,19 +27,14 @@ After calling the list-sections tool, you MUST analyze the returned documentatio
|
||||
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
|
||||
|
||||
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
|
||||
- **Styling**: Tailwind CSS 4
|
||||
- **Database**: SQLite with Drizzle ORM
|
||||
- **Auth**: Session-based authentication using @oslojs/crypto (SHA-256 hashed tokens)
|
||||
- **Auth**: Session-based authentication using Bun's built-in cryptographically secure functions
|
||||
- **Deployment**: Node.js adapter for production builds
|
||||
- **External API**: bible-api.com for fetching random verses
|
||||
- **ML**: `@xenova/transformers` for verse embeddings (initialized in server hook) (currently disabled, was a test for a cancelled project)
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -51,6 +46,11 @@ bun run dev
|
||||
bun run check
|
||||
bun run check:watch
|
||||
|
||||
# Run tests
|
||||
bun test
|
||||
bun test --watch
|
||||
bun test tests/timezone-handling.test.ts # Run a single test file
|
||||
|
||||
# Build for production
|
||||
bun run build
|
||||
|
||||
@@ -58,92 +58,108 @@ bun run build
|
||||
bun run preview
|
||||
|
||||
# Database operations
|
||||
bun run db:push # Push schema changes to database
|
||||
bun run db:push # Push schema changes directly (avoid in prod)
|
||||
bun run db:generate # Generate migrations
|
||||
bun run db:migrate # Run migrations
|
||||
bun run db:studio # Open Drizzle Studio GUI
|
||||
```
|
||||
|
||||
## Critical: Date/Time Handling
|
||||
|
||||
**Bibdle is played by users across many timezones worldwide. The verse shown to a player must always be the verse for the calendar date at *their* location — not the server's timezone, not UTC. A user in Tokyo on Wednesday must see Wednesday's verse, even if the server (or a user in New York) is still on Tuesday.**
|
||||
|
||||
**NEVER use server time or UTC time for user-facing date calculations.**
|
||||
|
||||
- Get today's date client-side: `new Date().toLocaleDateString("en-CA")` → `YYYY-MM-DD`
|
||||
- Pass the date to the server as a query param or POST body (`localDate`)
|
||||
- Server-side date arithmetic must use UTC methods on the client-provided date string: `new Date(dateStr + 'T00:00:00Z')` + `setUTCDate`/`getUTCDate`
|
||||
- `src/routes/+page.ts` has `ssr = false` so the load runs client-side with the true local date
|
||||
- Never set the user-facing URL to include their date as a parameter. It should always be passed to an API route behind the scenes if needed.
|
||||
|
||||
### Streak Calculation
|
||||
|
||||
A streak counts consecutive calendar days (in the user's local timezone) on which the user completed the puzzle. The rules:
|
||||
|
||||
- The client passes its local date (`localDate`) to the streak API. The server never uses its own clock.
|
||||
- A streak is **active** if the user has completed today's puzzle *or* yesterday's puzzle (they still have time to play today).
|
||||
- Walk backwards from `localDate` through the `dailyCompletions` records, counting each day that has a completion. Stop as soon as a day is missing.
|
||||
- A streak of 1 (completed only today or only yesterday, with no prior consecutive days) is **not displayed** — the minimum shown streak is 2.
|
||||
- "Yesterday" and all date arithmetic on the server must use UTC methods on the client-provided date string to avoid timezone drift: `new Date(localDate + 'T00:00:00Z')`, then `setUTCDate`/`getUTCDate`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Database Schema (`src/lib/server/db/schema.ts`)
|
||||
|
||||
- **user**: User accounts with id and age
|
||||
- **session**: Auth sessions linked to users with expiration timestamps
|
||||
- **user**: `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `isPrivate`
|
||||
- **session**: `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
|
||||
- **daily_verses**: Cached daily verses with book ID, verse text, reference, and date
|
||||
- **dailyCompletions**: Game results per user/date with guess count, grade, book; unique on `(userId, date)`
|
||||
|
||||
Sessions expire after 30 days and are automatically renewed when less than 15 days remain.
|
||||
Sessions expire after 30 days and auto-renew when < 15 days remain.
|
||||
|
||||
### Bible Data (`src/lib/types/bible.ts`)
|
||||
|
||||
The `bibleBooks` array contains all 66 Bible books with metadata:
|
||||
- Testament (old/new)
|
||||
- Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
|
||||
- Testament (old/new), Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
|
||||
- Order (1-66, used for adjacency detection)
|
||||
- Popularity (2-10, affects grading - higher is more popular)
|
||||
|
||||
### Daily Verse System (`src/routes/+page.server.ts`)
|
||||
|
||||
The `getTodayVerse()` function:
|
||||
1. Checks database for existing verse for today's date
|
||||
2. If none exists, fetches from bible-api.com (random verse + 2 consecutive verses)
|
||||
3. Caches in database with UTC date key
|
||||
4. Returns verse with book metadata for the game
|
||||
`getTodayVerse()` checks the database for today's date, fetches a verse if missing, caches permanently, and returns verse with book metadata.
|
||||
|
||||
### Game Logic (`src/routes/+page.svelte`)
|
||||
|
||||
**State Management:**
|
||||
- `guesses` array stores game state in localStorage keyed by date
|
||||
- `guesses` array stored in localStorage keyed by date: `bibdle-guesses-${date}`
|
||||
- Each guess tracks: book, testamentMatch, sectionMatch, adjacent
|
||||
- `isWon` is derived from whether any guess matches the correct book
|
||||
- `isWon` derived from whether any guess matches the correct book
|
||||
|
||||
**Grading System:**
|
||||
```javascript
|
||||
// Grade formula combines performance + difficulty
|
||||
performanceScore = max(0, 10 - numGuesses)
|
||||
difficulty = 14 - popularity
|
||||
totalScore = performanceScore + difficulty * 0.8
|
||||
|
||||
// S: 14+, A: 11+, B: 8+, C: 5+, C-: <5
|
||||
```
|
||||
|
||||
**Hint System:**
|
||||
- ✅ Green checkmark: Exact match
|
||||
- 🟩 Green square: Section matches
|
||||
- 🟧 Orange square: Testament matches (shared results)
|
||||
- ‼️ Double exclamation: Adjacent book in Bible order
|
||||
- 🟥 Red square: No match
|
||||
**Hint System, for share grid:**
|
||||
- ✅ Exact match | 🟩 Section match | 🟧 Testament match | ‼️ Adjacent book | 🟥 No match
|
||||
|
||||
### Authentication System (`src/lib/server/auth.ts`)
|
||||
|
||||
- Token-based sessions with SHA-256 hashing
|
||||
- Cookies store session tokens, validated on each request
|
||||
- Hook in `src/hooks.server.ts` populates `event.locals.user` and `event.locals.session`
|
||||
- Note: Currently the schema includes user table but auth UI is not yet implemented
|
||||
- Token generation: base64-encoded random bytes; stored as SHA-256 hash in DB
|
||||
- Cookie name: `auth-session`
|
||||
- Anonymous users: identified by a client-generated ID; stats migrate on sign-up via `migrateAnonymousStats()`
|
||||
- Apple Sign-In supported via `appleId` field
|
||||
|
||||
### Stats & Streak (`src/routes/stats/`)
|
||||
|
||||
- Stats page requires auth; returns `requiresAuth: true` if unauthenticated
|
||||
- Streak calculated client-side by calling `GET /api/streak?userId=X&localDate=Y`
|
||||
- Streak walk-back: counts consecutive days backwards from `localDate` through completed dates
|
||||
- Minimum displayed streak is 2 (single-day streaks suppressed)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `POST /api/daily-verse` — Fetch verse for a specific date
|
||||
- `POST /api/submit-completion` — Submit game result with stats
|
||||
- `GET /api/streak?userId=X&localDate=Y` — Current streak for user
|
||||
- `GET /api/streak-percentile` — Streak percentile ranking
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/routes/+page.svelte` - Main game UI and client-side logic
|
||||
- `src/routes/+page.server.ts` - Server load function, fetches/caches daily verse
|
||||
- `src/lib/server/bible-api.ts` - External API integration for verse fetching
|
||||
- `src/lib/server/bible.ts` - Bible book utility functions
|
||||
- `src/lib/types/bible.ts` - Bible books data and TypeScript types
|
||||
- `src/lib/server/db/schema.ts` - Drizzle ORM schema definitions
|
||||
- `src/hooks.server.ts` - SvelteKit server hook for session validation
|
||||
- `src/routes/+page.svelte` — Main game UI and client-side logic
|
||||
- `src/routes/+page.server.ts` / `+page.ts` — Server load (verse) + client load (`ssr: false`)
|
||||
- `src/routes/stats/+page.svelte` / `+page.server.ts` — Stats UI and server calculations
|
||||
- `src/lib/server/auth.ts` — Session management, password hashing, anonymous migration
|
||||
- `src/lib/server/bible-api.ts` — Random verse fetching from local XML Bible
|
||||
- `src/lib/server/bible.ts` — Bible book utility functions
|
||||
- `src/lib/types/bible.ts` — Bible books data and TypeScript types
|
||||
- `src/lib/server/db/schema.ts` — Drizzle ORM schema
|
||||
- `src/hooks.server.ts` — Session validation hook; initializes ML embeddings
|
||||
- `tests/` — Bun test suites (timezone, game, bible, stats, share, auth migration)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required in `.env`:
|
||||
- `DATABASE_URL` - Path to SQLite database file (e.g., `./local.db`)
|
||||
- `DATABASE_URL` — Path to SQLite database file (e.g., `./local.db`)
|
||||
|
||||
## Deployment
|
||||
|
||||
The project uses `@sveltejs/adapter-node` for deployment. The build output is a Node.js server that can be run with systemd or similar process managers. See `bibdle.service` and `bibdle.socket` for systemd configuration.
|
||||
Uses `@sveltejs/adapter-node`. See `bibdle.service` systemd configuration.
|
||||
|
||||
## Important Notes
|
||||
## A Note
|
||||
|
||||
- Uses Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`) - not stores or reactive declarations
|
||||
- The schema includes authentication tables but the login/signup UI is not yet implemented
|
||||
- Daily verses are cached permanently in the database to ensure consistency
|
||||
- LocalStorage persists guesses per day using the key pattern `bibdle-guesses-${date}`
|
||||
- The game validates book IDs from the API against the hardcoded `bibleBooks` array
|
||||
The main developer of this project is still learning a lot about developing full-stack applications. If they ask you to do something, make sure they understand how it will be implemented before proceeding.
|
||||
|
||||
@@ -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="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="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="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>
|
||||
@@ -33616,4 +33616,4 @@
|
||||
</chapter>
|
||||
</book>
|
||||
</testament>
|
||||
</bible>
|
||||
</bible>
|
||||
|
||||
12
README.md
12
README.md
@@ -8,10 +8,10 @@ If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
bunx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
bunx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
@@ -19,10 +19,10 @@ npx sv create my-app
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
bun run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
bun run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
@@ -30,9 +30,9 @@ npm run dev -- --open
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
bun run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
You can preview the production build with `bun run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
[Unit]
|
||||
Description=Bibdle SvelteKit App
|
||||
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
||||
Requires=bibdle.socket
|
||||
After=network-online.target bibdle.socket
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Environment=NODE_ENV=production
|
||||
Environment=ORIGIN=https://bibdle.com
|
||||
Environment=DATABASE_URL=local.db
|
||||
Environment=IDLE_TIMEOUT=60
|
||||
WorkingDirectory=/home/george/projects/bibdle
|
||||
ExecStart=/home/george/.nvm/versions/node/v24.12.0/bin/node build/index.js
|
||||
Environment=DATABASE_URL=prod.db
|
||||
Environment=IDLE_TIMEOUT=300
|
||||
Environment=PORT=5173
|
||||
WorkingDirectory=/home/xenia/projects/bibdle
|
||||
#ExecStart=/home/xenia/.nvm/versions/node/v24.13.0/bin/node build/index.js
|
||||
ExecStart=/home/xenia/.bun/bin/bun --bun build/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
[Socket]
|
||||
ListenStream=5173
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
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 |
22
bun.lock
22
bun.lock
@@ -6,23 +6,21 @@
|
||||
"name": "bibdle",
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"marked": "^17.0.4",
|
||||
"xml2js": "^0.6.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@sveltejs/adapter-node": "^5.5.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/node": "^22.19.7",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"svelte": "^5.48.3",
|
||||
"svelte": "^5.48.5",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
@@ -101,14 +99,6 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
|
||||
|
||||
"@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
@@ -229,6 +219,8 @@
|
||||
|
||||
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
@@ -273,6 +265,8 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||
@@ -401,6 +395,8 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["marked@17.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
18
deploy.sh
18
deploy.sh
@@ -3,16 +3,24 @@ set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BUN=$(which bun)
|
||||
|
||||
echo "Pulling latest changes..."
|
||||
git pull
|
||||
PULL_OUTPUT=$(git pull)
|
||||
echo "$PULL_OUTPUT"
|
||||
if [ "$PULL_OUTPUT" = "Already up to date." ]; then
|
||||
echo "Nothing to deploy."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Installing dependencies..."
|
||||
bun i
|
||||
$BUN i
|
||||
|
||||
echo "Building..."
|
||||
bun run build
|
||||
$BUN --bun run build
|
||||
|
||||
echo "Restarting service..."
|
||||
sudo systemctl restart bibdle
|
||||
SERVICE_NAME="$(basename "$(pwd)").service"
|
||||
echo "Restarting service ($SERVICE_NAME)..."
|
||||
sudo systemctl restart "$SERVICE_NAME"
|
||||
|
||||
echo "Done!"
|
||||
|
||||
11
drizzle.test.config.ts
Normal file
11
drizzle.test.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
if (!process.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set');
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/lib/server/db/schema.ts',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: { url: process.env.TEST_DATABASE_URL },
|
||||
verbose: true,
|
||||
strict: true
|
||||
});
|
||||
10
drizzle/0002_outstanding_hiroim.sql
Normal file
10
drizzle/0002_outstanding_hiroim.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE `user` ADD `first_name` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `last_name` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `email` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `password_hash` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `apple_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `is_private` integer DEFAULT false;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_apple_id_unique` ON `user` (`apple_id`);--> statement-breakpoint
|
||||
ALTER TABLE `user` DROP COLUMN `age`;--> statement-breakpoint
|
||||
CREATE INDEX `anonymous_id_date_idx` ON `daily_completions` (`anonymous_id`,`date`);
|
||||
275
drizzle/meta/0002_snapshot.json
Normal file
275
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
|
||||
"prevId": "569c1d8d-7308-47c2-ba44-85c4917b789d",
|
||||
"tables": {
|
||||
"daily_completions": {
|
||||
"name": "daily_completions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anonymous_id": {
|
||||
"name": "anonymous_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"guess_count": {
|
||||
"name": "guess_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"anonymous_id_date_idx": {
|
||||
"name": "anonymous_id_date_idx",
|
||||
"columns": [
|
||||
"anonymous_id",
|
||||
"date"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"date_idx": {
|
||||
"name": "date_idx",
|
||||
"columns": [
|
||||
"date"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"date_guess_idx": {
|
||||
"name": "date_guess_idx",
|
||||
"columns": [
|
||||
"date",
|
||||
"guess_count"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"daily_completions_anonymous_id_date_unique": {
|
||||
"name": "daily_completions_anonymous_id_date_unique",
|
||||
"columns": [
|
||||
"anonymous_id",
|
||||
"date"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"daily_verses": {
|
||||
"name": "daily_verses",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"book_id": {
|
||||
"name": "book_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verse_text": {
|
||||
"name": "verse_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reference": {
|
||||
"name": "reference",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"daily_verses_date_unique": {
|
||||
"name": "daily_verses_date_unique",
|
||||
"columns": [
|
||||
"date"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"first_name": {
|
||||
"name": "first_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_name": {
|
||||
"name": "last_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"apple_id": {
|
||||
"name": "apple_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_private": {
|
||||
"name": "is_private",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_apple_id_unique": {
|
||||
"name": "user_apple_id_unique",
|
||||
"columns": [
|
||||
"apple_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,20 @@
|
||||
"when": 1765934144883,
|
||||
"tag": "0000_clumsy_impossible_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1770266674489,
|
||||
"tag": "0001_loose_kree",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1770961427714,
|
||||
"tag": "0002_outstanding_hiroim",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
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"
|
||||
10
package.json
10
package.json
@@ -4,26 +4,26 @@
|
||||
"version": "3.0.0alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev": "bun --bun vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "bun test",
|
||||
"test:watch": "bun test --watch",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@sveltejs/adapter-node": "^5.5.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/node": "^22.19.7",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
@@ -35,8 +35,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"marked": "^17.0.4",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
31
scripts/analyze_top_users.sh
Executable file
31
scripts/analyze_top_users.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/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
|
||||
20
scripts/clear-today-verse.sh
Executable file
20
scripts/clear-today-verse.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/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
|
||||
34
scripts/daily_completions_report.sh
Executable file
34
scripts/daily_completions_report.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/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';"
|
||||
41
scripts/dedup-completions.ts
Normal file
41
scripts/dedup-completions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import path from 'path';
|
||||
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (!dbUrl) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice(5) : dbUrl;
|
||||
const db = new Database(path.resolve(dbPath));
|
||||
|
||||
const duplicates = db.query(`
|
||||
SELECT anonymous_id, date, COUNT(*) as count
|
||||
FROM daily_completions
|
||||
GROUP BY anonymous_id, date
|
||||
HAVING count > 1
|
||||
`).all() as { anonymous_id: string; date: string; count: number }[];
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
console.log('No duplicates found.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${duplicates.length} duplicate group(s):`);
|
||||
|
||||
const deleteStmt = db.query(`
|
||||
DELETE FROM daily_completions
|
||||
WHERE anonymous_id = $anonymous_id AND date = $date
|
||||
AND id NOT IN (
|
||||
SELECT id FROM daily_completions
|
||||
WHERE anonymous_id = $anonymous_id AND date = $date
|
||||
ORDER BY completed_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
`);
|
||||
|
||||
for (const { anonymous_id, date, count } of duplicates) {
|
||||
deleteStmt.run({ $anonymous_id: anonymous_id, $date: date });
|
||||
console.log(` ${anonymous_id} / ${date}: kept earliest, deleted ${count - 1} row(s) (had ${count})`);
|
||||
}
|
||||
|
||||
console.log('Done.');
|
||||
db.close();
|
||||
75
scripts/deduplicate-completions.ts
Normal file
75
scripts/deduplicate-completions.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import Database from 'bun:sqlite';
|
||||
|
||||
// Database path - adjust if your database is located elsewhere
|
||||
const dbPath = Bun.env.DATABASE_URL || './local.db';
|
||||
console.log(`Connecting to database: ${dbPath}`);
|
||||
const db = new Database(dbPath);
|
||||
|
||||
interface DuplicateGroup {
|
||||
anonymous_id: string;
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface Completion {
|
||||
id: string;
|
||||
anonymous_id: string;
|
||||
date: string;
|
||||
guess_count: number;
|
||||
completed_at: number;
|
||||
}
|
||||
|
||||
console.log('Finding duplicates...\n');
|
||||
|
||||
// Find all (anonymous_id, date) pairs with duplicates
|
||||
const duplicatesQuery = db.query<DuplicateGroup, []>(`
|
||||
SELECT anonymous_id, date, COUNT(*) as count
|
||||
FROM daily_completions
|
||||
GROUP BY anonymous_id, date
|
||||
HAVING count > 1
|
||||
`);
|
||||
|
||||
const duplicates = duplicatesQuery.all();
|
||||
console.log(`Found ${duplicates.length} duplicate groups\n`);
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
console.log('No duplicates to clean up!');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let totalDeleted = 0;
|
||||
|
||||
// Process each duplicate group
|
||||
for (const dup of duplicates) {
|
||||
// Get all completions for this (anonymous_id, date) pair
|
||||
const completionsQuery = db.query<Completion, [string, string]>(`
|
||||
SELECT id, anonymous_id, date, guess_count, completed_at
|
||||
FROM daily_completions
|
||||
WHERE anonymous_id = ? AND date = ?
|
||||
ORDER BY completed_at ASC
|
||||
`);
|
||||
|
||||
const completions = completionsQuery.all(dup.anonymous_id, dup.date);
|
||||
console.log(` ${dup.anonymous_id} on ${dup.date}: ${completions.length} entries`);
|
||||
|
||||
// Keep the first (earliest completion), delete the rest
|
||||
const toKeep = completions[0];
|
||||
const toDelete = completions.slice(1);
|
||||
|
||||
console.log(` Keeping: ${toKeep.id} (completed at ${new Date(toKeep.completed_at * 1000).toISOString()})`);
|
||||
|
||||
const deleteQuery = db.query('DELETE FROM daily_completions WHERE id = ?');
|
||||
|
||||
for (const comp of toDelete) {
|
||||
console.log(` Deleting: ${comp.id} (completed at ${new Date(comp.completed_at * 1000).toISOString()})`);
|
||||
deleteQuery.run(comp.id);
|
||||
totalDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Deduplication complete!`);
|
||||
console.log(`Total records deleted: ${totalDeleted}`);
|
||||
console.log(`Unique completions preserved: ${duplicates.length}`);
|
||||
|
||||
db.close();
|
||||
26
scripts/seed-fake-completions.sh
Executable file
26
scripts/seed-fake-completions.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/zsh
|
||||
|
||||
# Seed the database with 10 fake completions with random anonymous_ids
|
||||
# Useful for testing streak percentile and stats features
|
||||
|
||||
DB_PATH="dev.db"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
NOW=$(date +%s)
|
||||
|
||||
echo "Seeding 10 fake completions for date: $TODAY"
|
||||
|
||||
for i in {1..50}; do
|
||||
ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
ANON_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
|
||||
GUESS_COUNT=$(( (RANDOM % 6) + 1 )) # 1–6 guesses
|
||||
|
||||
sqlite3 "$DB_PATH" "
|
||||
INSERT OR IGNORE INTO daily_completions (id, anonymous_id, date, guess_count, completed_at)
|
||||
VALUES ('$ID', '$ANON_ID', '$TODAY', $GUESS_COUNT, $NOW);
|
||||
"
|
||||
|
||||
echo " [$i] anon=$ANON_ID guesses=$GUESS_COUNT"
|
||||
done
|
||||
|
||||
TOTAL=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '$TODAY';")
|
||||
echo "✓ Done. Total completions for $TODAY: $TOTAL"
|
||||
41
scripts/test-share-text.ts
Normal file
41
scripts/test-share-text.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||
import { generateShareText } from '../src/lib/utils/share';
|
||||
import { bibleBooks } from '../src/lib/types/bible';
|
||||
|
||||
const NUM_VERSES = 10;
|
||||
|
||||
for (let i = 0; i < NUM_VERSES; i++) {
|
||||
const verse = await fetchRandomVerse();
|
||||
|
||||
// Build a fake "solved in N guesses" scenario with some wrong guesses first
|
||||
const correctBook = bibleBooks.find((b) => b.id === verse.bookId)!;
|
||||
const wrongBook = bibleBooks.find((b) => b.id !== verse.bookId)!;
|
||||
const guessCount = Math.floor(Math.random() * 5) + 1;
|
||||
const guesses = [
|
||||
...Array(guessCount - 1).fill(null).map(() => ({
|
||||
book: wrongBook,
|
||||
testamentMatch: wrongBook.testament === correctBook.testament,
|
||||
sectionMatch: wrongBook.section === correctBook.section,
|
||||
adjacent: Math.abs(wrongBook.order - correctBook.order) === 1,
|
||||
})),
|
||||
{ book: correctBook, testamentMatch: true, sectionMatch: true, adjacent: false },
|
||||
];
|
||||
|
||||
const fakeStreak = Math.random() > 0.5 ? Math.floor(Math.random() * 14) + 2 : 0;
|
||||
|
||||
const shareText = generateShareText({
|
||||
guesses,
|
||||
correctBookId: verse.bookId,
|
||||
dailyVerseDate: new Date().toISOString().slice(0, 10),
|
||||
chapterCorrect: guessCount === 1 && Math.random() > 0.5,
|
||||
isLoggedIn: Math.random() > 0.5,
|
||||
streak: fakeStreak > 0 ? fakeStreak : undefined,
|
||||
origin: 'https://bibdle.com',
|
||||
verseText: verse.verseText,
|
||||
});
|
||||
|
||||
console.log(`\n── Verse ${i + 1}: ${verse.reference} ──`);
|
||||
console.log(`RAW: ${verse.verseText}`);
|
||||
console.log('─'.repeat(40));
|
||||
console.log(shareText);
|
||||
}
|
||||
10
scripts/test-verse-snippets.ts
Normal file
10
scripts/test-verse-snippets.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { fetchRandomVerse } from '../src/lib/server/bible-api';
|
||||
import { getVerseSnippet } from '../src/lib/utils/share';
|
||||
|
||||
const NUM_VERSES = 10;
|
||||
|
||||
for (let i = 0; i < NUM_VERSES; i++) {
|
||||
const verse = await fetchRandomVerse();
|
||||
|
||||
console.log(getVerseSnippet(verse.verseText));
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<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">
|
||||
|
||||
@@ -32,4 +32,4 @@ export const handle: Handle = handleAuth;
|
||||
|
||||
// Initialize embeddings on server start (runs once on module load)
|
||||
const verses = getAllNKJVVerses();
|
||||
await initializeEmbeddings(verses);
|
||||
// await initializeEmbeddings(verses);
|
||||
|
||||
4
src/lib/assets/Twitter_Logo.svg
Normal file
4
src/lib/assets/Twitter_Logo.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 248 204">
|
||||
<path fill="#1d9bf0" d="M221.95 51.29c.15 2.17.15 4.34.15 6.53 0 66.73-50.8 143.69-143.69 143.69v-.04c-27.44.04-54.31-7.82-77.41-22.64 3.99.48 8 .72 12.02.73 22.74.02 44.83-7.61 62.72-21.66-21.61-.41-40.56-14.5-47.18-35.07 7.57 1.46 15.37 1.16 22.8-.87-23.56-4.76-40.51-25.46-40.51-49.5v-.64c7.02 3.91 14.88 6.08 22.92 6.32C11.58 63.31 4.74 33.79 18.14 10.71c25.64 31.55 63.47 50.73 104.08 52.76-4.07-17.54 1.49-35.92 14.61-48.25 20.34-19.12 52.33-18.14 71.45 2.19 11.31-2.23 22.15-6.38 32.07-12.26-3.77 11.69-11.66 21.62-22.2 27.93 10.01-1.18 19.79-3.86 29-7.95-6.78 10.16-15.32 19.01-25.2 26.16z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 732 B |
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>
|
||||
237
src/lib/components/AuthModal.svelte
Normal file
237
src/lib/components/AuthModal.svelte
Normal file
@@ -0,0 +1,237 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { browser } from '$app/environment';
|
||||
import Container from './Container.svelte';
|
||||
|
||||
let {
|
||||
isOpen = $bindable(),
|
||||
anonymousId = ''
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
anonymousId: string;
|
||||
} = $props();
|
||||
|
||||
let mode = $state<'signin' | 'signup'>('signin');
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let success = $state('');
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let firstName = $state('');
|
||||
let lastName = $state('');
|
||||
|
||||
function resetForm() {
|
||||
email = '';
|
||||
password = '';
|
||||
firstName = '';
|
||||
lastName = '';
|
||||
error = '';
|
||||
success = '';
|
||||
}
|
||||
|
||||
function switchMode() {
|
||||
mode = mode === 'signin' ? 'signup' : 'signin';
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
isOpen = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
loading = true;
|
||||
error = '';
|
||||
success = '';
|
||||
}
|
||||
|
||||
function handleResult(event: any) {
|
||||
loading = false;
|
||||
const result = event.result;
|
||||
|
||||
if (result.type === 'success') {
|
||||
if (result.data?.success) {
|
||||
success = mode === 'signin' ? 'Signed in successfully!' : 'Account created successfully!';
|
||||
setTimeout(() => {
|
||||
if (browser) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, 1000);
|
||||
} else if (result.data?.error) {
|
||||
error = result.data.error;
|
||||
}
|
||||
} else if (result.type === 'failure') {
|
||||
error = result.data?.error || 'An error occurred. Please try again.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<Container class="w-full max-w-md p-6 relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
class="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">
|
||||
{mode === 'signin' ? 'Sign In' : 'Create Account'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/auth/apple">
|
||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||
<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"/>
|
||||
</svg>
|
||||
Sign in with Apple
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="flex items-center my-4">
|
||||
<div class="flex-1 border-t border-white/20"></div>
|
||||
<span class="px-3 text-sm text-white/60">or</span>
|
||||
<div class="flex-1 border-t border-white/20"></div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={mode === 'signin' ? '/auth/signin' : '/auth/signup'}
|
||||
use:enhance={({ formData }) => {
|
||||
if (anonymousId) {
|
||||
formData.append('anonymousId', anonymousId);
|
||||
}
|
||||
handleSubmit();
|
||||
return handleResult;
|
||||
}}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
{#if mode === 'signup'}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="firstName" class="block text-sm font-medium text-white mb-1">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
bind:value={firstName}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||
placeholder="John"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lastName" class="block text-sm font-medium text-white mb-1">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
bind:value={lastName}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-white mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
bind:value={email}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-white mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={password}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||
placeholder="••••••••"
|
||||
minlength="6"
|
||||
/>
|
||||
{#if mode === 'signup'}
|
||||
<p class="text-xs text-white/80 mt-1">Minimum 6 characters</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p class="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<div class="mt-4 p-3 bg-green-50 border border-green-200 rounded-md">
|
||||
<p class="text-sm text-green-600">{success}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full mt-6 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="inline-flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{mode === 'signin' ? 'Signing in...' : 'Creating account...'}
|
||||
</span>
|
||||
{:else}
|
||||
{mode === 'signin' ? 'Sign In' : 'Create Account'}
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-white">
|
||||
{mode === 'signin' ? "Don't have an account?" : 'Already have an account?'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={switchMode}
|
||||
class="text-blue-300 hover:text-blue-200 font-medium ml-1"
|
||||
>
|
||||
{mode === 'signin' ? 'Create one' : 'Sign in'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -90,7 +90,7 @@
|
||||
return chapterCounts[bookId] || 1;
|
||||
}
|
||||
|
||||
// Generate 6 random chapter options including the correct one
|
||||
// Generate 4 random chapter options including the correct one
|
||||
function generateChapterOptions(
|
||||
correctChapter: number,
|
||||
totalChapters: number,
|
||||
@@ -98,14 +98,14 @@
|
||||
const options = new Set<number>();
|
||||
options.add(correctChapter);
|
||||
|
||||
if (totalChapters >= 6) {
|
||||
while (options.size < 6) {
|
||||
if (totalChapters >= 4) {
|
||||
while (options.size < 4) {
|
||||
const randomChapter =
|
||||
Math.floor(Math.random() * totalChapters) + 1;
|
||||
options.add(randomChapter);
|
||||
}
|
||||
} else {
|
||||
while (options.size < 6) {
|
||||
while (options.size < 4) {
|
||||
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||
options.add(randomChapter);
|
||||
}
|
||||
@@ -167,18 +167,18 @@
|
||||
</script>
|
||||
|
||||
<Container
|
||||
class="w-full p-6 sm:p-8 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
|
||||
class="w-full p-3 sm:p-4 bg-linear-to-br from-yellow-100/80 to-amber-200/80 dark:from-amber-900/40 dark:to-yellow-900/30 text-gray-800 dark:text-gray-100 shadow-md"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
|
||||
<p class="text-sm sm:text-base opacity-80 mb-6">
|
||||
Guess the chapter for an even higher grade
|
||||
<p class="font-bold mb-3 text-lg sm:text-xl">
|
||||
Bonus Challenge
|
||||
<span class="text-base sm:text-lg opacity-60 font-normal"
|
||||
>— guess the chapter for an even higher grade</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
|
||||
>
|
||||
{#each chapterOptions as chapter}
|
||||
<div class="grid grid-cols-4 gap-2 justify-center mx-auto mb-3">
|
||||
{#each chapterOptions as chapter (chapter)}
|
||||
<button
|
||||
onclick={() => handleChapterSelect(chapter)}
|
||||
disabled={hasAnswered}
|
||||
@@ -193,8 +193,8 @@
|
||||
? isCorrect
|
||||
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||
: "bg-red-400 text-white border-red-500"
|
||||
: "bg-white/30 text-gray-400 border-gray-300 opacity-40"
|
||||
: "bg-white/80 hover:bg-white text-gray-800 border-gray-300 hover:border-amber-400 hover:shadow-md cursor-pointer"
|
||||
: "bg-white/30 dark:bg-white/10 text-gray-400 border-gray-300 dark:border-gray-600 opacity-40"
|
||||
: "bg-white/80 dark:bg-white/10 hover:bg-white dark:hover:bg-white/20 text-gray-800 dark:text-gray-100 border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500 hover:shadow-md cursor-pointer"
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm rounded-2xl border border-white/50 shadow-sm {className}"
|
||||
class="inline-flex flex-col items-center bg-white/10 dark:bg-white/5 backdrop-blur-sm rounded-2xl border border-white/20 dark:border-white/10 shadow-sm {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -1,88 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
let timeUntilNext = $state("");
|
||||
let intervalId: number | null = null;
|
||||
let timeUntilNext = $state("");
|
||||
let newVerseReady = $state(false);
|
||||
let intervalId: number | null = null;
|
||||
let targetTime = 0;
|
||||
|
||||
function calculateTimeUntilFivePM(): string {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
function initTarget() {
|
||||
const target = new Date();
|
||||
target.setHours(0, 0, 0, 0);
|
||||
if (Date.now() >= target.getTime()) {
|
||||
target.setDate(target.getDate() + 1);
|
||||
}
|
||||
targetTime = target.getTime();
|
||||
}
|
||||
|
||||
// Set target to 5:00 PM today
|
||||
target.setHours(17, 0, 0, 0);
|
||||
function updateTimer() {
|
||||
const diff = targetTime - Date.now();
|
||||
|
||||
// If it's already past 5:00 PM, set target to tomorrow 5:00 PM
|
||||
if (now.getTime() >= target.getTime()) {
|
||||
target.setDate(target.getDate() + 1);
|
||||
}
|
||||
if (diff <= 0) {
|
||||
newVerseReady = true;
|
||||
timeUntilNext = "";
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = target.getTime() - now.getTime();
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
if (diff <= 0) {
|
||||
return "00:00:00";
|
||||
}
|
||||
timeUntilNext = `${hours.toString().padStart(2, "0")}h ${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
onMount(() => {
|
||||
initTarget();
|
||||
updateTimer();
|
||||
intervalId = window.setInterval(updateTimer, 1000);
|
||||
});
|
||||
|
||||
return `${hours.toString().padStart(2, "0")}h ${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
function calculateTimeUntilMidnight(): string {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
|
||||
// Set target to midnight today
|
||||
target.setHours(0, 0, 0, 0);
|
||||
|
||||
// If it's already past midnight, set target to tomorrow midnight
|
||||
if (now.getTime() >= target.getTime()) {
|
||||
target.setDate(target.getDate() + 1);
|
||||
}
|
||||
|
||||
const diff = target.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
return "00:00:00";
|
||||
}
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
return `${hours.toString().padStart(2, "0")}h ${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
timeUntilNext = calculateTimeUntilMidnight();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateTimer();
|
||||
intervalId = window.setInterval(updateTimer, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2">
|
||||
Next Verse In
|
||||
</p>
|
||||
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
||||
{timeUntilNext}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full flex flex-col flex-1">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm w-full flex-1"
|
||||
>
|
||||
{#if newVerseReady}
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||
>
|
||||
Next Verse In
|
||||
</p>
|
||||
<p class="text-4xl font-triodion font-black text-gray-800">Now</p>
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mt-2"
|
||||
>
|
||||
(refresh page to see the new verse)
|
||||
</p>
|
||||
{:else}
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||
>
|
||||
Next Verse In
|
||||
</p>
|
||||
<p
|
||||
class="text-4xl font-triodion font-black text-gray-800 dark:text-gray-100 tabular-nums whitespace-nowrap"
|
||||
>
|
||||
{timeUntilNext}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||
import SocialLinks from "$lib/components/SocialLinks.svelte";
|
||||
</script>
|
||||
|
||||
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
|
||||
class="flex flex-col items-center gap-2 bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-300 font-bold"
|
||||
>
|
||||
A project by George Powell & Silent Summit Co.
|
||||
</p>
|
||||
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||
@@ -25,38 +27,8 @@
|
||||
<!-- Bluesky Social Media Button -->
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex items-center justify-center gap-6">
|
||||
<a
|
||||
href="https://bsky.app/profile/snail.city"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Bluesky"
|
||||
>
|
||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400"></div>
|
||||
|
||||
<a
|
||||
href="mailto:george+bibdle@silentsummit.co"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Send email"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-700"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="mt-8">
|
||||
<SocialLinks />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { enhance } from "$app/forms";
|
||||
import Button from "$lib/components/Button.svelte";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
appleId?: string | null;
|
||||
} | null;
|
||||
|
||||
let {
|
||||
anonymousId,
|
||||
user,
|
||||
onSignIn,
|
||||
}: { anonymousId: string | null; user: User; onSignIn: () => void } = $props();
|
||||
|
||||
let seeding = $state(false);
|
||||
|
||||
async function seedHistory(days: number = 10) {
|
||||
if (!browser || !anonymousId || seeding) return;
|
||||
seeding = true;
|
||||
try {
|
||||
const response = await fetch("/api/dev/seed-history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ anonymousId, days })
|
||||
});
|
||||
const result = await response.json();
|
||||
alert(
|
||||
`Seeded! Inserted: ${result.inserted?.join(", ")}. Skipped (already exist): ${result.skipped?.join(", ") || "none"}`
|
||||
);
|
||||
} catch {
|
||||
alert("Failed to seed history");
|
||||
} finally {
|
||||
seeding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearLocalStorage() {
|
||||
if (!browser) return;
|
||||
// Clear all bibdle-related localStorage items
|
||||
@@ -22,6 +59,41 @@
|
||||
<div class="border-t-2 border-gray-400"></div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<a
|
||||
href="/stats?{user
|
||||
? `userId=${user.id}`
|
||||
: `anonymousId=${anonymousId}`}&tz={encodeURIComponent(
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
)}"
|
||||
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
📊 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">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🚪 Sign Out
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
onclick={onSignIn}
|
||||
class="inline-flex items-center justify-center w-full px-4 py-4 md:py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium shadow-md"
|
||||
>
|
||||
🔐 Sign In
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -86,4 +158,21 @@
|
||||
>
|
||||
Clear LocalStorage
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={() => seedHistory(1)}
|
||||
disabled={seeding}
|
||||
class="w-full py-4 md:py-2"
|
||||
>
|
||||
{seeding ? "Seeding..." : "Add 1 Day of History"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={() => seedHistory(10)}
|
||||
disabled={seeding}
|
||||
class="w-full py-4 md:py-2"
|
||||
>
|
||||
{seeding ? "Seeding..." : "Seed 10 Days of History"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
41
src/lib/components/GamePrompt.svelte
Normal file
41
src/lib/components/GamePrompt.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
let { guessCount }: { guessCount: number } = $props();
|
||||
|
||||
let promptText = $state("What book of the Bible is this verse from?");
|
||||
let visible = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
let fadeOutId: ReturnType<typeof setTimeout>;
|
||||
let fadeInId: ReturnType<typeof setTimeout>;
|
||||
let changeId: ReturnType<typeof setTimeout>;
|
||||
|
||||
function animateTo(newText: string, delay = 0) {
|
||||
fadeOutId = setTimeout(() => {
|
||||
visible = false;
|
||||
changeId = setTimeout(() => {
|
||||
promptText = newText;
|
||||
visible = true;
|
||||
}, 300);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
if (guessCount === 0) {
|
||||
animateTo("What book of the Bible is this verse from?");
|
||||
} else {
|
||||
animateTo("Guess again", 2100);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(fadeOutId);
|
||||
clearTimeout(fadeInId);
|
||||
clearTimeout(changeId);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<p
|
||||
class="big-text text-center mb-6 px-4"
|
||||
style="transition: opacity 0.3s ease; opacity: {visible ? 1 : 0};"
|
||||
>
|
||||
{promptText}
|
||||
</p>
|
||||
@@ -1,26 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
import Container from "./Container.svelte";
|
||||
|
||||
interface Guess {
|
||||
book: {
|
||||
id: string;
|
||||
name: string;
|
||||
testament: string;
|
||||
section: string;
|
||||
};
|
||||
testamentMatch: boolean;
|
||||
sectionMatch: boolean;
|
||||
adjacent: boolean;
|
||||
firstLetterMatch: boolean;
|
||||
}
|
||||
import { getFirstLetter, type Guess } from "$lib/utils/game";
|
||||
|
||||
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";
|
||||
@@ -28,6 +22,19 @@
|
||||
return "bg-red-500 border-red-600";
|
||||
}
|
||||
|
||||
function getBookBoxStyle(guess: Guess): string {
|
||||
if (guess.book.id === correctBookId) {
|
||||
return "background-color: #22c55e; border-color: #16a34a;";
|
||||
}
|
||||
const correctBook = bibleBooks.find((b) => b.id === correctBookId);
|
||||
if (!correctBook)
|
||||
return "background-color: #ef4444; border-color: #dc2626;";
|
||||
const t = Math.abs(guess.book.order - correctBook.order) / 65;
|
||||
const hue = 120 * Math.pow(1 - t, 3);
|
||||
const lightness = 55 - (hue / 120) * 15;
|
||||
return `background-color: #ef4444; border-color: hsl(${hue}, 80%, ${lightness}%);`;
|
||||
}
|
||||
|
||||
function getBoxContent(
|
||||
guess: Guess,
|
||||
column: "book" | "firstLetter" | "testament" | "section",
|
||||
@@ -44,16 +51,27 @@
|
||||
(correctBook?.section === "Pauline Epistles" ||
|
||||
correctBook?.section === "General Epistles") &&
|
||||
correctBook.name[0] === "1";
|
||||
const guessStartsWithNumber = guess.book.name[0] === "1";
|
||||
const guessIsEpistlesWithNumber =
|
||||
(guess.book.section === "Pauline Epistles" ||
|
||||
guess.book.section === "General Epistles") &&
|
||||
guess.book.name[0] === "1";
|
||||
|
||||
if (
|
||||
correctIsEpistlesWithNumber &&
|
||||
guessStartsWithNumber &&
|
||||
guessIsEpistlesWithNumber &&
|
||||
guess.firstLetterMatch
|
||||
) {
|
||||
return "Yes"; // Special wordplay case
|
||||
const words = [
|
||||
"Exactly",
|
||||
"Right",
|
||||
"Yes",
|
||||
"Naturally",
|
||||
"Of course",
|
||||
"Sure",
|
||||
];
|
||||
return words[Math.floor(Math.random() * words.length)]; // Special wordplay case
|
||||
}
|
||||
return guess.book.name[0]; // Normal case: just show the first letter
|
||||
return getFirstLetter(guess.book.name); // Normal case: show first letter, ignoring numbers
|
||||
case "testament":
|
||||
return (
|
||||
guess.book.testament.charAt(0).toUpperCase() +
|
||||
@@ -65,82 +83,63 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !hasGuesses}
|
||||
<Container class="p-6 text-center">
|
||||
<h2 class="font-triodion text-xl italic mb-3 text-gray-800">
|
||||
Instructions
|
||||
</h2>
|
||||
<p class="text-gray-700 leading-relaxed italic">
|
||||
Guess what book of the bible you think the verse is from. You will
|
||||
get clues to tell you if your guess is close or not. Green means the
|
||||
category is correct; red means wrong.
|
||||
</p>
|
||||
</Container>
|
||||
{:else}
|
||||
{#if hasGuesses}
|
||||
<div class="space-y-3">
|
||||
<!-- Column Headers -->
|
||||
<div
|
||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
|
||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400 dark:border-gray-600"
|
||||
>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
>
|
||||
Book
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Testament
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Section
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
First Letter
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Book
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each guesses as guess, rowIndex (guess.book.id)}
|
||||
<div class="flex gap-2 justify-center">
|
||||
<!-- Book 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-lg animate-flip-in {getBoxColor(
|
||||
guess.book.id === correctBookId,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||
>{getBoxContent(guess, "book")}</span
|
||||
>
|
||||
</div>
|
||||
{#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 + 1 * 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 + 2 * 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>
|
||||
@@ -149,18 +148,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 + 3 * 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 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
|
||||
style="animation: none; opacity: 1; transform: none; {getBookBoxStyle(firstGuess)}"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||
>{getBoxContent(firstGuess, "book")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/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>
|
||||
|
||||
@@ -1,241 +1,246 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface ImposterData {
|
||||
verses: string[];
|
||||
refs: string[];
|
||||
imposterIndex: number;
|
||||
}
|
||||
interface ImposterData {
|
||||
verses: string[];
|
||||
refs: string[];
|
||||
imposterIndex: number;
|
||||
}
|
||||
|
||||
let data: ImposterData | null = null;
|
||||
let clicked: boolean[] = [];
|
||||
let gameOver = false;
|
||||
let loading = true;
|
||||
let error: string | null = null;
|
||||
let data: ImposterData | null = $state(null);
|
||||
let clicked: boolean[] = $state([]);
|
||||
let gameOver = $state(false);
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
async function loadGame() {
|
||||
try {
|
||||
const res = await fetch("/api/imposter");
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
data = (await res.json()) as ImposterData;
|
||||
clicked = new Array(data.verses.length).fill(false);
|
||||
gameOver = false;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Unknown error";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
async function loadGame() {
|
||||
try {
|
||||
const res = await fetch("/api/imposter");
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
data = (await res.json()) as ImposterData;
|
||||
clicked = new Array(data.verses.length).fill(false);
|
||||
gameOver = false;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Unknown error";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(index: number) {
|
||||
if (gameOver || !data || clicked[index]) return;
|
||||
clicked[index] = true;
|
||||
if (index !== data.imposterIndex) {
|
||||
clicked[data.imposterIndex] = true;
|
||||
}
|
||||
gameOver = true;
|
||||
}
|
||||
function handleClick(index: number) {
|
||||
if (gameOver || !data || clicked[index]) return;
|
||||
clicked[index] = true;
|
||||
if (index !== data.imposterIndex) {
|
||||
clicked[data.imposterIndex] = true;
|
||||
}
|
||||
gameOver = true;
|
||||
}
|
||||
|
||||
function newGame() {
|
||||
loading = true;
|
||||
error = null;
|
||||
data = null;
|
||||
loadGame();
|
||||
}
|
||||
function newGame() {
|
||||
loading = true;
|
||||
error = null;
|
||||
data = null;
|
||||
loadGame();
|
||||
}
|
||||
|
||||
onMount(loadGame);
|
||||
onMount(loadGame);
|
||||
|
||||
function formatVerse(verse: string): string {
|
||||
let formatted = verse;
|
||||
function formatVerse(verse: string): string {
|
||||
let formatted = verse;
|
||||
|
||||
// Handle unbalanced opening/closing punctuation
|
||||
const pairs: [string, string][] = [
|
||||
["(", ")"],
|
||||
["[", "]"],
|
||||
["{", "}"],
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
["\u201C", "\u201D"], // \u201C
|
||||
["\u2018", "\u2019"], // \u2018
|
||||
];
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||
formatted += "..." + close;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||
formatted = open + "..." + formatted;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Handle unbalanced opening/closing punctuation
|
||||
const pairs: [string, string][] = [
|
||||
["(", ")"],
|
||||
["[", "]"],
|
||||
["{", "}"],
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
["\u201C", "\u201D"], // \u201C
|
||||
["\u2018", "\u2019"], // \u2018
|
||||
];
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||
formatted += "..." + close;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||
formatted = open + "..." + formatted;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^[a-z]/.test(formatted)) {
|
||||
formatted = "..." + formatted;
|
||||
}
|
||||
formatted = formatted.replace(/[,:;-—]$/, "...");
|
||||
return formatted;
|
||||
}
|
||||
if (/^[a-z]/.test(formatted)) {
|
||||
formatted = "..." + formatted;
|
||||
}
|
||||
// Replace trailing punctuation with ellipsis
|
||||
// Preserve closing quotes/brackets that may have been added
|
||||
formatted = formatted.replace(
|
||||
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
|
||||
"...$1",
|
||||
);
|
||||
return formatted;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="imposter-game">
|
||||
{#if loading}
|
||||
<p class="loading">Loading verses...</p>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<p>Error: {error}</p>
|
||||
<button on:click={newGame}>Retry</button>
|
||||
</div>
|
||||
{:else if data}
|
||||
<!-- <div class="instructions">
|
||||
{#if loading}
|
||||
<p class="loading">Loading verses...</p>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<p>Error: {error}</p>
|
||||
<button onclick={newGame}>Retry</button>
|
||||
</div>
|
||||
{:else if data}
|
||||
<!-- <div class="instructions">
|
||||
<p>Click the verse that doesn't belong (from a different book).</p>
|
||||
</div> -->
|
||||
<div class="verses">
|
||||
{#each data.verses as verse, i}
|
||||
<div class="verse-item">
|
||||
<button
|
||||
class="verse-button"
|
||||
class:clicked={clicked[i]}
|
||||
class:correct={clicked[i] && i === data.imposterIndex}
|
||||
class:wrong={clicked[i] && i !== data.imposterIndex}
|
||||
on:click={() => handleClick(i)}
|
||||
disabled={gameOver}
|
||||
>
|
||||
{formatVerse(verse)}
|
||||
</button>
|
||||
{#if gameOver}
|
||||
<div class="ref">{data.refs[i]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if gameOver}
|
||||
<div class="result">
|
||||
<button on:click={newGame}>New Game</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="verses">
|
||||
{#each data.verses as verse, i}
|
||||
<div class="verse-item">
|
||||
<button
|
||||
class="verse-button"
|
||||
class:clicked={clicked[i]}
|
||||
class:correct={clicked[i] && i === data.imposterIndex}
|
||||
class:wrong={clicked[i] && i !== data.imposterIndex}
|
||||
onclick={() => handleClick(i)}
|
||||
disabled={gameOver}
|
||||
>
|
||||
{formatVerse(verse)}
|
||||
</button>
|
||||
{#if gameOver}
|
||||
<div class="ref">{data.refs[i]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if gameOver}
|
||||
<div class="result">
|
||||
<button onclick={newGame}>New Game</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.imposter-game {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.imposter-game {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
}
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
/*.instructions {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}
|
||||
}*/
|
||||
|
||||
.verses {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.verses {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.verse-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.verse-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.verse-button {
|
||||
padding: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
border: 3px solid #ddd;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 100px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.verse-button {
|
||||
padding: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
border: 3px solid #ddd;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 100px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.verse-button:hover:not(.clicked):not(:disabled) {
|
||||
border-color: #007bff;
|
||||
background: #f8f9ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
||||
}
|
||||
.verse-button:hover:not(.clicked):not(:disabled) {
|
||||
border-color: #007bff;
|
||||
background: #f8f9ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
||||
}
|
||||
|
||||
.verse-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.verse-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.verse-button.clicked {
|
||||
cursor: default;
|
||||
}
|
||||
.verse-button.clicked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.correct {
|
||||
background: #d4edda !important;
|
||||
border-color: #28a745 !important;
|
||||
color: #155724;
|
||||
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
.correct {
|
||||
background: #d4edda !important;
|
||||
border-color: #28a745 !important;
|
||||
color: #155724;
|
||||
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.wrong {
|
||||
background: #f8d7da !important;
|
||||
border-color: #dc3545 !important;
|
||||
color: #721c24;
|
||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
.wrong {
|
||||
background: #f8d7da !important;
|
||||
border-color: #dc3545 !important;
|
||||
color: #721c24;
|
||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.ref {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: #555;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
.ref {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: #555;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.verse-button.correct ~ .ref {
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}
|
||||
.verse-button.correct ~ .ref {
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.verse-button.wrong ~ .ref {
|
||||
color: #dc3545;
|
||||
}
|
||||
.verse-button.wrong ~ .ref {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.result {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.result {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.result button,
|
||||
.error button {
|
||||
padding: 0.75rem 2rem;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.result button,
|
||||
.error button {
|
||||
padding: 0.75rem 2rem;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.result button:hover,
|
||||
.error button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
.result button:hover,
|
||||
.error button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
</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,92 +1,355 @@
|
||||
<script lang="ts">
|
||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
||||
import {
|
||||
bibleBooks,
|
||||
type BibleBook,
|
||||
type BibleSection,
|
||||
type Testament,
|
||||
} from "$lib/types/bible";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
|
||||
let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props();
|
||||
let {
|
||||
searchQuery = $bindable(""),
|
||||
guessedIds,
|
||||
submitGuess,
|
||||
guessCount = 0,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
guessedIds: SvelteSet<string>;
|
||||
submitGuess: (id: string) => void;
|
||||
guessCount: number;
|
||||
} = $props();
|
||||
|
||||
let filteredBooks = $derived(
|
||||
bibleBooks.filter((book) =>
|
||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
type DisplayMode = "simple" | "testament" | "sections";
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && filteredBooks.length > 0) {
|
||||
submitGuess(filteredBooks[0].id);
|
||||
}
|
||||
}
|
||||
const displayMode = $derived<DisplayMode>(
|
||||
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple",
|
||||
);
|
||||
|
||||
const filteredBooks = $derived(
|
||||
bibleBooks.filter((book) =>
|
||||
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
type SimpleGroup = { books: BibleBook[] };
|
||||
|
||||
type TestamentGroup = {
|
||||
testament: Testament;
|
||||
label: string;
|
||||
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 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 }[] =
|
||||
[];
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
groups.push({
|
||||
testament: pair.testament,
|
||||
testamentLabel:
|
||||
pair.testament === "old"
|
||||
? "Old Testament"
|
||||
: "New Testament",
|
||||
showTestamentHeader,
|
||||
section: pair.section,
|
||||
books,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && firstBookId) {
|
||||
submitGuess(firstBookId);
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute left-4 sm:left-6 top-1/2 transform -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"
|
||||
>
|
||||
<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 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-200 transition-all bg-white"
|
||||
onkeydown={handleKeydown}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 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"
|
||||
>
|
||||
<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 border border-gray-300 rounded-2xl shadow-xl"
|
||||
>
|
||||
{#each filteredBooks as book (book.id)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id)
|
||||
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
|
||||
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-500'
|
||||
: ''}">{book.name}</span
|
||||
>
|
||||
<span class="ml-auto text-sm opacity-75"
|
||||
>({book.testament.toUpperCase()})</span
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if searchQuery}
|
||||
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
|
||||
{/if}
|
||||
<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
|
||||
{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
|
||||
{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
|
||||
{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}
|
||||
</div>
|
||||
|
||||
57
src/lib/components/SocialLinks.svelte
Normal file
57
src/lib/components/SocialLinks.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-center gap-6">
|
||||
<a
|
||||
href="https://bsky.app/profile/snail.city"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Bluesky"
|
||||
data-umami-event="Bluesky clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Bluesky clicked")}
|
||||
>
|
||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div>
|
||||
|
||||
<!-- <a
|
||||
href="https://x.com/pupperpowell"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Twitter"
|
||||
data-umami-event="Twitter clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Twitter clicked")}
|
||||
>
|
||||
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div> -->
|
||||
|
||||
<a
|
||||
href="mailto:george+bibdle@silentsummit.co"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Send email"
|
||||
data-umami-event="Email clicked"
|
||||
onclick={() => (window as any).rybbit?.event("Email clicked")}
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-700 dark:text-gray-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
31
src/lib/components/StreakCounter.svelte
Normal file
31
src/lib/components/StreakCounter.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
streak,
|
||||
streakPercentile = null,
|
||||
}: {
|
||||
streak: number;
|
||||
streakPercentile?: number | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-4 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm flex-1 text-center"
|
||||
>
|
||||
<p
|
||||
class="text-5xl font-triodion font-black text-orange-500 leading-none tabular-nums"
|
||||
>
|
||||
{streak}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs uppercase justify-center tracking-widest text-gray-500 dark:text-gray-200 font-triodion font-bold py-2 leading-tight"
|
||||
>
|
||||
day{streak === 1 ? "" : "s"} in a row
|
||||
</p>
|
||||
{#if streakPercentile !== null && streakPercentile <= 50}
|
||||
<p
|
||||
class="text-xs text-black dark:text-gray-200 w-full tracking-widest uppercase font-semibold border-t border-t-stone-400 dark:border-t-stone-600 pt-2"
|
||||
>
|
||||
Top {streakPercentile}%
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
62
src/lib/components/ThemeToggle.svelte
Normal file
62
src/lib/components/ThemeToggle.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let isDarkMode = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
const stored = localStorage.getItem('bibdle-theme');
|
||||
if (stored === 'dark') {
|
||||
isDarkMode = true;
|
||||
} else if (stored === 'light') {
|
||||
isDarkMode = false;
|
||||
} else {
|
||||
isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.classList.remove('light');
|
||||
localStorage.setItem('bibdle-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.classList.add('light');
|
||||
localStorage.setItem('bibdle-theme', 'light');
|
||||
}
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
isDarkMode = !isDarkMode;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if browser}
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
aria-label={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
class="flex items-center gap-2 p-1 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
{#if isDarkMode}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
<path d="M12 2v2"/>
|
||||
<path d="M12 20v2"/>
|
||||
<path d="m4.93 4.93 1.41 1.41"/>
|
||||
<path d="m17.66 17.66 1.41 1.41"/>
|
||||
<path d="M2 12h2"/>
|
||||
<path d="M20 12h2"/>
|
||||
<path d="m6.34 17.66-1.41 1.41"/>
|
||||
<path d="m19.07 4.93-1.41 1.41"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="text-xs uppercase tracking-widest">
|
||||
{isDarkMode ? 'Light mode' : 'Dark mode'}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -1,38 +1,87 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||
import Container from "./Container.svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { fade } from "svelte/transition";
|
||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||
import Container from "./Container.svelte";
|
||||
|
||||
let {
|
||||
data,
|
||||
isWon,
|
||||
blurChapter = false,
|
||||
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let displayReference = $derived(
|
||||
blurChapter
|
||||
? dailyVerse.reference
|
||||
.replace(/^Psalms /, "Psalm ")
|
||||
.replace(/\s(\d+):/, " ?:")
|
||||
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
||||
);
|
||||
let displayVerseText = $derived(
|
||||
dailyVerse.verseText
|
||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||
.replace(/[,:;-—]$/, "...")
|
||||
);
|
||||
let {
|
||||
data,
|
||||
isWon,
|
||||
blurChapter = false,
|
||||
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let displayReference = $derived(
|
||||
blurChapter
|
||||
? dailyVerse.reference
|
||||
.replace(/^Psalms /, "Psalm ")
|
||||
.replace(/\s(\d+):/, " ?:")
|
||||
: dailyVerse.reference.replace(/^Psalms /, "Psalm "),
|
||||
);
|
||||
let displayVerseText = $derived(
|
||||
dailyVerse.verseText
|
||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||
.replace(/[,:;-—]$/, "..."),
|
||||
);
|
||||
|
||||
let showReference = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
// Delay showing reference until GuessesTable animation completes
|
||||
$effect(() => {
|
||||
if (!isWon) {
|
||||
showReference = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user already won today (page reload case)
|
||||
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||
const alreadyWonToday =
|
||||
browser && localStorage.getItem(winTrackedKey) === "true";
|
||||
|
||||
if (alreadyWonToday) {
|
||||
// User already won and is refreshing - show immediately
|
||||
showReference = true;
|
||||
} else {
|
||||
// User just won this session - delay for animation
|
||||
const animationDelay = 1800;
|
||||
const timeoutId = setTimeout(() => {
|
||||
showReference = true;
|
||||
}, animationDelay);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
function copyVerse() {
|
||||
navigator.clipboard.writeText(displayVerseText).then(() => {
|
||||
copied = true;
|
||||
(window as any).rybbit?.event("Copy Verse");
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container class="w-full p-8 sm:p-12 bg-white/70">
|
||||
<blockquote
|
||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
||||
>
|
||||
{displayVerseText}
|
||||
</blockquote>
|
||||
{#if isWon}
|
||||
<p
|
||||
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
||||
>
|
||||
{displayReference}
|
||||
</p>
|
||||
{/if}
|
||||
<Container
|
||||
class="w-full p-8 sm:p-12 bg-white/70 dark:bg-black/30 overflow-hidden"
|
||||
>
|
||||
<blockquote
|
||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 dark:text-gray-200 text-center"
|
||||
>
|
||||
{displayVerseText}
|
||||
</blockquote>
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out overflow-hidden"
|
||||
style="max-height: {showReference ? '200px' : '0px'};"
|
||||
>
|
||||
{#if showReference}
|
||||
<p
|
||||
transition:fade={{ duration: 400 }}
|
||||
class="text-center text-lg! big-text text-green-600! dark:text-green-400! font-bold mt-8 bg-white/70 dark:bg-black/50 rounded-xl px-4 py-2"
|
||||
>
|
||||
{displayReference}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { getBookById, toOrdinal } from "$lib/utils/game";
|
||||
import {
|
||||
getBookById,
|
||||
toOrdinal,
|
||||
getNextGradeMessage,
|
||||
} from "$lib/utils/game";
|
||||
import { onMount } from "svelte";
|
||||
getVerseSnippet,
|
||||
shareResult,
|
||||
copyToClipboard as clipboardCopy,
|
||||
} from "$lib/utils/share";
|
||||
import Container from "./Container.svelte";
|
||||
import CountdownTimer from "./CountdownTimer.svelte";
|
||||
import StreakCounter from "./StreakCounter.svelte";
|
||||
import ChapterGuess from "./ChapterGuess.svelte";
|
||||
|
||||
interface StatsData {
|
||||
@@ -25,7 +26,6 @@
|
||||
}
|
||||
|
||||
let {
|
||||
grade,
|
||||
statsData,
|
||||
correctBookId,
|
||||
handleShare,
|
||||
@@ -35,6 +35,28 @@
|
||||
guessCount,
|
||||
reference,
|
||||
onChapterGuessCompleted,
|
||||
shareText,
|
||||
verseText,
|
||||
streak = 0,
|
||||
streakPercentile = null,
|
||||
isLoggedIn = false,
|
||||
anonymousId = "",
|
||||
}: {
|
||||
statsData: StatsData | null;
|
||||
correctBookId: string;
|
||||
handleShare: () => void;
|
||||
copyToClipboard: () => void;
|
||||
copied: boolean;
|
||||
statsSubmitted: boolean;
|
||||
guessCount: number;
|
||||
reference: string;
|
||||
onChapterGuessCompleted: () => void;
|
||||
shareText: string;
|
||||
verseText: string;
|
||||
streak?: number;
|
||||
streakPercentile?: number | null;
|
||||
isLoggedIn?: boolean;
|
||||
anonymousId?: string;
|
||||
} = $props();
|
||||
|
||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||
@@ -42,6 +64,24 @@
|
||||
typeof navigator !== "undefined" && "share" in navigator,
|
||||
);
|
||||
let copySuccess = $state(false);
|
||||
let bubbleCopied = $state(false);
|
||||
let copyTracked = $state(false);
|
||||
let showSnippetOption = $state(false);
|
||||
let includeSnippet = $state(false);
|
||||
|
||||
let effectiveShareText = $derived(
|
||||
includeSnippet
|
||||
? (() => {
|
||||
const snippet = getVerseSnippet(verseText);
|
||||
const lines = shareText.split("\n");
|
||||
return [
|
||||
...lines.slice(0, -1),
|
||||
snippet,
|
||||
lines[lines.length - 1],
|
||||
].join("\n");
|
||||
})()
|
||||
: shareText,
|
||||
);
|
||||
|
||||
// List of congratulations messages with weights
|
||||
const congratulationsMessages: WeightedMessage[] = [
|
||||
@@ -57,9 +97,9 @@
|
||||
if (guessCount === 1) {
|
||||
const n = Math.random();
|
||||
if (n < 0.99) {
|
||||
return "🌟 First try! 🌟";
|
||||
return "First try!";
|
||||
} else {
|
||||
return "🗣️ Axios! 🗣️";
|
||||
return "Axios!";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,66 +126,27 @@
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<Container
|
||||
class="w-full p-8 sm:p-12 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
|
||||
class="w-full px-4 sm:px-6 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 dark:text-gray-100 shadow-2xl text-center fade-in"
|
||||
>
|
||||
<p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed">
|
||||
{congratulationsMessage} The verse is from
|
||||
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
|
||||
</p>
|
||||
<p class="text-lg sm:text-xl md:text-2xl mt-4">
|
||||
You guessed correctly after {guessCount}
|
||||
{guessCount === 1 ? "guess" : "guesses"}.
|
||||
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
|
||||
>{grade}</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center mt-6">
|
||||
{#if hasWebShare}
|
||||
<!-- mobile and arc in production -->
|
||||
<button
|
||||
onclick={handleShare}
|
||||
data-umami-event="Share"
|
||||
class="text-2xl font-bold p-4 bg-white/70 hover:bg-white/80 rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none"
|
||||
>
|
||||
📤 Share
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
copyToClipboard();
|
||||
copySuccess = true;
|
||||
setTimeout(() => {
|
||||
copySuccess = false;
|
||||
}, 3000);
|
||||
}}
|
||||
data-umami-event="Copy to Clipboard"
|
||||
class={`text-2xl font-bold p-4 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
|
||||
copySuccess
|
||||
? "bg-white/30"
|
||||
: "bg-white/70 hover:bg-white/80"
|
||||
}`}
|
||||
>
|
||||
{copySuccess ? "✅ Copied!" : "📋 Copy"}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- dev mode and desktop browsers -->
|
||||
<button
|
||||
onclick={handleShare}
|
||||
data-umami-event="Copy to Clipboard"
|
||||
class={`text-2xl font-bold p-4 ${
|
||||
copied ? "bg-white/30" : "bg-white/70 hover:bg-white/80"
|
||||
} rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
|
||||
>
|
||||
{copied ? "✅ Copied!" : "📋 Share"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if guessCount !== 1}
|
||||
<p class="pt-6 big-text text-gray-700!">
|
||||
{getNextGradeMessage(guessCount)}
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
||||
{congratulationsMessage} The verse is from
|
||||
<span class="font-black font-triodion text-3xl md:text-4xl"
|
||||
>{bookName}</span
|
||||
>.
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-lg sm:text-xl md:text-2xl">
|
||||
You guessed correctly after {guessCount}
|
||||
{guessCount === 1 ? "guess" : "guesses"}.
|
||||
</p>
|
||||
<!-- {#if streak >= 7}
|
||||
<p
|
||||
class="italic tracking-wider px-8 font-semibold text-gray-500"
|
||||
>
|
||||
Thank you for making BIBDLE part of your daily routine!
|
||||
</p>
|
||||
{/if} -->
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- S++ Bonus Challenge for first try -->
|
||||
@@ -157,12 +158,21 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<CountdownTimer />
|
||||
<div class="flex flex-row gap-3 items-stretch w-full">
|
||||
<div class="flex-2 min-w-0 flex flex-col">
|
||||
<CountdownTimer />
|
||||
</div>
|
||||
{#if streak > 0}
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<StreakCounter {streak} {streakPercentile} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Statistics Display -->
|
||||
{#if statsData}
|
||||
<Container
|
||||
class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
||||
class="w-full p-4 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
||||
@@ -171,7 +181,7 @@
|
||||
<!-- Solve Rank Column -->
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||
>
|
||||
#{statsData.solveRank}
|
||||
</div>
|
||||
@@ -184,7 +194,7 @@
|
||||
<!-- Guess Rank Column -->
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||
>
|
||||
{toOrdinal(statsData.guessRank)}
|
||||
</div>
|
||||
@@ -206,7 +216,7 @@
|
||||
<!-- Average Column -->
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||
>
|
||||
{statsData.averageGuesses}
|
||||
</div>
|
||||
@@ -220,11 +230,135 @@
|
||||
</Container>
|
||||
{:else if !statsSubmitted}
|
||||
<Container
|
||||
class="w-full p-6 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
||||
class="w-full p-6 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
|
||||
>
|
||||
<div class="text-sm opacity-80">Submitting stats...</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
|
||||
<div class="big-text font-black! text-center">Share your result</div>
|
||||
<div class="chat-window">
|
||||
<!-- Received bubble: primary action (share / copy) -->
|
||||
<div class="bubble-wrapper received-wrapper">
|
||||
<button
|
||||
class="bubble bubble-received"
|
||||
class:success={copySuccess}
|
||||
aria-label={hasWebShare ? "Share" : "Copy to clipboard"}
|
||||
data-umami-event={hasWebShare
|
||||
? "Share"
|
||||
: "Copy to Clipboard"}
|
||||
onclick={() => {
|
||||
if (hasWebShare) {
|
||||
(window as any).rybbit?.event("Share");
|
||||
shareResult(effectiveShareText);
|
||||
} else {
|
||||
if (!copyTracked) {
|
||||
(window as any).rybbit?.event(
|
||||
"Copy to Clipboard",
|
||||
);
|
||||
copyTracked = true;
|
||||
}
|
||||
clipboardCopy(effectiveShareText);
|
||||
copySuccess = true;
|
||||
setTimeout(() => {
|
||||
copySuccess = false;
|
||||
}, 3000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if hasWebShare}
|
||||
📤 Tap here to share
|
||||
{:else if copySuccess}
|
||||
✅ Copied!
|
||||
{:else}
|
||||
📋 Copy to clipboard
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sent bubble: share text preview -->
|
||||
<div class="bubble-wrapper">
|
||||
<button
|
||||
class="bubble bubble-sent"
|
||||
aria-label="Copy to clipboard"
|
||||
data-umami-event="Copy to Clipboard"
|
||||
onclick={() => {
|
||||
if (!copyTracked) {
|
||||
(window as any).rybbit?.event("Copy to Clipboard");
|
||||
copyTracked = true;
|
||||
}
|
||||
clipboardCopy(effectiveShareText);
|
||||
showSnippetOption = true;
|
||||
bubbleCopied = true;
|
||||
setTimeout(() => {
|
||||
bubbleCopied = false;
|
||||
}, 2000);
|
||||
}}>{effectiveShareText}</button
|
||||
>
|
||||
{#if hasWebShare}
|
||||
<span class="copy-hint"
|
||||
>{bubbleCopied ? "copied!" : "(tap to copy)"}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="copy-hint"
|
||||
>{bubbleCopied ? "copied!" : ""}</span
|
||||
>
|
||||
{/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}
|
||||
<div class="snippet-toggle-row mr-4" in:fly={{ y: -8, duration: 220 }}>
|
||||
<span class="snippet-label">Show verse snippet in share?</span>
|
||||
<button
|
||||
class="snippet-toggle"
|
||||
class:on={includeSnippet}
|
||||
onclick={() => (includeSnippet = !includeSnippet)}
|
||||
aria-pressed={includeSnippet}
|
||||
aria-label="Show snippet in share"
|
||||
>
|
||||
<span class="toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoggedIn}
|
||||
<div class="signin-prompt">
|
||||
<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>
|
||||
Sign in with Apple
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -239,7 +373,379 @@
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
:global(.fade-in) {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* ── Share card ── */
|
||||
.share-card {
|
||||
background: oklch(94% 0.028 298.626);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.share-card {
|
||||
background: oklch(22% 0.025 298.626);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.share-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Chat window ── */
|
||||
.chat-window {
|
||||
--sent-color: #0b93f6;
|
||||
--received-color: #3a3a3c;
|
||||
--bg: oklch(94% 0.028 298.626);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.5rem 0;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.chat-window {
|
||||
--bg: oklch(22% 0.025 298.626);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Bubble wrappers ── */
|
||||
.bubble-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.received-wrapper {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Shared bubble base ── */
|
||||
.bubble {
|
||||
position: relative;
|
||||
max-width: 255px;
|
||||
margin-bottom: 0;
|
||||
padding: 10px 20px;
|
||||
line-height: 1.3;
|
||||
word-wrap: break-word;
|
||||
border-radius: 25px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
filter 80ms ease,
|
||||
transform 80ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Sent bubble (share text preview) ── */
|
||||
.bubble-sent {
|
||||
color: white;
|
||||
background: var(--sent-color);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.bubble-sent:hover {
|
||||
background-color: #2ea8ff;
|
||||
transform: rotate(-2deg) translateY(-2px);
|
||||
}
|
||||
|
||||
.bubble-sent:hover::before {
|
||||
background-color: #2ea8ff;
|
||||
}
|
||||
|
||||
.bubble-sent:active {
|
||||
background-color: #0878d4;
|
||||
transform: rotate(-2deg) scale(0.97);
|
||||
}
|
||||
|
||||
.bubble-sent:active::before {
|
||||
background-color: #0878d4;
|
||||
}
|
||||
|
||||
/* Sent tail: bottom-right */
|
||||
.bubble-sent::before,
|
||||
.bubble-sent::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 25px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.bubble-sent::before {
|
||||
width: 20px;
|
||||
right: -7px;
|
||||
background-color: var(--sent-color);
|
||||
border-bottom-left-radius: 16px 14px;
|
||||
}
|
||||
|
||||
.bubble-sent::after {
|
||||
width: 26px;
|
||||
right: -26px;
|
||||
border-bottom-left-radius: 10px;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Received bubble (action button) ── */
|
||||
.bubble-received {
|
||||
color: #f5f5f7;
|
||||
background: var(--received-color);
|
||||
transform: rotate(2deg);
|
||||
padding: 14px 24px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
min-width: 14rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bubble-received:hover {
|
||||
background-color: #4a4a4e;
|
||||
transform: rotate(2deg) translateY(-2px);
|
||||
}
|
||||
|
||||
.bubble-received:hover::before {
|
||||
background-color: #4a4a4e;
|
||||
}
|
||||
|
||||
.bubble-received:active {
|
||||
background-color: #2a2a2c;
|
||||
transform: rotate(2deg) scale(0.97);
|
||||
}
|
||||
|
||||
.bubble-received:active::before {
|
||||
background-color: #2a2a2c;
|
||||
}
|
||||
|
||||
.bubble-received.success {
|
||||
background: #c7f7d4;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
/* Received tail: bottom-left (mirror of sent) */
|
||||
.bubble-received::before,
|
||||
.bubble-received::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 25px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.bubble-received::before {
|
||||
width: 20px;
|
||||
left: -7px;
|
||||
background-color: var(--received-color);
|
||||
border-bottom-right-radius: 16px 14px;
|
||||
}
|
||||
|
||||
.bubble-received::after {
|
||||
width: 26px;
|
||||
left: -26px;
|
||||
border-bottom-right-radius: 10px;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
.bubble-received.success::before {
|
||||
background-color: #c7f7d4;
|
||||
}
|
||||
|
||||
/* ── Copy hints ── */
|
||||
.copy-hint {
|
||||
font-size: 0.68rem;
|
||||
color: #444;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
padding-right: 32px;
|
||||
transform: rotate(-2deg);
|
||||
transform-origin: right center;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.copy-hint {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Snippet toggle row ── */
|
||||
.snippet-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.snippet-label {
|
||||
font-size: 0.72rem;
|
||||
color: #666;
|
||||
letter-spacing: 0.01em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.snippet-label {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.snippet-toggle {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: #ccc;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 200ms ease;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.snippet-toggle.on {
|
||||
background: #34c759;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.snippet-toggle.on .toggle-thumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* ── Apple Sign In prompt ── */
|
||||
.signin-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
/*padding: 1rem 0 0.25rem;*/
|
||||
}
|
||||
|
||||
.signin-text {
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.signin-text {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
.apple-signin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.6rem;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
transform 80ms ease;
|
||||
}
|
||||
|
||||
.apple-signin-btn:hover {
|
||||
background: #222;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.apple-signin-btn:active {
|
||||
background: #111;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.apple-signin-btn {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
.apple-signin-btn:hover {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
.apple-signin-btn:active {
|
||||
background: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.apple-icon {
|
||||
width: 1.1rem;
|
||||
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>
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
|
||||
export * from './utils/game';
|
||||
export { default as VerseDisplay } from './components/VerseDisplay.svelte';
|
||||
export { default as SearchInput } from './components/SearchInput.svelte';
|
||||
export { default as GuessesTable } from './components/GuessesTable.svelte';
|
||||
export { default as WinScreen } from './components/WinScreen.svelte';
|
||||
export { default as Feedback } from './components/Feedback.svelte';
|
||||
|
||||
142
src/lib/server/apple-auth.ts
Normal file
142
src/lib/server/apple-auth.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
||||
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
||||
|
||||
export function getAppleAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: Bun.env.APPLE_ID!,
|
||||
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/apple/callback`,
|
||||
response_type: 'code',
|
||||
response_mode: 'form_post',
|
||||
scope: 'name email',
|
||||
state
|
||||
});
|
||||
return `${APPLE_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function generateAppleClientSecret(): Promise<string> {
|
||||
const header = { alg: 'ES256', kid: Bun.env.APPLE_KEY_ID! };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iss: Bun.env.APPLE_TEAM_ID!,
|
||||
iat: now,
|
||||
exp: now + 86400 * 180,
|
||||
aud: 'https://appleid.apple.com',
|
||||
sub: Bun.env.APPLE_ID!
|
||||
};
|
||||
|
||||
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
// Import PEM private key
|
||||
const pemBody = Bun.env.APPLE_PRIVATE_KEY!.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||
.replace(/-----END PRIVATE KEY-----/, '')
|
||||
.replace(/\s/g, '');
|
||||
const keyBuffer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0));
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
keyBuffer,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signatureBuffer = await crypto.subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
key,
|
||||
new TextEncoder().encode(signingInput)
|
||||
);
|
||||
|
||||
const signature = new Uint8Array(signatureBuffer);
|
||||
|
||||
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
||||
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
||||
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
||||
const encodedSignature = Buffer.from(rawSignature).toString('base64url');
|
||||
|
||||
return `${signingInput}.${encodedSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DER-encoded ECDSA signature to raw r||s format (64 bytes for P-256)
|
||||
*/
|
||||
function derToRaw(der: Uint8Array): Uint8Array {
|
||||
// DER structure: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s]
|
||||
let offset = 2; // skip 0x30 and total length
|
||||
|
||||
// Read r
|
||||
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
const rLen = der[offset];
|
||||
offset++;
|
||||
let r = der.slice(offset, offset + rLen);
|
||||
offset += rLen;
|
||||
|
||||
// Read s
|
||||
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||
offset++;
|
||||
const sLen = der[offset];
|
||||
offset++;
|
||||
let s = der.slice(offset, offset + sLen);
|
||||
|
||||
// Remove leading zero padding (DER uses it for positive sign)
|
||||
if (r.length === 33 && r[0] === 0) r = r.slice(1);
|
||||
if (s.length === 33 && s[0] === 0) s = s.slice(1);
|
||||
|
||||
// Pad to 32 bytes each
|
||||
const raw = new Uint8Array(64);
|
||||
raw.set(r, 32 - r.length);
|
||||
raw.set(s, 64 - s.length);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function exchangeAppleCode(
|
||||
code: string,
|
||||
redirectUri: string
|
||||
): Promise<{
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
id_token: string;
|
||||
}> {
|
||||
const clientSecret = await generateAppleClientSecret();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: Bun.env.APPLE_ID!,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri
|
||||
});
|
||||
|
||||
const response = await fetch(APPLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Apple token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Apple's id_token JWT payload without signature verification.
|
||||
* Safe because the token is received directly from Apple's token endpoint over TLS.
|
||||
*/
|
||||
export function decodeAppleIdToken(idToken: string): {
|
||||
sub: string;
|
||||
email?: string;
|
||||
email_verified?: string;
|
||||
is_private_email?: string;
|
||||
} {
|
||||
const [, payloadB64] = idToken.split('.');
|
||||
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
|
||||
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return payload;
|
||||
}
|
||||
113
src/lib/server/auth.test.ts
Normal file
113
src/lib/server/auth.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { testDb as db } from '$lib/server/db/test';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
|
||||
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
export const sessionCookieName = 'auth-session';
|
||||
|
||||
export function generateSessionToken() {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||
return Buffer.from(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
export async function createSession(token: string, userId: string) {
|
||||
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||
const session: table.Session = {
|
||||
id: sessionId,
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + DAY_IN_MS * 30)
|
||||
};
|
||||
await db.insert(table.session).values(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function validateSessionToken(token: string) {
|
||||
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||
const [result] = await db
|
||||
.select({
|
||||
// Adjust user table here to tweak returned data
|
||||
user: { id: table.user.id, email: table.user.email },
|
||||
session: table.session
|
||||
})
|
||||
.from(table.session)
|
||||
.innerJoin(table.user, eq(table.session.userId, table.user.id))
|
||||
.where(eq(table.session.id, sessionId));
|
||||
|
||||
if (!result) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
const { session, user } = result;
|
||||
|
||||
const sessionExpired = Date.now() >= session.expiresAt.getTime();
|
||||
if (sessionExpired) {
|
||||
await db.delete(table.session).where(eq(table.session.id, session.id));
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15;
|
||||
if (renewSession) {
|
||||
session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30);
|
||||
await db
|
||||
.update(table.session)
|
||||
.set({ expiresAt: session.expiresAt })
|
||||
.where(eq(table.session.id, session.id));
|
||||
}
|
||||
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
|
||||
|
||||
export async function invalidateSession(sessionId: string) {
|
||||
await db.delete(table.session).where(eq(table.session.id, sessionId));
|
||||
}
|
||||
|
||||
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
|
||||
event.cookies.set(sessionCookieName, token, {
|
||||
expires: expiresAt,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSessionTokenCookie(event: RequestEvent) {
|
||||
event.cookies.delete(sessionCookieName, {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await Bun.password.hash(password, {
|
||||
algorithm: 'argon2id',
|
||||
memoryCost: 4,
|
||||
timeCost: 3
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
try {
|
||||
return await Bun.password.verify(password, hash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) {
|
||||
const user: table.User = {
|
||||
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
||||
email,
|
||||
passwordHash,
|
||||
appleId: null,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
isPrivate: false
|
||||
};
|
||||
await db.insert(table.user).values(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
|
||||
return user || null;
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
|
||||
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
|
||||
|
||||
export function generateSessionToken() {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||
const token = encodeBase64url(bytes);
|
||||
return token;
|
||||
return Buffer.from(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
export async function createSession(token: string, userId: string) {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||
const session: table.Session = {
|
||||
id: sessionId,
|
||||
userId,
|
||||
@@ -27,11 +24,11 @@ export async function createSession(token: string, userId: string) {
|
||||
}
|
||||
|
||||
export async function validateSessionToken(token: string) {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const sessionId = new Bun.CryptoHasher('sha256').update(token).digest('hex');
|
||||
const [result] = await db
|
||||
.select({
|
||||
// Adjust user table here to tweak returned data
|
||||
user: { id: table.user.id, username: table.user.username },
|
||||
user: { id: table.user.id, email: table.user.email, firstName: table.user.firstName, lastName: table.user.lastName, appleId: table.user.appleId },
|
||||
session: table.session
|
||||
})
|
||||
.from(table.session)
|
||||
@@ -79,3 +76,83 @@ export function deleteSessionTokenCookie(event: RequestEvent) {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await Bun.password.hash(password, {
|
||||
algorithm: 'argon2id',
|
||||
memoryCost: 4,
|
||||
timeCost: 3
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
try {
|
||||
return await Bun.password.verify(password, hash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(anonymousId: string, email: string, passwordHash: string, firstName?: string, lastName?: string) {
|
||||
const user: table.User = {
|
||||
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
||||
email,
|
||||
passwordHash,
|
||||
appleId: null,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
isPrivate: false
|
||||
};
|
||||
await db.insert(table.user).values(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export async function getUserByAppleId(appleId: string) {
|
||||
const [user] = await db.select().from(table.user).where(eq(table.user.appleId, appleId));
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
|
||||
if (!anonymousId || anonymousId === userId) return;
|
||||
|
||||
try {
|
||||
const { dailyCompletions } = await import('$lib/server/db/schema');
|
||||
|
||||
const anonCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||
|
||||
const userCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, userId));
|
||||
|
||||
const userDates = new Set(userCompletions.map((c) => c.date));
|
||||
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const completion of anonCompletions) {
|
||||
if (!userDates.has(completion.date)) {
|
||||
await db
|
||||
.update(dailyCompletions)
|
||||
.set({ anonymousId: userId })
|
||||
.where(eq(dailyCompletions.id, completion.id));
|
||||
migrated++;
|
||||
} else {
|
||||
await db.delete(dailyCompletions).where(eq(dailyCompletions.id, completion.id));
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`);
|
||||
} catch (error) {
|
||||
console.error('Error migrating anonymous stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
33
src/lib/server/daily-verse.ts
Normal file
33
src/lib/server/daily-verse.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses } from '$lib/server/db/schema';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
|
||||
export async function getVerseForDate(dateStr: string): Promise<DailyVerse> {
|
||||
// Validate date format (YYYY-MM-DD)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
throw new Error('Invalid date format');
|
||||
}
|
||||
|
||||
// If there's an existing verse for this date, return it
|
||||
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// Otherwise get a new random verse for this date
|
||||
const apiVerse = await fetchRandomVerse();
|
||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
||||
|
||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||
id: Bun.randomUUIDv7(),
|
||||
date: dateStr,
|
||||
bookId: apiVerse.bookId,
|
||||
verseText: apiVerse.verseText,
|
||||
reference: apiVerse.reference,
|
||||
};
|
||||
|
||||
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
||||
return inserted;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as schema from './schema';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
if (!Bun.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const client = new Database(env.DATABASE_URL);
|
||||
const client = new Database(Bun.env.DATABASE_URL);
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
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(),
|
||||
firstName: text('first_name'),
|
||||
lastName: text('last_name'),
|
||||
email: text('email').unique(),
|
||||
passwordHash: text('password_hash'),
|
||||
appleId: text('apple_id').unique(),
|
||||
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
|
||||
});
|
||||
|
||||
export const session = sqliteTable('session', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -30,11 +36,14 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
||||
anonymousId: text('anonymous_id').notNull(),
|
||||
date: text('date').notNull(),
|
||||
guessCount: integer('guess_count').notNull(),
|
||||
guesses: text('guesses'), // nullable; only stored for logged-in users
|
||||
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => ({
|
||||
uniqueCompletion: unique().on(table.anonymousId, table.date),
|
||||
dateIndex: index('date_idx').on(table.date),
|
||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
||||
}));
|
||||
}, (table) => [
|
||||
index('anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||
index('date_idx').on(table.date),
|
||||
index('date_guess_idx').on(table.date, table.guessCount),
|
||||
// Ensures schema matches the database migration and prevents duplicate submissions
|
||||
unique('daily_completions_anonymous_id_date_unique').on(table.anonymousId, table.date),
|
||||
]);
|
||||
|
||||
export type DailyCompletion = typeof dailyCompletions.$inferSelect;
|
||||
|
||||
9
src/lib/server/db/test.ts
Normal file
9
src/lib/server/db/test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as schema from './schema';
|
||||
|
||||
if (!Bun.env.TEST_DATABASE_URL) throw new Error('TEST_DATABASE_URL is not set');
|
||||
|
||||
const testClient = new Database(Bun.env.TEST_DATABASE_URL);
|
||||
|
||||
export const testDb = drizzle(testClient, { schema });
|
||||
186
src/lib/stores/game-persistence.svelte.ts
Normal file
186
src/lib/stores/game-persistence.svelte.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
|
||||
|
||||
// Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet.
|
||||
// Used to attribute stats to a player who hasn't signed in.
|
||||
function getOrCreateAnonymousId(): string {
|
||||
if (!browser) return "";
|
||||
const key = "bibdle-anonymous-id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// Reactive store that keeps in-memory game state in sync with localStorage.
|
||||
// Accepts getter functions (rather than plain values) so Svelte's reactivity
|
||||
// system can track dependencies and re-run effects when they change.
|
||||
type AuthUser = {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export function createGamePersistence(
|
||||
getDate: () => string,
|
||||
getReference: () => string,
|
||||
getCorrectBookId: () => string,
|
||||
getUser: () => AuthUser | null | undefined,
|
||||
) {
|
||||
let guesses = $state<Guess[]>([]);
|
||||
let anonymousId = $state("");
|
||||
let statsSubmitted = $state(false);
|
||||
let chapterGuessCompleted = $state(false);
|
||||
let chapterCorrect = $state(false);
|
||||
|
||||
// On mount (and if the user logs in/out), resolve the player's identity and
|
||||
// restore per-day flags from localStorage.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const user = getUser();
|
||||
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
||||
if (user) {
|
||||
anonymousId = user.id;
|
||||
} else {
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
}
|
||||
|
||||
// Tell analytics which player this is so events are grouped correctly.
|
||||
if ((window as any).umami) {
|
||||
(window as any).umami.identify(anonymousId);
|
||||
}
|
||||
if (user) {
|
||||
const nameParts = [user.firstName, user.lastName].filter(Boolean);
|
||||
(window as any).rybbit?.identify(user.id, {
|
||||
...(nameParts.length ? { name: nameParts.join(' ') } : {}),
|
||||
...(user.email ? { email: user.email } : {}),
|
||||
});
|
||||
} else {
|
||||
(window as any).rybbit?.identify(anonymousId);
|
||||
}
|
||||
|
||||
const date = getDate();
|
||||
const reference = getReference();
|
||||
|
||||
// Restore whether today's completion was already submitted to the server.
|
||||
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
|
||||
|
||||
// Restore the chapter bonus guess result. The stored value includes the
|
||||
// chapter the player selected, so we can re-derive whether it was correct.
|
||||
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
||||
if (chapterGuessCompleted) {
|
||||
const saved = localStorage.getItem(chapterGuessKey);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match = reference.match(/\s(\d+):/);
|
||||
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||
chapterCorrect = data.selectedChapter === correctChapter;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// On mount (and if the date or correct answer changes), load today's guesses
|
||||
// from localStorage and reconstruct them as typed Guess objects by re-evaluating
|
||||
// each stored book ID against the correct answer.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const date = getDate();
|
||||
const correctBookId = getCorrectBookId();
|
||||
const key = `bibdle-guesses-${date}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (!saved) {
|
||||
guesses = [];
|
||||
return;
|
||||
}
|
||||
|
||||
let savedIds: string[] = JSON.parse(saved);
|
||||
savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case
|
||||
guesses = savedIds
|
||||
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||
.filter((g): g is Guess => g !== null);
|
||||
});
|
||||
|
||||
// Persist guesses to localStorage whenever they change. Only the book IDs are
|
||||
// stored — the full Guess shape is re-derived on load (see effect above).
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const date = getDate();
|
||||
localStorage.setItem(
|
||||
`bibdle-guesses-${date}`,
|
||||
JSON.stringify(guesses.map((g) => g.book.id)),
|
||||
);
|
||||
});
|
||||
|
||||
// Called after stats are successfully submitted to the server so that
|
||||
// returning to the page doesn't trigger a duplicate submission.
|
||||
function markStatsSubmitted() {
|
||||
if (!browser) return;
|
||||
statsSubmitted = true;
|
||||
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
|
||||
}
|
||||
|
||||
// Marks the win as tracked for analytics. Returns true the first time (new
|
||||
// win), false on subsequent calls so the analytics event fires exactly once.
|
||||
function markWinTracked() {
|
||||
if (!browser) return;
|
||||
const key = `bibdle-win-tracked-${getDate()}`;
|
||||
if (localStorage.getItem(key) === "true") return false;
|
||||
localStorage.setItem(key, "true");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns true if the win has already been tracked in a previous render/session.
|
||||
// Used to skip the animation delay when returning to an already-won game.
|
||||
function isWinAlreadyTracked(): boolean {
|
||||
if (!browser) return false;
|
||||
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
|
||||
}
|
||||
|
||||
// Overwrites local state with the server's authoritative guess record.
|
||||
// Called when a logged-in user opens the game on a new device so their
|
||||
// progress from another device is restored.
|
||||
function hydrateFromServer(guessIds: string[]) {
|
||||
if (!browser) return;
|
||||
const correctBookId = getCorrectBookId();
|
||||
const date = getDate();
|
||||
guesses = guessIds
|
||||
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||
.filter((g): g is Guess => g !== null);
|
||||
}
|
||||
|
||||
// Called by the WinScreen after the player submits their chapter bonus guess.
|
||||
// Reads the result written to localStorage by WinScreen and updates reactive state.
|
||||
function onChapterGuessCompleted() {
|
||||
if (!browser) return;
|
||||
chapterGuessCompleted = true;
|
||||
const reference = getReference();
|
||||
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||
const saved = localStorage.getItem(chapterGuessKey);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match = reference.match(/\s(\d+):/);
|
||||
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||
chapterCorrect = data.selectedChapter === correctChapter;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get guesses() { return guesses; },
|
||||
set guesses(v: Guess[]) { guesses = v; },
|
||||
get anonymousId() { return anonymousId; },
|
||||
get statsSubmitted() { return statsSubmitted; },
|
||||
get chapterGuessCompleted() { return chapterGuessCompleted; },
|
||||
get chapterCorrect() { return chapterCorrect; },
|
||||
markStatsSubmitted,
|
||||
markWinTracked,
|
||||
isWinAlreadyTracked,
|
||||
onChapterGuessCompleted,
|
||||
hydrateFromServer,
|
||||
};
|
||||
}
|
||||
@@ -17,75 +17,73 @@ export interface BibleBook {
|
||||
testament: Testament;
|
||||
section: BibleSection;
|
||||
order: number;
|
||||
url: string;
|
||||
popularity: number;
|
||||
}
|
||||
|
||||
export const bibleBooks: BibleBook[] = [
|
||||
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1, url: 'https://bible-api.com/data/web/GEN', popularity: 8 },
|
||||
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2, url: 'https://bible-api.com/data/web/EXO', popularity: 3 },
|
||||
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3, url: 'https://bible-api.com/data/web/LEV', popularity: 2 },
|
||||
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4, url: 'https://bible-api.com/data/web/NUM', popularity: 2 },
|
||||
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5, url: 'https://bible-api.com/data/web/DEU', popularity: 2 },
|
||||
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6, url: 'https://bible-api.com/data/web/JOS', popularity: 2 },
|
||||
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7, url: 'https://bible-api.com/data/web/JDG', popularity: 2 },
|
||||
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8, url: 'https://bible-api.com/data/web/RUT', popularity: 2 },
|
||||
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9, url: 'https://bible-api.com/data/web/1SA', popularity: 1 },
|
||||
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10, url: 'https://bible-api.com/data/web/2SA', popularity: 0 },
|
||||
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11, url: 'https://bible-api.com/data/web/1KI', popularity: 1 },
|
||||
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12, url: 'https://bible-api.com/data/web/2KI', popularity: 0 },
|
||||
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13, url: 'https://bible-api.com/data/web/1CH', popularity: 1 },
|
||||
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14, url: 'https://bible-api.com/data/web/2CH', popularity: 0 },
|
||||
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15, url: 'https://bible-api.com/data/web/EZR', popularity: 1 },
|
||||
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16, url: 'https://bible-api.com/data/web/NEH', popularity: 1 },
|
||||
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17, url: 'https://bible-api.com/data/web/EST', popularity: 1 },
|
||||
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18, url: 'https://bible-api.com/data/web/JOB', popularity: 2 },
|
||||
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19, url: 'https://bible-api.com/data/web/PSA', popularity: 7 },
|
||||
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20, url: 'https://bible-api.com/data/web/PRO', popularity: 7 },
|
||||
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21, url: 'https://bible-api.com/data/web/ECC', popularity: 2 },
|
||||
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22, url: 'https://bible-api.com/data/web/SNG', popularity: 2 },
|
||||
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23, url: 'https://bible-api.com/data/web/ISA', popularity: 2 },
|
||||
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24, url: 'https://bible-api.com/data/web/JER', popularity: 2 },
|
||||
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25, url: 'https://bible-api.com/data/web/LAM', popularity: 2 },
|
||||
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26, url: 'https://bible-api.com/data/web/EZK', popularity: 2 },
|
||||
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27, url: 'https://bible-api.com/data/web/DAN', popularity: 2 },
|
||||
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28, url: 'https://bible-api.com/data/web/HOS', popularity: 2 },
|
||||
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29, url: 'https://bible-api.com/data/web/JOL', popularity: 2 },
|
||||
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30, url: 'https://bible-api.com/data/web/AMO', popularity: 2 },
|
||||
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31, url: 'https://bible-api.com/data/web/OBA', popularity: 2 },
|
||||
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32, url: 'https://bible-api.com/data/web/JON', popularity: 2 },
|
||||
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33, url: 'https://bible-api.com/data/web/MIC', popularity: 2 },
|
||||
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34, url: 'https://bible-api.com/data/web/NAM', popularity: 2 },
|
||||
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35, url: 'https://bible-api.com/data/web/HAB', popularity: 2 },
|
||||
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36, url: 'https://bible-api.com/data/web/ZEP', popularity: 2 },
|
||||
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37, url: 'https://bible-api.com/data/web/HAG', popularity: 2 },
|
||||
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38, url: 'https://bible-api.com/data/web/ZEC', popularity: 2 },
|
||||
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39, url: 'https://bible-api.com/data/web/MAL', popularity: 2 },
|
||||
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40, url: 'https://bible-api.com/data/web/MAT', popularity: 8 },
|
||||
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41, url: 'https://bible-api.com/data/web/MRK', popularity: 8 },
|
||||
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42, url: 'https://bible-api.com/data/web/LUK', popularity: 8 },
|
||||
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43, url: 'https://bible-api.com/data/web/JHN', popularity: 8 },
|
||||
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44, url: 'https://bible-api.com/data/web/ACT', popularity: 2 },
|
||||
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45, url: 'https://bible-api.com/data/web/ROM', popularity: 6 },
|
||||
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46, url: 'https://bible-api.com/data/web/1CO', popularity: 5 },
|
||||
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47, url: 'https://bible-api.com/data/web/2CO', popularity: 5 },
|
||||
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48, url: 'https://bible-api.com/data/web/GAL', popularity: 5 },
|
||||
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49, url: 'https://bible-api.com/data/web/EPH', popularity: 5 },
|
||||
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50, url: 'https://bible-api.com/data/web/PHP', popularity: 5 },
|
||||
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51, url: 'https://bible-api.com/data/web/COL', popularity: 5 },
|
||||
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52, url: 'https://bible-api.com/data/web/1TH', popularity: 5 },
|
||||
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53, url: 'https://bible-api.com/data/web/2TH', popularity: 5 },
|
||||
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54, url: 'https://bible-api.com/data/web/1TI', popularity: 5 },
|
||||
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55, url: 'https://bible-api.com/data/web/2TI', popularity: 5 },
|
||||
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56, url: 'https://bible-api.com/data/web/TIT', popularity: 5 },
|
||||
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57, url: 'https://bible-api.com/data/web/PHM', popularity: 5 },
|
||||
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58, url: 'https://bible-api.com/data/web/HEB', popularity: 4 },
|
||||
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59, url: 'https://bible-api.com/data/web/JAS', popularity: 4 },
|
||||
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60, url: 'https://bible-api.com/data/web/1PE', popularity: 4 },
|
||||
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61, url: 'https://bible-api.com/data/web/2PE', popularity: 4 },
|
||||
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62, url: 'https://bible-api.com/data/web/1JN', popularity: 4 },
|
||||
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63, url: 'https://bible-api.com/data/web/2JN', popularity: 4 },
|
||||
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64, url: 'https://bible-api.com/data/web/3JN', popularity: 4 },
|
||||
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65, url: 'https://bible-api.com/data/web/JUD', popularity: 4 },
|
||||
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66, url: 'https://bible-api.com/data/web/REV', popularity: 2 }
|
||||
{ id: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1 },
|
||||
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2 },
|
||||
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3 },
|
||||
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4 },
|
||||
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5 },
|
||||
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6 },
|
||||
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7 },
|
||||
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8 },
|
||||
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9 },
|
||||
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10 },
|
||||
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11 },
|
||||
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12 },
|
||||
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13 },
|
||||
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14 },
|
||||
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15 },
|
||||
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16 },
|
||||
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17 },
|
||||
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18 },
|
||||
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19 },
|
||||
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20 },
|
||||
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21 },
|
||||
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22 },
|
||||
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23 },
|
||||
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24 },
|
||||
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25 },
|
||||
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26 },
|
||||
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27 },
|
||||
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28 },
|
||||
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29 },
|
||||
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30 },
|
||||
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31 },
|
||||
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32 },
|
||||
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33 },
|
||||
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34 },
|
||||
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35 },
|
||||
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36 },
|
||||
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37 },
|
||||
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38 },
|
||||
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39 },
|
||||
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40 },
|
||||
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41 },
|
||||
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42 },
|
||||
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43 },
|
||||
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44 },
|
||||
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45 },
|
||||
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46 },
|
||||
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47 },
|
||||
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48 },
|
||||
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49 },
|
||||
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50 },
|
||||
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51 },
|
||||
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52 },
|
||||
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53 },
|
||||
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54 },
|
||||
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55 },
|
||||
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56 },
|
||||
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57 },
|
||||
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58 },
|
||||
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59 },
|
||||
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60 },
|
||||
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61 },
|
||||
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62 },
|
||||
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63 },
|
||||
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64 },
|
||||
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65 },
|
||||
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66 }
|
||||
];
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { bibleBooks, type BibleBook } from '$lib/types/bible';
|
||||
|
||||
export interface Guess {
|
||||
book: BibleBook;
|
||||
testamentMatch: boolean;
|
||||
sectionMatch: boolean;
|
||||
adjacent: boolean;
|
||||
firstLetterMatch: boolean;
|
||||
}
|
||||
|
||||
export function getBookById(id: string): BibleBook | undefined {
|
||||
return bibleBooks.find((b) => b.id === id);
|
||||
}
|
||||
@@ -10,7 +18,47 @@ export function isAdjacent(id1: string, id2: string): boolean {
|
||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
||||
}
|
||||
|
||||
export function getGrade(numGuesses: number, popularity: number): string {
|
||||
export function getFirstLetter(bookName: string): string {
|
||||
const match = bookName.match(/[a-zA-Z]/);
|
||||
return match ? match[0] : bookName[0];
|
||||
}
|
||||
|
||||
export function evaluateGuess(guessBookId: string, correctBookId: string): Guess | null {
|
||||
const book = getBookById(guessBookId);
|
||||
const correctBook = getBookById(correctBookId);
|
||||
if (!book || !correctBook) return null;
|
||||
|
||||
const testamentMatch = book.testament === correctBook.testament;
|
||||
const sectionMatch = book.section === correctBook.section;
|
||||
const adjacent = isAdjacent(guessBookId, correctBookId);
|
||||
|
||||
// Special case: if correct book is in the Epistles + starts with "1",
|
||||
// any guess starting with "1" counts as first letter match
|
||||
const correctIsEpistlesWithNumber =
|
||||
(correctBook.section === "Pauline Epistles" ||
|
||||
correctBook.section === "General Epistles") &&
|
||||
correctBook.name[0] === "1";
|
||||
const guessIsEpistlesWithNumber =
|
||||
(book.section === "Pauline Epistles" ||
|
||||
book.section === "General Epistles") &&
|
||||
book.name[0] === "1";
|
||||
|
||||
const firstLetterMatch =
|
||||
correctIsEpistlesWithNumber && guessIsEpistlesWithNumber
|
||||
? true
|
||||
: getFirstLetter(book.name).toUpperCase() ===
|
||||
getFirstLetter(correctBook.name).toUpperCase();
|
||||
|
||||
return {
|
||||
book,
|
||||
testamentMatch,
|
||||
sectionMatch,
|
||||
adjacent,
|
||||
firstLetterMatch,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGrade(numGuesses: number): string {
|
||||
if (numGuesses === 1) return "S+";
|
||||
if (numGuesses === 2) return "A+";
|
||||
if (numGuesses === 3) return "A";
|
||||
@@ -31,7 +79,7 @@ export function getNextGradeMessage(numGuesses: number): string {
|
||||
}
|
||||
|
||||
export function toOrdinal(n: number): string {
|
||||
if (n >= 11 && n <= 13) {
|
||||
if (n % 100 >= 11 && n % 100 <= 13) {
|
||||
return `${n}th`;
|
||||
}
|
||||
const mod = n % 10;
|
||||
@@ -49,4 +97,4 @@ export function generateUUID(): string {
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
98
src/lib/utils/share.ts
Normal file
98
src/lib/utils/share.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Guess } from './game';
|
||||
|
||||
export function getVerseSnippet(verseText: string): string {
|
||||
const words = verseText.trim().split(/\s+/);
|
||||
const slice = words.slice(0, 25);
|
||||
const text = slice.join(' ');
|
||||
|
||||
// Returns character index immediately after the Nth word (1-indexed)
|
||||
function posAfterWord(n: number): number {
|
||||
let pos = 0;
|
||||
for (let w = 0; w < Math.min(n, slice.length); w++) {
|
||||
if (w > 0) pos++; // space between words
|
||||
pos += slice[w].length;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
const start = posAfterWord(9);
|
||||
const end = posAfterWord(25);
|
||||
|
||||
// Find first punctuation mark between words 10 and 25
|
||||
const range = text.substring(start, end);
|
||||
const match = range.match(/[,;:.!?—–-]/);
|
||||
|
||||
function withClosedQuotes(snippet: string): string {
|
||||
const opens = (snippet.match(/\u201C/g) ?? []).length;
|
||||
const closes = (snippet.match(/\u201D/g) ?? []).length;
|
||||
const closeQuote = opens > closes ? '\u201D' : '';
|
||||
return `\u201C${snippet}...${closeQuote}\u201D`;
|
||||
}
|
||||
|
||||
if (match && match.index !== undefined) {
|
||||
const cutPos = start + match.index;
|
||||
return withClosedQuotes(text.substring(0, cutPos).trimEnd());
|
||||
}
|
||||
|
||||
return withClosedQuotes(text);
|
||||
}
|
||||
|
||||
export function generateShareText(params: {
|
||||
guesses: Guess[];
|
||||
correctBookId: string;
|
||||
dailyVerseDate: string;
|
||||
chapterCorrect: boolean;
|
||||
isLoggedIn: boolean;
|
||||
streak?: number;
|
||||
origin: string;
|
||||
verseText: string;
|
||||
}): string {
|
||||
const { guesses, correctBookId, dailyVerseDate, chapterCorrect, isLoggedIn, streak, origin, verseText } = params;
|
||||
|
||||
const emojis = guesses
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((guess) => {
|
||||
if (guess.book.id === correctBookId) return "✅";
|
||||
if (guess.adjacent) return "‼️";
|
||||
if (guess.sectionMatch) return "🟩";
|
||||
if (guess.testamentMatch) return "🟧";
|
||||
return "🟥";
|
||||
})
|
||||
.join("");
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const formattedDate = dateFormatter.format(
|
||||
new Date(`${dailyVerseDate}T00:00:00`),
|
||||
);
|
||||
|
||||
const bookEmoji = isLoggedIn ? "📜" : "📖";
|
||||
|
||||
const guessWord = guesses.length === 1 ? "guess" : "guesses";
|
||||
const streakPart = streak !== undefined && streak > 1 ? ` ${streak} days 🔥` : "";
|
||||
const chapterStar = guesses.length === 1 && chapterCorrect ? " ⭐" : "";
|
||||
|
||||
const lines = [
|
||||
`${bookEmoji} Bibdle | ${formattedDate} ${bookEmoji}`,
|
||||
`${guesses.length} ${guessWord}${streakPart ? `,${streakPart}` : ""}`,
|
||||
`${emojis}${chapterStar}`
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function shareResult(shareText: string): Promise<void> {
|
||||
if ("share" in navigator) {
|
||||
await (navigator as any).share({ text: shareText });
|
||||
} else {
|
||||
await (navigator as any).clipboard.writeText(shareText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyToClipboard(shareText: string): Promise<void> {
|
||||
await (navigator as any).clipboard.writeText(shareText);
|
||||
}
|
||||
68
src/lib/utils/stats-client.ts
Normal file
68
src/lib/utils/stats-client.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export interface StatsData {
|
||||
solveRank: number;
|
||||
guessRank: number;
|
||||
totalSolves: number;
|
||||
averageGuesses: number;
|
||||
tiedCount: number;
|
||||
percentile: number;
|
||||
guesses?: string[]; // Present when fetching an existing completion (cross-device sync)
|
||||
}
|
||||
|
||||
export async function submitCompletion(params: {
|
||||
anonymousId: string;
|
||||
date: string;
|
||||
guessCount: number;
|
||||
guesses: string[];
|
||||
}): Promise<StatsData | null> {
|
||||
try {
|
||||
const response = await fetch("/api/submit-completion", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.stats) {
|
||||
return result.stats;
|
||||
}
|
||||
|
||||
if (response.status === 409) {
|
||||
// Already submitted from another device — fetch existing stats
|
||||
return fetchExistingStats({ anonymousId: params.anonymousId, date: params.date });
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error("Stats server error:", result.error);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("Stats submission failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchExistingStats(params: {
|
||||
anonymousId: string;
|
||||
date: string;
|
||||
}): Promise<StatsData | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/stats?anonymousId=${params.anonymousId}&date=${params.date}`,
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.stats) {
|
||||
return result.stats;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error("Stats server error:", result.error);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error("Stats fetch failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,21 @@ export interface UserStats {
|
||||
guessCount: number;
|
||||
grade: string;
|
||||
}>;
|
||||
worstDay: {
|
||||
date: string;
|
||||
guessCount: number;
|
||||
} | null;
|
||||
bestBook: {
|
||||
bookId: string;
|
||||
avgGuesses: number;
|
||||
count: number;
|
||||
} | null;
|
||||
mostSeenBook: {
|
||||
bookId: string;
|
||||
count: number;
|
||||
} | null;
|
||||
totalBooksSeenOT: number;
|
||||
totalBooksSeenNT: number;
|
||||
}
|
||||
|
||||
export function getGradeColor(grade: string): string {
|
||||
|
||||
15
src/lib/utils/streak.ts
Normal file
15
src/lib/utils/streak.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export async function fetchStreak(anonymousId: string, localDate: string): Promise<number> {
|
||||
const params = new URLSearchParams({ anonymousId, localDate });
|
||||
const res = await fetch(`/api/streak?${params}`);
|
||||
if (!res.ok) return 0;
|
||||
const data = await res.json();
|
||||
return typeof data.streak === 'number' ? data.streak : 0;
|
||||
}
|
||||
|
||||
export async function fetchStreakPercentile(streak: number, localDate: string): Promise<number | null> {
|
||||
const params = new URLSearchParams({ streak: String(streak), localDate });
|
||||
const res = await fetch(`/api/streak-percentile?${params}`);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return typeof data.percentile === 'number' ? data.percentile : null;
|
||||
}
|
||||
@@ -1,31 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.ico";
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
import "./layout.css";
|
||||
import favicon from "$lib/assets/favicon.ico";
|
||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
onMount(() => {
|
||||
// Inject analytics script
|
||||
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();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<!-- <script
|
||||
defer
|
||||
src="https://umami.snail.city/script.js"
|
||||
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
||||
data-domains="bibdle.com,www.bibdle.com"
|
||||
></script> -->
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||
<meta name="description" content="A daily Bible game" />
|
||||
</svelte:head>
|
||||
{@render children()}
|
||||
|
||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 dark:md:from-gray-900 dark:md:to-slate-950">
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 dark:text-gray-300 drop-shadow-2xl tracking-widest p-4 pt-12 animate-fade-in-up"
|
||||
>
|
||||
<TitleAnimation />
|
||||
<div class="font-normal"></div>
|
||||
</h1>
|
||||
<div class="hidden"><ThemeToggle /></div>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,14 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, sql, asc } from 'drizzle-orm';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||
import { getBookById } from '$lib/server/bible';
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
async function getTodayVerse(): Promise<DailyVerse> {
|
||||
// Get the current date (server-side)
|
||||
const dateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
|
||||
|
||||
// If there's an existing verse for the current date, return it
|
||||
const existing = await db.select().from(dailyVerses).where(eq(dailyVerses.date, dateStr)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// Otherwise get a new random verse
|
||||
const apiVerse = await fetchRandomVerse();
|
||||
const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
|
||||
|
||||
const newVerse: Omit<DailyVerse, 'createdAt'> = {
|
||||
id: crypto.randomUUID(),
|
||||
date: dateStr,
|
||||
bookId: apiVerse.bookId,
|
||||
verseText: apiVerse.verseText,
|
||||
reference: apiVerse.reference,
|
||||
};
|
||||
|
||||
const [inserted] = await db.insert(dailyVerses).values({ ...newVerse, createdAt }).returning();
|
||||
return inserted;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const dailyVerse = await getTodayVerse();
|
||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
dailyVerse,
|
||||
correctBookId: dailyVerse.bookId,
|
||||
correctBook
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
};
|
||||
|
||||
@@ -91,13 +58,20 @@ export const actions: Actions = {
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
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
|
||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||
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 {
|
||||
success: true,
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,52 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
||||
|
||||
import type { PageProps } from "./$types";
|
||||
import { browser } from "$app/environment";
|
||||
import { enhance } from "$app/forms";
|
||||
|
||||
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 TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||
import DevButtons from "$lib/components/DevButtons.svelte";
|
||||
import { getGrade } from "$lib/utils/game";
|
||||
|
||||
interface Guess {
|
||||
book: BibleBook;
|
||||
testamentMatch: boolean;
|
||||
sectionMatch: boolean;
|
||||
adjacent: boolean;
|
||||
firstLetterMatch: boolean;
|
||||
}
|
||||
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 {
|
||||
generateShareText,
|
||||
shareResult,
|
||||
copyToClipboard as clipboardCopy,
|
||||
} from "$lib/utils/share";
|
||||
import { fetchStreak, fetchStreakPercentile } from "$lib/utils/streak";
|
||||
import {
|
||||
submitCompletion,
|
||||
fetchExistingStats,
|
||||
type StatsData,
|
||||
} from "$lib/utils/stats-client";
|
||||
import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let dailyVerse = $derived(data.dailyVerse);
|
||||
let correctBookId = $derived(data.correctBookId);
|
||||
|
||||
let guesses = $state<Guess[]>([]);
|
||||
|
||||
let searchQuery = $state("");
|
||||
|
||||
let copied = $state(false);
|
||||
let isDev = $state(false);
|
||||
let chapterGuessCompleted = $state(false);
|
||||
let chapterCorrect = $state(false);
|
||||
|
||||
let anonymousId = $state("");
|
||||
let statsSubmitted = $state(false);
|
||||
let statsData = $state<{
|
||||
solveRank: number;
|
||||
guessRank: number;
|
||||
totalSolves: number;
|
||||
averageGuesses: number;
|
||||
tiedCount: number;
|
||||
percentile: number;
|
||||
} | null>(null);
|
||||
|
||||
let guessedIds = $derived(new Set(guesses.map((g) => g.book.id)));
|
||||
let correctBook = $derived(data.correctBook);
|
||||
let user = $derived(data.user);
|
||||
let session = $derived(data.session);
|
||||
|
||||
const currentDate = $derived(
|
||||
new Date().toLocaleDateString("en-US", {
|
||||
@@ -57,351 +45,222 @@
|
||||
}),
|
||||
);
|
||||
|
||||
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
|
||||
let grade = $derived(
|
||||
isWon
|
||||
? guesses.length === 1 && chapterCorrect
|
||||
? "S++"
|
||||
: getGrade(
|
||||
guesses.length,
|
||||
getBookById(correctBookId)?.popularity ?? 0,
|
||||
)
|
||||
: "",
|
||||
let searchQuery = $state("");
|
||||
let copied = $state(false);
|
||||
let isDev = $state(false);
|
||||
let authModalOpen = $state(false);
|
||||
let showWinScreen = $state(false);
|
||||
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,
|
||||
() => dailyVerse.reference,
|
||||
() => correctBookId,
|
||||
() => user,
|
||||
);
|
||||
|
||||
let guessedIds = $derived(
|
||||
new SvelteSet(persistence.guesses.map((g) => g.book.id)),
|
||||
);
|
||||
|
||||
let isWon = $derived(
|
||||
persistence.guesses.some((g) => g.book.id === correctBookId),
|
||||
);
|
||||
let blurChapter = $derived(
|
||||
isWon && guesses.length === 1 && !chapterGuessCompleted,
|
||||
isWon &&
|
||||
persistence.guesses.length === 1 &&
|
||||
!persistence.chapterGuessCompleted,
|
||||
);
|
||||
|
||||
function getBookById(id: string): BibleBook | undefined {
|
||||
return bibleBooks.find((b) => b.id === id);
|
||||
}
|
||||
async function submitGuess(bookId: string) {
|
||||
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
|
||||
|
||||
function isAdjacent(id1: string, id2: string): boolean {
|
||||
const b1 = getBookById(id1);
|
||||
const b2 = getBookById(id2);
|
||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
||||
}
|
||||
const guess = evaluateGuess(bookId, correctBookId);
|
||||
if (!guess) return;
|
||||
|
||||
function submitGuess(bookId: string) {
|
||||
if (guesses.some((g) => g.book.id === bookId)) return;
|
||||
|
||||
const book = getBookById(bookId);
|
||||
if (!book) return;
|
||||
|
||||
const correctBook = getBookById(correctBookId);
|
||||
if (!correctBook) return;
|
||||
|
||||
const testamentMatch = book.testament === correctBook.testament;
|
||||
const sectionMatch = book.section === correctBook.section;
|
||||
const adjacent = isAdjacent(bookId, correctBookId);
|
||||
|
||||
// Special case: if correct book is in the Epistles + starts with "1",
|
||||
// any guess starting with "1" counts as first letter match
|
||||
const correctIsEpistlesWithNumber =
|
||||
correctBook.section === "Pauline Epistles" &&
|
||||
correctBook.name[0] === "1";
|
||||
const guessStartsWithNumber = book.name[0] === "1";
|
||||
|
||||
const firstLetterMatch =
|
||||
correctIsEpistlesWithNumber && guessStartsWithNumber
|
||||
? true
|
||||
: book.name[0].toUpperCase() ===
|
||||
correctBook.name[0].toUpperCase();
|
||||
|
||||
console.log(
|
||||
`Guess: ${book.name} (order ${book.order}), Correct: ${correctBook.name} (order ${correctBook.order}), Adjacent: ${adjacent}`,
|
||||
);
|
||||
|
||||
if (guesses.length === 0) {
|
||||
if (persistence.guesses.length === 0) {
|
||||
const key = `bibdle-first-guess-${dailyVerse.date}`;
|
||||
if (
|
||||
localStorage.getItem(key) !== "true" &&
|
||||
browser &&
|
||||
localStorage.getItem(key) !== "true" &&
|
||||
(window as any).umami
|
||||
) {
|
||||
(window as any).umami.track("First guess");
|
||||
(window as any).rybbit?.event("First guess");
|
||||
localStorage.setItem(key, "true");
|
||||
}
|
||||
}
|
||||
|
||||
guesses = [
|
||||
{
|
||||
book,
|
||||
testamentMatch,
|
||||
sectionMatch,
|
||||
adjacent,
|
||||
firstLetterMatch,
|
||||
},
|
||||
...guesses,
|
||||
];
|
||||
|
||||
persistence.guesses = [guess, ...persistence.guesses];
|
||||
searchQuery = "";
|
||||
}
|
||||
|
||||
function generateUUID(): string {
|
||||
// Try native randomUUID if available
|
||||
if (typeof window.crypto.randomUUID === "function") {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback UUID v4 generator for older browsers
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r =
|
||||
window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function getOrCreateAnonymousId(): string {
|
||||
if (!browser) return "";
|
||||
const key = "bibdle-anonymous-id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// Initialize anonymous ID
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
if ((window as any).umami) {
|
||||
(window as any).umami.identify(anonymousId);
|
||||
}
|
||||
const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
|
||||
statsSubmitted = localStorage.getItem(statsKey) === "true";
|
||||
const chapterGuessKey = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
||||
if (chapterGuessCompleted) {
|
||||
const saved = localStorage.getItem(chapterGuessKey);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match = dailyVerse.reference.match(/\s(\d+):/);
|
||||
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||
chapterCorrect = data.selectedChapter === correctChapter;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
isDev = window.location.host === "localhost:5173";
|
||||
});
|
||||
|
||||
// Load saved guesses
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const key = `bibdle-guesses-${dailyVerse.date}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
let savedIds: string[] = JSON.parse(saved);
|
||||
savedIds = Array.from(new Set(savedIds));
|
||||
guesses = savedIds.map((bookId: string) => {
|
||||
const book = getBookById(bookId)!;
|
||||
const correctBook = getBookById(correctBookId)!;
|
||||
const testamentMatch = book.testament === correctBook.testament;
|
||||
const sectionMatch = book.section === correctBook.section;
|
||||
const adjacent = isAdjacent(bookId, correctBookId);
|
||||
|
||||
// Apply same first letter logic as in submitGuess
|
||||
const correctIsEpistlesWithNumber =
|
||||
correctBook.section === "Pauline Epistles" &&
|
||||
correctBook.name[0] === "1";
|
||||
const guessStartsWithNumber = book.name[0] === "1";
|
||||
|
||||
const firstLetterMatch =
|
||||
correctIsEpistlesWithNumber && guessStartsWithNumber
|
||||
? true
|
||||
: book.name[0].toUpperCase() ===
|
||||
correctBook.name[0].toUpperCase();
|
||||
|
||||
return {
|
||||
book,
|
||||
testamentMatch,
|
||||
sectionMatch,
|
||||
adjacent,
|
||||
firstLetterMatch,
|
||||
};
|
||||
if (
|
||||
guess.book.id === correctBookId &&
|
||||
browser &&
|
||||
persistence.anonymousId
|
||||
) {
|
||||
statsData = await submitCompletion({
|
||||
anonymousId: persistence.anonymousId,
|
||||
date: dailyVerse.date,
|
||||
guessCount: persistence.guesses.length,
|
||||
guesses: persistence.guesses.map((g) => g.book.id),
|
||||
});
|
||||
if (statsData) {
|
||||
persistence.markStatsSubmitted();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload when the user returns to a stale tab on a new calendar day
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const loadedDate = new Date().toLocaleDateString("en-CA");
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.hidden) return;
|
||||
const now = new Date().toLocaleDateString("en-CA");
|
||||
if (now !== loadedDate) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
onVisibilityChange,
|
||||
);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(
|
||||
`bibdle-guesses-${dailyVerse.date}`,
|
||||
JSON.stringify(guesses.map((g) => g.book.id)),
|
||||
);
|
||||
isDev =
|
||||
window.location.host === "localhost:5173" ||
|
||||
window.location.host === "test.bibdle.com";
|
||||
});
|
||||
|
||||
// Auto-submit stats when user wins
|
||||
// Fetch stats on page load if user already won in a previous session (same device)
|
||||
$effect(() => {
|
||||
console.log("Stats effect triggered:", {
|
||||
browser,
|
||||
isWon,
|
||||
anonymousId,
|
||||
statsSubmitted,
|
||||
statsData,
|
||||
if (
|
||||
!browser ||
|
||||
!isWon ||
|
||||
!persistence.anonymousId ||
|
||||
statsData ||
|
||||
!persistence.statsSubmitted
|
||||
)
|
||||
return;
|
||||
fetchExistingStats({
|
||||
anonymousId: persistence.anonymousId,
|
||||
date: dailyVerse.date,
|
||||
}).then((data) => {
|
||||
statsData = data;
|
||||
});
|
||||
|
||||
if (!browser || !isWon || !anonymousId) {
|
||||
console.log("Basic conditions not met");
|
||||
return;
|
||||
}
|
||||
|
||||
if (statsSubmitted && !statsData) {
|
||||
console.log("Fetching existing stats...");
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/submit-completion?anonymousId=${anonymousId}&date=${dailyVerse.date}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
console.log("Stats response:", result);
|
||||
|
||||
if (result.success && result.stats) {
|
||||
console.log("Setting stats data:", result.stats);
|
||||
statsData = result.stats;
|
||||
localStorage.setItem(
|
||||
`bibdle-stats-submitted-${dailyVerse.date}`,
|
||||
"true",
|
||||
);
|
||||
} else if (result.error) {
|
||||
console.error("Server error:", result.error);
|
||||
} else {
|
||||
console.error("Unexpected response format:", result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Stats fetch failed:", err);
|
||||
}
|
||||
})();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Submitting stats...");
|
||||
|
||||
async function submitStats() {
|
||||
try {
|
||||
const payload = {
|
||||
anonymousId,
|
||||
date: dailyVerse.date,
|
||||
guessCount: guesses.length,
|
||||
};
|
||||
|
||||
console.log("Sending POST request with:", payload);
|
||||
|
||||
const response = await fetch("/api/submit-completion", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log("Stats response:", result);
|
||||
|
||||
if (result.success && result.stats) {
|
||||
console.log("Setting stats data:", result.stats);
|
||||
statsData = result.stats;
|
||||
statsSubmitted = true;
|
||||
localStorage.setItem(
|
||||
`bibdle-stats-submitted-${dailyVerse.date}`,
|
||||
"true",
|
||||
);
|
||||
} else if (result.error) {
|
||||
console.error("Server error:", result.error);
|
||||
} else {
|
||||
console.error("Unexpected response format:", result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Stats submission failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
submitStats();
|
||||
});
|
||||
|
||||
// For logged-in users on a new device: restore today's game state from the server.
|
||||
// Runs even when isWon is true so that logging in after completing the game on another
|
||||
// device always replaces local localStorage with the authoritative DB record.
|
||||
let crossDeviceCheckDate = $state<string | null>(null);
|
||||
$effect(() => {
|
||||
if (
|
||||
!browser ||
|
||||
!user ||
|
||||
!dailyVerse?.date ||
|
||||
crossDeviceCheckDate === dailyVerse.date ||
|
||||
!persistence.anonymousId
|
||||
)
|
||||
return;
|
||||
crossDeviceCheckDate = dailyVerse.date;
|
||||
fetchExistingStats({
|
||||
anonymousId: persistence.anonymousId,
|
||||
date: dailyVerse.date,
|
||||
}).then((data) => {
|
||||
if (data?.guesses?.length) {
|
||||
persistence.hydrateFromServer(data.guesses);
|
||||
statsData = data;
|
||||
persistence.markStatsSubmitted();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delay showing win screen until GuessesTable animation completes
|
||||
$effect(() => {
|
||||
if (!isWon) {
|
||||
showWinScreen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (persistence.isWinAlreadyTracked()) {
|
||||
showWinScreen = true;
|
||||
} else {
|
||||
const animationDelay = 1800;
|
||||
const timeoutId = setTimeout(() => {
|
||||
showWinScreen = true;
|
||||
}, animationDelay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
const key = `bibdle-win-tracked-${dailyVerse.date}`;
|
||||
if (localStorage.getItem(key) === "true") return;
|
||||
if ((window as any).umami) {
|
||||
const isNew = persistence.markWinTracked();
|
||||
if (isNew && (window as any).umami) {
|
||||
(window as any).umami.track("Guessed correctly", {
|
||||
totalGuesses: guesses.length,
|
||||
totalGuesses: persistence.guesses.length,
|
||||
});
|
||||
(window as any).rybbit?.event("Guessed correctly", {
|
||||
totalGuesses: persistence.guesses.length,
|
||||
});
|
||||
}
|
||||
localStorage.setItem(key, "true");
|
||||
});
|
||||
|
||||
function generateShareText(): string {
|
||||
const emojis = guesses
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((guess) => {
|
||||
if (guess.book.id === correctBookId) return "✅";
|
||||
if (guess.adjacent) return "‼️";
|
||||
if (guess.sectionMatch) return "🟩";
|
||||
if (guess.testamentMatch) return "🟧";
|
||||
return "🟥";
|
||||
})
|
||||
.join("");
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const formattedDate = dateFormatter.format(
|
||||
new Date(`${dailyVerse.date}T00:00:00`),
|
||||
);
|
||||
const siteUrl = window.location.origin;
|
||||
return [
|
||||
`📖 Bibdle | ${formattedDate} 📖`,
|
||||
`${grade} (${guesses.length} ${guesses.length === 1 ? "guess" : "guesses"})`,
|
||||
`${emojis}${guesses.length === 1 && chapterCorrect ? " ⭐" : ""}`,
|
||||
siteUrl,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function share() {
|
||||
if (!browser) return;
|
||||
|
||||
const shareText = generateShareText();
|
||||
|
||||
try {
|
||||
if ("share" in navigator) {
|
||||
await (navigator as any).share({ text: shareText });
|
||||
} else {
|
||||
await (navigator as any).clipboard.writeText(shareText);
|
||||
// Fetch streak when the player wins
|
||||
$effect(() => {
|
||||
if (!browser || !isWon || !persistence.anonymousId) return;
|
||||
const localDate = new Date().toLocaleDateString("en-CA");
|
||||
fetchStreak(persistence.anonymousId, localDate).then((result) => {
|
||||
streak = result;
|
||||
if (result >= 2) {
|
||||
fetchStreakPercentile(result, localDate).then((p) => {
|
||||
streakPercentile = p;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Share failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!browser) return;
|
||||
|
||||
const shareText = generateShareText();
|
||||
|
||||
try {
|
||||
await (navigator as any).clipboard.writeText(shareText);
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error("Copy to clipboard failed:", err);
|
||||
throw err;
|
||||
}
|
||||
function getShareText(): string {
|
||||
return generateShareText({
|
||||
guesses: persistence.guesses,
|
||||
correctBookId,
|
||||
dailyVerseDate: dailyVerse.date,
|
||||
chapterCorrect: persistence.chapterCorrect,
|
||||
isLoggedIn: !!user,
|
||||
streak,
|
||||
origin: window.location.origin,
|
||||
verseText: dailyVerse.verseText,
|
||||
});
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
@@ -410,7 +269,7 @@
|
||||
if (useClipboard) {
|
||||
copied = true;
|
||||
}
|
||||
share()
|
||||
shareResult(getShareText())
|
||||
.then(() => {
|
||||
if (useClipboard) {
|
||||
setTimeout(() => {
|
||||
@@ -424,81 +283,173 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCopyToClipboard() {
|
||||
if (!browser) return;
|
||||
try {
|
||||
await clipboardCopy(getShareText());
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error("Copy to clipboard failed:", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<!-- <title>Bibdle — A daily bible game{isDev ? " (dev)" : ""}</title> -->
|
||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||
<!-- <meta
|
||||
name="description"
|
||||
content="Guess which book of the Bible a verse comes from."
|
||||
/> -->
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-dvh md:bg-linear-to-br md:from-blue-50 md:to-indigo-200 py-8">
|
||||
<div class="pb-8">
|
||||
<div class="w-full max-w-3xl mx-auto px-4">
|
||||
<h1
|
||||
class="text-3xl md:text-4xl font-bold text-center uppercase text-gray-600 drop-shadow-2xl tracking-widest p-4"
|
||||
>
|
||||
<TitleAnimation />
|
||||
<div class="font-normal"></div>
|
||||
</h1>
|
||||
<div class="text-center mb-8">
|
||||
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
|
||||
<span class="big-text"
|
||||
>{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 class="flex flex-col gap-6">
|
||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||
<div class="animate-fade-in-up animate-delay-200">
|
||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||
</div>
|
||||
|
||||
{#if !isWon}
|
||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
||||
{:else}
|
||||
<WinScreen
|
||||
{grade}
|
||||
{statsData}
|
||||
{correctBookId}
|
||||
{handleShare}
|
||||
{copyToClipboard}
|
||||
bind:copied
|
||||
{statsSubmitted}
|
||||
guessCount={guesses.length}
|
||||
reference={dailyVerse.reference}
|
||||
onChapterGuessCompleted={() => {
|
||||
chapterGuessCompleted = true;
|
||||
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match =
|
||||
dailyVerse.reference.match(/\s(\d+):/);
|
||||
const correctChapter = match
|
||||
? parseInt(match[1], 10)
|
||||
: 1;
|
||||
chapterCorrect =
|
||||
data.selectedChapter === correctChapter;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
<GamePrompt guessCount={persistence.guesses.length} />
|
||||
|
||||
<SearchInput
|
||||
bind:searchQuery
|
||||
{guessedIds}
|
||||
{submitGuess}
|
||||
guessCount={persistence.guesses.length}
|
||||
/>
|
||||
</div>
|
||||
{:else if showWinScreen}
|
||||
<div class="animate-fade-in-up animate-delay-400">
|
||||
<WinScreen
|
||||
{statsData}
|
||||
{correctBookId}
|
||||
{handleShare}
|
||||
copyToClipboard={handleCopyToClipboard}
|
||||
bind:copied
|
||||
statsSubmitted={persistence.statsSubmitted}
|
||||
guessCount={persistence.guesses.length}
|
||||
reference={dailyVerse.reference}
|
||||
onChapterGuessCompleted={persistence.onChapterGuessCompleted}
|
||||
shareText={getShareText()}
|
||||
verseText={dailyVerse.verseText}
|
||||
{streak}
|
||||
{streakPercentile}
|
||||
isLoggedIn={!!user}
|
||||
anonymousId={persistence.anonymousId}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<GuessesTable {guesses} {correctBookId} />
|
||||
<div class="animate-fade-in-up animate-delay-600">
|
||||
<GuessesTable
|
||||
guesses={persistence.guesses}
|
||||
{correctBookId}
|
||||
minimized={guessesMinimized}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isWon}
|
||||
<Credits />
|
||||
<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}
|
||||
</div>
|
||||
{#if isDev}
|
||||
<DevButtons />
|
||||
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
|
||||
<div
|
||||
class="text-xs text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded border dark:border-gray-700"
|
||||
>
|
||||
<div><strong>Debug Info:</strong></div>
|
||||
<div>
|
||||
User: {user
|
||||
? `${user.email} (ID: ${user.id})`
|
||||
: "Not signed in"}
|
||||
</div>
|
||||
<div>
|
||||
Session: {session
|
||||
? `Expires ${session.expiresAt.toLocaleDateString()}`
|
||||
: "No session"}
|
||||
</div>
|
||||
<div>
|
||||
Anonymous ID: {persistence.anonymousId || "Not set"}
|
||||
</div>
|
||||
<div>
|
||||
Client Local Time: {new Date().toLocaleString("en-US", {
|
||||
timeZone:
|
||||
Intl.DateTimeFormat().resolvedOptions()
|
||||
.timeZone,
|
||||
timeZoneName: "short",
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
Client Local Date: {new Date().toLocaleDateString(
|
||||
"en-CA",
|
||||
)}
|
||||
</div>
|
||||
<div>Daily Verse Date: {dailyVerse.date}</div>
|
||||
<div>Streak: {streak}</div>
|
||||
</div>
|
||||
<DevButtons
|
||||
anonymousId={persistence.anonymousId}
|
||||
{user}
|
||||
onSignIn={() => (authModalOpen = true)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if user && session}
|
||||
<div
|
||||
class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center text-xs text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
Signed in as {[user.firstName, user.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ")}{user.email
|
||||
? ` (${user.email})`
|
||||
: ""}{user.appleId ? " using Apple" : ""} |
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/auth/logout"
|
||||
use:enhance
|
||||
class="inline"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="ml-2 underline hover:text-gray-600 transition-colors cursor-pointer"
|
||||
>Sign out</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthModal bind:isOpen={authModalOpen} anonymousId={persistence.anonymousId} />
|
||||
|
||||
23
src/routes/+page.ts
Normal file
23
src/routes/+page.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
// Disable SSR so the load function runs on the client with the correct local date
|
||||
export const ssr = false;
|
||||
|
||||
export const load: PageLoad = async ({ fetch, data }) => {
|
||||
const localDate = new Date().toLocaleDateString("en-CA");
|
||||
|
||||
const res = await fetch('/api/daily-verse', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ date: localDate }),
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
return {
|
||||
...data,
|
||||
dailyVerse: result.dailyVerse,
|
||||
correctBookId: result.correctBookId,
|
||||
correctBook: result.correctBook,
|
||||
};
|
||||
};
|
||||
13
src/routes/about/+page.server.ts
Normal file
13
src/routes/about/+page.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { marked } from 'marked';
|
||||
|
||||
export async function load() {
|
||||
const about = readFileSync(resolve('static/about.md'), 'utf-8');
|
||||
const howToPlay = readFileSync(resolve('static/how-to-play.md'), 'utf-8');
|
||||
|
||||
return {
|
||||
about: await marked(about),
|
||||
howToPlay: await marked(howToPlay)
|
||||
};
|
||||
}
|
||||
48
src/routes/about/+page.svelte
Normal file
48
src/routes/about/+page.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<svelte:head>
|
||||
<title>About — Bibdle</title>
|
||||
</svelte:head>
|
||||
|
||||
<script lang="ts">
|
||||
import SocialLinks from "$lib/components/SocialLinks.svelte";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const SOCIAL_PLACEHOLDER = "<!-- social -->";
|
||||
|
||||
const aboutParts = $derived(
|
||||
data.about.includes(SOCIAL_PLACEHOLDER)
|
||||
? data.about.split(SOCIAL_PLACEHOLDER)
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="min-h-dvh py-10 px-4">
|
||||
<div class="w-full max-w-xl mx-auto">
|
||||
|
||||
<div class="mb-8">
|
||||
<a
|
||||
href="/"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="prose dark:prose-invert text-justify max-w-none">
|
||||
{#if aboutParts}
|
||||
{@html aboutParts[0]}
|
||||
<div class="my-8 not-prose">
|
||||
<SocialLinks />
|
||||
</div>
|
||||
{@html aboutParts[1]}
|
||||
{:else}
|
||||
{@html data.about}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="prose dark:prose-invert text-justify max-w-none mt-10">
|
||||
{@html data.howToPlay}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
24
src/routes/api/daily-verse/+server.ts
Normal file
24
src/routes/api/daily-verse/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getVerseForDate } from '$lib/server/daily-verse';
|
||||
import { getBookById } from '$lib/server/bible';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const { date } = body;
|
||||
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return json({ error: 'A valid date (YYYY-MM-DD) is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const dateStr = date;
|
||||
|
||||
const dailyVerse = await getVerseForDate(dateStr);
|
||||
const correctBook = getBookById(dailyVerse.bookId) ?? null;
|
||||
|
||||
return json({
|
||||
dailyVerse,
|
||||
correctBookId: dailyVerse.bookId,
|
||||
correctBook,
|
||||
});
|
||||
};
|
||||
66
src/routes/api/dev/seed-history/+server.ts
Normal file
66
src/routes/api/dev/seed-history/+server.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const DEV_HOSTS = ['localhost:5173', 'test.bibdle.com'];
|
||||
|
||||
// A spread of book IDs to use as fake guesses
|
||||
const SAMPLE_BOOK_IDS = [
|
||||
'GEN', 'EXO', 'PSA', 'PRO', 'ISA', 'JER', 'MAT', 'MRK', 'LUK', 'JHN',
|
||||
'ROM', 'GAL', 'EPH', 'PHP', 'REV', 'ACT', 'HEB', 'JAS', '1CO', '2CO',
|
||||
];
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const host = request.headers.get('host') ?? '';
|
||||
if (!DEV_HOSTS.includes(host)) {
|
||||
return json({ error: 'Not allowed in production' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { anonymousId, days = 10 } = await request.json();
|
||||
|
||||
if (!anonymousId || typeof anonymousId !== 'string') {
|
||||
return json({ error: 'anonymousId required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const inserted: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (let i = 1; i <= days; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const date = d.toLocaleDateString('en-CA'); // YYYY-MM-DD
|
||||
|
||||
const guessCount = Math.floor(Math.random() * 6) + 1; // 1-6 guesses
|
||||
// Pick `guessCount` random books (last one is the "correct" answer)
|
||||
const shuffled = [...SAMPLE_BOOK_IDS].sort(() => Math.random() - 0.5);
|
||||
const guesses = shuffled.slice(0, guessCount);
|
||||
|
||||
try {
|
||||
await db.insert(dailyCompletions).values({
|
||||
id: crypto.randomUUID(),
|
||||
anonymousId,
|
||||
date,
|
||||
guessCount,
|
||||
guesses: JSON.stringify(guesses),
|
||||
completedAt: new Date(d.getTime() + 12 * 60 * 60 * 1000), // noon on that day
|
||||
});
|
||||
inserted.push(date);
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
|
||||
skipped.push(date);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json({ success: true, inserted, skipped });
|
||||
} catch (err) {
|
||||
console.error('Error seeding history:', err);
|
||||
return json({ error: 'Failed to seed history' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
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 });
|
||||
};
|
||||
63
src/routes/api/stats/+server.ts
Normal file
63
src/routes/api/stats/+server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { and, eq, asc } from 'drizzle-orm';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const anonymousId = url.searchParams.get('anonymousId');
|
||||
const date = url.searchParams.get('date');
|
||||
|
||||
if (!anonymousId || !date) {
|
||||
return json({ error: 'Invalid data' }, { status: 400 });
|
||||
}
|
||||
|
||||
const userCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(and(
|
||||
eq(dailyCompletions.anonymousId, anonymousId),
|
||||
eq(dailyCompletions.date, date)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (userCompletions.length === 0) {
|
||||
return json({ error: 'No completion found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const userCompletion = userCompletions[0];
|
||||
const guessCount = userCompletion.guessCount;
|
||||
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.date, date))
|
||||
.orderBy(asc(dailyCompletions.completedAt));
|
||||
|
||||
const totalSolves = allCompletions.length;
|
||||
|
||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
const guessRank = betterGuesses + 1;
|
||||
|
||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||
|
||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||
|
||||
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
||||
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
||||
|
||||
const guesses = userCompletion.guesses ? JSON.parse(userCompletion.guesses) : undefined;
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile, guesses }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching stats:', err);
|
||||
return json({ error: 'Failed to fetch stats' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
103
src/routes/api/streak-percentile/+server.ts
Normal file
103
src/routes/api/streak-percentile/+server.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { desc } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const streakParam = url.searchParams.get('streak');
|
||||
const localDate = url.searchParams.get('localDate');
|
||||
|
||||
if (!streakParam || !localDate) {
|
||||
error(400, 'Missing streak or localDate');
|
||||
}
|
||||
|
||||
const targetStreak = parseInt(streakParam, 10);
|
||||
if (isNaN(targetStreak) || targetStreak < 1) {
|
||||
error(400, 'Invalid streak');
|
||||
}
|
||||
|
||||
// Fetch all completions ordered by anonymous_id and date desc
|
||||
// so we can walk each user's history to compute their current streak.
|
||||
const rows = await db
|
||||
.select({
|
||||
anonymousId: dailyCompletions.anonymousId,
|
||||
date: dailyCompletions.date,
|
||||
})
|
||||
.from(dailyCompletions)
|
||||
.orderBy(desc(dailyCompletions.date));
|
||||
|
||||
// Group dates by user
|
||||
const byUser = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
const list = byUser.get(row.anonymousId);
|
||||
if (list) {
|
||||
list.push(row.date);
|
||||
} else {
|
||||
byUser.set(row.anonymousId, [row.date]);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the current streak for each user.
|
||||
// Start from today; if the user hasn't played today yet, try yesterday so
|
||||
// that streaks aren't zeroed out mid-day before the player has had a chance
|
||||
// to complete today's puzzle.
|
||||
const yesterday = new Date(`${localDate}T00:00:00`);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = yesterday.toLocaleDateString('en-CA');
|
||||
|
||||
const thirtyDaysAgo = new Date(`${localDate}T00:00:00`);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const thirtyDaysAgoStr = thirtyDaysAgo.toLocaleDateString('en-CA');
|
||||
|
||||
// For each user, compute their current streak and whether they've played
|
||||
// within the last 30 days. "Eligible players" = active streak OR recent play.
|
||||
const userStats: { streak: number; isEligible: boolean }[] = [];
|
||||
for (const [, dates] of byUser) {
|
||||
// dates are already desc-sorted
|
||||
const dateSet = new Set(dates);
|
||||
|
||||
// Pick the most recent anchor: today if played, otherwise yesterday
|
||||
const anchor = dateSet.has(localDate) ? localDate : yesterdayStr;
|
||||
|
||||
let streak = 0;
|
||||
let cursor = new Date(`${anchor}T00:00:00`);
|
||||
|
||||
while (true) {
|
||||
const dateStr = cursor.toLocaleDateString('en-CA');
|
||||
if (!dateSet.has(dateStr)) break;
|
||||
streak++;
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
}
|
||||
|
||||
const hasRecentPlay = dates.some((d) => d >= thirtyDaysAgoStr);
|
||||
userStats.push({ streak, isEligible: streak >= 1 || hasRecentPlay });
|
||||
}
|
||||
|
||||
const eligiblePlayers = userStats.filter((u) => u.isEligible);
|
||||
|
||||
if (eligiblePlayers.length === 0) {
|
||||
console.log('[streak-percentile] No eligible players found, returning 100th percentile');
|
||||
return json({ percentile: 100 });
|
||||
}
|
||||
|
||||
// Percentage of eligible players who have a streak >= targetStreak
|
||||
const atOrAbove = eligiblePlayers.filter((u) => u.streak >= targetStreak).length;
|
||||
const raw = (atOrAbove / eligiblePlayers.length) * 100;
|
||||
const percentile = raw < 1 ? Math.round(raw * 100) / 100 : Math.round(raw);
|
||||
|
||||
console.log('[streak-percentile]', {
|
||||
localDate,
|
||||
targetStreak,
|
||||
totalUsers: byUser.size,
|
||||
totalRows: rows.length,
|
||||
eligiblePlayers: eligiblePlayers.length,
|
||||
activeStreaks: userStats.filter((u) => u.streak >= 1).length,
|
||||
recentPlayers: userStats.filter((u) => u.isEligible).length,
|
||||
atOrAbove,
|
||||
raw,
|
||||
percentile,
|
||||
});
|
||||
|
||||
return json({ percentile });
|
||||
};
|
||||
42
src/routes/api/streak/+server.ts
Normal file
42
src/routes/api/streak/+server.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const anonymousId = url.searchParams.get('anonymousId');
|
||||
const localDate = url.searchParams.get('localDate');
|
||||
|
||||
if (!anonymousId || !localDate) {
|
||||
error(400, 'Missing anonymousId or localDate');
|
||||
}
|
||||
|
||||
// Fetch all completion dates for this user (stored as the user's local date)
|
||||
const rows = await db
|
||||
.select({ date: dailyCompletions.date })
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, anonymousId))
|
||||
.orderBy(desc(dailyCompletions.date));
|
||||
|
||||
const completedDates = new Set(rows.map((r) => r.date));
|
||||
|
||||
// Subtract one calendar day from a YYYY-MM-DD string using UTC arithmetic —
|
||||
// this avoids any dependence on the server's local timezone or DST offsets.
|
||||
function prevDay(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Walk backwards from the user's local date, counting consecutive completed days
|
||||
let streak = 0;
|
||||
let cursor = localDate;
|
||||
|
||||
while (completedDates.has(cursor)) {
|
||||
streak++;
|
||||
cursor = prevDay(cursor);
|
||||
}
|
||||
|
||||
return json({ streak: streak < 2 ? 0 : streak });
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions } from '$lib/server/db/schema';
|
||||
import { and, eq, asc } from 'drizzle-orm';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const { anonymousId, date, guessCount } = await request.json();
|
||||
const { anonymousId, date, guessCount, guesses } = await request.json();
|
||||
|
||||
// Validation
|
||||
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
|
||||
@@ -23,6 +23,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
anonymousId,
|
||||
date,
|
||||
guessCount,
|
||||
guesses: Array.isArray(guesses) ? JSON.stringify(guesses) : null,
|
||||
completedAt,
|
||||
});
|
||||
} catch (err: any) {
|
||||
@@ -44,11 +45,9 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
// Solve rank: position in time-ordered list
|
||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||
|
||||
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
|
||||
const uniqueBetterGuessCounts = new Set(
|
||||
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
|
||||
);
|
||||
const guessRank = uniqueBetterGuessCounts.size + 1;
|
||||
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||
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;
|
||||
@@ -70,69 +69,3 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
return json({ error: 'Failed to submit completion' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const anonymousId = url.searchParams.get('anonymousId');
|
||||
const date = url.searchParams.get('date');
|
||||
|
||||
if (!anonymousId || !date) {
|
||||
return json({ error: 'Invalid data' }, { status: 400 });
|
||||
}
|
||||
|
||||
const userCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(and(
|
||||
eq(dailyCompletions.anonymousId, anonymousId),
|
||||
eq(dailyCompletions.date, date)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (userCompletions.length === 0) {
|
||||
return json({ error: 'No completion found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const userCompletion = userCompletions[0];
|
||||
const guessCount = userCompletion.guessCount;
|
||||
|
||||
// Calculate statistics
|
||||
const allCompletions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.date, date))
|
||||
.orderBy(asc(dailyCompletions.completedAt));
|
||||
|
||||
const totalSolves = allCompletions.length;
|
||||
|
||||
// Solve rank: position in time-ordered list
|
||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||
|
||||
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
|
||||
const uniqueBetterGuessCounts = new Set(
|
||||
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)
|
||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||
|
||||
// Average guesses
|
||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||
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 json({
|
||||
success: true,
|
||||
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching stats:', err);
|
||||
return json({ error: 'Failed to fetch stats' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
26
src/routes/auth/apple/+page.server.ts
Normal file
26
src/routes/auth/apple/+page.server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ cookies, request }) => {
|
||||
const data = await request.formData();
|
||||
const anonymousId = data.get('anonymousId')?.toString() || '';
|
||||
|
||||
// Generate CSRF state
|
||||
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
const state = Buffer.from(stateBytes).toString('base64url');
|
||||
|
||||
// Store state + anonymousId in a short-lived cookie
|
||||
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
||||
cookies.set('apple_oauth_state', JSON.stringify({ state, anonymousId }), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'none',
|
||||
maxAge: 600
|
||||
});
|
||||
|
||||
redirect(302, getAppleAuthUrl(state));
|
||||
}
|
||||
};
|
||||
137
src/routes/auth/apple/callback/+server.ts
Normal file
137
src/routes/auth/apple/callback/+server.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { exchangeAppleCode, decodeAppleIdToken } from '$lib/server/apple-auth';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
import * as auth from '$lib/server/auth';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user as userTable } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const formData = await request.formData();
|
||||
const code = formData.get('code')?.toString();
|
||||
const state = formData.get('state')?.toString();
|
||||
// Apple sends user info as JSON string on FIRST authorization only
|
||||
const userInfoStr = formData.get('user')?.toString();
|
||||
|
||||
// Validate CSRF state
|
||||
const storedRaw = cookies.get('apple_oauth_state');
|
||||
if (!storedRaw || !state || !code) {
|
||||
throw error(400, 'Invalid OAuth callback');
|
||||
}
|
||||
const stored = JSON.parse(storedRaw) as { state: string; anonymousId: string };
|
||||
if (stored.state !== state) {
|
||||
throw error(400, 'State mismatch');
|
||||
}
|
||||
cookies.delete('apple_oauth_state', { path: '/' });
|
||||
|
||||
const anonId = stored.anonymousId;
|
||||
if (!anonId) {
|
||||
console.error('[Apple auth] Missing anonymousId in state cookie');
|
||||
throw error(400, 'Missing anonymous ID — please return to the game and try again');
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
const tokens = await exchangeAppleCode(code, `${publicEnv.PUBLIC_SITE_URL}/auth/apple/callback`);
|
||||
const claims = decodeAppleIdToken(tokens.id_token);
|
||||
const appleId = claims.sub;
|
||||
|
||||
// Parse user info (only present on first authorization)
|
||||
let appleFirstName: string | undefined;
|
||||
let appleLastName: string | undefined;
|
||||
if (userInfoStr) {
|
||||
try {
|
||||
const userInfo = JSON.parse(userInfoStr);
|
||||
appleFirstName = userInfo.name?.firstName;
|
||||
appleLastName = userInfo.name?.lastName;
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
}
|
||||
|
||||
// --- User resolution ---
|
||||
let userId: string;
|
||||
|
||||
// 1. Check if a user with this appleId already exists (returning user)
|
||||
const existingAppleUser = await auth.getUserByAppleId(appleId);
|
||||
|
||||
if (existingAppleUser) {
|
||||
userId = existingAppleUser.id;
|
||||
console.log(`[Apple auth] Returning Apple user: userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else if (claims.email) {
|
||||
// 2. Check if email matches an existing email/password user
|
||||
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
||||
if (existingEmailUser) {
|
||||
// Link Apple account to existing user
|
||||
await db.update(userTable).set({ appleId }).where(eq(userTable.id, existingEmailUser.id));
|
||||
userId = existingEmailUser.id;
|
||||
console.log(`[Apple auth] Linked Apple to existing email user: userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else {
|
||||
// 3. Brand new user — use anonymousId as user ID to preserve local stats
|
||||
userId = anonId;
|
||||
console.log(`[Apple auth] New user (has email): userId=${userId}`);
|
||||
try {
|
||||
await db.insert(userTable).values({
|
||||
id: userId,
|
||||
email: claims.email,
|
||||
passwordHash: null,
|
||||
appleId,
|
||||
firstName: appleFirstName || null,
|
||||
lastName: appleLastName || null,
|
||||
isPrivate: false
|
||||
});
|
||||
} catch (e: any) {
|
||||
// Handle race condition: if appleId was inserted between our check and insert
|
||||
if (e?.message?.includes('UNIQUE constraint')) {
|
||||
const retryUser = await auth.getUserByAppleId(appleId);
|
||||
if (retryUser) {
|
||||
userId = retryUser.id;
|
||||
console.log(`[Apple auth] Race condition (has email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else {
|
||||
throw error(500, 'Failed to create user');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No email from Apple — create account with appleId only
|
||||
userId = anonId;
|
||||
console.log(`[Apple auth] New user (no email): userId=${userId}`);
|
||||
try {
|
||||
await db.insert(userTable).values({
|
||||
id: userId,
|
||||
email: null,
|
||||
passwordHash: null,
|
||||
appleId,
|
||||
firstName: appleFirstName || null,
|
||||
lastName: appleLastName || null,
|
||||
isPrivate: false
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.message?.includes('UNIQUE constraint')) {
|
||||
const retryUser = await auth.getUserByAppleId(appleId);
|
||||
if (retryUser) {
|
||||
userId = retryUser.id;
|
||||
console.log(`[Apple auth] Race condition (no email): resolved to userId=${userId}, anonId=${anonId}`);
|
||||
await auth.migrateAnonymousStats(anonId, userId);
|
||||
} else {
|
||||
throw error(500, 'Failed to create user');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create session
|
||||
const sessionToken = auth.generateSessionToken();
|
||||
const session = await auth.createSession(sessionToken, userId);
|
||||
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt);
|
||||
|
||||
redirect(302, '/');
|
||||
};
|
||||
8
src/routes/auth/apple/test/+page.server.ts
Normal file
8
src/routes/auth/apple/test/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
};
|
||||
40
src/routes/auth/apple/test/+page.svelte
Normal file
40
src/routes/auth/apple/test/+page.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { browser } from "$app/environment";
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
|
||||
let isOpen = $state(true);
|
||||
const user = $derived(page.data.user);
|
||||
let anonymousId = $state("");
|
||||
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
anonymousId = localStorage.getItem("bibdle-anonymous-id") ?? "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
{#if user}
|
||||
<div class="text-white text-center space-y-4">
|
||||
<p class="text-lg">
|
||||
Signed in as <strong>{user.email ?? "no email"}</strong>
|
||||
</p>
|
||||
<form method="POST" action="/auth/logout">
|
||||
<button
|
||||
class="px-4 py-2 bg-red-600 rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (isOpen = true)}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Open Auth Modal
|
||||
</button>
|
||||
<AuthModal bind:isOpen {anonymousId} />
|
||||
{/if}
|
||||
</div>
|
||||
13
src/routes/auth/logout/+page.server.ts
Normal file
13
src/routes/auth/logout/+page.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import * as auth from '$lib/server/auth';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ locals, cookies }) => {
|
||||
if (locals.session) {
|
||||
await auth.invalidateSession(locals.session.id);
|
||||
}
|
||||
auth.deleteSessionTokenCookie({ cookies });
|
||||
redirect(302, '/');
|
||||
}
|
||||
};
|
||||
53
src/routes/auth/signin/+page.server.ts
Normal file
53
src/routes/auth/signin/+page.server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import * as auth from '$lib/server/auth';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email')?.toString();
|
||||
const password = data.get('password')?.toString();
|
||||
const anonymousId = data.get('anonymousId')?.toString();
|
||||
|
||||
if (!email || !password) {
|
||||
return fail(400, { error: 'Email and password are required' });
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return fail(400, { error: 'Please enter a valid email address' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return fail(400, { error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user by email
|
||||
const user = await auth.getUserByEmail(email);
|
||||
if (!user || !user.passwordHash) {
|
||||
return fail(400, { error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await auth.verifyPassword(password, user.passwordHash);
|
||||
if (!isValidPassword) {
|
||||
return fail(400, { error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
// Migrate anonymous stats if different anonymous ID
|
||||
await auth.migrateAnonymousStats(anonymousId, user.id);
|
||||
|
||||
// Create session
|
||||
const sessionToken = auth.generateSessionToken();
|
||||
const session = await auth.createSession(sessionToken, user.id);
|
||||
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Sign in error:', error);
|
||||
return fail(500, { error: 'An error occurred during sign in' });
|
||||
}
|
||||
}
|
||||
};
|
||||
64
src/routes/auth/signup/+page.server.ts
Normal file
64
src/routes/auth/signup/+page.server.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import * as auth from '$lib/server/auth';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get('email')?.toString();
|
||||
const password = data.get('password')?.toString();
|
||||
const firstName = data.get('firstName')?.toString();
|
||||
const lastName = data.get('lastName')?.toString();
|
||||
const anonymousId = data.get('anonymousId')?.toString();
|
||||
|
||||
if (!email || !password || !anonymousId) {
|
||||
return fail(400, { error: 'Email, password, and anonymous ID are required' });
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return fail(400, { error: 'Please enter a valid email address' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return fail(400, { error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if user already exists
|
||||
const existingUser = await auth.getUserByEmail(email);
|
||||
if (existingUser) {
|
||||
return fail(400, { error: 'An account with this email already exists' });
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await auth.hashPassword(password);
|
||||
|
||||
// Create user with anonymousId as the user ID
|
||||
const user = await auth.createUser(
|
||||
anonymousId,
|
||||
email,
|
||||
passwordHash,
|
||||
firstName || undefined,
|
||||
lastName || undefined
|
||||
);
|
||||
|
||||
// Create session
|
||||
const sessionToken = auth.generateSessionToken();
|
||||
const session = await auth.createSession(sessionToken, user.id);
|
||||
auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Sign up error:', error);
|
||||
|
||||
// Check if it's a unique constraint error (user with this ID already exists)
|
||||
if (error instanceof Error && error.message.includes('UNIQUE constraint')) {
|
||||
return fail(400, { error: 'This account is already registered. Please sign in instead.' });
|
||||
}
|
||||
|
||||
return fail(500, { error: 'An error occurred during account creation' });
|
||||
}
|
||||
}
|
||||
};
|
||||
145
src/routes/feed.xml/+server.ts
Normal file
145
src/routes/feed.xml/+server.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
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 });
|
||||
}
|
||||
};
|
||||
484
src/routes/global/+page.server.ts
Normal file
484
src/routes/global/+page.server.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions, user } from '$lib/server/db/schema';
|
||||
import { eq, gte, count, countDistinct, avg, asc, min } from 'drizzle-orm';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
function estDateStr(daysAgo = 0): string {
|
||||
const estNow = new Date(Date.now() - 5 * 60 * 60 * 1000); // UTC-5
|
||||
estNow.setUTCDate(estNow.getUTCDate() - daysAgo);
|
||||
return estNow.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function prevDay(d: string): string {
|
||||
const dt = new Date(d + 'T00:00:00Z');
|
||||
dt.setUTCDate(dt.getUTCDate() - 1);
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(d: string, n: number): string {
|
||||
const dt = new Date(d + 'T00:00:00Z');
|
||||
dt.setUTCDate(dt.getUTCDate() + n);
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const todayEst = estDateStr(0);
|
||||
const yesterdayEst = estDateStr(1);
|
||||
const sevenDaysAgo = estDateStr(7);
|
||||
|
||||
// Three weekly windows for first + second derivative calculations
|
||||
// Week A: last 7 days (indices 0–6)
|
||||
// Week B: 7–13 days ago (indices 7–13)
|
||||
// Week C: 14–20 days ago (indices 14–20)
|
||||
const weekAStart = estDateStr(6);
|
||||
const weekBEnd = estDateStr(7);
|
||||
const weekBStart = estDateStr(13);
|
||||
const weekCEnd = estDateStr(14);
|
||||
const weekCStart = estDateStr(20);
|
||||
|
||||
// ── Scalar stats ──────────────────────────────────────────────────────────
|
||||
|
||||
const [{ todayCount }] = await db
|
||||
.select({ todayCount: count() })
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.date, todayEst));
|
||||
|
||||
const [{ totalCount }] = await db
|
||||
.select({ totalCount: count() })
|
||||
.from(dailyCompletions);
|
||||
|
||||
const [{ uniquePlayers }] = await db
|
||||
.select({ uniquePlayers: countDistinct(dailyCompletions.anonymousId) })
|
||||
.from(dailyCompletions);
|
||||
|
||||
const [{ weeklyPlayers }] = await db
|
||||
.select({ weeklyPlayers: countDistinct(dailyCompletions.anonymousId) })
|
||||
.from(dailyCompletions)
|
||||
.where(gte(dailyCompletions.date, sevenDaysAgo));
|
||||
|
||||
const thirtyDaysAgo = estDateStr(30);
|
||||
const [{ monthlyPlayers }] = await db
|
||||
.select({ monthlyPlayers: countDistinct(dailyCompletions.anonymousId) })
|
||||
.from(dailyCompletions)
|
||||
.where(gte(dailyCompletions.date, thirtyDaysAgo));
|
||||
|
||||
const todayPlayers = await db
|
||||
.selectDistinct({ id: dailyCompletions.anonymousId })
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.date, todayEst));
|
||||
|
||||
const yesterdayPlayers = await db
|
||||
.selectDistinct({ id: dailyCompletions.anonymousId })
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.date, yesterdayEst));
|
||||
|
||||
const todaySet = new Set(todayPlayers.map((r) => r.id));
|
||||
const activeStreaks = yesterdayPlayers.filter((r) => todaySet.has(r.id)).length;
|
||||
|
||||
const [{ avgGuessesRaw }] = await db
|
||||
.select({ avgGuessesRaw: avg(dailyCompletions.guessCount) })
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.date, todayEst));
|
||||
|
||||
const avgGuessesToday = avgGuessesRaw != null ? parseFloat(avgGuessesRaw) : null;
|
||||
|
||||
const [{ registeredUsers }] = await db
|
||||
.select({ registeredUsers: count() })
|
||||
.from(user);
|
||||
|
||||
const avgCompletionsPerPlayer =
|
||||
uniquePlayers > 0 ? Math.round((totalCount / uniquePlayers) * 100) / 100 : null;
|
||||
|
||||
// ── 21-day completions per day (covers all three weekly windows) ──────────
|
||||
|
||||
const rawPerDay21 = await db
|
||||
.select({ date: dailyCompletions.date, dayCount: count() })
|
||||
.from(dailyCompletions)
|
||||
.where(gte(dailyCompletions.date, weekCStart))
|
||||
.groupBy(dailyCompletions.date)
|
||||
.orderBy(asc(dailyCompletions.date));
|
||||
|
||||
const counts21 = new Map(rawPerDay21.map((r) => [r.date, r.dayCount]));
|
||||
|
||||
// Build indexed array: index 0 = today, index 20 = 20 days ago
|
||||
const completionsPerDay: number[] = [];
|
||||
for (let i = 0; i <= 20; i++) {
|
||||
completionsPerDay.push(counts21.get(estDateStr(i)) ?? 0);
|
||||
}
|
||||
|
||||
// last14Days for the trend chart (most recent first)
|
||||
const last14Days: { date: string; count: number }[] = [];
|
||||
for (let i = 0; i <= 13; i++) {
|
||||
last14Days.push({ date: estDateStr(i), count: completionsPerDay[i] });
|
||||
}
|
||||
|
||||
// Weekly totals from the indexed array
|
||||
const weekATotal = completionsPerDay.slice(0, 7).reduce((a, b) => a + b, 0);
|
||||
const weekBTotal = completionsPerDay.slice(7, 14).reduce((a, b) => a + b, 0);
|
||||
const weekCTotal = completionsPerDay.slice(14, 21).reduce((a, b) => a + b, 0);
|
||||
|
||||
// First derivative: avg daily completions change (week A vs week B)
|
||||
const completionsVelocity = Math.round(((weekATotal - weekBTotal) / 7) * 10) / 10;
|
||||
// Second derivative: is velocity itself increasing or decreasing?
|
||||
const completionsAcceleration =
|
||||
Math.round((((weekATotal - weekBTotal) - (weekBTotal - weekCTotal)) / 7) * 10) / 10;
|
||||
|
||||
// ── 90-day per-user data (reused for streaks + weekly user sets) ──────────
|
||||
|
||||
const ninetyDaysAgo = estDateStr(90);
|
||||
const recentCompletions = await db
|
||||
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
|
||||
.from(dailyCompletions)
|
||||
.where(gte(dailyCompletions.date, ninetyDaysAgo))
|
||||
.orderBy(asc(dailyCompletions.date));
|
||||
|
||||
// Group dates by user (ascending) and users by date
|
||||
const userDatesMap = new Map<string, string[]>();
|
||||
const dateUsersMap = new Map<string, Set<string>>();
|
||||
for (const row of recentCompletions) {
|
||||
const arr = userDatesMap.get(row.anonymousId);
|
||||
if (arr) arr.push(row.date);
|
||||
else userDatesMap.set(row.anonymousId, [row.date]);
|
||||
|
||||
let s = dateUsersMap.get(row.date);
|
||||
if (!s) { s = new Set(); dateUsersMap.set(row.date, s); }
|
||||
s.add(row.anonymousId);
|
||||
}
|
||||
|
||||
// ── Streak distribution ───────────────────────────────────────────────────
|
||||
|
||||
const streakDistribution = new Map<number, number>();
|
||||
for (const dates of userDatesMap.values()) {
|
||||
const desc = dates.slice().reverse();
|
||||
if (desc[0] !== todayEst && desc[0] !== yesterdayEst) continue;
|
||||
let streak = 1;
|
||||
let cur = desc[0];
|
||||
for (let i = 1; i < desc.length; i++) {
|
||||
if (desc[i] === prevDay(cur)) {
|
||||
streak++;
|
||||
cur = desc[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (streak >= 2) {
|
||||
streakDistribution.set(streak, (streakDistribution.get(streak) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const streakChart = Array.from(streakDistribution.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([days, userCount]) => ({ days, count: userCount }));
|
||||
|
||||
// ── Weekly user sets (for user-based velocity + churn) ───────────────────
|
||||
|
||||
const weekAUsers = new Set<string>();
|
||||
const weekBUsers = new Set<string>();
|
||||
const weekCUsers = new Set<string>();
|
||||
|
||||
for (const [userId, dates] of userDatesMap) {
|
||||
if (dates.some((d) => d >= weekAStart)) weekAUsers.add(userId);
|
||||
if (dates.some((d) => d >= weekBStart && d <= weekBEnd)) weekBUsers.add(userId);
|
||||
if (dates.some((d) => d >= weekCStart && d <= weekCEnd)) weekCUsers.add(userId);
|
||||
}
|
||||
|
||||
// First derivative: weekly unique users change
|
||||
const userVelocity = weekAUsers.size - weekBUsers.size;
|
||||
// Second derivative: is user growth speeding up or slowing down?
|
||||
const userAcceleration =
|
||||
weekAUsers.size - weekBUsers.size - (weekBUsers.size - weekCUsers.size);
|
||||
|
||||
// ── New players + churn ───────────────────────────────────────────────────
|
||||
// New players: anonymousIds whose first-ever completion falls in the last 7 days.
|
||||
// Checking against all-time data (not just the 90-day window) ensures accuracy.
|
||||
const firstDates = await db
|
||||
.select({
|
||||
anonymousId: dailyCompletions.anonymousId,
|
||||
firstDate: min(dailyCompletions.date),
|
||||
totalCompletions: count()
|
||||
})
|
||||
.from(dailyCompletions)
|
||||
.groupBy(dailyCompletions.anonymousId);
|
||||
|
||||
const newUsers7d = firstDates.filter((r) => r.firstDate != null && r.firstDate >= weekAStart).length;
|
||||
|
||||
// Churned: played in week B but not at all in week A
|
||||
const churned7d = [...weekBUsers].filter((id) => !weekAUsers.has(id)).length;
|
||||
|
||||
// Net growth = truly new arrivals minus departures
|
||||
const netGrowth7d = newUsers7d - churned7d;
|
||||
|
||||
// ── Session depth funnel ──────────────────────────────────────────────────
|
||||
// For each depth d, count players with >= d completions.
|
||||
// returnRate at depth d = (players with >= d+1) / (players with >= d).
|
||||
const depthCounts = new Map<number, number>();
|
||||
for (const r of firstDates) {
|
||||
const n = r.totalCompletions;
|
||||
for (let d = 1; d <= n; d++) {
|
||||
depthCounts.set(d, (depthCounts.get(d) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const sessionDepthCards = [2, 3, 4, 5, 7].map((d) => {
|
||||
const atD = depthCounts.get(d) ?? 0;
|
||||
const atDplus1 = depthCounts.get(d + 1) ?? 0;
|
||||
return {
|
||||
depth: d,
|
||||
players: atD,
|
||||
returnRate: atD >= 3 ? Math.round((atDplus1 / atD) * 1000) / 10 : null
|
||||
};
|
||||
});
|
||||
|
||||
// ── Return rate ───────────────────────────────────────────────────────────
|
||||
// "Return rate": % of all-time unique players who have ever played more than once.
|
||||
const playersWithReturn = firstDates.filter((r) => r.totalCompletions >= 2).length;
|
||||
const overallReturnRate =
|
||||
firstDates.length > 0
|
||||
? Math.round((playersWithReturn / firstDates.length) * 1000) / 10
|
||||
: null;
|
||||
|
||||
// Daily new-player return rate: for each day D, what % of first-time players
|
||||
// on D ever came back (i.e. totalCompletions >= 2)?
|
||||
const dailyNewPlayerReturn = new Map<string, { cohort: number; returned: number }>();
|
||||
for (const r of firstDates) {
|
||||
if (!r.firstDate) continue;
|
||||
const existing = dailyNewPlayerReturn.get(r.firstDate) ?? { cohort: 0, returned: 0 };
|
||||
existing.cohort++;
|
||||
if (r.totalCompletions >= 2) existing.returned++;
|
||||
dailyNewPlayerReturn.set(r.firstDate, existing);
|
||||
}
|
||||
|
||||
// Build chronological array of daily rates (oldest first, days 60→1 ago)
|
||||
// Days with fewer than 3 new players get rate=null to exclude from rolling avg
|
||||
const dailyReturnRates: { date: string; cohort: number; rate: number | null }[] = [];
|
||||
for (let i = 60; i >= 1; i--) {
|
||||
const dateD = estDateStr(i);
|
||||
const d = dailyNewPlayerReturn.get(dateD);
|
||||
dailyReturnRates.push({
|
||||
date: dateD,
|
||||
cohort: d?.cohort ?? 0,
|
||||
rate: d && d.cohort >= 3 ? Math.round((d.returned / d.cohort) * 1000) / 10 : null
|
||||
});
|
||||
}
|
||||
|
||||
// 7-day trailing rolling average of the daily rates
|
||||
// Index 0 = 60 days ago, index 59 = yesterday
|
||||
const newPlayerReturnSeries = dailyReturnRates.map((r, idx) => {
|
||||
const window = dailyReturnRates
|
||||
.slice(Math.max(0, idx - 6), idx + 1)
|
||||
.filter((d) => d.rate !== null);
|
||||
const avg =
|
||||
window.length > 0
|
||||
? Math.round((window.reduce((sum, d) => sum + (d.rate ?? 0), 0) / window.length) * 10) /
|
||||
10
|
||||
: null;
|
||||
return { date: r.date, cohort: r.cohort, rate: r.rate, rollingAvg: avg };
|
||||
});
|
||||
|
||||
// Velocity: avg of last 7 complete days (idx 53–59) vs prior 7 (idx 46–52)
|
||||
const recentWindow = newPlayerReturnSeries.slice(53).filter((d) => d.rate !== null);
|
||||
const priorWindow = newPlayerReturnSeries.slice(46, 53).filter((d) => d.rate !== null);
|
||||
const current7dReturnAvg =
|
||||
recentWindow.length > 0
|
||||
? Math.round(
|
||||
(recentWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / recentWindow.length) * 10
|
||||
) / 10
|
||||
: null;
|
||||
const prior7dReturnAvg =
|
||||
priorWindow.length > 0
|
||||
? Math.round(
|
||||
(priorWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / priorWindow.length) * 10
|
||||
) / 10
|
||||
: null;
|
||||
const returnRateChange =
|
||||
current7dReturnAvg !== null && prior7dReturnAvg !== null
|
||||
? Math.round((current7dReturnAvg - prior7dReturnAvg) * 10) / 10
|
||||
: null;
|
||||
|
||||
// ── Retention over time ───────────────────────────────────────────────────
|
||||
// For each cohort day D, retention = % of that day's players who played
|
||||
// again within the next N days. Only compute for days where D+N is in the past.
|
||||
|
||||
function retentionSeries(
|
||||
windowDays: number,
|
||||
seriesLength: number
|
||||
): { date: string; rate: number; cohortSize: number }[] {
|
||||
// Earliest computable cohort day: today - (windowDays + 1)
|
||||
// We use index windowDays+1 through windowDays+seriesLength
|
||||
const series: { date: string; rate: number; cohortSize: number }[] = [];
|
||||
for (let i = windowDays + 1; i <= windowDays + seriesLength; i++) {
|
||||
const dateD = estDateStr(i);
|
||||
const cohort = dateUsersMap.get(dateD);
|
||||
if (!cohort || cohort.size < 3) continue; // skip tiny cohorts
|
||||
let retained = 0;
|
||||
for (const userId of cohort) {
|
||||
if (dateUsersMap.get(addDays(dateD, windowDays))?.has(userId)) {
|
||||
retained++;
|
||||
}
|
||||
}
|
||||
series.push({
|
||||
date: dateD,
|
||||
rate: Math.round((retained / cohort.size) * 1000) / 10,
|
||||
cohortSize: cohort.size
|
||||
});
|
||||
}
|
||||
return series; // newest first (loop iterates i from smallest = most recent)
|
||||
}
|
||||
|
||||
const retention7dSeries = retentionSeries(7, 30);
|
||||
const retention30dSeries = retentionSeries(30, 30);
|
||||
|
||||
// ── Weekly Active Users history (12 weeks) ────────────────────────────────
|
||||
|
||||
const wauWeeks: { weekStart: string; weekEnd: string; wau: number; changePct: number | null }[] = [];
|
||||
|
||||
for (let w = 0; w < 12; w++) {
|
||||
const weekEnd = estDateStr(w * 7);
|
||||
const weekStart = estDateStr(w * 7 + 6);
|
||||
const users = new Set<string>();
|
||||
for (const row of recentCompletions) {
|
||||
if (row.date >= weekStart && row.date <= weekEnd) {
|
||||
users.add(row.anonymousId);
|
||||
}
|
||||
}
|
||||
wauWeeks.push({ weekEnd, weekStart, wau: users.size, changePct: null });
|
||||
}
|
||||
|
||||
// Change % vs prior week (index i+1 is the older week)
|
||||
for (let i = 0; i < wauWeeks.length - 1; i++) {
|
||||
const prev = wauWeeks[i + 1].wau;
|
||||
if (prev > 0) {
|
||||
wauWeeks[i].changePct = Math.round(((wauWeeks[i].wau - prev) / prev) * 1000) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
const avgWau = Math.round(wauWeeks.reduce((sum, w) => sum + w.wau, 0) / wauWeeks.length);
|
||||
|
||||
// ── Monthly Active Users history (6 months) ───────────────────────────────
|
||||
|
||||
const sixMonthsAgo = estDateStr(185);
|
||||
const mauCompletions = await db
|
||||
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
|
||||
.from(dailyCompletions)
|
||||
.where(gte(dailyCompletions.date, sixMonthsAgo));
|
||||
|
||||
// Rolling 30-day windows
|
||||
const mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[] = [];
|
||||
for (let m = 0; m < 6; m++) {
|
||||
const monthEnd = estDateStr(m * 30);
|
||||
const monthStart = estDateStr(m * 30 + 29);
|
||||
const users = new Set<string>();
|
||||
for (const row of mauCompletions) {
|
||||
if (row.date >= monthStart && row.date <= monthEnd) {
|
||||
users.add(row.anonymousId);
|
||||
}
|
||||
}
|
||||
mauMonths.push({ monthStart, monthEnd, mau: users.size, changePct: null });
|
||||
}
|
||||
for (let i = 0; i < mauMonths.length - 1; i++) {
|
||||
const prev = mauMonths[i + 1].mau;
|
||||
if (prev > 0) {
|
||||
mauMonths[i].changePct = Math.round(((mauMonths[i].mau - prev) / prev) * 1000) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar month windows
|
||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const [todayYear, todayMonth, todayDay] = todayEst.split('-').map(Number);
|
||||
|
||||
const calendarMauMonths: {
|
||||
label: string;
|
||||
monthStart: string;
|
||||
monthEnd: string;
|
||||
mau: number;
|
||||
daysElapsed: number;
|
||||
daysInMonth: number;
|
||||
projectedMau: number | null;
|
||||
changePct: number | null;
|
||||
isCurrentMonth: boolean;
|
||||
}[] = [];
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
let mo = todayMonth - i;
|
||||
let yr = todayYear;
|
||||
if (mo <= 0) { mo += 12; yr--; }
|
||||
|
||||
// new Date(yr, mo, 0) gives last day of month mo (1-indexed) in local time
|
||||
const daysInMonth = new Date(yr, mo, 0).getDate();
|
||||
const monthStart = `${yr}-${String(mo).padStart(2, '0')}-01`;
|
||||
const monthEnd = `${yr}-${String(mo).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
|
||||
const isCurrentMonth = i === 0;
|
||||
const daysElapsed = isCurrentMonth ? todayDay : daysInMonth;
|
||||
const queryEnd = isCurrentMonth ? todayEst : monthEnd;
|
||||
|
||||
const users = new Set<string>();
|
||||
for (const row of mauCompletions) {
|
||||
if (row.date >= monthStart && row.date <= queryEnd) {
|
||||
users.add(row.anonymousId);
|
||||
}
|
||||
}
|
||||
|
||||
const projectedMau = isCurrentMonth && daysElapsed > 0
|
||||
? Math.round(users.size * (daysInMonth / daysElapsed))
|
||||
: null;
|
||||
|
||||
calendarMauMonths.push({
|
||||
label: `${MONTH_NAMES[mo - 1]} ${yr}`,
|
||||
monthStart,
|
||||
monthEnd,
|
||||
mau: users.size,
|
||||
daysElapsed,
|
||||
daysInMonth,
|
||||
projectedMau,
|
||||
changePct: null,
|
||||
isCurrentMonth
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < calendarMauMonths.length - 1; i++) {
|
||||
const curr = calendarMauMonths[i];
|
||||
const prev = calendarMauMonths[i + 1];
|
||||
if (prev.mau > 0) {
|
||||
const compareVal = curr.projectedMau ?? curr.mau;
|
||||
curr.changePct = Math.round(((compareVal - prev.mau) / prev.mau) * 1000) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
todayEst,
|
||||
sessionDepthCards,
|
||||
stats: {
|
||||
todayCount,
|
||||
totalCount,
|
||||
uniquePlayers,
|
||||
weeklyPlayers,
|
||||
activeStreaks,
|
||||
avgGuessesToday,
|
||||
registeredUsers,
|
||||
monthlyPlayers
|
||||
},
|
||||
growth: {
|
||||
completionsVelocity,
|
||||
completionsAcceleration,
|
||||
userVelocity,
|
||||
userAcceleration,
|
||||
newUsers7d,
|
||||
churned7d,
|
||||
netGrowth7d
|
||||
},
|
||||
last14Days,
|
||||
streakChart,
|
||||
retention7dSeries,
|
||||
retention30dSeries,
|
||||
overallReturnRate,
|
||||
newPlayerReturnSeries: newPlayerReturnSeries.slice(-30).reverse(),
|
||||
newPlayerReturnVelocity: {
|
||||
current7dAvg: current7dReturnAvg,
|
||||
prior7dAvg: prior7dReturnAvg,
|
||||
change: returnRateChange
|
||||
},
|
||||
wauWeeks,
|
||||
avgWau,
|
||||
mauMonths,
|
||||
calendarMauMonths
|
||||
};
|
||||
};
|
||||
883
src/routes/global/+page.svelte
Normal file
883
src/routes/global/+page.svelte
Normal file
@@ -0,0 +1,883 @@
|
||||
<script lang="ts">
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
|
||||
interface Stats {
|
||||
todayCount: number;
|
||||
totalCount: number;
|
||||
uniquePlayers: number;
|
||||
weeklyPlayers: number;
|
||||
activeStreaks: number;
|
||||
avgGuessesToday: number | null;
|
||||
registeredUsers: number;
|
||||
monthlyPlayers: number;
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
todayEst: string;
|
||||
stats: Stats;
|
||||
last14Days: { date: string; count: number }[];
|
||||
streakChart: { days: number; count: number }[];
|
||||
growth: {
|
||||
completionsVelocity: number;
|
||||
completionsAcceleration: number;
|
||||
userVelocity: number;
|
||||
userAcceleration: number;
|
||||
newUsers7d: number;
|
||||
churned7d: number;
|
||||
netGrowth7d: number;
|
||||
};
|
||||
retention7dSeries: { date: string; rate: number; cohortSize: number }[];
|
||||
retention30dSeries: {
|
||||
date: string;
|
||||
rate: number;
|
||||
cohortSize: number;
|
||||
}[];
|
||||
overallReturnRate: number | null;
|
||||
newPlayerReturnSeries: {
|
||||
date: string;
|
||||
cohort: number;
|
||||
rate: number | null;
|
||||
rollingAvg: number | null;
|
||||
}[];
|
||||
newPlayerReturnVelocity: {
|
||||
current7dAvg: number | null;
|
||||
prior7dAvg: number | null;
|
||||
change: number | null;
|
||||
};
|
||||
wauWeeks: {
|
||||
weekStart: string;
|
||||
weekEnd: string;
|
||||
wau: number;
|
||||
changePct: number | null;
|
||||
}[];
|
||||
avgWau: number;
|
||||
mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[];
|
||||
calendarMauMonths: {
|
||||
label: string;
|
||||
monthStart: string;
|
||||
monthEnd: string;
|
||||
mau: number;
|
||||
daysElapsed: number;
|
||||
daysInMonth: number;
|
||||
projectedMau: number | null;
|
||||
changePct: number | null;
|
||||
isCurrentMonth: boolean;
|
||||
}[];
|
||||
sessionDepthCards: { depth: number; players: number; returnRate: number | null }[];
|
||||
}
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const {
|
||||
stats,
|
||||
last14Days,
|
||||
todayEst,
|
||||
streakChart,
|
||||
growth,
|
||||
retention7dSeries,
|
||||
retention30dSeries,
|
||||
overallReturnRate,
|
||||
newPlayerReturnSeries,
|
||||
newPlayerReturnVelocity,
|
||||
wauWeeks,
|
||||
avgWau,
|
||||
mauMonths,
|
||||
calendarMauMonths,
|
||||
sessionDepthCards,
|
||||
} = $derived(data);
|
||||
|
||||
// Collapsible table state
|
||||
let returnExpanded = $state(false);
|
||||
let wauExpanded = $state(false);
|
||||
let completionsExpanded = $state(false);
|
||||
let streakExpanded = $state(false);
|
||||
let ret7dExpanded = $state(false);
|
||||
let ret30dExpanded = $state(false);
|
||||
let mauExpanded = $state(false);
|
||||
let mauMode = $state<'rolling' | 'calendar'>('rolling');
|
||||
|
||||
function signed(n: number, unit = ""): string {
|
||||
if (n > 0) return `+${n}${unit}`;
|
||||
if (n < 0) return `${n}${unit}`;
|
||||
return `0${unit}`;
|
||||
}
|
||||
|
||||
function trendColor(n: number): string {
|
||||
if (n > 0) return "text-green-400";
|
||||
if (n < 0) return "text-red-400";
|
||||
return "text-gray-400";
|
||||
}
|
||||
|
||||
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
|
||||
|
||||
const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau)));
|
||||
|
||||
const maxStreakCount = $derived(
|
||||
Math.max(1, ...streakChart.map((r) => r.count)),
|
||||
);
|
||||
|
||||
const statCards = $derived([
|
||||
{ label: "Completions Today", value: String(stats.todayCount) },
|
||||
{ label: "All-Time Completions", value: String(stats.totalCount) },
|
||||
{ label: "Unique Players", value: String(stats.uniquePlayers) },
|
||||
{ label: "Players This Week", value: String(stats.weeklyPlayers) },
|
||||
{ label: "Active Streaks", value: String(stats.activeStreaks) },
|
||||
{ label: "Registered Users", value: String(stats.registeredUsers) },
|
||||
{ label: "Players This Month", value: String(stats.monthlyPlayers) },
|
||||
{
|
||||
label: "Overall Return Rate",
|
||||
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Global Stats | Bibdle</title>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 text-gray-100"
|
||||
>
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center gap-1 text-gray-400 hover:text-gray-100 text-sm mb-6 transition-colors"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-100">Global Stats</h1>
|
||||
<p class="text-gray-400 text-sm mt-1">
|
||||
EST reference date: {todayEst}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-10"
|
||||
>
|
||||
{#each statCards as card (card.label)}
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>{card.label}</span
|
||||
>
|
||||
<span class="text-2xl md:text-3xl font-bold text-gray-100"
|
||||
>{card.value}</span
|
||||
>
|
||||
</Container>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||
Traffic & Growth <span
|
||||
class="text-xs font-normal text-gray-400"
|
||||
>(7-day windows)</span
|
||||
>
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Completions Velocity</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
growth.completionsVelocity,
|
||||
)}">{signed(growth.completionsVelocity, "/day")}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>vs prior 7 days</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Completions Accel.</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
growth.completionsAcceleration,
|
||||
)}"
|
||||
>{signed(growth.completionsAcceleration, "/day")}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>rate of change of velocity</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>User Velocity</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
growth.userVelocity,
|
||||
)}">{signed(growth.userVelocity)}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>unique players, wk/wk</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>User Acceleration</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
growth.userAcceleration,
|
||||
)}">{signed(growth.userAcceleration)}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>rate of change of user velocity</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>New Players (7d)</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
growth.newUsers7d,
|
||||
)}">{String(growth.newUsers7d)}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>first-time players</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Churned (7d)</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
0,
|
||||
)}">{String(growth.churned7d)}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>played wk prior, not this wk</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Net Growth (7d)</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
growth.netGrowth7d,
|
||||
)}">{signed(growth.netGrowth7d)}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>new minus churned</span
|
||||
>
|
||||
</Container>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-1">Survival Curve</h2>
|
||||
<p class="text-gray-400 text-sm mb-4">
|
||||
Of players who completed N sessions, what % came back for N+1?
|
||||
</p>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{#each sessionDepthCards as card (card.depth)}
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>After {card.depth} plays</span
|
||||
>
|
||||
<span class="text-2xl md:text-3xl font-bold text-gray-100">
|
||||
{card.returnRate != null ? `${card.returnRate}%` : "N/A"}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>{card.players} players</span
|
||||
>
|
||||
</Container>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||
New Player Return Rate <span
|
||||
class="text-xs font-normal text-gray-400"
|
||||
>(7-day rolling avg)</span
|
||||
>
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Return Rate (7d avg)</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center text-gray-100"
|
||||
>
|
||||
{newPlayerReturnVelocity.current7dAvg != null
|
||||
? `${newPlayerReturnVelocity.current7dAvg}%`
|
||||
: "N/A"}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>new players who came back</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Return Rate Change</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {newPlayerReturnVelocity.change !=
|
||||
null
|
||||
? trendColor(newPlayerReturnVelocity.change)
|
||||
: 'text-gray-400'}"
|
||||
>
|
||||
{newPlayerReturnVelocity.change != null
|
||||
? signed(newPlayerReturnVelocity.change, "pp")
|
||||
: "N/A"}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>vs prior 7 days</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Prior 7d Avg</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center text-gray-100"
|
||||
>
|
||||
{newPlayerReturnVelocity.prior7dAvg != null
|
||||
? `${newPlayerReturnVelocity.prior7dAvg}%`
|
||||
: "N/A"}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>days 8–14 ago</span
|
||||
>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{#if newPlayerReturnSeries.length > 0}
|
||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
|
||||
>
|
||||
<th class="text-left px-4 py-3">Date</th>
|
||||
<th class="text-right px-4 py-3">New Players</th
|
||||
>
|
||||
<th class="text-right px-4 py-3">Return Rate</th
|
||||
>
|
||||
<th class="text-right px-4 py-3">7d Avg</th>
|
||||
<th class="px-4 py-3 w-32"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each (returnExpanded ? newPlayerReturnSeries : newPlayerReturnSeries.slice(0, 3)) as row (row.date)}
|
||||
<tr
|
||||
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-gray-300"
|
||||
>{row.date}</td
|
||||
>
|
||||
<td
|
||||
class="px-4 py-3 text-right text-gray-400 text-xs"
|
||||
>{row.cohort}</td
|
||||
>
|
||||
<td
|
||||
class="px-4 py-3 text-right text-gray-400"
|
||||
>{row.rate != null
|
||||
? `${row.rate}%`
|
||||
: "—"}</td
|
||||
>
|
||||
<td
|
||||
class="px-4 py-3 text-right text-gray-100 font-medium"
|
||||
>{row.rollingAvg != null
|
||||
? `${row.rollingAvg}%`
|
||||
: "—"}</td
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-20">
|
||||
{#if row.rollingAvg != null}
|
||||
<div
|
||||
class="bg-sky-500 h-4 rounded"
|
||||
style="width: {row.rollingAvg}%"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if newPlayerReturnSeries.length > 3}
|
||||
<button
|
||||
onclick={() => (returnExpanded = !returnExpanded)}
|
||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||
>
|
||||
<span>{returnExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-gray-400 text-sm px-4 py-6">
|
||||
Not enough data yet.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-1">
|
||||
Weekly Active Users
|
||||
</h2>
|
||||
<p class="text-gray-400 text-sm mb-4">
|
||||
Unique players per 7-day window. Most recent week first. Avg
|
||||
WAU: <span class="text-gray-100 font-medium">{avgWau}</span>
|
||||
</p>
|
||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
|
||||
>
|
||||
<th class="text-left px-4 py-3">Week</th>
|
||||
<th class="text-right px-4 py-3">Active Users</th>
|
||||
<th class="text-right px-4 py-3">Wk/Wk Change</th>
|
||||
<th class="px-4 py-3 w-48"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each (wauExpanded ? wauWeeks : wauWeeks.slice(0, 3)) as row (row.weekEnd)}
|
||||
{@const barPct = Math.round(
|
||||
(row.wau / maxWau) * 100,
|
||||
)}
|
||||
<tr
|
||||
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-gray-300 text-xs"
|
||||
>{row.weekStart} – {row.weekEnd}</td
|
||||
>
|
||||
<td
|
||||
class="px-4 py-3 text-right text-gray-100 font-medium"
|
||||
>{row.wau}</td
|
||||
>
|
||||
<td
|
||||
class="px-4 py-3 text-right text-xs font-medium {row.changePct !=
|
||||
null
|
||||
? row.changePct > 0
|
||||
? 'text-green-400'
|
||||
: row.changePct < 0
|
||||
? 'text-red-400'
|
||||
: 'text-gray-400'
|
||||
: 'text-gray-500'}"
|
||||
>
|
||||
{row.changePct != null
|
||||
? signed(row.changePct, "%")
|
||||
: "—"}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-24">
|
||||
<div
|
||||
class="bg-indigo-500 h-4 rounded"
|
||||
style="width: {barPct}%"
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if wauWeeks.length > 3}
|
||||
<button
|
||||
onclick={() => (wauExpanded = !wauExpanded)}
|
||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||
>
|
||||
<span>{wauExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h2 class="text-lg font-semibold text-gray-100">Monthly Active Users</h2>
|
||||
<div class="flex gap-1 bg-white/5 rounded-lg p-1">
|
||||
<button
|
||||
onclick={() => (mauMode = 'rolling')}
|
||||
class="px-3 py-1 text-xs rounded-md transition-colors {mauMode === 'rolling' ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
|
||||
>Rolling 30d</button>
|
||||
<button
|
||||
onclick={() => (mauMode = 'calendar')}
|
||||
class="px-3 py-1 text-xs rounded-md transition-colors {mauMode === 'calendar' ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
|
||||
>Calendar</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mb-4">
|
||||
{mauMode === 'rolling' ? 'Unique players per 30-day window. Most recent first.' : 'Unique players per calendar month. Current month projected to end of month.'}
|
||||
</p>
|
||||
|
||||
{#if mauMode === 'rolling'}
|
||||
{@const displayedMauMonths = mauExpanded ? mauMonths : mauMonths.slice(0, 3)}
|
||||
{@const maxMau = Math.max(1, ...mauMonths.map((m) => m.mau))}
|
||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
|
||||
<th class="text-left px-4 py-3">Period</th>
|
||||
<th class="text-right px-4 py-3">Active Users</th>
|
||||
<th class="text-right px-4 py-3">Mo/Mo Change</th>
|
||||
<th class="px-4 py-3 w-48"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each displayedMauMonths as row (row.monthEnd)}
|
||||
{@const barPct = Math.round((row.mau / maxMau) * 100)}
|
||||
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||
<td class="px-4 py-3 text-gray-300 text-xs">{row.monthStart} – {row.monthEnd}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{row.mau}</td>
|
||||
<td class="px-4 py-3 text-right text-xs font-medium {row.changePct != null ? row.changePct > 0 ? 'text-green-400' : row.changePct < 0 ? 'text-red-400' : 'text-gray-400' : 'text-gray-500'}">
|
||||
{row.changePct != null ? signed(row.changePct, '%') : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-24">
|
||||
<div class="bg-teal-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if mauMonths.length > 3}
|
||||
<button
|
||||
onclick={() => (mauExpanded = !mauExpanded)}
|
||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||
>
|
||||
<span>{mauExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const displayedCalMau = mauExpanded ? calendarMauMonths : calendarMauMonths.slice(0, 3)}
|
||||
{@const maxCalMau = Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau))}
|
||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
|
||||
<th class="text-left px-4 py-3">Month</th>
|
||||
<th class="text-right px-4 py-3">Active Users</th>
|
||||
<th class="text-right px-4 py-3">Mo/Mo Change</th>
|
||||
<th class="px-4 py-3 w-48"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each displayedCalMau as row (row.monthStart)}
|
||||
{@const displayMau = row.projectedMau ?? row.mau}
|
||||
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
|
||||
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||
<td class="px-4 py-3 text-gray-300">
|
||||
{row.label}
|
||||
{#if row.isCurrentMonth}
|
||||
<span class="text-xs text-gray-500 ml-1">(projected)</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-medium {row.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
|
||||
{#if row.isCurrentMonth}
|
||||
<span class="text-gray-500 text-xs">{row.mau} → </span>{row.projectedMau ?? row.mau}
|
||||
{:else}
|
||||
{row.mau}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-xs font-medium {row.changePct != null ? row.changePct > 0 ? 'text-green-400' : row.changePct < 0 ? 'text-red-400' : 'text-gray-400' : 'text-gray-500'}">
|
||||
{#if row.changePct != null}
|
||||
{row.isCurrentMonth ? '~' : ''}{signed(row.changePct, '%')}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-24">
|
||||
<div class="bg-teal-500 h-4 rounded {row.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if calendarMauMonths.length > 3}
|
||||
<button
|
||||
onclick={() => (mauExpanded = !mauExpanded)}
|
||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||
>
|
||||
<span>{mauExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||
Last 14 Days — Completions
|
||||
</h2>
|
||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
|
||||
>
|
||||
<th class="text-left px-4 py-3">Date</th>
|
||||
<th class="text-right px-4 py-3">Completions</th>
|
||||
<th class="px-4 py-3 w-48"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each (completionsExpanded ? last14Days : last14Days.slice(0, 3)) as row (row.date)}
|
||||
{@const barPct = Math.round(
|
||||
(row.count / maxCount) * 100,
|
||||
)}
|
||||
<tr
|
||||
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-gray-300"
|
||||
>{row.date}</td
|
||||
>
|
||||
<td
|
||||
class="px-4 py-3 text-right text-gray-100 font-medium"
|
||||
>{row.count}</td
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-24">
|
||||
<div
|
||||
class="bg-amber-500 h-4 rounded"
|
||||
style="width: {barPct}%"
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if last14Days.length > 3}
|
||||
<button
|
||||
onclick={() => (completionsExpanded = !completionsExpanded)}
|
||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||
>
|
||||
<span>{completionsExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||
Active Streak Distribution
|
||||
</h2>
|
||||
{#if streakChart.length === 0}
|
||||
<p class="text-gray-400 text-sm px-4 py-6">
|
||||
No active streaks yet.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
|
||||
>
|
||||
<th class="text-left px-4 py-3">Days</th>
|
||||
<th class="text-right px-4 py-3">Players</th>
|
||||
<th class="px-4 py-3 w-48"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each (streakExpanded ? streakChart : streakChart.slice(0, 3)) as row (row.days)}
|
||||
{@const barPct = Math.round(
|
||||
(row.count / maxStreakCount) * 100,
|
||||
)}
|
||||
<tr
|
||||
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-gray-300"
|
||||
>{row.days}</td
|
||||
>
|
||||
<td
|
||||
class="px-4 py-3 text-right text-gray-100 font-medium"
|
||||
>{row.count}</td
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-24">
|
||||
<div
|
||||
class="bg-blue-500 h-4 rounded"
|
||||
style="width: {barPct}%"
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if streakChart.length > 3}
|
||||
<button
|
||||
onclick={() => (streakExpanded = !streakExpanded)}
|
||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||
>
|
||||
<span>{streakExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-1">
|
||||
Retention Over Time
|
||||
</h2>
|
||||
<p class="text-gray-400 text-sm mb-6">
|
||||
% of each day's players who played again exactly 7 or 30 days later (regardless of activity in between). Cohorts with fewer than 3 players are excluded.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 7-day retention -->
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-200 mb-3">
|
||||
7-Day Retention
|
||||
</h3>
|
||||
{#if retention7dSeries.length === 0}
|
||||
<p class="text-gray-400 text-sm px-4 py-6">
|
||||
Not enough data yet.
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="overflow-x-auto rounded-xl border border-white/10"
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
|
||||
>
|
||||
<th class="text-left px-4 py-3"
|
||||
>Cohort Date</th
|
||||
>
|
||||
<th class="text-right px-4 py-3">n</th>
|
||||
<th class="text-right px-4 py-3"
|
||||
>Ret. %</th
|
||||
>
|
||||
<th class="px-4 py-3 w-32"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each (ret7dExpanded ? retention7dSeries : retention7dSeries.slice(0, 3)) as row (row.date)}
|
||||
<tr
|
||||
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-gray-300"
|
||||
>{row.date}</td
|
||||
>
|
||||
<td
|
||||
class="px-4 py-3 text-right text-gray-400 text-xs"
|
||||
>{row.cohortSize}</td
|
||||
>
|
||||
<td
|
||||
class="px-4 py-3 text-right text-gray-100 font-medium"
|
||||
>{row.rate}%</td
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-20">
|
||||
<div
|
||||
class="bg-emerald-500 h-4 rounded"
|
||||
style="width: {row.rate}%"
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if retention7dSeries.length > 3}
|
||||
<button
|
||||
onclick={() => (ret7dExpanded = !ret7dExpanded)}
|
||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||
>
|
||||
<span>{ret7dExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 30-day retention -->
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-200 mb-3">
|
||||
30-Day Retention
|
||||
</h3>
|
||||
{#if retention30dSeries.length === 0}
|
||||
<p class="text-gray-400 text-sm px-4 py-6">
|
||||
Not enough data yet.
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="overflow-x-auto rounded-xl border border-white/10"
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr
|
||||
class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide"
|
||||
>
|
||||
<th class="text-left px-4 py-3"
|
||||
>Cohort Date</th
|
||||
>
|
||||
<th class="text-right px-4 py-3">n</th>
|
||||
<th class="text-right px-4 py-3"
|
||||
>Ret. %</th
|
||||
>
|
||||
<th class="px-4 py-3 w-32"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each (ret30dExpanded ? retention30dSeries : retention30dSeries.slice(0, 3)) as row (row.date)}
|
||||
<tr
|
||||
class="border-t border-white/5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-gray-300"
|
||||
>{row.date}</td
|
||||
>
|
||||
<td
|
||||
class="px-4 py-3 text-right text-gray-400 text-xs"
|
||||
>{row.cohortSize}</td
|
||||
>
|
||||
<td
|
||||
class="px-4 py-3 text-right text-gray-100 font-medium"
|
||||
>{row.rate}%</td
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-20">
|
||||
<div
|
||||
class="bg-violet-500 h-4 rounded"
|
||||
style="width: {row.rate}%"
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if retention30dSeries.length > 3}
|
||||
<button
|
||||
onclick={() => (ret30dExpanded = !ret30dExpanded)}
|
||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||
>
|
||||
<span>{ret30dExpanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,12 +2,29 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--font-triodion: "PT Serif", serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: oklch(89.126% 0.06134 298.626);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not(.light), body:not(.light) {
|
||||
background: oklch(18% 0.03 298.626);
|
||||
}
|
||||
}
|
||||
|
||||
html.dark, html.dark body {
|
||||
background: oklch(18% 0.03 298.626);
|
||||
}
|
||||
|
||||
html.light, html.light body {
|
||||
background: oklch(89.126% 0.06134 298.626);
|
||||
}
|
||||
|
||||
.big-text {
|
||||
@@ -16,4 +33,50 @@ html, body {
|
||||
letter-spacing: 0.2em;
|
||||
color: rgb(107 114 128);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not(.light) .big-text {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .big-text {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
|
||||
html.light .big-text {
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
|
||||
/* Page load animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.8s ease-out both;
|
||||
}
|
||||
|
||||
.animate-delay-200 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.animate-delay-400 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.animate-delay-600 {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
.animate-delay-800 {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
278
src/routes/progress/+page.server.ts
Normal file
278
src/routes/progress/+page.server.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
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';
|
||||
|
||||
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 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 };
|
||||
};
|
||||
|
||||
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 },
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
} 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);
|
||||
}
|
||||
692
src/routes/progress/+page.svelte
Normal file
692
src/routes/progress/+page.svelte
Normal file
@@ -0,0 +1,692 @@
|
||||
<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 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;
|
||||
};
|
||||
};
|
||||
|
||||
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-amber-400 text-amber-900";
|
||||
case "mastered":
|
||||
return "bg-emerald-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-emerald-400"
|
||||
suffix="/ 66"
|
||||
/>
|
||||
<ProgressStatCard
|
||||
emoji="⭐"
|
||||
value={String(prog.booksPerfect)}
|
||||
label="Books Perfected"
|
||||
colorClass="text-amber-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-emerald-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-amber-400"
|
||||
></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-emerald-400 font-medium"
|
||||
>Mastered</span
|
||||
>
|
||||
— avg ≤ 3 guesses over 2+ plays<br />
|
||||
<span class="text-amber-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 -->
|
||||
{#if 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}
|
||||
|
||||
<!-- Milestones -->
|
||||
{#if prog.bestSingleGame || prog.streakMilestones.days7 || prog.streakMilestones.days14 || prog.streakMilestones.days30}
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-100 mb-3">
|
||||
Milestones
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{#if prog.bestSingleGame}
|
||||
<Container class="p-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-1">⚡</div>
|
||||
<div
|
||||
class="text-sm font-bold text-yellow-300 leading-tight"
|
||||
>
|
||||
{prog.bestSingleGame.bookName}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-300 font-medium mt-1"
|
||||
>
|
||||
First 1-Guess Win
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] text-gray-500 mt-0.5"
|
||||
>
|
||||
{formatDate(prog.bestSingleGame.date)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{#if prog.streakMilestones.days7}
|
||||
<Container class="p-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-1">🔥</div>
|
||||
<div
|
||||
class="text-sm font-bold text-orange-300 leading-tight"
|
||||
>
|
||||
7-Day Streak
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-300 font-medium mt-1"
|
||||
>
|
||||
First Achieved
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] text-gray-500 mt-0.5"
|
||||
>
|
||||
{formatDate(
|
||||
prog.streakMilestones.days7,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{#if prog.streakMilestones.days14}
|
||||
<Container class="p-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-1">💥</div>
|
||||
<div
|
||||
class="text-sm font-bold text-orange-400 leading-tight"
|
||||
>
|
||||
14-Day Streak
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-300 font-medium mt-1"
|
||||
>
|
||||
First Achieved
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] text-gray-500 mt-0.5"
|
||||
>
|
||||
{formatDate(
|
||||
prog.streakMilestones.days14,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
{#if prog.streakMilestones.days30}
|
||||
<Container class="p-4">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl mb-1">🏅</div>
|
||||
<div
|
||||
class="text-sm font-bold text-amber-300 leading-tight"
|
||||
>
|
||||
30-Day Streak
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-gray-300 font-medium mt-1"
|
||||
>
|
||||
First Achieved
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] text-gray-500 mt-0.5"
|
||||
>
|
||||
{formatDate(
|
||||
prog.streakMilestones.days30,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
</div>
|
||||
</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} />
|
||||
23
src/routes/sitemap.xml/+server.ts
Normal file
23
src/routes/sitemap.xml/+server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = () => {
|
||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://bibdle.com/</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://bibdle.com/about</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
</urlset>`;
|
||||
|
||||
return new Response(sitemap, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,24 +1,42 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions, type DailyCompletion } from '$lib/server/db/schema';
|
||||
import { dailyCompletions, dailyVerses, type DailyCompletion } from '$lib/server/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { bibleBooks } from '$lib/types/bible';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const anonymousId = url.searchParams.get('anonymousId');
|
||||
|
||||
if (!anonymousId) {
|
||||
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
// Check if user is authenticated
|
||||
if (!locals.user) {
|
||||
return {
|
||||
stats: null,
|
||||
error: 'No anonymous ID provided'
|
||||
error: null,
|
||||
user: null,
|
||||
session: null,
|
||||
requiresAuth: true
|
||||
};
|
||||
}
|
||||
|
||||
const userId = locals.user.id;
|
||||
|
||||
if (!userId) {
|
||||
return {
|
||||
stats: null,
|
||||
error: 'No user ID provided',
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
}
|
||||
|
||||
// Note: userToday is used only for the initial server-side streak estimate.
|
||||
// The client overrides this with a precise local-date calculation via /api/streak.
|
||||
const userToday = new Date().toISOString().slice(0, 10); // UTC date as safe fallback
|
||||
|
||||
try {
|
||||
// Get all completions for this user
|
||||
const completions = await db
|
||||
.select()
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.anonymousId, anonymousId))
|
||||
.where(eq(dailyCompletions.anonymousId, userId))
|
||||
.orderBy(desc(dailyCompletions.date));
|
||||
|
||||
if (completions.length === 0) {
|
||||
@@ -38,8 +56,15 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
},
|
||||
currentStreak: 0,
|
||||
bestStreak: 0,
|
||||
recentCompletions: []
|
||||
}
|
||||
recentCompletions: [],
|
||||
worstDay: null,
|
||||
bestBook: null,
|
||||
mostSeenBook: null,
|
||||
totalBooksSeenOT: 0,
|
||||
totalBooksSeenNT: 0
|
||||
},
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,30 +85,33 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
'C': completions.filter((c: DailyCompletion) => c.guessCount > 15).length
|
||||
};
|
||||
|
||||
// Calculate streaks
|
||||
// Calculate streaks — dates are stored as the user's local date
|
||||
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];
|
||||
// Use the user's local date passed from the client
|
||||
const today = userToday;
|
||||
const yesterdayDate = new Date(userToday);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = yesterdayDate.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 {
|
||||
@@ -91,14 +119,14 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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 {
|
||||
@@ -118,6 +146,66 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
grade: getGradeFromGuesses(c.guessCount)
|
||||
}));
|
||||
|
||||
// Calculate worst day (highest guess count)
|
||||
const worstDay = completions.reduce((max, c) =>
|
||||
c.guessCount > max.guessCount ? c : max,
|
||||
completions[0]
|
||||
);
|
||||
|
||||
// Get all daily verses to link completions to books
|
||||
const allVerses = await db
|
||||
.select()
|
||||
.from(dailyVerses);
|
||||
|
||||
// Create a map of date -> bookId
|
||||
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
|
||||
|
||||
// Calculate book-specific stats
|
||||
const bookStats = new Map<string, { count: number; totalGuesses: number }>();
|
||||
|
||||
for (const completion of completions) {
|
||||
const bookId = dateToBookId.get(completion.date);
|
||||
if (bookId) {
|
||||
const existing = bookStats.get(bookId) || { count: 0, totalGuesses: 0 };
|
||||
bookStats.set(bookId, {
|
||||
count: existing.count + 1,
|
||||
totalGuesses: existing.totalGuesses + completion.guessCount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find book you know the best (lowest avg guesses)
|
||||
let bestBook: { bookId: string; avgGuesses: number; count: number } | null = null;
|
||||
for (const [bookId, stats] of bookStats.entries()) {
|
||||
const avgGuesses = stats.totalGuesses / stats.count;
|
||||
if (!bestBook || avgGuesses < bestBook.avgGuesses) {
|
||||
bestBook = { bookId, avgGuesses, count: stats.count };
|
||||
}
|
||||
}
|
||||
|
||||
// Find most seen book
|
||||
let mostSeenBook: { bookId: string; count: number } | null = null;
|
||||
for (const [bookId, stats] of bookStats.entries()) {
|
||||
if (!mostSeenBook || stats.count > mostSeenBook.count) {
|
||||
mostSeenBook = { bookId, count: stats.count };
|
||||
}
|
||||
}
|
||||
|
||||
// Count unique books by testament
|
||||
const oldTestamentBooks = new Set<string>();
|
||||
const newTestamentBooks = new Set<string>();
|
||||
|
||||
for (const [bookId, _] of bookStats.entries()) {
|
||||
const book = bibleBooks.find(b => b.id === bookId);
|
||||
if (book) {
|
||||
if (book.testament === 'old') {
|
||||
oldTestamentBooks.add(bookId);
|
||||
} else {
|
||||
newTestamentBooks.add(bookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stats: {
|
||||
totalSolves,
|
||||
@@ -125,15 +213,34 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
gradeDistribution,
|
||||
currentStreak,
|
||||
bestStreak,
|
||||
recentCompletions
|
||||
}
|
||||
recentCompletions,
|
||||
worstDay: {
|
||||
date: worstDay.date,
|
||||
guessCount: worstDay.guessCount
|
||||
},
|
||||
bestBook: bestBook ? {
|
||||
bookId: bestBook.bookId,
|
||||
avgGuesses: Math.round(bestBook.avgGuesses * 100) / 100,
|
||||
count: bestBook.count
|
||||
} : null,
|
||||
mostSeenBook: mostSeenBook ? {
|
||||
bookId: mostSeenBook.bookId,
|
||||
count: mostSeenBook.count
|
||||
} : null,
|
||||
totalBooksSeenOT: oldTestamentBooks.size,
|
||||
totalBooksSeenNT: newTestamentBooks.size
|
||||
},
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching user stats:', error);
|
||||
return {
|
||||
stats: null,
|
||||
error: 'Failed to fetch stats'
|
||||
error: 'Failed to fetch stats',
|
||||
user: locals.user,
|
||||
session: locals.session
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -146,4 +253,4 @@ function getGradeFromGuesses(guessCount: number): string {
|
||||
if (guessCount >= 7 && guessCount <= 10) return "B";
|
||||
if (guessCount >= 11 && guessCount <= 15) return "C+";
|
||||
return "C";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getGradeColor,
|
||||
formatDate,
|
||||
getStreakMessage,
|
||||
getPerformanceMessage,
|
||||
type UserStats
|
||||
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
import {
|
||||
formatDate,
|
||||
type UserStats,
|
||||
} from "$lib/utils/stats";
|
||||
import { fetchStreak } from "$lib/utils/streak";
|
||||
|
||||
interface PageData {
|
||||
stats: UserStats | null;
|
||||
error?: string;
|
||||
user?: any;
|
||||
session?: any;
|
||||
requiresAuth?: boolean;
|
||||
}
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let authModalOpen = $state(false);
|
||||
let anonymousId = $state("");
|
||||
|
||||
let loading = $state(true);
|
||||
let currentStreak = $state(0);
|
||||
|
||||
function getOrCreateAnonymousId(): string {
|
||||
if (!browser) return "";
|
||||
@@ -31,25 +37,18 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const anonymousId = getOrCreateAnonymousId();
|
||||
if (!anonymousId) {
|
||||
goto("/");
|
||||
return;
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
if (data.user?.id) {
|
||||
const localDate = new Date().toLocaleDateString("en-CA");
|
||||
currentStreak = await fetchStreak(data.user.id, localDate);
|
||||
} else {
|
||||
currentStreak = data.stats?.currentStreak ?? 0;
|
||||
}
|
||||
|
||||
// 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;
|
||||
function getBookName(bookId: string): string {
|
||||
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
||||
}
|
||||
|
||||
$inspect(data);
|
||||
@@ -57,36 +56,74 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>Stats | Bibdle</title>
|
||||
<meta name="description" content="View your Bibdle game statistics and performance" />
|
||||
<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">
|
||||
<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-6xl 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 class="text-center mb-6 md:mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">
|
||||
Your Stats
|
||||
</h1>
|
||||
<p class="text-sm md:text-base text-gray-300 mb-4">
|
||||
Track your Bibdle performance over time
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
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"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
</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
|
||||
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-300">Loading your stats...</p>
|
||||
</div>
|
||||
{:else 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 stats.
|
||||
</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-100 border border-red-300 rounded-lg p-6 max-w-md mx-auto">
|
||||
<p class="text-red-700">{data.error}</p>
|
||||
<a
|
||||
href="/"
|
||||
<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
|
||||
@@ -95,112 +132,209 @@
|
||||
</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"
|
||||
<Container class="p-8 max-w-md mx-auto">
|
||||
<div class="text-yellow-400 mb-4 text-lg">
|
||||
No stats available yet.
|
||||
</div>
|
||||
<p class="text-gray-300 mb-6">
|
||||
Start playing to build your stats!
|
||||
</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>
|
||||
</div>
|
||||
</Container>
|
||||
</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">
|
||||
|
||||
<!-- Key Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-6">
|
||||
<!-- Current Streak -->
|
||||
<Container class="p-4 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 class="text-2xl md:text-3xl mb-1">🔥</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
||||
>
|
||||
{currentStreak}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Current Streak
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Longest Streak -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">⭐</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-purple-400 mb-1"
|
||||
>
|
||||
{stats.bestStreak}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Best Streak
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Average Guesses -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<Container class="p-4 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 class="text-2xl md:text-3xl mb-1">🎯</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-blue-400 mb-1"
|
||||
>
|
||||
{stats.avgGuesses}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Avg Guesses
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- Total Solves -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl md:text-3xl mb-1">✅</div>
|
||||
<div
|
||||
class="text-2xl md:text-3xl font-bold text-green-400 mb-1"
|
||||
>
|
||||
{stats.totalSolves}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||
>
|
||||
Total Solves
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</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>
|
||||
<!-- Book Stats Grid -->
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 mb-6"
|
||||
>
|
||||
<!-- Worst Day -->
|
||||
{#if stats.worstDay}
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">😅</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Worst Day
|
||||
</div>
|
||||
<div
|
||||
class="text-xl md:text-2xl font-bold text-red-400 truncate"
|
||||
>
|
||||
{stats.worstDay.guessCount} guesses
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-400"
|
||||
>
|
||||
{formatDate(stats.worstDay.date)}
|
||||
</div>
|
||||
</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>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<!-- 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>
|
||||
<!-- Best Book -->
|
||||
{#if stats.bestBook}
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">🏆</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Best Book
|
||||
</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
|
||||
class="text-lg md:text-xl font-bold text-amber-400 truncate"
|
||||
>
|
||||
{getBookName(stats.bestBook.bookId)}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-400"
|
||||
>
|
||||
{stats.bestBook.avgGuesses} avg guesses ({stats
|
||||
.bestBook.count}x)
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<!-- Most Seen Book -->
|
||||
{#if stats.mostSeenBook}
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">📖</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Most Seen Book
|
||||
</div>
|
||||
<div
|
||||
class="text-lg md:text-xl font-bold text-indigo-400 truncate"
|
||||
>
|
||||
{getBookName(stats.mostSeenBook.bookId)}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm text-gray-400"
|
||||
>
|
||||
{stats.mostSeenBook.count} time{stats
|
||||
.mostSeenBook.count === 1
|
||||
? ""
|
||||
: "s"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<!-- Total Books Seen -->
|
||||
<Container class="p-4 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-3xl md:text-4xl">📚</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||
>
|
||||
Unique Books
|
||||
</div>
|
||||
<div
|
||||
class="text-xl md:text-2xl font-bold text-teal-400"
|
||||
>
|
||||
{stats.totalBooksSeenOT +
|
||||
stats.totalBooksSeenNT}
|
||||
</div>
|
||||
<div class="text-xs md:text-sm text-gray-400">
|
||||
OT: {stats.totalBooksSeenOT} / NT: {stats.totalBooksSeenNT}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
|
||||
|
||||
15
static/about.md
Normal file
15
static/about.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# About Bibdle
|
||||
|
||||
Bibdle is a daily Bible guessing game. Every day, a random verse is posted to the website. Try to figure out which book of the Bible it comes from, in as few guesses as possible. That's it!
|
||||
|
||||
---
|
||||
|
||||
The game was built with the hope that it would be a small, delightful thing people can make part of their day. It's not a Bible study course. You don't need to know the Bible inside and out to enjoy it or to learn something from it.
|
||||
|
||||
If you're someone who grew up in church and can name all 66 books in order, great. If you're someone who can barely tell the Old Testament from the New, that's great too. The game meets you where you are.
|
||||
|
||||
It is completely free. If you'd like to support the developer (who works solely on small projects like this one) or express your thanks, you can become a Bibdle patron.
|
||||
|
||||
If you use Bibdle, I would love to hear from you! I can be reached via email or through Bluesky.
|
||||
|
||||
<!-- social -->
|
||||
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.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
47
static/how-to-play.md
Normal file
47
static/how-to-play.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# How to Play
|
||||
|
||||
Each day, Bibdle gives you a verse from the Bible. Your job is to guess which book it comes from. (Genesis, John, Corinthians, etc.)
|
||||
|
||||
You have unlimited guesses.
|
||||
|
||||
---
|
||||
|
||||
## The Basics
|
||||
|
||||
1. **Read the verse.** It appears at the top of the page.
|
||||
2. **Make a guess.** Type or select a book of the Bible from the list.
|
||||
3. **Read the feedback.** After each guess, you'll get clues telling you how close you were.
|
||||
4. **Keep guessing** until you get it right.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Feedback Hints
|
||||
|
||||
After each wrong guess, you'll see the following hints:
|
||||
|
||||
| Hint | What it means |
|
||||
|---|---|
|
||||
| **Testament** | If your guess was in the correct Testament (Old or New) |
|
||||
| **Section** | If your guess was in the correct section of the Bible (e.g. Gospels, Epistles, Major Prophets) |
|
||||
| **First Letter** | If your guess has the same first letter as the correct guess |
|
||||
|
||||
Use the hints to narrow down your search.
|
||||
|
||||
---
|
||||
|
||||
## A Few Things to Know
|
||||
|
||||
- **Everyone plays the same verse each day.** The daily verse resets at midnight.
|
||||
- **Your progress is saved automatically.** You can close the tab and come back later.
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- Pay attention to writing style: the voice of Psalms is very different from Paul's letters.
|
||||
- Historical narrative (battles, kings, genealogies) tends to be Old Testament.
|
||||
- Short, poetic, or wisdom-focused verses could be Proverbs, Ecclesiastes, or Psalms.
|
||||
- If a verse mentions Jesus by name, it's in the New Testament.
|
||||
|
||||
Good luck!
|
||||
@@ -1,3 +1,5 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
||||
Sitemap: https://bibdle.com/sitemap.xml
|
||||
|
||||
@@ -7,7 +7,13 @@ const config = {
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: { adapter: adapter() }
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
csrf: {
|
||||
// Allow Apple Sign In cross-origin form_post callback
|
||||
trustedOrigins: ['https://appleid.apple.com']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
234
tests/bible.test.ts
Normal file
234
tests/bible.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
getBookById,
|
||||
getBookByNumber,
|
||||
getBooksByTestament,
|
||||
getBooksBySection,
|
||||
isAdjacent,
|
||||
bookNumberToId,
|
||||
bookIdToNumber,
|
||||
bibleBooks,
|
||||
} from "$lib/server/bible";
|
||||
|
||||
describe("bibleBooks data integrity", () => {
|
||||
test("contains exactly 66 books", () => {
|
||||
expect(bibleBooks).toHaveLength(66);
|
||||
});
|
||||
|
||||
test("order numbers are 1 through 66 with no gaps or duplicates", () => {
|
||||
const orders = bibleBooks.map((b) => b.order).sort((a, b) => a - b);
|
||||
for (let i = 0; i < 66; i++) {
|
||||
expect(orders[i]).toBe(i + 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("book IDs are unique", () => {
|
||||
const ids = bibleBooks.map((b) => b.id);
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(66);
|
||||
});
|
||||
|
||||
test("every book has a non-empty name", () => {
|
||||
for (const book of bibleBooks) {
|
||||
expect(book.name.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("Old Testament has 39 books", () => {
|
||||
expect(bibleBooks.filter((b) => b.testament === "old")).toHaveLength(39);
|
||||
});
|
||||
|
||||
test("New Testament has 27 books", () => {
|
||||
expect(bibleBooks.filter((b) => b.testament === "new")).toHaveLength(27);
|
||||
});
|
||||
|
||||
test("Genesis is first and Revelation is last", () => {
|
||||
const sorted = [...bibleBooks].sort((a, b) => a.order - b.order);
|
||||
expect(sorted[0].id).toBe("GEN");
|
||||
expect(sorted[65].id).toBe("REV");
|
||||
});
|
||||
|
||||
test("Matthew is the first New Testament book", () => {
|
||||
const nt = bibleBooks
|
||||
.filter((b) => b.testament === "new")
|
||||
.sort((a, b) => a.order - b.order);
|
||||
expect(nt[0].id).toBe("MAT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("section counts", () => {
|
||||
test("Law: 5 books", () => {
|
||||
expect(getBooksBySection("Law")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("History: 13 books (12 OT + Acts)", () => {
|
||||
expect(getBooksBySection("History")).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("Wisdom: 5 books", () => {
|
||||
expect(getBooksBySection("Wisdom")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("Major Prophets: 5 books", () => {
|
||||
expect(getBooksBySection("Major Prophets")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("Minor Prophets: 12 books", () => {
|
||||
expect(getBooksBySection("Minor Prophets")).toHaveLength(12);
|
||||
});
|
||||
|
||||
test("Gospels: 4 books", () => {
|
||||
expect(getBooksBySection("Gospels")).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("Pauline Epistles: 13 books", () => {
|
||||
expect(getBooksBySection("Pauline Epistles")).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("General Epistles: 8 books", () => {
|
||||
expect(getBooksBySection("General Epistles")).toHaveLength(8);
|
||||
});
|
||||
|
||||
test("Apocalyptic: 1 book (Revelation)", () => {
|
||||
const books = getBooksBySection("Apocalyptic");
|
||||
expect(books).toHaveLength(1);
|
||||
expect(books[0].id).toBe("REV");
|
||||
});
|
||||
|
||||
test("all sections sum to 66", () => {
|
||||
const total =
|
||||
getBooksBySection("Law").length +
|
||||
getBooksBySection("History").length +
|
||||
getBooksBySection("Wisdom").length +
|
||||
getBooksBySection("Major Prophets").length +
|
||||
getBooksBySection("Minor Prophets").length +
|
||||
getBooksBySection("Gospels").length +
|
||||
getBooksBySection("Pauline Epistles").length +
|
||||
getBooksBySection("General Epistles").length +
|
||||
getBooksBySection("Apocalyptic").length;
|
||||
expect(total).toBe(66);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBookById", () => {
|
||||
test("returns Genesis for GEN", () => {
|
||||
const book = getBookById("GEN");
|
||||
expect(book).toBeDefined();
|
||||
expect(book!.name).toBe("Genesis");
|
||||
});
|
||||
|
||||
test("returns Revelation for REV", () => {
|
||||
const book = getBookById("REV");
|
||||
expect(book!.name).toBe("Revelation");
|
||||
});
|
||||
|
||||
test("returns undefined for unknown ID", () => {
|
||||
expect(getBookById("UNKNOWN")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBookByNumber", () => {
|
||||
test("1 → Genesis", () => {
|
||||
expect(getBookByNumber(1)!.id).toBe("GEN");
|
||||
});
|
||||
|
||||
test("66 → Revelation", () => {
|
||||
expect(getBookByNumber(66)!.id).toBe("REV");
|
||||
});
|
||||
|
||||
test("40 → Matthew (first NT book)", () => {
|
||||
expect(getBookByNumber(40)!.id).toBe("MAT");
|
||||
});
|
||||
|
||||
test("0 → undefined", () => {
|
||||
expect(getBookByNumber(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("67 → undefined", () => {
|
||||
expect(getBookByNumber(67)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBooksByTestament", () => {
|
||||
test("old returns 39 books", () => {
|
||||
expect(getBooksByTestament("old")).toHaveLength(39);
|
||||
});
|
||||
|
||||
test("new returns 27 books", () => {
|
||||
expect(getBooksByTestament("new")).toHaveLength(27);
|
||||
});
|
||||
|
||||
test("all OT books have testament = old", () => {
|
||||
for (const book of getBooksByTestament("old")) {
|
||||
expect(book.testament).toBe("old");
|
||||
}
|
||||
});
|
||||
|
||||
test("all NT books have testament = new", () => {
|
||||
for (const book of getBooksByTestament("new")) {
|
||||
expect(book.testament).toBe("new");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAdjacent", () => {
|
||||
test("Genesis and Exodus are adjacent", () => {
|
||||
expect(isAdjacent("GEN", "EXO")).toBe(true);
|
||||
});
|
||||
|
||||
test("adjacency is symmetric", () => {
|
||||
expect(isAdjacent("EXO", "GEN")).toBe(true);
|
||||
});
|
||||
|
||||
test("Malachi and Matthew are adjacent across testament boundary", () => {
|
||||
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
|
||||
});
|
||||
|
||||
test("Jude and Revelation are adjacent", () => {
|
||||
expect(isAdjacent("JUD", "REV")).toBe(true); // 65, 66
|
||||
});
|
||||
|
||||
test("same book is not adjacent to itself", () => {
|
||||
expect(isAdjacent("GEN", "GEN")).toBe(false);
|
||||
});
|
||||
|
||||
test("books two apart are not adjacent", () => {
|
||||
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
|
||||
});
|
||||
|
||||
test("returns false for invalid IDs", () => {
|
||||
expect(isAdjacent("FAKE", "GEN")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bookNumberToId / bookIdToNumber lookup tables", () => {
|
||||
test("bookNumberToId[1] is GEN", () => {
|
||||
expect(bookNumberToId[1]).toBe("GEN");
|
||||
});
|
||||
|
||||
test("bookNumberToId[66] is REV", () => {
|
||||
expect(bookNumberToId[66]).toBe("REV");
|
||||
});
|
||||
|
||||
test("bookIdToNumber['GEN'] is 1", () => {
|
||||
expect(bookIdToNumber["GEN"]).toBe(1);
|
||||
});
|
||||
|
||||
test("bookIdToNumber['REV'] is 66", () => {
|
||||
expect(bookIdToNumber["REV"]).toBe(66);
|
||||
});
|
||||
|
||||
test("round-trip: number → ID → number", () => {
|
||||
for (let i = 1; i <= 66; i++) {
|
||||
const id = bookNumberToId[i];
|
||||
expect(bookIdToNumber[id]).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
test("round-trip: ID → number → ID", () => {
|
||||
for (const book of bibleBooks) {
|
||||
const num = bookIdToNumber[book.id];
|
||||
expect(bookNumberToId[num]).toBe(book.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
271
tests/game.test.ts
Normal file
271
tests/game.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
evaluateGuess,
|
||||
getBookById,
|
||||
getFirstLetter,
|
||||
getGrade,
|
||||
getNextGradeMessage,
|
||||
isAdjacent,
|
||||
toOrdinal,
|
||||
} from "$lib/utils/game";
|
||||
|
||||
describe("getBookById", () => {
|
||||
test("returns correct book for a valid ID", () => {
|
||||
const book = getBookById("GEN");
|
||||
expect(book).toBeDefined();
|
||||
expect(book!.name).toBe("Genesis");
|
||||
expect(book!.order).toBe(1);
|
||||
});
|
||||
|
||||
test("returns the last book by ID", () => {
|
||||
const book = getBookById("REV");
|
||||
expect(book).toBeDefined();
|
||||
expect(book!.name).toBe("Revelation");
|
||||
expect(book!.order).toBe(66);
|
||||
});
|
||||
|
||||
test("returns undefined for an invalid ID", () => {
|
||||
expect(getBookById("INVALID")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for an empty string", () => {
|
||||
expect(getBookById("")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("is case-sensitive", () => {
|
||||
expect(getBookById("gen")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAdjacent", () => {
|
||||
test("consecutive books are adjacent", () => {
|
||||
expect(isAdjacent("GEN", "EXO")).toBe(true); // 1, 2
|
||||
});
|
||||
|
||||
test("adjacency is symmetric", () => {
|
||||
expect(isAdjacent("EXO", "GEN")).toBe(true);
|
||||
});
|
||||
|
||||
test("books two apart are not adjacent", () => {
|
||||
expect(isAdjacent("GEN", "LEV")).toBe(false); // 1, 3
|
||||
});
|
||||
|
||||
test("the same book is not adjacent to itself", () => {
|
||||
expect(isAdjacent("GEN", "GEN")).toBe(false); // diff = 0
|
||||
});
|
||||
|
||||
test("works across testament boundary (Malachi / Matthew)", () => {
|
||||
expect(isAdjacent("MAL", "MAT")).toBe(true); // 39, 40
|
||||
});
|
||||
|
||||
test("far-apart books are not adjacent", () => {
|
||||
expect(isAdjacent("GEN", "REV")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for unknown IDs", () => {
|
||||
expect(isAdjacent("FAKE", "GEN")).toBe(false);
|
||||
expect(isAdjacent("GEN", "FAKE")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFirstLetter", () => {
|
||||
test("returns first letter of a normal book name", () => {
|
||||
expect(getFirstLetter("Genesis")).toBe("G");
|
||||
expect(getFirstLetter("Revelation")).toBe("R");
|
||||
});
|
||||
|
||||
test("skips leading digits and returns first letter", () => {
|
||||
expect(getFirstLetter("1 Samuel")).toBe("S");
|
||||
expect(getFirstLetter("2 Kings")).toBe("K");
|
||||
expect(getFirstLetter("1 Corinthians")).toBe("C");
|
||||
expect(getFirstLetter("3 John")).toBe("J");
|
||||
});
|
||||
|
||||
test("returns first letter of multi-word names", () => {
|
||||
expect(getFirstLetter("Song of Solomon")).toBe("S");
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateGuess", () => {
|
||||
test("returns null for an invalid guess ID", () => {
|
||||
expect(evaluateGuess("INVALID", "GEN")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for an invalid correct ID", () => {
|
||||
expect(evaluateGuess("GEN", "INVALID")).toBeNull();
|
||||
});
|
||||
|
||||
test("exact book match: testamentMatch and sectionMatch are true, adjacent is false", () => {
|
||||
const result = evaluateGuess("GEN", "GEN");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.book.id).toBe("GEN");
|
||||
expect(result!.testamentMatch).toBe(true);
|
||||
expect(result!.sectionMatch).toBe(true);
|
||||
expect(result!.adjacent).toBe(false);
|
||||
});
|
||||
|
||||
test("same section implies same testament", () => {
|
||||
// Genesis and Exodus are both OT Law
|
||||
const result = evaluateGuess("GEN", "EXO");
|
||||
expect(result!.testamentMatch).toBe(true);
|
||||
expect(result!.sectionMatch).toBe(true);
|
||||
expect(result!.adjacent).toBe(true);
|
||||
});
|
||||
|
||||
test("same testament, different section", () => {
|
||||
// Genesis (Law) vs Joshua (History) — both OT
|
||||
const result = evaluateGuess("GEN", "JOS");
|
||||
expect(result!.testamentMatch).toBe(true);
|
||||
expect(result!.sectionMatch).toBe(false);
|
||||
expect(result!.adjacent).toBe(false);
|
||||
});
|
||||
|
||||
test("different testament, no match", () => {
|
||||
// Genesis (OT) vs Matthew (NT)
|
||||
const result = evaluateGuess("GEN", "MAT");
|
||||
expect(result!.testamentMatch).toBe(false);
|
||||
expect(result!.sectionMatch).toBe(false);
|
||||
expect(result!.adjacent).toBe(false);
|
||||
});
|
||||
|
||||
test("adjacent books across testament boundary", () => {
|
||||
// Malachi (OT, 39) and Matthew (NT, 40)
|
||||
const result = evaluateGuess("MAL", "MAT");
|
||||
expect(result!.adjacent).toBe(true);
|
||||
expect(result!.testamentMatch).toBe(false);
|
||||
expect(result!.sectionMatch).toBe(false);
|
||||
});
|
||||
|
||||
test("adjacent books within same testament and section", () => {
|
||||
// Hosea (28) and Joel (29), both Minor Prophets
|
||||
const result = evaluateGuess("HOS", "JOL");
|
||||
expect(result!.adjacent).toBe(true);
|
||||
expect(result!.testamentMatch).toBe(true);
|
||||
expect(result!.sectionMatch).toBe(true);
|
||||
});
|
||||
|
||||
test("firstLetterMatch: same first letter", () => {
|
||||
// Genesis and Galatians both start with G
|
||||
const result = evaluateGuess("GEN", "GAL");
|
||||
expect(result!.firstLetterMatch).toBe(true);
|
||||
});
|
||||
|
||||
test("firstLetterMatch: different first letter", () => {
|
||||
// Genesis (G) vs Matthew (M)
|
||||
const result = evaluateGuess("GEN", "MAT");
|
||||
expect(result!.firstLetterMatch).toBe(false);
|
||||
});
|
||||
|
||||
test("firstLetterMatch is case-insensitive", () => {
|
||||
// Both start with J but from different contexts — Jeremiah vs Joel
|
||||
const result = evaluateGuess("JER", "JOL");
|
||||
expect(result!.firstLetterMatch).toBe(true);
|
||||
});
|
||||
|
||||
test("special case: two Epistle '1' books always firstLetterMatch", () => {
|
||||
// 1 Corinthians (Pauline) vs 1 John (General) — both Epistles starting with "1"
|
||||
const result = evaluateGuess("1CO", "1JN");
|
||||
expect(result!.firstLetterMatch).toBe(true);
|
||||
});
|
||||
|
||||
test("special case: Epistle '1' book vs non-Epistle '1' book — no special treatment", () => {
|
||||
// 1 Corinthians (Pauline Epistles) vs 1 Samuel (History)
|
||||
// Correct is NOT Epistles, so special case doesn't apply
|
||||
const result = evaluateGuess("1CO", "1SA");
|
||||
// getFirstLetter("1 Corinthians") = "C", getFirstLetter("1 Samuel") = "S" → false
|
||||
expect(result!.firstLetterMatch).toBe(false);
|
||||
});
|
||||
|
||||
test("special case only triggers when BOTH are Epistle '1' books", () => {
|
||||
// 2 Corinthians (Pauline, starts with "2") vs 1 John (General, starts with "1")
|
||||
// guessIsEpistlesWithNumber requires name[0] === "1", so 2CO fails
|
||||
const result = evaluateGuess("2CO", "1JN");
|
||||
// getFirstLetter("2 Corinthians") = "C", getFirstLetter("1 John") = "J" → false
|
||||
expect(result!.firstLetterMatch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGrade", () => {
|
||||
test("1 guess → S+", () => expect(getGrade(1)).toBe("S+"));
|
||||
test("2 guesses → A+", () => expect(getGrade(2)).toBe("A+"));
|
||||
test("3 guesses → A", () => expect(getGrade(3)).toBe("A"));
|
||||
test("4 guesses → B+", () => expect(getGrade(4)).toBe("B+"));
|
||||
test("6 guesses → B+", () => expect(getGrade(6)).toBe("B+"));
|
||||
test("7 guesses → B", () => expect(getGrade(7)).toBe("B"));
|
||||
test("10 guesses → B", () => expect(getGrade(10)).toBe("B"));
|
||||
test("11 guesses → C+", () => expect(getGrade(11)).toBe("C+"));
|
||||
test("15 guesses → C+", () => expect(getGrade(15)).toBe("C+"));
|
||||
test("16 guesses → C", () => expect(getGrade(16)).toBe("C"));
|
||||
test("100 guesses → C", () => expect(getGrade(100)).toBe("C"));
|
||||
});
|
||||
|
||||
describe("getNextGradeMessage", () => {
|
||||
test("returns empty string at top grade", () => {
|
||||
expect(getNextGradeMessage(1)).toBe("");
|
||||
});
|
||||
|
||||
test("grade A+ shows 1 guess threshold", () => {
|
||||
expect(getNextGradeMessage(2)).toBe("Next grade: 1 guess or less");
|
||||
});
|
||||
|
||||
test("grade A shows 2 guess threshold", () => {
|
||||
expect(getNextGradeMessage(3)).toBe("Next grade: 2 guesses or less");
|
||||
});
|
||||
|
||||
test("grade B+ shows 3 guess threshold", () => {
|
||||
expect(getNextGradeMessage(4)).toBe("Next grade: 3 guesses or less");
|
||||
expect(getNextGradeMessage(6)).toBe("Next grade: 3 guesses or less");
|
||||
});
|
||||
|
||||
test("grade B shows 6 guess threshold", () => {
|
||||
expect(getNextGradeMessage(7)).toBe("Next grade: 6 guesses or less");
|
||||
expect(getNextGradeMessage(10)).toBe("Next grade: 6 guesses or less");
|
||||
});
|
||||
|
||||
test("grade C+ shows 10 guess threshold", () => {
|
||||
expect(getNextGradeMessage(11)).toBe("Next grade: 10 guesses or less");
|
||||
expect(getNextGradeMessage(15)).toBe("Next grade: 10 guesses or less");
|
||||
});
|
||||
|
||||
test("grade C shows 15 guess threshold", () => {
|
||||
expect(getNextGradeMessage(16)).toBe("Next grade: 15 guesses or less");
|
||||
expect(getNextGradeMessage(50)).toBe("Next grade: 15 guesses or less");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toOrdinal", () => {
|
||||
test("1st, 2nd, 3rd", () => {
|
||||
expect(toOrdinal(1)).toBe("1st");
|
||||
expect(toOrdinal(2)).toBe("2nd");
|
||||
expect(toOrdinal(3)).toBe("3rd");
|
||||
});
|
||||
|
||||
test("4-10 use th", () => {
|
||||
expect(toOrdinal(4)).toBe("4th");
|
||||
expect(toOrdinal(10)).toBe("10th");
|
||||
});
|
||||
|
||||
test("11, 12, 13 use th (not st/nd/rd)", () => {
|
||||
expect(toOrdinal(11)).toBe("11th");
|
||||
expect(toOrdinal(12)).toBe("12th");
|
||||
expect(toOrdinal(13)).toBe("13th");
|
||||
});
|
||||
|
||||
test("21, 22, 23 use st/nd/rd", () => {
|
||||
expect(toOrdinal(21)).toBe("21st");
|
||||
expect(toOrdinal(22)).toBe("22nd");
|
||||
expect(toOrdinal(23)).toBe("23rd");
|
||||
});
|
||||
|
||||
test("101, 102, 103 use st/nd/rd", () => {
|
||||
expect(toOrdinal(101)).toBe("101st");
|
||||
expect(toOrdinal(102)).toBe("102nd");
|
||||
expect(toOrdinal(103)).toBe("103rd");
|
||||
});
|
||||
|
||||
test("111, 112, 113 use th", () => {
|
||||
expect(toOrdinal(111)).toBe("111th");
|
||||
expect(toOrdinal(112)).toBe("112th");
|
||||
expect(toOrdinal(113)).toBe("113th");
|
||||
});
|
||||
});
|
||||
339
tests/share.test.ts
Normal file
339
tests/share.test.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { generateShareText, getVerseSnippet } from "$lib/utils/share";
|
||||
import { getBookById } from "$lib/utils/game";
|
||||
import type { Guess } from "$lib/utils/game";
|
||||
|
||||
// Helpers to build Guess objects without calling evaluateGuess
|
||||
function makeGuess(bookId: string, overrides: Partial<Omit<Guess, "book">> = {}): Guess {
|
||||
const book = getBookById(bookId)!;
|
||||
return {
|
||||
book,
|
||||
testamentMatch: false,
|
||||
sectionMatch: false,
|
||||
adjacent: false,
|
||||
firstLetterMatch: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const CORRECT_BOOK_ID = "GEN";
|
||||
|
||||
const exactGuess = makeGuess("GEN", {
|
||||
testamentMatch: true,
|
||||
sectionMatch: true,
|
||||
});
|
||||
|
||||
const adjacentGuess = makeGuess("EXO", {
|
||||
testamentMatch: true,
|
||||
sectionMatch: true,
|
||||
adjacent: true,
|
||||
});
|
||||
|
||||
const sectionGuess = makeGuess("LEV", {
|
||||
testamentMatch: true,
|
||||
sectionMatch: true,
|
||||
});
|
||||
|
||||
const testamentGuess = makeGuess("JOS", {
|
||||
testamentMatch: true,
|
||||
sectionMatch: false,
|
||||
});
|
||||
|
||||
const noMatchGuess = makeGuess("MAT", {
|
||||
testamentMatch: false,
|
||||
sectionMatch: false,
|
||||
});
|
||||
|
||||
describe("generateShareText — emoji mapping", () => {
|
||||
test("exact match → ✅", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("✅");
|
||||
});
|
||||
|
||||
test("adjacent book → ‼️", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [adjacentGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("‼️");
|
||||
});
|
||||
|
||||
test("section match → 🟩", () => {
|
||||
// LEV matches section (Law) but is not adjacent to GEN (order 1 vs 3)
|
||||
const text = generateShareText({
|
||||
guesses: [sectionGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("🟩");
|
||||
});
|
||||
|
||||
test("testament match only → 🟧", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [testamentGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("🟧");
|
||||
});
|
||||
|
||||
test("no match → 🟥", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [noMatchGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "In the beginning...",
|
||||
});
|
||||
expect(text).toContain("🟥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — guess count wording", () => {
|
||||
test("1 guess uses singular 'guess'", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("1 guess,");
|
||||
});
|
||||
|
||||
test("multiple guesses uses plural 'guesses'", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [noMatchGuess, testamentGuess, exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("3 guesses,");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — streak display", () => {
|
||||
test("streak > 1 is shown with fire emoji", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
streak: 5,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("5 days 🔥");
|
||||
});
|
||||
|
||||
test("streak of 1 is not shown", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
streak: 1,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).not.toContain("🔥");
|
||||
});
|
||||
|
||||
test("undefined streak is not shown", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).not.toContain("🔥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — chapter star", () => {
|
||||
test("1 guess + chapterCorrect → ⭐ appended to emoji line", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: true,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("✅ ⭐");
|
||||
});
|
||||
|
||||
test("multiple guesses + chapterCorrect → no star (only awarded for hole-in-one)", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [noMatchGuess, exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: true,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).not.toContain("⭐");
|
||||
});
|
||||
|
||||
test("1 guess + chapterCorrect false → no star", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).not.toContain("⭐");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — login book emoji", () => {
|
||||
test("logged in uses 📜", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: true,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("📜");
|
||||
expect(text).not.toContain("📖");
|
||||
});
|
||||
|
||||
test("not logged in uses 📖", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("📖");
|
||||
expect(text).not.toContain("📜");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — date formatting", () => {
|
||||
test("date is formatted as 'Mon DD, YYYY'", () => {
|
||||
const text = generateShareText({
|
||||
guesses: [exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
expect(text).toContain("Jan 15, 2025");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateShareText — guess order", () => {
|
||||
test("guesses are reversed in the emoji line (first guess last)", () => {
|
||||
// noMatchGuess first, then exactGuess — reversed output: ✅🟥
|
||||
const text = generateShareText({
|
||||
guesses: [noMatchGuess, exactGuess],
|
||||
correctBookId: CORRECT_BOOK_ID,
|
||||
dailyVerseDate: "2025-01-15",
|
||||
chapterCorrect: false,
|
||||
isLoggedIn: false,
|
||||
origin: "https://bibdle.com",
|
||||
verseText: "...",
|
||||
});
|
||||
const lines = text.split("\n");
|
||||
expect(lines[2]).toBe("✅🟥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVerseSnippet", () => {
|
||||
test("wraps output in curly double quotes", () => {
|
||||
const result = getVerseSnippet("Hello world");
|
||||
expect(result.startsWith("\u201C")).toBe(true);
|
||||
expect(result.endsWith("\u201D")).toBe(true);
|
||||
});
|
||||
|
||||
test("short verse (fewer than 10 words) returns full text", () => {
|
||||
const result = getVerseSnippet("For God so loved");
|
||||
// No punctuation search happens, returns all words
|
||||
expect(result).toContain("For God so loved");
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
test("verse with no punctuation in range returns first 25 words", () => {
|
||||
const words = Array.from({ length: 30 }, (_, i) => `word${i + 1}`);
|
||||
const verse = words.join(" ");
|
||||
const result = getVerseSnippet(verse);
|
||||
// Should contain up to 25 words
|
||||
expect(result).toContain("word25");
|
||||
expect(result).not.toContain("word26");
|
||||
});
|
||||
|
||||
test("truncates at punctuation between words 10 and 25", () => {
|
||||
// 12 words before comma, rest after
|
||||
const verse =
|
||||
"one two three four five six seven eight nine ten eleven twelve, thirteen fourteen fifteen twenty";
|
||||
const result = getVerseSnippet(verse);
|
||||
// The comma is after word 12, which is between word 10 and 25
|
||||
expect(result).toContain("twelve");
|
||||
expect(result).not.toContain("thirteen");
|
||||
});
|
||||
|
||||
test("punctuation before word 10 does not trigger truncation", () => {
|
||||
// Comma is after word 5 — before the search window starts at word 10
|
||||
const verse =
|
||||
"one two three four five, six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen";
|
||||
const result = getVerseSnippet(verse);
|
||||
// The comma at word 5 is before start of search range, so we continue
|
||||
// The snippet should contain word 10 at minimum
|
||||
expect(result).toContain("ten");
|
||||
});
|
||||
|
||||
test("does not include trailing whitespace before ellipsis", () => {
|
||||
const verse =
|
||||
"one two three four five six seven eight nine ten eleven twelve, rest of verse here";
|
||||
const result = getVerseSnippet(verse);
|
||||
// trimEnd is applied before adding ..., so no space before ...
|
||||
expect(result).not.toMatch(/\s\.\.\./);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user