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 <thisrepo> 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`).