mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-02-04 02:44:43 -05:00
v2
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
# SQLite
|
# SQLite
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
*.txt
|
||||||
267
deploy.txt
267
deploy.txt
@@ -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:
|
|
||||||
|
|
||||||
```
|
|
||||||
<client address>, <proxy 1 address>, <proxy 2 address>
|
|
||||||
```
|
|
||||||
|
|
||||||
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/):
|
|
||||||
|
|
||||||
```
|
|
||||||
<spoofed address>, <client address>, <proxy 1 address>, <proxy 2 address>
|
|
||||||
```
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
```
|
|
||||||
32
drizzle/0000_clumsy_impossible_man.sql
Normal file
32
drizzle/0000_clumsy_impossible_man.sql
Normal file
@@ -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
|
||||||
|
);
|
||||||
216
drizzle/meta/0000_snapshot.json
Normal file
216
drizzle/meta/0000_snapshot.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1765934144883,
|
||||||
|
"tag": "0000_clumsy_impossible_man",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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';
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
@@ -24,3 +24,17 @@ export const dailyVerses = sqliteTable('daily_verses', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type DailyVerse = typeof dailyVerses.$inferSelect;
|
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;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { dailyVerses } from '$lib/server/db/schema';
|
import { dailyVerses, dailyCompletions } from '$lib/server/db/schema';
|
||||||
import { eq, sql } from 'drizzle-orm';
|
import { eq, sql, asc } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
import { fetchRandomVerse } from '$lib/server/bible-api';
|
import { fetchRandomVerse } from '$lib/server/bible-api';
|
||||||
import { getBookById } from '$lib/server/bible';
|
import { getBookById } from '$lib/server/bible';
|
||||||
import type { DailyVerse } from '$lib/server/db/schema';
|
import type { DailyVerse } from '$lib/server/db/schema';
|
||||||
@@ -43,3 +44,60 @@ export const load: PageServerLoad = async () => {
|
|||||||
correctBook
|
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 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -23,6 +23,15 @@
|
|||||||
|
|
||||||
let copied = $state(false);
|
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(
|
let filteredBooks = $derived(
|
||||||
bibleBooks.filter((book) =>
|
bibleBooks.filter((book) =>
|
||||||
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
@@ -90,6 +99,41 @@
|
|||||||
return "🔴 C-";
|
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(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
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() {
|
async function share() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
@@ -269,6 +403,7 @@
|
|||||||
>
|
>
|
||||||
Your grade: {grade}
|
Your grade: {grade}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={handleShare}
|
onclick={handleShare}
|
||||||
data-umami-event="Share"
|
data-umami-event="Share"
|
||||||
@@ -278,8 +413,40 @@
|
|||||||
: "bg-white/20 hover:bg-white/30"
|
: "bg-white/20 hover:bg-white/30"
|
||||||
} rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
|
} rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
|
||||||
>
|
>
|
||||||
{copied ? "shared!" : "📤 Share"}
|
{copied ? "Copied to clipboard!" : "📤 Share"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Statistics Display -->
|
||||||
|
{#if statsData}
|
||||||
|
<div
|
||||||
|
class="mt-6 space-y-2 text-lg"
|
||||||
|
in:fade={{ delay: 800 }}
|
||||||
|
>
|
||||||
|
<p class="font-semibold">
|
||||||
|
You were the <span class="text-2xl font-black"
|
||||||
|
>#{statsData.solveRank}</span
|
||||||
|
> person to solve today!
|
||||||
|
</p>
|
||||||
|
<p class="font-semibold">
|
||||||
|
You ranked <span class="text-2xl font-black"
|
||||||
|
>#{statsData.guessRank}</span
|
||||||
|
> by number of guesses
|
||||||
|
</p>
|
||||||
|
<p class="opacity-90">
|
||||||
|
{statsData.totalSolves}
|
||||||
|
{statsData.totalSolves === 1
|
||||||
|
? "person has"
|
||||||
|
: "people have"} solved today
|
||||||
|
</p>
|
||||||
|
<p class="opacity-90">
|
||||||
|
Average guesses: {statsData.averageGuesses}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if !statsSubmitted}
|
||||||
|
<div class="mt-6 text-sm opacity-80">
|
||||||
|
Submitting stats...
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
120
src/routes/api/submit-completion/+server.ts
Normal file
120
src/routes/api/submit-completion/+server.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
12
todo.md
12
todo.md
@@ -1,11 +1,13 @@
|
|||||||
# todo
|
# todo
|
||||||
|
|
||||||
- metadata
|
|
||||||
- favicon
|
|
||||||
- site title
|
|
||||||
- deploy
|
|
||||||
|
|
||||||
## v2
|
## v2
|
||||||
|
|
||||||
- avg guesses per bible verse updating daily (on completion: avg. guesses: 6)
|
- avg guesses per bible verse updating daily (on completion: avg. guesses: 6)
|
||||||
- you're the XXXth person to guess correctly today
|
- you're the XXXth person to guess correctly today
|
||||||
|
|
||||||
|
# done
|
||||||
|
|
||||||
|
- metadata
|
||||||
|
- favicon
|
||||||
|
- site title
|
||||||
|
- deploy
|
||||||
Reference in New Issue
Block a user