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 +

+

+ {statsData.totalSolves} + {statsData.totalSolves === 1 + ? "person has" + : "people have"} solved today +

+

+ Average guesses: {statsData.averageGuesses} +

+
+ {:else if !statsSubmitted} +
+ Submitting stats... +
+ {/if} {/if} diff --git a/src/routes/api/submit-completion/+server.ts b/src/routes/api/submit-completion/+server.ts new file mode 100644 index 0000000..625186e --- /dev/null +++ b/src/routes/api/submit-completion/+server.ts @@ -0,0 +1,120 @@ +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'; +import crypto from 'node:crypto'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const { anonymousId, date, guessCount } = await request.json(); + + // Validation + if (!anonymousId || !date || typeof guessCount !== 'number' || guessCount < 1) { + return json({ error: 'Invalid data' }, { status: 400 }); + } + + 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 json({ error: 'Already submitted' }, { status: 409 }); + } + 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 json({ + success: true, + stats: { solveRank, guessRank, totalSolves, averageGuesses } + }); + } catch (err) { + console.error('Error submitting completion:', err); + 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 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 json({ + success: true, + stats: { solveRank, guessRank, totalSolves, averageGuesses } + }); + } catch (err) { + console.error('Error fetching stats:', err); + return json({ error: 'Failed to fetch stats' }, { status: 500 }); + } +}; diff --git a/todo.md b/todo.md index 44dc7cc..4f05861 100644 --- a/todo.md +++ b/todo.md @@ -1,11 +1,13 @@ # todo -- metadata -- favicon -- site title -- deploy - ## v2 - avg guesses per bible verse updating daily (on completion: avg. guesses: 6) - you're the XXXth person to guess correctly today + +# done + +- metadata +- favicon +- site title +- deploy \ No newline at end of file