From 32a078dd982669c422cd106351d3e991baf06897 Mon Sep 17 00:00:00 2001
From: George Powell
Date: Tue, 16 Dec 2025 20:44:52 -0500
Subject: [PATCH] v2
---
.gitignore | 2 +
deploy.txt | 267 --------------------
drizzle/0000_clumsy_impossible_man.sql | 32 +++
drizzle/meta/0000_snapshot.json | 216 ++++++++++++++++
drizzle/meta/_journal.json | 13 +
src/lib/server/db/schema.ts | 16 +-
src/routes/+page.server.ts | 64 ++++-
src/routes/+page.svelte | 169 ++++++++++++-
src/routes/api/submit-completion/+server.ts | 120 +++++++++
todo.md | 12 +-
10 files changed, 634 insertions(+), 277 deletions(-)
delete mode 100644 deploy.txt
create mode 100644 drizzle/0000_clumsy_impossible_man.sql
create mode 100644 drizzle/meta/0000_snapshot.json
create mode 100644 drizzle/meta/_journal.json
create mode 100644 src/routes/api/submit-completion/+server.ts
diff --git a/.gitignore b/.gitignore
index 171f629..f79bfc6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,5 @@ vite.config.ts.timestamp-*
# SQLite
*.db
+
+*.txt
\ No newline at end of file
diff --git a/deploy.txt b/deploy.txt
deleted file mode 100644
index 3237e59..0000000
--- a/deploy.txt
+++ /dev/null
@@ -1,267 +0,0 @@
-To generate a standalone Node server, use [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node).
-
-## Usage
-
-Install with `npm i -D @sveltejs/adapter-node`, then add the adapter to your `svelte.config.js`:
-
-```js
-// @errors: 2307
-/// file: svelte.config.js
-import adapter from '@sveltejs/adapter-node';
-
-/** @type {import('@sveltejs/kit').Config} */
-const config = {
- kit: {
- adapter: adapter()
- }
-};
-
-export default config;
-```
-
-## Deploying
-
-First, build your app with `npm run build`. This will create the production server in the output directory specified in the adapter options, defaulting to `build`.
-
-You will need the output directory, the project's `package.json`, and the production dependencies in `node_modules` to run the application. Production dependencies can be generated by copying the `package.json` and `package-lock.json` and then running `npm ci --omit dev` (you can skip this step if your app doesn't have any dependencies). You can then start your app with this command:
-
-```sh
-node build
-```
-
-Development dependencies will be bundled into your app using [Rollup](https://rollupjs.org). To control whether a given package is bundled or externalised, place it in `devDependencies` or `dependencies` respectively in your `package.json`.
-
-### Compressing responses
-
-You will typically want to compress responses coming from the server. If you're already deploying your server behind a reverse proxy for SSL or load balancing, it typically results in better performance to also handle compression at that layer since Node.js is single-threaded.
-
-However, if you're building a [custom server](#Custom-server) and do want to add a compression middleware there, note that we would recommend using [`@polka/compression`](https://www.npmjs.com/package/@polka/compression) since SvelteKit streams responses and the more popular `compression` package does not support streaming and may cause errors when used.
-
-## Environment variables
-
-In `dev` and `preview`, SvelteKit will read environment variables from your `.env` file (or `.env.local`, or `.env.[mode]`, [as determined by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files).)
-
-In production, `.env` files are _not_ automatically loaded. To do so, install `dotenv` in your project...
-
-```sh
-npm install dotenv
-```
-
-...and invoke it before running the built app:
-
-```sh
-node +++-r dotenv/config+++ build
-```
-
-If you use Node.js v20.6+, you can use the [`--env-file`](https://nodejs.org/en/learn/command-line/how-to-read-environment-variables-from-nodejs) flag instead:
-
-```sh
-node +++--env-file=.env+++ build
-```
-
-### `PORT`, `HOST` and `SOCKET_PATH`
-
-By default, the server will accept connections on `0.0.0.0` using port 3000. These can be customised with the `PORT` and `HOST` environment variables:
-
-```sh
-HOST=127.0.0.1 PORT=4000 node build
-```
-
-Alternatively, the server can be configured to accept connections on a specified socket path. When this is done using the `SOCKET_PATH` environment variable, the `HOST` and `PORT` environment variables will be disregarded.
-
-```sh
-SOCKET_PATH=/tmp/socket node build
-```
-
-### `ORIGIN`, `PROTOCOL_HEADER`, `HOST_HEADER`, and `PORT_HEADER`
-
-HTTP doesn't give SvelteKit a reliable way to know the URL that is currently being requested. The simplest way to tell SvelteKit where the app is being served is to set the `ORIGIN` environment variable:
-
-```sh
-ORIGIN=https://my.site node build
-
-# or e.g. for local previewing and testing
-ORIGIN=http://localhost:3000 node build
-```
-
-With this, a request for the `/stuff` pathname will correctly resolve to `https://my.site/stuff`. Alternatively, you can specify headers that tell SvelteKit about the request protocol and host, from which it can construct the origin URL:
-
-```sh
-PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host node build
-```
-
-> [!NOTE] [`x-forwarded-proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto) and [`x-forwarded-host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host) are de facto standard headers that forward the original protocol and host if you're using a reverse proxy (think load balancers and CDNs). You should only set these variables if your server is behind a trusted reverse proxy; otherwise, it'd be possible for clients to spoof these headers.
->
-> If you're hosting your proxy on a non-standard port and your reverse proxy supports `x-forwarded-port`, you can also set `PORT_HEADER=x-forwarded-port`.
-
-If `adapter-node` can't correctly determine the URL of your deployment, you may experience this error when using [form actions](form-actions):
-
-> [!NOTE] Cross-site POST form submissions are forbidden
-
-### `ADDRESS_HEADER` and `XFF_DEPTH`
-
-The [`RequestEvent`](@sveltejs-kit#RequestEvent) object passed to hooks and endpoints includes an `event.getClientAddress()` function that returns the client's IP address. By default this is the connecting `remoteAddress`. If your server is behind one or more proxies (such as a load balancer), this value will contain the innermost proxy's IP address rather than the client's, so we need to specify an `ADDRESS_HEADER` to read the address from:
-
-```sh
-ADDRESS_HEADER=True-Client-IP node build
-```
-
-> [!NOTE] Headers can easily be spoofed. As with `PROTOCOL_HEADER` and `HOST_HEADER`, you should [know what you're doing](https://adam-p.ca/blog/2022/03/x-forwarded-for/) before setting these.
-
-If the `ADDRESS_HEADER` is `X-Forwarded-For`, the header value will contain a comma-separated list of IP addresses. The `XFF_DEPTH` environment variable should specify how many trusted proxies sit in front of your server. E.g. if there are three trusted proxies, proxy 3 will forward the addresses of the original connection and the first two proxies:
-
-```
-, ,
-```
-
-Some guides will tell you to read the left-most address, but this leaves you [vulnerable to spoofing](https://adam-p.ca/blog/2022/03/x-forwarded-for/):
-
-```
-, , ,
-```
-
-We instead read from the _right_, accounting for the number of trusted proxies. In this case, we would use `XFF_DEPTH=3`.
-
-> [!NOTE] If you need to read the left-most address instead (and don't care about spoofing) — for example, to offer a geolocation service, where it's more important for the IP address to be _real_ than _trusted_, you can do so by inspecting the `x-forwarded-for` header within your app.
-
-### `BODY_SIZE_LIMIT`
-
-The maximum request body size to accept in bytes including while streaming. The body size can also be specified with a unit suffix for kilobytes (`K`), megabytes (`M`), or gigabytes (`G`). For example, `512K` or `1M`. Defaults to 512kb. You can disable this option with a value of `Infinity` (0 in older versions of the adapter) and implement a custom check in [`handle`](hooks#Server-hooks-handle) if you need something more advanced.
-
-### `SHUTDOWN_TIMEOUT`
-
-The number of seconds to wait before forcefully closing any remaining connections after receiving a `SIGTERM` or `SIGINT` signal. Defaults to `30`. Internally the adapter calls [`closeAllConnections`](https://nodejs.org/api/http.html#servercloseallconnections). See [Graceful shutdown](#Graceful-shutdown) for more details.
-
-### `IDLE_TIMEOUT`
-
-When using systemd socket activation, `IDLE_TIMEOUT` specifies the number of seconds after which the app is automatically put to sleep when receiving no requests. If not set, the app runs continuously. See [Socket activation](#Socket-activation) for more details.
-
-## Options
-
-The adapter can be configured with various options:
-
-```js
-// @errors: 2307
-/// file: svelte.config.js
-import adapter from '@sveltejs/adapter-node';
-
-/** @type {import('@sveltejs/kit').Config} */
-const config = {
- kit: {
- adapter: adapter({
- // default options are shown
- out: 'build',
- precompress: true,
- envPrefix: ''
- })
- }
-};
-
-export default config;
-```
-
-### out
-
-The directory to build the server to. It defaults to `build` — i.e. `node build` would start the server locally after it has been created.
-
-### precompress
-
-Enables precompressing using gzip and brotli for assets and prerendered pages. It defaults to `true`.
-
-### envPrefix
-
-If you need to change the name of the environment variables used to configure the deployment (for example, to deconflict with environment variables you don't control), you can specify a prefix:
-
-```js
-envPrefix: 'MY_CUSTOM_';
-```
-
-```sh
-MY_CUSTOM_HOST=127.0.0.1 \
-MY_CUSTOM_PORT=4000 \
-MY_CUSTOM_ORIGIN=https://my.site \
-node build
-```
-
-## Graceful shutdown
-
-By default `adapter-node` gracefully shuts down the HTTP server when a `SIGTERM` or `SIGINT` signal is received. It will:
-
-1. reject new requests ([`server.close`](https://nodejs.org/api/http.html#serverclosecallback))
-2. wait for requests that have already been made but not received a response yet to finish and close connections once they become idle ([`server.closeIdleConnections`](https://nodejs.org/api/http.html#servercloseidleconnections))
-3. and finally, close any remaining connections that are still active after [`SHUTDOWN_TIMEOUT`](#Environment-variables-SHUTDOWN_TIMEOUT) seconds. ([`server.closeAllConnections`](https://nodejs.org/api/http.html#servercloseallconnections))
-
-> [!NOTE] If you want to customize this behaviour you can use a [custom server](#Custom-server).
-
-You can listen to the `sveltekit:shutdown` event which is emitted after the HTTP server has closed all connections. Unlike Node's `exit` event, the `sveltekit:shutdown` event supports asynchronous operations and is always emitted when all connections are closed even if the server has dangling work such as open database connections.
-
-```js
-// @errors: 2304
-process.on('sveltekit:shutdown', async (reason) => {
- await jobs.stop();
- await db.close();
-});
-```
-
-The parameter `reason` has one of the following values:
-
-- `SIGINT` - shutdown was triggered by a `SIGINT` signal
-- `SIGTERM` - shutdown was triggered by a `SIGTERM` signal
-- `IDLE` - shutdown was triggered by [`IDLE_TIMEOUT`](#Environment-variables-IDLE_TIMEOUT)
-
-## Socket activation
-
-Most Linux operating systems today use a modern process manager called systemd to start the server and run and manage services. You can configure your server to allocate a socket and start and scale your app on demand. This is called [socket activation](https://0pointer.de/blog/projects/socket-activated-containers.html). In this case, the OS will pass two environment variables to your app — `LISTEN_PID` and `LISTEN_FDS`. The adapter will then listen on file descriptor 3 which refers to a systemd socket unit that you will have to create.
-
-> [!NOTE] You can still use [`envPrefix`](#Options-envPrefix) with systemd socket activation. `LISTEN_PID` and `LISTEN_FDS` are always read without a prefix.
-
-To take advantage of socket activation follow these steps.
-
-1. Run your app as a [systemd service](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html). It can either run directly on the host system or inside a container (using Docker or a systemd portable service for example). If you additionally pass an [`IDLE_TIMEOUT`](#Environment-variables-IDLE_TIMEOUT) environment variable to your app it will gracefully shutdown if there are no requests for `IDLE_TIMEOUT` seconds. systemd will automatically start your app again when new requests are coming in.
-
-```ini
-/// file: /etc/systemd/system/myapp.service
-[Service]
-Environment=NODE_ENV=production IDLE_TIMEOUT=60
-ExecStart=/usr/bin/node /usr/bin/myapp/build
-```
-
-2. Create an accompanying [socket unit](https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html). The adapter only accepts a single socket.
-
-```ini
-/// file: /etc/systemd/system/myapp.socket
-[Socket]
-ListenStream=3000
-
-[Install]
-WantedBy=sockets.target
-```
-
-3. Make sure systemd has recognised both units by running `sudo systemctl daemon-reload`. Then enable the socket on boot and start it immediately using `sudo systemctl enable --now myapp.socket`. The app will then automatically start once the first request is made to `localhost:3000`.
-
-## Custom server
-
-The adapter creates two files in your build directory — `index.js` and `handler.js`. Running `index.js` — e.g. `node build`, if you use the default build directory — will start a server on the configured port.
-
-Alternatively, you can import the `handler.js` file, which exports a handler suitable for use with [Express](https://github.com/expressjs/express), [Connect](https://github.com/senchalabs/connect) or [Polka](https://github.com/lukeed/polka) (or even just the built-in [`http.createServer`](https://nodejs.org/dist/latest/docs/api/http.html#httpcreateserveroptions-requestlistener)) and set up your own server:
-
-```js
-// @errors: 2307 7006
-/// file: my-server.js
-import { handler } from './build/handler.js';
-import express from 'express';
-
-const app = express();
-
-// add a route that lives separately from the SvelteKit app
-app.get('/healthcheck', (req, res) => {
- res.end('ok');
-});
-
-// let SvelteKit handle everything else, including serving prerendered pages and static assets
-app.use(handler);
-
-app.listen(3000, () => {
- console.log('listening on port 3000');
-});
-```
\ No newline at end of file
diff --git a/drizzle/0000_clumsy_impossible_man.sql b/drizzle/0000_clumsy_impossible_man.sql
new file mode 100644
index 0000000..ebb03b1
--- /dev/null
+++ b/drizzle/0000_clumsy_impossible_man.sql
@@ -0,0 +1,32 @@
+CREATE TABLE `daily_completions` (
+ `id` text PRIMARY KEY NOT NULL,
+ `anonymous_id` text NOT NULL,
+ `date` text NOT NULL,
+ `guess_count` integer NOT NULL,
+ `completed_at` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `date_idx` ON `daily_completions` (`date`);--> statement-breakpoint
+CREATE INDEX `date_guess_idx` ON `daily_completions` (`date`,`guess_count`);--> statement-breakpoint
+CREATE UNIQUE INDEX `daily_completions_anonymous_id_date_unique` ON `daily_completions` (`anonymous_id`,`date`);--> statement-breakpoint
+CREATE TABLE `daily_verses` (
+ `id` text PRIMARY KEY NOT NULL,
+ `date` text NOT NULL,
+ `book_id` text NOT NULL,
+ `verse_text` text NOT NULL,
+ `reference` text NOT NULL,
+ `created_at` integer
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `daily_verses_date_unique` ON `daily_verses` (`date`);--> statement-breakpoint
+CREATE TABLE `session` (
+ `id` text PRIMARY KEY NOT NULL,
+ `user_id` text NOT NULL,
+ `expires_at` integer NOT NULL,
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
+);
+--> statement-breakpoint
+CREATE TABLE `user` (
+ `id` text PRIMARY KEY NOT NULL,
+ `age` integer
+);
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
new file mode 100644
index 0000000..1e05a62
--- /dev/null
+++ b/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,216 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "569c1d8d-7308-47c2-ba44-85c4917b789d",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "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": {
+ "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
+ },
+ "age": {
+ "name": "age",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
new file mode 100644
index 0000000..640f2e3
--- /dev/null
+++ b/drizzle/meta/_journal.json
@@ -0,0 +1,13 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "6",
+ "when": 1765934144883,
+ "tag": "0000_clumsy_impossible_man",
+ "breakpoints": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index 55de33e..2067242 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -1,4 +1,4 @@
-import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
+import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
@@ -24,3 +24,17 @@ export const dailyVerses = sqliteTable('daily_verses', {
});
export type DailyVerse = typeof dailyVerses.$inferSelect;
+
+export const dailyCompletions = sqliteTable('daily_completions', {
+ id: text('id').primaryKey(),
+ anonymousId: text('anonymous_id').notNull(),
+ date: text('date').notNull(),
+ guessCount: integer('guess_count').notNull(),
+ completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
+}, (table) => ({
+ uniqueCompletion: unique().on(table.anonymousId, table.date),
+ dateIndex: index('date_idx').on(table.date),
+ dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
+}));
+
+export type DailyCompletion = typeof dailyCompletions.$inferSelect;
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts
index 1535ed9..6bc2326 100644
--- a/src/routes/+page.server.ts
+++ b/src/routes/+page.server.ts
@@ -1,7 +1,8 @@
-import type { PageServerLoad } from './$types';
+import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
-import { dailyVerses } from '$lib/server/db/schema';
-import { eq, sql } from 'drizzle-orm';
+import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
+import { eq, sql, asc } from 'drizzle-orm';
+import { fail } from '@sveltejs/kit';
import { fetchRandomVerse } from '$lib/server/bible-api';
import { getBookById } from '$lib/server/bible';
import type { DailyVerse } from '$lib/server/db/schema';
@@ -43,3 +44,60 @@ export const load: PageServerLoad = async () => {
correctBook
};
};
+
+export const actions: Actions = {
+ submitCompletion: async ({ request }) => {
+ const formData = await request.formData();
+ const anonymousId = formData.get('anonymousId') as string;
+ const date = formData.get('date') as string;
+ const guessCount = parseInt(formData.get('guessCount') as string, 10);
+
+ // Validation
+ if (!anonymousId || !date || isNaN(guessCount) || guessCount < 1) {
+ return fail(400, { error: 'Invalid data' });
+ }
+
+ const completedAt = new Date();
+
+ try {
+ // Insert with duplicate prevention
+ await db.insert(dailyCompletions).values({
+ id: crypto.randomUUID(),
+ anonymousId,
+ date,
+ guessCount,
+ completedAt,
+ });
+ } catch (err: any) {
+ if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
+ return fail(409, { error: 'Already submitted' });
+ }
+ throw err;
+ }
+
+ // 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 had FEWER guesses (ties get same rank)
+ const betterGuesses = allCompletions.filter(c => c.guessCount < guessCount).length;
+ const guessRank = betterGuesses + 1;
+
+ // Average guesses
+ const totalGuesses = allCompletions.reduce((sum, c) => sum + c.guessCount, 0);
+ const averageGuesses = Math.round((totalGuesses / totalSolves) * 10) / 10;
+
+ return {
+ success: true,
+ stats: { solveRank, guessRank, totalSolves, averageGuesses }
+ };
+ }
+};
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 2885929..3b4a2c2 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -23,6 +23,15 @@
let copied = $state(false);
+ let anonymousId = $state("");
+ let statsSubmitted = $state(false);
+ let statsData = $state<{
+ solveRank: number;
+ guessRank: number;
+ totalSolves: number;
+ averageGuesses: number;
+ } | null>(null);
+
let filteredBooks = $derived(
bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
@@ -90,6 +99,41 @@
return "🔴 C-";
}
+ function generateUUID(): string {
+ // Try native randomUUID if available
+ if (typeof window.crypto.randomUUID === "function") {
+ return window.crypto.randomUUID();
+ }
+
+ // Fallback UUID v4 generator for older browsers
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
+ const r =
+ window.crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
+ }
+
+ function getOrCreateAnonymousId(): string {
+ if (!browser) return "";
+ const key = "bibdle-anonymous-id";
+ let id = localStorage.getItem(key);
+ if (!id) {
+ id = generateUUID();
+ localStorage.setItem(key, id);
+ }
+ return id;
+ }
+
+ // Initialize anonymous ID
+ $effect(() => {
+ if (!browser) return;
+ anonymousId = getOrCreateAnonymousId();
+ const statsKey = `bibdle-stats-submitted-${dailyVerse.date}`;
+ statsSubmitted = localStorage.getItem(statsKey) === "true";
+ });
+
+ // Load saved guesses
$effect(() => {
if (!browser) return;
@@ -122,6 +166,96 @@
);
});
+ // Auto-submit stats when user wins
+ $effect(() => {
+ console.log("Stats effect triggered:", {
+ browser,
+ isWon,
+ anonymousId,
+ statsSubmitted,
+ statsData,
+ });
+
+ 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();
+ });
+
async function share() {
if (!browser) return;
@@ -269,6 +403,7 @@
>
Your grade: {grade}
+
+
+
+ {#if statsData}
+
+
+ You were the #{statsData.solveRank} person to solve today!
+
+
+ You ranked #{statsData.guessRank} by number of guesses
+