first commit

This commit is contained in:
Mathieu 2026-10-14 21:18:47 +02:00
commit f2200011ae
17 changed files with 2522 additions and 0 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
data
.env
test
*.md

15
.env.example Normal file
View file

@ -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

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
data
.env

13
Dockerfile Normal file
View file

@ -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"]

70
README.md Normal file
View file

@ -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 <https://console.cloud.google.com/> : 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`.

12
docker-compose.yml Normal file
View file

@ -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:

1013
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

17
package.json Normal file
View file

@ -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"
}
}

633
public/app.js Normal file
View file

@ -0,0 +1,633 @@
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
? `<img class="avatar" src="${esc(picture)}" alt="" referrerpolicy="no-referrer">`
: `<span class="avatar">${esc((name || '?')[0].toUpperCase())}</span>`;
}
const googleIcon = `<svg viewBox="0 0 48 48" aria-hidden="true"><path fill="#FFC107" d="M43.6 20.1H42V20H24v8h11.3C33.7 32.7 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.6-.4-3.9z"/><path fill="#FF3D00" d="m6.3 14.7 6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.3-11.3-7.9l-6.5 5C9.5 39.6 16.2 44 24 44z"/><path fill="#1976D2" d="M43.6 20.1H42V20H24v8h11.3c-.8 2.2-2.2 4.2-4.1 5.6l6.2 5.2C37 39.2 44 34 44 24c0-1.3-.1-2.6-.4-3.9z"/></svg>`;
// ---------- Rendu ----------
function renderAccount() {
$('#account').innerHTML = me.user
? `<div class="user-chip">${avatar(me.user.picture, me.user.firstName)}<span class="hello">Salut ${esc(me.user.firstName)} !</span>
<button class="btn ghost small" id="logout">Déconnexion</button></div>`
: `<a class="btn google small" href="/auth/google">${googleIcon} Se connecter</a>`;
$('#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 = `<h1><span class="wiggle">👶</span> ${title}</h1>`;
if (r) {
const s = SEX[r.sex];
html += `
<div class="announce">
<p class="big">${s.emoji} Bienvenue ${esc(r.first_name)} ! 🎉</p>
<div class="facts">
<span class="pill ${s.cls}">${s.label}</span>
<span class="pill yellow">📅 ${esc(fmtDate(r.birth_date))}</span>
<span class="pill mint">🕐 ${esc(fmtTime(r.birth_time))}</span>
<span class="pill pink">⚖️ ${esc(fmtWeight(r.weight_g))}</span>
<span class="pill blue">📏 ${esc(fmtLength(r.length_cm))}</span>
</div>
</div>`;
} else {
html += `<p>Jour, heure, sexe, poids, taille, prénom… À toi de jouer : fais ton pronostic et tente de décrocher la première place !</p>`;
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(`<span class="pill yellow">🗓️ Terme prévu : ${esc(fmtDate(state.dueDate, { day: 'numeric', month: 'long' }))}</span>`);
pills.push(`<span class="pill pink">⏳ ${when}</span>`);
}
pills.push(`<span class="pill blue">🎯 ${state.count} pronostic${state.count > 1 ? 's' : ''}</span>`);
if (state.locked) pills.push(`<span class="pill mint">🔒 Pronostics clôturés</span>`);
html += `<div class="countdown">${pills.join('')}</div>`;
}
$('#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 = `
<div class="card">
<h2 style="text-align:center">🏆 Et le grand gagnant est… ${esc(top[0].display_name)} !</h2>
<div class="podium">
${top.map((p, i) => `
<div class="step p${i + 1}">
<div class="medal">${medals[i]}</div>
${p.picture ? avatar(p.picture, p.display_name) : ''}
<div class="name">${esc(p.display_name)}</div>
<div>${fmtPts(p.score.total)} pts</div>
</div>`).join('')}
</div>
</div>`;
}
const ICONS = {
calendar: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15.5" rx="3"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>',
clock: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
};
// 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 `
<div class="field" data-field="${name}">
<label class="field-label" for="${id}"><span aria-hidden="true">${emoji}</span> ${label}</label>
${control}
<p class="field-error" id="${id}-err" hidden></p>
</div>`;
}
const pickerControl = (id, name, kind, value, extra = '') => `
<div class="control has-btn">
<input type="text" id="${id}" name="${name}" class="${kind === 'calendar' ? 'date-fr' : 'time-fr'}" inputmode="numeric"
maxlength="${kind === 'calendar' ? 10 : 5}" placeholder="${kind === 'calendar' ? 'jj/mm/aaaa' : 'hh:mm'}" autocomplete="off"
aria-describedby="${id}-err" value="${esc(value)}" ${extra}>
<button type="button" class="picker-btn" tabindex="-1" aria-label="${kind === 'calendar' ? 'Ouvrir le calendrier' : "Choisir l'heure"}">${ICONS[kind]}</button>
</div>`;
const unitControl = (id, name, unit, value, placeholder, mode) => `
<div class="control has-suffix">
<input type="text" id="${id}" name="${name}" inputmode="${mode}" autocomplete="off" placeholder="${placeholder}"
aria-describedby="${id}-err" value="${esc(value)}">
<span class="suffix" aria-hidden="true">${unit}</span>
</div>`;
function predictionForm(p, formId = 'pred-form') {
const v = p || {};
const id = (n) => `${formId}-${n}`;
return `
<form id="${formId}" novalidate>
<div class="form-grid">
${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 || '') })}
<div class="field" data-field="sex">
<span class="field-label" id="${id('sex')}-lbl"><span aria-hidden="true">👶</span> Sexe</span>
<div class="sex-choice" role="radiogroup" aria-labelledby="${id('sex')}-lbl">
<input type="radio" name="sex" id="${id('sex-f')}" value="F" ${v.sex === 'F' ? 'checked' : ''}><label for="${id('sex-f')}">👧 Fille</label>
<input type="radio" name="sex" id="${id('sex-m')}" value="M" ${v.sex === 'M' ? 'checked' : ''}><label for="${id('sex-m')}">👦 Garçon</label>
</div>
<p class="field-error" hidden></p>
</div>
${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: `<div class="control"><input type="text" id="${id('first_name')}" name="first_name" maxlength="40" autocomplete="off"
placeholder="Ton idée de prénom" aria-describedby="${id('first_name')}-err" value="${esc(v.first_name)}"></div>` })}
</div>
<div class="actions">
<button class="btn" type="submit">${p ? '💾 Enregistrer' : '🎯 Valider mon pronostic'}</button>
${p ? '<button class="btn ghost" type="button" id="cancel-edit">Annuler</button>' : ''}
</div>
</form>`;
}
// ---------- 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 = '<span></span>'.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 += `<button type="button" class="${cls}" data-iso="${iso}"${iso === state?.dueDate ? ' title="Terme prévu"' : ''}>${d}</button>`;
}
popover.innerHTML = `
<div class="picker-head">
<button type="button" data-nav="-1" aria-label="Mois précédent">‹</button>
<span>${MONTHS[m]} ${y}</span>
<button type="button" data-nav="1" aria-label="Mois suivant">›</button>
</div>
<div class="cal-grid cal-dow">${['lu', 'ma', 'me', 'je', 've', 'sa', 'di'].map((d) => `<span>${d}</span>`).join('')}</div>
<div class="cal-grid">${cells}</div>
${state?.dueDate ? '<div class="picker-legend"><i></i> terme prévu</div>' : ''}`;
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 = `
<div class="picker-head"><span>Heure</span></div>
<div class="clock-grid hours">${hours.map((x) => `<button type="button" data-h="${x}" class="${x === h ? 'sel' : ''}">${x}h</button>`).join('')}</div>
<div class="picker-head"><span>Minutes</span></div>
<div class="clock-grid">${mins.map((x) => `<button type="button" data-m="${x}" class="${x === min ? 'sel' : ''}">${x}</button>`).join('')}</div>`;
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 `<div class="tags">
<span>${s.emoji} <b>${s.label}</b></span>
<span>📅 <b>${esc(fmtShortDate(p.birth_date))}</b></span>
<span>🕐 <b>${esc(fmtTime(p.birth_time))}</b></span>
<span>⚖️ <b>${esc(fmtWeight(p.weight_g))}</b></span>
<span>📏 <b>${esc(fmtLength(p.length_cm))}</b></span>
<span>✨ <b>${esc(p.first_name)}</b></span>
</div>`;
}
function renderMine() {
const el = $('#mine');
if (!me.user) {
el.innerHTML = `<div class="card" style="text-align:center">
<h2>Envie de jouer ? 🎲</h2>
<p class="hint">Connecte-toi avec ton compte Google pour enregistrer ton pronostic.</p>
<a class="btn google" href="/auth/google">${googleIcon} Se connecter avec Google</a>
</div>`;
return;
}
const mine = state.predictions.find((p) => p.mine);
if (state.locked) {
el.innerHTML = `<div class="card"><h2>Mon pronostic</h2>${
mine ? predTags(mine) : '<p class="hint">Tu n\'as pas fait de pronostic… et les jeux sont faits ! 🙈</p>'}</div>`;
return;
}
if (mine && !editing) {
el.innerHTML = `<div class="card"><h2>Mon pronostic ✅</h2>${predTags(mine)}
<div class="actions">
<button class="btn" id="edit">✏️ Modifier</button>
<button class="btn ghost" id="delete">Supprimer</button>
<span class="hint">Tu peux le modifier jusqu'à la clôture des pronostics.</span>
</div></div>`;
$('#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 = `<div class="card"><h2>${mine ? 'Modifier mon pronostic' : 'Mon pronostic 🔮'}</h2>${predictionForm(mine)}</div>`;
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 = `<p class="hint">Personne n'a encore joué. Sois le premier ou la première ! 🚀</p>`;
} else if (!t) {
const missing = minForTrends - count;
body = `
<p class="big-count">🎯 ${count} pronostic${count > 1 ? 's' : ''}</p>
<p class="hint">Les pronostics restent secrets jusqu'à la naissance 🤫<br>
Les tendances apparaîtront à partir de ${minForTrends} pronostics (encore ${missing}).</p>
<div class="progress"><div style="width:${Math.round((count / minForTrends) * 100)}%"></div></div>`;
} else {
body = `
<p class="big-count">🎯 ${count} pronostics</p>
<div class="stats">
<span class="pill ${t.sex === 'F' ? 'pink' : t.sex === 'M' ? 'blue' : 'yellow'}">${SEX_TRENDS[t.sex]}</span>
<span class="pill yellow">📅 semaine du ${esc(fmtDate(t.week, { day: 'numeric', month: 'long' }))}</span>
<span class="pill mint">${TIME_TRENDS[t.time]}</span>
<span class="pill pink">⚖️ environ ${(t.weight_g / 1000).toLocaleString('fr-FR')} kg</span>
<span class="pill blue">📏 environ ${esc(fmtLength(t.length_cm))}</span>
</div>
<p class="hint">Tendances arrondies pour garder le suspense : les pronostics restent secrets jusqu'à la naissance 🤫</p>`;
}
const admin = me.isAdmin && state.participants?.length ? `
<hr>
<h3>🛠️ Participants <small class="hint">(visible par les administrateurs uniquement, sans les pronostics)</small></h3>
<div class="participants">
${state.participants.map((p) => `
<span class="participant">${esc(p.display_name)}
<button class="icon-del" data-del="${esc(p.user_id)}" title="Supprimer ce pronostic" aria-label="Supprimer le pronostic de ${esc(p.display_name)}">✕</button>
</span>`).join('')}
</div>` : '';
$('#all').innerHTML = `<div class="card"><h2>🔮 Les tendances</h2>${body}${admin}</div>`;
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 = `<div class="card">
<h2>📊 Classement</h2>
<div class="preds">
${preds.map((p) => `
<div class="pred ${p.mine ? 'mine' : ''}">
${p.rank <= 3 ? `<div class="rank medal">${['🥇', '🥈', '🥉'][p.rank - 1]}</div>` : `<div class="rank">${p.rank}</div>`}
<div>
<div class="who">${esc(p.display_name)}${p.mine ? ' <small>(moi)</small>' : ''}</div>
${predTags(p)}
</div>
<div class="score">${fmtPts(p.score.total)}<small>points</small></div>
<div class="breakdown">${Object.entries(p.score.details)
.map(([k, v]) => `<span>${labels[k]} ${fmtPts(v)}/${state.maxPoints[k]}</span>`).join('')}</div>
</div>`).join('')}
</div></div>`;
}
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 = `<div class="card">
<h2>🧮 Comment sont comptés les points ?</h2>
<div class="rules">
<div class="rule"><b>📅 Jour — ${m.date} pts</b><br>−3 pts par jour d'écart</div>
<div class="rule"><b>🕐 Heure — ${m.time} pts</b><br>Dégressif, 0 pt à 6 h d'écart</div>
<div class="rule"><b>👶 Sexe — ${m.sex} pts</b><br>Tout ou rien !</div>
<div class="rule"><b>⚖️ Poids — ${m.weight} pts</b><br>−1,5 pt par 100 g d'écart</div>
<div class="rule"><b>📏 Taille — ${m.length} pts</b><br>Dégressif, 0 pt à 8 cm d'écart</div>
<div class="rule"><b>✨ Prénom — ${m.name} pts</b><br>Exact = ${m.name} pts, même initiale = 3 pts</div>
</div>
<p class="hint">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.</p>
</div>`;
}
function renderAdmin() {
if (!me.isAdmin) return ($('#admin').innerHTML = '');
const r = state.result;
$('#admin').innerHTML = `<div class="card admin-card">
<h2>🛠️ Administration</h2>
<div class="admin-row">
<div class="due-field">${field({ id: 'due', name: 'due', emoji: '🗓️', label: 'Date du terme prévu',
control: pickerControl('due', 'due', 'calendar', isoToFr(state.dueDate)) })}</div>
<button class="btn ghost" id="save-due">Enregistrer le terme</button>
<label class="switch"><input type="checkbox" id="lock" ${state.locked ? 'checked' : ''} ${r ? 'disabled' : ''}> 🔒 Clôturer les pronostics</label>
</div>
<hr>
<h3>👶 Le bébé est né ! Saisir les vraies informations</h3>
<p class="hint">L'enregistrement clôture automatiquement les pronostics et affiche le classement.</p>
<div id="result-form">${predictionForm(r, 'admin-result-form')}</div>
${r ? '<div class="actions"><button class="btn danger small" id="clear-result">Effacer le résultat (rouvre le jeu)</button></div>' : ''}
</div>`;
// 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));

37
public/index.html Normal file
View file

@ -0,0 +1,37 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Le bébé de Jerry arrive ! 🍼</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍼</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="sky" aria-hidden="true">
<span>🎈</span><span>⭐</span><span>☁️</span><span>🎈</span><span>✨</span><span>☁️</span>
</div>
<header class="topbar">
<a class="brand" href="/">🍼 <span>Naissance</span></a>
<div id="account"></div>
</header>
<main>
<section class="hero" id="hero"></section>
<div id="flash" class="flash" hidden></div>
<section id="podium"></section>
<section id="mine"></section>
<section id="admin"></section>
<section id="all"></section>
<section id="rules"></section>
</main>
<footer>Fait avec 💛 pour Jerry et sa famille</footer>
<script src="/app.js"></script>
</body>
</html>

234
public/style.css Normal file
View file

@ -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; }

256
server.js Normal file
View file

@ -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)' : ''}`);
});

47
src/db.js Normal file
View file

@ -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 };

61
src/scoring.js Normal file
View file

@ -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 };

51
src/trends.js Normal file
View file

@ -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 };

31
test/scoring.test.js Normal file
View file

@ -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]]);
});

24
test/trends.test.js Normal file
View file

@ -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');
});