mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-06-25 08:45:22 -04:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a372db9b2c | |||
| 1c2f214963 | |||
| f7efe6738d | |||
| f5e16c7e71 | |||
| e45ac28169 | |||
| 3d578a9eb8 | |||
| db04da6a2c | |||
| 321fac9aa8 | |||
| 4a5aef5a3d | |||
| 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(./secrets/**)",
|
||||||
"Read(./config/credentials.json)",
|
"Read(./config/credentials.json)",
|
||||||
"Read(./build)",
|
"Read(./build)",
|
||||||
"Read(./**.xml)",
|
|
||||||
"Read(./embeddings**)"
|
"Read(./embeddings**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
DATABASE_URL=example.db
|
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
|
AUTH_SECRET=your-random-secret-here
|
||||||
APPLE_ID=com.yourcompany.yourapp.client
|
APPLE_ID=com.yourcompany.yourapp.client
|
||||||
APPLE_TEAM_ID=your-team-id
|
APPLE_TEAM_ID=your-team-id
|
||||||
|
|||||||
+5
-1
@@ -28,4 +28,8 @@ vite.config.ts.timestamp-*
|
|||||||
llms-*
|
llms-*
|
||||||
|
|
||||||
embeddings*
|
embeddings*
|
||||||
*.xml
|
*bible.xml
|
||||||
|
engwebu_usfx.xml
|
||||||
|
|
||||||
|
deploy.log
|
||||||
|
bibdle.socket
|
||||||
|
|||||||
@@ -27,19 +27,14 @@ After calling the list-sections tool, you MUST analyze the returned documentatio
|
|||||||
Analyzes Svelte code and returns issues and suggestions.
|
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.
|
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||||
|
|
||||||
### 4. playground-link
|
|
||||||
|
|
||||||
Generates a Svelte Playground link with the provided code.
|
|
||||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
|
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
|
||||||
- **Styling**: Tailwind CSS 4
|
- **Styling**: Tailwind CSS 4
|
||||||
- **Database**: SQLite with Drizzle ORM
|
- **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
|
- **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
|
## Development Commands
|
||||||
|
|
||||||
@@ -51,6 +46,11 @@ bun run dev
|
|||||||
bun run check
|
bun run check
|
||||||
bun run check:watch
|
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
|
# Build for production
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
@@ -58,92 +58,108 @@ bun run build
|
|||||||
bun run preview
|
bun run preview
|
||||||
|
|
||||||
# Database operations
|
# 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:generate # Generate migrations
|
||||||
bun run db:migrate # Run migrations
|
bun run db:migrate # Run migrations
|
||||||
bun run db:studio # Open Drizzle Studio GUI
|
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
|
## Architecture
|
||||||
|
|
||||||
### Database Schema (`src/lib/server/db/schema.ts`)
|
### Database Schema (`src/lib/server/db/schema.ts`)
|
||||||
|
|
||||||
- **user**: User accounts with id and age
|
- **user**: `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `isPrivate`
|
||||||
- **session**: Auth sessions linked to users with expiration timestamps
|
- **session**: `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
|
||||||
- **daily_verses**: Cached daily verses with book ID, verse text, reference, and date
|
- **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`)
|
### Bible Data (`src/lib/types/bible.ts`)
|
||||||
|
|
||||||
The `bibleBooks` array contains all 66 Bible books with metadata:
|
The `bibleBooks` array contains all 66 Bible books with metadata:
|
||||||
- Testament (old/new)
|
- Testament (old/new), Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
|
||||||
- Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
|
|
||||||
- Order (1-66, used for adjacency detection)
|
- Order (1-66, used for adjacency detection)
|
||||||
- Popularity (2-10, affects grading - higher is more popular)
|
|
||||||
|
|
||||||
### Daily Verse System (`src/routes/+page.server.ts`)
|
### Daily Verse System (`src/routes/+page.server.ts`)
|
||||||
|
|
||||||
The `getTodayVerse()` function:
|
`getTodayVerse()` checks the database for today's date, fetches a verse if missing, caches permanently, and returns verse with book metadata.
|
||||||
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
|
|
||||||
|
|
||||||
### Game Logic (`src/routes/+page.svelte`)
|
### Game Logic (`src/routes/+page.svelte`)
|
||||||
|
|
||||||
**State Management:**
|
**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
|
- 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:**
|
**Hint System, for share grid:**
|
||||||
```javascript
|
- ✅ Exact match | 🟩 Section match | 🟧 Testament match | ‼️ Adjacent book | 🟥 No match
|
||||||
// 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
|
|
||||||
|
|
||||||
### Authentication System (`src/lib/server/auth.ts`)
|
### Authentication System (`src/lib/server/auth.ts`)
|
||||||
|
|
||||||
- Token-based sessions with SHA-256 hashing
|
- Token generation: base64-encoded random bytes; stored as SHA-256 hash in DB
|
||||||
- Cookies store session tokens, validated on each request
|
- Cookie name: `auth-session`
|
||||||
- Hook in `src/hooks.server.ts` populates `event.locals.user` and `event.locals.session`
|
- Anonymous users: identified by a client-generated ID; stats migrate on sign-up via `migrateAnonymousStats()`
|
||||||
- Note: Currently the schema includes user table but auth UI is not yet implemented
|
- 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
|
## Key Files
|
||||||
|
|
||||||
- `src/routes/+page.svelte` - Main game UI and client-side logic
|
- `src/routes/+page.svelte` — Main game UI and client-side logic
|
||||||
- `src/routes/+page.server.ts` - Server load function, fetches/caches daily verse
|
- `src/routes/+page.server.ts` / `+page.ts` — Server load (verse) + client load (`ssr: false`)
|
||||||
- `src/lib/server/bible-api.ts` - External API integration for verse fetching
|
- `src/routes/stats/+page.svelte` / `+page.server.ts` — Stats UI and server calculations
|
||||||
- `src/lib/server/bible.ts` - Bible book utility functions
|
- `src/lib/server/auth.ts` — Session management, password hashing, anonymous migration
|
||||||
- `src/lib/types/bible.ts` - Bible books data and TypeScript types
|
- `src/lib/server/bible-api.ts` — Random verse fetching from local XML Bible
|
||||||
- `src/lib/server/db/schema.ts` - Drizzle ORM schema definitions
|
- `src/lib/server/bible.ts` — Bible book utility functions
|
||||||
- `src/hooks.server.ts` - SvelteKit server hook for session validation
|
- `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
|
## Environment Variables
|
||||||
|
|
||||||
Required in `.env`:
|
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
|
## 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 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.
|
||||||
- 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
|
|
||||||
|
|||||||
+2
-2
@@ -24143,7 +24143,7 @@
|
|||||||
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
|
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
|
||||||
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, ‘Where is their God?’ ”</verse>
|
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, ‘Where is their God?’ ”</verse>
|
||||||
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
|
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
|
||||||
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
|
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.”</verse>
|
||||||
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
|
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
|
||||||
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
|
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
|
||||||
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
|
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
|
||||||
@@ -33616,4 +33616,4 @@
|
|||||||
</chapter>
|
</chapter>
|
||||||
</book>
|
</book>
|
||||||
</testament>
|
</testament>
|
||||||
</bible>
|
</bible>
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ If you're seeing this, you've probably already done this step. Congrats!
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# create a new project in the current directory
|
# create a new project in the current directory
|
||||||
npx sv create
|
bunx sv create
|
||||||
|
|
||||||
# create a new project in my-app
|
# create a new project in my-app
|
||||||
npx sv create my-app
|
bunx sv create my-app
|
||||||
```
|
```
|
||||||
|
|
||||||
## Developing
|
## 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:
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run dev
|
bun run dev
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
# or start the server and open the app in a new browser tab
|
||||||
npm run dev -- --open
|
bun run dev -- --open
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
@@ -30,9 +30,9 @@ npm run dev -- --open
|
|||||||
To create a production version of your app:
|
To create a production version of your app:
|
||||||
|
|
||||||
```sh
|
```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.
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
|
|||||||
+8
-7
@@ -1,18 +1,19 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Bibdle SvelteKit App
|
Description=Bibdle SvelteKit App
|
||||||
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
||||||
Requires=bibdle.socket
|
After=network-online.target
|
||||||
After=network-online.target bibdle.socket
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
Environment=ORIGIN=https://bibdle.com
|
Environment=ORIGIN=https://bibdle.com
|
||||||
Environment=DATABASE_URL=local.db
|
Environment=DATABASE_URL=prod.db
|
||||||
Environment=IDLE_TIMEOUT=60
|
Environment=IDLE_TIMEOUT=300
|
||||||
WorkingDirectory=/home/george/projects/bibdle
|
Environment=PORT=5173
|
||||||
ExecStart=/home/george/.nvm/versions/node/v24.12.0/bin/node build/index.js
|
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
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
[Socket]
|
|
||||||
ListenStream=5173
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=sockets.target
|
|
||||||
+385
@@ -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 |
@@ -3,16 +3,24 @@ set -e
|
|||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
BUN=$(which bun)
|
||||||
|
|
||||||
echo "Pulling latest changes..."
|
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..."
|
echo "Installing dependencies..."
|
||||||
bun i
|
$BUN i
|
||||||
|
|
||||||
echo "Building..."
|
echo "Building..."
|
||||||
bun run build
|
$BUN --bun run build
|
||||||
|
|
||||||
echo "Restarting service..."
|
SERVICE_NAME="$(basename "$(pwd)").service"
|
||||||
sudo systemctl restart bibdle
|
echo "Restarting service ($SERVICE_NAME)..."
|
||||||
|
sudo systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
echo "Done!"
|
echo "Done!"
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `daily_completions` ADD `guesses` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `google_id` text;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_google_id_unique` ON `user` (`google_id`);
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "80883fb9-70cd-4fa5-b228-36358ffc4c40",
|
||||||
|
"prevId": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"guesses": {
|
||||||
|
"name": "guesses",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"google_id": {
|
||||||
|
"name": "google_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
|
||||||
|
},
|
||||||
|
"user_google_id_unique": {
|
||||||
|
"name": "user_google_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"google_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,27 @@
|
|||||||
"when": 1765934144883,
|
"when": 1765934144883,
|
||||||
"tag": "0000_clumsy_impossible_man",
|
"tag": "0000_clumsy_impossible_man",
|
||||||
"breakpoints": true
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1774416309647,
|
||||||
|
"tag": "0003_overjoyed_mindworm",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Executable
+87
@@ -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"
|
||||||
+6
-5
@@ -4,26 +4,26 @@
|
|||||||
"version": "3.0.0alpha",
|
"version": "3.0.0alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "bun --bun vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"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:push": "drizzle-kit push",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@oslojs/crypto": "^1.0.1",
|
|
||||||
"@oslojs/encoding": "^1.1.0",
|
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/bun": "^1.3.8",
|
||||||
"@types/node": "^22.19.7",
|
"@types/node": "^22.19.7",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
@@ -35,8 +35,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"better-sqlite3": "^12.6.2",
|
"drizzle": "^1.4.0",
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+31
@@ -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
|
||||||
Executable
+20
@@ -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
|
||||||
Executable
+34
@@ -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';"
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
Executable
+26
@@ -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"
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<script src="https://rybbit.snail.city/api/script.js" data-site-id="9abf0e81d024" defer></script>
|
||||||
|
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
+1
-1
@@ -32,4 +32,4 @@ export const handle: Handle = handleAuth;
|
|||||||
|
|
||||||
// Initialize embeddings on server start (runs once on module load)
|
// Initialize embeddings on server start (runs once on module load)
|
||||||
const verses = getAllNKJVVerses();
|
const verses = getAllNKJVVerses();
|
||||||
await initializeEmbeddings(verses);
|
// await initializeEmbeddings(verses);
|
||||||
|
|||||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
@@ -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>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<form method="POST" action="/auth/google">
|
||||||
|
<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 mt-3"
|
||||||
|
data-umami-event="Sign in with Google"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24">
|
||||||
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||||
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||||
|
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
|
||||||
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</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;
|
return chapterCounts[bookId] || 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 6 random chapter options including the correct one
|
// Generate 4 random chapter options including the correct one
|
||||||
function generateChapterOptions(
|
function generateChapterOptions(
|
||||||
correctChapter: number,
|
correctChapter: number,
|
||||||
totalChapters: number,
|
totalChapters: number,
|
||||||
@@ -98,14 +98,14 @@
|
|||||||
const options = new Set<number>();
|
const options = new Set<number>();
|
||||||
options.add(correctChapter);
|
options.add(correctChapter);
|
||||||
|
|
||||||
if (totalChapters >= 6) {
|
if (totalChapters >= 4) {
|
||||||
while (options.size < 6) {
|
while (options.size < 4) {
|
||||||
const randomChapter =
|
const randomChapter =
|
||||||
Math.floor(Math.random() * totalChapters) + 1;
|
Math.floor(Math.random() * totalChapters) + 1;
|
||||||
options.add(randomChapter);
|
options.add(randomChapter);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while (options.size < 6) {
|
while (options.size < 4) {
|
||||||
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||||
options.add(randomChapter);
|
options.add(randomChapter);
|
||||||
}
|
}
|
||||||
@@ -167,18 +167,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Container
|
<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">
|
<div class="text-center">
|
||||||
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
|
<p class="font-bold mb-3 text-lg sm:text-xl">
|
||||||
<p class="text-sm sm:text-base opacity-80 mb-6">
|
Bonus Challenge
|
||||||
Guess the chapter for an even higher grade
|
<span class="text-base sm:text-lg opacity-60 font-normal"
|
||||||
|
>— guess the chapter for an even higher grade</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div class="grid grid-cols-4 gap-2 justify-center mx-auto mb-3">
|
||||||
class="grid grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
|
{#each chapterOptions as chapter (chapter)}
|
||||||
>
|
|
||||||
{#each chapterOptions as chapter}
|
|
||||||
<button
|
<button
|
||||||
onclick={() => handleChapterSelect(chapter)}
|
onclick={() => handleChapterSelect(chapter)}
|
||||||
disabled={hasAnswered}
|
disabled={hasAnswered}
|
||||||
@@ -193,8 +193,8 @@
|
|||||||
? isCorrect
|
? isCorrect
|
||||||
? "bg-green-500 text-white border-green-600 shadow-lg"
|
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||||
: "bg-red-400 text-white border-red-500"
|
: "bg-red-400 text-white border-red-500"
|
||||||
: "bg-white/30 text-gray-400 border-gray-300 opacity-40"
|
: "bg-white/30 dark:bg-white/10 text-gray-400 border-gray-300 dark:border-gray-600 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/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"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts" generics="T">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Header {
|
||||||
|
label: string;
|
||||||
|
align?: 'left' | 'right';
|
||||||
|
width?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Mode {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rows: T[];
|
||||||
|
headers: Header[];
|
||||||
|
row: Snippet<[item: T]>;
|
||||||
|
empty?: Snippet;
|
||||||
|
initialRows?: number;
|
||||||
|
modes?: Mode[];
|
||||||
|
mode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
rows,
|
||||||
|
headers,
|
||||||
|
row: rowSnippet,
|
||||||
|
empty,
|
||||||
|
initialRows = 3,
|
||||||
|
modes,
|
||||||
|
mode = $bindable(modes && modes.length > 0 ? modes[0].value : undefined),
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
// Reset expanded when mode changes (e.g. switching Rolling 30d ↔ Calendar)
|
||||||
|
$effect(() => {
|
||||||
|
mode;
|
||||||
|
expanded = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleExpanded() {
|
||||||
|
expanded = !expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayedRows = $derived(expanded ? rows : rows.slice(0, initialRows));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if modes && modes.length > 1}
|
||||||
|
<div class="flex gap-1 bg-white/5 rounded-lg p-1 w-fit ml-auto mb-3">
|
||||||
|
{#each modes as m (m.value)}
|
||||||
|
{@const active = mode === m.value}
|
||||||
|
<button
|
||||||
|
onclick={() => (mode = m.value)}
|
||||||
|
class="px-3 py-1 text-xs rounded-md transition-colors {active ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if rows.length === 0}
|
||||||
|
{#if empty}
|
||||||
|
{@render empty()}
|
||||||
|
{:else}
|
||||||
|
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
|
||||||
|
{#each headers as header (header.label)}
|
||||||
|
<th
|
||||||
|
class="{header.align === 'right' ? 'text-right' : 'text-left'} px-4 py-3{header.width ? ' ' + header.width : ''}"
|
||||||
|
>
|
||||||
|
{header.label}
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each displayedRows as item, i (i)}
|
||||||
|
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||||
|
{@render rowSnippet(item)}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if rows.length > initialRows}
|
||||||
|
<button
|
||||||
|
onclick={toggleExpanded}
|
||||||
|
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||||
|
>
|
||||||
|
<span>{expanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<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()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,88 +1,97 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
|
||||||
let timeUntilNext = $state("");
|
let timeUntilNext = $state("");
|
||||||
let intervalId: number | null = null;
|
let newVerseReady = $state(false);
|
||||||
|
let showEncouragement = $state(false);
|
||||||
|
let intervalId: number | null = null;
|
||||||
|
let targetTime = 0;
|
||||||
|
|
||||||
function calculateTimeUntilFivePM(): string {
|
function initTarget() {
|
||||||
const now = new Date();
|
const target = new Date();
|
||||||
const target = new Date(now);
|
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
|
function updateTimer() {
|
||||||
target.setHours(17, 0, 0, 0);
|
const diff = targetTime - Date.now();
|
||||||
|
|
||||||
// If it's already past 5:00 PM, set target to tomorrow 5:00 PM
|
if (diff <= 0) {
|
||||||
if (now.getTime() >= target.getTime()) {
|
newVerseReady = true;
|
||||||
target.setDate(target.getDate() + 1);
|
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) {
|
timeUntilNext = `${hours.toString().padStart(2, "0")}h ${minutes
|
||||||
return "00:00:00";
|
.toString()
|
||||||
}
|
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||||
|
}
|
||||||
|
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
onMount(() => {
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
initTarget();
|
||||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
updateTimer();
|
||||||
|
intervalId = window.setInterval(updateTimer, 1000);
|
||||||
|
|
||||||
return `${hours.toString().padStart(2, "0")}h ${minutes
|
const winCount = Object.keys(localStorage).filter(
|
||||||
.toString()
|
(k) =>
|
||||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
k.startsWith("bibdle-win-tracked-") &&
|
||||||
}
|
localStorage.getItem(k) === "true",
|
||||||
|
).length;
|
||||||
|
showEncouragement = winCount < 3;
|
||||||
|
});
|
||||||
|
|
||||||
function calculateTimeUntilMidnight(): string {
|
onDestroy(() => {
|
||||||
const now = new Date();
|
if (intervalId) {
|
||||||
const target = new Date(now);
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full flex flex-col flex-1">
|
||||||
<div
|
<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"
|
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"
|
||||||
>
|
>
|
||||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2">
|
{#if newVerseReady}
|
||||||
Next Verse In
|
<p
|
||||||
</p>
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||||
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
>
|
||||||
{timeUntilNext}
|
Next Verse In
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<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 showEncouragement}
|
||||||
|
<p
|
||||||
|
class="text-xs text-center uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold px-4 mt-3"
|
||||||
|
>
|
||||||
|
Come back tomorrow for a new verse!
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
import SocialLinks from "$lib/components/SocialLinks.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
||||||
<div
|
<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.
|
A project by George Powell & Silent Summit Co.
|
||||||
</p>
|
</p>
|
||||||
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||||
@@ -25,38 +27,8 @@
|
|||||||
<!-- Bluesky Social Media Button -->
|
<!-- Bluesky Social Media Button -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex items-center justify-center gap-6">
|
<div class="mt-8">
|
||||||
<a
|
<SocialLinks />
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,44 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
import Button from "$lib/components/Button.svelte";
|
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() {
|
function clearLocalStorage() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
// Clear all bibdle-related localStorage items
|
// Clear all bibdle-related localStorage items
|
||||||
@@ -22,6 +59,41 @@
|
|||||||
<div class="border-t-2 border-gray-400"></div>
|
<div class="border-t-2 border-gray-400"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-3">
|
<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">
|
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -86,4 +158,21 @@
|
|||||||
>
|
>
|
||||||
Clear LocalStorage
|
Clear LocalStorage
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { guessCount }: { guessCount: number } = $props();
|
||||||
|
|
||||||
|
let promptText = $state("What book of the Bible is this verse from?");
|
||||||
|
let visible = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
let fadeOutId: ReturnType<typeof setTimeout>;
|
||||||
|
let fadeInId: ReturnType<typeof setTimeout>;
|
||||||
|
let changeId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
function animateTo(newText: string, delay = 0) {
|
||||||
|
fadeOutId = setTimeout(() => {
|
||||||
|
visible = false;
|
||||||
|
changeId = setTimeout(() => {
|
||||||
|
promptText = newText;
|
||||||
|
visible = true;
|
||||||
|
}, 300);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guessCount === 0) {
|
||||||
|
animateTo("What book of the Bible is this verse from?");
|
||||||
|
} else {
|
||||||
|
animateTo("Guess again", 2100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(fadeOutId);
|
||||||
|
clearTimeout(fadeInId);
|
||||||
|
clearTimeout(changeId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="big-text text-center text-gray-800 dark:text-gray-100 mb-6 px-4"
|
||||||
|
style="transition: opacity 0.3s ease; opacity: {visible ? 1 : 0};"
|
||||||
|
>
|
||||||
|
{promptText}
|
||||||
|
</p>
|
||||||
@@ -1,26 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks } from "$lib/types/bible";
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
import Container from "./Container.svelte";
|
import { getFirstLetter, type Guess } from "$lib/utils/game";
|
||||||
|
|
||||||
interface Guess {
|
|
||||||
book: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
testament: string;
|
|
||||||
section: string;
|
|
||||||
};
|
|
||||||
testamentMatch: boolean;
|
|
||||||
sectionMatch: boolean;
|
|
||||||
adjacent: boolean;
|
|
||||||
firstLetterMatch: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
guesses,
|
guesses,
|
||||||
correctBookId,
|
correctBookId,
|
||||||
}: { guesses: Guess[]; correctBookId: string } = $props();
|
minimized = false,
|
||||||
|
}: { guesses: Guess[]; correctBookId: string; minimized?: boolean } = $props();
|
||||||
|
|
||||||
let hasGuesses = $derived(guesses.length > 0);
|
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 {
|
function getBoxColor(isCorrect: boolean, isAdjacent?: boolean): string {
|
||||||
if (isCorrect) return "bg-green-500 border-green-600";
|
if (isCorrect) return "bg-green-500 border-green-600";
|
||||||
@@ -28,6 +22,13 @@
|
|||||||
return "bg-red-500 border-red-600";
|
return "bg-red-500 border-red-600";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBookBoxStyle(guess: Guess): string {
|
||||||
|
if (guess.book.id === correctBookId) {
|
||||||
|
return "background-color: #22c55e; border-color: #16a34a;";
|
||||||
|
}
|
||||||
|
return "background-color: #ef4444; border-color: #dc2626;";
|
||||||
|
}
|
||||||
|
|
||||||
function getBoxContent(
|
function getBoxContent(
|
||||||
guess: Guess,
|
guess: Guess,
|
||||||
column: "book" | "firstLetter" | "testament" | "section",
|
column: "book" | "firstLetter" | "testament" | "section",
|
||||||
@@ -44,16 +45,27 @@
|
|||||||
(correctBook?.section === "Pauline Epistles" ||
|
(correctBook?.section === "Pauline Epistles" ||
|
||||||
correctBook?.section === "General Epistles") &&
|
correctBook?.section === "General Epistles") &&
|
||||||
correctBook.name[0] === "1";
|
correctBook.name[0] === "1";
|
||||||
const guessStartsWithNumber = guess.book.name[0] === "1";
|
const guessIsEpistlesWithNumber =
|
||||||
|
(guess.book.section === "Pauline Epistles" ||
|
||||||
|
guess.book.section === "General Epistles") &&
|
||||||
|
guess.book.name[0] === "1";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
correctIsEpistlesWithNumber &&
|
correctIsEpistlesWithNumber &&
|
||||||
guessStartsWithNumber &&
|
guessIsEpistlesWithNumber &&
|
||||||
guess.firstLetterMatch
|
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":
|
case "testament":
|
||||||
return (
|
return (
|
||||||
guess.book.testament.charAt(0).toUpperCase() +
|
guess.book.testament.charAt(0).toUpperCase() +
|
||||||
@@ -65,82 +77,63 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !hasGuesses}
|
{#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}
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<!-- Column Headers -->
|
<!-- Column Headers -->
|
||||||
<div
|
<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
|
<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"
|
||||||
>
|
|
||||||
Book
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
|
||||||
>
|
>
|
||||||
Testament
|
Testament
|
||||||
</div>
|
</div>
|
||||||
<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
|
Section
|
||||||
</div>
|
</div>
|
||||||
<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
|
First Letter
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Book
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each guesses as guess, rowIndex (guess.book.id)}
|
{#if showMinimized && !expanded}
|
||||||
<div class="flex gap-2 justify-center">
|
<!-- Minimized view: first guess, divider, last two guesses -->
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
|
<!-- First guess (no animation since post-win) -->
|
||||||
|
{@const firstGuess = guesses[0]}
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
<!-- Testament Column -->
|
<!-- Testament Column -->
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
guess.testamentMatch,
|
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"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "testament")}</span
|
>{getBoxContent(firstGuess, "testament")}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section Column -->
|
<!-- Section Column -->
|
||||||
<div
|
<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(
|
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,
|
firstGuess.sectionMatch,
|
||||||
guess.adjacent,
|
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"
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
>{getBoxContent(guess, "section")}
|
>{getBoxContent(firstGuess, "section")}
|
||||||
{#if guess.adjacent}
|
{#if firstGuess.adjacent}
|
||||||
‼️
|
‼️
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
@@ -149,18 +142,173 @@
|
|||||||
<!-- First Letter Column -->
|
<!-- First Letter Column -->
|
||||||
<div
|
<div
|
||||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
guess.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"
|
<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>
|
||||||
</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>
|
||||||
<!-- </div> -->
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
+208
-203
@@ -1,241 +1,246 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
interface ImposterData {
|
interface ImposterData {
|
||||||
verses: string[];
|
verses: string[];
|
||||||
refs: string[];
|
refs: string[];
|
||||||
imposterIndex: number;
|
imposterIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: ImposterData | null = null;
|
let data: ImposterData | null = $state(null);
|
||||||
let clicked: boolean[] = [];
|
let clicked: boolean[] = $state([]);
|
||||||
let gameOver = false;
|
let gameOver = $state(false);
|
||||||
let loading = true;
|
let loading = $state(true);
|
||||||
let error: string | null = null;
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
async function loadGame() {
|
async function loadGame() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/imposter");
|
const res = await fetch("/api/imposter");
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
}
|
}
|
||||||
data = (await res.json()) as ImposterData;
|
data = (await res.json()) as ImposterData;
|
||||||
clicked = new Array(data.verses.length).fill(false);
|
clicked = new Array(data.verses.length).fill(false);
|
||||||
gameOver = false;
|
gameOver = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : "Unknown error";
|
error = e instanceof Error ? e.message : "Unknown error";
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClick(index: number) {
|
function handleClick(index: number) {
|
||||||
if (gameOver || !data || clicked[index]) return;
|
if (gameOver || !data || clicked[index]) return;
|
||||||
clicked[index] = true;
|
clicked[index] = true;
|
||||||
if (index !== data.imposterIndex) {
|
if (index !== data.imposterIndex) {
|
||||||
clicked[data.imposterIndex] = true;
|
clicked[data.imposterIndex] = true;
|
||||||
}
|
}
|
||||||
gameOver = true;
|
gameOver = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function newGame() {
|
function newGame() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
data = null;
|
data = null;
|
||||||
loadGame();
|
loadGame();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(loadGame);
|
onMount(loadGame);
|
||||||
|
|
||||||
function formatVerse(verse: string): string {
|
function formatVerse(verse: string): string {
|
||||||
let formatted = verse;
|
let formatted = verse;
|
||||||
|
|
||||||
// Handle unbalanced opening/closing punctuation
|
// Handle unbalanced opening/closing punctuation
|
||||||
const pairs: [string, string][] = [
|
const pairs: [string, string][] = [
|
||||||
["(", ")"],
|
["(", ")"],
|
||||||
["[", "]"],
|
["[", "]"],
|
||||||
["{", "}"],
|
["{", "}"],
|
||||||
['"', '"'],
|
['"', '"'],
|
||||||
["'", "'"],
|
["'", "'"],
|
||||||
["\u201C", "\u201D"], // \u201C
|
["\u201C", "\u201D"], // \u201C
|
||||||
["\u2018", "\u2019"], // \u2018
|
["\u2018", "\u2019"], // \u2018
|
||||||
];
|
];
|
||||||
for (const [open, close] of pairs) {
|
for (const [open, close] of pairs) {
|
||||||
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||||
formatted += "..." + close;
|
formatted += "..." + close;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [open, close] of pairs) {
|
for (const [open, close] of pairs) {
|
||||||
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||||
formatted = open + "..." + formatted;
|
formatted = open + "..." + formatted;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^[a-z]/.test(formatted)) {
|
if (/^[a-z]/.test(formatted)) {
|
||||||
formatted = "..." + formatted;
|
formatted = "..." + formatted;
|
||||||
}
|
}
|
||||||
formatted = formatted.replace(/[,:;-—]$/, "...");
|
// Replace trailing punctuation with ellipsis
|
||||||
return formatted;
|
// Preserve closing quotes/brackets that may have been added
|
||||||
}
|
formatted = formatted.replace(
|
||||||
|
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
|
||||||
|
"...$1",
|
||||||
|
);
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="imposter-game">
|
<div class="imposter-game">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="loading">Loading verses...</p>
|
<p class="loading">Loading verses...</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">
|
<div class="error">
|
||||||
<p>Error: {error}</p>
|
<p>Error: {error}</p>
|
||||||
<button on:click={newGame}>Retry</button>
|
<button onclick={newGame}>Retry</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if data}
|
{:else if data}
|
||||||
<!-- <div class="instructions">
|
<!-- <div class="instructions">
|
||||||
<p>Click the verse that doesn't belong (from a different book).</p>
|
<p>Click the verse that doesn't belong (from a different book).</p>
|
||||||
</div> -->
|
</div> -->
|
||||||
<div class="verses">
|
<div class="verses">
|
||||||
{#each data.verses as verse, i}
|
{#each data.verses as verse, i}
|
||||||
<div class="verse-item">
|
<div class="verse-item">
|
||||||
<button
|
<button
|
||||||
class="verse-button"
|
class="verse-button"
|
||||||
class:clicked={clicked[i]}
|
class:clicked={clicked[i]}
|
||||||
class:correct={clicked[i] && i === data.imposterIndex}
|
class:correct={clicked[i] && i === data.imposterIndex}
|
||||||
class:wrong={clicked[i] && i !== data.imposterIndex}
|
class:wrong={clicked[i] && i !== data.imposterIndex}
|
||||||
on:click={() => handleClick(i)}
|
onclick={() => handleClick(i)}
|
||||||
disabled={gameOver}
|
disabled={gameOver}
|
||||||
>
|
>
|
||||||
{formatVerse(verse)}
|
{formatVerse(verse)}
|
||||||
</button>
|
</button>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<div class="ref">{data.refs[i]}</div>
|
<div class="ref">{data.refs[i]}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<div class="result">
|
<div class="result">
|
||||||
<button on:click={newGame}>New Game</button>
|
<button onclick={newGame}>New Game</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.imposter-game {
|
.imposter-game {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading,
|
.loading,
|
||||||
.error {
|
.error {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instructions {
|
/*.instructions {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}*/
|
||||||
|
|
||||||
.verses {
|
.verses {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-item {
|
.verse-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-button {
|
.verse-button {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
border: 3px solid #ddd;
|
border: 3px solid #ddd;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-button:hover:not(.clicked):not(:disabled) {
|
.verse-button:hover:not(.clicked):not(:disabled) {
|
||||||
border-color: #007bff;
|
border-color: #007bff;
|
||||||
background: #f8f9ff;
|
background: #f8f9ff;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-button:disabled {
|
.verse-button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-button.clicked {
|
.verse-button.clicked {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.correct {
|
.correct {
|
||||||
background: #d4edda !important;
|
background: #d4edda !important;
|
||||||
border-color: #28a745 !important;
|
border-color: #28a745 !important;
|
||||||
color: #155724;
|
color: #155724;
|
||||||
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
|
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrong {
|
.wrong {
|
||||||
background: #f8d7da !important;
|
background: #f8d7da !important;
|
||||||
border-color: #dc3545 !important;
|
border-color: #dc3545 !important;
|
||||||
color: #721c24;
|
color: #721c24;
|
||||||
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
|
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ref {
|
.ref {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #555;
|
color: #555;
|
||||||
padding-top: 0.25rem;
|
padding-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-button.correct ~ .ref {
|
.verse-button.correct ~ .ref {
|
||||||
color: #28a745;
|
color: #28a745;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.verse-button.wrong ~ .ref {
|
.verse-button.wrong ~ .ref {
|
||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result {
|
.result {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result button,
|
.result button,
|
||||||
.error button {
|
.error button {
|
||||||
padding: 0.75rem 2rem;
|
padding: 0.75rem 2rem;
|
||||||
background: #007bff;
|
background: #007bff;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result button:hover,
|
.result button:hover,
|
||||||
.error button:hover {
|
.error button:hover {
|
||||||
background: #0056b3;
|
background: #0056b3;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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">
|
<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(
|
type DisplayMode = "simple" | "testament" | "sections";
|
||||||
bibleBooks.filter((book) =>
|
|
||||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
const displayMode = $derived<DisplayMode>(
|
||||||
if (e.key === "Enter" && filteredBooks.length > 0) {
|
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple",
|
||||||
submitGuess(filteredBooks[0].id);
|
);
|
||||||
}
|
|
||||||
}
|
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>
|
</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">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<svg
|
<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"
|
class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
aria-hidden="true"
|
||||||
<path
|
>
|
||||||
stroke-linecap="round"
|
<path
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
stroke-width="2"
|
stroke-linejoin="round"
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
stroke-width="2"
|
||||||
/>
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
</svg>
|
/>
|
||||||
<input
|
</svg>
|
||||||
bind:value={searchQuery}
|
<input
|
||||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
bind:value={searchQuery}
|
||||||
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"
|
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||||
onkeydown={handleKeydown}
|
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"
|
||||||
autocomplete="off"
|
onkeydown={handleKeydown}
|
||||||
/>
|
autocomplete="off"
|
||||||
{#if searchQuery}
|
/>
|
||||||
<button
|
{#if searchQuery}
|
||||||
class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
<button
|
||||||
onclick={() => (searchQuery = "")}
|
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"
|
||||||
aria-label="Clear search"
|
onclick={() => (searchQuery = "")}
|
||||||
>
|
aria-label="Clear search"
|
||||||
<svg
|
>
|
||||||
class="w-5 h-5 sm:w-6 sm:h-6"
|
<svg
|
||||||
fill="none"
|
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
viewBox="0 0 24 24"
|
||||||
>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path
|
aria-hidden="true"
|
||||||
stroke-linecap="round"
|
>
|
||||||
stroke-linejoin="round"
|
<path
|
||||||
stroke-width="2"
|
stroke-linecap="round"
|
||||||
d="M6 18L18 6M6 6l12 12"
|
stroke-linejoin="round"
|
||||||
/>
|
stroke-width="2"
|
||||||
</svg>
|
d="M6 18L18 6M6 6l12 12"
|
||||||
</button>
|
/>
|
||||||
{/if}
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
{#if searchQuery && filteredBooks.length > 0}
|
{/if}
|
||||||
<ul
|
</div>
|
||||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
|
|
||||||
>
|
{#if searchQuery && filteredBooks.length > 0}
|
||||||
{#each filteredBooks as book (book.id)}
|
<ul
|
||||||
<li>
|
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"
|
||||||
<button
|
role="listbox"
|
||||||
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'
|
{#if displayMode === "simple"}
|
||||||
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
{#each simpleGroup.books as book (book.id)}
|
||||||
onclick={() => submitGuess(book.id)}
|
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||||
>
|
<button
|
||||||
<span
|
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||||
class="font-semibold {guessedIds.has(book.id)
|
{guessedIds.has(book.id)
|
||||||
? 'line-through text-gray-500'
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
: ''}">{book.name}</span
|
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||||
>
|
onclick={() => submitGuess(book.id)}
|
||||||
<span class="ml-auto text-sm opacity-75"
|
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||||
>({book.testament.toUpperCase()})</span
|
>
|
||||||
>
|
<span
|
||||||
</button>
|
class="font-semibold dark:text-gray-100 {guessedIds.has(
|
||||||
</li>
|
book.id,
|
||||||
{/each}
|
)
|
||||||
</ul>
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
{:else if searchQuery}
|
: ''}"
|
||||||
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
|
>
|
||||||
{/if}
|
{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>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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">
|
<script lang="ts">
|
||||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
import { browser } from "$app/environment";
|
||||||
import Container from "./Container.svelte";
|
import { fade } from "svelte/transition";
|
||||||
|
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||||
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
isWon,
|
isWon,
|
||||||
blurChapter = false,
|
blurChapter = false,
|
||||||
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||||
let dailyVerse = $derived(data.dailyVerse);
|
let dailyVerse = $derived(data.dailyVerse);
|
||||||
let displayReference = $derived(
|
let displayReference = $derived(
|
||||||
blurChapter
|
blurChapter
|
||||||
? dailyVerse.reference
|
? dailyVerse.reference
|
||||||
.replace(/^Psalms /, "Psalm ")
|
.replace(/^Psalms /, "Psalm ")
|
||||||
.replace(/\s(\d+):/, " ?:")
|
.replace(/\s(\d+):/, " ?:")
|
||||||
: dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
: dailyVerse.reference.replace(/^Psalms /, "Psalm "),
|
||||||
);
|
);
|
||||||
let displayVerseText = $derived(
|
let displayVerseText = $derived(
|
||||||
dailyVerse.verseText
|
dailyVerse.verseText
|
||||||
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
.replace(/^([a-z])/, (c) => c.toUpperCase())
|
||||||
.replace(/[,:;-—]$/, "...")
|
.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>
|
</script>
|
||||||
|
|
||||||
<Container class="w-full p-8 sm:p-12 bg-white/70">
|
<Container
|
||||||
<blockquote
|
class="w-full p-8 sm:p-12 bg-white/70 dark:bg-black/30 overflow-hidden"
|
||||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
>
|
||||||
>
|
<blockquote
|
||||||
{displayVerseText}
|
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 dark:text-gray-200 text-center"
|
||||||
</blockquote>
|
>
|
||||||
{#if isWon}
|
{displayVerseText}
|
||||||
<p
|
</blockquote>
|
||||||
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
<div
|
||||||
>
|
class="transition-all duration-500 ease-in-out overflow-hidden"
|
||||||
{displayReference}
|
style="max-height: {showReference ? '200px' : '0px'};"
|
||||||
</p>
|
>
|
||||||
{/if}
|
{#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>
|
</Container>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
|
import { getBookById, toOrdinal } from "$lib/utils/game";
|
||||||
import {
|
import {
|
||||||
getBookById,
|
getVerseSnippet,
|
||||||
toOrdinal,
|
shareResult,
|
||||||
getNextGradeMessage,
|
copyToClipboard as clipboardCopy,
|
||||||
} from "$lib/utils/game";
|
} from "$lib/utils/share";
|
||||||
import { onMount } from "svelte";
|
|
||||||
import Container from "./Container.svelte";
|
import Container from "./Container.svelte";
|
||||||
import CountdownTimer from "./CountdownTimer.svelte";
|
import CountdownTimer from "./CountdownTimer.svelte";
|
||||||
|
import StreakCounter from "./StreakCounter.svelte";
|
||||||
import ChapterGuess from "./ChapterGuess.svelte";
|
import ChapterGuess from "./ChapterGuess.svelte";
|
||||||
|
|
||||||
interface StatsData {
|
interface StatsData {
|
||||||
@@ -25,7 +26,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
grade,
|
|
||||||
statsData,
|
statsData,
|
||||||
correctBookId,
|
correctBookId,
|
||||||
handleShare,
|
handleShare,
|
||||||
@@ -35,6 +35,28 @@
|
|||||||
guessCount,
|
guessCount,
|
||||||
reference,
|
reference,
|
||||||
onChapterGuessCompleted,
|
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();
|
} = $props();
|
||||||
|
|
||||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||||
@@ -42,6 +64,24 @@
|
|||||||
typeof navigator !== "undefined" && "share" in navigator,
|
typeof navigator !== "undefined" && "share" in navigator,
|
||||||
);
|
);
|
||||||
let copySuccess = $state(false);
|
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
|
// List of congratulations messages with weights
|
||||||
const congratulationsMessages: WeightedMessage[] = [
|
const congratulationsMessages: WeightedMessage[] = [
|
||||||
@@ -57,9 +97,9 @@
|
|||||||
if (guessCount === 1) {
|
if (guessCount === 1) {
|
||||||
const n = Math.random();
|
const n = Math.random();
|
||||||
if (n < 0.99) {
|
if (n < 0.99) {
|
||||||
return "🌟 First try! 🌟";
|
return "First try!";
|
||||||
} else {
|
} else {
|
||||||
return "🗣️ Axios! 🗣️";
|
return "Axios!";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,66 +126,27 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<Container
|
<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">
|
<div class="flex flex-col gap-3">
|
||||||
{congratulationsMessage} The verse is from
|
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
||||||
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
|
{congratulationsMessage} The verse is from
|
||||||
</p>
|
<span class="font-black font-triodion text-3xl md:text-4xl"
|
||||||
<p class="text-lg sm:text-xl md:text-2xl mt-4">
|
>{bookName}</span
|
||||||
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)}
|
|
||||||
</p>
|
</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>
|
</Container>
|
||||||
|
|
||||||
<!-- S++ Bonus Challenge for first try -->
|
<!-- S++ Bonus Challenge for first try -->
|
||||||
@@ -157,12 +158,21 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/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 -->
|
<!-- Statistics Display -->
|
||||||
{#if statsData}
|
{#if statsData}
|
||||||
<Container
|
<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
|
<div
|
||||||
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
||||||
@@ -171,7 +181,7 @@
|
|||||||
<!-- Solve Rank Column -->
|
<!-- Solve Rank Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<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}
|
#{statsData.solveRank}
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +194,7 @@
|
|||||||
<!-- Guess Rank Column -->
|
<!-- Guess Rank Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<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)}
|
{toOrdinal(statsData.guessRank)}
|
||||||
</div>
|
</div>
|
||||||
@@ -206,7 +216,7 @@
|
|||||||
<!-- Average Column -->
|
<!-- Average Column -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div
|
<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}
|
{statsData.averageGuesses}
|
||||||
</div>
|
</div>
|
||||||
@@ -220,11 +230,176 @@
|
|||||||
</Container>
|
</Container>
|
||||||
{:else if !statsSubmitted}
|
{:else if !statsSubmitted}
|
||||||
<Container
|
<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>
|
<div class="text-sm opacity-80">Submitting stats...</div>
|
||||||
</Container>
|
</Container>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
|
||||||
|
<div class="big-text font-black! text-center text-gray-300!">
|
||||||
|
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">
|
||||||
|
<div class="rainbow-glow w-full">
|
||||||
|
<a
|
||||||
|
href="/progress"
|
||||||
|
class="flex flex-col items-center justify-center gap-1 w-full p-4 mb-2 bg-white dark:bg-gray-900 border-2 border-black/40 dark:border-white/40 rounded-2xl shadow-sm text-gray-800 dark:text-gray-100 text-base font-semibold no-underline transition-transform duration-100 hover:-translate-y-px active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
📈 See your progress
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="signin-prompt">
|
||||||
|
<p class="signin-text text-gray-800 dark:text-gray-300">
|
||||||
|
Create an account (or sign in) to 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>
|
||||||
|
<form method="POST" action="/auth/google" class="w-full">
|
||||||
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="google-signin-btn"
|
||||||
|
data-umami-event="Sign in with Google"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="google-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Google
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -239,7 +414,422 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-in {
|
:global(.fade-in) {
|
||||||
animation: fadeIn 0.5s ease-out;
|
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 ── */
|
||||||
|
.rainbow-glow {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rainbow-glow::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0px;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: conic-gradient(
|
||||||
|
from var(--angle, 0deg),
|
||||||
|
#ff0080,
|
||||||
|
#ff8c00,
|
||||||
|
#ffd700,
|
||||||
|
#00ff88,
|
||||||
|
#00cfff,
|
||||||
|
#a855f7,
|
||||||
|
#ff0080
|
||||||
|
);
|
||||||
|
animation: rainbow-rotate 6s linear infinite;
|
||||||
|
filter: blur(8px);
|
||||||
|
opacity: 0.75;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property --angle {
|
||||||
|
syntax: "<angle>";
|
||||||
|
initial-value: 0deg;
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rainbow-rotate {
|
||||||
|
0% {
|
||||||
|
--angle: 0deg;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
--angle: 360deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-prompt {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
/*padding: 1rem 0 0.25rem;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.signin-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-signin-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-signin-btn:hover {
|
||||||
|
background: #222;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-signin-btn:active {
|
||||||
|
background: #111;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.google-signin-btn {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.google-signin-btn:hover {
|
||||||
|
background: #e5e5e5;
|
||||||
|
}
|
||||||
|
.google-signin-btn:active {
|
||||||
|
background: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.google-icon {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Card {
|
||||||
|
front: string;
|
||||||
|
back: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cards: Card[];
|
||||||
|
fanDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { cards, fanDelay = 2500 }: Props = $props();
|
||||||
|
|
||||||
|
// Extract 4 cards (or fewer if not enough provided)
|
||||||
|
let displayCards = $derived(cards.slice(0, 4));
|
||||||
|
let totalCards = $derived(displayCards.length);
|
||||||
|
|
||||||
|
let fanned = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fanned = true;
|
||||||
|
}, fanDelay);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative h-64 w-96">
|
||||||
|
<!-- Cards start piled on left, fan out to right -->
|
||||||
|
{#each displayCards as card, i (i)}
|
||||||
|
{@const fanOffset = (totalCards - 1 - i) * 75}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-center transition-all duration-700 ease-out"
|
||||||
|
style:transform={fanned
|
||||||
|
? `translateX(${fanOffset-100}px) rotate(${8 + i * (-16 / (totalCards - 1))}deg)`
|
||||||
|
: "translateX(-100px) rotate(-8deg)"}
|
||||||
|
style:z-index={totalCards - i}
|
||||||
|
style:transition-delay={fanned ? `${i * 100}ms` : "0ms"}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={card.front}
|
||||||
|
alt="Card {i + 1}"
|
||||||
|
class="max-h-64 w-auto object-contain drop-shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Attachment } from "svelte/attachments";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
front: string;
|
||||||
|
back: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { front, back }: Props = $props();
|
||||||
|
|
||||||
|
let fanned = $state(false);
|
||||||
|
|
||||||
|
const cardDeck: Attachment<HTMLDivElement> = (node) => {
|
||||||
|
const check = () => {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
const cardCenter = rect.top + rect.height / 2;
|
||||||
|
fanned = cardCenter <= window.innerHeight / 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", check, { passive: true });
|
||||||
|
check();
|
||||||
|
|
||||||
|
return () => window.removeEventListener("scroll", check);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
{@attach cardDeck}
|
||||||
|
class="relative h-64 w-48"
|
||||||
|
role="img"
|
||||||
|
aria-label="Card deck"
|
||||||
|
>
|
||||||
|
<!-- Back card -->
|
||||||
|
<img
|
||||||
|
src={back}
|
||||||
|
alt="Back"
|
||||||
|
class="absolute inset-0 max-h-64 w-full object-contain drop-shadow-md transition-all duration-500 ease-in-out"
|
||||||
|
style:transform={fanned ? "translateX(90px) rotate(4deg)" : "rotate(-2deg)"}
|
||||||
|
style:z-index="1"
|
||||||
|
/>
|
||||||
|
<!-- Front card -->
|
||||||
|
<img
|
||||||
|
src={front}
|
||||||
|
alt="Front"
|
||||||
|
class="absolute inset-0 max-h-64 w-full object-contain drop-shadow-md transition-all duration-500 ease-in-out"
|
||||||
|
style:transform={fanned ? "translateX(-90px) rotate(-4deg)" : "rotate(2deg)"}
|
||||||
|
style:z-index="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
|
|
||||||
export * from './utils/game';
|
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';
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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,
|
||||||
|
googleId: 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;
|
||||||
|
}
|
||||||
+90
-7
@@ -1,7 +1,5 @@
|
|||||||
import type { RequestEvent } from '@sveltejs/kit';
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { sha256 } from '@oslojs/crypto/sha2';
|
|
||||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import * as table from '$lib/server/db/schema';
|
import * as table from '$lib/server/db/schema';
|
||||||
|
|
||||||
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
|
|||||||
|
|
||||||
export function generateSessionToken() {
|
export function generateSessionToken() {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||||
const token = encodeBase64url(bytes);
|
return Buffer.from(bytes).toString('base64url');
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSession(token: string, userId: string) {
|
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 = {
|
const session: table.Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
userId,
|
userId,
|
||||||
@@ -27,11 +24,11 @@ export async function createSession(token: string, userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validateSessionToken(token: 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
|
const [result] = await db
|
||||||
.select({
|
.select({
|
||||||
// Adjust user table here to tweak returned data
|
// 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, googleId: table.user.googleId },
|
||||||
session: table.session
|
session: table.session
|
||||||
})
|
})
|
||||||
.from(table.session)
|
.from(table.session)
|
||||||
@@ -79,3 +76,89 @@ export function deleteSessionTokenCookie(event: RequestEvent) {
|
|||||||
path: '/'
|
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,
|
||||||
|
googleId: 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 getUserByGoogleId(googleId: string) {
|
||||||
|
const [user] = await db.select().from(table.user).where(eq(table.user.googleId, googleId));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
import Database from 'better-sqlite3';
|
import { Database } from 'bun:sqlite';
|
||||||
import * as schema from './schema';
|
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 });
|
export const db = drizzle(client, { schema });
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
import { sql } from 'drizzle-orm';
|
export const user = sqliteTable('user', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
export const user = sqliteTable('user', { id: text('id').primaryKey(), age: integer('age') });
|
firstName: text('first_name'),
|
||||||
|
lastName: text('last_name'),
|
||||||
|
email: text('email').unique(),
|
||||||
|
passwordHash: text('password_hash'),
|
||||||
|
appleId: text('apple_id').unique(),
|
||||||
|
googleId: text('google_id').unique(),
|
||||||
|
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
|
||||||
|
});
|
||||||
|
|
||||||
export const session = sqliteTable('session', {
|
export const session = sqliteTable('session', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -30,11 +37,14 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
|||||||
anonymousId: text('anonymous_id').notNull(),
|
anonymousId: text('anonymous_id').notNull(),
|
||||||
date: text('date').notNull(),
|
date: text('date').notNull(),
|
||||||
guessCount: integer('guess_count').notNull(),
|
guessCount: integer('guess_count').notNull(),
|
||||||
|
guesses: text('guesses'), // nullable; only stored for logged-in users
|
||||||
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
||||||
}, (table) => ({
|
}, (table) => [
|
||||||
uniqueCompletion: unique().on(table.anonymousId, table.date),
|
index('anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||||
dateIndex: index('date_idx').on(table.date),
|
index('date_idx').on(table.date),
|
||||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
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;
|
export type DailyCompletion = typeof dailyCompletions.$inferSelect;
|
||||||
|
|||||||
@@ -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 });
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||||
|
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||||
|
|
||||||
|
export function getGoogleAuthUrl(state: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: Bun.env.GOOGLE_CLIENT_ID!,
|
||||||
|
redirect_uri: `${Bun.env.PUBLIC_SITE_URL}/auth/google/callback`,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid email profile',
|
||||||
|
state,
|
||||||
|
access_type: 'online',
|
||||||
|
prompt: 'select_account'
|
||||||
|
});
|
||||||
|
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeGoogleCode(
|
||||||
|
code: string,
|
||||||
|
redirectUri: string
|
||||||
|
): Promise<{
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
id_token: string;
|
||||||
|
scope: string;
|
||||||
|
}> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: Bun.env.GOOGLE_CLIENT_ID!,
|
||||||
|
client_secret: Bun.env.GOOGLE_CLIENT_SECRET!,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: redirectUri
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Google token exchange failed: ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode Google's id_token JWT payload without signature verification.
|
||||||
|
* Safe because the token is received directly from Google's token endpoint over TLS.
|
||||||
|
*/
|
||||||
|
export function decodeGoogleIdToken(idToken: string): {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: boolean;
|
||||||
|
name?: string;
|
||||||
|
given_name?: string;
|
||||||
|
family_name?: 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
|
import { bibleBooks } from '$lib/types/bible';
|
||||||
|
import { inArray } from 'drizzle-orm';
|
||||||
|
import type { DailyCompletion } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
export type Milestone = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
emoji: string;
|
||||||
|
description: string;
|
||||||
|
achieved: boolean;
|
||||||
|
achievedDate: string | null; // YYYY-MM-DD of first achievement, or null
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClassicMilestoneInputs = {
|
||||||
|
bestSingleGame: { date: string; bookName: string } | null;
|
||||||
|
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function calculateMilestones(
|
||||||
|
completions: DailyCompletion[],
|
||||||
|
dateToBookId: Map<string, string>,
|
||||||
|
classic: ClassicMilestoneInputs,
|
||||||
|
): Promise<Milestone[]> {
|
||||||
|
const sorted = [...completions].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
|
// Helper: returns the date when all books in targetIds were first solved
|
||||||
|
function findSetDate(targetIds: Set<string>): string | null {
|
||||||
|
const solved = new Set<string>();
|
||||||
|
for (const c of sorted) {
|
||||||
|
const bookId = dateToBookId.get(c.date);
|
||||||
|
if (bookId && targetIds.has(bookId)) {
|
||||||
|
solved.add(bookId);
|
||||||
|
if (solved.size === targetIds.size) return c.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Book sets
|
||||||
|
const ntIds = new Set(bibleBooks.filter(b => b.testament === 'new').map(b => b.id));
|
||||||
|
const otIds = new Set(bibleBooks.filter(b => b.testament === 'old').map(b => b.id));
|
||||||
|
const allIds = new Set(bibleBooks.map(b => b.id));
|
||||||
|
const gospelIds = new Set(['MAT', 'MRK', 'LUK', 'JHN']);
|
||||||
|
const pentateuchIds = new Set(['GEN', 'EXO', 'LEV', 'NUM', 'DEU']);
|
||||||
|
|
||||||
|
// Set-completion milestones
|
||||||
|
const ntScholarDate = findSetDate(ntIds);
|
||||||
|
const otScholarDate = findSetDate(otIds);
|
||||||
|
const theologianDate = findSetDate(allIds);
|
||||||
|
const fantasticFourDate = findSetDate(gospelIds);
|
||||||
|
const pentatonixDate = findSetDate(pentateuchIds);
|
||||||
|
|
||||||
|
// With God, All Things Are Possible — solved in 1 guess for at least one puzzle from each of the 66 books
|
||||||
|
const booksInOne = new Set<string>();
|
||||||
|
let withGodDate: string | null = null;
|
||||||
|
for (const c of sorted) {
|
||||||
|
if (c.guessCount === 1) {
|
||||||
|
const bookId = dateToBookId.get(c.date);
|
||||||
|
if (bookId) {
|
||||||
|
booksInOne.add(bookId);
|
||||||
|
if (withGodDate === null && booksInOne.size === allIds.size) {
|
||||||
|
withGodDate = c.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const allInOne = booksInOne.size === allIds.size;
|
||||||
|
|
||||||
|
// Is This A Joke To You? — guessed all 65 other books first (66 guesses total)
|
||||||
|
const jokeCompletion = sorted.find(c => c.guessCount >= 66);
|
||||||
|
|
||||||
|
// Prodigal Son — returned after a 30+ day gap
|
||||||
|
let prodigalDate: string | null = null;
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
const prev = new Date(sorted[i - 1].date + 'T00:00:00Z');
|
||||||
|
const curr = new Date(sorted[i].date + 'T00:00:00Z');
|
||||||
|
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
|
||||||
|
if (diff >= 30) {
|
||||||
|
prodigalDate = sorted[i].date;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra Credit — solved on a Sunday
|
||||||
|
const sundayCompletion = sorted.find(c => {
|
||||||
|
const d = new Date(c.date + 'T00:00:00Z');
|
||||||
|
return d.getUTCDay() === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cross-user milestones: Overachiever, Procrastinator, Outlier
|
||||||
|
let overachieverDate: string | null = null;
|
||||||
|
let procrastinatorDate: string | null = null;
|
||||||
|
let outlierDate: string | null = null;
|
||||||
|
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
const userDates = sorted.map(c => c.date);
|
||||||
|
const allOnDates = await db
|
||||||
|
.select({
|
||||||
|
date: dailyCompletions.date,
|
||||||
|
completedAt: dailyCompletions.completedAt,
|
||||||
|
guessCount: dailyCompletions.guessCount,
|
||||||
|
anonymousId: dailyCompletions.anonymousId,
|
||||||
|
})
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(inArray(dailyCompletions.date, userDates));
|
||||||
|
|
||||||
|
// Group all completions by date
|
||||||
|
const byDate = new Map<string, typeof allOnDates>();
|
||||||
|
for (const c of allOnDates) {
|
||||||
|
const arr = byDate.get(c.date) ?? [];
|
||||||
|
arr.push(c);
|
||||||
|
byDate.set(c.date, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userByDate = new Map(sorted.map(c => [c.date, c]));
|
||||||
|
|
||||||
|
for (const userComp of sorted) {
|
||||||
|
const allForDate = byDate.get(userComp.date) ?? [];
|
||||||
|
if (allForDate.length < 2) continue; // need multiple players
|
||||||
|
|
||||||
|
const validTimes = allForDate
|
||||||
|
.filter(c => c.completedAt != null)
|
||||||
|
.map(c => c.completedAt!.getTime());
|
||||||
|
|
||||||
|
if (!overachieverDate && userComp.completedAt && validTimes.length > 0) {
|
||||||
|
const earliest = Math.min(...validTimes);
|
||||||
|
if (userComp.completedAt.getTime() === earliest) {
|
||||||
|
overachieverDate = userComp.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!procrastinatorDate && userComp.completedAt && validTimes.length > 0) {
|
||||||
|
const latest = Math.max(...validTimes);
|
||||||
|
if (userComp.completedAt.getTime() === latest) {
|
||||||
|
procrastinatorDate = userComp.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outlierDate && allForDate.length >= 10) {
|
||||||
|
const sortedGuesses = allForDate.map(c => c.guessCount).sort((a, b) => a - b);
|
||||||
|
const cutoffIndex = Math.ceil(sortedGuesses.length * 0.1) - 1;
|
||||||
|
const cutoff = sortedGuesses[cutoffIndex];
|
||||||
|
if (userComp.guessCount <= cutoff) {
|
||||||
|
outlierDate = userComp.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'first-1-guess',
|
||||||
|
name: 'Lightning Strike',
|
||||||
|
emoji: '⚡',
|
||||||
|
description: `First 1-guess solve${classic.bestSingleGame ? ` — ${classic.bestSingleGame.bookName}` : ''}`,
|
||||||
|
achieved: classic.bestSingleGame !== null,
|
||||||
|
achievedDate: classic.bestSingleGame?.date ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'streak-7',
|
||||||
|
name: '7-Day Streak',
|
||||||
|
emoji: '🔥',
|
||||||
|
description: 'Solve Bibdle 7 days in a row',
|
||||||
|
achieved: classic.streakMilestones.days7 !== null,
|
||||||
|
achievedDate: classic.streakMilestones.days7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'streak-14',
|
||||||
|
name: '14-Day Streak',
|
||||||
|
emoji: '💥',
|
||||||
|
description: 'Solve Bibdle 14 days in a row',
|
||||||
|
achieved: classic.streakMilestones.days14 !== null,
|
||||||
|
achievedDate: classic.streakMilestones.days14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'streak-30',
|
||||||
|
name: '30-Day Streak',
|
||||||
|
emoji: '🏅',
|
||||||
|
description: 'Solve Bibdle 30 days in a row',
|
||||||
|
achieved: classic.streakMilestones.days30 !== null,
|
||||||
|
achievedDate: classic.streakMilestones.days30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nt-scholar',
|
||||||
|
name: 'NT Scholar',
|
||||||
|
emoji: '✝️',
|
||||||
|
description: 'Solve for every New Testament book',
|
||||||
|
achieved: ntScholarDate !== null,
|
||||||
|
achievedDate: ntScholarDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ot-scholar',
|
||||||
|
name: 'OT Scholar',
|
||||||
|
emoji: '📜',
|
||||||
|
description: 'Solve for every Old Testament book',
|
||||||
|
achieved: otScholarDate !== null,
|
||||||
|
achievedDate: otScholarDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theologian',
|
||||||
|
name: 'Theologian',
|
||||||
|
emoji: '🎓',
|
||||||
|
description: 'Solve for all 66 books of the Bible',
|
||||||
|
achieved: theologianDate !== null,
|
||||||
|
achievedDate: theologianDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fantastic-four',
|
||||||
|
name: 'The Fantastic Four',
|
||||||
|
emoji: '4️⃣',
|
||||||
|
description: 'Solve a puzzle for all four Gospels',
|
||||||
|
achieved: fantasticFourDate !== null,
|
||||||
|
achievedDate: fantasticFourDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pentatonix',
|
||||||
|
name: 'Pentatonix',
|
||||||
|
emoji: '📃',
|
||||||
|
description: 'Solve a puzzle for all five books of the Pentateuch',
|
||||||
|
achieved: pentatonixDate !== null,
|
||||||
|
achievedDate: pentatonixDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'with-god',
|
||||||
|
name: 'With God, All Things Are Possible',
|
||||||
|
emoji: '🙏',
|
||||||
|
description: 'Solve in 1 guess for each of the 66 books at least once',
|
||||||
|
achieved: allInOne,
|
||||||
|
achievedDate: withGodDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'is-this-a-joke',
|
||||||
|
name: 'Is This A Joke To You?',
|
||||||
|
emoji: '😤',
|
||||||
|
description: 'Guess all 65 other books before getting the right one',
|
||||||
|
achieved: jokeCompletion !== undefined,
|
||||||
|
achievedDate: jokeCompletion?.date ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'overachiever',
|
||||||
|
name: 'Overachiever',
|
||||||
|
emoji: '⚡',
|
||||||
|
description: 'Be the first person to solve Bibdle on a day',
|
||||||
|
achieved: overachieverDate !== null,
|
||||||
|
achievedDate: overachieverDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'procrastinator',
|
||||||
|
name: 'Procrastinator',
|
||||||
|
emoji: '🐢',
|
||||||
|
description: 'Be the last person to solve Bibdle on a day',
|
||||||
|
achieved: procrastinatorDate !== null,
|
||||||
|
achievedDate: procrastinatorDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prodigal-son',
|
||||||
|
name: 'Prodigal Son',
|
||||||
|
emoji: '🏠',
|
||||||
|
description: 'Return to Bibdle after at least 30 days away',
|
||||||
|
achieved: prodigalDate !== null,
|
||||||
|
achievedDate: prodigalDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'extra-credit',
|
||||||
|
name: 'Extra Credit',
|
||||||
|
emoji: '📅',
|
||||||
|
description: 'Solve Bibdle on a Sunday',
|
||||||
|
achieved: sundayCompletion !== undefined,
|
||||||
|
achievedDate: sundayCompletion?.date ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'outlier',
|
||||||
|
name: 'Outlier',
|
||||||
|
emoji: '📊',
|
||||||
|
description: 'Finish in the top 10% of solves on a day (fewest guesses)',
|
||||||
|
achieved: outlierDate !== null,
|
||||||
|
achievedDate: outlierDate,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+66
-68
@@ -17,75 +17,73 @@ export interface BibleBook {
|
|||||||
testament: Testament;
|
testament: Testament;
|
||||||
section: BibleSection;
|
section: BibleSection;
|
||||||
order: number;
|
order: number;
|
||||||
url: string;
|
|
||||||
popularity: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bibleBooks: BibleBook[] = [
|
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: 'GEN', name: 'Genesis', testament: 'old', section: 'Law', order: 1 },
|
||||||
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2, url: 'https://bible-api.com/data/web/EXO', popularity: 3 },
|
{ id: 'EXO', name: 'Exodus', testament: 'old', section: 'Law', order: 2 },
|
||||||
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3, url: 'https://bible-api.com/data/web/LEV', popularity: 2 },
|
{ id: 'LEV', name: 'Leviticus', testament: 'old', section: 'Law', order: 3 },
|
||||||
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4, url: 'https://bible-api.com/data/web/NUM', popularity: 2 },
|
{ id: 'NUM', name: 'Numbers', testament: 'old', section: 'Law', order: 4 },
|
||||||
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5, url: 'https://bible-api.com/data/web/DEU', popularity: 2 },
|
{ id: 'DEU', name: 'Deuteronomy', testament: 'old', section: 'Law', order: 5 },
|
||||||
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6, url: 'https://bible-api.com/data/web/JOS', popularity: 2 },
|
{ id: 'JOS', name: 'Joshua', testament: 'old', section: 'History', order: 6 },
|
||||||
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7, url: 'https://bible-api.com/data/web/JDG', popularity: 2 },
|
{ id: 'JDG', name: 'Judges', testament: 'old', section: 'History', order: 7 },
|
||||||
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8, url: 'https://bible-api.com/data/web/RUT', popularity: 2 },
|
{ id: 'RUT', name: 'Ruth', testament: 'old', section: 'History', order: 8 },
|
||||||
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9, url: 'https://bible-api.com/data/web/1SA', popularity: 1 },
|
{ id: '1SA', name: '1 Samuel', testament: 'old', section: 'History', order: 9 },
|
||||||
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10, url: 'https://bible-api.com/data/web/2SA', popularity: 0 },
|
{ id: '2SA', name: '2 Samuel', testament: 'old', section: 'History', order: 10 },
|
||||||
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11, url: 'https://bible-api.com/data/web/1KI', popularity: 1 },
|
{ id: '1KI', name: '1 Kings', testament: 'old', section: 'History', order: 11 },
|
||||||
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12, url: 'https://bible-api.com/data/web/2KI', popularity: 0 },
|
{ id: '2KI', name: '2 Kings', testament: 'old', section: 'History', order: 12 },
|
||||||
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13, url: 'https://bible-api.com/data/web/1CH', popularity: 1 },
|
{ id: '1CH', name: '1 Chronicles', testament: 'old', section: 'History', order: 13 },
|
||||||
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14, url: 'https://bible-api.com/data/web/2CH', popularity: 0 },
|
{ id: '2CH', name: '2 Chronicles', testament: 'old', section: 'History', order: 14 },
|
||||||
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15, url: 'https://bible-api.com/data/web/EZR', popularity: 1 },
|
{ id: 'EZR', name: 'Ezra', testament: 'old', section: 'History', order: 15 },
|
||||||
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16, url: 'https://bible-api.com/data/web/NEH', popularity: 1 },
|
{ id: 'NEH', name: 'Nehemiah', testament: 'old', section: 'History', order: 16 },
|
||||||
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17, url: 'https://bible-api.com/data/web/EST', popularity: 1 },
|
{ id: 'EST', name: 'Esther', testament: 'old', section: 'History', order: 17 },
|
||||||
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18, url: 'https://bible-api.com/data/web/JOB', popularity: 2 },
|
{ id: 'JOB', name: 'Job', testament: 'old', section: 'Wisdom', order: 18 },
|
||||||
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19, url: 'https://bible-api.com/data/web/PSA', popularity: 7 },
|
{ id: 'PSA', name: 'Psalms', testament: 'old', section: 'Wisdom', order: 19 },
|
||||||
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20, url: 'https://bible-api.com/data/web/PRO', popularity: 7 },
|
{ id: 'PRO', name: 'Proverbs', testament: 'old', section: 'Wisdom', order: 20 },
|
||||||
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21, url: 'https://bible-api.com/data/web/ECC', popularity: 2 },
|
{ id: 'ECC', name: 'Ecclesiastes', testament: 'old', section: 'Wisdom', order: 21 },
|
||||||
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22, url: 'https://bible-api.com/data/web/SNG', popularity: 2 },
|
{ id: 'SNG', name: 'Song of Solomon', testament: 'old', section: 'Wisdom', order: 22 },
|
||||||
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23, url: 'https://bible-api.com/data/web/ISA', popularity: 2 },
|
{ id: 'ISA', name: 'Isaiah', testament: 'old', section: 'Major Prophets', order: 23 },
|
||||||
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24, url: 'https://bible-api.com/data/web/JER', popularity: 2 },
|
{ id: 'JER', name: 'Jeremiah', testament: 'old', section: 'Major Prophets', order: 24 },
|
||||||
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25, url: 'https://bible-api.com/data/web/LAM', popularity: 2 },
|
{ id: 'LAM', name: 'Lamentations', testament: 'old', section: 'Major Prophets', order: 25 },
|
||||||
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26, url: 'https://bible-api.com/data/web/EZK', popularity: 2 },
|
{ id: 'EZK', name: 'Ezekiel', testament: 'old', section: 'Major Prophets', order: 26 },
|
||||||
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27, url: 'https://bible-api.com/data/web/DAN', popularity: 2 },
|
{ id: 'DAN', name: 'Daniel', testament: 'old', section: 'Major Prophets', order: 27 },
|
||||||
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28, url: 'https://bible-api.com/data/web/HOS', popularity: 2 },
|
{ id: 'HOS', name: 'Hosea', testament: 'old', section: 'Minor Prophets', order: 28 },
|
||||||
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29, url: 'https://bible-api.com/data/web/JOL', popularity: 2 },
|
{ id: 'JOL', name: 'Joel', testament: 'old', section: 'Minor Prophets', order: 29 },
|
||||||
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30, url: 'https://bible-api.com/data/web/AMO', popularity: 2 },
|
{ id: 'AMO', name: 'Amos', testament: 'old', section: 'Minor Prophets', order: 30 },
|
||||||
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31, url: 'https://bible-api.com/data/web/OBA', popularity: 2 },
|
{ id: 'OBA', name: 'Obadiah', testament: 'old', section: 'Minor Prophets', order: 31 },
|
||||||
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32, url: 'https://bible-api.com/data/web/JON', popularity: 2 },
|
{ id: 'JON', name: 'Jonah', testament: 'old', section: 'Minor Prophets', order: 32 },
|
||||||
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33, url: 'https://bible-api.com/data/web/MIC', popularity: 2 },
|
{ id: 'MIC', name: 'Micah', testament: 'old', section: 'Minor Prophets', order: 33 },
|
||||||
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34, url: 'https://bible-api.com/data/web/NAM', popularity: 2 },
|
{ id: 'NAM', name: 'Nahum', testament: 'old', section: 'Minor Prophets', order: 34 },
|
||||||
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35, url: 'https://bible-api.com/data/web/HAB', popularity: 2 },
|
{ id: 'HAB', name: 'Habakkuk', testament: 'old', section: 'Minor Prophets', order: 35 },
|
||||||
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36, url: 'https://bible-api.com/data/web/ZEP', popularity: 2 },
|
{ id: 'ZEP', name: 'Zephaniah', testament: 'old', section: 'Minor Prophets', order: 36 },
|
||||||
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37, url: 'https://bible-api.com/data/web/HAG', popularity: 2 },
|
{ id: 'HAG', name: 'Haggai', testament: 'old', section: 'Minor Prophets', order: 37 },
|
||||||
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38, url: 'https://bible-api.com/data/web/ZEC', popularity: 2 },
|
{ id: 'ZEC', name: 'Zechariah', testament: 'old', section: 'Minor Prophets', order: 38 },
|
||||||
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39, url: 'https://bible-api.com/data/web/MAL', popularity: 2 },
|
{ id: 'MAL', name: 'Malachi', testament: 'old', section: 'Minor Prophets', order: 39 },
|
||||||
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40, url: 'https://bible-api.com/data/web/MAT', popularity: 8 },
|
{ id: 'MAT', name: 'Matthew', testament: 'new', section: 'Gospels', order: 40 },
|
||||||
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41, url: 'https://bible-api.com/data/web/MRK', popularity: 8 },
|
{ id: 'MRK', name: 'Mark', testament: 'new', section: 'Gospels', order: 41 },
|
||||||
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42, url: 'https://bible-api.com/data/web/LUK', popularity: 8 },
|
{ id: 'LUK', name: 'Luke', testament: 'new', section: 'Gospels', order: 42 },
|
||||||
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43, url: 'https://bible-api.com/data/web/JHN', popularity: 8 },
|
{ id: 'JHN', name: 'John', testament: 'new', section: 'Gospels', order: 43 },
|
||||||
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44, url: 'https://bible-api.com/data/web/ACT', popularity: 2 },
|
{ id: 'ACT', name: 'Acts', testament: 'new', section: 'History', order: 44 },
|
||||||
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45, url: 'https://bible-api.com/data/web/ROM', popularity: 6 },
|
{ id: 'ROM', name: 'Romans', testament: 'new', section: 'Pauline Epistles', order: 45 },
|
||||||
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46, url: 'https://bible-api.com/data/web/1CO', popularity: 5 },
|
{ id: '1CO', name: '1 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 46 },
|
||||||
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47, url: 'https://bible-api.com/data/web/2CO', popularity: 5 },
|
{ id: '2CO', name: '2 Corinthians', testament: 'new', section: 'Pauline Epistles', order: 47 },
|
||||||
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48, url: 'https://bible-api.com/data/web/GAL', popularity: 5 },
|
{ id: 'GAL', name: 'Galatians', testament: 'new', section: 'Pauline Epistles', order: 48 },
|
||||||
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49, url: 'https://bible-api.com/data/web/EPH', popularity: 5 },
|
{ id: 'EPH', name: 'Ephesians', testament: 'new', section: 'Pauline Epistles', order: 49 },
|
||||||
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50, url: 'https://bible-api.com/data/web/PHP', popularity: 5 },
|
{ id: 'PHP', name: 'Philippians', testament: 'new', section: 'Pauline Epistles', order: 50 },
|
||||||
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51, url: 'https://bible-api.com/data/web/COL', popularity: 5 },
|
{ id: 'COL', name: 'Colossians', testament: 'new', section: 'Pauline Epistles', order: 51 },
|
||||||
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52, url: 'https://bible-api.com/data/web/1TH', popularity: 5 },
|
{ id: '1TH', name: '1 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 52 },
|
||||||
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53, url: 'https://bible-api.com/data/web/2TH', popularity: 5 },
|
{ id: '2TH', name: '2 Thessalonians', testament: 'new', section: 'Pauline Epistles', order: 53 },
|
||||||
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54, url: 'https://bible-api.com/data/web/1TI', popularity: 5 },
|
{ id: '1TI', name: '1 Timothy', testament: 'new', section: 'Pauline Epistles', order: 54 },
|
||||||
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55, url: 'https://bible-api.com/data/web/2TI', popularity: 5 },
|
{ id: '2TI', name: '2 Timothy', testament: 'new', section: 'Pauline Epistles', order: 55 },
|
||||||
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56, url: 'https://bible-api.com/data/web/TIT', popularity: 5 },
|
{ id: 'TIT', name: 'Titus', testament: 'new', section: 'Pauline Epistles', order: 56 },
|
||||||
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57, url: 'https://bible-api.com/data/web/PHM', popularity: 5 },
|
{ id: 'PHM', name: 'Philemon', testament: 'new', section: 'Pauline Epistles', order: 57 },
|
||||||
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58, url: 'https://bible-api.com/data/web/HEB', popularity: 4 },
|
{ id: 'HEB', name: 'Hebrews', testament: 'new', section: 'General Epistles', order: 58 },
|
||||||
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59, url: 'https://bible-api.com/data/web/JAS', popularity: 4 },
|
{ id: 'JAS', name: 'James', testament: 'new', section: 'General Epistles', order: 59 },
|
||||||
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60, url: 'https://bible-api.com/data/web/1PE', popularity: 4 },
|
{ id: '1PE', name: '1 Peter', testament: 'new', section: 'General Epistles', order: 60 },
|
||||||
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61, url: 'https://bible-api.com/data/web/2PE', popularity: 4 },
|
{ id: '2PE', name: '2 Peter', testament: 'new', section: 'General Epistles', order: 61 },
|
||||||
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62, url: 'https://bible-api.com/data/web/1JN', popularity: 4 },
|
{ id: '1JN', name: '1 John', testament: 'new', section: 'General Epistles', order: 62 },
|
||||||
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63, url: 'https://bible-api.com/data/web/2JN', popularity: 4 },
|
{ id: '2JN', name: '2 John', testament: 'new', section: 'General Epistles', order: 63 },
|
||||||
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64, url: 'https://bible-api.com/data/web/3JN', popularity: 4 },
|
{ id: '3JN', name: '3 John', testament: 'new', section: 'General Epistles', order: 64 },
|
||||||
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65, url: 'https://bible-api.com/data/web/JUD', popularity: 4 },
|
{ id: 'JUD', name: 'Jude', testament: 'new', section: 'General Epistles', order: 65 },
|
||||||
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66, url: 'https://bible-api.com/data/web/REV', popularity: 2 }
|
{ id: 'REV', name: 'Revelation', testament: 'new', section: 'Apocalyptic', order: 66 }
|
||||||
];
|
];
|
||||||
|
|||||||
+51
-3
@@ -1,5 +1,13 @@
|
|||||||
import { bibleBooks, type BibleBook } from '$lib/types/bible';
|
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 {
|
export function getBookById(id: string): BibleBook | undefined {
|
||||||
return bibleBooks.find((b) => b.id === id);
|
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);
|
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 === 1) return "S+";
|
||||||
if (numGuesses === 2) return "A+";
|
if (numGuesses === 2) return "A+";
|
||||||
if (numGuesses === 3) return "A";
|
if (numGuesses === 3) return "A";
|
||||||
@@ -31,7 +79,7 @@ export function getNextGradeMessage(numGuesses: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toOrdinal(n: number): string {
|
export function toOrdinal(n: number): string {
|
||||||
if (n >= 11 && n <= 13) {
|
if (n % 100 >= 11 && n % 100 <= 13) {
|
||||||
return `${n}th`;
|
return `${n}th`;
|
||||||
}
|
}
|
||||||
const mod = n % 10;
|
const mod = n % 10;
|
||||||
@@ -49,4 +97,4 @@ export function generateUUID(): string {
|
|||||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||||
return v.toString(16);
|
return v.toString(16);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
guessCount: number;
|
||||||
grade: string;
|
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 {
|
export function getGradeColor(grade: string): string {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+51
-24
@@ -1,31 +1,58 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from "svelte";
|
||||||
import { browser } from '$app/environment';
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
import "./layout.css";
|
|
||||||
import favicon from "$lib/assets/favicon.ico";
|
|
||||||
|
|
||||||
onMount(() => {
|
import "./layout.css";
|
||||||
if (browser) {
|
import favicon from "$lib/assets/favicon.ico";
|
||||||
const script = document.createElement('script');
|
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||||
script.defer = true;
|
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||||
script.src = 'https://umami.snail.city/script.js';
|
|
||||||
script.setAttribute('data-website-id', '5b8c31ad-71cd-4317-940b-6bccea732acc');
|
|
||||||
script.setAttribute('data-domains', 'bibdle.com,www.bibdle.com');
|
|
||||||
document.body.appendChild(script);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let { children } = $props();
|
let isDev = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
isDev =
|
||||||
|
window.location.host === "localhost:5173" ||
|
||||||
|
window.location.host === "test.bibdle.com";
|
||||||
|
|
||||||
|
// 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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<!-- <script
|
<link
|
||||||
defer
|
rel="alternate"
|
||||||
src="https://umami.snail.city/script.js"
|
type="application/rss+xml"
|
||||||
data-website-id="5b8c31ad-71cd-4317-940b-6bccea732acc"
|
title="Bibdle RSS Feed"
|
||||||
data-domains="bibdle.com,www.bibdle.com"
|
href="/feed.xml"
|
||||||
></script> -->
|
/>
|
||||||
|
<meta name="description" content="A daily Bible game" />
|
||||||
</svelte:head>
|
</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>
|
||||||
|
{#if isDev}
|
||||||
|
<div class="flex justify-center pb-2"><ThemeToggle /></div>
|
||||||
|
{:else}
|
||||||
|
<div class="justify-center hidden pb-2"><ThemeToggle /></div>
|
||||||
|
{/if}
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|||||||
+13
-39
@@ -1,47 +1,14 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
import { dailyCompletions } from '$lib/server/db/schema';
|
||||||
import { eq, sql, asc } from 'drizzle-orm';
|
import { eq, asc } from 'drizzle-orm';
|
||||||
import { fail } from '@sveltejs/kit';
|
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';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
async function getTodayVerse(): Promise<DailyVerse> {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
// 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;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dailyVerse,
|
user: locals.user,
|
||||||
correctBookId: dailyVerse.bookId,
|
session: locals.session
|
||||||
correctBook
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,13 +58,20 @@ export const actions: Actions = {
|
|||||||
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
const guessRank = betterGuesses + 1;
|
const guessRank = betterGuesses + 1;
|
||||||
|
|
||||||
|
// Count ties: how many have the SAME guessCount (excluding self)
|
||||||
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
|
|
||||||
// Average guesses
|
// Average guesses
|
||||||
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||||
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
|
||||||
|
|
||||||
|
// Percentile: what percentage of people you beat (100 - your rank percentage)
|
||||||
|
const betterOrEqualCount = allCompletions.filter(c => c.guessCount <= guessCount).length;
|
||||||
|
const percentile = Math.round((betterOrEqualCount / totalSolves) * 100);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
stats: { solveRank, guessRank, totalSolves, averageGuesses }
|
stats: { solveRank, guessRank, totalSolves, averageGuesses, tiedCount, percentile }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+436
-390
@@ -1,52 +1,41 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { bibleBooks, type BibleBook } from "$lib/types/bible";
|
|
||||||
|
|
||||||
import type { PageProps } from "./$types";
|
import type { PageProps } from "./$types";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
import VerseDisplay from "$lib/components/VerseDisplay.svelte";
|
||||||
import SearchInput from "$lib/components/SearchInput.svelte";
|
import SearchInput from "$lib/components/SearchInput.svelte";
|
||||||
import GuessesTable from "$lib/components/GuessesTable.svelte";
|
import GuessesTable from "$lib/components/GuessesTable.svelte";
|
||||||
import WinScreen from "$lib/components/WinScreen.svelte";
|
import WinScreen from "$lib/components/WinScreen.svelte";
|
||||||
import Credits from "$lib/components/Credits.svelte";
|
import Credits from "$lib/components/Credits.svelte";
|
||||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
|
||||||
import DevButtons from "$lib/components/DevButtons.svelte";
|
|
||||||
import { getGrade } from "$lib/utils/game";
|
|
||||||
|
|
||||||
interface Guess {
|
import GamePrompt from "$lib/components/GamePrompt.svelte";
|
||||||
book: BibleBook;
|
import DevButtons from "$lib/components/DevButtons.svelte";
|
||||||
testamentMatch: boolean;
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
sectionMatch: boolean;
|
|
||||||
adjacent: boolean;
|
import { evaluateGuess, getFirstLetter } from "$lib/utils/game";
|
||||||
firstLetterMatch: boolean;
|
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 { data }: PageProps = $props();
|
||||||
|
|
||||||
let dailyVerse = $derived(data.dailyVerse);
|
let dailyVerse = $derived(data.dailyVerse);
|
||||||
let correctBookId = $derived(data.correctBookId);
|
let correctBookId = $derived(data.correctBookId);
|
||||||
|
let correctBook = $derived(data.correctBook);
|
||||||
let guesses = $state<Guess[]>([]);
|
let user = $derived(data.user);
|
||||||
|
let session = $derived(data.session);
|
||||||
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)));
|
|
||||||
|
|
||||||
const currentDate = $derived(
|
const currentDate = $derived(
|
||||||
new Date().toLocaleDateString("en-US", {
|
new Date().toLocaleDateString("en-US", {
|
||||||
@@ -57,351 +46,278 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let isWon = $derived(guesses.some((g) => g.book.id === correctBookId));
|
let searchQuery = $state("");
|
||||||
let grade = $derived(
|
let copied = $state(false);
|
||||||
isWon
|
let isDev = $state(false);
|
||||||
? guesses.length === 1 && chapterCorrect
|
let authModalOpen = $state(false);
|
||||||
? "S++"
|
let showWinScreen = $state(false);
|
||||||
: getGrade(
|
let statsData = $state<StatsData | null>(null);
|
||||||
guesses.length,
|
let streak = $state(0);
|
||||||
getBookById(correctBookId)?.popularity ?? 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(
|
let blurChapter = $derived(
|
||||||
isWon && guesses.length === 1 && !chapterGuessCompleted,
|
isWon &&
|
||||||
|
persistence.guesses.length === 1 &&
|
||||||
|
!persistence.chapterGuessCompleted,
|
||||||
);
|
);
|
||||||
|
|
||||||
function getBookById(id: string): BibleBook | undefined {
|
let knownTestament = $derived(
|
||||||
return bibleBooks.find((b) => b.id === id);
|
persistence.guesses.some((g) => g.testamentMatch)
|
||||||
}
|
? correctBook?.testament
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
let knownSection = $derived(
|
||||||
|
persistence.guesses.some((g) => g.sectionMatch)
|
||||||
|
? correctBook?.section
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
let knownFirstLetter = $derived(
|
||||||
|
persistence.guesses.some((g) => g.firstLetterMatch)
|
||||||
|
? getFirstLetter(correctBook?.name ?? "").toUpperCase()
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
function isAdjacent(id1: string, id2: string): boolean {
|
let testamentVisible = $state(false);
|
||||||
const b1 = getBookById(id1);
|
let sectionVisible = $state(false);
|
||||||
const b2 = getBookById(id2);
|
let firstLetterVisible = $state(false);
|
||||||
return !!(b1 && b2 && Math.abs(b1.order - b2.order) === 1);
|
let showHints = $state(false);
|
||||||
}
|
|
||||||
|
|
||||||
function submitGuess(bookId: string) {
|
// On page load, show hints that are already known without animation
|
||||||
if (guesses.some((g) => g.book.id === bookId)) return;
|
onMount(() => {
|
||||||
|
if (knownTestament) testamentVisible = true;
|
||||||
|
if (knownSection) sectionVisible = true;
|
||||||
|
if (knownFirstLetter) firstLetterVisible = true;
|
||||||
|
|
||||||
const book = getBookById(bookId);
|
const winCount = Object.keys(localStorage).filter(
|
||||||
if (!book) return;
|
(k) => k.startsWith("bibdle-win-tracked-") && localStorage.getItem(k) === "true"
|
||||||
|
).length;
|
||||||
|
showHints = winCount < 3;
|
||||||
|
});
|
||||||
|
|
||||||
const correctBook = getBookById(correctBookId);
|
// Fade in newly revealed hints after the guess animation completes
|
||||||
if (!correctBook) return;
|
$effect(() => {
|
||||||
|
if (!knownTestament || testamentVisible) return;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
testamentVisible = true;
|
||||||
|
}, 2800);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (!knownSection || sectionVisible) return;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
sectionVisible = true;
|
||||||
|
}, 2800);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (!knownFirstLetter || firstLetterVisible) return;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
firstLetterVisible = true;
|
||||||
|
}, 2800);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
});
|
||||||
|
|
||||||
const testamentMatch = book.testament === correctBook.testament;
|
async function submitGuess(bookId: string) {
|
||||||
const sectionMatch = book.section === correctBook.section;
|
if (persistence.guesses.some((g) => g.book.id === bookId)) return;
|
||||||
const adjacent = isAdjacent(bookId, correctBookId);
|
|
||||||
|
|
||||||
// Special case: if correct book is in the Epistles + starts with "1",
|
const guess = evaluateGuess(bookId, correctBookId);
|
||||||
// any guess starting with "1" counts as first letter match
|
if (!guess) return;
|
||||||
const correctIsEpistlesWithNumber =
|
|
||||||
correctBook.section === "Pauline Epistles" &&
|
|
||||||
correctBook.name[0] === "1";
|
|
||||||
const guessStartsWithNumber = book.name[0] === "1";
|
|
||||||
|
|
||||||
const firstLetterMatch =
|
if (persistence.guesses.length === 0) {
|
||||||
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) {
|
|
||||||
const key = `bibdle-first-guess-${dailyVerse.date}`;
|
const key = `bibdle-first-guess-${dailyVerse.date}`;
|
||||||
if (
|
if (
|
||||||
localStorage.getItem(key) !== "true" &&
|
|
||||||
browser &&
|
browser &&
|
||||||
|
localStorage.getItem(key) !== "true" &&
|
||||||
(window as any).umami
|
(window as any).umami
|
||||||
) {
|
) {
|
||||||
(window as any).umami.track("First guess");
|
(window as any).umami.track("First guess");
|
||||||
|
(window as any).rybbit?.event("First guess");
|
||||||
localStorage.setItem(key, "true");
|
localStorage.setItem(key, "true");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guesses = [
|
persistence.guesses = [guess, ...persistence.guesses];
|
||||||
{
|
|
||||||
book,
|
|
||||||
testamentMatch,
|
|
||||||
sectionMatch,
|
|
||||||
adjacent,
|
|
||||||
firstLetterMatch,
|
|
||||||
},
|
|
||||||
...guesses,
|
|
||||||
];
|
|
||||||
|
|
||||||
searchQuery = "";
|
searchQuery = "";
|
||||||
}
|
|
||||||
|
|
||||||
function generateUUID(): string {
|
if (
|
||||||
// Try native randomUUID if available
|
guess.book.id === correctBookId &&
|
||||||
if (typeof window.crypto.randomUUID === "function") {
|
browser &&
|
||||||
return window.crypto.randomUUID();
|
persistence.anonymousId
|
||||||
}
|
) {
|
||||||
|
statsData = await submitCompletion({
|
||||||
// Fallback UUID v4 generator for older browsers
|
anonymousId: persistence.anonymousId,
|
||||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
date: dailyVerse.date,
|
||||||
const r =
|
guessCount: persistence.guesses.length,
|
||||||
window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
|
guesses: persistence.guesses.map((g) => g.book.id),
|
||||||
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 (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(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
localStorage.setItem(
|
isDev =
|
||||||
`bibdle-guesses-${dailyVerse.date}`,
|
window.location.host === "localhost:5173" ||
|
||||||
JSON.stringify(guesses.map((g) => g.book.id)),
|
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(() => {
|
$effect(() => {
|
||||||
console.log("Stats effect triggered:", {
|
if (
|
||||||
browser,
|
!browser ||
|
||||||
isWon,
|
!isWon ||
|
||||||
anonymousId,
|
!persistence.anonymousId ||
|
||||||
statsSubmitted,
|
statsData ||
|
||||||
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(() => {
|
$effect(() => {
|
||||||
if (!browser || !isWon) return;
|
if (!browser || !isWon) return;
|
||||||
const key = `bibdle-win-tracked-${dailyVerse.date}`;
|
const isNew = persistence.markWinTracked();
|
||||||
if (localStorage.getItem(key) === "true") return;
|
if (isNew && (window as any).umami) {
|
||||||
if ((window as any).umami) {
|
|
||||||
(window as any).umami.track("Guessed correctly", {
|
(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 {
|
// Fetch streak when the player wins
|
||||||
const emojis = guesses
|
$effect(() => {
|
||||||
.slice()
|
if (!browser || !isWon || !persistence.anonymousId) return;
|
||||||
.reverse()
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
.map((guess) => {
|
fetchStreak(persistence.anonymousId, localDate).then((result) => {
|
||||||
if (guess.book.id === correctBookId) return "✅";
|
streak = result;
|
||||||
if (guess.adjacent) return "‼️";
|
if (result >= 2) {
|
||||||
if (guess.sectionMatch) return "🟩";
|
fetchStreakPercentile(result, localDate).then((p) => {
|
||||||
if (guess.testamentMatch) return "🟧";
|
streakPercentile = p;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
});
|
||||||
console.error("Share failed:", err);
|
});
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyToClipboard() {
|
function getShareText(): string {
|
||||||
if (!browser) return;
|
return generateShareText({
|
||||||
|
guesses: persistence.guesses,
|
||||||
const shareText = generateShareText();
|
correctBookId,
|
||||||
|
dailyVerseDate: dailyVerse.date,
|
||||||
try {
|
chapterCorrect: persistence.chapterCorrect,
|
||||||
await (navigator as any).clipboard.writeText(shareText);
|
isLoggedIn: !!user,
|
||||||
copied = true;
|
streak,
|
||||||
setTimeout(() => {
|
origin: window.location.origin,
|
||||||
copied = false;
|
verseText: dailyVerse.verseText,
|
||||||
}, 5000);
|
});
|
||||||
} catch (err) {
|
|
||||||
console.error("Copy to clipboard failed:", err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShare() {
|
function handleShare() {
|
||||||
@@ -410,7 +326,7 @@
|
|||||||
if (useClipboard) {
|
if (useClipboard) {
|
||||||
copied = true;
|
copied = true;
|
||||||
}
|
}
|
||||||
share()
|
shareResult(getShareText())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (useClipboard) {
|
if (useClipboard) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -424,81 +340,211 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<!-- <title>Bibdle — A daily bible game{isDev ? " (dev)" : ""}</title> -->
|
|
||||||
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
<title>A daily bible game{isDev ? " (dev)" : ""}</title>
|
||||||
<!-- <meta
|
|
||||||
name="description"
|
|
||||||
content="Guess which book of the Bible a verse comes from."
|
|
||||||
/> -->
|
|
||||||
</svelte:head>
|
</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">
|
<div class="w-full max-w-3xl mx-auto px-4">
|
||||||
<h1
|
<div class="text-center mb-8 animate-fade-in-up animate-delay-200">
|
||||||
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">
|
|
||||||
<span class="big-text"
|
<span class="big-text"
|
||||||
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
>{isDev ? "Dev Edition | " : ""}{currentDate}</span
|
||||||
>
|
>
|
||||||
<div class="mt-4">
|
|
||||||
<a
|
|
||||||
href="/stats?anonymousId={anonymousId}"
|
|
||||||
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-sm font-medium shadow-md"
|
|
||||||
>
|
|
||||||
📊 View Stats
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<VerseDisplay {data} {isWon} {blurChapter} />
|
<div class="animate-fade-in-up animate-delay-200">
|
||||||
|
<VerseDisplay {data} {isWon} {blurChapter} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if !isWon}
|
{#if !isWon}
|
||||||
<SearchInput bind:searchQuery {guessedIds} {submitGuess} />
|
<div class="animate-fade-in-up animate-delay-400">
|
||||||
{:else}
|
<GamePrompt guessCount={persistence.guesses.length} />
|
||||||
<WinScreen
|
|
||||||
{grade}
|
{#if showHints && (knownTestament || knownSection || knownFirstLetter)}
|
||||||
{statsData}
|
<div
|
||||||
{correctBookId}
|
class="text-xs uppercase tracking-widest font-bold text-center text-gray-500 dark:text-gray-400 flex flex-col gap-1 mb-4 -mt-2"
|
||||||
{handleShare}
|
>
|
||||||
{copyToClipboard}
|
{#if knownTestament}
|
||||||
bind:copied
|
<p
|
||||||
{statsSubmitted}
|
style="transition: opacity 0.5s ease; opacity: {testamentVisible
|
||||||
guessCount={guesses.length}
|
? 1
|
||||||
reference={dailyVerse.reference}
|
: 0};"
|
||||||
onChapterGuessCompleted={() => {
|
>
|
||||||
chapterGuessCompleted = true;
|
It is in the {knownTestament === "old"
|
||||||
const key = `bibdle-chapter-guess-${dailyVerse.reference}`;
|
? "Old"
|
||||||
const saved = localStorage.getItem(key);
|
: "New"} Testament.
|
||||||
if (saved) {
|
</p>
|
||||||
const data = JSON.parse(saved);
|
{/if}
|
||||||
const match =
|
{#if knownSection}
|
||||||
dailyVerse.reference.match(/\s(\d+):/);
|
<p
|
||||||
const correctChapter = match
|
style="transition: opacity 0.5s ease; opacity: {sectionVisible
|
||||||
? parseInt(match[1], 10)
|
? 1
|
||||||
: 1;
|
: 0};"
|
||||||
chapterCorrect =
|
>
|
||||||
data.selectedChapter === correctChapter;
|
It is in the {knownSection} section.
|
||||||
}
|
</p>
|
||||||
}}
|
{/if}
|
||||||
/>
|
{#if knownFirstLetter}
|
||||||
|
<p
|
||||||
|
style="transition: opacity 0.5s ease; opacity: {firstLetterVisible
|
||||||
|
? 1
|
||||||
|
: 0};"
|
||||||
|
>
|
||||||
|
The book's name starts with "{knownFirstLetter}".
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<SearchInput
|
||||||
|
bind:searchQuery
|
||||||
|
{guessedIds}
|
||||||
|
{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}
|
{/if}
|
||||||
|
|
||||||
<GuessesTable {guesses} {correctBookId} />
|
<div class="animate-fade-in-up animate-delay-600">
|
||||||
|
<GuessesTable
|
||||||
|
guesses={persistence.guesses}
|
||||||
|
{correctBookId}
|
||||||
|
minimized={guessesMinimized}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if isWon}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isDev}
|
{#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" : user.googleId ? " using Google" : ""} |
|
||||||
|
|
||||||
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AuthModal bind:isOpen={authModalOpen} anonymousId={persistence.anonymousId} />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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 type { RequestHandler } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { dailyCompletions } from '$lib/server/db/schema';
|
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 { json } from '@sveltejs/kit';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
const { anonymousId, date, guessCount } = await request.json();
|
const { anonymousId, date, guessCount, guesses } = await request.json();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
|
if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) {
|
||||||
@@ -23,6 +23,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
anonymousId,
|
anonymousId,
|
||||||
date,
|
date,
|
||||||
guessCount,
|
guessCount,
|
||||||
|
guesses: Array.isArray(guesses) ? JSON.stringify(guesses) : null,
|
||||||
completedAt,
|
completedAt,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -44,11 +45,9 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
// Solve rank: position in time-ordered list
|
// Solve rank: position in time-ordered list
|
||||||
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
const solveRank = allCompletions.findIndex(c => c.anonymousId === anonymousId) + 1;
|
||||||
|
|
||||||
// Guess rank: count how many DISTINCT guess counts are better (grouped ranking)
|
// Guess rank: count how many had FEWER guesses (ties get same rank)
|
||||||
const uniqueBetterGuessCounts = new Set(
|
const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
|
||||||
allCompletions.filter(c => c.guessCount < guessCount).map(c => c.guessCount)
|
const guessRank = betterGuesses + 1;
|
||||||
);
|
|
||||||
const guessRank = uniqueBetterGuessCounts.size + 1;
|
|
||||||
|
|
||||||
// Count ties: how many have the SAME guessCount (excluding self)
|
// Count ties: how many have the SAME guessCount (excluding self)
|
||||||
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
const tiedCount = allCompletions.filter(c => c.guessCount === guessCount && c.anonymousId !== anonymousId).length;
|
||||||
@@ -70,69 +69,3 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
return json({ error: 'Failed to submit completion' }, { status: 500 });
|
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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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, '/');
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import { getGoogleAuthUrl } from '$lib/server/google-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');
|
||||||
|
|
||||||
|
// sameSite 'lax' is safe here because Google sends a GET redirect back
|
||||||
|
// (unlike Apple which POSTs cross-origin, requiring 'none')
|
||||||
|
cookies.set('google_oauth_state', JSON.stringify({ state, anonymousId }), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 600
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(302, getGoogleAuthUrl(state));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { exchangeGoogleCode, decodeGoogleIdToken } from '$lib/server/google-auth';
|
||||||
|
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 GET: RequestHandler = async ({ url, cookies }) => {
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const state = url.searchParams.get('state');
|
||||||
|
const errorParam = url.searchParams.get('error');
|
||||||
|
|
||||||
|
// User denied access
|
||||||
|
if (errorParam) {
|
||||||
|
redirect(302, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedRaw = cookies.get('google_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('google_oauth_state', { path: '/' });
|
||||||
|
|
||||||
|
const anonId = stored.anonymousId;
|
||||||
|
if (!anonId) {
|
||||||
|
console.error('[Google 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 exchangeGoogleCode(
|
||||||
|
code,
|
||||||
|
`${Bun.env.PUBLIC_SITE_URL}/auth/google/callback`
|
||||||
|
);
|
||||||
|
const claims = decodeGoogleIdToken(tokens.id_token);
|
||||||
|
const googleId = claims.sub;
|
||||||
|
|
||||||
|
// --- User resolution ---
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
// 1. Check if a user with this googleId already exists (returning user)
|
||||||
|
const existingGoogleUser = await auth.getUserByGoogleId(googleId);
|
||||||
|
|
||||||
|
if (existingGoogleUser) {
|
||||||
|
userId = existingGoogleUser.id;
|
||||||
|
console.log(`[Google auth] Returning Google user: userId=${userId}, anonId=${anonId}`);
|
||||||
|
await auth.migrateAnonymousStats(anonId, userId);
|
||||||
|
} else if (claims.email) {
|
||||||
|
// 2. Check if email matches an existing email/password or Apple user
|
||||||
|
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
||||||
|
if (existingEmailUser) {
|
||||||
|
// Link Google account to existing user
|
||||||
|
await db.update(userTable).set({ googleId }).where(eq(userTable.id, existingEmailUser.id));
|
||||||
|
userId = existingEmailUser.id;
|
||||||
|
console.log(`[Google auth] Linked Google 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(`[Google auth] New user (has email): userId=${userId}`);
|
||||||
|
try {
|
||||||
|
await db.insert(userTable).values({
|
||||||
|
id: userId,
|
||||||
|
email: claims.email,
|
||||||
|
passwordHash: null,
|
||||||
|
appleId: null,
|
||||||
|
googleId,
|
||||||
|
firstName: claims.given_name || null,
|
||||||
|
lastName: claims.family_name || null,
|
||||||
|
isPrivate: false
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// Handle race condition: if googleId was inserted between our check and insert
|
||||||
|
if (e?.message?.includes('UNIQUE constraint')) {
|
||||||
|
const retryUser = await auth.getUserByGoogleId(googleId);
|
||||||
|
if (retryUser) {
|
||||||
|
userId = retryUser.id;
|
||||||
|
console.log(`[Google 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 Google (edge case — Google almost always returns email)
|
||||||
|
userId = anonId;
|
||||||
|
console.log(`[Google auth] New user (no email): userId=${userId}`);
|
||||||
|
try {
|
||||||
|
await db.insert(userTable).values({
|
||||||
|
id: userId,
|
||||||
|
email: null,
|
||||||
|
passwordHash: null,
|
||||||
|
appleId: null,
|
||||||
|
googleId,
|
||||||
|
firstName: claims.given_name || null,
|
||||||
|
lastName: claims.family_name || null,
|
||||||
|
isPrivate: false
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.message?.includes('UNIQUE constraint')) {
|
||||||
|
const retryUser = await auth.getUserByGoogleId(googleId);
|
||||||
|
if (retryUser) {
|
||||||
|
userId = retryUser.id;
|
||||||
|
console.log(`[Google 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, '/');
|
||||||
|
};
|
||||||
@@ -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, '/');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FrontBack from "$lib/components/cards/FrontBack.svelte";
|
||||||
|
import CardFan from "$lib/components/cards/CardFan.svelte";
|
||||||
|
|
||||||
|
const sampleCards = [
|
||||||
|
{ front: "/cards/1_Corinthians_13_front.png", back: "/cards/1_Corinthians_13_back.png" },
|
||||||
|
{ front: "/cards/Esther_4_front.png", back: "/cards/Esther_4_back.png" },
|
||||||
|
{ front: "/cards/Psalms_front.png", back: "/cards/Psalms_back.png" },
|
||||||
|
{ front: "/cards/Revelation_12_13_15_front.png", back: "/cards/Revelation_12_13_15_back.png" }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>BIBDLE Cards</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="text-center pb-96">Collectible Bible Verse Trading Cards</div>
|
||||||
|
<div class="min-h-dvh py-10 px-4 overflow-x-hidden">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mb-6">Card Fan Demo</h2>
|
||||||
|
<p class="text-gray-400 mb-4">Cards will fan out after a few seconds...</p>
|
||||||
|
<div class="flex justify-center mb-96">
|
||||||
|
<CardFan cards={sampleCards} />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center mb-96">
|
||||||
|
<FrontBack
|
||||||
|
front="/cards/Esther_4_front.png"
|
||||||
|
back="/cards/Esther_4_back.png"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from "$lib/components/Container.svelte";
|
||||||
|
import CollapsibleTable from "$lib/components/CollapsibleTable.svelte";
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
todayCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
uniquePlayers: number;
|
||||||
|
weeklyPlayers: number;
|
||||||
|
activeStreaks: number;
|
||||||
|
avgGuessesToday: number | null;
|
||||||
|
registeredUsers: number;
|
||||||
|
monthlyPlayers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageData {
|
||||||
|
todayEst: string;
|
||||||
|
stats: Stats;
|
||||||
|
last14Days: { date: string; count: number }[];
|
||||||
|
streakChart: { days: number; count: number }[];
|
||||||
|
growth: {
|
||||||
|
completionsVelocity: number;
|
||||||
|
completionsAcceleration: number;
|
||||||
|
userVelocity: number;
|
||||||
|
userAcceleration: number;
|
||||||
|
newUsers7d: number;
|
||||||
|
churned7d: number;
|
||||||
|
netGrowth7d: number;
|
||||||
|
};
|
||||||
|
retention7dSeries: { date: string; rate: number; cohortSize: number }[];
|
||||||
|
retention30dSeries: {
|
||||||
|
date: string;
|
||||||
|
rate: number;
|
||||||
|
cohortSize: number;
|
||||||
|
}[];
|
||||||
|
overallReturnRate: number | null;
|
||||||
|
newPlayerReturnSeries: {
|
||||||
|
date: string;
|
||||||
|
cohort: number;
|
||||||
|
rate: number | null;
|
||||||
|
rollingAvg: number | null;
|
||||||
|
}[];
|
||||||
|
newPlayerReturnVelocity: {
|
||||||
|
current7dAvg: number | null;
|
||||||
|
prior7dAvg: number | null;
|
||||||
|
change: number | null;
|
||||||
|
};
|
||||||
|
wauWeeks: {
|
||||||
|
weekStart: string;
|
||||||
|
weekEnd: string;
|
||||||
|
wau: number;
|
||||||
|
changePct: number | null;
|
||||||
|
}[];
|
||||||
|
avgWau: number;
|
||||||
|
mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[];
|
||||||
|
calendarMauMonths: {
|
||||||
|
label: string;
|
||||||
|
monthStart: string;
|
||||||
|
monthEnd: string;
|
||||||
|
mau: number;
|
||||||
|
daysElapsed: number;
|
||||||
|
daysInMonth: number;
|
||||||
|
projectedMau: number | null;
|
||||||
|
changePct: number | null;
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
}[];
|
||||||
|
sessionDepthCards: { depth: number; players: number; returnRate: number | null }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
const {
|
||||||
|
stats,
|
||||||
|
last14Days,
|
||||||
|
todayEst,
|
||||||
|
streakChart,
|
||||||
|
growth,
|
||||||
|
retention7dSeries,
|
||||||
|
retention30dSeries,
|
||||||
|
overallReturnRate,
|
||||||
|
newPlayerReturnSeries,
|
||||||
|
newPlayerReturnVelocity,
|
||||||
|
wauWeeks,
|
||||||
|
avgWau,
|
||||||
|
mauMonths,
|
||||||
|
calendarMauMonths,
|
||||||
|
sessionDepthCards,
|
||||||
|
} = $derived(data);
|
||||||
|
|
||||||
|
let mauMode = $state<'rolling' | 'calendar'>('rolling');
|
||||||
|
|
||||||
|
function signed(n: number, unit = ""): string {
|
||||||
|
if (n > 0) return `+${n}${unit}`;
|
||||||
|
if (n < 0) return `${n}${unit}`;
|
||||||
|
return `0${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendColor(n: number): string {
|
||||||
|
if (n > 0) return "text-green-400";
|
||||||
|
if (n < 0) return "text-red-400";
|
||||||
|
return "text-gray-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
|
||||||
|
const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau)));
|
||||||
|
const maxStreakCount = $derived(Math.max(1, ...streakChart.map((r) => r.count)));
|
||||||
|
const maxMau = $derived(Math.max(1, ...mauMonths.map((m) => m.mau)));
|
||||||
|
const maxCalMau = $derived(Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau)));
|
||||||
|
|
||||||
|
const statCards = $derived([
|
||||||
|
{ label: "Completions Today", value: String(stats.todayCount) },
|
||||||
|
{ label: "All-Time Completions", value: String(stats.totalCount) },
|
||||||
|
{ label: "Unique Players", value: String(stats.uniquePlayers) },
|
||||||
|
{ label: "Players This Week", value: String(stats.weeklyPlayers) },
|
||||||
|
{ label: "Active Streaks", value: String(stats.activeStreaks) },
|
||||||
|
{ label: "Registered Users", value: String(stats.registeredUsers) },
|
||||||
|
{ label: "Players This Month", value: String(stats.monthlyPlayers) },
|
||||||
|
{
|
||||||
|
label: "Overall Return Rate",
|
||||||
|
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mauModes = [
|
||||||
|
{ value: 'rolling', label: 'Rolling 30d' },
|
||||||
|
{ value: 'calendar', label: 'Calendar' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Global Stats | Bibdle</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 text-gray-100"
|
||||||
|
>
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center gap-1 text-gray-400 hover:text-gray-100 text-sm mb-6 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Game
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<header class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-100">Global Stats</h1>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">
|
||||||
|
EST reference date: {todayEst}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-10"
|
||||||
|
>
|
||||||
|
{#each statCards as card (card.label)}
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>{card.label}</span
|
||||||
|
>
|
||||||
|
<span class="text-2xl md:text-3xl font-bold text-gray-100"
|
||||||
|
>{card.value}</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||||
|
Traffic & Growth <span
|
||||||
|
class="text-xs font-normal text-gray-400"
|
||||||
|
>(7-day windows)</span
|
||||||
|
>
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>Completions Velocity</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
growth.completionsVelocity,
|
||||||
|
)}">{signed(growth.completionsVelocity, "/day")}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>vs prior 7 days</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>Completions Accel.</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
growth.completionsAcceleration,
|
||||||
|
)}"
|
||||||
|
>{signed(growth.completionsAcceleration, "/day")}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>rate of change of velocity</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>User Velocity</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
growth.userVelocity,
|
||||||
|
)}">{signed(growth.userVelocity)}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>unique players, wk/wk</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>User Acceleration</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
growth.userAcceleration,
|
||||||
|
)}">{signed(growth.userAcceleration)}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>rate of change of user velocity</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>New Players (7d)</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
growth.newUsers7d,
|
||||||
|
)}">{String(growth.newUsers7d)}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>first-time players</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>Churned (7d)</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
0,
|
||||||
|
)}">{String(growth.churned7d)}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>played wk prior, not this wk</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>Net Growth (7d)</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||||
|
growth.netGrowth7d,
|
||||||
|
)}">{signed(growth.netGrowth7d)}</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>new minus churned</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-1">Survival Curve</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-4">
|
||||||
|
Of players who completed N sessions, what % came back for N+1?
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
|
{#each sessionDepthCards as card (card.depth)}
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>After {card.depth} plays</span
|
||||||
|
>
|
||||||
|
<span class="text-2xl md:text-3xl font-bold text-gray-100">
|
||||||
|
{card.returnRate != null ? `${card.returnRate}%` : "N/A"}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>{card.players} players</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||||
|
New Player Return Rate <span
|
||||||
|
class="text-xs font-normal text-gray-400"
|
||||||
|
>(7-day rolling avg)</span
|
||||||
|
>
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>Return Rate (7d avg)</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center text-gray-100"
|
||||||
|
>
|
||||||
|
{newPlayerReturnVelocity.current7dAvg != null
|
||||||
|
? `${newPlayerReturnVelocity.current7dAvg}%`
|
||||||
|
: "N/A"}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>new players who came back</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>Return Rate Change</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center {newPlayerReturnVelocity.change !=
|
||||||
|
null
|
||||||
|
? trendColor(newPlayerReturnVelocity.change)
|
||||||
|
: 'text-gray-400'}"
|
||||||
|
>
|
||||||
|
{newPlayerReturnVelocity.change != null
|
||||||
|
? signed(newPlayerReturnVelocity.change, "pp")
|
||||||
|
: "N/A"}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>vs prior 7 days</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
<Container class="w-full p-5 gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||||
|
>Prior 7d Avg</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-2xl md:text-3xl font-bold text-center text-gray-100"
|
||||||
|
>
|
||||||
|
{newPlayerReturnVelocity.prior7dAvg != null
|
||||||
|
? `${newPlayerReturnVelocity.prior7dAvg}%`
|
||||||
|
: "N/A"}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 text-center"
|
||||||
|
>days 8–14 ago</span
|
||||||
|
>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleTable
|
||||||
|
rows={newPlayerReturnSeries}
|
||||||
|
headers={[
|
||||||
|
{ label: 'Date' },
|
||||||
|
{ label: 'New Players', align: 'right' },
|
||||||
|
{ label: 'Return Rate', align: 'right' },
|
||||||
|
{ label: '7d Avg', align: 'right' },
|
||||||
|
{ label: '', width: 'w-32' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{#snippet row(item)}
|
||||||
|
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohort}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-400">
|
||||||
|
{item.rate != null ? `${item.rate}%` : '—'}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">
|
||||||
|
{item.rollingAvg != null ? `${item.rollingAvg}%` : '—'}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-20">
|
||||||
|
{#if item.rollingAvg != null}
|
||||||
|
<div class="bg-sky-500 h-4 rounded" style="width: {item.rollingAvg}%"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/snippet}
|
||||||
|
</CollapsibleTable>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-1">
|
||||||
|
Weekly Active Users
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-4">
|
||||||
|
Unique players per 7-day window. Most recent week first. Avg
|
||||||
|
WAU: <span class="text-gray-100 font-medium">{avgWau}</span>
|
||||||
|
</p>
|
||||||
|
<CollapsibleTable
|
||||||
|
rows={wauWeeks}
|
||||||
|
headers={[
|
||||||
|
{ label: 'Week' },
|
||||||
|
{ label: 'Active Users', align: 'right' },
|
||||||
|
{ label: 'Wk/Wk Change', align: 'right' },
|
||||||
|
{ label: '', width: 'w-48' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{#snippet row(item)}
|
||||||
|
{@const barPct = Math.round((item.wau / maxWau) * 100)}
|
||||||
|
<td class="px-4 py-3 text-gray-300 text-xs">{item.weekStart} – {item.weekEnd}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.wau}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
|
||||||
|
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
|
||||||
|
: 'text-gray-500'}">
|
||||||
|
{item.changePct != null ? signed(item.changePct, '%') : '—'}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-24">
|
||||||
|
<div class="bg-indigo-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/snippet}
|
||||||
|
</CollapsibleTable>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-1">Monthly Active Users</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-4">
|
||||||
|
{mauMode === 'rolling'
|
||||||
|
? 'Unique players per 30-day window. Most recent first.'
|
||||||
|
: 'Unique players per calendar month. Current month projected to end of month.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if mauMode === 'rolling'}
|
||||||
|
<CollapsibleTable
|
||||||
|
rows={mauMonths}
|
||||||
|
headers={[
|
||||||
|
{ label: 'Period' },
|
||||||
|
{ label: 'Active Users', align: 'right' },
|
||||||
|
{ label: 'Mo/Mo Change', align: 'right' },
|
||||||
|
{ label: '', width: 'w-48' },
|
||||||
|
]}
|
||||||
|
modes={mauModes}
|
||||||
|
bind:mode={mauMode}
|
||||||
|
>
|
||||||
|
{#snippet row(item)}
|
||||||
|
{@const barPct = Math.round((item.mau / maxMau) * 100)}
|
||||||
|
<td class="px-4 py-3 text-gray-300 text-xs">{item.monthStart} – {item.monthEnd}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.mau}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
|
||||||
|
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
|
||||||
|
: 'text-gray-500'}">
|
||||||
|
{item.changePct != null ? signed(item.changePct, '%') : '—'}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-24">
|
||||||
|
<div class="bg-teal-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/snippet}
|
||||||
|
</CollapsibleTable>
|
||||||
|
{:else}
|
||||||
|
<CollapsibleTable
|
||||||
|
rows={calendarMauMonths}
|
||||||
|
headers={[
|
||||||
|
{ label: 'Month' },
|
||||||
|
{ label: 'Active Users', align: 'right' },
|
||||||
|
{ label: 'Mo/Mo Change', align: 'right' },
|
||||||
|
{ label: '', width: 'w-48' },
|
||||||
|
]}
|
||||||
|
modes={mauModes}
|
||||||
|
bind:mode={mauMode}
|
||||||
|
>
|
||||||
|
{#snippet row(item)}
|
||||||
|
{@const displayMau = item.projectedMau ?? item.mau}
|
||||||
|
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
|
||||||
|
<td class="px-4 py-3 text-gray-300">
|
||||||
|
{item.label}
|
||||||
|
{#if item.isCurrentMonth}
|
||||||
|
<span class="text-xs text-gray-500 ml-1">(projected)</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right font-medium {item.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
|
||||||
|
{#if item.isCurrentMonth}
|
||||||
|
<span class="text-gray-500 text-xs">{item.mau} → </span>{item.projectedMau ?? item.mau}
|
||||||
|
{:else}
|
||||||
|
{item.mau}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
|
||||||
|
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
|
||||||
|
: 'text-gray-500'}">
|
||||||
|
{#if item.changePct != null}
|
||||||
|
{item.isCurrentMonth ? '~' : ''}{signed(item.changePct, '%')}
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-24">
|
||||||
|
<div class="bg-teal-500 h-4 rounded {item.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/snippet}
|
||||||
|
</CollapsibleTable>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||||
|
Last 14 Days — Completions
|
||||||
|
</h2>
|
||||||
|
<CollapsibleTable
|
||||||
|
rows={last14Days}
|
||||||
|
headers={[
|
||||||
|
{ label: 'Date' },
|
||||||
|
{ label: 'Completions', align: 'right' },
|
||||||
|
{ label: '', width: 'w-48' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{#snippet row(item)}
|
||||||
|
{@const barPct = Math.round((item.count / maxCount) * 100)}
|
||||||
|
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-24">
|
||||||
|
<div class="bg-amber-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/snippet}
|
||||||
|
</CollapsibleTable>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||||
|
Active Streak Distribution
|
||||||
|
</h2>
|
||||||
|
<CollapsibleTable
|
||||||
|
rows={streakChart}
|
||||||
|
headers={[
|
||||||
|
{ label: 'Days' },
|
||||||
|
{ label: 'Players', align: 'right' },
|
||||||
|
{ label: '', width: 'w-48' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{#snippet row(item)}
|
||||||
|
{@const barPct = Math.round((item.count / maxStreakCount) * 100)}
|
||||||
|
<td class="px-4 py-3 text-gray-300">{item.days}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-24">
|
||||||
|
<div class="bg-blue-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet empty()}
|
||||||
|
<p class="text-gray-400 text-sm px-4 py-6">No active streaks yet.</p>
|
||||||
|
{/snippet}
|
||||||
|
</CollapsibleTable>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-100 mb-1">
|
||||||
|
Retention Over Time
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400 text-sm mb-6">
|
||||||
|
% of each day's players who played again exactly 7 or 30 days later (regardless of activity in between). Cohorts with fewer than 3 players are excluded.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- 7-day retention -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-200 mb-3">
|
||||||
|
7-Day Retention
|
||||||
|
</h3>
|
||||||
|
<CollapsibleTable
|
||||||
|
rows={retention7dSeries}
|
||||||
|
headers={[
|
||||||
|
{ label: 'Cohort Date' },
|
||||||
|
{ label: 'n', align: 'right' },
|
||||||
|
{ label: 'Ret. %', align: 'right' },
|
||||||
|
{ label: '', width: 'w-32' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{#snippet row(item)}
|
||||||
|
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-20">
|
||||||
|
<div class="bg-emerald-500 h-4 rounded" style="width: {item.rate}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/snippet}
|
||||||
|
</CollapsibleTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 30-day retention -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-200 mb-3">
|
||||||
|
30-Day Retention
|
||||||
|
</h3>
|
||||||
|
<CollapsibleTable
|
||||||
|
rows={retention30dSeries}
|
||||||
|
headers={[
|
||||||
|
{ label: 'Cohort Date' },
|
||||||
|
{ label: 'n', align: 'right' },
|
||||||
|
{ label: 'Ret. %', align: 'right' },
|
||||||
|
{ label: '', width: 'w-32' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{#snippet row(item)}
|
||||||
|
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="w-full min-w-20">
|
||||||
|
<div class="bg-violet-500 h-4 rounded" style="width: {item.rate}%"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/snippet}
|
||||||
|
</CollapsibleTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+64
-1
@@ -2,12 +2,29 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-triodion: "PT Serif", serif;
|
--font-triodion: "PT Serif", serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
background: oklch(89.126% 0.06134 298.626);
|
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 {
|
.big-text {
|
||||||
@@ -16,4 +33,50 @@ html, body {
|
|||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
color: rgb(107 114 128);
|
color: rgb(107 114 128);
|
||||||
font-weight: 700;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { dailyCompletions, dailyVerses } from '$lib/server/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { bibleBooks } from '$lib/types/bible';
|
||||||
|
import { calculateMilestones } from '$lib/server/milestones';
|
||||||
|
import type { Milestone } from '$lib/server/milestones';
|
||||||
|
|
||||||
|
export type BookTier = 'unseen' | 'explored' | 'mastered' | 'perfect';
|
||||||
|
|
||||||
|
export type BookGridEntry = {
|
||||||
|
bookId: string;
|
||||||
|
tier: BookTier;
|
||||||
|
avgGuesses: number | null;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChartPoint = {
|
||||||
|
label: string;
|
||||||
|
avgGuesses: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SectionStat = {
|
||||||
|
section: string;
|
||||||
|
avgGuesses: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestamentStat = {
|
||||||
|
avgGuesses: number;
|
||||||
|
count: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export type { Milestone };
|
||||||
|
|
||||||
|
export type ProgressData = {
|
||||||
|
completions: Array<{ date: string; guessCount: number }>;
|
||||||
|
chartPoints: ChartPoint[];
|
||||||
|
bookGrid: BookGridEntry[];
|
||||||
|
sectionStats: SectionStat[];
|
||||||
|
testamentStats: { old: TestamentStat; new: TestamentStat };
|
||||||
|
totalSolves: number;
|
||||||
|
bestStreak: number;
|
||||||
|
currentStreak: number;
|
||||||
|
booksExplored: number;
|
||||||
|
booksMastered: number;
|
||||||
|
booksPerfect: number;
|
||||||
|
bestSingleGame: { date: string; bookName: string } | null;
|
||||||
|
totalWords: number;
|
||||||
|
streakMilestones: { days7: string | null; days14: string | null; days30: string | null };
|
||||||
|
milestones: Milestone[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
return {
|
||||||
|
progress: null,
|
||||||
|
requiresAuth: true,
|
||||||
|
user: null,
|
||||||
|
session: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = locals.user.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const completions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId))
|
||||||
|
.orderBy(desc(dailyCompletions.date));
|
||||||
|
|
||||||
|
if (completions.length === 0) {
|
||||||
|
return {
|
||||||
|
progress: {
|
||||||
|
completions: [],
|
||||||
|
chartPoints: [],
|
||||||
|
bookGrid: bibleBooks.map(b => ({ bookId: b.id, tier: 'unseen' as BookTier, avgGuesses: null, count: 0 })),
|
||||||
|
sectionStats: [],
|
||||||
|
testamentStats: { old: null, new: null },
|
||||||
|
totalSolves: 0,
|
||||||
|
bestStreak: 0,
|
||||||
|
currentStreak: 0,
|
||||||
|
booksExplored: 0,
|
||||||
|
booksMastered: 0,
|
||||||
|
booksPerfect: 0,
|
||||||
|
bestSingleGame: null,
|
||||||
|
totalWords: 0,
|
||||||
|
streakMilestones: { days7: null, days14: null, days30: null },
|
||||||
|
milestones: [],
|
||||||
|
} satisfies ProgressData,
|
||||||
|
requiresAuth: false,
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map dates to book IDs and verse text via cached daily_verses
|
||||||
|
const allVerses = await db.select().from(dailyVerses);
|
||||||
|
const dateToBookId = new Map(allVerses.map(v => [v.date, v.bookId]));
|
||||||
|
const dateToVerseText = new Map(allVerses.map(v => [v.date, v.verseText]));
|
||||||
|
|
||||||
|
// Total words across all played verses
|
||||||
|
let totalWords = 0;
|
||||||
|
for (const c of completions) {
|
||||||
|
const verseText = dateToVerseText.get(c.date);
|
||||||
|
if (verseText) {
|
||||||
|
totalWords += verseText.trim().split(/\s+/).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-book stats
|
||||||
|
const bookStatsMap = new Map<string, { count: number; totalGuesses: number; everGuessedIn1: boolean }>();
|
||||||
|
for (const c of completions) {
|
||||||
|
const bookId = dateToBookId.get(c.date);
|
||||||
|
if (!bookId) continue;
|
||||||
|
const existing = bookStatsMap.get(bookId) ?? { count: 0, totalGuesses: 0, everGuessedIn1: false };
|
||||||
|
bookStatsMap.set(bookId, {
|
||||||
|
count: existing.count + 1,
|
||||||
|
totalGuesses: existing.totalGuesses + c.guessCount,
|
||||||
|
everGuessedIn1: existing.everGuessedIn1 || c.guessCount === 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Book grid (all 66 in canonical order)
|
||||||
|
const bookGrid: BookGridEntry[] = bibleBooks.map(book => {
|
||||||
|
const stats = bookStatsMap.get(book.id);
|
||||||
|
if (!stats) return { bookId: book.id, tier: 'unseen', avgGuesses: null, count: 0 };
|
||||||
|
const avgGuesses = stats.totalGuesses / stats.count;
|
||||||
|
let tier: BookTier = 'explored';
|
||||||
|
if (stats.count >= 2 && avgGuesses <= 3) {
|
||||||
|
tier = stats.everGuessedIn1 ? 'perfect' : 'mastered';
|
||||||
|
}
|
||||||
|
return { bookId: book.id, tier, avgGuesses: Math.round(avgGuesses * 10) / 10, count: stats.count };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Section stats
|
||||||
|
const sectionMap = new Map<string, { totalGuesses: number; count: number }>();
|
||||||
|
for (const c of completions) {
|
||||||
|
const bookId = dateToBookId.get(c.date);
|
||||||
|
if (!bookId) continue;
|
||||||
|
const book = bibleBooks.find(b => b.id === bookId);
|
||||||
|
if (!book) continue;
|
||||||
|
const existing = sectionMap.get(book.section) ?? { totalGuesses: 0, count: 0 };
|
||||||
|
sectionMap.set(book.section, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
|
||||||
|
}
|
||||||
|
const sectionStats: SectionStat[] = Array.from(sectionMap.entries())
|
||||||
|
.filter(([, s]) => s.count >= 3)
|
||||||
|
.map(([section, s]) => ({ section, avgGuesses: Math.round((s.totalGuesses / s.count) * 10) / 10, count: s.count }))
|
||||||
|
.sort((a, b) => a.avgGuesses - b.avgGuesses);
|
||||||
|
|
||||||
|
// Testament stats (only show if ≥5 games per testament)
|
||||||
|
let otTotal = 0, otCount = 0, ntTotal = 0, ntCount = 0;
|
||||||
|
for (const c of completions) {
|
||||||
|
const bookId = dateToBookId.get(c.date);
|
||||||
|
if (!bookId) continue;
|
||||||
|
const book = bibleBooks.find(b => b.id === bookId);
|
||||||
|
if (!book) continue;
|
||||||
|
if (book.testament === 'old') { otTotal += c.guessCount; otCount++; }
|
||||||
|
else { ntTotal += c.guessCount; ntCount++; }
|
||||||
|
}
|
||||||
|
const testamentStats = {
|
||||||
|
old: otCount >= 5 ? { avgGuesses: Math.round((otTotal / otCount) * 10) / 10, count: otCount } : null,
|
||||||
|
new: ntCount >= 5 ? { avgGuesses: Math.round((ntTotal / ntCount) * 10) / 10, count: ntCount } : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chart points — monthly averages sorted ascending
|
||||||
|
const sortedCompletions = [...completions].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
const monthMap = new Map<string, { totalGuesses: number; count: number }>();
|
||||||
|
for (const c of sortedCompletions) {
|
||||||
|
const month = c.date.slice(0, 7); // YYYY-MM
|
||||||
|
const existing = monthMap.get(month) ?? { totalGuesses: 0, count: 0 };
|
||||||
|
monthMap.set(month, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
|
||||||
|
}
|
||||||
|
let chartPoints: ChartPoint[] = Array.from(monthMap.entries())
|
||||||
|
.map(([label, m]) => ({ label, avgGuesses: Math.round((m.totalGuesses / m.count) * 10) / 10 }));
|
||||||
|
|
||||||
|
// Fall back to weekly if fewer than 3 months of data
|
||||||
|
if (chartPoints.length < 3 && sortedCompletions.length >= 5) {
|
||||||
|
const weekMap = new Map<string, { totalGuesses: number; count: number }>();
|
||||||
|
for (const c of sortedCompletions) {
|
||||||
|
const d = new Date(c.date + 'T00:00:00Z');
|
||||||
|
const year = d.getUTCFullYear();
|
||||||
|
const week = getISOWeek(d);
|
||||||
|
const key = `${year}-W${String(week).padStart(2, '0')}`;
|
||||||
|
const existing = weekMap.get(key) ?? { totalGuesses: 0, count: 0 };
|
||||||
|
weekMap.set(key, { totalGuesses: existing.totalGuesses + c.guessCount, count: existing.count + 1 });
|
||||||
|
}
|
||||||
|
chartPoints = Array.from(weekMap.entries())
|
||||||
|
.map(([label, m]) => ({ label, avgGuesses: Math.round((m.totalGuesses / m.count) * 10) / 10 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best streak (all-time) + streak milestones
|
||||||
|
const sortedDates = completions.map(c => c.date).sort();
|
||||||
|
let bestStreak = sortedDates.length > 0 ? 1 : 0;
|
||||||
|
let tempStreak = 1;
|
||||||
|
const streakMilestones: { days7: string | null; days14: string | null; days30: string | null } = { days7: null, days14: null, days30: null };
|
||||||
|
for (let i = 1; i < sortedDates.length; i++) {
|
||||||
|
const curr = new Date(sortedDates[i] + 'T00:00:00Z');
|
||||||
|
const prev = new Date(sortedDates[i - 1] + 'T00:00:00Z');
|
||||||
|
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
|
||||||
|
if (diff === 1) { tempStreak++; }
|
||||||
|
else { bestStreak = Math.max(bestStreak, tempStreak); tempStreak = 1; }
|
||||||
|
if (tempStreak >= 7 && !streakMilestones.days7) streakMilestones.days7 = sortedDates[i];
|
||||||
|
if (tempStreak >= 14 && !streakMilestones.days14) streakMilestones.days14 = sortedDates[i];
|
||||||
|
if (tempStreak >= 30 && !streakMilestones.days30) streakMilestones.days30 = sortedDates[i];
|
||||||
|
}
|
||||||
|
bestStreak = Math.max(bestStreak, tempStreak);
|
||||||
|
|
||||||
|
// Server-side current streak estimate (client overrides via /api/streak)
|
||||||
|
const userToday = new Date().toISOString().slice(0, 10);
|
||||||
|
const yesterday = new Date(new Date(userToday + 'T00:00:00Z').getTime() - 86400000).toISOString().slice(0, 10);
|
||||||
|
const lastDate = sortedDates[sortedDates.length - 1] ?? '';
|
||||||
|
let currentStreak = 0;
|
||||||
|
if (lastDate === userToday || lastDate === yesterday) {
|
||||||
|
currentStreak = 1;
|
||||||
|
for (let i = sortedDates.length - 2; i >= 0; i--) {
|
||||||
|
const curr = new Date(sortedDates[i + 1] + 'T00:00:00Z');
|
||||||
|
const prev = new Date(sortedDates[i] + 'T00:00:00Z');
|
||||||
|
const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000);
|
||||||
|
if (diff === 1) currentStreak++;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Milestone counts
|
||||||
|
const booksExplored = bookStatsMap.size;
|
||||||
|
const booksMastered = bookGrid.filter(b => b.tier === 'mastered' || b.tier === 'perfect').length;
|
||||||
|
const booksPerfect = bookGrid.filter(b => b.tier === 'perfect').length;
|
||||||
|
|
||||||
|
// Best single game (earliest 1-guess solve)
|
||||||
|
let bestSingleGame: { date: string; bookName: string } | null = null;
|
||||||
|
for (const c of sortedCompletions) {
|
||||||
|
if (c.guessCount === 1) {
|
||||||
|
const bookId = dateToBookId.get(c.date);
|
||||||
|
const book = bookId ? bibleBooks.find(b => b.id === bookId) : null;
|
||||||
|
if (book) {
|
||||||
|
bestSingleGame = { date: c.date, bookName: book.name };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestones = await calculateMilestones(completions, dateToBookId, { bestSingleGame, streakMilestones });
|
||||||
|
|
||||||
|
return {
|
||||||
|
progress: {
|
||||||
|
completions: completions.map(c => ({ date: c.date, guessCount: c.guessCount })),
|
||||||
|
chartPoints,
|
||||||
|
bookGrid,
|
||||||
|
sectionStats,
|
||||||
|
testamentStats,
|
||||||
|
totalSolves: completions.length,
|
||||||
|
bestStreak,
|
||||||
|
currentStreak,
|
||||||
|
booksExplored,
|
||||||
|
booksMastered,
|
||||||
|
booksPerfect,
|
||||||
|
bestSingleGame,
|
||||||
|
totalWords,
|
||||||
|
streakMilestones,
|
||||||
|
milestones,
|
||||||
|
} satisfies ProgressData,
|
||||||
|
requiresAuth: false,
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching progress data:', error);
|
||||||
|
return {
|
||||||
|
progress: null,
|
||||||
|
error: 'Failed to load progress data',
|
||||||
|
requiresAuth: false,
|
||||||
|
user: locals.user,
|
||||||
|
session: locals.session,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getISOWeek(d: Date): number {
|
||||||
|
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||||
|
const dayNum = date.getUTCDay() || 7;
|
||||||
|
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||||
|
}
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { SvelteDate } from "svelte/reactivity";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
|
import Container from "$lib/components/Container.svelte";
|
||||||
|
import ProgressStatCard from "$lib/components/ProgressStatCard.svelte";
|
||||||
|
import ActivityCalendar from "$lib/components/ActivityCalendar.svelte";
|
||||||
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
|
|
||||||
|
type BookTier = "unseen" | "explored" | "mastered" | "perfect";
|
||||||
|
|
||||||
|
type BookGridEntry = {
|
||||||
|
bookId: string;
|
||||||
|
tier: BookTier;
|
||||||
|
avgGuesses: number | null;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartPoint = {
|
||||||
|
label: string;
|
||||||
|
avgGuesses: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SectionStat = {
|
||||||
|
section: string;
|
||||||
|
avgGuesses: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Milestone = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
emoji: string;
|
||||||
|
description: string;
|
||||||
|
achieved: boolean;
|
||||||
|
achievedDate: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProgressData = {
|
||||||
|
completions: Array<{ date: string; guessCount: number }>;
|
||||||
|
chartPoints: ChartPoint[];
|
||||||
|
bookGrid: BookGridEntry[];
|
||||||
|
sectionStats: SectionStat[];
|
||||||
|
testamentStats: {
|
||||||
|
old: { avgGuesses: number; count: number } | null;
|
||||||
|
new: { avgGuesses: number; count: number } | null;
|
||||||
|
};
|
||||||
|
totalSolves: number;
|
||||||
|
bestStreak: number;
|
||||||
|
currentStreak: number;
|
||||||
|
booksExplored: number;
|
||||||
|
booksMastered: number;
|
||||||
|
booksPerfect: number;
|
||||||
|
bestSingleGame: { date: string; bookName: string } | null;
|
||||||
|
totalWords: number;
|
||||||
|
streakMilestones: {
|
||||||
|
days7: string | null;
|
||||||
|
days14: string | null;
|
||||||
|
days30: string | null;
|
||||||
|
};
|
||||||
|
milestones: Milestone[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PageData {
|
||||||
|
progress: ProgressData | null;
|
||||||
|
error?: string;
|
||||||
|
user?: any;
|
||||||
|
session?: any;
|
||||||
|
requiresAuth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let authModalOpen = $state(false);
|
||||||
|
let anonymousId = $state("");
|
||||||
|
|
||||||
|
function getOrCreateAnonymousId(): string {
|
||||||
|
if (!browser) return "";
|
||||||
|
const key = "bibdle-anonymous-id";
|
||||||
|
let id = localStorage.getItem(key);
|
||||||
|
if (!id) {
|
||||||
|
id = crypto.randomUUID();
|
||||||
|
localStorage.setItem(key, id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookTileClass(tier: BookTier): string {
|
||||||
|
switch (tier) {
|
||||||
|
case "perfect":
|
||||||
|
return "bg-emerald-500 text-white";
|
||||||
|
case "mastered":
|
||||||
|
return "bg-purple-600 text-white";
|
||||||
|
case "explored":
|
||||||
|
return "bg-blue-700 text-blue-100";
|
||||||
|
default:
|
||||||
|
return "bg-gray-700/50 text-gray-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecent(dateStr: string | null): boolean {
|
||||||
|
if (!dateStr || !browser) return false;
|
||||||
|
const sevenDaysAgo = new SvelteDate();
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
const achievedDate = new Date(dateStr + "T00:00:00Z");
|
||||||
|
return achievedDate >= sevenDaysAgo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + "T00:00:00Z");
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
anonymousId = getOrCreateAnonymousId();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived SVG chart values
|
||||||
|
const chartPoints = $derived(data.progress?.chartPoints ?? []);
|
||||||
|
const showChart = $derived(chartPoints.length >= 3);
|
||||||
|
const maxGuesses = $derived(
|
||||||
|
showChart ? Math.max(...chartPoints.map((p) => p.avgGuesses)) : 6,
|
||||||
|
);
|
||||||
|
const chartImproving = $derived(
|
||||||
|
showChart &&
|
||||||
|
chartPoints[chartPoints.length - 1].avgGuesses <
|
||||||
|
chartPoints[0].avgGuesses,
|
||||||
|
);
|
||||||
|
|
||||||
|
function svgX(index: number, total: number): number {
|
||||||
|
return (index / (total - 1)) * 360 + 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgY(avgGuesses: number, maxG: number): number {
|
||||||
|
return 100 - ((maxG - avgGuesses) / (maxG - 1)) * 90 + 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
const polylinePoints = $derived(
|
||||||
|
showChart
|
||||||
|
? chartPoints
|
||||||
|
.map(
|
||||||
|
(p, i) =>
|
||||||
|
`${svgX(i, chartPoints.length)},${svgY(p.avgGuesses, maxGuesses)}`,
|
||||||
|
)
|
||||||
|
.join(" ")
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insights helpers
|
||||||
|
const progress = $derived(data.progress);
|
||||||
|
const bestSection = $derived(
|
||||||
|
progress?.sectionStats.find((s) => s.count >= 3) ?? null,
|
||||||
|
);
|
||||||
|
const hardestSection = $derived.by(() => {
|
||||||
|
if (!progress) return null;
|
||||||
|
const eligible = progress.sectionStats.filter((s) => s.count >= 3);
|
||||||
|
if (eligible.length === 0) return null;
|
||||||
|
const last = eligible[eligible.length - 1];
|
||||||
|
if (bestSection && last.section === bestSection.section) return null;
|
||||||
|
return last;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showInsights = $derived(
|
||||||
|
progress !== null &&
|
||||||
|
((progress.testamentStats.old !== null &&
|
||||||
|
progress.testamentStats.new !== null) ||
|
||||||
|
bestSection !== null),
|
||||||
|
);
|
||||||
|
|
||||||
|
function testamentComparison(
|
||||||
|
old_: { avgGuesses: number; count: number } | null,
|
||||||
|
new_: { avgGuesses: number; count: number } | null,
|
||||||
|
): string | null {
|
||||||
|
if (!old_ || !new_) return null;
|
||||||
|
const ratio = old_.avgGuesses / new_.avgGuesses;
|
||||||
|
if (ratio < 0.85) {
|
||||||
|
const x = (new_.avgGuesses / old_.avgGuesses).toFixed(1);
|
||||||
|
return `You're ${x}x faster at Old Testament books`;
|
||||||
|
}
|
||||||
|
if (ratio > 1.18) {
|
||||||
|
const x = (old_.avgGuesses / new_.avgGuesses).toFixed(1);
|
||||||
|
return `You're ${x}x faster at New Testament books`;
|
||||||
|
}
|
||||||
|
return "Your speed is similar for Old and New Testament books";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Your Progress | Bibdle</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Track your Bible knowledge journey with Bibdle"
|
||||||
|
/>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
|
||||||
|
>
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center mb-6 md:mb-8">
|
||||||
|
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-4">
|
||||||
|
Your Progress
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<a href="/" class="p-2 px-20 w-full items-center text-gray-300">
|
||||||
|
← Back to Game
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.requiresAuth}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div
|
||||||
|
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<h2 class="text-2xl font-bold text-blue-200 mb-4">
|
||||||
|
Authentication Required
|
||||||
|
</h2>
|
||||||
|
<p class="text-blue-300 mb-6">
|
||||||
|
You must be logged in to see your progress.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => (authModalOpen = true)}
|
||||||
|
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Sign In / Sign Up
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
← Back to Game
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if data.error}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div
|
||||||
|
class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<p class="text-red-300">{data.error}</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Return to Game
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !data.progress}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<Container class="p-8 max-w-md mx-auto">
|
||||||
|
<div class="text-yellow-400 mb-4 text-lg">
|
||||||
|
No progress yet.
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-300 mb-6">
|
||||||
|
Start playing to build your Bible knowledge journey!
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center px-6 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium shadow-md"
|
||||||
|
>
|
||||||
|
Start Playing
|
||||||
|
</a>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@const prog = data.progress}
|
||||||
|
|
||||||
|
<!-- Key Stats Row -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 mb-6">
|
||||||
|
<ProgressStatCard
|
||||||
|
emoji="📅"
|
||||||
|
value={String(prog.totalSolves)}
|
||||||
|
label="Total Played"
|
||||||
|
colorClass="text-blue-400"
|
||||||
|
/>
|
||||||
|
<ProgressStatCard
|
||||||
|
emoji="📖"
|
||||||
|
value={String(prog.booksExplored)}
|
||||||
|
label="Books Explored"
|
||||||
|
colorClass="text-teal-400"
|
||||||
|
suffix="/ 66"
|
||||||
|
/>
|
||||||
|
<ProgressStatCard
|
||||||
|
emoji="✍️"
|
||||||
|
value={prog.totalWords.toLocaleString()}
|
||||||
|
label="Words Read"
|
||||||
|
colorClass="text-violet-400"
|
||||||
|
/>
|
||||||
|
<ProgressStatCard
|
||||||
|
emoji="✝️"
|
||||||
|
value={(((prog.totalSolves * 3) / 31102) * 100).toFixed(2) +
|
||||||
|
"%"}
|
||||||
|
label="Bible Read"
|
||||||
|
colorClass="text-amber-400"
|
||||||
|
/>
|
||||||
|
<ProgressStatCard
|
||||||
|
emoji="🏆"
|
||||||
|
value={String(prog.booksMastered)}
|
||||||
|
label="Books Mastered"
|
||||||
|
colorClass="text-purple-400"
|
||||||
|
suffix="/ 66"
|
||||||
|
/>
|
||||||
|
<ProgressStatCard
|
||||||
|
emoji="⭐"
|
||||||
|
value={String(prog.booksPerfect)}
|
||||||
|
label="Books Perfected"
|
||||||
|
colorClass="text-emerald-400"
|
||||||
|
suffix="/ 66"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bible Book Grid -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<Container class="p-4 md:p-6 w-full">
|
||||||
|
<h2
|
||||||
|
class="text-xl font-bold text-gray-100 mb-3 w-full text-left"
|
||||||
|
>
|
||||||
|
Bible Books
|
||||||
|
</h2>
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-3">
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1 text-xs text-gray-400"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block w-5 h-5 rounded bg-blue-700"
|
||||||
|
></span>
|
||||||
|
Explored
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1 text-xs text-gray-400"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block w-5 h-5 rounded bg-purple-600"
|
||||||
|
></span>
|
||||||
|
Mastered
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="flex items-center gap-1 text-xs text-gray-400"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block w-5 h-5 rounded bg-emerald-500"
|
||||||
|
></span>
|
||||||
|
Perfect
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Grid -->
|
||||||
|
<div class="grid grid-cols-8 md:grid-cols-11 gap-1 w-full">
|
||||||
|
{#each prog.bookGrid as entry (entry.bookId)}
|
||||||
|
{@const bookMeta = bibleBooks.find(
|
||||||
|
(b) => b.id === entry.bookId,
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
class="aspect-square flex items-center justify-center rounded text-[9px] md:text-[10px] font-bold cursor-default {bookTileClass(
|
||||||
|
entry.tier,
|
||||||
|
)}"
|
||||||
|
title="{bookMeta?.name ??
|
||||||
|
entry.bookId} — {entry.tier}{entry.avgGuesses !==
|
||||||
|
null
|
||||||
|
? ` (avg ${entry.avgGuesses})`
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{entry.bookId}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-3 leading-relaxed">
|
||||||
|
<span class="text-blue-400 font-medium">Explored</span>
|
||||||
|
— played at least once<br />
|
||||||
|
<span class="text-purple-400 font-medium"
|
||||||
|
>Mastered</span
|
||||||
|
>
|
||||||
|
— avg ≤ 3 guesses over 2+ plays<br />
|
||||||
|
<span class="text-emerald-400 font-medium">Perfect</span> —
|
||||||
|
mastered and guessed in 1 at least once
|
||||||
|
</p>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity Calendar -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<ActivityCalendar completions={prog.completions} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skill Growth Chart (hidden, needs rework) -->
|
||||||
|
{#if false && showChart}
|
||||||
|
<div class="mb-6">
|
||||||
|
<Container class="p-4 md:p-6 w-full">
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex items-baseline gap-2 mb-1">
|
||||||
|
<h2 class="text-xl font-bold text-gray-100">
|
||||||
|
Skill Growth
|
||||||
|
</h2>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
Lower is better
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 400 135"
|
||||||
|
class="w-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="chartFill"
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stop-color="#10b981"
|
||||||
|
stop-opacity="0.3"
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stop-color="#10b981"
|
||||||
|
stop-opacity="0"
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- Y-axis label -->
|
||||||
|
<text
|
||||||
|
transform="translate(8, 60) rotate(-90)"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-size="8"
|
||||||
|
fill="#9ca3af"
|
||||||
|
>Guesses</text>
|
||||||
|
<!-- Fill polygon -->
|
||||||
|
<polygon
|
||||||
|
points="{polylinePoints} {svgX(
|
||||||
|
chartPoints.length - 1,
|
||||||
|
chartPoints.length,
|
||||||
|
)},110 {svgX(0, chartPoints.length)},110"
|
||||||
|
fill="url(#chartFill)"
|
||||||
|
/>
|
||||||
|
<!-- Line -->
|
||||||
|
<polyline
|
||||||
|
points={polylinePoints}
|
||||||
|
fill="none"
|
||||||
|
stroke="#10b981"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<!-- Dots -->
|
||||||
|
{#each chartPoints as point, i (i)}
|
||||||
|
<circle
|
||||||
|
cx={svgX(i, chartPoints.length)}
|
||||||
|
cy={svgY(point.avgGuesses, maxGuesses)}
|
||||||
|
r="3"
|
||||||
|
fill="#10b981"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={svgX(i, chartPoints.length)}
|
||||||
|
y={svgY(point.avgGuesses, maxGuesses) - 6}
|
||||||
|
font-size="7"
|
||||||
|
fill="#6ee7b7"
|
||||||
|
text-anchor="middle"
|
||||||
|
>{point.avgGuesses}</text>
|
||||||
|
{/each}
|
||||||
|
<!-- X-axis labels -->
|
||||||
|
<text
|
||||||
|
x={svgX(0, chartPoints.length)}
|
||||||
|
y="118"
|
||||||
|
font-size="8"
|
||||||
|
fill="#9ca3af"
|
||||||
|
text-anchor="middle"
|
||||||
|
>
|
||||||
|
{chartPoints[0].label}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={svgX(
|
||||||
|
chartPoints.length - 1,
|
||||||
|
chartPoints.length,
|
||||||
|
)}
|
||||||
|
y="118"
|
||||||
|
font-size="8"
|
||||||
|
fill="#9ca3af"
|
||||||
|
text-anchor="middle"
|
||||||
|
>
|
||||||
|
{chartPoints[chartPoints.length - 1].label}
|
||||||
|
</text>
|
||||||
|
<!-- X-axis title -->
|
||||||
|
<text
|
||||||
|
x="200"
|
||||||
|
y="132"
|
||||||
|
font-size="8"
|
||||||
|
fill="#9ca3af"
|
||||||
|
text-anchor="middle"
|
||||||
|
>Date</text>
|
||||||
|
</svg>
|
||||||
|
{#if chartImproving}
|
||||||
|
<p class="text-xs text-emerald-400 mt-1">
|
||||||
|
You're getting better!
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-500 mt-2 leading-relaxed"
|
||||||
|
>
|
||||||
|
Each point is your average guesses over a
|
||||||
|
rolling window of games. A downward trend means
|
||||||
|
you're improving.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Achievements -->
|
||||||
|
{#if prog.milestones.length > 0}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-100 mb-3">🏆 Achievements</h2>
|
||||||
|
<div class="grid grid-cols-3 md:grid-cols-4 gap-2 md:gap-3">
|
||||||
|
{#each prog.milestones
|
||||||
|
.filter(m => m.achieved)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (!a.achievedDate && !b.achievedDate) return 0;
|
||||||
|
if (!a.achievedDate) return 1;
|
||||||
|
if (!b.achievedDate) return -1;
|
||||||
|
return a.achievedDate.localeCompare(b.achievedDate);
|
||||||
|
}) as milestone (milestone.id)}
|
||||||
|
<Container class="p-3 min-h-[130px] {isRecent(milestone.achievedDate) ? 'recent-achievement' : ''}">
|
||||||
|
<div class="text-center flex flex-col items-center justify-center h-full">
|
||||||
|
<div class="text-2xl mb-1">{milestone.emoji}</div>
|
||||||
|
<div class="text-sm font-bold text-yellow-300 leading-tight mb-1">
|
||||||
|
{milestone.name}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 leading-tight">
|
||||||
|
{milestone.description}
|
||||||
|
</div>
|
||||||
|
{#if milestone.achievedDate}
|
||||||
|
<div class="text-[10px] text-gray-500 mt-1">
|
||||||
|
{formatDate(milestone.achievedDate)}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-[10px] text-emerald-500 mt-1">Earned</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">{prog.milestones.filter(m => m.achieved).length} / {prog.milestones.length} achievements unlocked</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Insights -->
|
||||||
|
{#if showInsights}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-100 mb-3">
|
||||||
|
Insights
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#if prog.testamentStats.old && prog.testamentStats.new}
|
||||||
|
{@const comparison = testamentComparison(
|
||||||
|
prog.testamentStats.old,
|
||||||
|
prog.testamentStats.new,
|
||||||
|
)}
|
||||||
|
{#if comparison}
|
||||||
|
<Container class="p-4 w-full">
|
||||||
|
<div class="flex items-start gap-3 w-full">
|
||||||
|
<span class="text-2xl">📊</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-gray-100 font-medium text-sm"
|
||||||
|
>
|
||||||
|
{comparison}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-gray-400 text-xs mt-0.5"
|
||||||
|
>
|
||||||
|
OT avg: {prog.testamentStats.old
|
||||||
|
.avgGuesses} guesses • NT
|
||||||
|
avg: {prog.testamentStats.new
|
||||||
|
.avgGuesses} guesses
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if bestSection && bestSection.count >= 3}
|
||||||
|
<Container class="p-4 w-full">
|
||||||
|
<div class="flex items-start gap-3 w-full">
|
||||||
|
<span class="text-2xl">🌟</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-gray-100 font-medium text-sm"
|
||||||
|
>
|
||||||
|
Your strongest section: {bestSection.section}
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-400 text-xs mt-0.5">
|
||||||
|
{bestSection.avgGuesses} avg guesses
|
||||||
|
across {bestSection.count} games
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hardestSection}
|
||||||
|
{@const hard = hardestSection}
|
||||||
|
{#if hard && hard.count >= 3}
|
||||||
|
<Container class="p-4 w-full">
|
||||||
|
<div class="flex items-start gap-3 w-full">
|
||||||
|
<span class="text-2xl">💪</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-gray-100 font-medium text-sm"
|
||||||
|
>
|
||||||
|
Room to grow: {hard.section}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-gray-400 text-xs mt-0.5"
|
||||||
|
>
|
||||||
|
{hard.avgGuesses} avg guesses across
|
||||||
|
{hard.count} games
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes breathe-glow {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 6px 2px rgba(251, 146, 60, 0.3);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 18px 6px rgba(251, 146, 60, 0.65);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:global(.recent-achievement) {
|
||||||
|
animation: breathe-glow 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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 { 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 { eq, desc } from 'drizzle-orm';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { bibleBooks } from '$lib/types/bible';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||||
const anonymousId = url.searchParams.get('anonymousId');
|
// Check if user is authenticated
|
||||||
|
if (!locals.user) {
|
||||||
if (!anonymousId) {
|
|
||||||
return {
|
return {
|
||||||
stats: null,
|
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 {
|
try {
|
||||||
// Get all completions for this user
|
// Get all completions for this user
|
||||||
const completions = await db
|
const completions = await db
|
||||||
.select()
|
.select()
|
||||||
.from(dailyCompletions)
|
.from(dailyCompletions)
|
||||||
.where(eq(dailyCompletions.anonymousId, anonymousId))
|
.where(eq(dailyCompletions.anonymousId, userId))
|
||||||
.orderBy(desc(dailyCompletions.date));
|
.orderBy(desc(dailyCompletions.date));
|
||||||
|
|
||||||
if (completions.length === 0) {
|
if (completions.length === 0) {
|
||||||
@@ -38,8 +56,15 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||||||
},
|
},
|
||||||
currentStreak: 0,
|
currentStreak: 0,
|
||||||
bestStreak: 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
|
'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
|
const sortedDates = completions
|
||||||
.map((c: DailyCompletion) => c.date)
|
.map((c: DailyCompletion) => c.date)
|
||||||
.sort();
|
.sort();
|
||||||
|
|
||||||
let currentStreak = 0;
|
let currentStreak = 0;
|
||||||
let bestStreak = 0;
|
let bestStreak = 0;
|
||||||
let tempStreak = 1;
|
let tempStreak = 1;
|
||||||
|
|
||||||
if (sortedDates.length > 0) {
|
if (sortedDates.length > 0) {
|
||||||
// Check if current streak is active (includes today or yesterday)
|
// Check if current streak is active (includes today or yesterday)
|
||||||
const today = new Date().toISOString().split('T')[0];
|
// Use the user's local date passed from the client
|
||||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
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];
|
const lastPlayedDate = sortedDates[sortedDates.length - 1];
|
||||||
|
|
||||||
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
|
if (lastPlayedDate === today || lastPlayedDate === yesterday) {
|
||||||
currentStreak = 1;
|
currentStreak = 1;
|
||||||
|
|
||||||
// Count backwards from the most recent date
|
// Count backwards from the most recent date
|
||||||
for (let i = sortedDates.length - 2; i >= 0; i--) {
|
for (let i = sortedDates.length - 2; i >= 0; i--) {
|
||||||
const currentDate = new Date(sortedDates[i + 1]);
|
const currentDate = new Date(sortedDates[i + 1]);
|
||||||
const prevDate = new Date(sortedDates[i]);
|
const prevDate = new Date(sortedDates[i]);
|
||||||
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (daysDiff === 1) {
|
if (daysDiff === 1) {
|
||||||
currentStreak++;
|
currentStreak++;
|
||||||
} else {
|
} else {
|
||||||
@@ -91,14 +119,14 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate best streak
|
// Calculate best streak
|
||||||
bestStreak = 1;
|
bestStreak = 1;
|
||||||
for (let i = 1; i < sortedDates.length; i++) {
|
for (let i = 1; i < sortedDates.length; i++) {
|
||||||
const currentDate = new Date(sortedDates[i]);
|
const currentDate = new Date(sortedDates[i]);
|
||||||
const prevDate = new Date(sortedDates[i - 1]);
|
const prevDate = new Date(sortedDates[i - 1]);
|
||||||
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
const daysDiff = Math.floor((currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (daysDiff === 1) {
|
if (daysDiff === 1) {
|
||||||
tempStreak++;
|
tempStreak++;
|
||||||
} else {
|
} else {
|
||||||
@@ -118,6 +146,66 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||||||
grade: getGradeFromGuesses(c.guessCount)
|
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 {
|
return {
|
||||||
stats: {
|
stats: {
|
||||||
totalSolves,
|
totalSolves,
|
||||||
@@ -125,15 +213,34 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||||||
gradeDistribution,
|
gradeDistribution,
|
||||||
currentStreak,
|
currentStreak,
|
||||||
bestStreak,
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching user stats:', error);
|
console.error('Error fetching user stats:', error);
|
||||||
return {
|
return {
|
||||||
stats: null,
|
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 >= 7 && guessCount <= 10) return "B";
|
||||||
if (guessCount >= 11 && guessCount <= 15) return "C+";
|
if (guessCount >= 11 && guessCount <= 15) return "C+";
|
||||||
return "C";
|
return "C";
|
||||||
}
|
}
|
||||||
|
|||||||
+259
-125
@@ -1,23 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import {
|
import AuthModal from "$lib/components/AuthModal.svelte";
|
||||||
getGradeColor,
|
import Container from "$lib/components/Container.svelte";
|
||||||
formatDate,
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
getStreakMessage,
|
import {
|
||||||
getPerformanceMessage,
|
formatDate,
|
||||||
type UserStats
|
type UserStats,
|
||||||
} from "$lib/utils/stats";
|
} from "$lib/utils/stats";
|
||||||
|
import { fetchStreak } from "$lib/utils/streak";
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
stats: UserStats | null;
|
stats: UserStats | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
user?: any;
|
||||||
|
session?: any;
|
||||||
|
requiresAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
let authModalOpen = $state(false);
|
||||||
|
let anonymousId = $state("");
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let currentStreak = $state(0);
|
||||||
|
|
||||||
function getOrCreateAnonymousId(): string {
|
function getOrCreateAnonymousId(): string {
|
||||||
if (!browser) return "";
|
if (!browser) return "";
|
||||||
@@ -31,25 +37,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const anonymousId = getOrCreateAnonymousId();
|
anonymousId = getOrCreateAnonymousId();
|
||||||
if (!anonymousId) {
|
if (data.user?.id) {
|
||||||
goto("/");
|
const localDate = new Date().toLocaleDateString("en-CA");
|
||||||
return;
|
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;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
function getGradePercentage(count: number, total: number): number {
|
function getBookName(bookId: string): string {
|
||||||
return total > 0 ? Math.round((count / total) * 100) : 0;
|
return bibleBooks.find((b) => b.id === bookId)?.name || bookId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$inspect(data);
|
$inspect(data);
|
||||||
@@ -57,36 +56,74 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Stats | Bibdle</title>
|
<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>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gradient-to-br from-amber-50 to-orange-100 p-4">
|
<div
|
||||||
<div class="max-w-4xl mx-auto">
|
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 -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-6 md:mb-8">
|
||||||
<h1 class="text-4xl font-bold text-gray-800 mb-2">Your Stats</h1>
|
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-2">
|
||||||
<p class="text-gray-600">Track your Bibdle performance over time</p>
|
Your Stats
|
||||||
<div class="mt-4">
|
</h1>
|
||||||
<a
|
<p class="text-sm md:text-base text-gray-300 mb-4">
|
||||||
href="/"
|
Track your Bibdle performance over time
|
||||||
class="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
|
</p>
|
||||||
>
|
<a
|
||||||
← Back to Game
|
href="/"
|
||||||
</a>
|
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"
|
||||||
</div>
|
>
|
||||||
|
← Back to Game
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="text-center py-12">
|
<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>
|
<div
|
||||||
<p class="mt-4 text-gray-600">Loading your stats...</p>
|
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>
|
</div>
|
||||||
{:else if data.error}
|
{:else if data.error}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<div class="bg-red-100 border border-red-300 rounded-lg p-6 max-w-md mx-auto">
|
<div
|
||||||
<p class="text-red-700">{data.error}</p>
|
class="bg-red-950/50 border border-red-800/50 rounded-lg p-6 max-w-md mx-auto backdrop-blur-sm"
|
||||||
<a
|
>
|
||||||
href="/"
|
<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"
|
class="mt-4 inline-block px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Return to Game
|
Return to Game
|
||||||
@@ -95,112 +132,209 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if !data.stats}
|
{:else if !data.stats}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<div class="bg-yellow-100 border border-yellow-300 rounded-lg p-6 max-w-md mx-auto">
|
<Container class="p-8 max-w-md mx-auto">
|
||||||
<p class="text-yellow-700">No stats available.</p>
|
<div class="text-yellow-400 mb-4 text-lg">
|
||||||
<a
|
No stats available yet.
|
||||||
href="/"
|
</div>
|
||||||
class="mt-4 inline-block px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
|
<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
|
Start Playing
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{@const stats = data.stats}
|
{@const stats = data.stats}
|
||||||
|
|
||||||
<!-- Overview Cards -->
|
<!-- Key Stats Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-6">
|
||||||
<!-- Total Solves -->
|
<!-- Current Streak -->
|
||||||
<div class="bg-white rounded-lg shadow-md p-6">
|
<Container class="p-4 md:p-6">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-3xl font-bold text-amber-600 mb-2">{stats.totalSolves}</div>
|
<div class="text-2xl md:text-3xl mb-1">🔥</div>
|
||||||
<div class="text-gray-600">Total Solves</div>
|
<div
|
||||||
{#if stats.totalSolves > 0}
|
class="text-2xl md:text-3xl font-bold text-orange-400 mb-1"
|
||||||
<div class="text-sm text-gray-500 mt-1">
|
>
|
||||||
{getPerformanceMessage(stats.avgGuesses)}
|
{currentStreak}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div
|
||||||
|
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||||
|
>
|
||||||
|
Current Streak
|
||||||
|
</div>
|
||||||
</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 -->
|
<!-- 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-center">
|
||||||
<div class="text-3xl font-bold text-blue-600 mb-2">{stats.avgGuesses}</div>
|
<div class="text-2xl md:text-3xl mb-1">🎯</div>
|
||||||
<div class="text-gray-600">Avg. Guesses</div>
|
<div
|
||||||
<div class="text-sm text-gray-500 mt-1">per solve</div>
|
class="text-2xl md:text-3xl font-bold text-blue-400 mb-1"
|
||||||
</div>
|
>
|
||||||
</div>
|
{stats.avgGuesses}
|
||||||
|
</div>
|
||||||
<!-- Current Streak -->
|
<div
|
||||||
<div class="bg-white rounded-lg shadow-md p-6">
|
class="text-xs md:text-sm text-gray-300 font-medium"
|
||||||
<div class="text-center">
|
>
|
||||||
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
|
Avg Guesses
|
||||||
<div class="text-gray-600">Current Streak</div>
|
|
||||||
<div class="text-sm text-gray-500 mt-1">
|
|
||||||
{getStreakMessage(stats.currentStreak)}
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Grade Distribution -->
|
|
||||||
{#if stats.totalSolves > 0}
|
{#if stats.totalSolves > 0}
|
||||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
<!-- Book Stats Grid -->
|
||||||
<h2 class="text-2xl font-bold text-gray-800 mb-4">Grade Distribution</h2>
|
<div
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
class="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 mb-6"
|
||||||
{#each Object.entries(stats.gradeDistribution) as [grade, count]}
|
>
|
||||||
{@const percentage = getGradePercentage(count, stats.totalSolves)}
|
<!-- Worst Day -->
|
||||||
<div class="text-center">
|
{#if stats.worstDay}
|
||||||
<div class="mb-2">
|
<Container class="p-4 md:p-6">
|
||||||
<span class="inline-block px-3 py-1 rounded-full text-sm font-semibold {getGradeColor(grade)}">
|
<div class="flex items-start gap-3">
|
||||||
{grade}
|
<div class="text-3xl md:text-4xl">😅</div>
|
||||||
</span>
|
<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>
|
||||||
<div class="text-2xl font-bold text-gray-800">{count}</div>
|
|
||||||
<div class="text-sm text-gray-500">{percentage}%</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</Container>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Streak Info -->
|
<!-- Best Book -->
|
||||||
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
{#if stats.bestBook}
|
||||||
<h2 class="text-2xl font-bold text-gray-800 mb-4">Streak Information</h2>
|
<Container class="p-4 md:p-6">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="flex items-start gap-3">
|
||||||
<div class="text-center">
|
<div class="text-3xl md:text-4xl">🏆</div>
|
||||||
<div class="text-3xl font-bold text-green-600 mb-2">{stats.currentStreak}</div>
|
<div class="flex-1 min-w-0">
|
||||||
<div class="text-gray-600">Current Streak</div>
|
<div
|
||||||
</div>
|
class="text-sm md:text-base text-gray-300 font-medium mb-1"
|
||||||
<div class="text-center">
|
>
|
||||||
<div class="text-3xl font-bold text-purple-600 mb-2">{stats.bestStreak}</div>
|
Best Book
|
||||||
<div class="text-gray-600">Best Streak</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent Performance -->
|
|
||||||
{#if stats.recentCompletions.length > 0}
|
|
||||||
<div class="bg-white rounded-lg shadow-md p-6">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-800 mb-4">Recent Performance</h2>
|
|
||||||
<div class="space-y-3">
|
|
||||||
{#each stats.recentCompletions as completion}
|
|
||||||
<div class="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium">{formatDate(completion.date)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div
|
||||||
<span class="text-gray-600">{completion.guessCount} guess{completion.guessCount === 1 ? '' : 'es'}</span>
|
class="text-lg md:text-xl font-bold text-amber-400 truncate"
|
||||||
<span class="px-2 py-1 rounded text-sm font-semibold {getGradeColor(completion.grade)}">
|
>
|
||||||
{completion.grade}
|
{getBookName(stats.bestBook.bookId)}
|
||||||
</span>
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-xs md:text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
{stats.bestBook.avgGuesses} avg guesses ({stats
|
||||||
|
.bestBook.count}x)
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</Container>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user