commit f2200011ae26929ab578f436689b4443ed32e0ba Author: Mathieu Date: Wed Oct 14 21:18:47 2026 +0200 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..248bb83 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +data +.env +test +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c25a3ef --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# URL publique du site (sans / final). Sert à construire l'URL de retour Google. +BASE_URL=https://naissance.example.com + +# Identifiants OAuth Google (console.cloud.google.com > API et services > Identifiants) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# Secret de signature des cookies : openssl rand -hex 32 +SESSION_SECRET= + +# Emails Google des administrateurs, séparés par des virgules +ADMIN_EMAILS=jerry@gmail.com + +# Nombre de pronostics à partir duquel les tendances (arrondies) sont affichées +STATS_MIN_PREDICTIONS=5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a045a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +data +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b8e67b4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:24-alpine +WORKDIR /app +ENV NODE_ENV=production DB_PATH=/data/naissance.db +COPY package*.json ./ +RUN npm ci --omit=dev +COPY server.js ./ +COPY src ./src +COPY public ./public +RUN mkdir -p /data && chown node:node /data +USER node +VOLUME /data +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c59b8e --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# 🍼 Naissance — les pronostics pour le bébé de Jerry + +Petite application web pour parier sur l'arrivée du bébé : **jour, heure, sexe, poids, taille et prénom**. + +- Connexion avec un compte Google (le prénom Google est affiché). +- Un pronostic par personne, que seul son auteur peut modifier ou supprimer, jusqu'à la clôture. +- Les pronostics restent secrets jusqu'à la naissance : chacun ne voit que le sien et le nombre de participants. + À partir de `STATS_MIN_PREDICTIONS` pronostics (5 par défaut), des tendances volontairement arrondies + s'affichent (plutôt fille/garçon, semaine, moment de la journée, poids à 100 g près, taille au cm près). + Les administrateurs voient en plus la liste des participants (sans leurs pronostics). +- Les administrateurs (`ADMIN_EMAILS`) fixent la date du terme, clôturent les pronostics, saisissent + les vraies informations à la naissance et peuvent supprimer un pronostic. +- Une fois le résultat saisi : classement automatique, podium et confettis 🎉. + +## Barème (sur 100) + +| Critère | Points | Calcul | +|---|---|---| +| Jour | 30 | −3 pts par jour d'écart | +| Heure | 15 | dégressif, 0 à 6 h d'écart (circulaire autour de minuit) | +| Sexe | 15 | tout ou rien | +| Poids | 15 | dégressif, 0 à 1 kg d'écart (−1,5 pt / 100 g) | +| Taille | 15 | dégressif, 0 à 8 cm d'écart | +| Prénom | 10 | exact (accents et majuscules ignorés) ; 3 pts si même initiale | + +Égalité : le pronostic dont la dernière modification est la plus ancienne gagne. +Le barème se règle dans `src/scoring.js`. + +## Configuration Google + +1. Sur : créer un projet, configurer l'écran de consentement OAuth + (type « Externe », portées `openid`, `email`, `profile`), puis le **publier** pour que tout le monde puisse se connecter. +2. *Identifiants > Créer > ID client OAuth > Application Web*, avec comme URI de redirection autorisé : + `https://VOTRE-DOMAINE/auth/google/callback` +3. Copier `.env.example` en `.env` et le remplir. + +## Déploiement (Docker) + +```sh +cp .env.example .env # puis éditer +docker compose up -d --build +``` + +L'app écoute sur `127.0.0.1:3000` ; placez un reverse proxy HTTPS devant (Caddy, Nginx, Traefik…). +Exemple Caddy : + +``` +naissance.example.com { + reverse_proxy 127.0.0.1:3000 +} +``` + +Les données (SQLite) sont dans le volume `naissance-data`. Sauvegarde : +`docker compose cp naissance:/data/naissance.db ./sauvegarde.db` + +Sans Docker : Node.js ≥ 22.13, `npm ci --omit=dev`, puis `npm start` avec les variables d'environnement +(`DB_PATH` optionnel, `./data/naissance.db` par défaut). + +## Développement + +```sh +npm install +ADMIN_EMAILS=jerry@example.com npm run dev +``` + +`npm run dev` active `DEV_LOGIN` : sans identifiants Google, « Se connecter » crée un utilisateur de test. +Plusieurs comptes : `http://localhost:3000/auth/dev?name=Alice`, `…?name=Jerry&email=jerry@example.com`. +**Ne jamais activer `DEV_LOGIN` en production.** + +Tests du barème : `npm test`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b8e7841 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + naissance: + build: . + restart: unless-stopped + env_file: .env + ports: + - "127.0.0.1:3000:3000" + volumes: + - naissance-data:/data + +volumes: + naissance-data: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1268edd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1013 @@ +{ + "name": "naissance", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "naissance", + "version": "1.0.0", + "dependencies": { + "cookie-session": "^2.1.0", + "express": "^5.1.0" + }, + "engines": { + "node": ">=22.13" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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-session": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.1.tgz", + "integrity": "sha512-ji3kym/XZaFVew1+tIZk5ZLp9Z/fLv9rK1aZmpug0FsgE7Cu3ZDrUdRo7FT9vFjMYfNimrrUHJzywDwT7XEFlg==", + "license": "MIT", + "dependencies": { + "cookies": "0.9.1", + "debug": "3.2.7", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "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/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": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "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": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "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.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/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" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7baa255 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "naissance", + "version": "1.0.0", + "description": "Pronostics pour la naissance du bébé de Jerry", + "main": "server.js", + "type": "commonjs", + "engines": { "node": ">=22.13" }, + "scripts": { + "start": "node --disable-warning=ExperimentalWarning server.js", + "dev": "DEV_LOGIN=true node --disable-warning=ExperimentalWarning --watch server.js", + "test": "node --test" + }, + "dependencies": { + "cookie-session": "^2.1.0", + "express": "^5.1.0" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..83d536d --- /dev/null +++ b/public/app.js @@ -0,0 +1,633 @@ +const $ = (sel) => document.querySelector(sel); +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + +let me = { user: null, isAdmin: false }; +let state = null; +let editing = false; + +const SEX = { F: { label: 'Fille', emoji: '👧', cls: 'pink' }, M: { label: 'Garçon', emoji: '👦', cls: 'blue' } }; + +const fmtDate = (iso, opts = { weekday: 'long', day: 'numeric', month: 'long' }) => + new Date(`${iso}T12:00:00`).toLocaleDateString('fr-FR', opts); +const fmtShortDate = (iso) => fmtDate(iso, { day: 'numeric', month: 'short' }); +const fmtTime = (hhmm) => hhmm.replace(':', 'h'); +const fmtWeight = (g) => `${(g / 1000).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 3 })} kg`; +const fmtLength = (cm) => `${Number(cm).toLocaleString('fr-FR')} cm`; +const fmtPts = (n) => n.toLocaleString('fr-FR', { maximumFractionDigits: 1 }); + +async function api(method, url, body) { + const res = await fetch(url, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Oups, une erreur est survenue'); + return data; +} + +function flash(msg, isError = false) { + const el = $('#flash'); + el.textContent = msg; + el.className = `flash${isError ? ' error' : ''}`; + el.hidden = false; + clearTimeout(flash.t); + flash.t = setTimeout(() => { el.hidden = true; }, 4000); +} + +function avatar(picture, name) { + return picture + ? `` + : `${esc((name || '?')[0].toUpperCase())}`; +} + +const googleIcon = ``; + +// ---------- Rendu ---------- + +function renderAccount() { + $('#account').innerHTML = me.user + ? `
${avatar(me.user.picture, me.user.firstName)}Salut ${esc(me.user.firstName)} ! +
` + : `${googleIcon} Se connecter`; + $('#logout')?.addEventListener('click', async () => { + await api('POST', '/auth/logout'); + location.reload(); + }); +} + +function renderHero() { + const r = state.result; + const title = r ? 'Le bébé de Jerry est arrivé !' : 'Le bébé de Jerry arrive !'; + document.title = `${title} 🍼`; + let html = `

👶 ${title}

`; + if (r) { + const s = SEX[r.sex]; + html += ` +
+

${s.emoji} Bienvenue ${esc(r.first_name)} ! 🎉

+
+ ${s.label} + 📅 ${esc(fmtDate(r.birth_date))} + 🕐 ${esc(fmtTime(r.birth_time))} + ⚖️ ${esc(fmtWeight(r.weight_g))} + 📏 ${esc(fmtLength(r.length_cm))} +
+
`; + } else { + html += `

Jour, heure, sexe, poids, taille, prénom… À toi de jouer : fais ton pronostic et tente de décrocher la première place !

`; + const pills = []; + if (state.dueDate) { + const days = Math.round((new Date(`${state.dueDate}T00:00:00`) - new Date(new Date().toDateString())) / 86400000); + const when = days > 1 ? `J-${days}` : days === 1 ? 'Demain !' : days === 0 ? "C'est aujourd'hui !" : `Terme dépassé de ${-days} j`; + pills.push(`🗓️ Terme prévu : ${esc(fmtDate(state.dueDate, { day: 'numeric', month: 'long' }))}`); + pills.push(`⏳ ${when}`); + } + pills.push(`🎯 ${state.count} pronostic${state.count > 1 ? 's' : ''}`); + if (state.locked) pills.push(`🔒 Pronostics clôturés`); + html += `
${pills.join('')}
`; + } + $('#hero').innerHTML = html; +} + +function renderPodium() { + if (!state.result || !state.predictions.length) return ($('#podium').innerHTML = ''); + const medals = ['🥇', '🥈', '🥉']; + const top = state.predictions.slice(0, 3); + $('#podium').innerHTML = ` +
+

🏆 Et le grand gagnant est… ${esc(top[0].display_name)} !

+
+ ${top.map((p, i) => ` +
+
${medals[i]}
+ ${p.picture ? avatar(p.picture, p.display_name) : ''} +
${esc(p.display_name)}
+
${fmtPts(p.score.total)} pts
+
`).join('')} +
+
`; +} + +const ICONS = { + calendar: '', + clock: '', +}; + +// Un champ de formulaire : libellé, contrôle et zone d'erreur, tous alignés de la même façon. +function field({ id, name, emoji, label, control }) { + return ` +
+ + ${control} + +
`; +} + +const pickerControl = (id, name, kind, value, extra = '') => ` +
+ + +
`; + +const unitControl = (id, name, unit, value, placeholder, mode) => ` +
+ + +
`; + +function predictionForm(p, formId = 'pred-form') { + const v = p || {}; + const id = (n) => `${formId}-${n}`; + return ` +
+
+ ${field({ id: id('birth_date'), name: 'birth_date', emoji: '📅', label: 'Jour de naissance', + control: pickerControl(id('birth_date'), 'birth_date', 'calendar', isoToFr(v.birth_date)) })} + ${field({ id: id('birth_time'), name: 'birth_time', emoji: '🕐', label: 'Heure', + control: pickerControl(id('birth_time'), 'birth_time', 'clock', v.birth_time || '') })} +
+ Sexe +
+ + +
+ +
+ ${field({ id: id('weight_g'), name: 'weight_g', emoji: '⚖️', label: 'Poids', + control: unitControl(id('weight_g'), 'weight_g', 'g', v.weight_g ?? '', 'ex : 3350', 'numeric') })} + ${field({ id: id('length_cm'), name: 'length_cm', emoji: '📏', label: 'Taille', + control: unitControl(id('length_cm'), 'length_cm', 'cm', v.length_cm != null ? String(v.length_cm).replace('.', ',') : '', 'ex : 50,5', 'decimal') })} + ${field({ id: id('first_name'), name: 'first_name', emoji: '✨', label: 'Prénom', + control: `
` })} +
+
+ + ${p ? '' : ''} +
+
`; +} + +// ---------- Dates et heures au format français ---------- + +const isoToFr = (iso) => (iso ? iso.split('-').reverse().join('/') : ''); + +function frToIso(s) { + const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(String(s || '').trim()); + if (!m) return null; + const iso = `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`; + const d = new Date(`${iso}T00:00:00Z`); + return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso ? iso : null; +} + +function normalizeTime(s) { + const m = /^(\d{1,2})\s*[:hH.]?\s*(\d{2})?$/.exec(String(s || '').trim()); + if (!m) return null; + const h = Number(m[1]); + const min = Number(m[2] || 0); + return h < 24 && min < 60 ? `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}` : null; +} + +// Insère automatiquement les « / » et « : » pendant la saisie, en respectant +// les séparateurs tapés par l'utilisateur (« 1/11/2026 », « 9h05 »…). +function maskInput(input, groups, sep) { + input.addEventListener('input', (e) => { + if (e.inputType?.startsWith('delete')) return; + const v = input.value; + const parts = []; + for (let chunk of v.split(/\D+/).filter(Boolean)) { + while (parts.length < groups.length - 1 && chunk.length > groups[parts.length]) { + parts.push(chunk.slice(0, groups[parts.length])); + chunk = chunk.slice(groups[parts.length - 1]); + } + if (parts.length < groups.length) parts.push(chunk.slice(0, groups[parts.length])); + } + let out = parts.join(sep); + const n = parts.length; + if (n && n < groups.length && (/\D$/.test(v) || parts[n - 1].length === groups[n - 1])) out += sep; + input.value = out; + }); +} + +function enhanceInputs(root = document) { + root.querySelectorAll('input.date-fr').forEach((i) => { + maskInput(i, [2, 2, 4], '/'); + attachPicker(i, renderCalendar); + }); + root.querySelectorAll('input.time-fr').forEach((i) => { + maskInput(i, [2, 2], ':'); + i.addEventListener('blur', () => { const t = normalizeTime(i.value); if (t) i.value = t; }); + attachPicker(i, renderClock); + }); +} + +// ---------- Sélecteurs de date et d'heure (en français) ---------- + +const MONTHS = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']; +const pad2 = (n) => String(n).padStart(2, '0'); +const popover = Object.assign(document.createElement('div'), { className: 'picker', hidden: true }); +popover.setAttribute('role', 'dialog'); +document.body.appendChild(popover); +let pickerInput = null; + +function attachPicker(input, renderFn) { + const open = () => openPicker(input, renderFn); + input.addEventListener('click', open); + input.parentElement.querySelector('.picker-btn').addEventListener('click', (e) => { + e.preventDefault(); + if (pickerInput === input) closePicker(); else { open(); input.focus(); } + }); + input.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePicker(); if (e.key === 'Tab') closePicker(); }); + input.addEventListener('input', () => { if (pickerInput === input) { popover.calMonth = null; renderFn(input); } }); +} + +function openPicker(input, renderFn) { + pickerInput = input; + popover.calMonth = null; + renderFn(input); + popover.hidden = false; + placePicker(); +} + +function closePicker() { + popover.hidden = true; + pickerInput = null; +} + +function placePicker() { + if (!pickerInput) return; + const r = pickerInput.getBoundingClientRect(); + const w = popover.offsetWidth; + const left = Math.max(8, Math.min(r.left, document.documentElement.clientWidth - w - 8)); + popover.style.left = `${left + scrollX}px`; + popover.style.top = `${r.bottom + scrollY + 6}px`; +} + +function pick(value) { + pickerInput.value = value; + pickerInput.dispatchEvent(new Event('change', { bubbles: true })); + pickerInput.focus(); + closePicker(); +} + +function renderCalendar(input) { + const selected = frToIso(input.value); + const todayIso = new Date().toLocaleDateString('sv-SE'); // AAAA-MM-JJ en heure locale + if (!popover.calMonth) { + const base = selected || state?.dueDate || todayIso; + popover.calMonth = { y: Number(base.slice(0, 4)), m: Number(base.slice(5, 7)) - 1 }; + } + const { y, m } = popover.calMonth; + const first = (new Date(y, m, 1).getDay() + 6) % 7; // lundi = 0 + const days = new Date(y, m + 1, 0).getDate(); + let cells = ''.repeat(first); + for (let d = 1; d <= days; d++) { + const iso = `${y}-${pad2(m + 1)}-${pad2(d)}`; + const cls = [iso === selected && 'sel', iso === todayIso && 'today', iso === state?.dueDate && 'due'].filter(Boolean).join(' '); + cells += ``; + } + popover.innerHTML = ` +
+ + ${MONTHS[m]} ${y} + +
+
${['lu', 'ma', 'me', 'je', 've', 'sa', 'di'].map((d) => `${d}`).join('')}
+
${cells}
+ ${state?.dueDate ? '
terme prévu
' : ''}`; + popover.querySelectorAll('[data-nav]').forEach((b) => { + b.onclick = () => { + const d = new Date(y, m + Number(b.dataset.nav), 1); + popover.calMonth = { y: d.getFullYear(), m: d.getMonth() }; + renderCalendar(input); + }; + }); + popover.querySelectorAll('[data-iso]').forEach((b) => { b.onclick = () => pick(isoToFr(b.dataset.iso)); }); +} + +function renderClock(input) { + const cur = normalizeTime(input.value); + const [h, min] = cur ? cur.split(':') : [null, null]; + const hours = Array.from({ length: 24 }, (_, i) => pad2(i)); + const mins = Array.from({ length: 12 }, (_, i) => pad2(i * 5)); + popover.innerHTML = ` +
Heure
+
${hours.map((x) => ``).join('')}
+
Minutes
+
${mins.map((x) => ``).join('')}
`; + popover.querySelectorAll('[data-h]').forEach((b) => { + b.onclick = () => { input.value = `${b.dataset.h}:${min || '00'}`; renderClock(input); input.focus(); }; + }); + popover.querySelectorAll('[data-m]').forEach((b) => { b.onclick = () => pick(`${h || '12'}:${b.dataset.m}`); }); +} + +// Les clics dans le sélecteur ne doivent pas faire perdre le focus au champ +popover.addEventListener('mousedown', (e) => e.preventDefault()); +document.addEventListener('mousedown', (e) => { + if (pickerInput && !popover.contains(e.target) && !pickerInput.parentElement.contains(e.target)) closePicker(); +}); +document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePicker(); }); +addEventListener('resize', placePicker); + +function readForm(form) { + const fd = new FormData(form); + return { + birth_date: frToIso(fd.get('birth_date')), + birth_time: normalizeTime(fd.get('birth_time')), + sex: fd.get('sex'), + weight_g: Number(String(fd.get('weight_g')).replace(/\s/g, '')), + length_cm: Number(String(fd.get('length_cm')).replace(/\s/g, '').replace(',', '.')), + first_name: String(fd.get('first_name') || '').trim(), + }; +} + +// Vérifie les saisies et affiche les erreurs sous les champs concernés. +function checkForm(form, data) { + const errors = { + birth_date: !data.birth_date && 'Date invalide : format jj/mm/aaaa', + birth_time: !data.birth_time && 'Heure invalide : format hh:mm', + sex: !data.sex && 'Choisis fille ou garçon 😉', + weight_g: !(Number.isInteger(data.weight_g) && data.weight_g >= 500 && data.weight_g <= 6500) && 'Entre 500 et 6500 g, sans virgule', + length_cm: !(data.length_cm >= 25 && data.length_cm <= 65) && 'Entre 25 et 65 cm', + first_name: !data.first_name && 'Propose un prénom', + }; + let first = null; + form.querySelectorAll('.field[data-field]').forEach((f) => { + const msg = errors[f.dataset.field]; + const p = f.querySelector('.field-error'); + f.classList.toggle('invalid', !!msg); + p.hidden = !msg; + p.textContent = msg || ''; + f.querySelector('input')?.setAttribute('aria-invalid', msg ? 'true' : 'false'); + if (msg && !first) first = f; + }); + first?.querySelector('input:not([type=radio])')?.focus(); + return !first; +} + +// Efface l'erreur d'un champ dès qu'on le corrige. +function clearErrorsOnEdit(form) { + const clear = (e) => { + const f = e.target.closest('.field.invalid'); + if (!f) return; + f.classList.remove('invalid'); + f.querySelector('.field-error').hidden = true; + e.target.removeAttribute('aria-invalid'); + }; + form.addEventListener('input', clear); + form.addEventListener('change', clear); +} + +function predTags(p) { + const s = SEX[p.sex]; + return `
+ ${s.emoji} ${s.label} + 📅 ${esc(fmtShortDate(p.birth_date))} + 🕐 ${esc(fmtTime(p.birth_time))} + ⚖️ ${esc(fmtWeight(p.weight_g))} + 📏 ${esc(fmtLength(p.length_cm))} + ✨ ${esc(p.first_name)} +
`; +} + +function renderMine() { + const el = $('#mine'); + if (!me.user) { + el.innerHTML = `
+

Envie de jouer ? 🎲

+

Connecte-toi avec ton compte Google pour enregistrer ton pronostic.

+ ${googleIcon} Se connecter avec Google +
`; + return; + } + const mine = state.predictions.find((p) => p.mine); + if (state.locked) { + el.innerHTML = `

Mon pronostic

${ + mine ? predTags(mine) : '

Tu n\'as pas fait de pronostic… et les jeux sont faits ! 🙈

'}
`; + return; + } + if (mine && !editing) { + el.innerHTML = `

Mon pronostic ✅

${predTags(mine)} +
+ + + Tu peux le modifier jusqu'à la clôture des pronostics. +
`; + $('#edit').onclick = () => { editing = true; renderMine(); }; + $('#delete').onclick = async () => { + if (!confirm('Supprimer ton pronostic ?')) return; + await run(() => api('DELETE', '/api/predictions/me'), 'Pronostic supprimé'); + }; + return; + } + el.innerHTML = `

${mine ? 'Modifier mon pronostic' : 'Mon pronostic 🔮'}

${predictionForm(mine)}
`; + enhanceInputs(el); + if ($('#pred-form')) clearErrorsOnEdit($('#pred-form')); + $('#cancel-edit')?.addEventListener('click', () => { editing = false; renderMine(); }); + $('#pred-form').onsubmit = async (e) => { + e.preventDefault(); + const data = readForm(e.target); + if (!checkForm(e.target, data)) return; + const ok = await run(() => api('PUT', '/api/predictions/me', data), mine ? 'Pronostic mis à jour 💾' : 'Pronostic enregistré, bonne chance ! 🍀'); + if (ok) { editing = false; if (!mine) confetti(); renderAll(); } + }; +} + +const TIME_TRENDS = { + night: '🌙 plutôt la nuit', + morning: '🌅 plutôt le matin', + afternoon: '☀️ plutôt l\'après-midi', + evening: '🌆 plutôt le soir', + any: '🕐 à toute heure', +}; +const SEX_TRENDS = { F: '👧 plutôt fille', M: '👦 plutôt garçon', split: '👧👦 avis partagés' }; + +function renderAll() { + if (state.result) return renderRanking(); + const { count, trends: t, minForTrends } = state; + let body; + if (!count) { + body = `

Personne n'a encore joué. Sois le premier ou la première ! 🚀

`; + } else if (!t) { + const missing = minForTrends - count; + body = ` +

🎯 ${count} pronostic${count > 1 ? 's' : ''}

+

Les pronostics restent secrets jusqu'à la naissance 🤫
+ Les tendances apparaîtront à partir de ${minForTrends} pronostics (encore ${missing}).

+
`; + } else { + body = ` +

🎯 ${count} pronostics

+
+ ${SEX_TRENDS[t.sex]} + 📅 semaine du ${esc(fmtDate(t.week, { day: 'numeric', month: 'long' }))} + ${TIME_TRENDS[t.time]} + ⚖️ environ ${(t.weight_g / 1000).toLocaleString('fr-FR')} kg + 📏 environ ${esc(fmtLength(t.length_cm))} +
+

Tendances arrondies pour garder le suspense : les pronostics restent secrets jusqu'à la naissance 🤫

`; + } + const admin = me.isAdmin && state.participants?.length ? ` +
+

🛠️ Participants (visible par les administrateurs uniquement, sans les pronostics)

+
+ ${state.participants.map((p) => ` + ${esc(p.display_name)} + + `).join('')} +
` : ''; + $('#all').innerHTML = `

🔮 Les tendances

${body}${admin}
`; + bindDeleteButtons(); +} + +function renderRanking() { + const preds = state.predictions; + if (!preds.length) return ($('#all').innerHTML = ''); + const labels = { date: '📅 Jour', time: '🕐 Heure', sex: '👶 Sexe', weight: '⚖️ Poids', length: '📏 Taille', name: '✨ Prénom' }; + $('#all').innerHTML = `
+

📊 Classement

+
+ ${preds.map((p) => ` +
+ ${p.rank <= 3 ? `
${['🥇', '🥈', '🥉'][p.rank - 1]}
` : `
${p.rank}
`} +
+
${esc(p.display_name)}${p.mine ? ' (moi)' : ''}
+ ${predTags(p)} +
+
${fmtPts(p.score.total)}points
+
${Object.entries(p.score.details) + .map(([k, v]) => `${labels[k]} ${fmtPts(v)}/${state.maxPoints[k]}`).join('')}
+
`).join('')} +
`; +} + +function bindDeleteButtons() { + document.querySelectorAll('[data-del]').forEach((b) => { + b.onclick = async () => { + if (!confirm('Supprimer ce pronostic ?')) return; + await run(() => api('DELETE', `/api/admin/predictions/${encodeURIComponent(b.dataset.del)}`), 'Pronostic supprimé'); + }; + }); +} + +function renderRules() { + const m = state.maxPoints; + $('#rules').innerHTML = `
+

🧮 Comment sont comptés les points ?

+
+
📅 Jour — ${m.date} pts
−3 pts par jour d'écart
+
🕐 Heure — ${m.time} pts
Dégressif, 0 pt à 6 h d'écart
+
👶 Sexe — ${m.sex} pts
Tout ou rien !
+
⚖️ Poids — ${m.weight} pts
−1,5 pt par 100 g d'écart
+
📏 Taille — ${m.length} pts
Dégressif, 0 pt à 8 cm d'écart
+
✨ Prénom — ${m.name} pts
Exact = ${m.name} pts, même initiale = 3 pts
+
+

Total sur 100. En cas d'égalité, c'est le pronostic enregistré (ou modifié pour la dernière fois) le plus tôt qui l'emporte.

+
`; +} + +function renderAdmin() { + if (!me.isAdmin) return ($('#admin').innerHTML = ''); + const r = state.result; + $('#admin').innerHTML = `
+

🛠️ Administration

+
+
${field({ id: 'due', name: 'due', emoji: '🗓️', label: 'Date du terme prévu', + control: pickerControl('due', 'due', 'calendar', isoToFr(state.dueDate)) })}
+ + +
+
+

👶 Le bébé est né ! Saisir les vraies informations

+

L'enregistrement clôture automatiquement les pronostics et affiche le classement.

+
${predictionForm(r, 'admin-result-form')}
+ ${r ? '
' : ''} +
`; + + // Le formulaire partagé affiche "Annuler" en mode édition : inutile ici. + $('#result-form #cancel-edit')?.remove(); + $('#result-form button[type=submit]').textContent = r ? '💾 Mettre à jour le résultat' : '🎉 Annoncer la naissance'; + + enhanceInputs($('#admin')); + clearErrorsOnEdit($('#admin-result-form')); + $('#save-due').onclick = () => { + const raw = $('#due').value.trim(); + const dueDate = raw ? frToIso(raw) : null; + const f = $('#admin [data-field=due]'); + f.classList.toggle('invalid', !!raw && !dueDate); + f.querySelector('.field-error').hidden = !raw || !!dueDate; + f.querySelector('.field-error').textContent = 'Date invalide : format jj/mm/aaaa'; + if (raw && !dueDate) return; + run(() => api('PUT', '/api/admin/settings', { dueDate }), 'Terme enregistré'); + }; + $('#lock').onchange = (e) => run(() => api('PUT', '/api/admin/settings', { locked: e.target.checked }), + e.target.checked ? 'Pronostics clôturés 🔒' : 'Pronostics rouverts 🔓'); + $('#result-form form').onsubmit = async (e) => { + e.preventDefault(); + const data = readForm(e.target); + if (!checkForm(e.target, data)) return; + if (!r && !confirm('Annoncer la naissance et calculer le classement ?')) return; + if (await run(() => api('PUT', '/api/admin/result', data), 'Résultat enregistré 🎉')) { + confetti(); + scrollTo({ top: 0, behavior: 'smooth' }); + } + }; + $('#clear-result')?.addEventListener('click', () => { + if (confirm('Effacer le résultat ?')) run(() => api('DELETE', '/api/admin/result'), 'Résultat effacé'); + }); +} + +function render() { + closePicker(); + renderAccount(); + renderHero(); + renderPodium(); + renderMine(); + renderAdmin(); + renderAll(); + renderRules(); +} + +async function load() { + [me, state] = await Promise.all([api('GET', '/api/me'), api('GET', '/api/state')]); + render(); +} + +async function run(fn, successMsg) { + try { + await fn(); + await load(); + flash(successMsg); + return true; + } catch (err) { + flash(err.message, true); + return false; + } +} + +function confetti() { + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; + const colors = ['#ff8fb1', '#6cb8ff', '#ffd166', '#5fd3a8', '#9b7bff']; + for (let i = 0; i < 120; i++) { + const c = document.createElement('div'); + c.className = 'confetti'; + c.style.left = `${Math.random() * 100}vw`; + c.style.background = colors[i % colors.length]; + c.style.animationDuration = `${2 + Math.random() * 2.5}s`; + c.style.animationDelay = `${Math.random() * 0.6}s`; + document.body.appendChild(c); + setTimeout(() => c.remove(), 5500); + } +} + +if (new URLSearchParams(location.search).get('erreur') === 'connexion') { + history.replaceState(null, '', '/'); + setTimeout(() => flash('La connexion Google a échoué, réessaie 🙏', true)); +} + +load().then(() => { if (state.result && !sessionStorage.getItem('party')) { sessionStorage.setItem('party', 1); confetti(); } }) + .catch((err) => flash(err.message, true)); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..237cc47 --- /dev/null +++ b/public/index.html @@ -0,0 +1,37 @@ + + + + + + Le bébé de Jerry arrive ! 🍼 + + + + + + + + + +
+ 🍼 Naissance +
+
+ +
+
+ +
+
+
+
+
+
+ +
Fait avec 💛 pour Jerry et sa famille
+ + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..828347d --- /dev/null +++ b/public/style.css @@ -0,0 +1,234 @@ +:root { + --bg: #fff9f0; + --ink: #3b3355; + --muted: #7d7596; + --card: #ffffff; + --pink: #ff8fb1; + --pink-soft: #ffe3ec; + --blue: #6cb8ff; + --blue-soft: #e1f0ff; + --yellow: #ffd166; + --yellow-soft: #fff3cf; + --mint: #5fd3a8; + --mint-soft: #ddf7ec; + --violet: #9b7bff; + --radius: 22px; + --shadow: 0 10px 30px rgba(91, 70, 140, .10), 0 2px 6px rgba(91, 70, 140, .06); +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + font-family: 'Nunito', system-ui, sans-serif; + color: var(--ink); + background: + radial-gradient(circle at 10% 0%, var(--pink-soft) 0, transparent 40%), + radial-gradient(circle at 90% 10%, var(--blue-soft) 0, transparent 40%), + radial-gradient(circle at 50% 100%, var(--yellow-soft) 0, transparent 50%), + var(--bg); + background-attachment: fixed; + min-height: 100vh; + line-height: 1.5; +} +h1, h2, h3, .brand { font-family: 'Fredoka', 'Nunito', sans-serif; } +h2 { margin: 0 0 .8rem; font-size: 1.5rem; } +a { color: var(--violet); } + +/* Décor flottant */ +.sky { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; } +.sky span { position: absolute; font-size: 2rem; opacity: .55; animation: float 9s ease-in-out infinite; } +.sky span:nth-child(1) { left: 4%; top: 18%; } +.sky span:nth-child(2) { left: 88%; top: 12%; animation-delay: -2s; font-size: 1.4rem; } +.sky span:nth-child(3) { left: 75%; top: 55%; animation-delay: -4s; font-size: 2.6rem; } +.sky span:nth-child(4) { left: 93%; top: 78%; animation-delay: -1s; } +.sky span:nth-child(5) { left: 8%; top: 70%; animation-delay: -3s; font-size: 1.3rem; } +.sky span:nth-child(6) { left: 20%; top: 40%; animation-delay: -6s; font-size: 2.2rem; } +@keyframes float { 0%, 100% { transform: translateY(0) rotate(-4deg); } 50% { transform: translateY(-18px) rotate(4deg); } } +@media (prefers-reduced-motion: reduce) { .sky span { animation: none; } } + +.topbar, main, footer { position: relative; z-index: 1; } +.topbar { + display: flex; justify-content: space-between; align-items: center; gap: 1rem; + max-width: 1000px; margin: 0 auto; padding: 1rem 1.25rem; +} +.brand { font-size: 1.5rem; text-decoration: none; color: var(--ink); font-weight: 700; } +.brand span { background: linear-gradient(90deg, var(--pink), var(--violet), var(--blue)); -webkit-background-clip: text; background-clip: text; color: transparent; } + +.user-chip { display: flex; align-items: center; gap: .6rem; background: var(--card); padding: .35rem .5rem .35rem .4rem; border-radius: 999px; box-shadow: var(--shadow); font-weight: 700; } +.user-chip img, .avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; background: var(--yellow-soft); display: inline-grid; place-items: center; font-size: 1rem; flex: none; } + +main { max-width: 1000px; margin: 0 auto; padding: 0 1.25rem 2rem; display: grid; gap: 1.5rem; } +main > section:empty { display: none; } + +.card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.5rem; } + +/* Hero */ +.hero { text-align: center; padding: 2rem 1rem 1rem; } +.hero h1 { font-size: clamp(2rem, 6vw, 3.4rem); margin: 0 0 .4rem; line-height: 1.1; } +.hero h1 .wiggle { display: inline-block; animation: wiggle 2.4s ease-in-out infinite; } +@keyframes wiggle { 0%, 100% { transform: rotate(0) } 10% { transform: rotate(-12deg) } 20% { transform: rotate(10deg) } 30% { transform: rotate(0) } } +.hero p { margin: 0 auto; color: var(--muted); font-size: 1.15rem; max-width: 600px; } +.countdown { display: inline-flex; gap: .75rem; margin-top: 1.25rem; flex-wrap: wrap; justify-content: center; } +.pill { display: inline-flex; align-items: center; gap: .4rem; padding: .45rem 1rem; border-radius: 999px; font-weight: 800; background: var(--card); box-shadow: var(--shadow); } +.pill.pink { background: var(--pink-soft); } .pill.blue { background: var(--blue-soft); } .pill.yellow { background: var(--yellow-soft); } .pill.mint { background: var(--mint-soft); } + +.announce { margin: 1.25rem auto 0; max-width: 680px; padding: 1.5rem; border-radius: var(--radius); background: linear-gradient(135deg, var(--pink-soft), var(--yellow-soft), var(--blue-soft)); box-shadow: var(--shadow); } +.announce .big { font-family: 'Fredoka', sans-serif; font-size: clamp(1.6rem, 5vw, 2.4rem); font-weight: 700; margin: 0 0 .5rem; } +.facts { display: flex; flex-wrap: wrap; gap: .6rem; justify-content: center; } + +/* Boutons */ +.btn { + appearance: none; border: 0; cursor: pointer; font: inherit; font-weight: 800; + padding: .75rem 1.3rem; border-radius: 999px; color: #fff; + background: linear-gradient(135deg, var(--pink), var(--violet)); + box-shadow: 0 6px 16px rgba(155, 123, 255, .35); + transition: transform .15s ease, box-shadow .15s ease; text-decoration: none; display: inline-flex; align-items: center; gap: .5rem; +} +.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 22px rgba(155, 123, 255, .4); } +.btn:active { transform: translateY(0); } +.btn.ghost { background: var(--card); color: var(--ink); box-shadow: inset 0 0 0 2px #ece7f7; } +.btn.small { padding: .4rem .9rem; font-size: .9rem; } +.btn.danger { background: #ff7a7a; box-shadow: 0 6px 16px rgba(255, 122, 122, .35); } +.btn.google { background: #fff; color: var(--ink); box-shadow: var(--shadow); } +.btn.google svg { width: 20px; height: 20px; } + +/* Formulaire */ +:root { --control-h: 3rem; --line: #e7e1f5; } +.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.1rem 1rem; align-items: start; } +@media (max-width: 760px) { .form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 480px) { .form-grid { grid-template-columns: 1fr; } } + +.field { display: flex; flex-direction: column; gap: .4rem; min-width: 0; } +.field-label { display: flex; align-items: center; gap: .45rem; font-weight: 800; font-size: .98rem; line-height: 1.2; } +.field-error { margin: 0; color: #d64545; font-size: .85rem; font-weight: 700; } + +.control { position: relative; display: flex; align-items: center; } +.control input { + font: inherit; font-size: 1rem; font-weight: 700; color: var(--ink); + width: 100%; height: var(--control-h); padding: 0 .95rem; + border-radius: 14px; border: 2px solid var(--line); background: #fcfbff; + transition: border-color .15s, box-shadow .15s, background .15s; +} +.control input::placeholder { color: #b3abc8; font-weight: 600; } +.control input:hover { border-color: #d9d0f0; } +.control input:focus { outline: none; border-color: var(--violet); background: #fff; box-shadow: 0 0 0 4px rgba(155, 123, 255, .18); } +.has-btn input, .has-suffix input { padding-right: 3rem; } + +.picker-btn { + position: absolute; right: .4rem; top: 50%; transform: translateY(-50%); + width: 2.2rem; height: 2.2rem; padding: 0; border: 0; border-radius: 10px; + display: grid; place-items: center; cursor: pointer; + background: transparent; color: var(--muted); transition: background .15s, color .15s; +} +.picker-btn svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; display: block; } +.picker-btn:hover, .control:focus-within .picker-btn { color: var(--violet); } +.picker-btn:hover { background: var(--pink-soft); } + +.suffix { position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); color: var(--muted); font-weight: 800; pointer-events: none; } + +.sex-choice { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; } +.sex-choice input { position: absolute; opacity: 0; pointer-events: none; } +.sex-choice label { + height: var(--control-h); display: flex; align-items: center; justify-content: center; gap: .4rem; + border-radius: 14px; border: 2px solid var(--line); background: #fcfbff; + cursor: pointer; font-weight: 800; transition: all .15s; white-space: nowrap; +} +.sex-choice label:hover { border-color: #d9d0f0; } +.sex-choice input:focus-visible + label { box-shadow: 0 0 0 4px rgba(155, 123, 255, .25); } +.sex-choice input[value=F]:checked + label { background: var(--pink-soft); border-color: var(--pink); } +.sex-choice input[value=M]:checked + label { background: var(--blue-soft); border-color: var(--blue); } + +.field.invalid .control input, .field.invalid .sex-choice label { border-color: #ff8a8a; background: #fff6f6; } +.field.invalid .control input:focus { box-shadow: 0 0 0 4px rgba(255, 122, 122, .2); } + +.actions { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: 1.4rem; align-items: center; } +.hint { color: var(--muted); font-size: .95rem; } + +/* Ligne admin : le bouton et la case s'alignent sur le champ, même si une erreur s'affiche dessous */ +.admin-row { display: flex; flex-wrap: wrap; gap: .75rem 1rem; align-items: flex-start; } +.admin-row .due-field { width: 240px; max-width: 100%; } +.admin-row > .btn, .admin-row > .switch { height: var(--control-h); margin-top: calc(1.2em + .4rem); } + +/* Flash */ +.flash { padding: .9rem 1.2rem; border-radius: 16px; font-weight: 800; background: var(--mint-soft); box-shadow: var(--shadow); } +.flash.error { background: #ffe1e1; } + +/* Liste des pronostics */ +.stats { display: flex; flex-wrap: wrap; gap: .6rem; margin-bottom: 1rem; } +.big-count { font-family: 'Fredoka', sans-serif; font-size: 1.6rem; font-weight: 700; margin: 0 0 .6rem; } +.progress { height: 12px; border-radius: 999px; background: #f1edf9; overflow: hidden; margin-top: .8rem; } +.progress div { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--pink), var(--violet), var(--blue)); } +.participants { display: flex; flex-wrap: wrap; gap: .5rem; } +.participant { display: inline-flex; align-items: center; gap: .4rem; padding: .3rem .4rem .3rem .9rem; border-radius: 999px; background: #fcfbff; border: 2px solid #f1edf9; font-weight: 700; } +.icon-del { border: 0; background: transparent; cursor: pointer; width: 1.7rem; height: 1.7rem; border-radius: 50%; color: var(--muted); font-weight: 800; display: grid; place-items: center; } +.icon-del:hover { background: #ffe1e1; color: #d64545; } +.preds { display: grid; gap: .75rem; } +.pred { display: grid; grid-template-columns: auto 1fr auto; gap: .9rem; align-items: center; padding: .9rem 1rem; border-radius: 18px; background: #fcfbff; border: 2px solid #f1edf9; } +.pred.mine { border-color: var(--yellow); background: var(--yellow-soft); } +.pred .who { font-weight: 800; } +.pred .tags { display: flex; flex-wrap: wrap; gap: .35rem .5rem; margin-top: .25rem; font-size: .92rem; color: var(--muted); } +.pred .tags b { color: var(--ink); } +.pred .score { font-family: 'Fredoka', sans-serif; font-size: 1.5rem; font-weight: 700; text-align: right; } +.pred .score small { display: block; font-size: .75rem; color: var(--muted); font-family: 'Nunito', sans-serif; } +.rank { width: 2.6rem; height: 2.6rem; display: grid; place-items: center; border-radius: 50%; background: #f1edf9; color: var(--muted); font-family: 'Fredoka', sans-serif; font-weight: 700; font-size: 1.25rem; } +.rank.medal { background: transparent; font-size: 2.2rem; } +.breakdown { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: .4rem; font-size: .85rem; } +.breakdown span { background: #fff; border-radius: 999px; padding: .15rem .6rem; border: 1px solid #ece7f7; } + +/* Podium */ +.podium { display: grid; grid-template-columns: 1fr 1fr 1fr; align-items: end; gap: .75rem; max-width: 640px; margin: 0 auto; text-align: center; } +.podium .step { border-radius: 18px 18px 8px 8px; padding: 1rem .5rem; box-shadow: var(--shadow); } +.podium .step .medal { font-size: 2.6rem; line-height: 1.2; } +.podium .step .avatar { margin: .2rem auto; width: 44px; height: 44px; } +.podium .step .name { font-weight: 800; font-size: 1.1rem; } +.podium .p1 { background: var(--yellow-soft); min-height: 210px; order: 2; } +.podium .p2 { background: var(--blue-soft); min-height: 170px; order: 1; } +.podium .p3 { background: var(--pink-soft); min-height: 140px; order: 3; } + +/* Barème */ +.rules { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .75rem; } +.rule { padding: .9rem 1rem; border-radius: 16px; background: #fcfbff; border: 2px solid #f1edf9; } +.rule b { font-family: 'Fredoka', sans-serif; font-size: 1.1rem; } + +.admin-card { border: 3px dashed var(--violet); } +.switch { display: inline-flex; align-items: center; gap: .6rem; font-weight: 800; cursor: pointer; } +.switch input { width: 20px; height: 20px; accent-color: var(--violet); } +hr { border: 0; border-top: 2px dashed #ece7f7; margin: 1.5rem 0; } + +footer { text-align: center; color: var(--muted); padding: 2rem 1rem 3rem; font-weight: 700; } + +/* Confettis */ +.confetti { position: fixed; top: -20px; width: 10px; height: 14px; z-index: 50; pointer-events: none; border-radius: 3px; animation: fall linear forwards; } +@keyframes fall { to { transform: translateY(110vh) rotate(720deg); } } + +@media (max-width: 560px) { + .pred { grid-template-columns: auto 1fr; } + .pred .score { grid-column: 1 / -1; text-align: left; } + .user-chip .hello { display: none; } +} + +/* Sélecteurs de date et d'heure */ +.picker { + position: absolute; z-index: 40; width: 300px; max-width: calc(100vw - 16px); + background: var(--card); border-radius: 18px; padding: .8rem; + box-shadow: 0 16px 40px rgba(91, 70, 140, .22); border: 2px solid #f1edf9; +} +.picker[hidden] { display: none; } +.picker button { font: inherit; cursor: pointer; border: 0; background: transparent; color: var(--ink); border-radius: 10px; } +.picker-head { display: flex; align-items: center; justify-content: space-between; margin: .1rem 0 .5rem; } +.picker-head span { font-family: 'Fredoka', sans-serif; font-weight: 600; font-size: 1.05rem; text-transform: capitalize; flex: 1; text-align: center; } +.picker-head button { width: 2rem; height: 2rem; font-size: 1.3rem; font-weight: 800; } +.picker-head button:hover { background: var(--blue-soft); } +.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; text-align: center; } +.cal-dow span { font-size: .8rem; font-weight: 800; color: var(--muted); padding-bottom: .2rem; } +.cal-grid button { aspect-ratio: 1; font-weight: 700; } +.cal-grid button:hover { background: var(--pink-soft); } +.cal-grid button.today { box-shadow: inset 0 0 0 2px var(--blue); } +.cal-grid button.due { background: var(--yellow-soft); } +.picker-legend { margin-top: .5rem; font-size: .8rem; color: var(--muted); display: flex; align-items: center; gap: .4rem; } +.picker-legend i { width: 14px; height: 14px; border-radius: 4px; background: var(--yellow-soft); border: 1px solid var(--yellow); } +.clock-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 3px; margin-bottom: .6rem; } +.clock-grid button { padding: .4rem 0; font-weight: 700; font-size: .92rem; } +.clock-grid button:hover { background: var(--mint-soft); } +.picker button.sel, .picker button.sel:hover { background: linear-gradient(135deg, var(--pink), var(--violet)); color: #fff; } diff --git a/server.js b/server.js new file mode 100644 index 0000000..ada9725 --- /dev/null +++ b/server.js @@ -0,0 +1,256 @@ +const path = require('node:path'); +const crypto = require('node:crypto'); +const express = require('express'); +const cookieSession = require('cookie-session'); +const { db, getSetting, setSetting } = require('./src/db'); +const { leaderboard, MAX } = require('./src/scoring'); +const { trends } = require('./src/trends'); + +const { + PORT = 3000, + BASE_URL = `http://localhost:${PORT}`, + GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET, + SESSION_SECRET, + ADMIN_EMAILS = '', + DEV_LOGIN, + STATS_MIN_PREDICTIONS = 5, +} = process.env; + +const devLogin = DEV_LOGIN === 'true'; +if (!SESSION_SECRET && !devLogin) { + console.error('SESSION_SECRET est obligatoire (ex : openssl rand -hex 32)'); + process.exit(1); +} +if ((!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) && !devLogin) { + console.error('GOOGLE_CLIENT_ID et GOOGLE_CLIENT_SECRET sont obligatoires'); + process.exit(1); +} + +const admins = new Set(ADMIN_EMAILS.split(',').map((e) => e.trim().toLowerCase()).filter(Boolean)); +const isAdmin = (user) => !!user && admins.has(user.email.toLowerCase()); + +const app = express(); +app.set('trust proxy', 1); +app.use(express.json({ limit: '10kb' })); +app.use(cookieSession({ + name: 'naissance', + secret: SESSION_SECRET || 'dev-secret', + maxAge: 60 * 24 * 3600 * 1000, + sameSite: 'lax', + httpOnly: true, + secure: BASE_URL.startsWith('https://'), +})); + +app.use((req, res, next) => { + const uid = req.session?.uid; + req.user = uid ? db.prepare('SELECT * FROM users WHERE id = ?').get(uid) : null; + next(); +}); + +const requireUser = (req, res, next) => (req.user ? next() : res.status(401).json({ error: 'Connexion requise' })); +const requireAdmin = (req, res, next) => (isAdmin(req.user) ? next() : res.status(403).json({ error: 'Réservé aux administrateurs' })); + +// ---------- Authentification Google (OAuth 2.0 / OpenID Connect) ---------- + +const redirectUri = `${BASE_URL}/auth/google/callback`; + +function upsertUser({ sub, email, given_name, family_name, picture }) { + db.prepare(` + INSERT INTO users (id, email, given_name, family_name, picture) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET email = excluded.email, given_name = excluded.given_name, + family_name = excluded.family_name, picture = excluded.picture + `).run(sub, email, given_name || email.split('@')[0], family_name || null, picture || null); +} + +app.get('/auth/google', (req, res) => { + if (devLogin && !GOOGLE_CLIENT_ID) return res.redirect('/auth/dev'); + const state = crypto.randomBytes(16).toString('hex'); + req.session.oauthState = state; + const params = new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + state, + prompt: 'select_account', + }); + res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`); +}); + +app.get('/auth/google/callback', async (req, res) => { + const { code, state } = req.query; + const expected = req.session.oauthState; + req.session.oauthState = null; + if (!code || !state || state !== expected) return res.redirect('/?erreur=connexion'); + try { + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET, + redirect_uri: redirectUri, grant_type: 'authorization_code', + }), + }); + if (!tokenRes.ok) throw new Error(`token ${tokenRes.status}`); + const { access_token } = await tokenRes.json(); + const infoRes = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { + headers: { Authorization: `Bearer ${access_token}` }, + }); + if (!infoRes.ok) throw new Error(`userinfo ${infoRes.status}`); + const info = await infoRes.json(); + if (!info.sub || !info.email || !info.email_verified) throw new Error('email non vérifié'); + upsertUser(info); + req.session.uid = info.sub; + res.redirect('/'); + } catch (err) { + console.error('Échec de la connexion Google :', err.message); + res.redirect('/?erreur=connexion'); + } +}); + +if (devLogin) { + // Connexion factice pour le développement local uniquement (DEV_LOGIN=true). + app.get('/auth/dev', (req, res) => { + const name = String(req.query.name || 'Testeur').slice(0, 30); + const email = String(req.query.email || `${name.toLowerCase()}@example.com`); + upsertUser({ sub: `dev-${email}`, email, given_name: name }); + req.session.uid = `dev-${email}`; + res.redirect('/'); + }); +} + +app.post('/auth/logout', (req, res) => { + req.session = null; + res.json({ ok: true }); +}); + +// ---------- Validation ---------- + +function validatePrediction(b) { + const errors = []; + const out = {}; + const d = /^\d{4}-\d{2}-\d{2}$/.test(b.birth_date || '') && new Date(`${b.birth_date}T00:00:00Z`); + if (!d || Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== b.birth_date) errors.push('Jour invalide'); + else out.birth_date = b.birth_date; + const t = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(b.birth_time || ''); + if (!t) errors.push('Heure invalide'); + else out.birth_time = b.birth_time; + if (!['F', 'M'].includes(b.sex)) errors.push('Sexe invalide'); + else out.sex = b.sex; + const w = Number(b.weight_g); + if (!Number.isInteger(w) || w < 500 || w > 6500) errors.push('Poids invalide (entre 500 et 6500 g)'); + else out.weight_g = w; + const l = Number(b.length_cm); + if (!Number.isFinite(l) || l < 25 || l > 65) errors.push('Taille invalide (entre 25 et 65 cm)'); + else out.length_cm = Math.round(l * 10) / 10; + const n = String(b.first_name || '').trim().replace(/\s+/g, ' '); + if (n.length < 1 || n.length > 40) errors.push('Prénom invalide'); + else out.first_name = n; + return { errors, value: out }; +} + +// ---------- API ---------- + +function displayNames() { + const users = db.prepare('SELECT id, given_name, family_name FROM users').all(); + const counts = {}; + for (const u of users) counts[u.given_name.toLowerCase()] = (counts[u.given_name.toLowerCase()] || 0) + 1; + const map = {}; + for (const u of users) { + const dup = counts[u.given_name.toLowerCase()] > 1 && u.family_name; + map[u.id] = dup ? `${u.given_name} ${u.family_name[0]}.` : u.given_name; + } + return map; +} + +app.get('/api/me', (req, res) => { + const u = req.user; + res.json({ + user: u ? { id: u.id, firstName: u.given_name, picture: u.picture, email: u.email } : null, + isAdmin: isAdmin(u), + }); +}); + +app.get('/api/state', (req, res) => { + const result = getSetting('result'); + const names = displayNames(); + const rows = db.prepare(` + SELECT p.*, u.picture FROM predictions p JOIN users u ON u.id = p.user_id ORDER BY p.created_at + `).all().map((p) => ({ ...p, display_name: names[p.user_id], mine: req.user?.id === p.user_id })); + const base = { + locked: !!getSetting('locked') || !!result, + dueDate: getSetting('dueDate'), + result, + maxPoints: MAX, + count: rows.length, + }; + if (result) return res.json({ ...base, predictions: leaderboard(rows, result) }); + + // Avant la naissance, personne ne voit les pronostics des autres : seulement le sien, + // le nombre de participants et, s'il y en a assez, des tendances arrondies. + const minForTrends = Number(STATS_MIN_PREDICTIONS); + res.json({ + ...base, + predictions: rows.filter((p) => p.mine), + trends: rows.length >= minForTrends ? trends(rows) : null, + minForTrends, + // Les administrateurs voient qui a participé (sans les valeurs) pour pouvoir modérer. + participants: isAdmin(req.user) ? rows.map((p) => ({ user_id: p.user_id, display_name: p.display_name })) : undefined, + }); +}); + +app.put('/api/predictions/me', requireUser, (req, res) => { + if (getSetting('locked') || getSetting('result')) return res.status(409).json({ error: 'Les pronostics sont clôturés' }); + const { errors, value: v } = validatePrediction(req.body || {}); + if (errors.length) return res.status(400).json({ error: errors.join(', ') }); + db.prepare(` + INSERT INTO predictions (user_id, birth_date, birth_time, sex, weight_g, length_cm, first_name) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET birth_date = excluded.birth_date, birth_time = excluded.birth_time, + sex = excluded.sex, weight_g = excluded.weight_g, length_cm = excluded.length_cm, + first_name = excluded.first_name, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + `).run(req.user.id, v.birth_date, v.birth_time, v.sex, v.weight_g, v.length_cm, v.first_name); + res.json({ ok: true }); +}); + +app.delete('/api/predictions/me', requireUser, (req, res) => { + if (getSetting('locked') || getSetting('result')) return res.status(409).json({ error: 'Les pronostics sont clôturés' }); + db.prepare('DELETE FROM predictions WHERE user_id = ?').run(req.user.id); + res.json({ ok: true }); +}); + +// ---------- Administration ---------- + +app.put('/api/admin/settings', requireAdmin, (req, res) => { + const { locked, dueDate } = req.body || {}; + if (locked !== undefined) setSetting('locked', !!locked); + if (dueDate !== undefined) { + if (dueDate && !/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) return res.status(400).json({ error: 'Date de terme invalide' }); + setSetting('dueDate', dueDate || null); + } + res.json({ ok: true }); +}); + +app.put('/api/admin/result', requireAdmin, (req, res) => { + const { errors, value } = validatePrediction(req.body || {}); + if (errors.length) return res.status(400).json({ error: errors.join(', ') }); + setSetting('result', value); + res.json({ ok: true }); +}); + +app.delete('/api/admin/result', requireAdmin, (req, res) => { + setSetting('result', null); + res.json({ ok: true }); +}); + +app.delete('/api/admin/predictions/:userId', requireAdmin, (req, res) => { + db.prepare('DELETE FROM predictions WHERE user_id = ?').run(req.params.userId); + res.json({ ok: true }); +}); + +app.use(express.static(path.join(__dirname, 'public'))); + +app.listen(PORT, () => { + console.log(`🍼 Naissance en écoute sur ${BASE_URL}${devLogin ? ' (DEV_LOGIN actif)' : ''}`); +}); diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..f1198aa --- /dev/null +++ b/src/db.js @@ -0,0 +1,47 @@ +const path = require('node:path'); +const fs = require('node:fs'); +const { DatabaseSync } = require('node:sqlite'); + +const file = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'naissance.db'); +fs.mkdirSync(path.dirname(file), { recursive: true }); + +const db = new DatabaseSync(file); +db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + given_name TEXT NOT NULL, + family_name TEXT, + picture TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + CREATE TABLE IF NOT EXISTS predictions ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + birth_date TEXT NOT NULL, + birth_time TEXT NOT NULL, + sex TEXT NOT NULL CHECK (sex IN ('F','M')), + weight_g INTEGER NOT NULL, + length_cm REAL NOT NULL, + first_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); +`); + +const getSetting = (key) => { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); + return row ? JSON.parse(row.value) : null; +}; +const setSetting = (key, value) => { + if (value === null || value === undefined) db.prepare('DELETE FROM settings WHERE key = ?').run(key); + else db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') + .run(key, JSON.stringify(value)); +}; + +module.exports = { db, getSetting, setSetting }; diff --git a/src/scoring.js b/src/scoring.js new file mode 100644 index 0000000..37cde2c --- /dev/null +++ b/src/scoring.js @@ -0,0 +1,61 @@ +// Barème : 100 points au maximum. +// Jour 30 pts — -3 pts par jour d'écart (0 à partir de 10 jours) +// Heure 15 pts — dégressif jusqu'à 0 à 6 h d'écart (sur 24 h, circulaire) +// Sexe 15 pts — tout ou rien +// Poids 15 pts — dégressif jusqu'à 0 à 1 kg d'écart +// Taille 15 pts — dégressif jusqu'à 0 à 8 cm d'écart +// Prénom 10 pts — exact (sans tenir compte des accents/majuscules), 3 pts si même initiale +// En cas d'égalité, le pronostic dont la dernière modification est la plus ancienne l'emporte. + +const MAX = { date: 30, time: 15, sex: 15, weight: 15, length: 15, name: 10 }; + +const round1 = (n) => Math.round(n * 10) / 10; +const linear = (max, diff, zeroAt) => round1(max * Math.max(0, 1 - diff / zeroAt)); + +function dayNumber(isoDate) { + const [y, m, d] = isoDate.split('-').map(Number); + return Date.UTC(y, m - 1, d) / 86400000; +} + +function minutes(hhmm) { + const [h, m] = hhmm.split(':').map(Number); + return h * 60 + m; +} + +function normalizeName(s) { + return String(s) + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z]/g, ''); +} + +function scorePrediction(p, r) { + const dayDiff = Math.abs(dayNumber(p.birth_date) - dayNumber(r.birth_date)); + const rawMin = Math.abs(minutes(p.birth_time) - minutes(r.birth_time)); + const minDiff = Math.min(rawMin, 1440 - rawMin); + const gDiff = Math.abs(p.weight_g - r.weight_g); + const cmDiff = Math.abs(p.length_cm - r.length_cm); + const pn = normalizeName(p.first_name); + const rn = normalizeName(r.first_name); + + const details = { + date: linear(MAX.date, dayDiff, 10), + time: linear(MAX.time, minDiff, 360), + sex: p.sex === r.sex ? MAX.sex : 0, + weight: linear(MAX.weight, gDiff, 1000), + length: linear(MAX.length, cmDiff, 8), + name: pn && pn === rn ? MAX.name : pn && pn[0] === rn[0] ? 3 : 0, + }; + const total = round1(Object.values(details).reduce((a, b) => a + b, 0)); + return { total, details, gaps: { days: dayDiff, minutes: minDiff, grams: gDiff, cm: round1(cmDiff) } }; +} + +function leaderboard(predictions, result) { + return predictions + .map((p) => ({ ...p, score: scorePrediction(p, result) })) + .sort((a, b) => b.score.total - a.score.total || a.updated_at.localeCompare(b.updated_at)) + .map((p, i) => ({ ...p, rank: i + 1 })); +} + +module.exports = { MAX, scorePrediction, leaderboard, normalizeName }; diff --git a/src/trends.js b/src/trends.js new file mode 100644 index 0000000..8460970 --- /dev/null +++ b/src/trends.js @@ -0,0 +1,51 @@ +// Tendances affichées avant la naissance. Volontairement imprécises : avec peu de participants, +// une moyenne exacte permettrait de deviner les pronostics des autres (ou le dernier saisi). +// Sexe : « plutôt fille », « plutôt garçon » ou « partagé » (pas de pourcentage) +// Jour : la semaine (lundi) autour de laquelle tombe la date moyenne +// Heure : un moment de la journée (nuit, matin, après-midi, soir) +// Poids : arrondi aux 100 g +// Taille : arrondie au centimètre + +function dayNumber(iso) { + const [y, m, d] = iso.split('-').map(Number); + return Date.UTC(y, m - 1, d) / 86400000; +} + +function weekMonday(day) { + const date = new Date(day * 86400000); + const offset = (date.getUTCDay() + 6) % 7; // lundi = 0 + return new Date((day - offset) * 86400000).toISOString().slice(0, 10); +} + +// Moyenne circulaire des heures (23h et 1h donnent minuit, pas midi). +function timeTrend(times) { + let x = 0; + let y = 0; + for (const t of times) { + const [h, m] = t.split(':').map(Number); + const a = ((h * 60 + m) / 1440) * 2 * Math.PI; + x += Math.cos(a); + y += Math.sin(a); + } + if (Math.hypot(x, y) / times.length < 0.3) return 'any'; + const hour = ((Math.atan2(y, x) / (2 * Math.PI)) * 24 + 24) % 24; + if (hour < 6) return 'night'; + if (hour < 12) return 'morning'; + if (hour < 18) return 'afternoon'; + return 'evening'; +} + +function trends(predictions) { + const n = predictions.length; + const avg = (f) => predictions.reduce((a, p) => a + f(p), 0) / n; + const girls = predictions.filter((p) => p.sex === 'F').length / n; + return { + sex: girls >= 0.6 ? 'F' : girls <= 0.4 ? 'M' : 'split', + week: weekMonday(Math.round(avg((p) => dayNumber(p.birth_date)))), + time: timeTrend(predictions.map((p) => p.birth_time)), + weight_g: Math.round(avg((p) => p.weight_g) / 100) * 100, + length_cm: Math.round(avg((p) => p.length_cm)), + }; +} + +module.exports = { trends }; diff --git a/test/scoring.test.js b/test/scoring.test.js new file mode 100644 index 0000000..c1a8f53 --- /dev/null +++ b/test/scoring.test.js @@ -0,0 +1,31 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { scorePrediction, leaderboard } = require('../src/scoring'); + +const result = { birth_date: '2026-11-20', birth_time: '03:30', sex: 'F', weight_g: 3400, length_cm: 50, first_name: 'Léa' }; + +test('pronostic parfait = 100 points', () => { + assert.strictEqual(scorePrediction({ ...result, first_name: 'lea' }, result).total, 100); +}); + +test('écarts dégressifs', () => { + const s = scorePrediction({ birth_date: '2026-11-22', birth_time: '06:30', sex: 'M', weight_g: 3900, length_cm: 52, first_name: 'Lucie' }, result); + assert.deepStrictEqual(s.details, { date: 24, time: 7.5, sex: 0, weight: 7.5, length: 11.3, name: 3 }); +}); + +test("l'heure est circulaire autour de minuit", () => { + const s = scorePrediction({ ...result, birth_time: '23:30' }, { ...result, birth_time: '00:30' }); + assert.strictEqual(s.gaps.minutes, 60); +}); + +test('aucun point négatif', () => { + const s = scorePrediction({ birth_date: '2027-01-01', birth_time: '15:30', sex: 'M', weight_g: 1000, length_cm: 30, first_name: 'Max' }, result); + assert.strictEqual(s.total, 0); +}); + +test('égalité départagée par la saisie la plus ancienne', () => { + const a = { ...result, user_id: 'a', updated_at: '2026-10-02T00:00:00Z' }; + const b = { ...result, user_id: 'b', updated_at: '2026-10-01T00:00:00Z' }; + const lb = leaderboard([a, b], result); + assert.deepStrictEqual(lb.map((p) => [p.user_id, p.rank]), [['b', 1], ['a', 2]]); +}); diff --git a/test/trends.test.js b/test/trends.test.js new file mode 100644 index 0000000..7eeaf2a --- /dev/null +++ b/test/trends.test.js @@ -0,0 +1,24 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { trends } = require('../src/trends'); + +const p = (birth_date, birth_time, sex, weight_g, length_cm) => ({ birth_date, birth_time, sex, weight_g, length_cm }); + +test('valeurs arrondies et imprécises', () => { + const t = trends([ + p('2026-11-18', '03:10', 'F', 3280, 49.5), + p('2026-11-20', '04:20', 'F', 3410, 50.5), + p('2026-11-22', '02:00', 'M', 3530, 51), + ]); + assert.deepStrictEqual(t, { sex: 'F', week: '2026-11-16', time: 'night', weight_g: 3400, length_cm: 50 }); +}); + +test("l'heure moyenne passe par minuit", () => { + assert.strictEqual(trends([p('2026-11-20', '23:00', 'F', 3000, 50), p('2026-11-20', '01:00', 'M', 3000, 50)]).time, 'night'); +}); + +test('avis partagés', () => { + const t = trends([p('2026-11-20', '08:00', 'F', 3000, 50), p('2026-11-20', '20:00', 'M', 3000, 50)]); + assert.strictEqual(t.sex, 'split'); + assert.strictEqual(t.time, 'any'); +});