const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
let me = { user: null, isAdmin: false };
let state = null;
let editing = false;
const SEX = { F: { label: 'Fille', emoji: '👧', cls: 'pink' }, M: { label: 'Garçon', emoji: '👦', cls: 'blue' } };
const fmtDate = (iso, opts = { weekday: 'long', day: 'numeric', month: 'long' }) =>
new Date(`${iso}T12:00:00`).toLocaleDateString('fr-FR', opts);
const fmtShortDate = (iso) => fmtDate(iso, { day: 'numeric', month: 'short' });
const fmtTime = (hhmm) => hhmm.replace(':', 'h');
const fmtWeight = (g) => `${(g / 1000).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 3 })} kg`;
const fmtLength = (cm) => `${Number(cm).toLocaleString('fr-FR')} cm`;
const fmtPts = (n) => n.toLocaleString('fr-FR', { maximumFractionDigits: 1 });
async function api(method, url, body) {
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Oups, une erreur est survenue');
return data;
}
function flash(msg, isError = false) {
const el = $('#flash');
el.textContent = msg;
el.className = `flash${isError ? ' error' : ''}`;
el.hidden = false;
clearTimeout(flash.t);
flash.t = setTimeout(() => { el.hidden = true; }, 4000);
}
function avatar(picture, name) {
return picture
? ` `
: `${esc((name || '?')[0].toUpperCase())} `;
}
const googleIcon = ` `;
// ---------- Rendu ----------
function renderAccount() {
$('#account').innerHTML = me.user
? `
${avatar(me.user.picture, me.user.firstName)}Salut ${esc(me.user.firstName)} !
Déconnexion
`
: `${googleIcon} Se connecter `;
$('#logout')?.addEventListener('click', async () => {
await api('POST', '/auth/logout');
location.reload();
});
}
function renderHero() {
const r = state.result;
const title = r ? 'Le bébé de Jerry est arrivé !' : 'Le bébé de Jerry arrive !';
document.title = `${title} 🍼`;
let html = `👶 ${title} `;
if (r) {
const s = SEX[r.sex];
html += `
${s.emoji} Bienvenue ${esc(r.first_name)} ! 🎉
${s.label}
📅 ${esc(fmtDate(r.birth_date))}
🕐 ${esc(fmtTime(r.birth_time))}
⚖️ ${esc(fmtWeight(r.weight_g))}
📏 ${esc(fmtLength(r.length_cm))}
`;
} else {
html += `Jour, heure, sexe, poids, taille, prénom… À toi de jouer : fais ton pronostic et tente de décrocher la première place !
`;
const pills = [];
if (state.dueDate) {
const days = Math.round((new Date(`${state.dueDate}T00:00:00`) - new Date(new Date().toDateString())) / 86400000);
const when = days > 1 ? `J-${days}` : days === 1 ? 'Demain !' : days === 0 ? "C'est aujourd'hui !" : `Terme dépassé de ${-days} j`;
pills.push(`🗓️ Terme prévu : ${esc(fmtDate(state.dueDate, { day: 'numeric', month: 'long' }))} `);
pills.push(`⏳ ${when} `);
}
pills.push(`🎯 ${state.count} pronostic${state.count > 1 ? 's' : ''} `);
if (state.locked) pills.push(`🔒 Pronostics clôturés `);
html += `${pills.join('')}
`;
}
$('#hero').innerHTML = html;
}
function renderPodium() {
if (!state.result || !state.predictions.length) return ($('#podium').innerHTML = '');
const medals = ['🥇', '🥈', '🥉'];
const top = state.predictions.slice(0, 3);
$('#podium').innerHTML = `
🏆 Et le grand gagnant est… ${esc(top[0].display_name)} !
${top.map((p, i) => `
${medals[i]}
${p.picture ? avatar(p.picture, p.display_name) : ''}
${esc(p.display_name)}
${fmtPts(p.score.total)} pts
`).join('')}
`;
}
const ICONS = {
calendar: ' ',
clock: ' ',
};
// Un champ de formulaire : libellé, contrôle et zone d'erreur, tous alignés de la même façon.
function field({ id, name, emoji, label, control }) {
return `
${emoji} ${label}
${control}
`;
}
const pickerControl = (id, name, kind, value, extra = '') => `
${ICONS[kind]}
`;
const unitControl = (id, name, unit, value, placeholder, mode) => `
${unit}
`;
function predictionForm(p, formId = 'pred-form') {
const v = p || {};
const id = (n) => `${formId}-${n}`;
return `
`;
}
// ---------- Dates et heures au format français ----------
const isoToFr = (iso) => (iso ? iso.split('-').reverse().join('/') : '');
function frToIso(s) {
const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(String(s || '').trim());
if (!m) return null;
const iso = `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`;
const d = new Date(`${iso}T00:00:00Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso ? iso : null;
}
function normalizeTime(s) {
const m = /^(\d{1,2})\s*[:hH.]?\s*(\d{2})?$/.exec(String(s || '').trim());
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2] || 0);
return h < 24 && min < 60 ? `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}` : null;
}
// Insère automatiquement les « / » et « : » pendant la saisie, en respectant
// les séparateurs tapés par l'utilisateur (« 1/11/2026 », « 9h05 »…).
function maskInput(input, groups, sep) {
input.addEventListener('input', (e) => {
if (e.inputType?.startsWith('delete')) return;
const v = input.value;
const parts = [];
for (let chunk of v.split(/\D+/).filter(Boolean)) {
while (parts.length < groups.length - 1 && chunk.length > groups[parts.length]) {
parts.push(chunk.slice(0, groups[parts.length]));
chunk = chunk.slice(groups[parts.length - 1]);
}
if (parts.length < groups.length) parts.push(chunk.slice(0, groups[parts.length]));
}
let out = parts.join(sep);
const n = parts.length;
if (n && n < groups.length && (/\D$/.test(v) || parts[n - 1].length === groups[n - 1])) out += sep;
input.value = out;
});
}
function enhanceInputs(root = document) {
root.querySelectorAll('input.date-fr').forEach((i) => {
maskInput(i, [2, 2, 4], '/');
attachPicker(i, renderCalendar);
});
root.querySelectorAll('input.time-fr').forEach((i) => {
maskInput(i, [2, 2], ':');
i.addEventListener('blur', () => { const t = normalizeTime(i.value); if (t) i.value = t; });
attachPicker(i, renderClock);
});
}
// ---------- Sélecteurs de date et d'heure (en français) ----------
const MONTHS = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'];
const pad2 = (n) => String(n).padStart(2, '0');
const popover = Object.assign(document.createElement('div'), { className: 'picker', hidden: true });
popover.setAttribute('role', 'dialog');
document.body.appendChild(popover);
let pickerInput = null;
function attachPicker(input, renderFn) {
const open = () => openPicker(input, renderFn);
input.addEventListener('click', open);
input.parentElement.querySelector('.picker-btn').addEventListener('click', (e) => {
e.preventDefault();
if (pickerInput === input) closePicker(); else { open(); input.focus(); }
});
input.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePicker(); if (e.key === 'Tab') closePicker(); });
input.addEventListener('input', () => { if (pickerInput === input) { popover.calMonth = null; renderFn(input); } });
}
function openPicker(input, renderFn) {
pickerInput = input;
popover.calMonth = null;
renderFn(input);
popover.hidden = false;
placePicker();
}
function closePicker() {
popover.hidden = true;
pickerInput = null;
}
function placePicker() {
if (!pickerInput) return;
const r = pickerInput.getBoundingClientRect();
const w = popover.offsetWidth;
const left = Math.max(8, Math.min(r.left, document.documentElement.clientWidth - w - 8));
popover.style.left = `${left + scrollX}px`;
popover.style.top = `${r.bottom + scrollY + 6}px`;
}
function pick(value) {
pickerInput.value = value;
pickerInput.dispatchEvent(new Event('change', { bubbles: true }));
pickerInput.focus();
closePicker();
}
function renderCalendar(input) {
const selected = frToIso(input.value);
const todayIso = new Date().toLocaleDateString('sv-SE'); // AAAA-MM-JJ en heure locale
if (!popover.calMonth) {
const base = selected || state?.dueDate || todayIso;
popover.calMonth = { y: Number(base.slice(0, 4)), m: Number(base.slice(5, 7)) - 1 };
}
const { y, m } = popover.calMonth;
const first = (new Date(y, m, 1).getDay() + 6) % 7; // lundi = 0
const days = new Date(y, m + 1, 0).getDate();
let cells = ' '.repeat(first);
for (let d = 1; d <= days; d++) {
const iso = `${y}-${pad2(m + 1)}-${pad2(d)}`;
const cls = [iso === selected && 'sel', iso === todayIso && 'today', iso === state?.dueDate && 'due'].filter(Boolean).join(' ');
cells += `${d} `;
}
popover.innerHTML = `
‹
${MONTHS[m]} ${y}
›
${['lu', 'ma', 'me', 'je', 've', 'sa', 'di'].map((d) => `${d} `).join('')}
${cells}
${state?.dueDate ? ' terme prévu
' : ''}`;
popover.querySelectorAll('[data-nav]').forEach((b) => {
b.onclick = () => {
const d = new Date(y, m + Number(b.dataset.nav), 1);
popover.calMonth = { y: d.getFullYear(), m: d.getMonth() };
renderCalendar(input);
};
});
popover.querySelectorAll('[data-iso]').forEach((b) => { b.onclick = () => pick(isoToFr(b.dataset.iso)); });
}
function renderClock(input) {
const cur = normalizeTime(input.value);
const [h, min] = cur ? cur.split(':') : [null, null];
const hours = Array.from({ length: 24 }, (_, i) => pad2(i));
const mins = Array.from({ length: 12 }, (_, i) => pad2(i * 5));
popover.innerHTML = `
Heure
${hours.map((x) => `${x}h `).join('')}
Minutes
${mins.map((x) => `${x} `).join('')}
`;
popover.querySelectorAll('[data-h]').forEach((b) => {
b.onclick = () => { input.value = `${b.dataset.h}:${min || '00'}`; renderClock(input); input.focus(); };
});
popover.querySelectorAll('[data-m]').forEach((b) => { b.onclick = () => pick(`${h || '12'}:${b.dataset.m}`); });
}
// Les clics dans le sélecteur ne doivent pas faire perdre le focus au champ
popover.addEventListener('mousedown', (e) => e.preventDefault());
document.addEventListener('mousedown', (e) => {
if (pickerInput && !popover.contains(e.target) && !pickerInput.parentElement.contains(e.target)) closePicker();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePicker(); });
addEventListener('resize', placePicker);
function readForm(form) {
const fd = new FormData(form);
return {
birth_date: frToIso(fd.get('birth_date')),
birth_time: normalizeTime(fd.get('birth_time')),
sex: fd.get('sex'),
weight_g: Number(String(fd.get('weight_g')).replace(/\s/g, '')),
length_cm: Number(String(fd.get('length_cm')).replace(/\s/g, '').replace(',', '.')),
first_name: String(fd.get('first_name') || '').trim(),
};
}
// Vérifie les saisies et affiche les erreurs sous les champs concernés.
function checkForm(form, data) {
const errors = {
birth_date: !data.birth_date && 'Date invalide : format jj/mm/aaaa',
birth_time: !data.birth_time && 'Heure invalide : format hh:mm',
sex: !data.sex && 'Choisis fille ou garçon 😉',
weight_g: !(Number.isInteger(data.weight_g) && data.weight_g >= 500 && data.weight_g <= 6500) && 'Entre 500 et 6500 g, sans virgule',
length_cm: !(data.length_cm >= 25 && data.length_cm <= 65) && 'Entre 25 et 65 cm',
first_name: !data.first_name && 'Propose un prénom',
};
let first = null;
form.querySelectorAll('.field[data-field]').forEach((f) => {
const msg = errors[f.dataset.field];
const p = f.querySelector('.field-error');
f.classList.toggle('invalid', !!msg);
p.hidden = !msg;
p.textContent = msg || '';
f.querySelector('input')?.setAttribute('aria-invalid', msg ? 'true' : 'false');
if (msg && !first) first = f;
});
first?.querySelector('input:not([type=radio])')?.focus();
return !first;
}
// Efface l'erreur d'un champ dès qu'on le corrige.
function clearErrorsOnEdit(form) {
const clear = (e) => {
const f = e.target.closest('.field.invalid');
if (!f) return;
f.classList.remove('invalid');
f.querySelector('.field-error').hidden = true;
e.target.removeAttribute('aria-invalid');
};
form.addEventListener('input', clear);
form.addEventListener('change', clear);
}
function predTags(p) {
const s = SEX[p.sex];
return `
${s.emoji} ${s.label}
📅 ${esc(fmtShortDate(p.birth_date))}
🕐 ${esc(fmtTime(p.birth_time))}
⚖️ ${esc(fmtWeight(p.weight_g))}
📏 ${esc(fmtLength(p.length_cm))}
✨ ${esc(p.first_name)}
`;
}
function renderMine() {
const el = $('#mine');
if (!me.user) {
el.innerHTML = ``;
return;
}
const mine = state.predictions.find((p) => p.mine);
if (state.locked) {
el.innerHTML = `Mon pronostic ${
mine ? predTags(mine) : '
Tu n\'as pas fait de pronostic… et les jeux sont faits ! 🙈
'}
`;
return;
}
if (mine && !editing) {
el.innerHTML = `Mon pronostic ✅ ${predTags(mine)}
✏️ Modifier
Supprimer
Tu peux le modifier jusqu'à la clôture des pronostics.
`;
$('#edit').onclick = () => { editing = true; renderMine(); };
$('#delete').onclick = async () => {
if (!confirm('Supprimer ton pronostic ?')) return;
await run(() => api('DELETE', '/api/predictions/me'), 'Pronostic supprimé');
};
return;
}
el.innerHTML = `
${mine ? 'Modifier mon pronostic' : 'Mon pronostic 🔮'} ${predictionForm(mine)}`;
enhanceInputs(el);
if ($('#pred-form')) clearErrorsOnEdit($('#pred-form'));
$('#cancel-edit')?.addEventListener('click', () => { editing = false; renderMine(); });
$('#pred-form').onsubmit = async (e) => {
e.preventDefault();
const data = readForm(e.target);
if (!checkForm(e.target, data)) return;
const ok = await run(() => api('PUT', '/api/predictions/me', data), mine ? 'Pronostic mis à jour 💾' : 'Pronostic enregistré, bonne chance ! 🍀');
if (ok) { editing = false; if (!mine) confetti(); renderAll(); }
};
}
const TIME_TRENDS = {
night: '🌙 plutôt la nuit',
morning: '🌅 plutôt le matin',
afternoon: '☀️ plutôt l\'après-midi',
evening: '🌆 plutôt le soir',
any: '🕐 à toute heure',
};
const SEX_TRENDS = { F: '👧 plutôt fille', M: '👦 plutôt garçon', split: '👧👦 avis partagés' };
function renderAll() {
if (state.result) return renderRanking();
const { count, trends: t, minForTrends } = state;
let body;
if (!count) {
body = `Personne n'a encore joué. Sois le premier ou la première ! 🚀
`;
} else if (!t) {
const missing = minForTrends - count;
body = `
🎯 ${count} pronostic${count > 1 ? 's' : ''}
Les pronostics restent secrets jusqu'à la naissance 🤫
Les tendances apparaîtront à partir de ${minForTrends} pronostics (encore ${missing}).
`;
} else {
body = `
🎯 ${count} pronostics
${SEX_TRENDS[t.sex]}
📅 semaine du ${esc(fmtDate(t.week, { day: 'numeric', month: 'long' }))}
${TIME_TRENDS[t.time]}
⚖️ environ ${(t.weight_g / 1000).toLocaleString('fr-FR')} kg
📏 environ ${esc(fmtLength(t.length_cm))}
Tendances arrondies pour garder le suspense : les pronostics restent secrets jusqu'à la naissance 🤫
`;
}
const admin = me.isAdmin && state.participants?.length ? `
🛠️ Participants (visible par les administrateurs uniquement, sans les pronostics)
${state.participants.map((p) => `
${esc(p.display_name)}
✕
`).join('')}
` : '';
$('#all').innerHTML = `
🔮 Les tendances ${body}${admin}`;
bindDeleteButtons();
}
function renderRanking() {
const preds = state.predictions;
if (!preds.length) return ($('#all').innerHTML = '');
const labels = { date: '📅 Jour', time: '🕐 Heure', sex: '👶 Sexe', weight: '⚖️ Poids', length: '📏 Taille', name: '✨ Prénom' };
$('#all').innerHTML = `
📊 Classement
${preds.map((p) => `
${p.rank <= 3 ? `
${['🥇', '🥈', '🥉'][p.rank - 1]}
` : `
${p.rank}
`}
${esc(p.display_name)}${p.mine ? ' (moi) ' : ''}
${predTags(p)}
${fmtPts(p.score.total)}points
${Object.entries(p.score.details)
.map(([k, v]) => `${labels[k]} ${fmtPts(v)}/${state.maxPoints[k]} `).join('')}
`).join('')}
`;
}
function bindDeleteButtons() {
document.querySelectorAll('[data-del]').forEach((b) => {
b.onclick = async () => {
if (!confirm('Supprimer ce pronostic ?')) return;
await run(() => api('DELETE', `/api/admin/predictions/${encodeURIComponent(b.dataset.del)}`), 'Pronostic supprimé');
};
});
}
function renderRules() {
const m = state.maxPoints;
$('#rules').innerHTML = `
🧮 Comment sont comptés les points ?
📅 Jour — ${m.date} pts −3 pts par jour d'écart
🕐 Heure — ${m.time} pts Dégressif, 0 pt à 6 h d'écart
👶 Sexe — ${m.sex} pts Tout ou rien !
⚖️ Poids — ${m.weight} pts −1,5 pt par 100 g d'écart
📏 Taille — ${m.length} pts Dégressif, 0 pt à 8 cm d'écart
✨ Prénom — ${m.name} pts Exact = ${m.name} pts, même initiale = 3 pts
Total sur 100. En cas d'égalité, c'est le pronostic enregistré (ou modifié pour la dernière fois) le plus tôt qui l'emporte.
`;
}
function renderAdmin() {
if (!me.isAdmin) return ($('#admin').innerHTML = '');
const r = state.result;
$('#admin').innerHTML = `
🛠️ Administration
👶 Le bébé est né ! Saisir les vraies informations
L'enregistrement clôture automatiquement les pronostics et affiche le classement.
${predictionForm(r, 'admin-result-form')}
${r ? '
Effacer le résultat (rouvre le jeu)
' : ''}
`;
// 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));