first commit
This commit is contained in:
commit
f2200011ae
17 changed files with 2522 additions and 0 deletions
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
test
|
||||||
|
*.md
|
||||||
15
.env.example
Normal file
15
.env.example
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
data
|
||||||
|
.env
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal 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
70
README.md
Normal 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
12
docker-compose.yml
Normal 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
1013
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
17
package.json
Normal file
17
package.json
Normal 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
633
public/app.js
Normal file
|
|
@ -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
|
||||||
|
? `<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
37
public/index.html
Normal 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
234
public/style.css
Normal 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
256
server.js
Normal 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
47
src/db.js
Normal 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
61
src/scoring.js
Normal 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
51
src/trends.js
Normal 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
31
test/scoring.test.js
Normal 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
24
test/trends.test.js
Normal 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');
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue