adding the whole website made by claude

This commit is contained in:
un-oeil 2026-08-07 17:20:22 +02:00
parent 7feda6ca0e
commit e4b4ed59fb
16 changed files with 2125 additions and 1 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules/
data/
.env
.git/
public/vendor/
*.log

8
.env.example Normal file
View file

@ -0,0 +1,8 @@
# Shared password required to log in as admin (add/undo doses). Required.
ADMIN_PASSWORD=changeme
# Host port to expose the app on. The container always listens on 3000 internally. Optional.
PORT=3000
# IANA timezone used to decide what "today" is when logging a dose (e.g. Europe/Paris). Optional, defaults to UTC.
TZ=UTC

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
data/
.env
public/vendor/
*.log

21
Dockerfile Normal file
View file

@ -0,0 +1,21 @@
FROM node:22-bookworm-slim
# tzdata lets the TZ env var control what counts as "today" when logging a dose
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production PORT=3000 DATA_DIR=/data
COPY package.json package-lock.json ./
COPY scripts ./scripts
RUN npm ci --omit=dev
COPY server ./server
COPY public ./public
EXPOSE 3000
VOLUME ["/data"]
CMD ["node", "server/index.js"]

136
README.md
View file

@ -1,2 +1,136 @@
# leaderboard
# Dose Leaderboard
A tiny self-hosted leaderboard for tracking wins ("**doses**") in a card game
played with friends. The UI is in French; this README is in English for
whoever's maintaining/deploying it.
- Anyone can view the standings.
- A chart shows each player's doses **per day** (not cumulative — it goes up
and down with how many wins landed on each day).
- Only the admin (a single shared password) can add/remove players, and
add/remove doses — for **any date**, not just today.
No accounts, no build step, one Docker container, data stored in a local
SQLite file.
## Quick start (Docker)
Requires [Docker](https://docs.docker.com/get-docker/) and Docker Compose
(bundled with Docker Desktop, or `docker-compose-plugin` on Linux).
```bash
git clone <this repo> dose-leaderboard
cd dose-leaderboard
cp .env.example .env
# edit .env and set ADMIN_PASSWORD to something only you and maybe a co-admin know
sudo docker compose up -d --build
```
Open `http://<your-server>:3000` (or whatever `PORT` you set in `.env`).
Doses and players are stored in `./data/leaderboard.db`, which is
bind-mounted into the container — the data survives rebuilds, restarts, and
`docker compose down`. Back it up by copying that one file. (The container
runs as `root`, so that directory ends up root-owned on the host — that's
fine, only the container needs to write to it.)
To update after pulling new code:
```bash
sudo docker compose up -d --build
```
To stop it:
```bash
sudo docker compose down
```
## Using it (Classement des doses)
- **Standings ("Classement")**: everyone can see the list of players ranked
by total doses, and the "Total des doses" stat row under the chart.
- **Chart ("Doses par jour")**: one line per player, one point per day that
has at least one dose (from anyone). Y-axis is that day's count — expect it
to go up and down, not just climb.
- **Admin login**: click "Connexion admin" top-right and enter the password
from `ADMIN_PASSWORD`. The browser remembers it (in `localStorage`) until
you click "Déconnexion" or clear site data.
- **Add / remove a player**: admin-only. Logged in, an "Ajouter un joueur"
form appears at the bottom, and a `✕` button appears next to each player in
the standings (removing a player deletes all of their doses too — it asks
for confirmation first).
- **Add / remove a dose for a specific date**: admin-only. A date picker
("Modifier les doses du :") appears above the standings, defaulting to
today. Pick any date (not in the future), then use each player's `+`/``
buttons to add or remove a dose on *that* date. `` is disabled once that
date's count for a player reaches 0.
## Environment variables
| Variable | Required | Default | Meaning |
|---|---|---|---|
| `ADMIN_PASSWORD` | yes | — | Shared password for admin actions. The server refuses to start without it. |
| `PORT` | no | `3000` | Host port Docker Compose publishes the app on. The container always listens on `3000` internally. |
| `TZ` | no | `UTC` | IANA timezone (e.g. `Europe/Paris`) used to compute "today" (the default date picker value and the cap on how far in the future a dose date may be). |
## Running locally without Docker
Requires Node.js **22.5+** (uses the built-in `node:sqlite` module — no
native dependencies to compile).
```bash
npm install
ADMIN_PASSWORD=changeme npm run dev # auto-restarts on file changes
```
Then open `http://localhost:3000`. Data is stored in `./data/leaderboard.db`
by default (override with `DATA_DIR`).
## API reference
All routes are under `/api`. Admin-only routes require an `x-admin-key`
header matching `ADMIN_PASSWORD`.
| Method & path | Auth | Body / query | Notes |
|---|---|---|---|
| `GET /players?date=YYYY-MM-DD` | — | — | List of players with `total` doses and `dateCount` (doses on `date`, defaults to today). |
| `POST /players` | admin | `{ name }` | Create a player. 409 if the name already exists (case-insensitive). |
| `DELETE /players/:id` | admin | — | Delete a player and all their doses. |
| `GET /timeseries` | — | — | `{ dates, series: [{ playerId, name, counts }] }``counts[i]` is that player's dose count on `dates[i]`. |
| `POST /admin/verify` | — | `{ password }` | `{ ok: boolean }`, used by the UI to check a password before storing it. |
| `POST /doses` | admin | `{ playerId, date? }` | Add one dose for `playerId` on `date` (defaults to today; can't be in the future). |
| `DELETE /doses/latest?playerId=&date=` | admin | — | Remove the most recently logged dose for that player on `date` (defaults to today). |
## How it's built
- **Backend**: Node.js + Express, `server/`. SQLite via Node's built-in
`node:sqlite`, one file at `DATA_DIR/leaderboard.db`.
- **Frontend**: plain HTML/CSS/JS in `public/`, no build step. Chart.js is
vendored into `public/vendor/` at install time (`scripts/copy-vendor.js`) so
the app has no runtime dependency on a CDN.
- **Auth**: a single admin password, checked against the `x-admin-key` header
on write endpoints. Stateless — no sessions, no cookies.
```
server/
index.js — Express app bootstrap
db.js — SQLite connection + schema
routes.js — /api/* endpoints
auth.js — admin-key middleware
public/
index.html, styles.css, app.js — the UI (French)
vendor/ — generated, not committed (see .gitignore)
```
## Troubleshooting
- **"ADMIN_PASSWORD environment variable is required"** — you haven't set it
in `.env` (Docker) or your shell (local dev).
- **Permission errors writing to `./data`** — if you pre-created the `data`
directory as a different user, make sure it's writable by whoever the
container runs as (the container runs as `root` by default for simplicity;
`chmod 777 data` or matching ownership fixes it).
- **Wrong default date in the picker** — set `TZ` in `.env` to your local
timezone and restart (`sudo docker compose up -d`).

12
docker-compose.yml Normal file
View file

@ -0,0 +1,12 @@
services:
leaderboard:
build: .
container_name: dose-leaderboard
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
environment:
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- TZ=${TZ:-UTC}
volumes:
- ./data:/data

851
package-lock.json generated Normal file
View file

@ -0,0 +1,851 @@
{
"name": "dose-leaderboard",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dose-leaderboard",
"version": "1.0.0",
"hasInstallScript": true,
"dependencies": {
"chart.js": "^4.4.4",
"express": "^4.21.0"
},
"engines": {
"node": ">=22.5.0"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "dose-leaderboard",
"version": "1.0.0",
"description": "Leaderboard for tracking card-game wins (\"doses\") between friends",
"private": true,
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "node --watch server/index.js",
"postinstall": "node scripts/copy-vendor.js"
},
"dependencies": {
"chart.js": "^4.4.4",
"express": "^4.21.0"
},
"engines": {
"node": ">=22.5.0"
}
}

405
public/app.js Normal file
View file

@ -0,0 +1,405 @@
(() => {
const ADMIN_KEY_STORAGE = 'doseAdminKey';
const PALETTE_SLOTS = 8;
function clientTodayStr() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
const state = {
players: [],
timeseries: { dates: [], series: [] },
adminKey: localStorage.getItem(ADMIN_KEY_STORAGE) || null,
selectedDate: clientTodayStr(),
chart: null,
};
const $ = (sel) => document.querySelector(sel);
const leaderboardBody = $('#leaderboard-body');
const leaderboardEmpty = $('#leaderboard-empty');
const chartEmpty = $('#chart-empty');
const totalsRow = $('#totals-row');
const totalsEmpty = $('#totals-empty');
const adminStatus = $('#admin-status');
const adminLoginBtn = $('#admin-login-btn');
const adminLogoutBtn = $('#admin-logout-btn');
const adminDialog = $('#admin-dialog');
const adminLoginForm = $('#admin-login-form');
const adminPasswordInput = $('#admin-password-input');
const adminLoginError = $('#admin-login-error');
const adminCancelBtn = $('#admin-cancel-btn');
const addPlayerForm = $('#add-player-form');
const playerNameInput = $('#player-name-input');
const addPlayerError = $('#add-player-error');
const doseDateInput = $('#dose-date-input');
const adminOnlyEls = document.querySelectorAll('.admin-only');
doseDateInput.max = clientTodayStr();
doseDateInput.value = state.selectedDate;
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function colorForPlayer(playerId) {
const index = (playerId - 1) % PALETTE_SLOTS;
return cssVar(`--series-${index + 1}`);
}
function dashForPlayer(playerId) {
const cycle = Math.floor((playerId - 1) / PALETTE_SLOTS);
return cycle === 0 ? [] : [6, 3];
}
function formatDateLabel(iso) {
const [y, m, d] = iso.split('-').map(Number);
const date = new Date(y, m - 1, d);
return date.toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' });
}
async function api(path, options = {}) {
const headers = Object.assign({}, options.headers);
if (options.body) headers['Content-Type'] = 'application/json';
if (options.auth && state.adminKey) headers['x-admin-key'] = state.adminKey;
const res = await fetch(`/api${path}`, { ...options, headers });
let data = null;
try {
data = await res.json();
} catch (_) {
/* no body */
}
if (!res.ok) {
const error = new Error(data?.error || `Request failed (${res.status})`);
error.status = res.status;
throw error;
}
return data;
}
async function loadAll() {
const [players, timeseries] = await Promise.all([
api(`/players?date=${state.selectedDate}`),
api('/timeseries'),
]);
state.players = players;
state.timeseries = timeseries;
renderLeaderboard();
renderChart();
renderTotals();
}
function setAdminMode(isAdmin) {
adminStatus.classList.toggle('hidden', !isAdmin);
adminLoginBtn.classList.toggle('hidden', isAdmin);
adminLogoutBtn.classList.toggle('hidden', !isAdmin);
adminOnlyEls.forEach((el) => el.classList.toggle('hidden', !isAdmin));
renderLeaderboard();
}
function renderLeaderboard() {
leaderboardBody.innerHTML = '';
leaderboardEmpty.classList.toggle('hidden', state.players.length > 0);
state.players.forEach((player, index) => {
const tr = document.createElement('tr');
const rankTd = document.createElement('td');
rankTd.className = 'col-rank';
rankTd.textContent = String(index + 1);
const nameTd = document.createElement('td');
nameTd.className = 'col-name';
const nameWrap = document.createElement('span');
nameWrap.className = 'player-name';
const dot = document.createElement('span');
dot.className = 'color-dot';
dot.style.background = colorForPlayer(player.id);
nameWrap.appendChild(dot);
nameWrap.appendChild(document.createTextNode(player.name));
nameTd.appendChild(nameWrap);
const totalTd = document.createElement('td');
totalTd.className = 'col-total';
totalTd.textContent = String(player.total);
tr.append(rankTd, nameTd, totalTd);
if (state.adminKey) {
const actionsTd = document.createElement('td');
actionsTd.className = 'col-actions';
const controls = document.createElement('div');
controls.className = 'dose-controls';
const minusBtn = document.createElement('button');
minusBtn.className = 'btn btn-icon';
minusBtn.textContent = '';
minusBtn.disabled = player.dateCount <= 0;
minusBtn.title = 'Retirer une dose à cette date';
minusBtn.addEventListener('click', () => undoDose(player.id));
const dateCount = document.createElement('span');
dateCount.className = 'today-count';
dateCount.textContent = String(player.dateCount);
const plusBtn = document.createElement('button');
plusBtn.className = 'btn btn-icon';
plusBtn.textContent = '+';
plusBtn.title = 'Ajouter une dose à cette date';
plusBtn.addEventListener('click', () => addDose(player.id));
controls.append(minusBtn, dateCount, plusBtn);
actionsTd.appendChild(controls);
tr.appendChild(actionsTd);
const removeTd = document.createElement('td');
removeTd.className = 'col-remove';
const removeBtn = document.createElement('button');
removeBtn.className = 'btn btn-icon btn-remove';
removeBtn.textContent = '✕';
removeBtn.title = 'Supprimer ce joueur';
removeBtn.addEventListener('click', () => removePlayer(player.id, player.name));
removeTd.appendChild(removeBtn);
tr.appendChild(removeTd);
}
leaderboardBody.appendChild(tr);
});
}
function renderTotals() {
totalsRow.innerHTML = '';
totalsEmpty.classList.toggle('hidden', state.players.length > 0);
state.players.forEach((player) => {
const tile = document.createElement('div');
tile.className = 'stat-tile';
const dot = document.createElement('span');
dot.className = 'color-dot';
dot.style.background = colorForPlayer(player.id);
const name = document.createElement('span');
name.className = 'stat-name';
name.textContent = player.name;
const value = document.createElement('span');
value.className = 'stat-value';
value.textContent = String(player.total);
tile.append(dot, name, value);
totalsRow.appendChild(tile);
});
}
function renderChart() {
const { dates, series } = state.timeseries;
chartEmpty.classList.toggle('hidden', dates.length > 0);
const canvas = $('#dose-chart');
canvas.classList.toggle('hidden', dates.length === 0);
if (state.chart) {
state.chart.destroy();
state.chart = null;
}
if (dates.length === 0) return;
const textMuted = cssVar('--text-muted');
const textPrimary = cssVar('--text-primary');
const gridline = cssVar('--gridline');
const surface = cssVar('--surface-1');
const datasets = series.map((s) => {
const color = colorForPlayer(s.playerId);
return {
label: s.name,
data: s.counts,
borderColor: color,
backgroundColor: color,
borderWidth: 2,
borderDash: dashForPlayer(s.playerId),
pointRadius: 4,
pointHoverRadius: 5,
pointBackgroundColor: color,
pointBorderColor: surface,
pointBorderWidth: 2,
tension: 0,
};
});
state.chart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: { labels: dates, datasets },
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
x: {
grid: { display: false },
border: { color: cssVar('--baseline') },
ticks: {
color: textMuted,
callback: function (value) {
const label = this.getLabelForValue(value);
return formatDateLabel(label);
},
},
},
y: {
beginAtZero: true,
grid: { color: gridline },
border: { display: false },
ticks: { color: textMuted, precision: 0 },
},
},
plugins: {
legend: {
display: datasets.length > 1,
position: 'top',
align: 'start',
labels: {
color: textPrimary,
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 8,
boxHeight: 8,
},
},
tooltip: {
callbacks: {
title: (items) => formatDateLabel(items[0].label),
},
},
},
},
});
}
async function addDose(playerId) {
try {
await api('/doses', {
method: 'POST',
auth: true,
body: JSON.stringify({ playerId, date: state.selectedDate }),
});
await loadAll();
} catch (err) {
handleAdminError(err);
}
}
async function undoDose(playerId) {
try {
await api(`/doses/latest?playerId=${playerId}&date=${state.selectedDate}`, {
method: 'DELETE',
auth: true,
});
await loadAll();
} catch (err) {
handleAdminError(err);
}
}
async function removePlayer(playerId, name) {
if (!confirm(`Supprimer ${name} et toutes ses doses ? Cette action est irréversible.`)) return;
try {
await api(`/players/${playerId}`, { method: 'DELETE', auth: true });
await loadAll();
} catch (err) {
handleAdminError(err);
}
}
function handleAdminError(err) {
if (err.status === 401) {
clearAdminKey();
alert('Votre session admin a expiré. Merci de vous reconnecter.');
} else {
alert(err.message);
}
}
function clearAdminKey() {
state.adminKey = null;
localStorage.removeItem(ADMIN_KEY_STORAGE);
setAdminMode(false);
}
async function tryRestoreAdminSession() {
if (!state.adminKey) return;
try {
const result = await api('/admin/verify', {
method: 'POST',
body: JSON.stringify({ password: state.adminKey }),
});
if (result.ok) {
setAdminMode(true);
} else {
clearAdminKey();
}
} catch (_) {
clearAdminKey();
}
}
addPlayerForm.addEventListener('submit', async (e) => {
e.preventDefault();
addPlayerError.classList.add('hidden');
const name = playerNameInput.value.trim();
if (!name) return;
try {
await api('/players', { method: 'POST', auth: true, body: JSON.stringify({ name }) });
playerNameInput.value = '';
await loadAll();
} catch (err) {
addPlayerError.textContent = err.message;
addPlayerError.classList.remove('hidden');
}
});
adminLoginBtn.addEventListener('click', () => {
adminLoginError.classList.add('hidden');
adminPasswordInput.value = '';
adminDialog.showModal();
adminPasswordInput.focus();
});
adminCancelBtn.addEventListener('click', () => adminDialog.close());
adminLoginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const password = adminPasswordInput.value;
try {
const result = await api('/admin/verify', {
method: 'POST',
body: JSON.stringify({ password }),
});
if (result.ok) {
state.adminKey = password;
localStorage.setItem(ADMIN_KEY_STORAGE, password);
adminDialog.close();
setAdminMode(true);
} else {
adminLoginError.classList.remove('hidden');
}
} catch (_) {
adminLoginError.classList.remove('hidden');
}
});
adminLogoutBtn.addEventListener('click', clearAdminKey);
doseDateInput.addEventListener('change', () => {
if (!doseDateInput.value) return;
state.selectedDate = doseDateInput.value;
loadAll();
});
(async function init() {
await tryRestoreAdminSession();
await loadAll();
})();
})();

85
public/index.html Normal file
View file

@ -0,0 +1,85 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Classement des doses</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="page">
<header class="page-header">
<h1>Classement des doses</h1>
<div class="admin-controls">
<span id="admin-status" class="admin-status hidden">Mode admin</span>
<button id="admin-login-btn" class="btn btn-ghost">Connexion admin</button>
<button id="admin-logout-btn" class="btn btn-ghost hidden">Déconnexion</button>
</div>
</header>
<main>
<section class="card">
<h2>Doses par jour</h2>
<div class="chart-wrap">
<canvas id="dose-chart" height="320"></canvas>
</div>
<p id="chart-empty" class="empty-state hidden">Enregistrez une dose pour voir le graphique.</p>
<h3 class="totals-heading">Total des doses</h3>
<div class="totals-row" id="totals-row"></div>
<p id="totals-empty" class="empty-state hidden">Aucune dose enregistrée.</p>
</section>
<section class="card">
<h2>Classement</h2>
<div id="date-picker-row" class="date-picker-row admin-only hidden">
<label for="dose-date-input">Modifier les doses du :</label>
<input type="date" id="dose-date-input" />
</div>
<table class="leaderboard" id="leaderboard-table">
<thead>
<tr>
<th class="col-rank">#</th>
<th class="col-name">Joueur</th>
<th class="col-total">Doses</th>
<th class="col-actions admin-only hidden">Modifier</th>
<th class="col-remove admin-only hidden"></th>
</tr>
</thead>
<tbody id="leaderboard-body"></tbody>
</table>
<p id="leaderboard-empty" class="empty-state hidden">Aucun joueur pour le moment.</p>
</section>
<section id="add-player-section" class="card admin-only hidden">
<h2>Ajouter un joueur</h2>
<form id="add-player-form" class="add-player-form">
<input type="text" id="player-name-input" placeholder="Nom du joueur" maxlength="40" autocomplete="off" required />
<button type="submit" class="btn btn-primary">Ajouter</button>
</form>
<p id="add-player-error" class="form-error hidden"></p>
</section>
</main>
<footer class="page-footer">
<p>Une victoire = une dose. Seul l'admin peut ajouter/annuler des doses et ajouter/supprimer des joueurs.</p>
</footer>
</div>
<dialog id="admin-dialog">
<form id="admin-login-form" method="dialog">
<h2>Connexion admin</h2>
<label for="admin-password-input">Mot de passe</label>
<input type="password" id="admin-password-input" autocomplete="current-password" required />
<p id="admin-login-error" class="form-error hidden">Mot de passe incorrect.</p>
<div class="dialog-actions">
<button type="button" id="admin-cancel-btn" class="btn btn-ghost">Annuler</button>
<button type="submit" class="btn btn-primary">Connexion</button>
</div>
</form>
</dialog>
<script src="/vendor/chart.umd.js"></script>
<script src="/app.js"></script>
</body>
</html>

333
public/styles.css Normal file
View file

@ -0,0 +1,333 @@
:root {
color-scheme: light;
--page-plane: #f9f9f7;
--surface-1: #fcfcfb;
--text-primary: #0b0b0b;
--text-secondary: #52514e;
--text-muted: #898781;
--gridline: #e1e0d9;
--baseline: #c3c2b7;
--border: rgba(11, 11, 11, 0.10);
--success-text: #006300;
--danger-text: #b3261e;
--series-1: #2a78d6; /* blue */
--series-2: #eb6834; /* orange */
--series-3: #1baf7a; /* aqua */
--series-4: #eda100; /* yellow */
--series-5: #e87ba4; /* magenta */
--series-6: #008300; /* green */
--series-7: #4a3aa7; /* violet */
--series-8: #e34948; /* red */
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--page-plane: #0d0d0d;
--surface-1: #1a1a19;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--gridline: #2c2c2a;
--baseline: #383835;
--border: rgba(255, 255, 255, 0.10);
--success-text: #0ca30c;
--danger-text: #e66767;
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--series-7: #9085e9;
--series-8: #e66767;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--page-plane);
color: var(--text-primary);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.4;
}
.page {
max-width: 860px;
margin: 0 auto;
padding: 24px 20px 48px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
h2 {
font-size: 1.05rem;
font-weight: 600;
margin: 0 0 14px;
}
.admin-controls {
display: flex;
align-items: center;
gap: 10px;
}
.admin-status {
font-size: 0.85rem;
color: var(--success-text);
font-weight: 600;
}
.card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.btn {
font: inherit;
font-size: 0.9rem;
font-weight: 500;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--border);
cursor: pointer;
background: var(--surface-1);
color: var(--text-primary);
}
.btn:hover { background: var(--gridline); }
.btn-primary {
background: var(--series-1);
border-color: var(--series-1);
color: #ffffff;
}
.btn-primary:hover { filter: brightness(0.92); }
.btn-ghost { background: transparent; }
.btn-icon {
width: 30px;
height: 30px;
padding: 0;
border-radius: 6px;
font-weight: 700;
line-height: 1;
}
.btn-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.date-picker-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
font-size: 0.9rem;
color: var(--text-secondary);
}
.date-picker-row input[type="date"] {
font: inherit;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--baseline);
background: var(--surface-1);
color: var(--text-primary);
}
table.leaderboard {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
table.leaderboard th {
text-align: left;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted);
font-weight: 600;
padding: 6px 8px;
border-bottom: 1px solid var(--gridline);
}
table.leaderboard td {
padding: 10px 8px;
border-bottom: 1px solid var(--gridline);
}
table.leaderboard tr:last-child td { border-bottom: none; }
.col-rank { width: 36px; color: var(--text-muted); }
.col-total { width: 90px; text-align: right; font-weight: 600; }
.col-actions { width: 140px; text-align: right; }
.col-remove { width: 40px; text-align: right; }
.btn-remove {
color: var(--danger-text);
border-color: var(--border);
}
.player-name {
display: flex;
align-items: center;
gap: 10px;
}
.color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: none;
}
.dose-controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.today-count {
min-width: 1.4em;
text-align: center;
color: var(--text-secondary);
}
.totals-heading {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
margin: 20px 0 10px;
}
.totals-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.stat-tile {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--page-plane);
}
.stat-tile .stat-name {
color: var(--text-secondary);
font-size: 0.85rem;
}
.stat-tile .stat-value {
font-weight: 600;
font-variant-numeric: tabular-nums;
font-size: 1.05rem;
}
.add-player-form {
display: flex;
gap: 10px;
}
.add-player-form input {
flex: 1;
font: inherit;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--baseline);
background: var(--surface-1);
color: var(--text-primary);
}
.form-error {
color: var(--danger-text);
font-size: 0.85rem;
margin: 10px 0 0;
}
.empty-state {
color: var(--text-muted);
font-size: 0.9rem;
}
.chart-wrap {
position: relative;
}
.page-footer {
text-align: center;
color: var(--text-muted);
font-size: 0.85rem;
margin-top: 8px;
}
.hidden { display: none !important; }
dialog {
border: none;
border-radius: 12px;
padding: 0;
background: var(--surface-1);
color: var(--text-primary);
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.4);
}
#admin-login-form {
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
width: 260px;
}
#admin-login-form label {
font-size: 0.85rem;
color: var(--text-secondary);
}
#admin-login-form input {
font: inherit;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--baseline);
background: var(--surface-1);
color: var(--text-primary);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 6px;
}

12
scripts/copy-vendor.js Normal file
View file

@ -0,0 +1,12 @@
// Copies the Chart.js UMD bundle into public/vendor so the app can serve it
// itself (no CDN dependency at runtime).
const fs = require('fs');
const path = require('path');
const src = path.join(__dirname, '..', 'node_modules', 'chart.js', 'dist', 'chart.umd.js');
const destDir = path.join(__dirname, '..', 'public', 'vendor');
const dest = path.join(destDir, 'chart.umd.js');
fs.mkdirSync(destDir, { recursive: true });
fs.copyFileSync(src, dest);
console.log(`Copied ${path.relative(process.cwd(), src)} -> ${path.relative(process.cwd(), dest)}`);

23
server/auth.js Normal file
View file

@ -0,0 +1,23 @@
const crypto = require('crypto');
function timingSafeEqual(a, b) {
const bufA = Buffer.from(String(a));
const bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
function isValidAdminKey(key) {
const expected = process.env.ADMIN_PASSWORD;
return Boolean(expected) && Boolean(key) && timingSafeEqual(key, expected);
}
function requireAdmin(req, res, next) {
const key = req.get('x-admin-key');
if (!isValidAdminKey(key)) {
return res.status(401).json({ error: "Clé admin invalide ou manquante" });
}
next();
}
module.exports = { requireAdmin, isValidAdminKey };

28
server/db.js Normal file
View file

@ -0,0 +1,28 @@
const path = require('path');
const fs = require('fs');
const { DatabaseSync } = require('node:sqlite');
const dataDir = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
fs.mkdirSync(dataDir, { recursive: true });
const db = new DatabaseSync(path.join(dataDir, 'leaderboard.db'));
db.exec('PRAGMA journal_mode = WAL;');
db.exec(`
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL COLLATE NOCASE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS doses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
date TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_doses_player_date ON doses(player_id, date);
`);
module.exports = db;

19
server/index.js Normal file
View file

@ -0,0 +1,19 @@
const path = require('path');
const express = require('express');
const routes = require('./routes');
if (!process.env.ADMIN_PASSWORD) {
console.error('ADMIN_PASSWORD environment variable is required. Set it in .env and restart.');
process.exit(1);
}
const app = express();
const port = process.env.PORT || 3000;
app.use(express.json());
app.use('/api', routes);
app.use(express.static(path.join(__dirname, '..', 'public')));
app.listen(port, () => {
console.log(`Dose leaderboard listening on port ${port}`);
});

163
server/routes.js Normal file
View file

@ -0,0 +1,163 @@
const express = require('express');
const db = require('./db');
const { requireAdmin, isValidAdminKey } = require('./auth');
const router = express.Router();
function todayStr() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// Resolves a caller-supplied date, falling back to today and rejecting
// malformed or future dates (returns null on invalid input).
function resolveDate(raw) {
if (!raw) return todayStr();
if (!DATE_RE.test(raw)) return null;
if (raw > todayStr()) return null;
return raw;
}
// ---- Players -----------------------------------------------------------
router.get('/players', (req, res) => {
const date = resolveDate(req.query.date) || todayStr();
const rows = db
.prepare(
`SELECT p.id, p.name,
COUNT(d.id) AS total,
SUM(CASE WHEN d.date = ? THEN 1 ELSE 0 END) AS dateCount
FROM players p
LEFT JOIN doses d ON d.player_id = p.id
GROUP BY p.id
ORDER BY total DESC, p.name COLLATE NOCASE ASC`
)
.all(date);
res.json(rows.map((r) => ({ ...r, dateCount: r.dateCount || 0 })));
});
router.post('/players', requireAdmin, (req, res) => {
const name = String(req.body?.name || '').trim();
if (!name) {
return res.status(400).json({ error: 'Le nom est requis' });
}
if (name.length > 40) {
return res.status(400).json({ error: 'Le nom est trop long (40 caractères maximum)' });
}
try {
const info = db.prepare('INSERT INTO players (name) VALUES (?)').run(name);
res.status(201).json({ id: info.lastInsertRowid, name, total: 0, dateCount: 0 });
} catch (err) {
if (err.code === 'ERR_SQLITE_ERROR' && /UNIQUE constraint failed/.test(err.message)) {
return res.status(409).json({ error: 'Un joueur avec ce nom existe déjà' });
}
throw err;
}
});
router.delete('/players/:id', requireAdmin, (req, res) => {
const playerId = Number(req.params.id);
const player = db.prepare('SELECT id FROM players WHERE id = ?').get(playerId);
if (!player) {
return res.status(404).json({ error: 'Joueur introuvable' });
}
db.exec('BEGIN');
db.prepare('DELETE FROM doses WHERE player_id = ?').run(playerId);
db.prepare('DELETE FROM players WHERE id = ?').run(playerId);
db.exec('COMMIT');
res.status(204).end();
});
// ---- Timeseries (doses per player, per date) -----------------------------
router.get('/timeseries', (req, res) => {
const players = db.prepare('SELECT id, name FROM players ORDER BY id ASC').all();
const dates = db
.prepare('SELECT DISTINCT date FROM doses ORDER BY date ASC')
.all()
.map((r) => r.date);
const counts = db
.prepare('SELECT player_id, date, COUNT(*) AS c FROM doses GROUP BY player_id, date')
.all();
const byPlayerDate = new Map(); // `${playerId}|${date}` -> count
for (const row of counts) {
byPlayerDate.set(`${row.player_id}|${row.date}`, row.c);
}
const series = players.map((p) => ({
playerId: p.id,
name: p.name,
counts: dates.map((date) => byPlayerDate.get(`${p.id}|${date}`) || 0),
}));
res.json({ dates, series });
});
// ---- Admin auth ----------------------------------------------------------
router.post('/admin/verify', (req, res) => {
const password = String(req.body?.password || '');
res.json({ ok: isValidAdminKey(password) });
});
// ---- Doses (admin only) --------------------------------------------------
router.post('/doses', requireAdmin, (req, res) => {
const playerId = Number(req.body?.playerId);
const player = db.prepare('SELECT id FROM players WHERE id = ?').get(playerId);
if (!player) {
return res.status(404).json({ error: 'Joueur introuvable' });
}
const date = resolveDate(req.body?.date);
if (!date) {
return res.status(400).json({ error: 'Date invalide (elle ne peut pas être dans le futur)' });
}
db.prepare('INSERT INTO doses (player_id, date) VALUES (?, ?)').run(playerId, date);
const total = db.prepare('SELECT COUNT(*) AS c FROM doses WHERE player_id = ?').get(playerId).c;
const dateCount = db
.prepare('SELECT COUNT(*) AS c FROM doses WHERE player_id = ? AND date = ?')
.get(playerId, date).c;
res.status(201).json({ playerId, total, dateCount });
});
router.delete('/doses/latest', requireAdmin, (req, res) => {
const playerId = Number(req.query.playerId);
const player = db.prepare('SELECT id FROM players WHERE id = ?').get(playerId);
if (!player) {
return res.status(404).json({ error: 'Joueur introuvable' });
}
const date = resolveDate(req.query.date);
if (!date) {
return res.status(400).json({ error: 'Date invalide (elle ne peut pas être dans le futur)' });
}
const latest = db
.prepare('SELECT id FROM doses WHERE player_id = ? AND date = ? ORDER BY id DESC LIMIT 1')
.get(playerId, date);
if (!latest) {
return res.status(404).json({ error: 'Aucune dose enregistrée pour ce joueur à cette date' });
}
db.prepare('DELETE FROM doses WHERE id = ?').run(latest.id);
const total = db.prepare('SELECT COUNT(*) AS c FROM doses WHERE player_id = ?').get(playerId).c;
const dateCount = db
.prepare('SELECT COUNT(*) AS c FROM doses WHERE player_id = ? AND date = ?')
.get(playerId, date).c;
res.json({ playerId, total, dateCount });
});
module.exports = router;