PageSourceSearch

https://polecompetbouliac.netlify.app/assets/js/app.js

js polecompetbouliac.netlify.app collected 2026-10-03 11:17:07 UTC 804,670 bytes, 14,873 lines download raw bytes

1// Un seul invariant de collection pour le métier et l'interface.
2const listeSure = globalThis.TennisDomainRules.tableau;
3
4const VENDORS_LAZY = {
5  chartjs: {
6    src: 'assets/vendor/chart-4.4.0.umd.min.js',
7    integrity: 'sha384-e6nUZLBkQ86NJ6TVVKAeSaK8jWa3NhkYWZFomE39AvDbQWeie9PlQqM3pmYW5d1g',
8    disponible: () => !!window.Chart
9  },
10  jspdf: {
11    src: 'assets/vendor/jspdf-2.5.1.umd.min.js',
12    integrity: 'sha384-JcnsjUPPylna1s1fvi1u12X5qjY5OL56iySh75FdtrwhO/SWXgMjoVqcKyIIWOLk',
13    disponible: () => !!window.jspdf
14  },
15  pdfjs: {
16    src: 'assets/vendor/pdf-3.11.174.min.js',
17    integrity: 'sha384-/1qUCSGwTur9vjf/z9lmu/eCUYbpOTgSjmpbMQZ1/CtX2v/WcAIKqRv+U1DUCG6e',
18    disponible: () => !!window.pdfjsLib
19  },
20  pdfexports: {
21    src: 'assets/js/pdf-exports.js',
22    disponible: () => !!window.TennisPdfExports
23  },
24  changelog: {
25    src: 'assets/js/changelog.js',
26    disponible: () => !!window.TennisChangelog
27  }
28};
29let chargeurVendor = null;
30const journal = Object.freeze({
31  warn: (...args) => { if (/^(localhost|127\.0\.0\.1)$/i.test(location.hostname)) globalThis.console?.warn?.(...args); },
32  error: (...args) => { if (/^(localhost|127\.0\.0\.1)$/i.test(location.hostname)) globalThis.console?.error?.(...args); },
33  info: (...args) => { if (/^(localhost|127\.0\.0\.1)$/i.test(location.hostname)) globalThis.console?.info?.(...args); }
34});
35
36async function chargerVendor(cle) {
37  const vendor = VENDORS_LAZY[cle];
38  if (!vendor) return false;
39  return chargeurVendor.charger(cle, vendor);
40}
41
42async function chargerJsPdf() {
43  return chargerVendor('jspdf');
44}
45
46async function chargerChartJs() {
47  return chargerVendor('chartjs');
48}
49
50async function chargerPdfJs() {
51  const disponible = await chargerVendor('pdfjs');
52  if (disponible) window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'assets/vendor/pdf-worker-3.11.174.min.js';
53  return disponible;
54}
55
56// ============================================================
57// POLE COMPETITION TENNIS BOULIAC - v2 avec Supabase
58// ============================================================
59
60// Validation config
61if (!window.APP_CONFIG || !window.APP_CONFIG.SUPABASE_URL || window.APP_CONFIG.SUPABASE_URL.includes('VOTRE-PROJET')) {
62  document.getElementById('loading-screen').innerHTML =
63    '<div style="text-align:center; padding:24px; color:var(--danger-text)"><h3>⚠ Configuration manquante</h3>' +
64    '<p>Édite le fichier <code>config.js</code> pour ajouter ton URL et ta clé Supabase.</p>' +
65    '<p class="text-faint">Voir <code>README-DEPLOIEMENT.md</code> pour les instructions.</p></div>';
66  throw new Error('Config Supabase manquante');
67}
68
69const contratsDependancesEssentielles = [
70  ['métier', globalThis.TennisDomainRules, ['bornesSaison', 'messageErreurUtilisateur']],
71  ['asynchrone', globalThis.TennisAsync, ['avecDelai', 'creerChargeurScripts']],
72  ['données', globalThis.TennisData, ['creerCacheVide', 'updateConfirmee']],
73  ['formats', globalThis.TennisFormatters, ['escapeHtml', 'formatDate']],
74  ['état', globalThis.TennisState, ['creerEtat']],
75  ['archives saisonnières', globalThis.TennisSeasonArchives, ['creerChargeurDerniereDemande', 'chargerDonnees']],
76  ['météo', globalThis.TennisTournamentWeather, ['creerService']],
77  ['tournois Ten’Up', globalThis.TennisTournamentsTenup, ['choixTournoisMatch', 'selecteurMatchHtml']],
78  ['avatars', globalThis.TennisAvatars, ['creerService']],
79  ['notifications', globalThis.TennisNotifications, ['creerCentre']],
80  ['emails', globalThis.TennisEmailService, ['creerService']],
81  ['pagination', globalThis.TennisPagination, ['chargerToutesPages']],
82  ['modèles d\'email', globalThis.TennisEmailTemplates, ['creerModeles']],
83  ['rappels', globalThis.TennisReminders, ['creerService']],
84  ['brouillons', globalThis.TennisDrafts, ['creerGestionnaire']],
85  ['thème', globalThis.TennisTheme, ['creerControleurTheme']],
86  ['interface', globalThis.TennisUiCommon, ['creerGestionnaireModale', 'creerGestionnaireRetours']],
87  ['comptes famille', globalThis.TennisFamilyAccounts, ['renderSelector']],
88  ['actions', globalThis.TennisUiGrammar, ['decouperExpressionUI', 'argumentActionUI']],
89  ['iconographie', globalThis.TennisIconography, ['creer']],
90  ['graphiques', globalThis.TennisChartTheme, ['chartColors', 'chartGradient']]
91];
92const dependancesEssentiellesManquantes = contratsDependancesEssentielles
93  .filter(([, api, methodes]) => !api || methodes.some(methode => typeof api[methode] !== 'function'))
94  .map(([nom]) => nom);
95if (dependancesEssentiellesManquantes.length) {
96  const recoveryKey = 'pcb_module_recovery_v1';
97  let derniereReprise = 0;
98  try { derniereReprise = Number(sessionStorage.getItem(recoveryKey) || 0); } catch (_) { /* stockage bloqué */ }
99  const repriseRecente = Date.now() - derniereReprise < 60_000;
100  const purgerCacheApplicatifEtRecharger = async () => {
101    try { sessionStorage.setItem(recoveryKey, String(Date.now())); } catch (_) { /* stockage bloqué */ }
102    if (navigator.onLine && 'caches' in window) {
103      const cles = await caches.keys();
104      await Promise.all(cles.filter(cle => cle.startsWith('pole-tennis-')).map(cle => caches.delete(cle)));
105    }
106    const inscription = await navigator.serviceWorker?.getRegistration?.();
107    await inscription?.update?.().catch(() => {});
108    location.reload();
109  };
110  const loadingRoot = document.getElementById('loading-screen');
111  if (loadingRoot) {
112    loadingRoot.style.display = '';
113    loadingRoot.innerHTML = `<div class="card" style="max-width:430px;margin:24px;padding:22px;text-align:center;border-top:4px solid var(--danger)">
114      <h3>Une mise à jour doit être terminée</h3>
115      <p class="text-dim">Des fichiers de versions différentes sont encore présents sur cet appareil. Tes données n’ont pas été modifiées.</p>
116      <button type="button" class="btn btn-primary btn-full" id="moduleRecoveryButton">Actualiser l’application</button>
117    </div>`;
118    document.getElementById('moduleRecoveryButton')?.addEventListener('click', () => purgerCacheApplicatifEtRecharger());
119  }
120  if (!repriseRecente) setTimeout(() => purgerCacheApplicatifEtRecharger(), 50);
121  throw new Error(`DEPENDANCE_ESSENTIELLE_INDISPONIBLE:${dependancesEssentiellesManquantes.join(',')}`);
122}
123try { sessionStorage.removeItem('pcb_module_recovery_v1'); } catch (_) { /* stockage bloqué */ }
124
125chargeurVendor = TennisAsync.creerChargeurScripts({
126  document,
127  horloge: window,
128  journal,
129  onErreur: () => toast('Ce module n’a pas pu être chargé. Vérifie ta connexion puis réessaie.', true)
130});
131
132const db = window.supabase.createClient(
133  window.APP_CONFIG.SUPABASE_URL,
134  window.APP_CONFIG.SUPABASE_ANON_KEY
135);
136
137// État global
138let session = null;
139let profile = null; // { id, email, role, joueur_id, prenom, nom }
140
141// État transversal persistant et filtres partagés.
142const etatApp = TennisState.creerEtat({ storage: localStorage, sessionStorage });
143// Les brouillons doivent survivre à la mise en veille/éviction d'une PWA iOS.
144const gestionnaireBrouillons = TennisDrafts.creerGestionnaire({ storage: localStorage, horloge: window });
145
146const controleurTheme = TennisTheme.creerControleurTheme({
147  window,
148  document,
149  storage: localStorage,
150  onMessage: message => { if (typeof toast === 'function') toast(message); },
151  onChange: () => {
152    if (typeof render === 'function') { try { render(); } catch (_) { /* démarrage */ } }
153    try {
154      const modalEl = document.getElementById('modal');
155      const modaleOuverte = modalEl?.classList.contains('show') && document.getElementById('pdChartClassement');
156      if (modaleOuverte && etatApp.joueurDetailId
157          && typeof openPlayerDetail === 'function') openPlayerDetail(etatApp.joueurDetailId);
158    } catch (_) { /* modale absente */ }
159  }
160});
161function toggleThemeSwitch(event) { return controleurTheme.basculer(event); }
162function updateThemeSwitchIcon() { return controleurTheme.actualiserInterrupteur(); }
163
164controleurTheme.appliquer({ emettre: false });
165if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', updateThemeSwitchIcon);
166else updateThemeSwitchIcon();
167controleurTheme.suivreSysteme();
168function isPoleEnv() {
169  if (!isCoach()) return true;
170  return etatApp.environnement === 'pole' || etatApp.environnement === 'pre_academie';
171}
172
173function isPreAcademieEnv() {
174  return isCoach() && etatApp.environnement === 'pre_academie';
175}
176
177function isAcademieEnv() {
178  return isCoach() && etatApp.environnement === 'pole';
179}
180// Renvoie l'objet événement actif (depuis cache.evenements) ou null si académie.
181function getActiveEvenement() {
182  if (!isCoach()) return null;
183  if (etatApp.environnement === 'pole') return null;
184  return (listeSure(cache.evenements)).find(ev => ev.cle === etatApp.environnement && ev.actif) || null;
185}
186
187// Le raccourci Palmyre reste toujours présent pour le coach, même entre deux
188// éditions. Une archive ne redevient jamais silencieusement l'environnement actif.
189function ouvrirPalmyreCoach() {
190  if (!isCoach()) return;
191  const edition = (listeSure(cache.evenementsTous)).find(ev => ev.actif);
192  if (!edition) {
193    showModal(`<div class="modal-header"><h3 class="modal-title">🏖️ Palmyre</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
194      <div class="modal-body"><div class="empty"><span class="icon">🏖️</span><strong>Aucun événement en cours</strong><br><span class="text-faint">Crée ou réactive une édition Palmyre depuis les réglages lorsque tu souhaites reprendre le travail.</span></div>
195      <button class="btn btn-primary btn-full mt-2" data-ui-click="closeModal(); openReglagesFromMenu()">Gérer les éditions Palmyre</button></div>`);
196    return;
197  }
198  setCurrentEnv(edition.cle);
199}
200// Bascule d'environnement et recharge l'écran courant.
201function setCurrentEnv(env) {
202  if (!isCoach()) return; // sécurité : le switch d'environnement est réservé au coach
203  const editionCible = env !== 'pole' && env !== 'pre_academie'
204    ? (listeSure(cache.evenementsTous)).find(ev => ev.cle === env && ev.actif)
205    : null;
206  const saisonCible = editionCible
207    ? TennisDomainRules.saisonPourDate(`${editionCible.date_debut}T12:00:00`)
208    : getSaisonCourante();
209  if (saisonCible && getSaisonAnalyse() !== saisonCible) {
210    chargerSaisonAnalyse(saisonCible).then(ok => { if (ok) setCurrentEnv(env); });
211    return;
212  }
213  etatApp.definirEnvironnement(env);
214  appliquerPerimetreMatchs();
215  cache.alertes = computeAlertes();
216  cache.bellePerfs = computeBellePerfs();
217  navigate('dashboard');
218}
219
220// Nom court de l'événement d'un match (pour l'estampille), ou null si match académie.
221function matchEvenementNom(m) {
222  if (!m || !m.evenement_id) return null;
223  const ev = (listeSure(cache.evenements)).find(e => e.id === m.evenement_id);
224  return ev ? ev.nom : 'Palmyre';
225}
226// Un joueur est-il "en Palmyre maintenant" ? (participant ET on est dans la
227// fenêtre de dates d'une édition active). Sert à afficher un badge temporaire.
228// Édition Palmyre dont l'intervalle de dates couvre une date donnée (YYYY-MM-DD).
229// Sert à rattacher automatiquement un match (saisi ou importé) à l'édition correspondante
230// quand sa date tombe pendant le stage. Contrairement à joueurEnPalmyreMaintenant(), ce
231// test porte sur la date DU MATCH, pas sur la date du jour : un match importé après le
232// stage doit quand même être rattaché s'il a été joué pendant.
233function editionPalmyrePourDate(dateStr) {
234  if (!dateStr) return null;
235  const d = String(dateStr).slice(0, 10);
236  return (listeSure(cache.evenements)).find(e =>
237    TennisDomainRules.dateDansIntervalle(d, e.date_debut, e.date_fin)) || null;
238}
239
240function joueurEnPalmyreMaintenant(joueur) {
241  if (!joueur || !joueur.participe_palmyre) return null;
242  const today = dateLocaleISO(new Date());
243  const ed = (listeSure(cache.evenements)).find(e =>
244    TennisDomainRules.dateDansIntervalle(today, e.date_debut, e.date_fin));
245  return ed || null;
246}
247
248function editionsPlanningPalmyreJoueur(joueur) {
249  return TennisDomainRules.editionsPlanningPalmyreJoueur({
250    joueur,
251    evenements: cache.evenements,
252    creneaux: cache.creneauxPalmyre,
253    aujourdHui: dateLocaleISO(new Date())
254  });
255}
256
257// Un match appartient-il à l'environnement actuellement sélectionné ?
258// En mode académie : tous les matchs comptent. En mode événement : seulement ceux de l'édition.
259function appliquerPerimetreMatchs() {
260  const tous = listeSure(cache.matchesSaison);
261  const evenement = getActiveEvenement();
262  const options = TennisDomainRules.construireOptionsPerimetreMatchs({
263    estCoach: isCoach(), environnement: etatApp.environnement,
264    evenementId: evenement?.id || null, joueurs: cache.joueurs, estPreAcademie
265  });
266  cache.matches = TennisDomainRules.matchsDansPerimetre(tous, options);
267  invaliderIndexMatchs();
268}
269// Onglet Tournois : vue unique groupée par mois (plus de bascule Liste/Calendrier).
270// Coach : calendarPlayerId = null → tous les joueurs (vue d'ensemble) ; sinon prog d'un joueur.
271// Joueur : calendarMineOnly = true → ses tournois ; false → tous (découverte/inscription).
272// Planning Palmyre côté joueur : true = mes créneaux uniquement, false = tout le planning.
273const { creerCacheVide } = TennisData;
274let cache = creerCacheVide();
275
276const CLASSEMENTS = TennisDomainRules.CLASSEMENTS;
277
278// Contrats de lecture centralisés dans data.js et exposés localement pour garder
279// les requêtes lisibles sans réintroduire de chaînes dupliquées.
280const {
281  COLONNES_PROFILE, COLONNES_NOTIFICATION, COLONNES_CONFIG, COLONNES_INVITATION,
282  COLONNES_NOTE_COACH, COLONNES_PREFERENCES, COLONNES_CRENEAU_COURS,
283  COLONNES_AFFECTATION_COURS, COLONNES_SEANCE_COURS, COLONNES_PRESENCE,
284  COLONNES_HISTORIQUE_CLASSEMENT, COLONNES_DEMANDE_CLASSEMENT,
285  COLONNES_ENTRAINEMENT, COLONNES_BADGE_JOUEUR, COLONNES_EVENEMENT,
286  COLONNES_CRENEAU_PALMYRE, COLONNES_BILAN_EVENEMENT, COLONNES_DEBRIEF_PALMYRE,
287  COLONNES_AXE_PALMYRE, COLONNES_OBSERVATION_PALMYRE, COLONNES_TOURNOI,
288  COLONNES_INSCRIPTION_TOURNOI, COLONNES_IMPORT_TOURNOI, COLONNES_RAPPROCHEMENT_TOURNOI,
289  COLONNES_BILAN, COLONNES_JOUEUR, COLONNES_MATCH
290} = TennisData.colonnes;
291
292// Ordre canonique d'affichage des groupes (du plus accessible au plus élevé),
293// Utilisé partout où on liste/ordonne les groupes pour éviter
294// l'ordre arbitraire des clés de cache.config.
295const { ORDRE_GROUPES } = TennisData;
296// Retourne les clés de groupes présentes dans la config, dans l'ordre canonique.
297// Les éventuelles clés inconnues sont ajoutées à la fin (filet de sécurité).
298function groupesOrdonnes() {
299  return TennisData.groupesOrdonnes(cache.config);
300}
301
302// Index d'un classement dans l'échelle FFT (0 = NC, plus haut = meilleur)
303function getRankIndex(c) {
304  return TennisDomainRules.classementIndex(c);
305}
306
307// ============================================================
308// HELPERS
309// ============================================================
310function $(id) { return document.getElementById(id); }
311function updateRangeValue(element, targetId) {
312  const target = $(targetId);
313  if (target) target.textContent = element?.value ?? '';
314}
315
316// Associe automatiquement les libellés aux contrôles des formulaires générés.
317// Une grande partie de l'interface est construite par chaînes HTML : ce post-traitement
318// garantit un couple label/for stable sans dupliquer manuellement des dizaines d'IDs.
319let _formControlId = 0;
320function associerLabelsFormulaires(root = document) {
321  root.querySelectorAll('label.form-label:not([for])').forEach(label => {
322    if (label.querySelector('input,select,textarea')) return; // association implicite
323    const zone = label.closest('.form-row') || label.parentElement;
324    const controles = zone
325      ? [...zone.querySelectorAll('input:not([type="hidden"]),select,textarea')]
326      : [];
327    if (controles.length !== 1) {
328      // Les choix rendus sous forme de boutons/pastilles n'ont pas de contrôle natif
329      // unique, et les widgets composites (score, période...) en ont plusieurs :
330      // leur conteneur devient un groupe nommé par le libellé.
331      const groupe = zone && (zone.querySelector('.pill-group') || zone);
332      const interactif = groupe && groupe.querySelector('button,[role="radio"],[role="checkbox"],[data-ui-click],input,select,textarea');
333      if (!groupe || !interactif) return;
334      if (!label.id) label.id = `form-label-${++_formControlId}`;
335      groupe.setAttribute('role', 'group');
336      groupe.setAttribute('aria-labelledby', label.id);
337      return;
338    }
339    const controle = controles[0];
340    if (!controle.id) controle.id = `form-control-${++_formControlId}`;
341    label.htmlFor = controle.id;
342  });
343}
344
345function auditerLabelsFormulaires(root = document) {
346  return [...root.querySelectorAll('label.form-label')].filter(label => {
347    if (label.querySelector('input,select,textarea')) return false;
348    if (label.htmlFor && document.getElementById(label.htmlFor)) return false;
349    if (!label.id) return true;
350    const zone = label.closest('.form-row') || label.parentElement;
351    const groupe = zone && (zone.querySelector(`[aria-labelledby~="${label.id}"]`)
352      || (zone.getAttribute('aria-labelledby') || '').split(/\s+/).includes(label.id) && zone);
353    return !groupe;
354  }).map(label => ({ texte: label.textContent.trim(), id: label.id || '' }));
355}
356
357// Délégation sécurisée des interactions historiques. Les attributs data-ui-*
358// remplacent les on* HTML, incompatibles avec une CSP stricte. Aucun code n'est
359// évalué dynamiquement : seuls les appels figurant dans cette liste blanche sont
360// autorisés et leurs arguments utilisent une grammaire volontairement limitée.
361const ACTIONS_UI_AUTORISEES = new Set([
362  'addNoteCoach','ajouterObservation','ajouterObservationGroupe','applyUpdate','archivePlayer',
363  'changeCodeAcces','changeCodeCoach','closeModal','closeModalDirect','closeNouveautes','confirmChangerClassementCoach',
364  'confirmDemanderClassementJoueur','confirmImportClassement','confirmTenupImport','copyCodeFromElement','runTenupSync','openTenupSyncHistory','resolveTourn
364amentMerge',
365  'debouncedSearchRender','deleteAvatar','deleteBilan','deleteBilanPalmyre','deleteCreneau',
366  'deleteCreneauCours','deleteNoteCoach','deleteNotif','deletePlayer','deleteTournoi','dismissUpdateBanner',
367  'doForgotPassword','doImportMatchsTenup','doLogin','doLogout','doSignup','doSignupCoach','editerDebrief',
368  'enregistrerEditObservation','envoyerAxeTravail','exportBilanPalmyrePDF','exportBilanPDF',
369  'exportChartConsentementPDF','exportPlanningPalmyrePDF','exportPlanningPDF','exportPresencesPDF',
370  'exportProgrammePDF','exportRegistreConsentementsPDF','exporterMesDonnees','genererBilanIA','genererDebriefIA',
371  'genererInvitation','genererInvitationDepuisModal','handleAvatarFileSelected','markAllNotifsRead',
372  'markNotifLu','navigate','onAideSearch','onBilanPeriodeChange','onDateNaissanceChange','onNotifClick',
373  'onTenupDrop','onTenupDragLeave','onTenupDragOver','onTenupFileSelect','openAideFromMenu',
374  'openAvatarUpload','openCarnetGroupe','openCarnetObservations',
375  'openChangeEmailModal','openChangePasswordModal','openChangerClassementCoach','openComparaisonEditions',
376  'openContrePerfsModal','openDemanderClassementJoueur','openEditObservation','openInscriptionCoach',
377  'openInvitationsFromMenu','openMatchDetail','openModalAppel','openModalBilan','openModalBilanPalmyre',
378  'openModalCreneau','openModalCreneauCours','openModalEdition','openModalEntrainement',
379  'openModalExportPresences','openModalImportClassement','openModalImportMatchsTenup','openModalImportTenup',
380  'openModalJoueursCreneau','openModalMatch','openModalPlayer','openModalTournoi','openMonProfilModal','openRattacherJoueurModal',
381  'openCoachPerformanceSection','openMyBilanDetail','openMyBilanPalmyreDetail','openNiveauxModal','openNouveautesFromMenu',
382  'openPlayerDetail','openNotePlayer','openPreferencesNotifsModal','openReglagesFromMenu','openValidationModal','ouvrirPalmyreCoach',
383  'ouvrirObsDepuisBilan','previewImportClassement','previewImportMatchs','rattrapageBadgesTousJoueurs',
384  'navigationRetourContextuel','rattacherJoueurInvitation','reactivatePlayer','rejectJoueur','render','resetAllPlayerFilters','retirerDuGroupe','revoquerInvitation','retryAppStart',
385  'resoudreConfirmation',
386  'sauverDebriefEdite','saveBilan','saveBilanPalmyre','saveBrouillonBilanPalmyre','saveCodeAcces','saveCodeCoach','saveCreneau',
387  'saveCreneauCours','saveEdition','saveEmail','saveEntrainement','saveJoueursCreneau','sendPasswordReset',
388  'saveMonProfilTechnique','saveMyBilanComment','savePassword','savePlayer','saveTournoi','selectObsEditTheme',
389  'selectObsGroupeTheme','selectObsTheme','selectPill','selectPillToggle','selectionnerJoueurActif','setAlertFilter','setBadgeVitrine',
390  'setCalendarMineOnly','setCalendarPlayer','setCurrentEnv','setDebriefDate','setGroupeDetail','setMatchResultFilter',
391  'setOverviewSort','setPerfFilter','setPlanningFiltreJoueur','setPlayerFilter','setPlayerFilterField',
392  'setPresence','setPresenceJoueur','openRetardJustification','savePresenceRetard','setRadarBilan','showRgpdPolicy','signupNextStep','supprimerAxe',
393  'supprimerDebrief','supprimerEntrainement','supprimerObservation','supprimerObservationGroupe','demanderSuppressionCompte',
394  'switchAuthTab','toggleAdvancedFilters','toggleAxeFait','toggleDebriefTheme','toggleEditionActive',
395  'toggleFilBadgesGroupe','toggleFiltreTournoi','toggleNotifPref','toggleNotifsPanel',
396  'togglePalmyreParticipation','togglePasswordVisibility','toggleThemeSwitch','toggleTousJoueursExportPresences','toggleTournoiInscription',
397  'toggleUserMenu','traiterDemandeClassement','updateGroupeConfig','updateRangeValue','validateJoueur','verifierMajManuelle'
398]);
399
400if (!globalThis.TennisUiGrammar) {
401  throw new Error('GRAMMAIRE_UI_INDISPONIBLE');
402}
403const { decouperExpressionUI, argumentActionUI, encoderArgumentTexteUI } = globalThis.TennisUiGrammar;
404
405function appelerActionUI(nom, sourceArgs, element, event) {
406  if (!ACTIONS_UI_AUTORISEES.has(nom)) throw new Error(`Action UI interdite : ${nom}`);
407  const action = globalThis[nom];
408  if (typeof action !== 'function') throw new Error(`Action UI indisponible : ${nom}`);
409  const args = sourceArgs.trim()
410    ? decouperExpressionUI(sourceArgs, ',').map(arg => argumentActionUI(arg, element, event))
411    : [];
412  return action(...args);
413}
414
415function executerInstructionUI(instruction, element, event) {
416  const code = instruction.trim();
417  if (!code) return;
418  if (code === 'event.preventDefault()') { event.preventDefault(); return; }
419  if (code === 'event.stopPropagation()') { event.stopPropagation(); return; }
420  if (code === 'location.reload()') { location.reload(); return; }
421
422  let match = code.match(/^document\.getElementById\((['"])(.*?)\1\)\.click\(\)$/);
423  if (match) { document.getElementById(match[2])?.click(); return; }
424
425  match = code.match(/^\$\((['"])(.*?)\1\)\.textContent\s*=\s*this\.value$/);
426  if (match) { const cible = $(match[2]); if (cible) cible.textContent = element.value; return; }
427
428  match = code.match(/^document\.getElementById\((['"])(.*?)\1\)\.style\.display\s*=\s*this\.value\s*===\s*(['"])(.*?)\3\s*\?\s*(['"])(.*?)\5\s*:\s*(['"])(.*?)\7$/);
429  if (match) {
430    const cible = document.getElementById(match[2]);
431    if (cible) cible.style.display = element.value === match[4] ? match[6] : match[8];
432    return;
433  }
434
435  match = code.match(/^(playerFilters\.search)\s*=\s*this\.value$/);
436  if (match) {
437    if (match[1] === 'playerFilters.search') playerFilters.search = element.value;
438    return;
439  }
440
441  match = code.match(/^setTimeout\(\(\)\s*=>\s*([A-Za-z_$][\w$]*)\((.*)\),\s*(\d+)\)$/);
442  if (match) {
443    const [, nom, args, delai] = match;
444    setTimeout(() => appelerActionUI(nom, args, element, event), Number(delai));
445    return;
446  }
447
448  match = code.match(/^([A-Za-z_$][\w$]*)\((.*)\)$/);
449  if (match) { appelerActionUI(match[1], match[2], element, event); return; }
450  throw new Error(`Instruction d'action UI non reconnue : ${code}`);
451}
452
453function gererActionUI(event) {
454  // Dès qu'un champ précédemment signalé redevient valide, retirer son état
455  // d'erreur sans attendre une nouvelle soumission du formulaire.
456  if ((event.type === 'input' || event.type === 'change')
457      && event.target?.matches?.('[aria-invalid="true"]')
458      && typeof event.target.checkValidity === 'function'
459      && event.target.checkValidity()) {
460    event.target.removeAttribute('aria-invalid');
461  }
462  const attribut = `data-ui-${event.type}`;
463  const element = event.target.closest(`[${attribut}]`);
464  if (!element) return;
465  try {
466    decouperExpressionUI(element.getAttribute(attribut) || '', ';')
467      .forEach(instruction =>
467 executerInstructionUI(instruction, element, event));
468    document.documentElement.dataset.uiLastError = '';
469  } catch (erreur) {
470    document.documentElement.dataset.uiLastError = erreur && erreur.message ? erreur.message : String(erreur);
471    journal.error('[action UI]', erreur, element.getAttribute(attribut));
472    toast('Cette action n’a pas pu être exécutée. Recharge la page puis réessaie.', true);
473  }
474}
475
476// Diagnostic sans exécution, utilisé par la recette automatisée pour repérer une
477// ancienne expression qui ne ferait pas partie de la grammaire autorisée.
478function auditerActionsUI(root = document) {
479  const erreurs = [];
480  const attributs = ['data-ui-click', 'data-ui-change', 'data-ui-input', 'data-ui-submit', 'data-ui-drop', 'data-ui-dragover', 'data-ui-dragleave'];
481  root.querySelectorAll(attributs.map(attr => `[${attr}]`).join(',')).forEach(element => {
482    attributs.forEach(attribut => {
483      if (!element.hasAttribute(attribut)) return;
484      const fauxEvent = { preventDefault() {}, stopPropagation() {} };
485      decouperExpressionUI(element.getAttribute(attribut) || '', ';').forEach(instruction => {
486        const code = instruction.trim();
487        if (!code || code === 'event.preventDefault()' || code === 'event.stopPropagation()' || code === 'location.reload()') return;
488        if (/^document\.getElementById\((['"])(.*?)\1\)\.click\(\)$/.test(code)) return;
489        if (/^\$\((['"])(.*?)\1\)\.textContent\s*=\s*this\.value$/.test(code)) return;
490        if (/^document\.getElementById\((['"])(.*?)\1\)\.style\.display\s*=/.test(code)) return;
491        if (/^(playerFilters\.search)\s*=\s*this\.value$/.test(code)) return;
492        const appelDiffere = code.match(/^setTimeout\(\(\)\s*=>\s*([A-Za-z_$][\w$]*)\((.*)\),\s*(\d+)\)$/);
493        const appel = appelDiffere || code.match(/^([A-Za-z_$][\w$]*)\((.*)\)$/);
494        if (!appel || !ACTIONS_UI_AUTORISEES.has(appel[1]) || typeof globalThis[appel[1]] !== 'function') {
495          erreurs.push({ attribut, code, raison: 'action inconnue' });
496          return;
497        }
498        try {
499          if (appel[2].trim()) decouperExpressionUI(appel[2], ',').forEach(arg => argumentActionUI(arg, element, fauxEvent));
500        } catch (erreur) {
501          erreurs.push({ attribut, code, raison: erreur.message });
502        }
503      });
504    });
505  });
506  return erreurs;
507}
508
509function actualiserAuditActionsUI() {
510  associerLabelsFormulaires();
511  ameliorerSemantiqueActionsUI();
512  ameliorerEtatsInterface();
513  ameliorerImagesInterface();
514  const erreurs = auditerActionsUI();
515  const erreursSemantiques = auditerSemantiqueActionsUI();
516  const erreursLabels = auditerLabelsFormulaires();
517  const erreursEtats = auditerEtatsInterface();
518  const erreursImages = auditerImagesInterface();
519  document.documentElement.dataset.uiActionErrors = String(erreurs.length);
520  document.documentElement.dataset.uiSemanticErrors = String(erreursSemantiques.length);
521  document.documentElement.dataset.uiLabelErrors = String(erreursLabels.length);
522  document.documentElement.dataset.uiStateErrors = String(erreursEtats.length);
523  document.documentElement.dataset.uiImageErrors = String(erreursImages.length);
524  if (erreurs.length) journal.warn('[audit actions UI]', erreurs);
525  if (erreursSemantiques.length) journal.warn('[audit sémantique UI]', erreursSemantiques);
526  if (erreursLabels.length) journal.warn('[audit libellés UI]', erreursLabels);
527  if (erreursEtats.length) journal.warn('[audit états UI]', erreursEtats);
528  if (erreursImages.length) journal.warn('[audit images UI]', erreursImages);
529}
530
531const SELECTEUR_ACTION_CLIC = '[data-ui-click]';
532const TAGS_INTERACTIFS_NATIFS = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY']);
533
534function estInteractifNatif(element) {
535  return !!element && (TAGS_INTERACTIFS_NATIFS.has(element.tagName) || element.isContentEditable);
536}
537
538function ameliorerSemantiqueActionsUI(root = document) {
539  root.querySelectorAll(SELECTEUR_ACTION_CLIC).forEach(element => {
540    if (estInteractifNatif(element)) return;
541    if (!element.hasAttribute('role')) element.setAttribute('role', 'button');
542    if (!element.hasAttribute('tabindex')) element.setAttribute('tabindex', '0');
543  });
544}
545
546function auditerSemantiqueActionsUI(root = document) {
547  return [...root.querySelectorAll(SELECTEUR_ACTION_CLIC)]
548    .filter(element => !estInteractifNatif(element))
549    .filter(element => element.getAttribute('role') !== 'button' || element.tabIndex < 0)
550    .map(element => ({ tag: element.tagName, action: element.getAttribute('data-ui-click') || '' }));
551}
552
553function ameliorerEtatsInterface(root = document) {
554  root.querySelectorAll('.empty').forEach(element => {
555    if (!element.hasAttribute('role')) element.setAttribute('role', 'status');
556    if (!element.hasAttribute('aria-live')) element.setAttribute('aria-live', 'polite');
557  });
558  root.querySelectorAll('.loading-screen').forEach(element => {
559    element.setAttribute('role', 'status');
560    element.setAttribute('aria-live', 'polite');
561    element.setAttribute('aria-busy', 'true');
562    if (!element.querySelector('.sr-only')) {
563      const texte = document.createElement('span');
564      texte.className = 'sr-only';
565      texte.textContent = 'Chargement…';
566      element.appendChild(texte);
567    }
568  });
569}
570
571function auditerEtatsInterface(root = document) {
572  const erreurs = [];
573  root.querySelectorAll('.empty').forEach(element => {
574    if (element.getAttribute('role') !== 'status' || element.getAttribute('aria-live') !== 'polite') erreurs.push('empty');
575  });
576  root.querySelectorAll('.loading-screen').forEach(element => {
577    if (element.getAttribute('role') !== 'status' || element.getAttribute('aria-busy') !== 'true' || !element.querySelector('.sr-only')) erreurs.push('loading');
578  });
579  return erreurs;
580}
581
582function estImageCritique(element) {
583  return !!element.closest('header, .auth-logo-frame') || element.matches('.auth-logo-img, #themeSwitchIcon');
584}
585
586function ameliorerImagesInterface(root = document) {
587  root.querySelectorAll('img').forEach(element => {
588    if (!element.hasAttribute('alt')) element.setAttribute('alt', '');
589    if (!element.hasAttribute('decoding')) element.setAttribute('decoding', 'async');
590    if (!element.hasAttribute('loading')) element.setAttribute('loading', estImageCritique(element) ? 'eager' : 'lazy');
591    if (estImageCritique(element) && !element.hasAttribute('fetchpriority')) element.setAttribute('fetchpriority', 'high');
592  });
593}
594
595function auditerImagesInterface(root = document) {
596  return [...root.querySelectorAll('img')]
597    .filter(element => !element.hasAttribute('alt') || element.getAttribute('decoding') !== 'async' || !element.hasAttribute('loading'))
598    .map(element => ({ src: element.getAttribute('src') || '', contexte: element.className || element.i
598d || element.tagName }));
599}
600
601document.addEventListener('keydown', event => {
602  if (event.key !== 'Enter' && event.key !== ' ') return;
603  const element = event.target.closest?.(SELECTEUR_ACTION_CLIC);
604  if (!element || estInteractifNatif(element)) return;
605  if (event.target !== element && estInteractifNatif(event.target)) return;
606  event.preventDefault();
607  element.click();
608});
609
610['click', 'change', 'input', 'submit', 'drop', 'dragover', 'dragleave'].forEach(type => document.addEventListener(type, gererActionUI));
611
612const gestionnaireRetours = TennisUiCommon.creerGestionnaireRetours({
613  window,
614  document,
615  navigator,
616  ids: { toast: 'toast', reseau: 'network-status' },
617  traduireErreur: TennisDomainRules.messageErreurUtilisateur,
618  journal
619});
620
621function toast(message, erreur) { return gestionnaireRetours.afficher(message, erreur); }
622
623// Point d'entrée unique pour les erreurs techniques : le détail reste dans la
624// console pour le diagnostic, mais aucun message Supabase/PostgREST brut ne
625// doit atteindre l'interface utilisateur.
626function toastErreur(error) {
627  // FunctionsHttpError conserve le vrai corps JSON dans context. Le lire sur un
628  // clone permet d'afficher 403/429/500 sans perdre la réponse pour le diagnostic.
629  const contexte = error && error.context;
630  if (contexte && typeof contexte.clone === 'function') {
631    contexte.clone().json().then(detail => {
632      const message = detail && (detail.error || detail.message);
633      if (message) gestionnaireRetours.afficherErreur({ ...error, message });
634      else gestionnaireRetours.afficherErreur(error);
635    }).catch(() => gestionnaireRetours.afficherErreur(error));
636    return null;
637  }
638  return gestionnaireRetours.afficherErreur(error);
639}
640
641async function updateConfirmee(requete, colonne = 'id') {
642  return TennisData.updateConfirmee(requete, colonne);
643}
644
645function creerIdErreur() {
646  return gestionnaireRetours.identifiantErreur();
647}
648
649function mettreAJourEtatReseau() {
650  return gestionnaireRetours.actualiserReseau();
651}
652
653function synchroniserClavierVirtuel() {
654  const viewport = window.visualViewport;
655  if (!viewport) return;
656  const hauteurReference = window.innerHeight || document.documentElement.clientHeight;
657  const clavierOuvert = hauteurReference - viewport.height > 120;
658  document.documentElement.classList.toggle('keyboard-open', clavierOuvert);
659  document.documentElement.style.setProperty('--visual-viewport-height', `${Math.round(viewport.height)}px`);
660  if (clavierOuvert && document.activeElement?.matches?.('input,select,textarea')) {
661    requestAnimationFrame(() => document.activeElement?.scrollIntoView?.({ block: 'center', behavior: 'smooth' }));
662  }
663}
664if (window.visualViewport) {
665  window.visualViewport.addEventListener('resize', synchroniserClavierVirtuel);
666  window.visualViewport.addEventListener('scroll', synchroniserClavierVirtuel);
667  synchroniserClavierVirtuel();
668}
669
670const escapeHtml = TennisFormatters.escapeHtml;
671
672function avecDelai(promise, delaiMs, code = 'DELAI_DEPASSE') {
673  return TennisAsync.avecDelai(promise, delaiMs, code, window);
674}
675
676// Empêche deux écritures identiques lorsqu'un utilisateur double-clique ou que
677// le réseau répond lentement. Tous les boutons du formulaire sont neutralisés
678// pendant l'écriture, puis restaurés même si Supabase rejette la requête.
679function verrouillerSoumission(form, libelle = 'Enregistrement…') {
680  return TennisUiCommon.verrouillerSoumission(form, libelle);
681}
682
683function deverrouillerSoumission(verrou) {
684  return TennisUiCommon.deverrouillerSoumission(verrou);
685}
686
687async function executerAvecVerrou(conteneur, operation, libelle = 'Enregistrement…') {
688  return TennisUiCommon.executerAvecVerrou(conteneur, operation, libelle);
689}
690
691function formatDate(iso) {
692  return TennisFormatters.formatDate(iso);
693}
694
695function normaliserTelephone(raw) {
696  return TennisFormatters.normaliserTelephone(raw);
697}
698
699function normaliserNomAdversaire(raw) {
700  return TennisDomainRules.normaliserNomAdversaire(raw);
701}
702
703// Clé de comparaison d'un adversaire, gérant les doubles ("Nom1 / Nom2").
704// On normalise chaque équipier puis on trie les deux, pour que l'ordre des
705// partenaires n'ait pas d'importance non plus.
706function cleAdversaire(adversaire) {
707  return TennisDomainRules.cleAdversaire(adversaire);
708}
709
710// Construit l'URL Ten'Up d'un tournoi à partir de son tenup_code.
711// Le code Ten'Up est de la forme "T 20265933013600163269" : on prend les 6 derniers chiffres
712// → https://tenup.fft.fr/tournoi/163269
713// Si pas de tenup_code (tournoi créé manuellement), fallback sur la recherche.
714function tenupTournoiUrl(tenupCode) {
715  if (!tenupCode) return 'https://tenup.fft.fr/recherche/competitions';
716  const onlyDigits = String(tenupCode).replace(/\D/g, '');
717  if (onlyDigits.length < 6) return 'https://tenup.fft.fr/recherche/competitions';
718  const last6 = onlyDigits.slice(-6);
719  return `https://tenup.fft.fr/tournoi/${last6}`;
720}
721
722// ============================================================
723// AIDE — Mode d'emploi condensé, affiché dans une modale (menu → Aide)
724// ============================================================
725// Chaque rubrique a un champ "pour" : 'tous' | 'coach' | 'joueur', utilisé pour
726// filtrer selon le rôle de l'utilisateur connecté. Contenu volontairement CONCIS
727// (l'essentiel par écran). À maintenir quand une fonctionnalité change notablement.
728const AIDE_SECTIONS = [
729  { pour: 'tous', icone: '🔐', titre: 'Connexion & compte', points: [
730    'Connecte-toi avec ton email et ton mot de passe (le bouton œil affiche le mot de passe).',
731    'Mot de passe oublié : utilise le lien sur l\'écran de connexion (l\'email peut être celui d\'un parent pour les plus jeunes).',
732    'Ton menu (ton nom, en haut à droite) : photo de profil, préférences d\'emails, changement de mot de passe, thème clair/sombre.',
733    'L\'app s\'installe sur ton téléphone (ajouter à l\'écran d\'accueil) et garde l\'onglet où tu étais.'
734  ]},
735  { pour: 'tous', icone: '🔔', titre: 'Notifications', points: [
736    'La cloche regroupe tes notifications : validations, bilans, perfs, nouveaux tournois, classements.',
737    'Touche une notification pour aller directement à l\'élément concerné.'
738  ]},
739
740  // ---------------- COACH ----------------
741  { pour: 'coach', icone: '📊', titre: 'Tableau de bord', points: [
742    'Vue d\'ensemble de l\'académie, avec un sélecteur de période (saison ou trimestre).',
743    'Belles perfs (victoires contre mieux classé), alertes triées par criticité (rouge → orange → bleu), activité récente.'
744  ]},
745  { pour: 'coach', icone: '📋', titre: 'Groupes', points: [
746    'Score santé de chaque groupe (activité, victoires, perfs, progression de classement).',
747    'Listes « Locomotives » et « À accompagner » pour repérer dynamiques et difficultés.',
748    'Les alertes de suivi concernent Accession Élite et Élite ; Tremplin conserve uniquement l’alerte sans groupe.'
749  ]},
750  { pour: 'coach', icone: '👤', titre: 'Fiche joueur', points: [
751    'Organisée en catégories : Profil technique, Statistiques (par période), Suivi global, Tournois, Matchs, Notes, Administratif.',
752    'Importe ses matchs et son historique de classement depuis Ten\'Up (copier-coller).',
753    'Les belles perfs affichent le classement du joueur au moment du match.',
754    'Notes du coach : privées, jamais visibles par le joueur ni les parents.'
755  ]},
756  { pour: 'coach', icone: '📈', titre: 'Classement', points: [
757    'Valide ou refuse les changements de classement déclarés (depuis l\'app ou l\'email).',
758    'Une montée notifie tout l\'académie ; une descente t\'est signalée discrètement.'
759  ]},
760  { pour: 'coach', icone: '🎾', titre: 'Matchs & Tournois', points: [
761    'Saisis les matchs (surface optionnelle). Importe les tournois depuis Ten\'Up.',
762    'À l\'import de tournois, les joueurs dont le profil correspond sont notifiés automatiquement.',
763    'Météo affichée sur les tournois à venir et pendant un tournoi en cours.'
764  ]},
765  { pour: 'coach', icone: '📆', titre: 'Cours & Présence', points: [
766    'Planning : crée tes créneaux (lundi→vendredi), export PDF. Deux créneaux au même horaire s\'affichent côte à côte ; un champ « Coach » indique qui les assure.',
767    'Présence : fais l\'appel (présent / absent / retard), recliquer annule le statut. Export du suivi en PDF.'
768  ]},
769  { pour: 'coach', icone: '📝', titre: 'Bilans trimestriels', points: [
770    'Note chaque joueur sur 5 axes ;
770 les stats sont calculées automatiquement.',
771    'Des rappels t\'avertissent avant la clôture du trimestre.'
772  ]},
773  { pour: 'coach', icone: '🏖️', titre: 'Stage de la Palmyre', points: [
774    'Bascule en environnement Palmyre via le sélecteur en haut : tout se limite alors aux participants et aux matchs du stage.',
775    'Inscris un joueur en un clic (pastille 🏖️) ; il est prévenu par notification et email.',
776    'Bilans de fin de semaine (5 axes dont savoir-être), carnet d\'observations (avec lieu et classement adverse, modifiables), planning et transport.',
777    'Observations et planning : le lieu se choisit dans la liste des lieux du stage.'
778  ]},
779  { pour: 'coach', icone: '🛠️', titre: 'Réglages de l\'académie', points: [
780    'Dans ton menu : groupes, code d\'accès de l\'académie (à donner aux joueurs), RGPD, éditions Palmyre.',
781    'Registre RGPD exportable en PDF (numéros de téléphone affichés en entier).'
782  ]},
783
784  // ---------------- JOUEUR ----------------
785  { pour: 'joueur', icone: '👤', titre: 'Ma fiche', points: [
786    'Ton profil, tes statistiques (assiduité, classement, forme, victoires) et tes matchs.',
787    'Touche la carte « Classement » pour déclarer un changement ; la carte « Matches » pour tout voir.',
788    'Ajoute ta photo en cliquant sur ton avatar.'
789  ]},
790  { pour: 'joueur', icone: '🎾', titre: 'Mes matches', points: [
791    'Saisis tes matchs (adversaire, score, surface, ressenti) ou importe-les depuis Ten\'Up.',
792    'Filtre par Tous / Victoires / Défaites.'
793  ]},
794  { pour: 'joueur', icone: '🏋️', titre: 'Mes entraînements', points: [
795    'Déclare tes entraînements perso : ton coach est informé et ça alimente ton assiduité.'
796  ]},
797  { pour: 'joueur', icone: '🤝', titre: 'Mon groupe', points: [
798    'Objectif collectif, membres, prochains tournois ensemble, belles perfs et dynamique du groupe.'
799  ]},
800  { pour: 'joueur', icone: '📆', titre: 'Mes cours', points: [
801    'Indique à l\'avance si tu seras présent, absent ou en retard. Un rappel arrive si tu n\'as pas répondu.'
802  ]},
803  { pour: 'joueur', icone: '📅', titre: 'Tournois', points: [
804    'Les tournois sont filtrés selon ton profil (sexe, âge, classement) — filtres activables.',
805    'Vois qui est inscrit, inscris-toi/désinscris-toi, exporte un programme en PDF.'
806  ]},
807  { pour: 'joueur', icone: '📋', titre: 'Mes bilans', points: [
808    'Consulte tes bilans une fois publiés par le coach ; le radar 5 axes montre ta progression.'
809  ]},
810  { pour: 'joueur', icone: '🏖️', titre: 'Palmyre (stage)', points: [
811    'Si tu es sélectionné, tu reçois une notification et un badge apparaît sur ta fiche.',
812    'Onglet « Mon planning » : uniquement tes créneaux personnels (heure et lieu).',
813    'Tes matchs du stage comptent dans toutes tes statistiques.'
814  ]},
815
816  { pour: 'tous', icone: '📚', titre: 'Quelques notions', points: [
817    'Belle perf : victoire contre un adversaire mieux classé (les forfaits ne comptent pas).',
818    'Contre-perf : défaite contre un adversaire moins bien classé.',
819    'Saison : de septembre à août ; les objectifs de matchs sont calculés au prorata du temps écoulé.'
820  ]}
821];
822
823
824const APP_VERSION_CURRENT = globalThis.TennisDomainRules?.APP_VERSION || 'inconnue';
825const CHANGELOG_CURRENT = Object.freeze({
826  'v4.5.76':{silencieuse:true,items:[]},
827  'v4.5.75':{silencieuse:true,items:[]},
828  'v4.5.74':{silencieuse:true,items:[]},
829  'v4.5.73':{silencieuse:true,items:[]},
830  'v4.5.72':{silencieuse:true,items:[]},
831  'v4.5.71':{silencieuse:true,items:[]},
832  'v4.5.70':{silencieuse:true,items:[]},
833  'v4.5.69':{silencieuse:true,items:[]},
834  'v4.5.68':{silencieuse:true,items:[]},
835  'v4.5.65':{silencieuse:true,items:[]},
836  'v4.5.64':{silencieuse:true,items:[]},
837  'v4.5.63':{silencieuse:true,items:[]},
838  'v4.5.62':{silencieuse:true,items:[]},
839  'v4.5.61':{silencieuse:true,items:[]},
840  'v4.5.60':{silencieuse:true,items:[]},
841  'v4.5.59':{silencieuse:true,items:[]},
842  'v4.5.58':{silencieuse:true,items:[]},
843  'v4.5.55':{silencieuse:true,items:[]},
844  'v4.5.54':{silencieuse:true,items:[]},
845  'v4.5.53':{silencieuse:true,items:[]},
846  'v4.5.52':{silencieuse:true,items:[]},
847  'v4.5.51':{titre:'Inscription Pré-Académie automatique',items:[{pour:'tous',icone:'🌱',texte:'Un joueur de moins de 12 ans tennis rejoint désormais automatiquement la Pré-Académie, sans validation du coach.'}]},
848  'v4.5.50':{silencieuse:true,titre:'Temps réel adapté au rôle joueur',items:[]},
849  'v4.5.49':{silencieuse:true,titre:'Accès direct au tableau de bord du groupe',items:[]},
850  'v4.5.48':{silencieuse:true,titre:'Actualisation temps réel des présence
850s',items:[]},
851  'v4.5.47':{silencieuse:true,titre:'Planning synchronisé entre coachs',items:[]},
852  'v4.5.46':{silencieuse:true,titre:'Trousseau iOS dans l’application installée',items:[]},
853  'v4.5.45':{silencieuse:true,titre:'Remplissage du login avec le Trousseau iOS',items:[]},
854  'v4.5.44':{silencieuse:true,titre:'Liste complète dans la fiche tournoi joueur',items:[]},
855  'v4.5.43':{silencieuse:true,titre:'Co-inscrits visibles dans le cockpit joueur',items:[]},
856  'v4.5.42':{silencieuse:true,titre:'Filtres tournois et participants plus lisibles',items:[]},
857  'v4.5.41':{silencieuse:true,titre:'Filtre des tournois avec inscrits',items:[]},
858  'v4.5.40':{silencieuse:true,titre:'Participants visibles sur le cockpit joueur',items:[]},
859  'v4.5.39':{silencieuse:true,titre:'Inscription depuis les tournois du groupe',items:[]},
860  'v4.5.38':{silencieuse:true,titre:'Rareté des badges par saison',items:[]},
861  'v4.5.37':{silencieuse:true,titre:'Participants aux tournois du groupe',items:[]},
862  'v4.5.36':{titre:'Imports Ten’Up fiabilisés',items:[
863    { pour:'joueur', icone:'🎾', texte:'L’import de l’historique accepte désormais aussi les matchs libres Ten’Up et affiche un diagnostic utile en cas d’échec.' },
864    { pour:'tous', icone:'🏆', texte:'Les cartes distinguent clairement le titre donné au tournoi de l’épreuve officielle publiée sur Ten’Up.' }
865  ]},
866  'v4.5.35':{titre:'Toutes tes séances et ton engagement récompensé',items:[
867    { pour:'joueur', icone:'📆', texte:'L’accueil affiche toutes tes prochaines séances et ta première réponse de présence rapporte 5 XP.' }
868  ]},
869  'v4.5.34':{titre:'Un compte parent pour plusieurs enfants',items:[
870    { pour:'joueur', icone:'👨‍👩‍👧‍👦', texte:'Un parent peut maintenant rattacher plusieurs joueurs à son compte et passer facilement de l’un à l’autre.' }
871  ]},
872  'v4.5.33':{silencieuse:true,titre:'Diagnostic Ten’Up explicite',items:[]},
873  'v4.5.32':{silencieuse:true,titre:'PDF planning corrigé',items:[]},
874  'v4.5.30':{silencieuse:true,titre:'Protection du quota Ten’Up',items:[]},
875  'v4.5.29':{silencieuse:true,titre:'Planning coach optimisé',items:[]},
876  'v4.5.28':{silencieuse:true,titre:'Codes d’inscription différenciés',items:[]},
877  'v4.5.27':{silencieuse:true,titre:'Planning temps réel',items:[]},
878  'v4.5.26':{silencieuse:true,titre:'Planning',items:[]},
879  'v4.5.25':{silencieuse:true,titre:'Synchronisation Ten’Up protégée',items:[]},
880  'v4.5.24':{silencieuse:true,titre:'Compteur de performances',items:[]},
881  'v4.5.23':{silencieuse:true,titre:'Suivi Palmyre',items:[]},
882  'v4.5.22':{silencieuse:true,titre:'Alertes Palmyre',items:[]},
883  'v4.5.21':{silencieuse:true,titre:'Palmyre isolée',items:[]},
884  'v4.5.20':{silencieuse:true,titre:'Périmètre Palmyre',items:[]},
885  'v4.5.19':{silencieuse:true,titre:'Retour',items:[]},
886  'v4.5.18': { silencieuse:true, titre:'Accès aux performances', items:[] },
887  'v4.5.17': { silencieuse:true, titre:'Navigation du cockpit coach', items:[] },
888  'v4.5.16': { silencieuse:true, titre:'Nouveau cockpit coach', items:[] },
889  'v4.5.15': { silencieuse:true, titre:'Âges tournoi', items:[] },
890  'v4.5.14': { silencieuse:true, titre:'Filtres FFT', items:[] },
891  'v4.5.10': { silencieuse:true, titre:'Tournois terminés masqués', items:[] },
892  'v4.5.9': { silencieuse:true, titre:'Détection des mises à jour accélérée', items:[] },
893  'v4.5.8': { titre:'La Pré-Académie gagne en autonomie', items:[
894    { pour:'coach', iconeImage:'assets/v4/menu/invitations.webp', texte:'Une invitation active peut être révoquée et disparaît automatiquement avec la fiche du joueur.' },
895    { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'La Pré-Académie permet de créer ses plateaux et ne montre que les compétitions adaptées aux jeunes.' },
896    { pour:'tous', iconeImage:'assets/v4/nav/performances.webp', texte:'Les classements 40/2 et 40/1 sont disponibles, et la Pré-Académie accueille désormais les enfants dès 7 ans.' }
897  ]},
898  'v4.5.7': { titre:'Les montées sont de très belles performances', items:[
899    { pour:'coach', iconeImage:'assets/v4/nav/performances.webp', texte:'Le cockpit annonce désormais chaque montée de classement validée c
899omme une très belle perf.' },
900    { pour:'joueur', iconeImage:'assets/v4/nav/performances.webp', texte:'Le mur de ton groupe célèbre maintenant les montées de classement, en plus des belles victoires.' }
901  ]},
902  'v4.5.6': { titre:'Le planning Académie reste accessible', items:[
903    { pour:'coach', iconeImage:'assets/v4/nav/planning.webp', texte:'Le bouton Planning est de nouveau visible en Académie pour créer et gérer les créneaux de cours.' }
904  ]},
905  'v4.5.5': { titre:'Une seule édition Palmyre à la fois', items:[
906    { pour:'coach', iconeImage:'assets/v4/nav/palmyre.webp', texte:'Archive l’édition Palmyre en cours avant d’en créer une nouvelle ; chaque édition conserve son propre historique.' }
907  ]},
908  'v4.5.4': { titre:'Le bilan reste dans le bon environnement', items:[
909    { pour:'coach', iconeImage:'assets/v4/nav/reports.webp', texte:'Depuis une fiche joueur Palmyre, le bouton Bilan ouvre désormais directement le bilan de la semaine.' },
910    { pour:'coach', iconeImage:'assets/v4/menu/update.webp', texte:'L’import de l’historique de classement est désormais réservé aux fiches Académie.' }
911  ]},
912  'v4.5.3': { titre:'La fiche Palmyre reste centrée sur le stage', items:[
913    { pour:'coach', iconeImage:'assets/v4/nav/observations.webp', texte:'La fiche joueur Palmyre masque désormais entièrement les notes et la gestion administrative de l’Académie.' }
914  ]},
915  'v4.5.2': { titre:'Une fiche joueur Palmyre plus claire', items:[
916    { pour:'coach', iconeImage:'assets/v4/nav/observations.webp', texte:'La fiche joueur Palmyre s’appuie uniquement sur les observations du stage, sans doublon avec les notes Académie.' }
917  ]},
918  'v4.5.1': { titre:'Un suivi Palmyre cohérent pour tous', items:[
919    { pour:'coach', iconeImage:'assets/v4/progress/objectif_groupe-v2.webp', texte:'Les joueurs sans groupe Académie affichent désormais leur progression de matchs pendant la Palmyre.' }
920  ]},
921  'v4.5.0': { titre:'Pilotage coach et suivi sportif renforcés', items:[
922    { pour:'coach', iconeImage:'assets/v4/menu/coach_notes.webp', texte:'Un accès Notes permet de choisir rapidement un joueur ; ses notes privées sont aussi visibles pendant la préparation du bilan.' },
923    { pour:'coach', iconeImage:'assets/v4/nav/planning.webp', texte:'À la création d’un créneau, tu peux immédiatement choisir les joueurs, et les changements de planning remontent en temps réel.' },
924    { pour:'tous', iconeImage:'assets/v4/progress/objectif_groupe-v2.webp', texte:'Les axes de travail partagés sont maintenant disponibles toute l’année en Académie, en plus de la Palmyre.' },
925    { pour:'coach', iconeImage:'assets/v4/nav/palmyre.webp', texte:'Les éditions Palmyre archivées restent accessibles pour terminer, publier ou consulter les bilans.' },
926    { pour:'coach', iconeImage:'assets/v4/nav/presence.webp', texte:'Les notifications de cours signalent uniquement les absences et retards utiles.' }
927  ]},
928  'v4.4.10': { titre:'Tous les tournois détaillés en une seule synchronisation', items:[
929    { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Une seule synchronisation parcourt maintenant tout le catalogue Ten’Up et signale honnêtement toute fiche inaccessible.' },
930    { pour:'joueur', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Les épreuves officielles améliorent la sélection des tournois selon ton sexe, ton âge et ton classement.' }
931  ] },
932  'v4.4.9': { titre:'Des tournois fiables et réellement à jour', items:[
933    { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'La synchronisation Ten’Up distingue clairement un traitement terminé, partiel ou interrompu et complète en priorité les fiches sans épreuves.' },
934    { pour:'joueur', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Le catalogue propose uniquement les tournois auxquels il est encore pertinent de s’inscrire ; un tournoi en cours reste accessible dans « Mes tournois ».' }
935  ] },
936  'v4.4.8': { titre:'Davantage de contrôle pour le coach', items:[
937    { pour:'coach', iconeImage:'assets/v4/nav/pre_academie.webp', texte:'L’espace Pré
937-Académie reste visible toute l’année, même lorsqu’aucun joueur n’y est actuellement rattaché.' },
938    { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Le catalogue Ten’Up peut être synchronisé immédiatement ; le bilan indique aussi les fiches dont les épreuves ne sont pas encore publiées.' }
939  ] },
940  'v4.4.7': { titre:'Une interface cohérente et des matchs mieux qualifiés', items:[
941    { pour:'tous', iconeImage:'assets/v4/menu/update.webp', texte:'La typographie est désormais identique sur tous les écrans, pour les joueurs comme pour les coachs.' },
942    { pour:'tous', iconeImage:'assets/v4/nav/matches.webp', texte:'Un match libre homologué FFT peut maintenant être déclaré sans l’associer à un tournoi.' }
943  ] },
944  'v4.4.6': { titre:'Les fiches tournoi sont plus compactes', items:[
945    { pour:'tous', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Un repère FFT discret et la distance depuis Bouliac rendent chaque tournoi plus facile à lire.' }
946  ] },
947  'v4.4.5': { titre:'L’accès aux tournois est rétabli', items:[
948    { pour:'tous', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Les listes de tournois s’ouvrent de nouveau normalement pour les joueurs et les coachs.' }
949  ] },
950  'v4.4.4': { titre:'Des tournois mieux ciblés et plus lisibles', items:[
951    { pour:'joueur', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Les propositions de tournois tiennent compte des épreuves réelles : sexe, âge et classement, tout en laissant accessibles les catégories Senior.' },
952    { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Chaque tournoi importé depuis Ten’Up affiche ses épreuves détaillées et un repère FFT immédiatement identifiable.' }
953  ] },
954  'v4.4.3': { titre:'Les nouveautés sont propres à chaque compte', items:[
955    { pour:'tous', iconeImage:'assets/v4/menu/whats_new.webp', texte:'Lire les nouveautés sur un compte ne les masque plus pour les autres profils utilisés sur le même appareil.' }
956  ] },
957  'v4.4.2': { titre:'Les tournois suivent la saison sportive', items:[
958    { pour:'tous', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Le catalogue affiche uniquement les tournois de la saison sportive en cours ; les saisons précédentes sont archivées.' }
959  ] },
960  'v4.4.1': { titre:'Les tournois Ten’Up restent à jour automatiquement', items:[
961    { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Le catalogue public Ten’Up est désormais contrôlé toutes les 6 heures dans un rayon de 50 km autour de Bouliac.' }
962  ] },
963  'v4.4.0': { titre:'Les tournois Ten’Up arrivent automatiquement', items:[
964    { pour:'coach', iconeImage:'assets/v4/nav/tournaments.webp', texte:'Les tournois Ten’Up alimentent désormais automatiquement le catalogue, tout en conservant l’import manuel et le contrôle des doublons.' }
965  ] }
966});
967globalThis.TennisCurrentChangelog = CHANGELOG_CURRENT;
968
969let CHANGELOG = null;
970let promesseChangelog = null;
971
972async function chargerChangelog() {
973  if (CHANGELOG) return CHANGELOG;
974  if (promesseChangelog) return promesseChangelog;
975  promesseChangelog = (async () => {
976    const disponible = await chargerVendor('changelog');
977    CHANGELOG = Object.freeze({ ...(disponible ? globalThis.TennisChangelog : {}), ...(globalThis.TennisCurrentChangelog || {}) });
978    return CHANGELOG;
979  })();
980  try {
981    return await promesseChangelog;
982  } finally {
983    promesseChangelog = null;
984  }
985}
986
987// ============================================================
988// MODULE MÉTÉO — Géocodage + Open-Meteo (gratuit, sans clé API)
989// ============================================================
990// Affiche la météo prévue pour un tournoi entre J-7 et J-0.
991// Cache localStorage pour éviter les appels répétés (24h pour géocodage, 1h pour météo).
992
993const METEO_CACHE_PREFIX = 'pcb_meteo_v2_'; // v2 : invalide les anciens échecs (avant dico Gironde)
994const GEO_CACHE_PREFIX = 'pcb_geo_v3_'; // v3 : dico Gironde local prioritaire
995const METEO_TTL_MS = 60 * 60 * 1000;      // 1 heure pour les données météo
996const GEO_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 jours pour le géocodage (les villes ne bougent pas)
997
998// ============================================================
999// DICTIONNAIRE LOCAL DES COMMUNES DE GIRONDE
1000// ============================================================
1001// L'académie est à Bouliac (33) : la quasi-totalité des tournois sont girondins.
1002// Le format Ten'Up dégrade les noms (accents perdus, "ST" abrégé, apostrophes en espaces),
1003// ce qui fait échouer le géocodage API. Ce dictionnaire local résout ces cas
1004// instantanément et sans ambiguïté. Coordonnées [lat, lon].
1005// Clé = nom normalisé (sans accent, majuscules, ST/STE abrégés, séparateurs unifiés).
1006const COMMUNES_GIRONDE = {
1007  'AMBARES ET LAGRAVE': [44.9274, -0.4914],
1008  'ANDERNOS LES BAINS': [44.7457, -1.1036],
1009  'ARCACHON': [44.6613, -1.1725],
1010  'ARTIGUES PRES BORDEAUX': [44.8589, -0.4972],
1011  'AUDENGE': [44.6837, -1.0134],
1012  'BAZAS': [44.4316, -0.2134],
1013  'BEGLES': [44.8076, -0.5514],
1014  'BIGANOS': [44.6442, -0.9784],
1015  'BLANQUEFORT': [44.9106, -0.6376],
1016  'BORDEAUX': [44.8412, -0.5805],
1017  'BOULIAC': [44.8193, -0.5027],
1018  'BRUGES': [44.8756, -0.6056],
1019  'CADAUJAC': [44.7552, -0.5302],
1020  'CANEJAN': [44.763, -0.6544],
1021  'CARBON BLANC': [44.895, -0.5018],
1022  'CASTELNAU DE MEDOC': [45.0269, -0.7991],
1023  'CENON': [44.8561, -0.5335],
1024  'CESTAS': [44.7447, -0.6819],
1025  'CREON': [44.7746, -0.3482],
1026  'CUBZAC LES PONTS': [44.9712, -0.4536],
1027  'EYSINES': [44.8844, -0.6514],
1028  'FARGUES ST HILAIRE': [44.8238, -0.445],
1029  'FLOIRAC': [44.8306, -0.5268],
1030  'GRADIGNAN': [44.7736, -0.6139],
1031  'GUJAN MESTRAS': [44.636, -1.0681],
1032  'LA BREDE': [44.6833, -0.5333],
1033  'LA REOLE': [44.5824, -0.0386],
1034  'LA TESTE DE BUCH': [44.6328, -1.1451],
1035  'LACANAU': [44.9791, -1.0796],
1036  'LANGON': [44.5528, -0.2499],
1037  'LATRESNE': [44.7852, -0.4966],
1038  'LE BOUSCAT': [44.8649, -0.5986],
1039  'LE HAILLAN': [44.8716, -0.6769],
1040  'LE PIAN MEDOC': [44.9552, -0.6702],
1041  'LE TEICH': [44.6339, -1.0244],
1042  'LEGE CAP FERRET': [44.8, -1.15],
1043  'LEOGNAN': [44.729, -0.6005],
1044  'LIBOURNE': [44.9145, -0.2419],
1045  'LORMONT': [44.8765, -0.5192],
1046  'MARTIGNAS SUR JALLE': [44.8451, -0.783],
1047  'MERIGNAC': [44.8425, -0.6451],
1048  'PESSAC': [44.8056, -0.6324],
1049  'PODENSAC': [44.6509, -0.3557],
1050  'ST ANDRE DE CUBZAC': [44.9945, -0.4444],
1051  'ST LOUBES': [44.9172, -0.4286],
1052  'ST MEDARD D EYRANS': [44.7167, -0.5167],
1053  'ST MEDARD EN JALLES': [44.8955, -0.7178],
1054  'STE FOY LA GRANDE': [44.8407, 0.2172],
1055  'TALENCE': [44.8085, -0.5891],
1056  'TARGON': [44.7351, -0.2637],
1057  'TRESSES': [44.8488, -0.4638],
1058  'VILLENAVE D ORNON': [44.7794, -0.5671]
1059};
1060
1061// Normalise un nom de lieu en clé de dictionnaire :
1062// sans accent, majuscules, apostrophes/tirets → espaces, SAINT→ST, SAINTE→STE.
1063// "Saint-Médard-d'Eyrans" et "ST MEDARD D EYRANS" donnent la même clé.
1064const serviceMeteoTournois = TennisTournamentWeather.creerService({
1065  fetchFn: (...args) => fetch(...args), storage: localStorage, document,
1066  domainRules: TennisDomainRules, escapeHtml, dateLocaleISO,
1067  journal,
1068  coordonneesLocales: cle => COMMUNES_GIRONDE[cle] || null,
1069  maintenant: () => new Date(), geoPrefix: GEO_CACHE_PREFIX,
1070  meteoPrefix: METEO_CACHE_PREFIX, geoTtlMs: GEO_TTL_MS, meteoTtlMs: METEO_TTL_MS
1071});
1072const dateMeteoTournoi = serviceMeteoTournois.dateMeteoTournoi;
1073const hydrateMeteoPlaceholders = serviceMeteoTournois.hydrateMeteoPlaceholders;
1074const hydrateMeteoEnrichie = serviceMeteoTournois.hydrateMeteoEnrichie;
1075// ============================================================
1076// FILTRE TOURNOIS PAR GENRE — Approche textuelle souple
1077// ============================================================
1078// Le champ tournois.categorie est un texte libre. On scanne les mots-clés.
1079// Par défaut, un tournoi sans aucun marqueur de genre est gardé (par sécurité,
1080// on n'exclut jamais un tournoi par défaut).
1081
1082// Détecte le(s) genre(s) présent(s) dans un texte de catégorie.
1083// Retourne { hasMasculin, hasFeminin, hasNone }
1084function detectGenresTournoi(categorieText) {
1085  return TennisDomainRules.detectGenresTournoi(categorieText);
1086}
1087
1088function detectAgeTournoi(categorieText) {
1089  return TennisDomainRules.detectAgeTournoi(categorieText);
1090}
1091
1092// Extrait la fourchette de classement d'un libellé de tournoi.
1093// Renvoie { min, max } en INDICES de l'échelle CLASSEMENTS (min = moins bien classé,
1094// max = mieux classé), ou hasNone si rien détecté.
1095function detectClassementTournoi(categorieText) {
1096  return TennisDomainRules.detectClassementTournoi(categorieText);
1097}
1098
1099function inscritsNomsPole(tournoi) {
1100  const ids = listeSure(tournoi.inscrits);
1101  const annu = cache.annuaire || {};
1102  return ids
1103    .map(id => {
1104      if (annu[id]) return annu[id];
1105      const j = joueurById(id);
1106      if (!j) return null;
1107      return `${j.prenom}${j.nom ? ' ' + j.nom.charAt(0).toUpperCase() + '.' : ''}`;
1108    })
1109    .filter(Boolean)
1110    .sort((a, b) => a.localeCompare(b, 'fr'));
1111}
1112
1113function inscritsNomsPoleCollectifs(tournoi) {
1114  const ligneCollective = listeSure(cache.prochainsTournoisGroupe).find(ligne => String(ligne.tournoi_id) === String(tournoi.id));
1115  return ligneCollective && listeSure(ligneCollective.participants).length
1116    ? listeSure(ligneCollective.participants)
1117    : inscritsNomsPole(tournoi);
1118}
1119
1120function autresInscritsNomsPole(tournoi, joueurId) {
1121  const noms = inscritsNomsPoleCollectifs(tournoi);
1122  const joueur = joueurById(joueurId);
1123  const nomJoueur = (cache.annuaire || {})[joueurId] || (joueur ? `${joueur.prenom}${joueur.nom ? ' ' + joueur.nom[0].toUpperCase() + '.' : ''}` : '');
1124  return [...new Set(noms.filter(Boolean))].filter(nom => nom !== nomJoueur).sort((a, b) => a.localeCompare(b, 'fr'));
1125}
1126
1127// Correspondance globale selon les critères ACTIFS (objet filtresTournoi).
1128// Découpe le champ catégorie en sous-épreuves. Un tournoi peut proposer plusieurs
1129// catégories séparées par ·, |, ; ou retour à la ligne (ex. "15/16 SM · 13/14 SD").
1130// Le tournoi correspond-il au joueur, selon les critères actifs ?
1131// Gère les catégories MULTIPLES : le tournoi correspond si AU MOINS UNE sous-épreuve
1132// correspond aux 3 critères. Le nom du tournoi fournit un contexte commun (genre/âge
1133// parfois indiqués uniquement dans le titre), combiné à chaque sous-catégorie.
1134function tournoiCorrespondAuJoueur(tournoi, joueur, criteres) {
1135  return TennisDomainRules.tournoiCorrespondAuJoueur(tournoi, joueur, criteres, getSaisonCourante());
1136}
1137
1138// État des filtres tournoi (par critère), persisté et isolé par compte.
1139// Par défaut pour un joueur : les 3 critères actifs (sexe + âge + classement).
1140function getFiltresTournoi() {
1141  const estJoueur = profile?.role === 'joueur' && !!profile.joueur_id;
1142  return etatApp.lireFiltresTournoi(profile?.id, estJoueur);
1143}
1144// Active/désactive un critère et redessine
1145function toggleFiltreTournoi(critere) {
1146  const estJoueur = profile?.role === 'joueur' && !!profile.joueur_id;
1147  if (!etatApp.basculerFiltreTournoi(profile?.id, critere, estJoueur)) return;
1148  if (etatApp.ecran === 'calendar') $('app').innerHTML = renderCalendar();
1149}
1150
1151// Applique les filtres tournoi (selon critères actifs) à une liste de tournois.
1152function filterTournoisParGenre(tournois) {
1153  // Coach : pas de filtrage automatique
1154  if (isCoach()) return tournois;
1155  if (!profile || !profile.joueur_id) return tournois;
1156  const j = joueurById(profile.joueur_id);
1157  if (!j) return tournois;
1158  const tournoisDuProfil = estPreAcademie(j)
1159    ? tournois.filter(t => TennisDomainRules.tournoiPourJoueurPreAcademie(t, j, getSaisonCourante()))
1160    : tournois;
1161  const criteres = getFiltresTournoi();
1162  if (!criteres.sexe && !criteres.age && !criteres.classement) return tournoisDuProfil;
1163  return tournoisDuProfil.filter(t => tournoiCorrespondAuJoueur(t, j, criteres));
1164}
1165
1166const avatarService = TennisAvatars.creerService({
1167  db, document, ImageCtor: Image, URLApi: URL, escapeHtml,
1168  getProfile: () => profile, getJoueurs: () => cache.joueurs,
1169  joueurById, isCoach, toast, toastErreur, demanderConfirmation,
1170  closeModal, updateHeaderAvatar, render, maintenant: () => Date.now()
1171});
1172function initials(prenom, nom) { return avatarService.initiales(prenom, nom); }
1173function avatarDisplayUrl(joueur) { return avatarService.urlAffichage(joueur); }
1174async function chargerUrlsAvatarsSignees() { return avatarService.chargerUrlsSignees(); }
1175async function rafraichirAvatarsAuPremierPlan() {
1176  if (!session || !_appInitialized) return;
1177  const modifies = await avatarService.chargerUrlsSignees();
1178  if (!modifies) return;
1179  avatarService.actualiserDom();
1180  updateHeaderAvatar();
1181}
1182function avatarHtml(joueur, size = 'md') { return avatarService.html(joueur, size); }
1183function updateHeaderPalmyreBadge() {
1184  const roleEl = $('user-role');
1185  if (!roleEl || !profile) return;
1186  // Seulement pour un joueur (le coach a son sélecteur d'environnement)
1187  if (profile.role === 'joueur' && profile.joueur_id) {
1188    const j = (listeSure(cache.joueurs)).find(x => x.id === profile.joueur_id);
1189    const ed = joueurEnPalmyreMaintenant(j);
1190    if (ed) {
1191      roleEl.innerHTML = `<span style="display:inline-block; background:var(--orange); color:#fff; padding:1px 7px; border-radius:10px; font-size:10px; font-weight:700; letter-spacing:0.3px">🏖️ ${escapeHtml(ed.nom)}</span>`;
1192      return;
1193    }
1194  }
1195  // Sinon, rôle normal
1196  roleEl.textContent = profile.role;
1197}
1198
1199function updateHeaderAvatar() {
1200  const slot = $('user-avatar');
1201  if (!slot || !profile) return;
1202
1203  // Prénom/nom à utiliser pour les initiales : le profile en priorité
1204  let prenom = profile.prenom || '';
1205  let nom = profile.nom || '';
1206  let photoUrl = null;
1207
1208  // Si joueur, on tente d'utiliser sa photo
1209  if (profile.role === 'joueur' && profile.joueur_id) {
1210    const j = (listeSure(cache.joueurs)).find(x => x.id === profile.joueur_id);
1211    if (j) {
1212      prenom = j.prenom || prenom;
1213      nom = j.nom || nom;
1214      photoUrl = avatarDisplayUrl(j);
1215    }
1216  }
1217
1218  if (photoUrl) {
1219    slot.style.backgroundImage = `url('${photoUrl}')`;
1220    slot.textContent = '';
1221  } else {
1222    slot.style.backgroundImage = '';
1223    slot.textContent = initials(prenom, nom);
1224  }
1225}
1226
1227// ============================================================
1228// UPLOAD PHOTO DE PROFIL — modale, compression et envoi Supabase Storage
1229// ============================================================
1230
1231// Ouvre la modale d'upload pour le joueur donné
1232// (le joueur peut modifier sa propre photo, le coach peut modifier celle de n'importe quel joueur)
1233function openAvatarUpload(joueurId) {
1234  const j = joueurById(joueurId);
1235  if (!j) { toast('Joueur introuvable', true); return; }
1236
1237  // Permission : le joueur lui-même OU un coach
1238  const peutModifier = isCoach() || (profile && profile.joueur_id === joueurId);
1239  if (!peutModifier) { toast('Tu ne peux modifier que ta propre photo', true); return; }
1240
1241  const html = `<div class="modal-header"><h3 class="modal-title" style="display:flex;align-items:center;gap:10px"><img class="modal-title-art" src="assets/v4/nav/camera.webp" alt="">Photo de profil</h3><button class="modal-close" data-ui-click="closeModalDirect()">×</button></div>
1242    <div style="text-align:center; padding:8px 0">
1243      <div style="display:inline-block; position:relative">
1244        ${avatarHtml(j, 120)}
1245      </div>
1246      <div class="text-faint" style="font-size:12px; margin-top:8px">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</div>
1247    </div>
1248
1249    <div class="card" style="padding:14px; margin-top:12px">
1250      <div class="text-dim" style="font-size:13px; margin-bottom:10px">
1251        Formats acceptés : JPG, PNG, WebP. Taille max : 5 Mo (compression automatique).
1252      </div>
1253      <input type="file" id="avatarFileInput" accept="image/jpeg,image/png,image/webp" style="width:100%; padding:8px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px" data-ui-change="handleAvatarFileSelected(event, ${escapeHtml(encoderArgumentTexteUI(joueurId))})">
1254      <div id="avatarUploadStatus" class="text-faint mt-2" style="font-size:12px; text-align:center"></div>
1255    </div>
1256
1257    ${j.photo_url ? `<div class="mt-2" style="text-align:center">
1258      <button class="btn btn-sm btn-danger" data-ui-click="deleteAvatar(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img class="btn-action-art" src="assets/v4/menu/player_archive.webp" alt="">Supprimer la photo (revenir aux initiales)</button>
1259    </div>` : ''}
1260
1261    <div class="flex gap-1 mt-2">
1262      <button class="btn flex-1" data-ui-click="closeModalDirect()">Fermer</button>
1263    </div>`;
1264
1265  showModal(html);
1266}
1267
1268// Compresse une image côté client (canvas) pour rester sous ~200 Ko
1269// Retourne un Blob JPEG prêt à l'upload
1270async function handleAvatarFileSelected(event, joueurId) {
1271  return avatarService.traiterFichier(event, joueurId);
1272}
1273async function deleteAvatar(joueurId) {
1274  return avatarService.supprimer(joueurId);
1275}
1276function isCoach() {
1277  return profile && profile.role === 'coach';
1278}
1279
1280// ============================================================
1281// SAISON / TRIMESTRE (FFT : sept→août)
1282// ============================================================
1283function getSaisonCourante() {
1284  return TennisDomainRules.saisonPourDate(new Date());
1285}
1286
1287function getTrimestreCourant() {
1288  return TennisDomainRules.trimestrePourDate(new Date());
1289}
1290
1291function getTrimestreDates(saison, trimestre) {
1292  return TennisDomainRules.trimestreDates(saison, trimestre);
1293}
1294
1295function getTrimestreLabel(t) {
1296  return { T1: 'T1 · Sept-Nov', T2: 'T2 · Déc-Fév', T3: 'T3 · Mars-Mai', T4: 'T4 · Juin-Août', ANNUEL: '🏆 Annuel' }[t] || t;
1297}
1298
1299// ============================================================
1300// PÉRIODE D'ANALYSE (filtre réutilisable) — saison chargée à la demande
1301// ============================================================
1302// État de période par écran (défaut = trimestre en cours).
1303etatApp.initialiserPeriodes(getTrimestreCourant());
1304const periodes = etatApp.periodes;
1305function setPeriodeMesEntrainements(p) { etatApp.definirPeriode('mesEntrainements', p); render(); }
1306
1307function getSaisonAnalyse() {
1308  if (!etatApp.saisonAnalyse) etatApp.definirSaisonAnalyse(getSaisonCourante());
1309  return etatApp.saisonAnalyse || getSaisonCourante();
1310}
1311
1312function getPeriodeDates(periode) {
1313  if (!isPoleEnv()) {
1314    const ev = getActiveEvenement();
1315    if (ev) {
1316      let debut = ev.date_debut || '2000-01-01';
1317      let fin = ev.date_fin || '2100-12-31';
1318      (listeSure(cache.matchesSaison)).forEach(m => {
1319        if (!m.date || m.evenement_id !== ev.id) return;
1320        const d = String(m.date).slice(0, 10);
1321        if (d < debut) debut = d;
1322        if (d > fin) fin = d;
1323      });
1324      return [debut, fin];
1325    }
1326    return ['2000-01-01', '2100-12-31'];
1327  }
1328  const saison = getSaisonAnalyse();
1329  if (periode === 'saison') return TennisDomainRules.bornesSaison(saison);
1330  return getTrimestreDates(saison, periode);
1331}
1332
1333// Libellé court pour affichage
1334function getPeriodeLabel(periode) {
1335  // En mode événement (Palmyre), il n'y a pas de trimestre : on parle de la semaine.
1336  if (!isPoleEnv()) return 'Toute la semaine';
1337  if (periode === 'saison') return 'Saison complète';
1338  return getTrimestreLabel(periode);
1339}
1340
1341function renderSaisonSelector() {
1342  if (!isPoleEnv()) return '';
1343  const saisonAnalyse = getSaisonAnalyse();
1344  return TennisSeasonArchives.rendreSelecteurSaison({
1345    saisonCourante: getSaisonCourante(),
1346    saisonsDisponibles: cache.saisonsDisponibles,
1347    saisonSelectionnee: saisonAnalyse,
1348    bornesSaison: TennisDomainRules.bornesSaison
1349  });
1350}
1351
1352// Composant : barre de sélection de période. `onChangeFn` identifie une action
1353// explicitement autorisée, déclenchée par le gestionnaire partagé ci-dessous.
1354function renderPeriodeSelector(courant, onChangeFn, options = {}) {
1355  // En mode événement (Palmyre), pas de sélecteur de période : on affiche toute
1356  // la semaine de l'édition. On montre juste un libellé.
1357  if (!isPoleEnv()) {
1358    const ev = getActiveEvenement();
1359    return `<div class="card mb-2" style="padding:8px; text-align:center">
1360      <div class="text-faint" style="font-size:12px">🏖️ ${ev ? escapeHtml(ev.nom) : 'Palmyre'} · toute la semaine</div>
1361    </div>`;
1362  }
1363  const saisonAnalyse = getSaisonAnalyse();
1364  return TennisSeasonArchives.rendreBarrePeriode({
1365    selecteurSaison: options.afficherSaison === false ? '' : renderSaisonSelector(),
1366    periodeCourante: courant,
1367    action: onChangeFn,
1368    libellePeriode: getPeriodeLabel(courant),
1369    saison: saisonAnalyse
1370  });
1371}
1372
1373// Bornes saison courante complète (réutilisé pour les stats "année tennistique")
1374function getSaisonDates() {
1375  return getPeriodeDates('saison');
1376}
1377
1378// Le démarrage ne charge que la saison sportive active. Les archives restent
1379// disponibles via leurs écrans dédiés, mais ne grossissent plus le payload à
1380// chaque ouverture au fil des années.
1381function getBornesChargementSaison(saison = getSaisonAnalyse()) {
1382  return TennisDomainRules.bornesSaison(saison);
1383}
1384
1385async function chargerPresencesSaison(saison = getSaisonAnalyse()) {
1386  const [debut, fin] = getBornesChargementSaison(saison);
1387  return TennisSeasonArchives.chargerPresences({
1388    db, colonnesPresence: COLONNES_PRESENCE, debut, fin, chargerToutesPages
1389  });
1390}
1391
1392async function executerChargementSaisonAnalyse(saison) {
1393  if (!TennisDomainRules.bornesSaison(saison)[0]) return false;
1394  if (saison === getSaisonAnalyse() && cache.matchesSaison.length) return true;
1395
1396  return (async () => {
1397    const [debut, fin] = getBornesChargementSaison(saison);
1398    toast(`Chargement de la saison ${saison}…`);
1399    const resultat = await TennisSeasonArchives.chargerDonnees({
1400      db,
1401      contrats: { matches: COLONNES_MATCH, entrainements: COLONNES_ENTRAINEMENT, seances: COLONNES_SEANCE_COURS },
1402      debut,
1403      fin,
1404      chargerToutesPages,
1405      chargerPresencesSaison: () => chargerPresencesSaison(saison)
1406    });
1407    if (resultat.error) throw resultat.error;
1408    const { matches: matchesRes, entrainements: entrainementsRes, seances: seancesRes, presences: presencesRes } = resultat;
1409
1410    cache.matchesSaison = listeSure(matchesRes.data);
1411    cache.entrainements = listeSure(entrainementsRes.data);
1412    cache.seancesCours = listeSure(seancesRes.data);
1413    cache.presences = listeSure(presencesRes.data);
1414    etatApp.definirSaisonAnalyse(saison);
1415    appliquerPerimetreMatchs();
1416    invaliderIndexMatchs();
1417    cache.alertes = computeAlertes();
1418    cache.bellePerfs = computeBellePerfs();
1419    render();
1420    if (etatApp.joueurDetailId && document.getElementById('modal')?.classList.contains('show')) {
1421      openPlayerDetail(etatApp.joueurDetailId);
1422    }
1423    toast(`Saison ${saison} chargée`);
1424    return true;
1425  })().catch(error => {
1426    journal.error('[saison] chargement impossible', error);
1427    toast('Impossible de charger cette saison pour le moment.', true);
1428    render();
1429    return false;
1430  });
1431}
1432
1433const chargerSaisonAnalyse = TennisSeasonArchives.creerChargeurDerniereDemande(executerChargementSaisonAnalyse);
1434
1435document.addEventListener('change', event => {
1436  const select = event.target.closest('[data-saison-analyse]');
1437  if (!select) return;
1438  chargerSaisonAnalyse(select.value);
1439});
1440
1441// Setters de période (rerendent l'écran courant)
1442function setPeriodeDashboard(p) { etatApp.definirPeriode('dashboard', p); render(); }
1443function setPeriodePerfs(p) { etatApp.definirPeriode('perfs', p); render(); }
1444function setPeriodePlayerDetail(p) { etatApp.definirPeriode('playerDetail', p); if (etatApp.joueurDetailId) openPlayerDetail(etatApp.joueurDetailId); }
1445function setPeriodeMyProfile(p) { etatApp.definirPeriode('myProfile', p); render(); }
1446function setPeriodeGroupe(p) { etatApp.definirPeriode('groupe', p); render(); }
1447
1448const PERIODE_HANDLERS = {
1449  setPeriodeDashboard,
1450  setPeriodePerfs,
1451  setPeriodePlayerDetail,
1452  setPeriodeMyProfile,
1453  setPeriodeGroupe,
1454  setPeriodeMesEntrainements
1455};
1456
1457document.addEventListener('click', event => {
1458  const bouton = event.target.closest('[data-periode-handler][data-periode-value]');
1459  if (!bouton) return;
1460  const action = PERIODE_HANDLERS[bouton.dataset.periodeHandler];
1461  if (action) action(bouton.dataset.periodeValue);
1462});
1463
1464// Détecte un match gagné/perdu par forfait (WO / walkover). Un WO n'est pas un
1465// résultat sportif : on l'exclut notamment du calcul des contre-perfs.
1466// On cherche les variantes courantes dans le score et les notes.
1467function estWalkover(match) {
1468  return TennisDomainRules.estWalkover(match);
1469}
1470
1471// Comptage de matchs pour les STATISTIQUES : les forfaits (WO) sont exclus. Un WO n'est
1472// pas un match joué (l'adversaire ne s'est pas présenté), il ne doit donc jamais compter
1473// ni comme match joué, ni comme victoire, ni dans le taux de victoire. Les WO restent en
1474// revanche visibles dans les LISTES de matchs (ils ont bien eu lieu dans le calendrier).
1475function matchsHorsWO(liste) {
1476  return TennisDomainRules.matchsSportifs(liste);
1477}
1478// Renvoie {total, victoires, defaites} en ignorant les WO. Base commune à tous les écrans.
1479function comptesMatchs(liste) {
1480  return TennisDomainRules.comptesMatchs(liste);
1481}
1482
1483// Un WO reste consultable dans l'historique mais ne doit jamais être présenté
1484// visuellement comme une victoire ou une défaite jouée.
1485function resultatMatchHtml(match) {
1486  if (estWalkover(match)) return '<span class="match-result-wo">WO</span>';
1487  const classe = match && match.victoire ? 'win' : 'loss';
1488  const libelle = match && match.victoire ? 'V' : 'D';
1489  return `<span class="${classe}">${libelle} ${escapeHtml((match && match.score) || '')}</span>`;
1490}
1491
1492// Définition canonique d'une belle perf : victoire sportive, simple ou double,
1493// contre le meilleur adversaire du match, avec les classements valides à la date.
1494function detailBellePerf(match, joueurId) {
1495  return TennisDomainRules.detailBellePerf(match, classementADate(joueurId, match && match.date));
1496}
1497
1498// Contre-perfs d'un joueur sur la saison : défaites contre un adversaire de
1499// classement strictement INFÉRIEUR (un adversaire qu'il aurait dû battre). Miroir des
1500// belles perfs. Une défaite à classement équivalent n'est PAS une contre-perf, et
1501// les défaites par forfait (WO) sont exclues (pas un résultat sportif).
1502// Renvoie les matchs concernés, triés du plus récent au plus ancien.
1503function computeContrePerfs(joueurId, periode) {
1504  const j = joueurById(joueurId);
1505  if (!j) return [];
1506  const [debut, fin] = periode ? getPeriodeDates(periode) : getSaisonDates();
1507  const matches = matchsDuJoueur(joueurId).map(match => ({
1508    ...match,
1509    classement_joueur_date: classementADate(joueurId, match.date)
1510  }));
1511  return TennisDomainRules.contrePerfsSurPeriode(matches, debut, fin);
1512}
1513
1514// Nombre de matchs d'un joueur sur la saison en cours
1515function matchesSaisonJoueur(joueurId) {
1516  // En mode événement (Palmyre), cache.matches est déjà restreint à l'édition :
1517  // on compte tous ces matchs sans filtre de saison.
1518  if (!isPoleEnv()) {
1519    return matchsHorsWO(matchsDuJoueur(joueurId)).length;
1520  }
1521  const [debut, fin] = getSaisonDates();
1522  return matchsHorsWO(matchsDuJoueur(joueurId).filter(m => TennisDomainRules.dateDansIntervalle(m.date, debut, fin))).length;
1523}
1524
1525// Modal : liste des contre-perfs d'un joueur (sur la période), chacune ouvre le match
1526function openContrePerfsModal(joueurId, periode) {
1527  const j = joueurById(joueurId);
1528  if (!j) { toast('Joueur introuvable', true); return; }
1529  const cps = computeContrePerfs(joueurId, periode);
1530  const labelPeriode = periode ? getPeriodeLabel(periode) : 'Saison complète';
1531
1532  let html = `<div class="modal-header">
1533    <h3 class="modal-title">Contre-perfs de ${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</h3>
1534    <button class="modal-close" data-ui-click="closeModal()">×</button>
1535  </div>
1536  <div class="text-faint mb-2" style="font-size:12px">
1537    ${escapeHtml(labelPeriode)} · défaites contre un adversaire moins bien classé (${cps.length}). Touche un match pour l'ouvrir.
1538  </div>`;
1539
1540  if (cps.length === 0) {
1541    html += `<div class="empty"><span class="icon">✓</span>Aucune contre-perf cette saison.</div>`;
1542  } else {
1543    html += `<div class="card" style="padding:8px">`;
1544    cps.forEach((m, i) => {
1545      html += `<div class="match-row clickable" data-ui-click="openModalMatch(${escapeHtml(encoderArgumentTexteUI(m.id))})" style="${i < cps.length - 1 ? 'border-bottom:1px solid var(--border)' : ''}" title="Ouvrir ce match">
1546        <div class="match-result-row">
1547          <span class="match-opponent">vs ${escapeHtml(m.adversaire)} <span class="text-faint">(${escapeHtml(m.adversaire_class || '?')})</span></span>
1548          <span class="loss">D ${escapeHtml(m.score || '')}</span>
1549        </div>
1550        <div class="match-meta">${escapeHtml(m.tournoi || 'Match')} · ${formatDate(m.date)}</div>
1551      </div>`;
1552    });
1553    html += `</div>`;
1554  }
1555
1556  showModal(html);
1557}
1558
1559function getProrataObjectif() {
1560  const ordre = ['T1', 'T2', 'T3', 'T4'];
1561  const courant = getTrimestreCourant();
1562  const index = ordre.indexOf(courant);
1563  if (index < 0) return TennisDomainRules.fractionIntervalleEcoulee(...getSaisonDates(), Date.now());
1564  const acquis = ordre.slice(0, index).reduce((total, periode) => total + TennisDomainRules.POIDS_SAISONNIERS[periode], 0);
1565  return Math.min(1, acquis + TennisDomainRules.POIDS_SAISONNIERS[courant] * getProrataPeriode(courant));
1566}
1567
1568function getProrataPeriode(periode) {
1569  const [debutStr, finStr] = getPeriodeDates(periode);
1570  return TennisDomainRules.fractionIntervalleEcoulee(debutStr, finStr, Date.now(), 0.001);
1571}
1572
1573// Vrai si on est dans le 1er tiers de la période EN COURS. Sert à neutraliser le
1574// label d'alerte d'un groupe en tout début de trimestre (score mécaniquement bas).
1575// Retourne false pour une période passée (déjà terminée) ou future.
1576function estDebutDePeriode(periode) {
1577  const [debutStr, finStr] = getPeriodeDates(periode);
1578  return TennisDomainRules.estPremierTiersIntervalle(debutStr, finStr, new Date());
1579}
1580
1581// Poids saisonniers des objectifs trimestriels de matchs.
1582// Plutôt qu'une moyenne plate (25 %/trimestre), l'objectif annuel d'un groupe est
1583// réparti selon la densité réelle de tournois dans un rayon de 30 km autour de
1584// Bouliac. Source : exports Ten'Up adultes + jeunes (saison 2025-2026, 595 tournois
1585// uniques), chaque tournoi compté comme 1 unité répartie au prorata de ses jours
1586// d'ouverture par trimestre. Un trimestre creux en tournois porte moins d'objectif,
1587// un trimestre dense en porte plus. La somme des poids vaut 1.
1588//   T1 sept-nov : 22 %  ·  T2 déc-fév : 16 %  ·  T3 mars-mai : 30 %  ·  T4 juin-août : 32 %
1589// Poids d'un trimestre (fraction de l'objectif annuel). Repli sur 0.25 (moyenne
1590// plate) si la clé est inconnue, par sécurité.
1591function getObjectifBasePeriode(objectifAnnuel, periode) {
1592  return TennisDomainRules.objectifBasePeriode(objectifAnnuel, periode);
1593}
1594
1595// Objectif de matchs attendu pour un groupe sur une période donnée, proratisé par
1596// l'avancement dans la période (getProrataPeriode, inchangé).
1597function getObjectifPeriode(objectifAnnuel, periode) {
1598  return getObjectifBasePeriode(objectifAnnuel, periode) * getProrataPeriode(periode);
1599}
1600
1601// Objectif PLEIN d'une période arrondi à l'ENTIER, AFFICHABLE.
1602// Pour les trimestres, on utilise un arrondi CUMULATIF : on arrondit le cumul
1603// (T1, puis T1+T2, …) et on prend les écarts. Ainsi T1+T2+T3+T4 = objectif annuel
1604// EXACTEMENT (les arrondis indépendants pouvaient faire ±1 sur la somme).
1605// Saison : objectif annuel entier.
1606function getObjectifBasePeriodeEntier(objectifAnnuel, periode) {
1607  return TennisDomainRules.objectifBasePeriodeEntier(objectifAnnuel, periode);
1608}
1609
1610// Récupère les matches d'un joueur sur une période bilan
1611function matchesEntreDate(joueurId, dateDebut, dateFin) {
1612  // Utilisée uniquement pour des CALCULS de stats (bilans, synthèses, séries). Les WO en
1613  // sont exclus : un forfait n'est pas un match joué. Les listes d'affichage n'utilisent
1614  // pas cette fonction, les WO y restent donc visibles.
1615  return matchsHorsWO(matchsDuJoueur(joueurId).filter(m =>
1616    TennisDomainRules.dateDansIntervalle(m.date, dateDebut, dateFin)
1617  ));
1618}
1619
1620// ============================================================
1621// NOTIFICATIONS
1622// ============================================================
1623const centreNotifications = TennisNotifications.creerCentre({
1624  db, cache, getSession: () => session, element: $, escapeHtml,
1625  encoderArgument: encoderArgumentTexteUI, toastErreur,
1626  colonnes: COLONNES_NOTIFICATION, maintenant: () => Date.now()
1627}
1627);
1628async function loadNotifications() { return centreNotifications.charger(); }
1629function updateNotifBadge() { return centreNotifications.actualiserBadge(); }
1630function toggleNotifsPanel(event) { return centreNotifications.basculerPanneau(event); }
1631function toggleUserMenu(e) {
1632  if (e) { e.stopPropagation(); }
1633  const menu = $('userMenu');
1634  if (!menu) return;
1635  const isOpen = menu.style.display !== 'none';
1636  // Fermer aussi le panneau notifs s'il était ouvert
1637  const notifsPanel = $('notifsPanel');
1638  if (notifsPanel) notifsPanel.style.display = 'none';
1639  // L'entrée "Réglages de l'académie" n'est visible que pour le coach
1640  // Sous-titre de "Mon profil" : le coach n'a pas de profil technique
1641  const profilSub = $('userMenuProfilSub');
1642  if (profilSub) profilSub.textContent = isCoach() ? 'Réglages, invitations, compte' : 'Profil technique, email, mot de passe';
1643  menu.style.display = isOpen ? 'none' : 'block';
1644}
1645
1646// Ouvre les réglages de l'académie depuis le menu utilisateur (coach)
1647function openReglagesFromMenu() {
1648  const menu = $('userMenu');
1649  if (menu) menu.style.display = 'none';
1650  navigate('settings');
1651}
1652
1653// Ouvre les invitations depuis le menu utilisateur (coach)
1654function openInvitationsFromMenu() {
1655  const menu = $('userMenu');
1656  if (menu) menu.style.display = 'none';
1657  navigate('invitations');
1658}
1659
1660// Ouvre la modale des préférences de notifications (joueur ET coach)
1661// La modale détecte le rôle et affiche les bonnes prefs.
1662function openPreferencesNotifsModal() {
1663  // Fermer le menu utilisateur
1664  const menu = $('userMenu');
1665  if (menu) menu.style.display = 'none';
1666
1667  // Construire le contenu selon le rôle
1668  const html = `<div class="modal-header">
1669      <h3 class="modal-title">🔔 Mes préférences d'emails</h3>
1670      <button class="modal-close" data-ui-click="closeModalDirect()">×</button>
1671    </div>
1672    ${renderPreferencesNotifs(isCoach() ? 'coach' : 'joueur', { noTitle: true })}
1673    <div class="flex gap-1 mt-2">
1674      <button class="btn flex-1" data-ui-click="closeModal()">Fermer</button>
1675    </div>`;
1676  showModal(html);
1677}
1678
1679// ============================================================
1680// NOUVEAUTÉS (What's New) — modale cumulative affichée après mise à jour
1681// ============================================================
1682const LAST_SEEN_VERSION_KEY = 'pcb_last_seen_version';
1683
1684// Plusieurs comptes (joueur et coach) utilisent souvent le même appareil.
1685// La version vue doit donc être isolée par utilisateur ET par rôle : une lecture
1686// côté joueur ne peut plus acquitter silencieusement les nouveautés du coach.
1687function cleDerniereVersionVue() {
1688  const userId = session?.user?.id || profile?.id || 'anonyme';
1689  const role = isCoach() ? 'coach' : 'joueur';
1690  return `${LAST_SEEN_VERSION_KEY}:${userId}:${role}`;
1691}
1692
1693function lireDerniereVersionVue() {
1694  try { return localStorage.getItem(cleDerniereVersionVue()); } catch (e) { return null; }
1695}
1696
1697function memoriserDerniereVersionVue(version = APP_VERSION_CURRENT) {
1698  try { localStorage.setItem(cleDerniereVersionVue(), version); } catch (e) { /* ignore */ }
1699}
1700
1701// Compare deux versions "vX.Y.Z". Retourne -1 si a<b, 0 si égal, 1 si a>b.
1702function compareVersions(a, b) {
1703  return TennisDomainRules.comparerVersions(a, b);
1704}
1705
1706// Retourne la liste des versions du CHANGELOG strictement postérieures à `depuis`
1707// (ou toutes si `depuis` est null), triées de la plus récente à la plus ancienne.
1708function entreeNouveauteVisiblePourRole(entry, role) {
1709  return TennisDomainRules.entreeNouveauteVisiblePourRole(entry, role);
1710}
1711
1712function versionsDepuis(depuis, role) {
1713  return TennisDomainRules.versionsDepuisChangelog(CHANGELOG, depuis, role);
1714}
1715
1716// Affiche la modale des nouveautés pour une liste de versions (groupées par version).
1717// Si versionsList est vide, ne fait rien.
1718function renderNouveautesModal(versionsList) {
1719  const menu = $('userMenu');
1720  if (menu) menu.style.display = 'none';
1721
1722  const role = isCoach() ? 'coach' : 'joueur';
1723
1724  // Construire les blocs par version, en ne gardant que les versions ayant au moins
1725  // un item pour ce rôle
1726  const blocs = [];
1727  versionsList.forEach(v => {
1728    const entry = CHANGELOG[v];
1729    if (!entreeNouveauteVisiblePourRole(entry, role)) return;
1730    const items = entry.items.filter(it => it && (it.pour === 'tous' || it.pour === role));
1731    if (items.length > 0) blocs.push({ version: v, titre: entry.titre, items });
1732  });
1733
1734  if (blocs.length === 0) {
1735    // Rien à montrer pour ce rôle : on mémorise quand même pour ne pas re-tenter à chaque chargement
1736    memoriserDerniereVersionVue();
1737    return;
1738  }
1739
1740  // Combien de versions cumulées ? (pour le sous-titre)
1741  const multi = blocs.length > 1;
1742
1743  let html = `<div class="modal-header">
1744      <h3 class="modal-title" style="display:flex;align-items:center;gap:10px"><img class="modal-title-art" src="assets/v4/menu/whats_new.webp" alt=""> Nouveautés</h3>
1745      <button class="modal-close" data-ui-click="closeNouveautes()">×</button>
1746    </div>`;
1747
1748  if (multi) {
1749    html += `<div style="text-align:center; margin-bottom:14px; font-size:13px; color:var(--text-dim)">
1750      Voici tout ce qui a changé depuis ta dernière visite 👇
1751    </div>`;
1752  }
1753
1754  blocs.forEach(bloc => {
1755    html += `<div style="text-align:${multi ? 'left' : 'center'}; margin:${multi ? '16px 0 10px' : '0 0 14px'}">
1756      <span style="display:inline-block; background:var(--orange); color:#fff; font-size:12px; font-weight:600; padding:3px 12px; border-radius:12px">Version ${escapeHtml(bloc.version)}</span>
1757      <span style="font-size:14px; font-weight:600; margin-left:8px">${escapeHtml(bloc.titre)}</span>
1758    </div>`;
1759    bloc.items.forEach(it => {
1760      html += `<div class="card" style="padding:12px; margin-bottom:8px; display:flex; gap:12px; align-items:flex-start">
1761        ${it.iconeImage
1762          ? `<img class="menu-art-icon" src="${escapeHtml(it.iconeImage)}" alt="" style="width:38px;height:38px;flex-shrink:0">`
1763          : `<div style="font-size:22px; flex-shrink:0; line-height:1.2">${escapeHtml(it.icone || '✨')}</div>`}
1764        <div style="font-size:13px; line-height:1.5">${escapeHtml(it.texte)}</div>
1765      </div>`;
1766    });
1767  });
1768
1769  html += `<div class="flex gap-1 mt-2">
1770      <button class="btn btn-primary flex-1" data-ui-click="closeNouveautes()">J'ai compris 👍</button>
1771    </div>`;
1772
1773  showModal(html);
1774}
1775
1776// Ferme la modale et mémorise la version courante comme "vue"
1777function closeNouveautes() {
1778  memoriserDerniereVersionVue();
1779  closeModal();
1780}
1781
1782// Vérifie au démarrage s'il faut afficher les nouveautés (versions non vues)
1783// Garde : évite que checkNouveautes s'exécute plusieurs fois (onAuthChange peut être
1784// appelé 2 fois au démarrage : appel direct + événement onAuthStateChange).
1785let _nouveautesChecked = false;
1786let _nouveautesRetries = 0;
1787
1788function reprogrammerNouveautes() {
1789  _nouveautesChecked = false;
1790  if (_nouveautesRetries >= 2) return;
1791  _nouveautesRetries += 1;
1792  setTimeout(() => checkNouveautes(), 3000 * _nouveautesRetries);
1793}
1794
1795async function checkNouveautes() {
1796  if (_nouveautesChecked) return; // déjà traité dans ce chargement de page
1797  _nouveautesChecked = true;
1798
1799  let lastSeen = lireDerniereVersionVue();
1800
1801  // Une valeur locale corrompue n'est ni un nouvel utilisateur ni une ancienne
1802  // version : on la répare sans ouvrir l'intégralité de l'historique.
1803  if (lastSeen !== null && !TennisDomainRules.versionValide(lastSeen)) {
1804    memoriserDerniereVersionVue();
1805    return;
1806  }
1807
1808  // Cas 1 — Aucune version mémorisée.
1809  // Cela couvre DEUX situations indistinguables côté navigateur :
1810  //  a) un utilisateur réellement nouveau,
1811  //  b) un utilisateur existant qui découvre le système de nouveautés pour la 1re fois.
1812  // Pour ne pas priver les utilisateurs existants du changelog, on montre les nouveautés
1813  // les plus récentes disponibles. On prend les entrées du changelog jusqu'à 2 versions
1814  // en arrière pour donner un aperçu utile sans noyer (et sans dépendre du fait que la
1815  // version courante ait ou non sa propre entrée — cas d'un simple c
1815orrectif).
1816  if (!lastSeen) {
1817    if (!await chargerChangelog()) {
1818      reprogrammerNouveautes();
1819      return;
1820    }
1821    const role = isCoach() ? 'coach' : 'joueur';
1822    const toutes = versionsDepuis(null, role);
1823    const aMontrer = toutes.slice(0, 2); // les 2 entrées les plus récentes
1824    if (aMontrer.length > 0) {
1825      setTimeout(() => renderNouveautesModal(aMontrer), 800);
1826    } else {
1827      memoriserDerniereVersionVue();
1828    }
1829    return;
1830  }
1831
1832  // Cas 2 — Déjà à jour : rien à faire
1833  if (compareVersions(lastSeen, APP_VERSION_CURRENT) >= 0) return;
1834
1835  if (!await chargerChangelog()) {
1836    reprogrammerNouveautes();
1837    return;
1838  }
1839
1840  // Cas 3 — Mise à jour : on montre toutes les versions ratées (cumulatif)
1841  const role = isCoach() ? 'coach' : 'joueur';
1842  const aMontrer = versionsDepuis(lastSeen, role);
1843  if (aMontrer.length === 0) {
1844    memoriserDerniereVersionVue();
1845    return;
1846  }
1847
1848  setTimeout(() => renderNouveautesModal(aMontrer), 800);
1849}
1850
1851// Ouvre les nouveautés depuis le menu utilisateur : on montre la dernière version
1852// qui a RÉELLEMENT du contenu pour le rôle de l'utilisateur (la version courante peut
1853// être un simple correctif sans entrée, ou une entrée qui ne concerne pas ce rôle —
1854// on ne veut jamais d'une modale vide).
1855async function openNouveautesFromMenu() {
1856  if (!await chargerChangelog()) {
1857    toast('Les nouveautés sont momentanément indisponibles');
1858    return;
1859  }
1860  const role = isCoach() ? 'coach' : 'joueur';
1861  const cible = TennisDomainRules.derniereVersionVisibleChangelog(CHANGELOG, role);
1862  if (!cible) {
1863    toast('Aucune nouveauté à afficher pour le moment');
1864    return;
1865  }
1866  renderNouveautesModal([cible]);
1867}
1868
1869// État de la recherche dans la modale d'aide. 'var' (et non 'let') pour éviter toute
1870// zone morte temporelle si la fonction est appelée tôt.
1871var _aideRecherche = '';
1872
1873// Ouvre la modale d'aide (mode d'emploi condensé), filtrée selon le rôle.
1874function openAideFromMenu() {
1875  const menu = $('userMenu');
1876  if (menu) menu.style.display = 'none';
1877  _aideRecherche = '';
1878  renderAideModal();
1879}
1880
1881function onAideSearch(v) {
1882  _aideRecherche = v || '';
1883  const cont = $('aideContenu');
1884  if (cont) cont.innerHTML = renderAideContenu();
1885}
1886
1887// Construit le HTML des rubriques d'aide pour le rôle courant, filtrées par recherche.
1888function renderAideContenu() {
1889  const role = isCoach() ? 'coach' : 'joueur';
1890  const q = _aideRecherche.trim().toLowerCase();
1891  const sections = AIDE_SECTIONS.filter(s => s.pour === 'tous' || s.pour === role);
1892
1893  let html = '';
1894  let nbAffichees = 0;
1895  sections.forEach(s => {
1896    // Filtre recherche : on garde la rubrique si le titre OU un point matche
1897    let points = s.points;
1898    if (q) {
1899      const titreMatch = s.titre.toLowerCase().includes(q);
1900      if (!titreMatch) points = s.points.filter(p => p.toLowerCase().includes(q));
1901      if (!titreMatch && points.length === 0) return; // rien dans cette rubrique
1902    }
1903    nbAffichees++;
1904    html += `<div class="card" style="padding:14px; margin-bottom:10px">
1905      <div style="display:flex; align-items:center; gap:10px; margin-bottom:8px">
1906        <span style="font-size:22px">${s.icone}</span>
1907        <strong style="font-size:15px">${escapeHtml(s.titre)}</strong>
1908      </div>
1909      <ul style="margin:0; padding-left:20px; display:flex; flex-direction:column; gap:6px">
1910        ${points.map(p => `<li style="font-size:13px; line-height:1.5">${escapeHtml(p)}</li>`).join('')}
1911      </ul>
1912    </div>`;
1913  });
1914
1915  if (nbAffichees === 0) {
1916    html = `<div class="text-faint text-center" style="padding:24px; font-style:italic">Aucune rubrique ne correspond à ta recherche.</div>`;
1917  }
1918  return html;
1919}
1920
1921function renderAideModal() {
1922  const role = isCoach() ? 'coach' : 'joueur';
1923  const sousTitre = role === 'coach'
1924    ? 'Tout ce que tu peux faire en tant que coach.'
1925    : 'Tout ce que tu peux faire dans l\'application.';
1926
1927  let html = `<div class="modal-header">
1928      <h3 class="modal-title">❓ Aide — Mode d'emploi</h3>
1929      <button class="modal-close" data-ui-click="closeModal()">×</button>
1930    </div>
1931    <div style="text-align:center; font-size:13px; color:var(--text-dim); margin-bottom:12px">${sousTitre}</div>
1932      <input type="search" id="aideRechercheInput" data-ui-input="onAideSearch(this.value)" placeholder="Rechercher une fonctionnalité…" style="width:100%; margin-bottom:14px" autocomplete="off">
1933    <div id="aideContenu">${renderAideContenu()}</div>
1934    <div class="flex gap-1 mt-2">
1935      <button class="btn btn-primary flex-1" data-ui-click="closeModal()">Fermer</button>
1936    </div>`;
1937  showModal(html);
1938}
1939
1940// Ouvre la modale de changement de mot de passe (utilisateur connecté)
1941// Modale "Mon profil" : regroupe profil technique (éditable par le joueur),
1942// adresse email, mot de passe et préférences d'emails.
1943function openMonProfilModal() {
1944  const menu = $('userMenu');
1945  if (menu) menu.style.display = 'none';
1946
1947  const estJoueur = !!(profile && profile.joueur_id) && !isCoach();
1948  const j = estJoueur ? joueurById(profile.joueur_id) : null;
1949
1950  let html = `<div class="modal-header">
1951      <h3 class="modal-title">👤 Mon profil</h3>
1952      <button class="modal-close" data-ui-click="closeModal()">×</button>
1953    </div>
1954    <div class="modal-body">`;
1955
1956  // Profil technique éditable (uniquement pour un joueur)
1957  if (estJoueur && j) {
1958    html += TennisFamilyAccounts.renderSelector();
1959    html += `<div class="section-title">🎯 Mon profil technique</div>
1960      <div class="form-row"><label class="form-label">Mes points forts <span class="text-faint" style="font-weight:400">(séparés par virgule)</span></label>
1961        <input type="text" id="monProfilPointsForts" value="${escapeHtml((listeSure(j.points_forts)).join(', '))}" placeholder="Coup droit, service, mental..."></div>
1962      <div class="form-row"><label class="form-label">Mes axes de progrès <span class="text-faint" style="font-weight:400">(séparés par virgule)</span></label>
1963        <input type="text" id="monProfilAxes" value="${escapeHtml((listeSure(j.axes_progres)).join(', '))}" placeholder="Volée, retour, endurance..."></div>
1964      <div class="mt-1"><button class="btn btn-success btn-full" data-ui-click="saveMonProfilTechnique()">Enregistrer mon profil technique</button></div>`;
1965  }
1966
1967  // Gestion de l'académie (coach uniquement) : réglages + invitations
1968  if (isCoach()) {
1969    html += `<div class="section-title"><img class="section-art-icon" src="assets/v4/menu/academy_management.webp" alt="">Gestion de l'académie</div>
1970      <div style="display:flex; flex-direction:column; gap:8px">
1971        <button class="btn btn-full" data-ui-click="closeModal(); openReglagesFromMenu()"><img class="btn-art-icon" src="assets/v4/menu/academy_settings.webp" alt="">Réglages de l'académie</button>
1972        <button class="btn btn-full" data-ui-click="closeModal(); openInvitationsFromMenu()"><img class="btn-art-icon" src="assets/v4/menu/invitations.webp" alt="">Invitations</button>
1973      </div>`;
1974  }
1975
1976  // Accès aux réglages de compte
1977  html += `<div class="section-title" style="margin-top:18px"><img class="section-art-icon" src="assets/v4/menu/account.webp" alt="">
1977Mon compte</div>
1978    <div style="display:flex; flex-direction:column; gap:8px">`;
1979  if (estJoueur) {
1980    html += `<button class="btn btn-full" data-ui-click="openChangeEmailModal()"><img class="btn-art-icon" src="assets/v4/menu/email_preferences.webp" alt="">Mon adresse email</button>`;
1981  }
1982  html += `<button class="btn btn-full" data-ui-click="openChangePasswordModal()"><img class="btn-art-icon" src="assets/v4/menu/password.webp" alt="">Changer mon mot de passe</button>
1983      <button class="btn btn-full" data-ui-click="openPreferencesNotifsModal()"><img class="btn-art-icon" src="assets/v4/menu/email_preferences.webp" alt="">Préférences d'emails</button>
1984      ${estJoueur ? `<button class="btn btn-full" data-ui-click="exporterMesDonnees()"><img class="btn-art-icon" src="assets/v4/menu/consent_download.webp" alt="">Exporter mes données</button>
1985      <button class="btn btn-full" data-ui-click="demanderSuppressionCompte()" style="color:var(--danger)"><img class="btn-art-icon" src="assets/v4/menu/player_archive.webp" alt="">Demander la suppression de mon compte</button>` : ''}
1986    </div>`;
1987
1988  html += `</div>`;
1989  showModal(html);
1990}
1991
1992function getFamilyAccountsContext() {
1993  return { db, joueurs: listeSure(cache.joueurs), userId: session?.user?.id, activeId: profile?.joueur_id,
1994    escapeHtml, encoderArgumentTexteUI, messageErreurUtilisateur: TennisDomainRules.messageErreurUtilisateur };
1995}
1996
1997// Portabilité RGPD : export local des seules données visibles et rattachées au
1998// joueur connecté. Le fichier n'est transmis à aucun tiers et ne contient ni
1999// jeton de session, ni données d'autres membres de l'académie.
2000function exporterMesDonnees() {
2001  if (!profile?.joueur_id) { toast('Export disponible depuis un compte joueur.', true); return; }
2002  const joueurId = profile.joueur_id;
2003  const joueur = joueurById(joueurId);
2004  if (!joueur) { toast('Ta fiche joueur est temporairement indisponible.', true); return; }
2005  const seanceIds = new Set((listeSure(cache.presences)).filter(p => p.joueur_id === joueurId).map(p => p.seance_id));
2006  const exportRgpd = {
2007    exporte_le: new Date().toISOString(),
2008    politique_confidentialite_version: RGPD_POLICY_VERSION,
2009    compte: { email: session?.user?.email || null, role: profile.role || 'joueur' },
2010    fiche_joueur: joueur,
2011    matches: (listeSure(cache.matchesSaison)).filter(m => m.joueur_id === joueurId),
2012    entrainements_libres: (listeSure(cache.entrainements)).filter(e => e.joueur_id === joueurId),
2013    presences: (listeSure(cache.presences)).filter(p => p.joueur_id === joueurId),
2014    seances_associees: (listeSure(cache.seancesCours)).filter(s => seanceIds.has(s.id)),
2015    bilans: (listeSure(cache.bilans)).filter(b => b.joueur_id === joueurId),
2016    bilans_evenement: (listeSure(cache.bilansEvenement)).filter(b => b.joueur_id === joueurId),
2017    axes_travail: (listeSure(cache.axesPalmyre)).filter(a => a.joueur_id === joueurId),
2018    badges: (listeSure(cache.badgesJoueurs)).filter(b => b.joueur_id === joueurId),
2019    historique_classement: (listeSure(cache.classementHistorique)).filter(h => h.joueur_id === joueurId),
2020    tournois: (listeSure(cache.tournois)).filter(t => (listeSure(t.inscrits)).includes(joueurId)).map(t => ({
2021      id: t.id, nom: t.nom, date_debut: t.date_debut, date_fin: t.date_fin, lieu: t.lieu, categorie: t.categorie
2022    }))
2023  };
2024  const blob = new Blob([JSON.stringify(exportRgpd, null, 2)], { type: 'application/json;charset=utf-8' });
2025  const url = URL.createObjectURL(blob);
2026  const lien = document.createElement('a');
2027  const nom = `${joueur.prenom || 'joueur'}-${joueur.nom || 'tennis-bouliac'}`.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
2028  lien.href = url;
2029  lien.download = `mes-donnees-tennis-bouliac-${nom || 'joueur'}.json`;
2030  document.body.appendChild(lien);
2031  lien.click();
2032  lien.remove();
2033  setTimeout(() => URL.revokeObjectURL(url), 1000);
2034  toast('Ton export de données a été téléchargé.');
2035}
2036
2037// L'effacement n'est volontairement pas automatique : les données sportives
2038// d'un mineur peuvent être liées à des bilans et obligations de conservation.
2039// La demande est néanmoins self-service, authentifiée et adressée aux coachs.
2040async function demanderSuppressionCompte() {
2041  if (!profile?.joueur_id) { toast('Demande disponible depuis un compte joueur.', true); return; }
2042  const confirme = await demanderConfirmation(
2043    'Ta demande sera transmise aux responsables de l’académie. Ils vérifieront avec toi ou ton représentant légal les données à effacer et celles qui doivent être conservées légalement.',
2044    { titre: 'Demander la suppression du compte', libelle: 'Envoyer ma demande', danger: true }
2045  );
2046  if (!confirme) return;
2047  const { error } = await db.functions.invoke('send-email', {
2048    body: { event: 'account_deletion_request', resource_id: profile.joueur_id }
2049  });
2050  if (error) { toastErreur(error); return; }
2051  toast('Ta demande de suppression a bien été transmise.');
2052}
2053
2054// Sauvegarde du profil technique modifié par le joueur (sa propre fiche).
2055async function saveMonProfilTechnique() {
2056  if (!profile || !profile.joueur_id) { toast('Action réservée aux joueurs', true); return; }
2057  const pfEl = document.getElementById('monProfilPointsForts');
2058  const axEl = document.getElementById('monProfilAxes');
2059  const toArr = (s) => (s || '').split(',').map(x => x.trim()).filter(Boolean);
2060  const obj = {
2061    points_forts: toArr(pfEl ? pfEl.value : ''),
2062    axes_progres: toArr(axEl ? axEl.value : '')
2063  };
2064  const execution = await executerAvecVerrou($('modalBody'),
2065    () => updateConfirmee(db.from('joueurs').update(obj).eq('id', profile.joueur_id)));
2066  if (execution.ignoree) return;
2067  const { error } = execution.valeur;
2068  if (error) { toastErreur(error); return; }
2069  closeModal();
2070  toast('✅ Profil technique mis à jour');
2071  await reloadTables(['joueurs']);
2072  render();
2073}
2074
2075function openChangePasswordModal() {
2076  // Fermer le menu utilisateur
2077  const menu = $('userMenu');
2078  if (menu) menu.style.display = 'none';
2079
2080  const html = `<div class="modal-header">
2081      <h3 class="modal-title">🔐 Changer mon mot de passe</h3>
2082      <button class="modal-close" data-ui-click="closeModal()">×</button>
2083    </div>
2084    <div class="card" style="padding:14px">
2085      <div class="text-faint mb-2" style="font-size:12px">
2086        Saisis ton nouveau mot de passe (minimum 6 caractères) puis confirme-le. Tu resteras connecté(e) après le changement.
2087      </div>
2088      <div class="form-row">
2089        <label class="form-label">Nouveau mot de passe *</label>
2090        <div class="pwd-wrap">
2091          <input type="password" id="pwdNew" autocomplete="new-password" minlength="6" placeholder="6 caractères minimum">
2092          <button type="button" class="pwd-toggle" data-ui-click="togglePasswordVisibility(this)" aria-label="Afficher le mot de passe" tabindex="-1">👁️</button>
2093        </div>
2094      </div>
2095      <div class="form-row">
2096        <label class="form-label">Confirmer le nouveau mot de passe *</label>
2097        <div class="pwd-wrap">
2098          <input type="password" id="pwdConfirm" autocomplete="new-password" minlength="6" placeholder="Re-saisis le même mot de passe">
2099          <button type="button" class="pwd-toggle" data-ui-click="togglePasswordVisibility(this)" aria-label="Afficher le mot de passe" tabindex="-1">👁️</button>
2100        </div>
2101      </div>
2102      <div id="pwdError" class="text-faint" style="font-size:12px; color:var(--danger-text); display:none; margin-top:6px"></div>
2103    </div>
2104    <div class="flex gap-1 mt-2">
2105      <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button>
2106      <button class="btn btn-primary flex-1" data-ui-click="savePassword()"><img class="btn-action-art" src="assets/v4/menu/password.webp" alt="">Mettre à jour</button>
2107    </div>`;
2108  showModal(html);
2109
2110  // Focus auto sur le premier champ
2111  setTimeout(() => {
2112    const inp = $('pwdNew');
2113    if (inp) inp.focus();
2114  }, 50);
2115}
2116
2117// Sauvegarde du nouveau mot de passe (utilisateur connecté OU mode recovery)
2118async function savePassword() {
2119  const newPwd = ($('pwdNew') || {}).value || '';
2120  const confirmPwd = ($('pwdConfirm') || {}).value || '';
2121  const errBox = $('pwdError');
2122
2123  // Affiche un message d'erreur dans la modale (pas un toast pour rester contextuel)
2124  const showErr = (msg) => {
2125    if (errBox) {
2126      errBox.textContent = '⚠️ ' + msg;
2127      errBox.style.display = 'block';
2128    } else {
2129      toast(msg, true);
2130    }
2131  };
2132
2133  if (errBox) errBox.style.display = 'none';
2134
2135  if (!newPwd || newPwd.length < 6) {
2136    showErr('Le mot de passe doit faire au moins 6 caractères');
2137    return;
2138  }
2139  if (newPwd !== confirmPwd) {
2140    showErr('Les 2 mots de passe ne correspondent pas');
2141    return;
2142  }
2143
2144  const execution = await executerAvecVerrou($('modalBody'),
2145    () => db.auth.updateUser({ password: newPwd }), 'Mise à jour…');
2146  if (execution.ignoree) return;
2147  const { error } = execution.valeur;
2148  if (error) {
2149    const idErreur = creerIdErreur();
2150    journal.error(`[mot de passe ${idErreur}]`, error);
2151    showErr(`${TennisDomainRules.messageErreurUtilisateur(error.message)} (réf. ${idErreur})`);
2152    return;
2153  }
2154
2155  toast('Mot de passe mis à jour 🔐');
2156  closeModal();
2157  // Si on était en mode recovery (lien email), on nettoie le hash de l'URL
2158  if (window.location.hash && window.location.hash.includes('type=recovery')) {
2159    history.replaceState(null, '', window.location.pathname);
2160  }
2161}
2162
2163// ============================================================
2164// GESTION DE L'EMAIL DE CONNEXION (joueur)
2165// ============================================================
2166// Le joueur peut voir et modifier l'email de SON compte de connexion (auth.users),
2167// par ex. le jour où il a sa propre adresse au lieu de celle d'un parent.
2168// À chaque demande de changement, le parent (parent_email de la fiche) est prévenu
2169// par email, par transparence (les joueurs sont mineurs).
2170function openChangeEmailModal() {
2171  const menu = $('userMenu');
2172  if (menu) menu.style.display = 'none';
2173
2174  const emailActuel = (session && session.user && session.user.email) || '';
2175  const j = profile && profile.joueur_id ? joueurById(profile.joueur_id) : null;
2176  const parentEmail = j && j.parent_email ? j.parent_email : null;
2177  // Indique au joueur si son compte utilise actuellement l'email d'un parent.
2178  const utiliseEmailParent = parentEmail && emailActuel
2179    && parentEmail.trim().toLowerCase() === emailActuel.trim().toLowerCase();
2180
2181  const html = `<div class="modal-header">
2182      <h3 class="modal-title">✉️ Mon adresse email</h3>
2183      <button class="modal-close" data-ui-click="closeModal()">×</button>
2184    </div>
2185    <div class="card" style="padding:14px">
2186      <div class="form-row">
2187        <label class="form-label">Email de connexion actuel</label>
2188        <input type="email" value="${escapeHtml(emailActuel)}" disabled style="opacity:0.7">
2189      </div>
2190      ${utiliseEmailParent ? `<div class="text-faint" style="font-size:12px; margin:4px 0 12px; padding:8px 10px; background:var(--accent-bg,#e8eef7); border-radius:6px">ℹ️ Ton compte utilise actuellement l'adresse d'un parent. Si tu as ta propre adresse email, tu peux la renseigner ci-dessous.</div>` : ''}
2191      <div class="form-row">
2192        <label class="form-label">Nouvelle adresse email *</label>
2193        <input type="email" id="emailNew" autocomplete="email" placeholder="[email protected]">
2194      </div>
2195      <div class="text-faint" style="font-size:12px; margin-top:6px">
2196        Tu recevras un email de confirmation à ta nouvelle adresse : le changement ne sera effectif qu'après avoir cliqué le lien dedans.
2197        ${parentEmail ? `Un email d'information sera aussi envoyé à tes parents (${escapeHtml(parentEmail)}).` : ''}
2198      </div>
2199      <div id="emailError" class="text-faint" style="font-size:12px; color:var(--danger-text); display:none; margin-top:6px"></div>
2200    </div>
2201    <div class="flex gap-1 mt-2">
2202      <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button>
2203      <button class="btn btn-primary flex-1" data-ui-click="saveEmail()"><img class="btn-action-art" src="assets/v4/menu/email_preferences.webp" alt="">
2203Mettre à jour</button>
2204    </div>`;
2205  showModal(html);
2206  setTimeout(() => { const i = $('emailNew'); if (i) i.focus(); }, 50);
2207}
2208
2209async function saveEmail() {
2210  const nouvel = (($('emailNew') || {}).value || '').trim();
2211  const errBox = $('emailError');
2212  const showErr = (msg) => { if (errBox) { errBox.textContent = '⚠️ ' + msg; errBox.style.display = 'block'; } else { toast(msg, true); } };
2213  if (errBox) errBox.style.display = 'none';
2214
2215  // Validation basique de l'email
2216  if (!nouvel || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(nouvel)) {
2217    showErr('Saisis une adresse email valide.');
2218    return;
2219  }
2220  const emailActuel = (session && session.user && session.user.email) || '';
2221  if (nouvel.toLowerCase() === emailActuel.trim().toLowerCase()) {
2222    showErr('C\'est déjà ton adresse de connexion actuelle.');
2223    return;
2224  }
2225
2226  // 1) Demander le changement à Supabase (envoie un mail de confirmation à la nouvelle adresse)
2227  const execution = await executerAvecVerrou($('modalBody'),
2228    () => db.auth.updateUser({ email: nouvel }), 'Envoi…');
2229  if (execution.ignoree) return;
2230  const { error } = execution.valeur;
2231  if (error) {
2232    const idErreur = creerIdErreur();
2233    journal.error(`[changement email ${idErreur}]`, error);
2234    showErr(`${TennisDomainRules.messageErreurUtilisateur(error.message)} (réf. ${idErreur})`);
2235    return;
2236  }
2237
2238  // 2) Prévenir le parent via un événement serveur. Le navigateur ne choisit ni
2239  // le destinataire ni le contenu : l'Edge Function relit la fiche du joueur.
2240  try {
2241    const j = profile && profile.joueur_id ? joueurById(profile.joueur_id) : null;
2242    const parentEmail = j && j.parent_email ? j.parent_email : null;
2243    if (parentEmail && parentEmail.includes('@')) {
2244      await sendEmailEvent('parent_email_change', j.id);
2245    }
2246  } catch (e) { journal.warn('[changement email] notif parent:', e); }
2247
2248  closeModal();
2249  toast('Demande envoyée : confirme via le lien reçu sur ta nouvelle adresse ✉️');
2250}
2251
2252function renderNotifsList() { return centreNotifications.rendre(); }
2253async function markNotifLu(notifId) { return centreNotifications.marquerLue(notifId); }
2254async function onNotifClick(notifId, lienType, lienId) {
2255  const n = cache.notifications.find(x => x.id === notifId);
2256  await centreNotifications.marquerLue(notifId, false);
2257  // Naviguer vers la cible
2258  toggleNotifsPanel();
2259  if (lienType === 'joueur' && lienId) {
2260    if (isCoach()) {
2261      const j = joueurById(lienId);
2262      if (j && j.statut === 'en_attente_validation') {
2263        navigate('validations');
2264        setTimeout(() => openValidationModal(lienId), 200);
2265      } else {
2266        navigate('players');
2267        setTimeout(() => openPlayerDetail(lienId), 200);
2268      }
2269    } else {
2270      navigate('my');
2271    }
2272  } else if (lienType === 'bilan' && lienId) {
2273    if (isCoach()) {
2274      navigate('bilans');
2275      setTimeout(() => openModalBilan(lienId), 200);
2276    } else {
2277      navigate('mybilans');
2278      setTimeout(() => openMyBilanDetail(lienId), 200);
2279    }
2280  } else if (lienType === 'tournoi' && lienId) {
2281    navigate('calendar');
2282    setTimeout(() => openModalTournoi(lienId), 200);
2283  } else if (n && n.type === 'nouveau_tournoi') {
2284    // Plusieurs tournois pertinents : on ouvre juste l'onglet Tournois (pas de modal unique)
2285    navigate('calendar');
2286  } else if (lienType === 'match' && lienId) {
2287    // Belle perf signalée au coach → ouvrir le match
2288    setTimeout(() => openModalMatch(lienId), 200);
2289  } else if (lienType === 'entrainement' && lienId) {
2290    // Entraînement déclaré → retrouver son joueur et ouvrir sa fiche coach.
2291    const entrainement = entrainementById(lienId);
2292    if (isCoach() && entrainement && entrainement.joueur_id) {
2293      navigate('players');
2294      setTimeout(() => openPlayerDetail(entrainement.joueur_id), 200);
2295    } else {
2296      navigate(isCoach() ? 'players' : 'mytrainings');
2297    }
2298  } else if (lienType === 'seance') {
2299    // Rappel de présence joueur → onglet Mes cours.
2300    navigate(isCoach() ? 'planning' : 'mescours');
2301    if (isCoach() && lienId) setTimeout(() => openModalAppel(lienId), 200);
2302  } else if (lienType === 'presence' && lienId) {
2303    // Déclaration joueur → ouvrir directement l'appel de la séance côté coach.
2304    const presence = (listeSure(cache.presences)).find(item => item.id === lienId);
2305    if (isCoach() && presence) {
2306      navigate('planning');
2307      setTimeout(() => openModalAppel(presence.seance_id), 200);
2308    } else {
2309      navigate(isCoach() ? 'planning' : 'mescours');
2310    }
2311  } else if (lienType === 'bilans') {
2312    // Rappel clôture trimestre → onglet Bilans (vue coach)
2313    navigate('bilans');
2314  }
2315  // Refresh list (pour màj UI lu)
2316  renderNotifsList();
2317}
2318
2319async function markAllNotifsRead() { return centreNotifications.toutMarquerLu(); }
2320async function deleteNotif(notifId) { return centreNotifications.supprimer(notifId); }
2321// ============================================================
2322// EMAIL MAILER — Envoi via Edge Function Supabase + Resend
2323// ============================================================
2324// La fonction sendEmail appelle l'Edge Function "send-email" qui se charge
2325// d'envoyer le mail via Resend. Tous les emails sont silencieux : si l'envoi
2326// échoue (Resend down, config manquante, etc.), l'app continue à fonctionner.
2327// On logge juste l'erreur dans la console.
2328
2329const EMAIL_FROM_LABEL = 'Académie de Tennis Bouliac';
2330const APP_PUBLIC_URL = window.location.origin;
2331const serviceEmail = TennisEmailService.creerService({
2332  db, getSession: () => session, getPreferences: () => cache.preferencesNotifs,
2333  computeAge, escapeHtml, appUrl: APP_PUBLIC_URL, fromLabel: EMAIL_FROM_LABEL,
2334  logger: journal
2335});
2336async function sendEmailEvent(event, resourceId) { return serviceEmail.envoyerEvenement(event, resourceId); }
2337// ============================================================
2338// Templates et déclencheurs spécifiques
2339// ============================================================
2340
2341const modelesEmail = TennisEmailTemplates.creerModeles({
2342  serviceEmail, escapeHtml, formatDate, tenupUrl: tenupTournoiUrl,
2343  appUrl: APP_PUBLIC_URL, getProfile: () => profile, getSession: () => session,
2344  getPreferences: () => cache.preferencesNotifs
2345});
2346async function emailBilanPublie(bilan, joueur) { return modelesEmail.bilanPublie(bilan, joueur); }
2347async function emailBilanPalmyrePublie(joueur, edition, stats) { return modelesEmail.bilanPalmyre(joueur, edition, stats); }
2348async function emailInscriptionValidee(joueur, groupeNom) { return modelesEmail.inscriptionValidee(joueur, groupeNom); }
2349async function emailRappelTournoi(tournoi, joueur, joursAvant) { return modelesEmail.rappelTournoi(tournoi, joueur, joursAvant); }
2350async function emailRappelSaisieMatch(tournoi, joueur) { return modelesEmail.rappelSaisie(tournoi, joueur); }
2351async function emailMatchAjouteParCoach(match, joueur) { return modelesEmail.matchAjoute(match, joueur); }
2352// Notifie le coach d'une belle perf (in-app via fonction SECURITY DEFINER, + email).
2353// match : objet match qu'on vient d'insérer ; joueur : le joueur ; matchId : id inséré.
2354async function notifierPerfCoach(match, joueur, matchId) {
2355  if (!joueur || joueur.groupe === 'tremplin') return;
2356  try {
2357    const { data, error } = await db.rpc('notifier_perf_coach', {
2358      p_joueur_id: joueur.id,
2359      p_adversaire: match.adversaire || '',
2360      p_adversaire_class: match.adversaire_class || '',
2361      p_score: match.score || '',
2362      p_match_id: matchId
2363    });
2364    if (error) { journal.warn('[perf coach] notif error:', error); return; }
2365    // La RPC est l'autorité métier : aucune ligne retournée signifie que le
2366    // résultat n'est pas une performance éligible, donc aucun email non plus.
2367    if (Array.isArray(data) && data.length) await sendEmailEvent('perf_coach', matchId);
2368  } catch (e) {
2369    journal.warn('[perf coach] exception:', e);
2370  }
2371}
2372
2373async function emailPerfNotable(match, joueur, ecart) { return modelesEmail.bellePerf(match, joueur, ecart); }
2374
2375// ============================================================
2376// RAPPELS TOURNOIS — généré côté client au chargement
2377// ============================================================
2378// Pour chaque tournoi à venir entre 25 et 30 jours, créer une notif
2379// "rappel_tournoi" pour les inscrits, sauf si elle existe déjà.
2380// Cette fonction est idempotente : appelable plusieurs fois sans doublons.
2381
2382const serviceRappels = TennisReminders.creerService({
2383  db, cache, getSession: () => session, getProfile: () => profile, isCoach,
2384  joueurById, formatDate, emailRappelTournoi, emailRappelSaisieMatch,
2385  emailRappelBilanTrimestre, getSaisonCourante, getTrimestreCourant,
2386  getTrimestreDates, domainRules: TennisDomainRules, estPreAcademie,
2387  maintenant: () => Date.now()
2388});
2389async function checkAndCreateTournoiReminders() { return serviceRappels.verifierTournois(); }
2390async function checkAndCreateSaisieMatchReminders() { return serviceRappels.verifierSaisieMatchs(); }
2391async function checkAndCreateTrimestreReminders() { return serviceRappels.verifierTrimestre(); }
2392// Email coach : modèle isolé et préférences centralisées.
2393async function emailRappelBilanTrimestre(seuil, saison, trimestre, restantsAfaire, nbJoueursActifs) {
2394  return modelesEmail.rappelBilanCoach(seuil, saison, trimestre, restantsAfaire, nbJoueursActifs);
2395}
2396
2397// ============================================================
2398// GRAPHIQUES (Chart.js)
2399// ============================================================
2400const chartInstances = {}; // pour pouvoir détruire les anciens charts
2401const { chartColors, chartGradient } = globalThis.TennisChartTheme;
2402
2403// Chart.js pèse environ 200 Ko et n'est utile que sur quelques écrans. Le
2404// charger au premier canvas rencontré accélère la connexion, l'accueil et les
2405// parcours sans statistiques. Le chargeur partagé déduplique les appels quand
2406// plusieurs graphiques apparaissent simultanément.
2407function avecChartJs(canvasId, relancer) {
2408  if (window.Chart) return true;
2409  chargerChartJs().then(disponible => {
2410    if (disponible && document.getElementById(canvasId)) relancer();
2411  });
2412  return false;
2413}
2414
2415function destroyChart(id) {
2416  if (chartInstances[id]) {
2417    try { chartInstances[id].destroy(); } catch(e) {}
2418    delete chartInstances[id];
2419  }
2420}
2421
2422// Un changement d'écran remplace le contenu de #app. Libérer les graphiques
2423// dont le canvas a disparu évite de conserver contextes, écouteurs et données
2424// pendant toute la session, particulièrement coûteux sur téléphone.
2425function destroyChartsHorsDom() {
2426  Object.keys(chartInstances).forEach(id => {
2427    if (!document.getElementById(id)) destroyChart(id);
2428  });
2429}
2430
2431// ------------------------------------------------------------
2432// Graphique évolution classement d'un joueur
2433// ------------------------------------------------------------
2434// On reconstruit l'évolution depuis les bilans + classement initial+actuel
2435function renderClassementChart(canvasId, joueur) {
2436  destroyChart(canvasId);
2437  const canvas = document.getElementById(canvasId);
2438  if (!canvas || !avecChartJs(canvasId, () => renderClassementChart(canvasId, joueur))) return;
2439
2440  // Source de l'évolution : on privilégie l'historique réel des changements de
2441  // classement (chaque changement daté). À défaut, on retombe sur les bilans.
2442  const histo = historiqueClassementDuJoueur(joueur.id)
2443    .slice()
2444    .sort((a, b) => (a.date_changement || '').localeCompare(b.date_changement || ''));
2445
2446  const labels = [];
2447  const values = [];
2448
2449  if (histo.length > 0) {
2450    // Premier point : l'ancien classement du tout premier changement (point de départ)
2451    const premier = histo[0];
2452    if (premier.ancien_classement) {
2453      labels.push(formatDate(premier.date_changement));
2454      values.push(getRankIndex(premier.ancien_classement));
2455    }
2456    histo.forEach(h => {
2457      labels.push(formatDate(h.date_changement));
2458      values.push(getRankIndex(h.nouveau_classement));
2459    });
2460    // Point actuel (si différent du dernier historisé, sinon on évite le doublon)
2461    const dernierIdx = histo.length ? getRankIndex(histo[histo.length - 1].nouveau_classement) : null;
2462    if (getRankIndex(joueur.classement || 'NC') !== dernierIdx) {
2463      labels.push('Aujourd\'hui');
2464      values.push(getRankIndex(joueur.classement || 'NC'));
2465    }
2466  } else {
2467    // Pas d'historique : on reconstruit depuis les bilans (ancien comportement)
2468    const bilans = cache.bilans
2469      .filter(b => b.joueur_id === joueur.id && (b.classement_debut || b.classement_fin))
2470      .sort((a, b) => (a.saison + a.trimestre).localeCompare(b.saison + b.trimestre));
2471
2472    if (bilans.length === 0) {
2473      canvas.parentElement.innerHTML = '<div class="text-faint text-center" style="padding:20px; font-size:13px">Pas encore d\'historique de classement.<br>L\'évolution apparaîtra dès le premier changement de classement validé.</div>';
2474      return;
2475    }
2476    bilans.forEach(b => {
2477      if (b.classement_debut) {
2478        labels.push(`${b.trimestre} ${b.saison.split('-')[0]} (début)`);
2479        values.push(getRankIndex(b.classement_debut));
2480      }
2481      if (b.classement_fin) {
2482        labels.push(`${b.trimestre} ${b.saison.split('-')[0]} (fin)`);
2483        values.push(getRankIndex(b.classement_fin));
2484      }
2485    });
2486    labels.push('Aujourd\'hui');
2487    values.push(getRankIndex(joueur.classement || 'NC'));
2488  }
2489
2490  const { gridColor, textColor, tooltipBg, pointBorder, lineOrange, winColor } = chartColors();
2491
2492  // Axe Y : les valeurs sont des index ENTIERS dans CLASSEMENTS (paliers discrets).
2493  // On borne l'axe aux classements réellement atteints (avec 1 cran de marge) et on
2494  // force un pas de 1, sinon Chart.js place des graduations intermédiaires (5.5, 6.5…)
2495  // qui, une fois arrondies, affichent deux fois le même classement (« 30/2 30/2 »).
2496  const valsValides = values.filter(v => typeof v === 'number' && !isNaN(v));
2497  let yMin = valsValides.length ? Math.max(0, Math.min(...valsValides) - 1) : 0;
2498  let yMax = valsValides.length ? Math.min(CLASSEMENTS.length - 1, Math.max(...valsValides) + 1) : CLASSEMENTS.length - 1;
2499  if (yMin === yMax) { yMin = Math.max(0, yMin - 1); yMax = Math.min(CLASSEMENTS.length - 1, yMax + 1); }
2500
2501  chartInstances[canvasId] = new Chart(canvas, {
2502    type: 'line',
2503    data: {
2504      labels,
2505      datasets: [{
2506        label: 'Classement',
2507        data: values,
2508        borderColor: lineOrange,
2509        backgroundColor: ctx => chartGradient(ctx, 'rgba(255,121,0,.48)', 'rgba(223,255,53,.025)'),
2510        fill: true,
2511        tension: 0.42,
2512        borderWidth: 3,
2513        pointRadius: 5,
2514        pointHoverRadius: 8,
2515        pointBackgroundColor: winColor,
2516        pointBorderColor: pointBorder,
2517        pointBorderWidth: 1.5
2518      }]
2519    },
2520    options: {
2521      responsive: true,
2522      maintainAspectRatio: false,
2523      plugins: {
2524        legend: { display: false },
2525        tooltip: {
2526          backgroundColor: tooltipBg,
2527          borderColor: lineOrange,
2528          borderWidth: 1,
2529          callbacks: {
2530            label: (ctx) => 'Classt : ' + (CLASSEMENTS[ctx.parsed.y] || ctx.parsed.y)
2531          }
2532        }
2533      },
2534      scales: {
2535        y: {
2536          min: yMin,
2537          max: yMax,
2538          ticks: {
2539            color: textColor,
2540            stepSize: 1,
2541            autoSkip: false,
2542            callback: (v) => (Number.isInteger(v) ? (CLASSEMENTS[v] || '') : '')
2543          },
2544          grid: { color: gridColor }
2545        },
2546        x: {
2547          ticks: { color: textColor, maxRotation: 45, minRotation: 0 },
2548          grid: { display: false }
2549        }
2550      }
2551    }
2552  });
2553}
2554
2555// ------------------------------------------------------------
2556// Radar des 5 axes — compare jusqu'à 3 bilans
2557// ------------------------------------------------------------
2558// Construit la liste unifiée des bilans d'un joueur (compét + Palmyre), triée du
2559// plus récent au plus ancien, avec un descripteur normalisé pour le radar.
2560function bilansUnifiesJoueur(joueurId) {
2561  const liste = [];
2562  // Bilans compét (non brouillon, évalués)
2563  (listeSure(cache.bilans))
2564    .filter(b => b.joueur_id === joueurId && b.statut !== 'brouillon' && b.note_performance != null)
2565    .forEach(b => liste.push({
2566      id: 'c_' + b.id,
2567      type: 'compet',
2568      label: `${b.trimestre} ${b.saison}`,
2569      tri: b.saison + b.trimestre,
2570      labels: ['Performance', 'Volume', 'Technique / Tactique', 'Physique', 'Mental'],
2571      data: [b.note_performance, b.note_volume, b.note_technique, b.note_physique, b.note_mental]
2572    }));
2573  // Bilans Palmyre (publiés)
2574  (listeSure(cache.bilansEvenement))
2575    .filter(b => b.joueur_id === joueurId && b.statut === 'publie' && b.note_performance != null)
2576    .forEach(b => {
2577      const ev = (listeSure(cache.evenements)).find(e => e.id === b.evenement_id)
2578        || (listeSure(cache.evenementsTous)).find(e => e.id === b.evenement_id);
2579      liste.push({
2580        id: 'p_' + b.id,
2581        type: 'palmyre',
2582        label: `🏖️ ${ev ? ev.nom : 'Palmyre'}`,
2583        tri: (ev && ev.date_debut) ? ev.date_debut : '9999',
2584        labels: ['Performance', 'Technique / Tactique', 'Physique', 'Mental', 'Savoir-être / vivre'],
2585        data: [b.note_performance, b.note_technique, b.note_physique, b.note_mental, b.note_savoir_etre]
2586      });
2587    });
2588  // Tri du plus récent au plus ancien
2589  liste.sort((a, b) => b.tri.localeCompare(a.tri));
2590  return liste;
2591}
2592
2593function setRadarBilan(canvasId, bilanId, joueurId) {
2594  etatApp.definirSelectionRadar(canvasId, bilanId);
2595  const j = joueurById(joueurId);
2596  if (j) renderRadarBilans(canvasId, j);
2597}
2598
2599function renderRadarBilans(canvasId, joueur) {
2600  destroyChart(canvasId);
2601  const canvas = document.getElementById(canvasId);
2602  if (!canvas || !avecChartJs(canvasId, () => renderRadarBilans(canvasId, joueur))) return;
2603
2604  const bilans = bilansUnifiesJoueur(joueur.id);
2605  const selectorHost = document.getElementById(canvasId + '_selector');
2606
2607  if (bilans.length === 0) {
2608    if (selectorHost) selectorHost.innerHTML = '';
2609    canvas.parentElement.innerHTML = '<div class="text-faint text-center" style="padding:20px; font-size:13px">Aucun bilan évalué.<br>Le radar apparaîtra dès qu\'un bilan sera publié.</div>';
2610    return;
2611  }
2612
2613  // Options "Évolution" (superposition des 3 derniers bilans d'un même type).
2614  const bilansCompet = bilans.filter(b => b.type === 'compet');
2615  const bilansPalmyre = bilans.filter(b => b.type === 'palmyre');
2616  const options = [];
2617  if (bilansCompet.length >= 2) options.push({ id: 'evo_compet', label: '📊 Évolution compét (3 derniers)' });
2618  if (bilansPalmyre.length >= 2) options.push({ id: 'evo_palmyre', label: '📊 Évolution Palmyre (3 derniers)' });
2619  // Puis les bilans individuels
2620  bilans.forEach(b => options.push({ id: b.id, label: b.label }));
2621
2622  // Sélection courante (défaut = évolution compét si dispo, sinon le plus récent)
2623  let selId = etatApp.lireSelectionRadar(canvasId);
2624  if (!selId || !options.some(o => o.id === selId)) {
2625    selId = options[0].id;
2626    etatApp.definirSelectionRadar(canvasId, selId);
2627  }
2628
2629  // Sélecteur (affiché seulement s'il y a plus d'une option)
2630  if (selectorHost) {
2631    if (options.length > 1) {
2632      selectorHost.innerHTML = `<div class="form-row" style="margin-bottom:8px">
2633        <select data-ui-change="setRadarBilan(${escapeHtml(encoderArgumentTexteUI(canvasId))}, this.value, ${escapeHtml(encoderArgumentTexteUI(joueur.id))})" style="width:100%">
2634          ${options.map(o => `<option value="${o.id}" ${o.id === selId ? 'selected' : ''}>${escapeHtml(o.label)}</option>`).join('')}
2635        </select>
2636      </div>`;
2637    } else {
2638      selectorHost.innerHTML = `<div class="text-faint" style="font-size:12px; margin-bottom:8px">${escapeHtml(options[0].label)}</div>`;
2639    }
2640  }
2641
2642  // Construction des labels (axes) + datasets selon la sélection.
2643  let labels, datasets;
2644  const { gridColor, textColor, tooltipBg, pointBorder, purpleColor, blueColor, tealColor, lineOrange } = chartColors();
2645  const couleursEvo = [purpleColor, blueColor, tealColor]; // du plus ancien au plus récent
2646
2647  if (selId === 'evo_compet' || selId === 'evo_palmyre') {
2648    // Superposition des 3 derniers bilans du type choisi (chronologique).
2649    const source = (selId === 'evo_compet' ? bilansCompet : bilansPalmyre).slice(0, 3).reverse();
2650    labels = source[0].labels; // tous les bilans d'un même type partagent les mêmes axes
2651    datasets = source.map((b, idx) => ({
2652      label: b.label,
2653      data: b.data,
2654      borderColor: couleursEvo[idx],
2655      backgroundColor: couleursEvo[idx] + '22',
2656      borderWidth: 2,
2657      pointBackgroundColor: couleursEvo[idx],
2658      pointRadius: 4
2659    }));
2660  } else {
2661    // Un seul bilan sélectionné.
2662    const sel = bilans.find(b => b.id === selId) || bilans[0];
2663    const couleur = sel.type === 'palmyre' ? lineOrange : blueColor;
2664    labels = sel.labels;
2665    datasets = [{
2666      label: sel.label,
2667      data: sel.data,
2668      borderColor: couleur,
2669      backgroundColor: couleur + '33',
2670      borderWidth: 2,
2671      pointBackgroundColor: couleur,
2672      pointRadius: 4
2673    }];
2674  }
2675
2676  chartInstances[canvasId] = new Chart(canvas, {
2677    type: 'radar',
2678    data: { labels, datasets },
2679    options: {
2680      responsive: true,
2681      maintainAspectRatio: false,
2682      plugins: {
2683        legend: {
2684          position: 'bottom',
2685          labels: { color: textColor, font: { size: 11 }, boxWidth: 12 }
2686        },
2687        tooltip: {
2688          backgroundColor: tooltipBg,
2689          borderColor: lineOrange,
2690          borderWidth: 1
2691        }
2692      },
2693      scales: {
2694        r: {
2695          min: 0,
2696          max: 10,
2697          ticks: {
2698            stepSize: 2,
2699            color: textColor,
2700            backdropColor: 'transparent',
2701            font: { size: 10 }
2702          },
2703          grid: { color: gridColor },
2704          angleLines: { color: gridColor },
2705          pointLabels: { color: textColor, font: { size: 11 } }
2706        }
2707      }
2708    }
2709  });
2710}
2711
2712// ------------------------------------------------------------
2713// 🌡️ Forme actuelle — Taux victoires sur fenêtre glissante (10 derniers)
2714// ------------------------------------------------------------
2715function renderFormeChart(canvasId, joueur) {
2716  destroyChart(canvasId);
2717  const canvas = document.getElementById(canvasId);
2718  if (!canvas || !avecChartJs(canvasId, () => renderFormeChart(canvasId, joueur))) return;
2719
2720  const matches = matchsHorsWO(cache.matches
2721    .filter(m => m.joueur_id === joueur.id))
2722    .slice()
2723    .sort((a, b) => (a.date || '').localeCompare(b.date || ''));
2724
2725  if (matches.length < 3) {
2726    canvas.parentElement.innerHTML = '<div class="text-faint text-center" style="padding:20px; font-size:13px">Tu as besoin d\'au moins 3 matches pour calculer ta forme actuelle.</div>';
2727    return;
2728  }
2729
2730  const WINDOW = 10;
2731  const labels = [];
2732  const values = [];
2733
2734  matches.forEach((m, i) => {
2735    if (i < 2) return;
2736    const debut = Math.max(0, i - WINDOW + 1);
2737    const fenetre = matches.slice(debut, i + 1);
2738    const taux = comptesMatchs(fenetre).tauxVictoire;
2739    labels.push(formatDate(m.date));
2740    values.push(taux);
2741  });
2742
2743  const { gridColor, textColor, tooltipBg, pointBorder, lineOrange, winColor } = chartColors();
2744
2745  chartInstances[canvasId] = new Chart(canvas, {
2746    type: 'line',
2747    data: {
2748      labels,
2749      datasets: [{
2750        label: 'Taux victoires',
2751        data: values,
2752        borderColor: lineOrange,
2753        backgroundColor: ctx => chartGradient(ctx, 'rgba(223,255,53,.38)', 'rgba(255,121,0,.025)'),
2754        fill: true,
2755        tension: 0.46,
2756        borderWidth: 3,
2757        pointRadius: 4,
2758        pointHoverRadius: 8,
2759        pointBackgroundColor: winColor,
2760        pointBorderColor: pointBorder,
2761        pointBorderWidth: 2
2762      }]
2763    },
2764    options: {
2765      responsive: true,
2766      maintainAspectRatio: false,
2767      plugins: {
2768        legend: { display: false },
2769        tooltip: { callbacks: { label: ctx => `Forme : ${ctx.parsed.y}% de victoires` } }
2770      },
2771      scales: {
2772        x: { ticks: { color: textColor, maxTicksLimit: 6, font: { size: 10 } }, grid: { color: gridColor } },
2773        y: { min: 0, max: 100, ticks: { color: textColor, callback: v => v + '%' }, grid: { color: gridColor } }
2774      }
2775    }
2776  });
2777
2778  // Explication claire sous le graphique (compréhensible de tous), ajoutée une seule
2779  // fois quel que soit l'écran qui affiche la courbe (fiche joueur ou vue coach).
2780  const carte = canvas.parentElement;
2781  if (carte && !carte.parentElement.querySelector('.forme-legende')) {
2782    const legende = document.createElement('div');
2783    legende.className = 'forme-legende text-faint';
2784    legende.style.cssText = 'font-size:11px; text-align:center; margin:-2px 0 8px; line-height:1.3';
2785    legende.textContent = 'Taux de victoires sur les 10 derniers matches';
2786    carte.parentElement.insertBefore(legende, carte.nextSibling);
2787  }
2788}
2789
2790// ------------------------------------------------------------
2791// 📊 Activité par mois (histogramme V/D)
2792// ------------------------------------------------------------
2793function renderActiviteChart(canvasId, joueur, bornes) {
2794  destroyChart(canvasId);
2795  const canvas = document.getElementById(canvasId);
2796  if (!canvas || !avecChartJs(canvasId, () => renderActiviteChart(canvasId, joueur, bornes))) return;
2797
2798  const matches = matchsHorsWO(matchsDuJoueur(joueur.id));
2799  if (matches.length === 0) {
2800    canvas.parentElement.innerHTML = '<div class="text-faint text-center" style="padding:20px; font-size:13px">Aucun match saisi pour le moment.</div>';
2801    return;
2802  }
2803
2804  // Si des bornes [debut, fin] sont fournies, on génère les mois de cette plage.
2805  // Sinon, comportement par défaut : 12 derniers mois glissants.
2806  const mois = [];
2807  if (bornes && bornes[0] && bornes[1]) {
2808    let [yD, mD] = bornes[0].split('-').map(Number);
2809    const [yF, mF] = bornes[1].split('-').map(Number);
2810    let garde = 0;
2811    while ((yD < yF || (yD === yF && mD <= mF)) && garde < 24) {
2812      const d = new Date(yD, mD - 1, 1);
2813      mois.push({
2814        key: yD + '-' + String(mD).padStart(2, '0'),
2815        label: d.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' }),
2816        v: 0, d: 0
2817      });
2818      mD++; if (mD > 12) { mD = 1; yD++; }
2819      garde++;
2820    }
2821  } else {
2822    const now = new Date();
2823    for (let i = 11; i >= 0; i--) {
2824      const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
2825      mois.push({
2826        key: d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'),
2827        label: d.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' }),
2828        v: 0, d: 0
2829      });
2830    }
2831  }
2832
2833  matches.forEach(m => {
2834    if (!m.date) return;
2835    const key = m.date.slice(0, 7);
2836    const slot = mois.find(x => x.key === key);
2837    if (slot) {
2838      if (m.victoire) slot.v++;
2839      else slot.d++;
2840    }
2841  });
2842
2843  const { gridColor, textColor, tooltipBg, pointBorder, lineOrange, winColor, lossColor } = chartColors();
2844
2845  chartInstances[canvasId] = new Chart(canvas, {
2846    type: 'bar',
2847    data: {
2848      labels: mois.map(m => m.label),
2849      datasets: [
2850        { label: 'Victoires', data: mois.map(m => m.v), backgroundColor: winColor, borderColor: winColor, borderWidth:1, borderRadius: 10, borderSkipped:false },
2851        { label: 'Défaites', data: mois.map(m => m.d), backgroundColor: lossColor, borderColor: lossColor, borderWidth:1, borderRadius: 10, borderSkipped:false }
2852      ]
2853    },
2854    options: {
2855      responsive: true,
2856      maintainAspectRatio: false,
2857      plugins: {
2858        legend: { labels: { color: textColor, boxWidth: 12, font: { size: 11 } } },
2859        tooltip: {
2860          mode: 'index',
2861          callbacks: {
2862            footer: items => {
2863              const total = items.reduce((s, it) => s + it.parsed.y, 0);
2864              return total > 0 ? 'Total : ' + total + ' match' + (total > 1 ? 'es' : '') : '';
2865            }
2866          }
2867        }
2868      },
2869      scales: {
2870        x: { stacked: true, ticks: { color: textColor, font: { size: 10 } }, grid: { display: false } },
2871        y: { stacked: true, beginAtZero: true, ticks: { color: textColor, stepSize: 1, precision: 0 }, grid: { color: gridColor } }
2872      }
2873    }
2874  });
2875}
2876
2877// ------------------------------------------------------------
2878// 🏆 Cumul des victoires depuis le début de saison
2879// ------------------------------------------------------------
2880function renderCumulChart(canvasId, joueur, bornes) {
2881  destroyChart(canvasId);
2882  const canvas = document.getElementById(canvasId);
2883  if (!canvas || !avecChartJs(canvasId, () => renderCumulChart(canvasId, joueur, bornes))) return;
2884
2885  // Si des bornes [debut, fin] sont fournies (vue coach avec sélecteur de période),
2886  // on filtre dessus. Sinon, comportement par défaut = saison en cours (fiche joueur).
2887  let matchesPeriode;
2888  if (bornes && bornes[0] && bornes[1]) {
2889    matchesPeriode = matchsHorsWO(cache.matches
2890      .filter(m => m.joueur_id === joueur.id && TennisDomainRules.dateDansIntervalle(m.date, bornes[0], bornes[1])))
2891      .slice()
2892      .sort((a, b) => (a.date || '').localeCompare(b.date || ''));
2893  } else {
2894    const [debutSaison, finSaison] = getSaisonDates();
2895    matchesPeriode = matchsHorsWO(cache.matches
2896      .filter(m => m.joueur_id === joueur.id && TennisDomainRules.dateDansIntervalle(m.date, debutSaison, finSaison)))
2897      .slice()
2898      .sort((a, b) => (a.date || '').localeCompare(b.date || ''));
2899  }
2900  const matchesSaison = matchesPeriode;
2901
2902  if (matchesSaison.length === 0) {
2903    canvas.parentElement.innerHTML = '<div class="text-faint text-center" style="padding:20px; font-size:13px">Aucun match sur cette période.</div>';
2904    return;
2905  }
2906
2907  const labels = [];
2908  const cumulV = [];
2909  const cumulD = [];
2910  let v = 0, d = 0;
2911
2912  matchesSaison.forEach(m => {
2913    if (m.victoire) v++; else d++;
2914    labels.push(formatDate(m.date));
2915    cumulV.push(v);
2916    cumulD.push(d);
2917  });
2918
2919  const { gridColor, textColor, tooltipBg, pointBorder, lineOrange, winColor, lossColor } = chartColors();
2920
2921  chartInstances[canvasId] = new Chart(canvas, {
2922    type: 'line',
2923    data: {
2924      labels,
2925      datasets: [
2926        {
2927          label: '🏆 Victoires cumulées', data: cumulV,
2928          borderColor: winColor, backgroundColor: ctx => chartGradient(ctx,'rgba(223,255,53,.3)','rgba(223,255,53,.01)'),
2929          borderWidth: 3, fill: true, tension: 0.4, pointRadius: 4, pointBackgroun
2929dColor: lossColor, pointBorderColor: pointBorder, pointBorderWidth:2
2930        },
2931        {
2932          label: 'Défaites cumulées', data: cumulD,
2933          borderColor: lossColor, borderWidth: 2, borderDash: [7, 6],
2934          fill: false, tension: 0.2, pointRadius: 0
2935        }
2936      ]
2937    },
2938    options: {
2939      responsive: true,
2940      maintainAspectRatio: false,
2941      plugins: {
2942        legend: { labels: { color: textColor, boxWidth: 14, font: { size: 11 } } },
2943        tooltip: { mode: 'index', callbacks: { title: items => 'Au ' + items[0].label } }
2944      },
2945      scales: {
2946        x: { ticks: { color: textColor, maxTicksLimit: 6, font: { size: 10 } }, grid: { color: gridColor } },
2947        y: { beginAtZero: true, ticks: { color: textColor, stepSize: 1, precision: 0 }, grid: { color: gridColor } }
2948      }
2949    }
2950  });
2951}
2952
2953// ------------------------------------------------------------
2954// Graphique volume matches par mois (dashboard global)
2955// ------------------------------------------------------------
2956function renderVolumeChart(canvasId) {
2957  destroyChart(canvasId);
2958  const canvas = document.getElementById(canvasId);
2959  if (!canvas || !avecChartJs(canvasId, () => renderVolumeChart(canvasId))) return;
2960
2961  // Grouper les matches par mois sur la SAISON TENNISTIQUE analysée (sept → août),
2962  // et non sur les 12 derniers mois glissants (cohérence avec le reste de l'app).
2963  const saison = getSaisonAnalyse();
2964  const [a1, a2] = saison.split('-').map(s => parseInt(s));
2965  const buckets = {};
2966  // Sept (mois 8) de a1 → Août (mois 7) de a2 : 12 mois dans l'ordre de la saison
2967  for (let i = 0; i < 12; i++) {
2968    const moisIndex = (8 + i) % 12;          // 8,9,10,11,0,1,...,7
2969    const annee = (8 + i) < 12 ? a1 : a2;
2970    const key = `${annee}-${String(moisIndex + 1).padStart(2, '0')}`;
2971    buckets[key] = { matches: 0, victoires: 0 };
2972  }
2973
2974  matchsHorsWO(cache.matches).forEach(m => {
2975    const d = new Date(m.date);
2976    const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
2977    if (buckets[key]) {
2978      buckets[key].matches++;
2979      if (m.victoire) buckets[key].victoires++;
2980    }
2981  });
2982
2983  const labels = Object.keys(buckets).map(k => {
2984    const [y, m] = k.split('-');
2985    return ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'][parseInt(m)-1] + ' ' + y.slice(2);
2986  });
2987  const matchesData = Object.values(buckets).map(b => b.matches);
2988  const victoiresData = Object.values(buckets).map(b => b.victoires);
2989
2990  const { gridColor, textColor, tooltipBg, pointBorder, lineOrange, winColor, lossColor } = chartColors();
2991
2992  chartInstances[canvasId] = new Chart(canvas, {
2993    type: 'bar',
2994    data: {
2995      labels,
2996      datasets: [
2997        {
2998          label: 'Défaites',
2999          data: matchesData.map((m, i) => m - victoiresData[i]),
3000          backgroundColor: lossColor,
3001          borderColor: lossColor,
3002          borderWidth: 1,
3003          borderRadius: 10,
3004          borderSkipped: false
3005        },
3006        {
3007          label: 'Victoires',
3008          data: victoiresData,
3009          backgroundColor: winColor,
3010          borderColor: winColor,
3011          borderWidth: 1,
3012          borderRadius: 10,
3013          borderSkipped: false
3014        }
3015      ]
3016    },
3017    options: {
3018      responsive: true,
3019      maintainAspectRatio: false,
3020      plugins: {
3021        legend: {
3022          position: 'bottom',
3023          labels: { color: textColor, font: { size: 11 }, boxWidth: 12 }
3024        },
3025        tooltip: {
3026          backgroundColor: tooltipBg,
3027          borderColor: lineOrange,
3028          borderWidth: 1
3029        }
3030      },
3031      scales: {
3032        y: {
3033          stacked: true,
3034          ticks: { color: textColor, stepSize: 5 },
3035          grid: { color: gridColor }
3036        },
3037        x: {
3038          stacked: true,
3039          ticks: { color: textColor, font: { size: 10 } },
3040          grid: { display: false }
3041        }
3042      }
3043    }
3044  });
3045}
3046
3047const gestionnaireModale = TennisUiCommon.creerGestionnaireModale({
3048  window,
3049  document,
3050  ids: { fond: 'modal', contenu: 'modalBody', application: 'app-wrapper' },
3051  apresContenu: body => {
3052    associerLabelsFormulaires(body);
3053    actualiserAuditActionsUI();
3054  },
3055  apresFermeture: () => {
3056    if (_renderEnAttente) {
3057      _renderEnAttente = false;
3058      try { render(); } catch (_) { /* rendu différé devenu inutile */ }
3059    }
3060  }
3061});
3062
3063function showModal(html) { return gestionnaireModale.ouvrir(html); }
3064
3065const CLE_ETAT_NAVIGATION = 'pcb-navigation-v1';
3066let _navigationDepuisHistorique = false;
3067
3068function etatNavigationCourant(supplement = {}) {
3069  return {
3070    cle: CLE_ETAT_NAVIGATION,
3071    ecran: etatApp.ecran || (isCoach() ? 'dashboard' : 'my'),
3072    environnement: etatApp.environnement || 'pole',
3073    saisonAnalyse: getSaisonAnalyse(),
3074    scrollY: Math.max(0, Math.round(window.scrollY || 0)),
3075    cockpitPilotage: typeof cockpitPilotageTab === 'string' ? cockpitPilotageTab : 'todo',
3076    ...supplement
3077  };
3078}
3079
3080function assurerEtatNavigationInitial() {
3081  if (history.state?.cle === CLE_ETAT_NAVIGATION) return;
3082  history.replaceState(etatNavigationCourant(), '', window.location.href);
3083}
3084
3085function memoriserContexteNavigation() {
3086  assurerEtatNavigationInitial();
3087  const precedent = history.state || {};
3088  history.replaceState(etatNavigationCourant({ modal: precedent.modal || null }), '', window.location.href);
3089}
3090
3091function enregistrerDetailNavigation(type, id) {
3092  if (_navigationDepuisHistorique || !id) return;
3093  assurerEtatNavigationInitial();
3094  if (history.state?.modal?.type === type && String(history.state.modal.id) === String(id)) return;
3095  memoriserContexteNavigation();
3096  history.pushState(etatNavigationCourant({ modal: { type, id: String(id) } }), '', window.location.href);
3097}
3098
3099function boutonRetourDetail(libelle = 'Retour') {
3100  return `<button type="button" class="modal-context-back" data-ui-click="navigationRetourContextuel()" aria-label="${escapeHtml(libelle)}">← <span>${escapeHtml(libelle)}</span></button>`;
3101}
3102
3103let _confirmationResolve = null;
3104function demanderConfirmation(message, options = {}) {
3105  // Une seule confirmation peut être active : fermer proprement une éventuelle
3106  // attente précédente au lieu de laisser sa Promise suspendue.
3107  if (_confirmationResolve) {
3108    const precedente = _confirmationResolve;
3109    _confirmationResolve = null;
3110    precedente(false);
3111  }
3112  const titre = options.titre || 'Confirmer cette action';
3113  const libelle = options.libelle || 'Confirmer';
3114  const danger = options.danger !== false;
3115  showModal(`<div class="modal-header">
3116    <h3 class="modal-title">${escapeHtml(titre)}</h3>
3117    <button class="modal-close" data-ui-click="resoudreConfirmation(false)" aria-label="Annuler et fermer">×</button>
3118  </div>
3119  <div style="font-size:14px;line-height:1.55">${escapeHtml(String(message || '')).replace(/\n/g, '<br>')}</div>
3120  <div class="flex gap-1 mt-2">
3121    <button class="btn flex-1" data-ui-click="resoudreConfirmation(false)" autofocus>Annuler</button>
3122    <button class="btn flex-1 ${danger ? 'btn-danger' : 'btn-primary'}" data-ui-click="resoudreConfirmation(true)">${escapeHtml(libelle)}</button>
3123  </div>`);
3124  return new Promise(resolve => { _confirmationResolve = resolve; });
3125}
3126
3127function resoudreConfirmation(accepte) {
3128  const resolve = _confirmationResolve;
3129  _confirmationResolve = null;
3130  closeModal();
3131  if (resolve) resolve(!!accepte);
3132}
3133
3134function closeModalDirect() {
3135  if (_confirmationResolve) {
3136    const resolve = _confirmationResolve;
3137    _confirmationResolve = null;
3138    resolve(false);
3139  }
3140  gestionnaireModale.fermer();
3141}
3142
3143function closeModal() {
3144  if (!_navigationDepuisHistorique && history.state?.cle === CLE_ETAT_NAVIGATION && history.state?.modal) {
3145    history.back();
3146    return;
3147  }
3148  closeModalDirect();
3149}
3150
3151function navigationRetourContextuel() {
3152  if (history.state?.cle === CLE_ETAT_NAVIGATION && history.state?.modal) {
3153    history.back();
3154    return;
3155  }
3156  if (history.length > 1) {
3157    history.back();
3158    return;
3159  }
3160  closeModalDirect();
3161  navigate(isCoach() ? 'dashboard' : 'my');
3162}
3163
3164window.addEventListener('popstate', async event => {
3165  const cible = event.state;
3166  if (!cible || cible.cle !== CLE_ETAT_NAVIGATION) return;
3167  _navigationDepuisHistorique = true;
3168  try {
3169    closeModalDirect();
3170    const environnementChange = cible.environnement && cible.environnement !== etatApp.environnement;
3171    if (environnementChange) {
3172      etatApp.definirEnvironnement(cible.environnement);
3173    }
3174    const saisonRechargee = cible.saisonAnalyse && cible.saisonAnalyse !== getSaisonAnalyse()
3175      ? await chargerSaisonAnalyse(cible.saisonAnalyse) : false;
3176    if (environnementChange && !saisonRechargee) {
3177      appliquerPerimetreMatchs();
3178      cache.alertes = computeAlertes();
3179      cache.bellePerfs = computeBellePerfs();
3180    }
3181    if (typeof cible.cockpitPilotage === 'string') cockpitPilotageTab = cible.cockpitPilotage;
3182    if (cible.ecran && etatApp.definirEcran(cible.ecran)) {
3183      buildNavMenu();
3184      render();
3185    }
3186    if (cible.modal?.type === 'player') openPlayerDetail(cible.modal.id);
3187    else if (cible.modal?.type === 'match') openModalMatch(cible.modal.id);
3188    requestAnimationFrame(() => window.scrollTo(0, Number(cible.scrollY) || 0));
3189  } finally {
3190    _navigationDepuisHistorique = false;
3191  }
3192});
3193
3194document.addEventListener('keydown', event => {
3195  if (event.key === 'Escape' && $('modal')?.classList.contains('show')) {
3196    // Passer par closeModal afin de résoudre aussi une éventuelle confirmation.
3197    event.preventDefault();
3198    closeModal();
3199    return;
3200  }
3201  gestionnaireModale.gererClavier(event);
3202});
3203
3204function selectPill(pill, groupId) {
3205  const group = $(groupId);
3206  if (!group) return;
3207  group.querySelectorAll('.pill').forEach(p => {
3208    p.classList.remove('active');
3209    p.setAttribute('aria-pressed', 'false');
3210  });
3211  pill.classList.add('active');
3212  pill.setAttribute('aria-pressed', 'true');
3213}
3214
3215// Variante pour un choix OPTIONNEL : recliquer sur la pill active la désélectionne
3216// (permet de laisser le groupe sans valeur). Utilisé pour la surface de jeu.
3217function selectPillToggle(pill, groupId) {
3218  const group = $(groupId);
3219  if (!group) return;
3220  const wasActive = pill.classList.contains('active');
3221  group.querySelectorAll('.pill').forEach(p => {
3222    p.classList.remove('active');
3223    p.setAttribute('aria-pressed', 'false');
3224  });
3225  if (!wasActive) {
3226    pill.classList.add('active');
3227    pill.setAttribute('aria-pressed', 'true');
3228  }
3229}
3230
3231// ============================================================
3232// AUTHENTIFICATION
3233// ============================================================
3234function switchAuthTab(tab) {
3235  document.querySelectorAll('.auth-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
3236  $('form-login').style.display = tab === 'login' ? '' : 'none';
3237  $('form-signup').style.display = tab === 'signup' ? '' : 'none';
3238  const coachLink = $('coachSignupLink');
3239  if (coachLink) coachLink.style.display = tab === 'signup' ? 'block' : 'none';
3240  $('auth-error').classList.remove('show');
3241}
3242
3243function authError(msg) {
3244  const el = $('auth-error');
3245  el.textContent = msg;
3246  el.classList.add('show');
3247  // Le message est placé en haut de l'écran : sur le formulaire multi-étapes
3248  // (inscription), l'utilisateur est souvent scrollé en bas et ne le verrait pas.
3249  // On l'amène donc à l'écran, et on double d'un toast flottant pour être sûr.
3250  try { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
3251  if (typeof toast === 'function') toast('⚠️ ' + msg);
3252}
3253
3254// Bascule l'affichage d'un champ mot de passe (bouton œil).
3255// Le bouton appelle cette fonction ; on bascule le type de l'input frère.
3256function togglePasswordVisibility(btn) {
3257  const wrap = btn.closest('.pwd-wrap');
3258  if (!wrap) return;
3259  const input = wrap.querySelector('input');
3260  if (!input) return;
3261  if (input.type === 'password') {
3262    input.type = 'text';
3263    btn.textContent = '🙈';
3264    btn.setAttribute('aria-label', 'Masquer le mot de passe');
3265  } else {
3266    input.type = 'password';
3267    btn.textContent = '👁️';
3268    btn.setAttribute('aria-label', 'Afficher le mot de passe');
3269  }
3270}
3271
3272async function doLogin(e) {
3273  e.preventDefault();
3274  $('auth-error').classList.remove('show');
3275  const f = e.target;
3276  const btn = f.querySelector('button[type=submit]');
3277  btn.disabled = true; btn.textContent = 'Connexion...';
3278
3279  const { error } = await db.auth.signInWithPassword({
3280    email: f.email.value.trim(),
3281    password: f.password.value
3282  });
3283
3284  btn.disabled = false; btn.textContent = 'Se connecter';
3285
3286  if (error) {
3287    authError('Email ou mot de passe incorrect');
3288    return;
3289  }
3290  // Le listener onAuthStateChange prendra le relais
3291}
3292
3293// Navigation entre les étapes d'inscription
3294function libelleChampInscription(input) {
3295  const label = input.closest('.form-row, label')?.querySelector('label, span');
3296  const texte = (label?.textContent || input.name || 'champ').replace(/\s*\*.*$/, '').trim();
3297  return texte || 'champ obligatoire';
3298}
3299
3300function signalerErreursInscription(erreurs) {
3301  if (!erreurs.length) return false;
3302  erreurs.forEach(({ champ }) => champ?.setAttribute('aria-invalid', 'true'));
3303  const libelles = [...new Set(erreurs.map(({ message, champ }) => message || libelleChampInscription(champ)))];
3304  authError(`Vérifie les champs suivants : ${libelles.join(' · ')}`);
3305  const premier = erreurs.find(({ champ }) => champ && typeof champ.focus === 'function')?.champ;
3306  if (premier) {
3307    premier.focus({ preventScroll: true });
3308    premier.scrollIntoView({ behavior: 'smooth', block: 'center' });
3309    try { premier.reportValidity(); } catch (_) {}
3310  }
3311  return true;
3312}
3313
3314function signupNextStep(stepNumber) {
3315  const form = $('form-signup');
3316  if (!form) return;
3317  // Ne jamais déduire l'étape active de la sérialisation de l'attribut style :
3318  // le navigateur peut écrire « display: none; » et rendre le sélecteur fragile.
3319  const currentStep = Array.from(form.querySelectorAll('[data-step]'))
3320    .find(step => !step.hidden && getComputedStyle(step).display !== 'none');
3321
3322  // Validation : vérifier les champs requis de l'étape courante avant de continuer
3323  if (currentStep) {
3324    const requiredInputs = currentStep.querySelectorAll('input[required], select[required]');
3325    const erreurs = Array.from(requiredInputs)
3326      .filter(inp => !inp.checkValidity())
3327      .map(champ => ({ champ }));
3328    if (erreurs.length && stepNumber > parseInt(currentStep.dataset.step)) {
3329      signalerErreursInscription(erreurs);
3330      return; // Bloquer l'avance si validation échoue
3331    }
3332  }
3333
3334  // Afficher l'étape demandée, cacher les autres
3335  form.querySelectorAll('[data-step]').forEach(s => {
3336    const active = s.dataset.step == stepNumber;
3337    s.hidden = !active;
3338    s.style.display = active ? '' : 'none';
3339  });
3340
3341  // Quand on arrive sur l'étape 3, appliquer la logique mineur/majeur
3342  if (stepNumber == 3) {
3343    const dateInp = form.querySelector('input[name="date_naissance"]');
3344    applyMinorLogic(dateInp ? ageFromDate(dateInp.value) : null);
3345  }
3346
3347  // Scroll vers le haut du formulaire
3348  setTimeout(() => form.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
3349}
3350
3351const RGPD_POLICY_VERSION = '2026-08-24-v3';
3352
3353function showRgpdPolicy(e) {
3354  if (e) e.preventDefault();
3355  const version = RGPD_POLICY_VERSION;
3356  const html = `<div class="modal-header">
3357    <h3 class="modal-title">Politique de confidentialité</h3>
3358    <button class="modal-close" data-ui-click="closeModal()" aria-label="Fermer">×</button>
3359  </div>
3360  <div class="text-faint" style="font-size:11px;margin-bottom:14px">Académie de Tennis Bouliac · version ${version} · mise à jour du 24 août 2026</div>
3361  <div class="card" style="line-height:1.55">
3362    <div class="sub-title">Données collectées</div>
3363    <p>Identité (prénom, nom, date de naissance, sexe), données sportives (classement, matchs, performances), coordonnées et contact parental, photos de profil facultatives, ainsi que les informations volontairement saisies comme les notes et ressentis.</p>
3364    <div class="sub-title">Finalités et bases légales</div>
3365    <p>Le suivi sportif, l'organisation des cours, tournois et stages ainsi que la gestion du compte sont nécessaires à l'accompagnement demandé au club. La sécurité, la prévention des abus et les communications indispensables reposent sur l'intérêt légitime du club. Les usages facultatifs, notamment la photo de profil et l'assistance à la rédaction par intelligence artificielle, reposent sur le consentement applicable et peuvent être refusés ou retirés.</p>
3366    <div class="sub-title">Conservation</div>
3367    <p>Pendant la durée d'inscription à l'académie, puis un an après le départ.</p>
3368    <div class="sub-title">Tes droits</div>
3369    <p>Accès, rectification, suppression, limitation, portabilité et opposition. Pour les exercer, contacte le coach principal ou le responsable du club par les coordonnées officielles communiquées lors de l'inscription. Une réclamation peut également être déposée auprès de la CNIL sur <a href="https://www.cnil.fr" target="_blank" rel="noopener noreferrer">cnil.fr</a>.</p>
3370    <div class="sub-title">Hébergement et sous-traitants</div>
3371    <p>L'application et ses journaux techniques sont distribués par Netlify. Les données métier sont hébergées par Supabase dans une région de l'Union européenne. Les emails de service sont transmis par Resend, qui reçoit uniquement les adresses, identités et contenus nécessaires à leur acheminement. Pour les fonctions optionnelles de proposition de bilan individuel et de débrief, les seules données sportives nécessaires sont transmises à Anthropic PBC, fournisseur d'intelligence artificielle établi aux États-Unis.</p>
3372    <p>Ce transfert relève des conditions commerciales et de l'accord de traitement des données applicables au service API Anthropic, qui intègre les clauses contractuelles types. Les propositions générées ne constituent jamais une décision automatisée : elles sont systématiquement relues, modifiables et validées par un entraîneur avant tout enregistrement ou publication.</p>
3373    <p>
3373Aucune donnée n'est envoyée à Anthropic lors de la simple consultation de l'application. L'envoi intervient uniquement lorsqu'un coach déclenche explicitement l'assistance à la rédaction.</p>
3374    <div class="sub-title">Responsable du traitement</div>
3375    <p>Académie de Tennis Bouliac. Le contact RGPD nominatif et son adresse doivent être confirmés par le bureau du club avant la prochaine campagne d'inscription.</p>
3376  </div>
3377  <button class="btn btn-primary btn-full" data-ui-click="closeModal()">J'ai compris</button>`;
3378  showModal(html);
3379}
3380
3381// Calcul d'âge depuis date_naissance
3382function ageFromDate(dateStr) {
3383  return TennisDomainRules.ageCivil(dateStr, new Date());
3384}
3385
3386// Réagit au changement de date de naissance : adapte l'étape 3 (mineur/majeur)
3387function onDateNaissanceChange() {
3388  const form = $('form-signup');
3389  if (!form) return;
3390  const dateInp = form.querySelector('input[name="date_naissance"]');
3391  if (!dateInp) return;
3392  applyMinorLogic(ageFromDate(dateInp.value));
3393}
3394
3395function applyMinorLogic(age) {
3396  const minorSection = $('minorSection');
3397  const parentalConsent = $('parentalConsentLabel');
3398  const ageStatus = $('ageStatus');
3399  const ageStatusText = $('ageStatusText');
3400  if (!minorSection || !parentalConsent || !ageStatus || !ageStatusText) return;
3401
3402  if (age === null) {
3403    minorSection.style.display = 'none';
3404    parentalConsent.style.display = 'none';
3405    setRequired(minorSection, false);
3406    parentalConsent.querySelector('input').required = false;
3407    ageStatusText.innerHTML = 'Renseigne ta date de naissance à l\'étape précédente pour adapter ce formulaire.';
3408    ageStatus.style.borderLeft = '';
3409    return;
3410  }
3411
3412  if (age < 18) {
3413    minorSection.style.display = '';
3414    parentalConsent.style.display = 'flex';
3415    setRequired(minorSection, true);
3416    parentalConsent.querySelector('input').required = true;
3417    ageStatusText.innerHTML = `Tu as <strong>${age} ans</strong> (mineur). Les coordonnées d'un parent/tuteur et son accord sont obligatoires.`;
3418    // Pré-remplir l'email parent avec l'email du compte s'il est vide : pour un enfant
3419    // sans adresse propre, l'email saisi à l'étape 1 est en pratique celui du parent.
3420    const form = $('form-signup');
3421    const emailCompte = form ? (form.querySelector('input[name="email"]') || {}).value : '';
3422    const parentEmailInp = form ? form.querySelector('input[name="parent_email"]') : null;
3423    if (parentEmailInp && !parentEmailInp.value && emailCompte) {
3424      parentEmailInp.value = emailCompte;
3425    }
3426  } else {
3427    minorSection.style.display = 'none';
3428    parentalConsent.style.display = 'none';
3429    setRequired(minorSection, false);
3430    parentalConsent.querySelector('input').required = false;
3431    ageStatusText.innerHTML = `Tu as <strong>${age} ans</strong> (majeur). Seul ton consentement RGPD est requis.`;
3432  }
3433}
3434
3435// Rend obligatoires (ou pas) tous les inputs d'un conteneur
3436function setRequired(container, isRequired) {
3437  if (!container) return;
3438  container.querySelectorAll('input').forEach(inp => {
3439    if (isRequired) inp.setAttribute('required', '');
3440    else inp.removeAttribute('required');
3441  });
3442}
3443
3444// Inscription coach : modale allégée (email, mot de passe, prénom, nom, code coach).
3445// Un coach n'a aucune donnée joueur (pas de classement, date de naissance, etc.).
3446function openInscriptionCoach(e) {
3447  if (e) e.preventDefault();
3448  const html = `<div class="modal-header"><h3 class="modal-title">👤 Créer un compte coach</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
3449    <div class="modal-body">
3450      <div class="text-dim" style="font-size:13px; margin-bottom:14px">Réservé aux coachs. Il te faut le <strong>code coach</strong> communiqué par un coach de l'académie.</div>
3451      <div id="coach-signup-error" class="auth-error" style="margin-bottom:10px"></div>
3452      <form id="form-signup-coach" data-ui-submit="doSignupCoach(event)">
3453        <div class="form-group"><label>Prénom</label><input type="text" name="prenom" required autocomplete="given-name"></div>
3454        <div class="form-group"><label>Nom</label><input type="text" name="nom" required autocomplete="family-name"></div>
3455        <div class="form-group"><label>Email</label><input type="email" name="email" required autocomplete="email"></div>
3456        <div class="form-group"><label>Mot de passe</label>
3457          <div class="pwd-wrap" style="position:relative">
3458            <input type="password" name="password" required minlength="6" autocomplete="new-password">
3459            <button type="button" class="pwd-toggle" data-ui-click="togglePasswordVisibility(this)" aria-label="Afficher le mot de passe">👁️</button>
3460          </div>
3461        </div>
3462        <div class="form-group"><label>Code coach</label><input type="text" name="coach_code" required autocomplete="off" placeholder="Donné par un coach" style="text-transform:uppercase; letter-spacing:1px"></div>
3463        <button type="submit" class="btn btn-primary btn-full mt-1">Créer mon compte coach</button>
3464      </form>
3465    </div>`;
3466  showModal(html);
3467}
3468
3469async function doSignupCoach(e) {
3470  e.preventDefault();
3471  const f = e.target;
3472  const errEl = $('coach-signup-error');
3473  const showErr = (m) => { if (errEl) { errEl.textContent = m; errEl.classList.add('show'); } };
3474  if (errEl) errEl.classList.remove('show');
3475  const btn = f.querySelector('button[type=submit]');
3476  btn.disabled = true; btn.textContent = 'Création...';
3477
3478  const { error } = await db.auth.signUp({
3479    email: f.email.value.trim(),
3480    password: f.password.value,
3481    options: {
3482      data: {
3483        coach_access_code: f.coach_code.value.trim().toUpperCase(),
3484        prenom: f.prenom.value.trim(),
3485        nom: f.nom.value.trim()
3486      }
3487    }
3488  });
3489
3490  btn.disabled = false; btn.textContent = 'Créer mon compte coach';
3491
3492  if (error) {
3493    const msg = (error.message || '').toLowerCase();
3494    if (msg.includes('coach')) showErr('Code coach invalide.');
3495    else if (msg.includes('registered') || msg.includes('already')) showErr('Un compte existe déjà avec cet email.');
3496    else showErr(TennisDomainRules.messageErreurUtilisateur(error.message));
3497    return;
3498  }
3499
3500  closeModal();
3501  toast('Compte coach créé ! Vérifie tes emails pour confirmer, puis connecte-toi.');
3502  switchAuthTab('login');
3503}
3504
3505async function doSignup(e) {
3506  e.preventDefault();
3507  $('auth-error').classList.remove('show');
3508  const f = e.target;
3509  const btn = f.querySelector('button[type=submit]');
3510
3511  const age = ageFromDate(f.date_naissance.value);
3512  const isMineur = age !== null && age < 18;
3513  const erreurs = [];
3514
3515  if (!f.consentement_rgpd.checked) {
3516    erreurs.push({ champ: f.consentement_rgpd, message: 'politique de confidentialité' });
3517  }
3518  if (isMineur) {
3519    if (!f.consentement_parental.checked) {
3520      erreurs.push({ champ: f.consentement_parental, message: 'consentement parental' });
3521    }
3522    if (!f.parent_nom.value.trim()) {
3523      erreurs.push({ champ: f.parent_nom, message: 'nom du parent ou tuteur' });
3524    }
3525    if (!f.parent_telephone.value.trim()) {
3526      erreurs.push({ champ: f.parent_telephone, message: 'téléphone du parent' });
3527    }
3528    if (!f.parent_email.value.trim() || !f.parent_email.checkValidity()) {
3529      erreurs.push({ champ: f.parent_email, message: 'email valide du parent' });
3530    }
3531  }
3532  if (signalerErreursInscription(erreurs)) return;
3533
3534  btn.disabled = true; btn.textContent = 'Création...';
3535
3536  const codePole = f.code_pole.value.trim().toUpperCase();
3537  const codeJoueur = /^[A-HJ-NP-Z2-9]{8}$/.test(codePole);
3538
3539  const { data: signupData, error } = await db.auth.signUp({
3540    email: f.email.value.trim(),
3541    password: f.password.value,
3542    options: {
3543      data: {
3544        ...(codeJoueur ? { invitation_code: codePole } : { pole_access_code: codePole }),
3545        prenom: f.prenom.value.trim(),
3546        nom: f.nom.value.trim(),
3547        date_naissance: f.date_naissance.value,
3548        sexe: f.sexe.value,
3549        telephone: normaliserTelephone(f.telephone.value),
3550        classement: f.classement.value,
3551        points_forts: f.points_forts.value.trim(),
3552        axes_progres: f.axes_progres.value.trim(),
3553        parent_nom: isMineur ? f.parent_nom.value.trim() : '',
3554        parent_telephone: isMineur ? normaliserTelephone(f.parent_telephone.value) : '',
3555        parent_email: isMineur ? f.parent_email.value.trim() : '',
3556        consentement_rgpd: f.consentement_rgpd.checked,
3557        consentement_parental: isMineur ? f.consentement_parental.checked : false,
3558        politique_confidentialite_version: RGPD_POLICY_VERSION
3559      }
3560    }
3561  });
3562
3563  btn.disabled = false; btn.textContent = 'Créer mon compte';
3564
3565  const emailDejaUtilise = !error && signupData?.user && Array.isArray(signupData.user.identities)
3566    && signupData.user.identities.length === 0;
3567  if (error || emailDejaUtilise) {
3568    const messageErreur = error?.message || '';
3569    if (messageErreur && (messageErreur.toLowerCase().includes('pole') || messageErreur.toLowerCase().includes('académie'))) {
3570      authError('Code d\'accès à l\'académie invalide. Demande-le à ton coach.');
3571    } else if (messageErreur && messageErreur.toLowerCase().includes('invitation')) {
3572      authError('Code d\'invitation invalide ou expiré');
3573    } else if (emailDejaUtilise || (messageErreur && (messageErreur.toLowerCase().includes('already') || messageErreur.toLowerCase().includes('registered')))) {
3574      authError('Cet email appartient déjà au compte familial : il ne faut pas créer un deuxième compte. Reviens sur Connexion avec cet email, puis ouvre Mon profil → Mes enfants → Ajouter un enfant et saisis son code joueur.');
3575    } else {
3576      authError(TennisDomainRules.messageErreurUtilisateur(messageErreur));
3577    }
3578    return;
3579  }
3580
3581  const ageTennisInscription = TennisDomainRules.ageTennistique({ date_naissance: f.date_naissance.value }, getSaisonCourante());
3582  toast(ageTennisInscription !== null && ageTennisInscription < 12
3583    ? 'Compte créé ! Bienvenue en Pré-Académie.'
3584    : 'Compte créé ! En attente de validation par le coach.');
3585  switchAuthTab('login');
3586  signupNextStep(1);
3587}
3588
3589async function doLogout() {
3590  if (!(await demanderConfirmation('Se déconnecter ?', { titre: 'Déconnexion', libelle: 'Se déconnecter', danger: false }))) return;
3591  await db.auth.signOut();
3592}
3593
3594function doForgotPassword(e) {
3595  if (e) e.preventDefault();
3596  const emailCourant = document.querySelector('#form-login input[name="email"]')?.value.trim() || '';
3597  showModal(`<div class="modal-header">
3598    <h3 class="modal-title">Mot de passe oublié</h3>
3599    <button class="modal-close" data-ui-click="closeModal()" aria-label="Fermer">×</button>
3600  </div>
3601  <p class="text-dim" style="font-size:13px;line-height:1.45">Saisis l'adresse email de ton compte. Tu recevras un lien sécurisé pour choisir un nouveau mot de passe.</p>
3602  <div class="form-row">
3603    <label class="form-label" for="resetEmail">Adresse email</label>
3604    <input type="email" id="resetEmail" value="${escapeHtml(emailCourant)}" autocomplete="email" required>
3605  </div>
3606  <div class="flex gap-1 mt-2">
3607    <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button>
3608    <button class="btn btn-primary flex-1" data-ui-click="sendPasswordReset()">Envoyer le lien</button>
3609  </div>`);
3610}
3611
3612async function sendPasswordReset() {
3613  const input = $('resetEmail');
3614  const email = input?.value.trim() || '';
3615  if (!email || !input.checkValidity()) {
3616    input?.focus();
3617    toast('Saisis une adresse email valide.', true);
3618    return;
3619  }
3620  const { error } = await db.auth.resetPasswordForEmail(email, {
3621    redirectTo: window.location.origin + window.location.pathname
3622  });
3623  if (error) {
3624    toastErreur(error);
3625  } else {
3626    closeModal();
3627    toast('Email envoyé. Vérifie ta boîte mail (et les spams).');
3628  }
3629}
3630
3631// Détection du retour depuis un lien de reset password
3632async function handlePasswordRecovery() {
3633  // Supabase met le type=recovery dans l'URL hash après clic sur le lien email
3634  const hash = window.location.hash;
3635  if (hash && hash.includes('type=recovery')) {
3636    // On laisse l'app se charger puis on ouvre la modale propre (au lieu de l'ancien prompt)
3637    setTimeout(() => openChangePasswordModal(), 500);
3638  }
3639}
3640
3641async function loadProfile() {
3642  if (!session) return null;
3643  const { data, error } = await db
3644    .from('profiles')
3645    .select(COLONNES_PROFILE)
3646    .eq('id', session.user.id)
3647    .single();
3648  if (error) {
3649    journal.error('loadProfile error:', error);
3650    throw new Error('PROFIL_CHARGEMENT_IMPOSSIBLE');
3651  }
3652  return data;
3653}
3654
3655// ============================================================
3656// CHARGEMENT DES DONNÉES (selon rôle)
3657// ============================================================
3658// Alias direct : aucune couture locale ne peut réintroduire une première page
3659// tronquée tout en contournant le module de pagination testé.
3660const chargerToutesPages = TennisPagination.chargerToutesPages;
3661
3662// Un coach charge la table complète. Un joueur passe par une RPC qui ne renvoie
3663// jamais les inscriptions des autres joueurs : le tableau `inscrits` contient
3664// uniquement son propre identifiant lorsqu'il est inscrit.
3665function requeteTournoisVisibles() {
3666  if (!isCoach()) return db.rpc('tournois_catalogue_joueur');
3667  const [debutSaison, finSaison] = TennisDomainRules.bornesSaison(getSaisonCourante());
3668  return db.from('tournois').select(COLONNES_TOURNOI)
3669    .is('archived_at', null)
3670    .gte('date_fin', debutSaison)
3671    .lte('date_debut', finSaison)
3672    .order('date_debut');
3673}
3674
3675async function loadAllData() {
3676  const donneesIndisponibles = new Set();
3677  if (!etatApp.saisonAnalyse) etatApp.definirSaisonAnalyse(getSaisonCourante());
3678  // Valider la saison mémorisée AVANT de borner les grosses lectures. Une
3679  // ancienne valeur de session créée par les suggestions historiques ne doit
3680  // pas ouvrir l'application sur une saison vide ni générer de fausses alertes.
3681  let saisonsInitiales = { data: [], error: null };
3682  try {
3683    saisonsInitiales = await Promise.resolve(db.rpc('saisons_sportives_disponibles'));
3684  } catch (error) {
3685    saisonsInitiales = { data: [], error };
3686  }
3687  const saisonsDetectees = new Set([
3688    getSaisonCourante(),
3689    ...((listeSure(saisonsInitiales.data)).map(ligne =>
3689 ligne.saison).filter(Boolean))
3690  ]);
3691  if (!saisonsDetectees.has(getSaisonAnalyse())) etatApp.definirSaisonAnalyse(getSaisonCourante());
3692  const [saisonDebutChargement, saisonFinChargement] = getBornesChargementSaison();
3693  // RLS filtre automatiquement selon le rôle/identité
3694  const queries = [
3695    chargerToutesPages(() => db.from('joueurs').select(COLONNES_JOUEUR).order('nom')),
3696    chargerToutesPages(() => db.from('matches').select(COLONNES_MATCH).gte('date', saisonDebutChargement).lte('date', saisonFinChargement).order('date', { ascending: false })),
3697    isCoach()
3698      ? chargerToutesPages(() => requeteTournoisVisibles())
3699      : requeteTournoisVisibles(),
3700    db.from('config').select(COLONNES_CONFIG).eq('cle', 'groupes').single(),
3701    chargerToutesPages(() => db.from('bilans').select(COLONNES_BILAN).order('saison', { ascending: false }).order('trimestre', { ascending: false }))
3702  ];
3703  const queryLabels = ['joueurs', 'matches', 'tournois', 'configuration', 'bilans'];
3704  const inscriptionsTournoisIdx = queries.length;
3705  queries.push(chargerToutesPages(() => db.from('player_tournament_registrations').select(COLONNES_INSCRIPTION_TOURNOI).order('created_at', { ascending: false })));
3706  queryLabels.push('inscriptions aux tournois');
3707  const importsTournoisIdx = isCoach() ? queries.length : -1;
3708  if (isCoach()) {
3709    queries.push(chargerToutesPages(() => db.from('tournament_import_runs').select(COLONNES_IMPORT_TOURNOI).order('started_at', { ascending: false }).limit(30)));
3710    queryLabels.push('historique de synchronisation Ten’Up');
3711  }
3712  const rapprochementsTournoisIdx = isCoach() ? queries.length : -1;
3713  if (isCoach()) {
3714    queries.push(chargerToutesPages(() => db.from('tournament_merge_candidates').select(COLONNES_RAPPROCHEMENT_TOURNOI).eq('statut', 'pending').order('created_at', { ascending: false })));
3715    queryLabels.push('rapprochements de tournois');
3716  }
3717  // Les invitations ne sont accessibles qu'au coach (RLS)
3718  if (isCoach()) {
3719    queries.push(chargerToutesPages(() => db.from('invitations').select(COLONNES_INVITATION).order('created_at', { ascending: false })));
3720    queryLabels.push('invitations');
3721  }
3722
3723  // Codes d'accès (académie + coach), même table config, en une requête.
3724  // Ils ne sont chargés que pour un coach ; la RLS reste l'autorité serveur.
3725  const codeAccesQuery = isCoach() ? db.from('config').select(COLONNES_CONFIG).in('cle', ['code_acces_pole', 'code_acces_coach']) : null;
3726  if (codeAccesQuery) { queries.push(codeAccesQuery); queryLabels.push('codes d’accès'); }
3727
3728  // Notes coach — uniquement les coachs y ont accès (RLS protège déjà, mais on évite l'appel inutile)
3729  const notesQuery = isCoach() ? chargerToutesPages(() => db.from('notes_joueur').select(COLONNES_NOTE_COACH).order('created_at', { ascending: false })) : null;
3730  if (notesQuery) { queries.push(notesQuery); queryLabels.push('notes coach'); }
3731
3732  // Préférences notifications — pour le coach on charge TOUTES les prefs (pour pouvoir vérifier celles des joueurs avant d'envoyer un email)
3733  // Pour un joueur, on charge uniquement sa propre ligne
3734  const prefsQuery = isCoach()
3735    ? chargerToutesPages(() => db.from('preferences_notifications').select(COLONNES_PREFERENCES))
3736    : chargerToutesPages(() => db.from('preferences_notifications').select(COLONNES_PREFERENCES).eq('user_id', session.user.id));
3737  queries.push(prefsQuery);
3738  queryLabels.push('préférences de notifications');
3739
3740  // Dynamique du groupe (fonctions sécurisées) — uniquement pour les joueurs.
3741  // Renvoie des agrégats + un mur de belles perfs + objectif collectif + prochains
3742  // tournois du groupe, sans exposer les lignes individuelles. Le coach n'en a pas besoin.
3743  let dynGroupeQuery = null, perfsGroupeQuery = null, monteesGroupeQuery = null, objectifGroupeQuery = null, prochainsTournoisGroupeQuery = null, membresGroupeQuery = null;
3744  if (!isCoach()) {
3745    const saisonG = getSaisonCourante();
3746    const trimG = getTrimestreCourant();
3747    const [dDebG, dFinG] = getTrimestreDates(saisonG, trimG);
3748    // Bornes de saison (sept → août)
3749    const [sa1, sa2] = saisonG.split('-');
3750    const saisonDebut = `${sa1}-09-01`;
3751    const saisonFin = `${sa2}-08-31`;
3752    dynGroupeQuery = db.rpc('dynamique_mon_groupe', { date_debut: dDebG, date_fin: dFinG });
3753    perfsGroupeQuery = db.rpc('perfs_mon_groupe', { date_debut: dDebG, date_fin: dFinG });
3754    monteesGroupeQuery = db.rpc('montees_mon_groupe', { date_debut: dDebG, date_fin: dFinG });
3755    objectifGroupeQuery = db.rpc('objectif_collectif_groupe', { saison_debut: s
3755aisonDebut, saison_fin: saisonFin });
3756    prochainsTournoisGroupeQuery = db.rpc('prochains_tournois_groupe');
3757    membresGroupeQuery = db.rpc('membres_de_mon_groupe', { saison_debut: saisonDebut, saison_fin: saisonFin });
3758  }
3759  if (dynGroupeQuery) {
3760    queries.push(dynGroupeQuery, perfsGroupeQuery, monteesGroupeQuery, objectifGroupeQuery, prochainsTournoisGroupeQuery, membresGroupeQuery);
3761    queryLabels.push('dynamique du groupe', 'performances du groupe', 'montées du groupe', 'objectif collectif', 'tournois du groupe', 'membres du groupe');
3762  }
3763
3764  // Historique de classement (RLS : coach voit tout, joueur voit le sien) + demandes en attente
3765  const histoIdx = queries.length;
3766  queries.push(chargerToutesPages(() => db.from('classement_historique').select(COLONNES_HISTORIQUE_CLASSEMENT).order('date_changement', { ascending: true })));
3767  queryLabels.push('historique de classement');
3768  queries.push(chargerToutesPages(() => db.from('classement_demande').select(COLONNES_DEMANDE_CLASSEMENT).eq('statut', 'en_attente').order('created_at', { ascending: false })));
3769  queryLabels.push('demandes de classement');
3770
3771  // Entraînements perso (RLS : coach voit tout, joueur voit les siens)
3772  const entrainementsIdx = queries.length;
3773  queries.push(chargerToutesPages(() => db.from('entrainements').select(COLONNES_ENTRAINEMENT).gte('date', saisonDebutChargement).lte('date', saisonFinChargement).order('date', { ascending: false })));
3774  queryLabels.push('entraînements libres');
3775
3776  // Badges débloqués (fil d'actualité, lisible par tout l'académie)
3777  const badgesJoueursIdx = queries.length;
3778  queries.push(chargerToutesPages(() => db.from('badges_joueurs').select(COLONNES_BADGE_JOUEUR).order('obtenu_le', { ascending: false })));
3779  queryLabels.push('badges');
3780
3781  // Présence aux cours — planning type (RLS : coach tout, joueur ce qui le concerne)
3782  const creneauxCoursIdx = queries.length;
3783  queries.push(chargerToutesPages(() => db.from('creneaux_cours').select(COLONNES_CRENEAU_COURS).order('jour_semaine').order('heure_debut')));
3784  queryLabels.push('créneaux de cours');
3785  queries.push(chargerToutesPages(() => db.from('creneaux_cours_joueurs').select(COLONNES_AFFECTATION_COURS)));
3786  queryLabels.push('affectations aux cours');
3787
3788  const pSeancesGen = Promise.all([getLundiSemaine(),getLundiSemaine(Date.now()+6048e5)].map(p_lundi=>db.rpc('generer_seances_semaine',{p_lundi}).then(({error})=>{if(error)journal.warn('[seances] génération:',error);}).catch(error=>journal.warn('[seances] génération:',error))));
3789
3790  const results = await Promise.all(queries.map(query => Promise.resolve(query).catch(error => ({ data: null, error }))));
3791  // Une donnée secondaire indisponible conserve son fallback pour ne pas bloquer
3792  // toute l'application, mais l'erreur ne doit jamais rester silencieuse : elle
3793  // apparaît dans la console avec l'index de la requête pour permettre le diagnostic.
3794  results.forEach((result, index) => {
3795    if (result && result.error) {
3796      const label = queryLabels[index] || `données complémentaires #${index + 1}`;
3797      donneesIndisponibles.add(label);
3798      journal.warn(`[chargement ${label}]`, result.error);
3799    }
3800  });
3801  let cursor = 6 + (isCoach() ? 2 : 0); // socle + inscriptions + journaux Ten’Up coach
3802  const [joueursRes, matchesRes, tournoisRes, configRes, bilansRes] = results;
3803  // Les requêtes Supabase résolvent souvent avec { error } plutôt que de rejeter la
3804  // promesse. Sans ce contrôle, une panne pouvait être interprétée comme des tableaux
3805  // vides et produire une application incohérente au lieu de l'écran Réessayer.
3806  const critiqueErreur = [joueursRes, matchesRes].find(result => result && result.error);
3807  if (critiqueErreur) throw new Error('CHARGEMENT_DONNEES_CRITIQUES');
3808  const invitationsRes = isCoach() ? results[cursor++] : null;
3809  const codeAccesRes = isCoach() ? results[cursor++] : null;
3810  const notesRes = isCoach() ? results[cursor++] : null;
3811  const prefsRes = results[cursor++];
3812  const dynGroupeRes = !isCoach() ? results[cursor++] : null;
3813  const perfsGroupeRes = !isCoach() ? results[cursor++] : null;
3814  const monteesGroupeRes = !isCoach() ? results[cursor++] : null;
3815  const objectifGroupeRes = !isCoach() ? results[cursor++] : null;
3816  const prochainsTournoisGroupeRes = !isCoach() ? results[cursor++] : null;
3817  const membresGroupeRes = !isCoach() ? results[cursor++] : null;
3818
3819  cache.joueurs = listeSure(joueursRes.data);
3820  cache.matchesSaison = listeSure(matchesRes.data); // tous les matchs visibles de la saison chargée, avant filtre d'environnement
3821  cache.matches = listeSure(matchesRes.data);     // périmètre courant (recalculé ci-dessous)
3822  cache.tournois = TennisDomainRules.tournoisDansSaison(tournoisRes.data, getSaisonCourante())
3823    .filter(tournoi => !tournoi.archived_at);
3824  cache.inscriptionsTournois = listeSure(results[inscriptionsTournoisIdx]?.data);
3825  cache.importsTournois = importsTournoisIdx >= 0 ? listeSure(results[importsTournoisIdx]?.data) : [];
3826  cache.rapprochementsTournois = rapprochementsTournoisIdx >= 0 ? listeSure(results[rapprochementsTournoisIdx]?.data) : [];
3827  cache.config = configRes.data ? configRes.data.valeur : {
3828    elite: { nom: 'Élite', objectifMatches: 50, places: 6 },
3829    accession: { nom: 'Accession Élite', objectifMatches: 40, places: 8 },
3830    tremplin: { nom: 'Tremplin', objectifMatches: 30, places: 8 },
3831    tremplin: { nom: 'Tremplin', objectifMatches: 30, places: 8 }
3832  };
3833  if (cache.config) delete cache.config.espoirs;
3834  cache.invitations = listeSure((invitationsRes && invitationsRes.data));
3835  cache.bilans = listeSure((bilansRes && bilansRes.data));
3836  // codeAccesRes.data est un tableau (académie + coach). On extrait chaque code.
3837  const codesArr = listeSure((codeAccesRes && codeAccesRes.data));
3838  const codePoleRow = codesArr.find(r => r.cle === 'code_acces_pole');
3839  const codeCoachRow = codesArr.find(r => r.cle === 'code_acces_coach');
3840  // Sécurité : une configuration absente doit échouer explicitement. Un code de
3841  // secours embarqué dans le bundle serait public et ne constituerait aucun secret.
3842  cache.codeAccesPole = codePoleRow && codePoleRow.valeur ? String(codePoleRow.valeur) : '';
3843  cache.codeAccesCoach = codeCoachRow && codeCoachRow.valeur ? String(codeCoachRow.valeur) : '';
3844  cache.notesCoach = listeSure((notesRes && notesRes.data));
3845
3846  // Préférences notifications : on indexe par user_id pour accès rapide
3847  cache.preferencesNotifs = {};
3848  (listeSure((prefsRes && prefsRes.data))).forEach(p => {
3849    cache.preferencesNotifs[p.user_id] = p;
3850  });
3851
3852  // Dynamique du groupe (agrégats sécurisés + mur de perfs + objectif + prochains tournois, côté joueur).
3853  cache.dynamiqueGroupe = (dynGroupeRes && dynGroupeRes.data && dynGroupeRes.data[0]) || null;
3854  cache.perfsGroupe = listeSure((perfsGroupeRes && perfsGroupeRes.data));
3855  cache.monteesGroupe = listeSure((monteesGroupeRes && monteesGroupeRes.data));
3856  cache.objectifGroupe = (objectifGroupeRes && objectifGroupeRes.data && objectifGroupeRes.data[0]) || null;
3857  cache.prochainsTournoisGroupe = listeSure((prochainsTournoisGroupeRes && prochainsTournoisGroupeRes.data));
3858  cache.membresGroupe = listeSure((membresGroupeRes && membresGroupeRes.data));
3859
3860  // Historique de classement + demandes en attente (indices fixés à l'ajout des requêtes)
3861  cache.classementHistorique = listeSure(results[histoIdx] && results[histoIdx].data);
3862  cache.classementDemandes = listeSure(results[histoIdx + 1] && results[histoIdx + 1].data);
3863  cache.entrainements = listeSure(results[entrainementsIdx] && results[entrainementsIdx].data);
3864  cache.badgesJoueurs = listeSure(results[badgesJoueursIdx] && results[badgesJoueursIdx].data);
3865  cache.creneauxCours = listeSure(results[creneauxCoursIdx] && results[creneauxCoursIdx].data);
3866  cache.creneauxCoursJoueurs = listeSure(results[creneauxCoursIdx + 1] && results[creneauxCoursIdx + 1].data);
3867  // ── PERFORMANCE : ces requêtes étaient auparavant enchaînées les unes après les
3868  // autres (chaque await attendait le précédent), ce qui ajoutait autant d'allers-retours
3869  // réseau en série au démarrage. On les lance TOUTES EN PARALLÈLE : le temps total
3870  // devient celui de la plus lente, au lieu de leur somme.
3871  // Toutes les lectures indépendantes démarrent immédiatement. Seule la lecture
3872  // des séances attend leur génération idempotente ; elle ne doit pas retarder
3873  // l'annuaire, les événements, les présences ou les données Palmyre.
3874  const pSeances    = pSeancesGen.then(() => chargerToutesPages(() => db.from('seances_cours').select(COLONNES_SEANCE_COURS).gte('date', saisonDebutChargement).lte('date', saisonFinChargement).order('date', { ascending: false })));
3875  const pPresences  = chargerPresencesSaison();
3876  const pSaisons    = Promise.resolve(saisonsInitiales);
3877  const pAnnuaire   = db.rpc('annuaire_pole_minimal');
3878  // Le coach charge déjà toutes les éditions : inutile de requêter une seconde fois
3879  // les seules éditions actives, elles seront dérivées localement du même résultat.
3880  const pEvActifs   = isCoach()
3881    ? Promise.resolve({ data: null })
3882    : chargerToutesPages(() => db.from('evenements').select(COLONNES_EVENEMENT).eq('actif', true).order('date_debut', { ascending: false }));
3883  const pEvTous     = isCoach() ? chargerToutesPages(() => db.from('evenements').select(COLONNES_EVENEMENT).order('date_debut', { ascending: false })) : Promise.resolve({ data: null });
3884  const pBilansEv   = chargerToutesPages(() => db.from('bilans_evenement').select(COLONNES_BILAN_EVENEMENT));
3885  const pDebriefs   = isCoach()
3886    ? chargerToutesPages(() => db.from('debriefs_palmyre').select(COLONNES_DEBRIEF_PALMYRE).order('date', { ascending: false }))
3887    : Promise.resolve({ data: [], error: null });
3888  const pAxes       = chargerToutesPages(() => db.from('axes_travail_palmyre').select(COLONNES_AXE_PALMYRE).order('created_at', { ascending: false }));
3889  const pObs        = isCoach() ? chargerToutesPages(() => db.from('observations_palmyre').select(COLONNES_OBSERVATION_PALMYRE).order('created_at', { ascending: false })) : Promise.resolve({ data: [] });
3890  const pCreneaux   = chargerToutesPages(() => db.from('creneaux_palmyre').select(COLONNES_CRENEAU_PALMYRE).order('jour', { ascending: true }));
3891
3892  // NOTE IMPORTANTE : les query builders Supabase sont des "thenables" (ils ont .then())
3893  // mais PAS de méthode .catch(). On les enveloppe donc dans Promise.resolve(...) avant
3894  // d'attacher un .catch(), sinon : "TypeError: .catch is not a function".
3895  const safe = async (q, label, fallback) => {
3896    try {
3897      const result = await Promise.resolve(q);
3898      if (result && result.error) {
3899        donneesIndisponibles.add(label);
3900        journal.warn('[' + label + '] indisponible:', result.error);
3901        return fallback;
3902      }
3903      return result || fallback;
3904    } catch (error) {
3905      donneesIndisponibles.add(label);
3906      journal.warn('[' + label + '] indisponible:', error);
3907      return fallback;
3908    }
3909  };
3910
3911  const [rAnnuaire, rEvActifs, rEvTous, rBilansEv, rDebriefs, rAxes, rObs, rCreneaux, rSeances, rPresences, rSaisons] = await Promise.all([
3912    safe(pAnnuaire,  'annuaire',              { data: null }),
3913    safe(pEvActifs,  'evenements',            { data: [] }),
3914    safe(pEvTous,    'evenements tous',       { data: null }),
3915    safe(pBilansEv,  'bilans_evenement',      { data: [] }),
3916    safe(pDebriefs,  'debriefs_palmyre',      { data: [] }),
3917    safe(pAxes,      'axes_travail_palmyre',  { data: [] }),
3918    safe(pObs,       'observations_palmyre',  { data: [] }),
3919    safe(pCreneaux,  'creneaux_palmyre',      { data: [] }),
3920    safe(pSeances,   'seances_cours',         { data: [] }),
3921    safe(pPresences, 'presences',             { data: [] }),
3922    safe(pSaisons,   'saisons sportives',     { data: [] })
3923  ]);
3924
3925  cache.seancesCours = listeSure((rSeances && rSeances.data));
3926  cache.presences = listeSure((rPresences && rPresences.data));
3927  cache.saisonsDisponibles = [...new Set([
3928    getSaisonCourante(),
3929    ...(listeSure((rSaisons && rSaisons.data))).map(ligne => ligne.saison).filter(Boolean),
3930    ...cache.bilans.map(bilan => bilan.saison).filter(Boolean)
3931  ])].filter(saison => TennisDomainRules.bornesSaison(saison)[0]).sort((a, b) => b.localeCompare(a));
3932
3933  cache.annuaire = {};
3934  cache.annuaireGroupe = {};
3935  (listeSure((rAnnuaire && rAnnuaire.data))).forEach(p => {
3936    cache.annuaire[p.id] = p.initiale ? `${p.prenom} ${p.initiale}.` : p.prenom;
3937    if (p.groupe) cache.annuaireGroupe[p.id] = p.groupe;
3938  });
3939
3940  cache.evenementsTous = (rEvTous && rEvTous.data) || listeSure((rEvActifs && rEvActifs.data));
3941  cache.evenements = isCoach()
3942    ? cache.evenementsTous
3943    : (listeSure((rEvActifs && rEvActifs.data)));
3944
3945  cache.bilansEvenement = listeSure((rBilansEv && rBilansEv.data));
3946
3947  // Débriefs du soir (synthèses IA) et observations Palmyre — coach uniquement (RLS).
3948  cache.debriefsPalmyre = listeSure((rDebriefs && rDebriefs.data));
3949  cache.observationsPalmyre = listeSure((rObs && rObs.data));
3950
3951  // Axes de travail Palmyre (partagés avec le joueur) : coach ET joueur (RLS filtre).
3952  cache.axesPalmyre = listeSure((rAxes && rAxes.data));
3953
3954  // Créneaux Palmyre (planning & logistique du stage). RLS : coach voit tout,
3955  // joueur voit les siens.
3956  cache.creneauxPalmyre = listeSure((rCreneaux && rCreneaux.data));
3957  cache.donneesIndisponibles = [...donneesIndisponibles];
3958
3959  // Une édition archivée reste chargée pour ses bilans, mais ne peut plus être
3960  // utilisée comme environnement de travail.
3961  etatApp.validerEnvironnement((listeSure(cache.evenements)).filter(evenement => evenement.actif).map(evenement => evenement.cle));
3962
3963  // Applique le périmètre de matchs selon l'environnement courant, puis recalcule
3964  // les caches dérivés (alertes, belles perfs) sur ce périmètre.
3965  appliquerPerimetreMatchs();
3966  cache.alertes = computeAlertes();
3967  cache.bellePerfs = computeBellePerfs();
3968  await chargerUrlsAvatarsSignees();
3969}
3970
3971async function reloadTables(tables) {
3972  const [saisonDebutChargement, saisonFinChargement] = getBornesChargementSaison();
3973  const map = {
3974    matches:               { q: () => db.from('matches').select(COLONNES_MATCH).gte('date', saisonDebutChargement).lte('date', saisonFinChargement).order('date', { ascending: false }), set: d => { cache.matchesSaison = d; } },
3975    entrainements:         { q: () => db.from('entrainements').select(COLONNES_ENTRAINEMENT).gte('date', saisonDebutChargement).lte('date', saisonFinChargement).order('date', { ascending: false }), set: d => { cache.entrainements = d; } },
3976    presences:             { q: () => chargerPresencesSaison(), set: d => { cache.presences = d; } },
3977    seances_cours:         { q: () => db.from('seances_cours').select(COLONNES_SEANCE_COURS).gte('date', saisonDebutChargement).lte('date', saisonFinChargement).order('date', { ascending: false }), set: d => { cache.seancesCours = d; } },
3978    creneaux_cours:        { q: () => db.from('creneaux_cours').select(COLONNES_CRENEAU_COURS).order('jour_semaine').order('heure_debut'), set: d => { cache.creneauxCours = d; } },
3979    creneaux_cours_joueurs:{ q: () => db.from('creneaux_cours_joueurs').select(COLONNES_AFFECTATION_COURS), set: d => { cache.creneauxCoursJoueurs = d; } },
3980    tournois:              { q: () => requeteTournoisVisibles(), set: d => {
3981      cache.tournois = TennisDomainRules.tournoisDansSaison(d, getSaisonCourante())
3982        .filter(tournoi => !tournoi.archived_at);
3983    } },
3984    bilans:                { q: () => db.from('bilans').select(COLONNES_BILAN).order('saison', { ascending: false }).order('trimestre', { ascending: false }), set: d => { cache.bilans = d; } },
3985    notes_joueur:          { q: () => isCoach() ? db.from('notes_joueur').select(COLONNES_NOTE_COACH).order('created_at', { ascending: false }) : Promise.resolve({ data: [], error: null }), set: d => { cache.notesCoach = d; } },
3986    joueurs:               { q: () => db.from('joueurs').select(COLONNES_JOUEUR).order('nom'), set: d => { cache.joueurs = d; } },
3987    notifications:         { q: () => db.from('notifications').select(COLONNES_NOTIFICATION).eq('user_id', session?.user?.id).order('created_at', { ascending: false }).limit(50), set: d => { cache.notifications = d; } },
3988    inscriptions_tournois: { q: () => db.from('player_tournament_registrations').select(COLONNES_INSCRIPTION_TOURNOI).order('created_at', { ascending: false }), set: d => { cache.inscriptionsTournois = d; } },
3989    imports_tournois:      { q: () => isCoach() ? db.from('tournament_import_runs').select(COLONNES_IMPORT_TOURNOI).order('started_at', { ascending: false }).limit(30) : Promise.resolve({ data:[], error:null }), set: d => { cache.importsTournois = d; } },
3990    invitations:           { q: () => isCoach() ? db.from('invitations').select(COLONNES_INVITATION).order('created_at', { ascending: false }) : Promise.resolve({ data: [], error: null }), set: d => { cache.invitations = d; } },
3991    preferences_notifications: { q: () => isCoach() ? db.from('preferences_notifications').select(COLONNES_PREFERENCES) : db.from('preferences_notifications').select(COLONNES_PREFERENCES).eq('user_id', session.user.id), set: d => { cache.preferencesNotifs = Object.fromEntries(d.map(p => [p.user_id, p])); } },
3992    classement_historique: { q: () => db.from('classement_historique').select(COLONNES_HISTORIQUE_CLASSEMENT).order('date_changement', { ascending: true }), set: d => { cache.classementHistorique = d; } },
3993    classement_demandes:   { q: () => db.from('classement_demande').select(COLONNES_DEMANDE_CLASSEMENT).eq('statut', 'en_attente').order('created_at', { ascending: false }), set: d => { cache.classementDemandes = d; } },
3994    creneaux_palmyre:      { q: () => db.from('creneaux_palmyre').select(COLONNES_CRENEAU_PALMYRE).order('jour', { ascending: true }), set: d => { cache.creneauxPalmyre = d; } },
3995    observations_palmyre:  { q: () => db.from('observations_palmyre').select(COLONNES_OBSERVATION_PALMYRE).order('created_at', { ascending: false }), set: d => { cache.observationsPalmyre = d; } },
3996    axes_travail_palmyre:  { q: () => db.from('axes_travail_palmyre').select(COLONNES_AXE_PALMYRE).order('created_at', { ascending: false }), set: d => { cache.axesPalmyre = d; } },
3997    badges_joueurs:        { q: () => db.from('badges_joueurs').select(COLONNES_BADGE_JOUEUR).order('obtenu_le', { ascending: false }), set: d => { cache.badgesJoueurs = d; } },
3998    bilans_evenement:      { q: () => db.from('bilans_evenement').select(COLONNES_BILAN_EVENEMENT), set
3998: d => { cache.bilansEvenement = d; } },
3999    debriefs_palmyre:      { q: () => isCoach() ? db.from('debriefs_palmyre').select(COLONNES_DEBRIEF_PALMYRE).order('date', { ascending: false }) : Promise.resolve({ data: [], error: null }), set: d => { cache.debriefsPalmyre = d; } },
4000    evenements:            { q: () => db.from('evenements').select(COLONNES_EVENEMENT).order('date_debut', { ascending: false }), set: d => { cache.evenementsTous = d; cache.evenements = isCoach() ? d : d.filter(ev => ev.actif); } },
4001    config:                { q: () => db.from('config').select(COLONNES_CONFIG).in('cle', isCoach() ? ['groupes', 'code_acces_coach'] : ['groupes']), set: d => {
4002      const groupes = d.find(row => row.cle === 'groupes');
4003      if (groupes && groupes.valeur) cache.config = groupes.valeur;
4004      if (cache.config) delete cache.config.espoirs;
4005      if (isCoach()) {
4006        const codeCoach = d.find(row => row.cle === 'code_acces_coach');
4007        cache.codeAccesPole = '';
4008        if (codeCoach) cache.codeAccesCoach = String(codeCoach.valeur || '').trim().toUpperCase();
4009      }
4010    } }
4011  };
4012  const cibles = tables.filter(t => map[t]);
4013  if (cibles.length === 0) return;
4014  const sansPagination = new Set(['notifications', 'config', 'presences']);
4015  const results = await Promise.all(cibles.map(t => Promise.resolve(
4016    sansPagination.has(t) ? map[t].q() : chargerToutesPages(map[t].q)
4017  ).catch(error => ({ data: null, error }))));
4018  const echecs = [];
4019  cibles.forEach((t, i) => {
4020    const result = results[i];
4021    if (!result || result.error) {
4022      echecs.push(t);
4023      journal.warn('[rechargement] ' + t + ' conservé dans le cache :', result && result.error);
4024      return;
4025    }
4026    map[t].set(listeSure(result.data));
4027  });
4028  const donneesIndisponibles = new Set(cache.donneesIndisponibles || []);
4029  cibles.forEach(t => {
4030    if (echecs.includes(t)) donneesIndisponibles.add(t);
4031    else donneesIndisponibles.delete(t);
4032  });
4033  cache.donneesIndisponibles = [...donneesIndisponibles];
4034  if (echecs.length) {
4035    toast(`Enregistrement effectué, mais l’affichage de ${echecs.join(', ')} n’a pas pu être actualisé. Ne ressaisis pas la donnée : reconnecte-toi puis recharge.`, true);
4036  }
4037
4038  if (cibles.includes('matches')) {
4039    appliquerPerimetreMatchs();
4040    cache.alertes = computeAlertes();
4041    cache.bellePerfs = computeBellePerfs();
4042  }
4043  if (cibles.includes('joueurs') || cibles.includes('classement_historique')) {
4044    cache.alertes = computeAlertes();
4045    cache.bellePerfs = computeBellePerfs();
4046  }
4047  if (cibles.includes('joueurs') && !echecs.includes('joueurs')) {
4048    await chargerUrlsAvatarsSignees();
4049    updateHeaderAvatar();
4050  }
4051  // Notifications : rafraîchir le badge.
4052  if (cibles.includes('notifications') && typeof updateNotifBadge === 'function') {
4053    updateNotifBadge();
4054  }
4055  return { reussis: cibles.filter(t => !echecs.includes(t)), echecs };
4056}
4057
4058// Progression de classement d'un joueur sur une période, en nombre d'échelons gagnés
4059// (positif = montée). Basée sur l'historique des changements validés.
4060// On somme les variations d'index sur la période ; un retour en arrière annule.
4061function progressionClassementPeriode(joueurId, periode) {
4062  const [debut, fin] = periode ? getPeriodeDates(periode) : getSaisonDates();
4063  return TennisDomainRules.progressionClassementSurPeriode(
4064    historiqueClassementDuJoueur(joueurId),
4065    debut,
4066    fin
4067  );
4068}
4069
4070// Classement d'un joueur À UNE DATE donnée (YYYY-MM-DD), reconstitué depuis l'historique.
4071// On prend le classement de l'entrée d'historique la plus récente antérieure ou égale à
4072// la date du match. Si aucune (match antérieur à tout historique), on retombe sur le
4073// classement actuel du joueur. Sert à évaluer perfs/contre-perfs avec le bon classement
4074// d'époque plutôt que le classement actuel.
4075function classementADate(joueurId, dateMatch) {
4076  const j = joueurById(joueurId);
4077  if (!j) return 'NC';
4078  return TennisDomainRules.classementADate(j, historiqueClassementDuJoueur(joueurId), dateMatch);
4079}
4080
4081// Statistiques de ressenti sur un ensemble de matchs.
4082// Le ressenti (m.ressenti) est une note déclarative de 1 à 5 (😞😐🙂😀🔥), facultative.
4083// Retourne un objet riche, ou { renseigne:false } si aucun match noté.
4084// IMPORTANT : info de LECTURE uniquement — ne jamais injecter dans le score de groupe
4085// ni les alertes (donnée subjective/déclarative).
4086function emojiRessenti(note) {
4087  return TennisDomainRules.emojiRessenti(note);
4088}
4089function statsRessenti(matchs) {
4090  return TennisDomainRules.statistiquesRessenti(matchs);
4091}
4092
4093// Petit bloc HTML réutilisable affichant les stats de ressenti.
4094function renderRessentiBloc(stats) {
4095  if (!stats || !stats.renseigne) {
4096    return `<div class="text-faint" style="font-size:12px; padding:8px 0">Ressenti non renseigné sur cette période.</div>`;
4097  }
4098  const flecheColor = stats.tendance === 'hausse' ? 'var(--success-text, #1d8a5a)' : stats.tendance === 'baisse' ? 'var(--danger-text, #c0392b)' : 'var(--text-faint, #8a9794)';
4099  const tendanceLabel = stats.tendance === 'hausse' ? 'en hausse' : stats.tendance === 'baisse' ? 'en baisse' : 'stable';
4100  return `<div class="feeling-strip">
4101      <div class="feeling-score"><span class="feeling-gauge" style="--score:${stats.moyenne}"><strong>${stats.moyenneTexte}</strong></span><div><strong>${stats.moyenneTexte}/5</strong><div class="text-faint" style="font-size:11px">${stats.nbNotes} match${stats.nbNotes > 1 ? 's' : ''} noté${stats.nbNotes > 1 ? 's' : ''}</div></div></div>
4102      ${stats.moyVictoireTexte != null ? `<div class="feeling-split" style="--tone:#1d9e75">En victoire <strong>${stats.moyVictoireTexte}</strong></div>` : ''}
4103      ${stats.moyDefaiteTexte != null ? `<div class="feeling-split" style="--tone:#ff7900">En défaite <strong>${stats.moyDefaiteTexte}</strong></div>` : ''}
4104      ${stats.deltaTendance !== 0 || stats.nbNotes >= 4 ? `<div class="trend-signal" style="color:${flecheColor}">${tendanceLabel}</div>` : ''}
4105    </div>`;
4106}
4107
4108// ============================================================
4109// BADGES (gamification) — calcul à la volée sur la saison en cours
4110// ============================================================
4111// Aucune donnée stockée : tout se recalcule depuis matches/entrainements/classement.
4112// Paliers : bronze → argent → or → diamant. Certains badges sont "uniques" (exploit).
4113const BADGE_PALIERS = ['🥉', '🥈', '🥇', '💎'];
4114const BADGE_PALIERS_NOMS = ['Bronze', 'Argent', 'Or', 'Diamant'];
4115
4116// Catalogue : chaque badge a une piste, un libellé, une icône, et soit des paliers
4117// (seuils), soit un déclenchement unique (type 'exploit').
4118const BADGES_CATALOGUE = [
4119  // --- Piste ASSIDUITÉ ---
4120  { id: 'heures', piste: 'Assiduité', icone: '⏱️', label: 'Heures d\'entraînement', unite: 'h', paliers: [20, 50, 100, 200] },
4121  { id: 'seances', piste: 'Assiduité', icone: '🎾', label: 'Séances déclarées', unite: '', paliers: [10, 25, 50, 100] },
4122  { id: 'sans_faute', piste: 'Assiduité', icone: '📆', label: 'Régularité', unite: ' sem.', paliers: [4, 12, 26, 52] },
4123  // --- Piste COMPÉTITEUR ---
4124  { id: 'matchs', piste: 'Compétiteur', icone: '🎽', label: 'Matchs joués', unite: '', paliers: [10, 25, 50, 100] },
4125  { id: 'victoires', piste: 'Compétiteur', icone: '✅', label: 'Victoires', unite: '', paliers: [5, 15, 30, 60] },
4126  { id: 'serie', piste: 'Compétiteur', icone: '🔥', label: 'Série de victoires', unite: ' d\'affilée', paliers: [3, 5, 10] },
4127  // --- Piste PERFORMANCE ---
4128  { id: 'perfs', piste: 'Performance', icone: '⭐', label: 'Belles perfs', unite: '', paliers: [1, 5, 15, 30] },
4129  { id: 'belles_defaites', piste: 'Performance', icone: '🥊', label: 'Beaux combats', unite: '', paliers: [1, 5, 15, 30] },
4130  // --- Badge spécial relatif au groupe ---
4131  { id: 'objectif_groupe', piste: 'Objectif', icone: '🏅', label: 'Ma part dans l\'objectif du groupe', desc: 'Atteindre ton objectif de matchs individuel, qui compte pour le groupe', type: 'exploit' },
4132  // --- Piste INSOLITES (one-shot, fun) ---
4133  { id: 'semaine_parfaite', piste: 'Insolites', icone: '💯', label: 'Semaine parfaite', desc: '7 entraînements en 7 jours', type: 'exploit' },
4134  { id: 'dragon', piste: 'Insolites', icone: '🐉', label: 'Tombeur de dragon', desc: 'Battre un j
4134oueur classé 3 échelons ou plus au-dessus', type: 'exploit' },
4135  { id: 'remontada', piste: 'Insolites', icone: '🎢', label: 'Montagnes russes', desc: 'Gagner 3 matchs après avoir perdu le 1er set', type: 'exploit' },
4136  { id: 'mur', piste: 'Insolites', icone: '🧱', label: 'Le Mur', desc: '5 victoires d\'affilée sans perdre un set', type: 'exploit' },
4137  { id: 'tornade', piste: 'Insolites', icone: '🌪️', label: 'Tornade', desc: 'Gagner un match 6/0 6/0', type: 'exploit' },
4138  { id: 'onfire', piste: 'Insolites', icone: '🔥', label: 'On fire', desc: '3 belles perfs dans le même mois', type: 'exploit' },
4139  { id: 'marathonien', piste: 'Insolites', icone: '🏃', label: 'Marathonien', desc: '5 matchs ou plus dans la même semaine', type: 'exploit' },
4140  { id: 'mental_acier', piste: 'Insolites', icone: '💪', label: 'Mental en acier', desc: 'Gagner un match en 3 sets avec un dernier set au tie-break (7/6)', type: 'exploit' },
4141  { id: 'infatigable', piste: 'Insolites', icone: '🔋', label: 'Infatigable', desc: '3 matchs dans la même journée', type: 'exploit' },
4142  { id: 'matin', piste: 'Insolites', icone: '⏰', label: 'Lève-tôt', desc: 'Un entraînement commencé avant 8h du matin', type: 'exploit' },
4143  { id: 'extreme', piste: 'Insolites', icone: '📅', label: 'Assidu de l\'extrême', desc: '10 entraînements dans le mois', type: 'exploit' },
4144  { id: 'ascension2', piste: 'Insolites', icone: '🏔️', label: 'L\'ascension', desc: 'Monter de 2 échelons ou plus dans la saison', type: 'exploit' }
4145];
4146
4147const { badgeArtHtml, rankArtHtml, progressArtHtml, levelArtHtml, PISTE_ART_ASSETS, sportNavIcon, sportUiIcon, matchChoiceIcon, EMOJI_ICON_KEYS } = TennisIconography.creer({ escapeHtml, badgePalierNoms: BADGE_PALIERS_NOMS });
4148
4149// Parse un score "6/4 6/1" → [{moi, adv}, ...]. Tolérant (ignore les sets mal formés).
4150function parseScoreSets(scoreStr) {
4151  return TennisDomainRules.parseScoreSets(scoreStr);
4152}
4153
4154// "Belle défaite" (beau combat) : une DÉFAITE en simple (hors WO) contre un
4155// adversaire MIEUX classé (au moins 1 échelon au-dessus) où le joueur a tout de même
4156// pris AU MOINS UN SET. Symétrique de la belle perf, pour valoriser le combat.
4157// Nécessite un score exploitable (sets lisibles) — d'où l'intérêt de la saisie
4158// structurée qui garantit un format propre.
4159function estBelleDefaite(m, joueurId) {
4160  return TennisDomainRules.estBelleDefaite(m, classementADate(joueurId, m && m.date));
4161}
4162
4163// Calcule la plus longue série de victoires (matchs triés par date, WO ignorés).
4164function plusLongueSerieVictoires(matchs) {
4165  return TennisDomainRules.plusLongueSerieVictoires(matchs);
4166}
4167
4168// Retourne, pour un joueur et une saison, l'état de chaque badge.
4169// Adaptateur UI : collecte les données du cache puis confie toutes les règles de
4170// déblocage, paliers et progression au moteur métier pur et testé.
4171function calculerBadges(joueurId, saison) {
4172  const joueur = joueurById(joueurId);
4173  if (!joueur) return [];
4174  const saisonCible = saison || getSaisonAnalyse();
4175  const morceaux = saisonCible.match(/^(\d{4})-(\d{4})$/);
4176  if (!morceaux) return [];
4177  const debut = `${morceaux[1]}-09-01`;
4178  const fin = `${morceaux[2]}-08-31`;
4179  const matches = matchesEntreDate(joueurId, debut, fin).map(match => ({
4180    ...match,
4181    classement_joueur_date: classementADate(joueurId, match.date)
4182  }));
4183  const entrainements = entrainementsDuJoueur(joueurId)
4184    .filter(item => TennisDomainRules.dateDansIntervalle(item.date, debut, fin));
4185  return TennisDomainRules.calculerBadgesDepuisDonnees({
4186    matches,
4187    entrainements,
4188    classementDebut: classementADate(joueurId, debut),
4189    classementFin: classementADate(joueurId, fin),
4190    objectifMatches: (cache.config[joueur.groupe] && cache.config[joueur.groupe].objectifMatches) || 0,
4191    catalogue: BADGES_CATALOGUE,
4192    iconesPaliers: BADGE_PALIERS,
4193    nomsPaliers: BADGE_PALIERS_NOMS
4194  });
4195}
4196
4197const XP_BAREME = {
4198  entrainement: 10,    // déclarer une séance
4199  presenceDeclaree: 5,
4200  matchJoue: 20,       // jouer un match (quel que soit le résultat)
4201  matchGagne: 15,      // bonus victoire
4202  perfParEchelon: 20,  // une belle perf rapporte 20 XP par échelon d'écart
4203  perfMaxEchelons: 12, // filet anti-erreur de saisie : 240 XP maximum par perf
4204  belleDefaite: 15,    // beau combat : défaite en ayant pris un set à plus fort
4205  badgeInsolite: 50,   // débloquer un insolite
4206  palier: { 1: 40, 2: 80, 3: 150, 4: 300 } // bronze, argent, or, diamant
4207};
4208
4209const NIVEAUX = [
4210  { niveau: 1,  titre: 'Débutant',          seuil: 0 },
4211  { niveau: 2,  titre: 'Apprenti',          seuil: 100 },
4212  { niveau: 3,  titre: 'Joueur confirmé',   seuil: 250 },
4213  { niveau: 4,  titre: 'Compétiteur',       seuil: 450 },
4214  { niveau: 5,  titre: 'Compétiteur aguerri', seuil: 700 },
4215  { niveau: 6,  titre: 'Battant',           seuil: 1000 },
4216  { niveau: 7,  titre: 'Pilier de l\'académie',    seuil: 1400 },
4217  { niveau: 8,  titre: 'Redoutable',        seuil: 1900 },
4218  { niveau: 9,  titre: 'Maître du court',   seuil: 2500 },
4219  { niveau: 10, titre: 'Champion',          seuil: 3200 },
4220  { niveau: 11, titre: 'Champion d\'élite', seuil: 4100 },
4221  { niveau: 12, titre: 'Légende du club',   seuil: 5200 }
4222];
4223
4224function calculerXP(joueurId, saison) {
4225  saison = saison || getSaisonAnalyse();
4226  // Les badges sont dérivés des données sportives sources : l'XP ne dépend
4227  // jamais d'une auto-attribution persistée par le navigateur.
4228  const badgesCalcules = calculerBadges(joueurId, saison).flatMap(badge => {
4229    if (!badge.debloque) return [];
4230    if (badge.type === 'exploit') return [{ badge_id: badge.id, palier: null, saison }];
4231    return Array.from({ length: badge.niveau || 0 }, (_, index) => ({
4232      badge_id: badge.id, palier: index + 1, saison
4233    }));
4234  });
4235  return TennisDomainRules.calculerXPDepuisDonnees({
4236    saison,
4237    bareme: XP_BAREME,
4238    catalogue: BADGES_CATALOGUE,
4239    entrainements: entrainementsDuJoueur(joueurId),
4240    presences: presencesDuJoueur(joueurId).map(p => ({ ...p, date: seanceCoursById(p.seance_id)?.date })),
4241    matches: matchsDuJoueur(joueurId).map(match => ({
4242      ...match,
4243      classement_joueur_date: classementADate(joueurId, match.date)
4244    })),
4245    badges: badgesCalcules
4246  });
4247}
4248
4249// Détermine le niveau atteint pour un total d'XP, + progression ver
4249s le suivant.
4250function niveauDepuisXP(xp) {
4251  return TennisDomainRules.niveauDepuisXP(xp, NIVEAUX);
4252}
4253
4254// Détecte les badges nouvellement débloqués pour un joueur et les enregistre dans
4255// badges_joueurs (alimente le fil d'actualité de l'académie). Idempotent : n'insère que
4256// ce qui n'existe pas déjà (contrainte d'unicité en base + filtre local).
4257// Pour les badges à paliers, on enregistre chaque palier franchi (palier = niveau).
4258async function synchroniserBadges(joueurId) {
4259  if (!joueurId) return;
4260  // Les joueurs voient leurs badges calculés immédiatement, mais seule une
4261  // session coach persiste les médailles dans le fil collectif.
4262  if (!isCoach()) return;
4263  const saison = getSaisonCourante();
4264  const badges = calculerBadges(joueurId, saison);
4265  const dejaEnreg = badgesDuJoueurPourSaison(joueurId, saison);
4266
4267  const aInserer = [];
4268  badges.forEach(b => {
4269    if (!b.debloque) return;
4270    if (b.type === 'exploit') {
4271      const existe = dejaEnreg.some(x => x.badge_id === b.id && x.palier == null);
4272      if (!existe) aInserer.push({ joueur_id: joueurId, badge_id: b.id, palier: null, saison });
4273    } else {
4274      // Enregistrer chaque palier atteint jusqu'au niveau courant
4275      for (let p = 1; p <= b.niveau; p++) {
4276        const existe = dejaEnreg.some(x => x.badge_id === b.id && x.palier === p);
4277        if (!existe) aInserer.push({ joueur_id: joueurId, badge_id: b.id, palier: p, saison });
4278      }
4279    }
4280  });
4281
4282  if (aInserer.length === 0) return;
4283  const { data, error } = await db.rpc('coach_inserer_badges', { p_badges: aInserer }).select(COLONNES_BADGE_JOUEUR);
4284  if (error) { journal.warn('[badges] sync:', error); return; }
4285  // Mettre à jour le cache local
4286  if (data && data.length) cache.badgesJoueurs = [...data, ...(listeSure(cache.badgesJoueurs))];
4287}
4288
4289// Synchronise les badges ET célèbre le joueur : nouveaux badges + montée de niveau.
4290// `niveauAvant` = numéro de niveau avant l'action (capturé par l'appelant avant save).
4291// Renvoie le nombre de nouveaux badges. Déclenche les animations (enchaînées).
4292async function synchroniserBadgesEtCelebrer(joueurId, etatAvant) {
4293  // etatAvant : { niveau, xp } capturé AVANT l'action (ou null si pas le joueur lui-même).
4294  // Rétrocompat : si un simple nombre est passé, on le traite comme le niveau avant.
4295  let niveauAvant = null, xpAvant = null;
4296  if (etatAvant != null) {
4297    if (typeof etatAvant === 'object') { niveauAvant = etatAvant.niveau; xpAvant = etatAvant.xp; }
4298    else { niveauAvant = etatAvant; }
4299  }
4300
4301  const cleAvant = new Set((listeSure(cache.badgesJoueurs))
4302    .filter(x => x.joueur_id === joueurId)
4303    .map(x => `${x.badge_id}|${x.palier == null ? '' : x.palier}`));
4304  await synchroniserBadges(joueurId);
4305  const apresList = badgesDuJoueur(joueurId);
4306  const nouveauxRows = apresList.filter(x => !cleAvant.has(`${x.badge_id}|${x.palier == null ? '' : x.palier}`));
4307
4308  const xpApres = calculerXP(joueurId).xp;
4309
4310  // Popup flottant "+X XP" : feedback immédiat, affiché en premier.
4311  if (xpAvant != null) {
4312    const gain = xpApres - xpAvant;
4313    if (gain > 0) animerGainXP(gain);
4314  }
4315
4316  const file = []; // file d'animations à enchaîner (badges puis level-up)
4317
4318  if (nouveauxRows.length > 0) {
4319    nouveauxRows.forEach(row => {
4320      const def = BADGES_CATALOGUE.find(b => b.id === row.badge_id);
4321      if (!def) return;
4322      const estPalier = row.palier != null && def.paliers;
4323      file.push({
4324        type: 'badge',
4325        badgeId: def.id,
4326        icone: def.icone,
4327        label: def.label,
4328        palierNom: estPalier ? (BADGE_PALIERS_NOMS[row.palier - 1] || '') : '',
4329        palierIcone: estPalier ? (BADGE_PALIERS[row.palier - 1] || '') : '',
4330        palierIndex: estPalier ? row.palier - 1 : null,
4331        insolite: def.piste === 'Insolites'
4332      });
4333    });
4334  }
4335
4336  // Montée de niveau : comparer le niveau avant/après l'action
4337  if (niveauAvant != null) {
4338    const nApres = niveauDepuisXP(xpApres);
4339    if (nApres.niveau > niveauAvant) {
4340      file.push({ type: 'levelup', niveau: nApres.niveau, titre: nApres.titre });
4341    }
4342  }
4343
4344  // On laisse le popup XP vivre seul un court instant avant les grosses animations.
4345  if (file.length > 0) {
4346    if (xpAvant != null && (xpApres - xpAvant) > 0) setTimeout(() => celebrerProgression(file), 900);
4347    else celebrerProgression(file);
4348  }
4349  return nouveauxRows.length;
4350}
4351
4352// ✨ Popup flottant "+X XP" : surgit au centre, façon lointain → grossit en
4353// s'approchant, puis monte et s'estompe. Purement visuel, n'empêche aucune action.
4354function animerGainXP(montant) {
4355  const el = document.createElement('div');
4356  el.textContent = `+${montant} XP`;
4357  el.style.cssText = `position:fixed; top:42%; left:50%; z-index:99998; pointer-events:none;
4358    font-size:42px; font-weight:900; color:#FFD23F; white-space:nowrap;
4359    text-shadow:0 2px 12px rgba(243,146,0,0.7), 0 0 24px rgba(255,210,63,0.5);
4360    transform:translate(-50%,-50%) scale(0.2); opacity:0;
4361    animation:gainXpFloat 1.6s cubic-bezier(0.18,0.7,0.3,1) forwards;`;
4362  document.body.appendChild(el);
4363  setTimeout(() => el.remove(), 1700);
4364}
4365
4366// Enchaîne une file de célébrations (badges puis montée de niveau).
4367function celebrerProgression(file) {
4368  if (!file || !file.length) return;
4369  if (document.getElementById('deblocageOverlay')) return;
4370  const badges = file.filter(f => f.type === 'badge');
4371  const levelup = file.find(f => f.type === 'levelup');
4372  // On affiche d'abord les badges, puis le level-up à la suite.
4373  if (badges.length > 0) {
4374    celebrerDeblocage(badges, levelup ? () => celebrerLevelUp(levelup) : null);
4375  } else if (levelup) {
4376    celebrerLevelUp(levelup);
4377  }
4378}
4379
4380// 🎉 Animation plein écran de déblocage de badge(s).
4381// Fond sombre, rayons de lumière rotatifs, badge géant qui pulse, confettis.
4382// Si plusieurs badges, on les enchaîne. Tap n'importe où pour passer/fermer.
4383function celebrerDeblocage(badges, onFini) {
4384  if (!badges || !badges.length) { if (onFini) onFini(); return; }
4385  // Éviter les superpositions : si une célébration est déjà à l'écran, on ne réempile pas.
4386  if (document.getElementById('deblocageOverlay')) return;
4387
4388  let index = 0;
4389  const overlay = document.createElement('div');
4390  overlay.id = 'deblocageOverlay';
4391  overlay.style.cssText = `position:fixed; inset:0; z-index:99999; display:flex; align-items:center; justify-content:center;
4392    background:radial-gradient(circle at center, rgba(20,18,30,0.92), rgba(8,6,12,0.97)); backdrop-filter:blur(3px);
4393    opacity:0; transition:opacity 0.3s ease; overflow:hidden; cursor:pointer;`;
4394
4395  // Génère les confettis (petits rectangles colorés qui tombent en tournant)
4396  const couleurs = ['#F39200', '#1D9E75', '#378ADD', '#7F77DD', '#E94F64', '#FFD23F'];
4397  let confettisHtml = '';
4398  for (let i = 0; i < 70; i++) {
4399    const c = couleurs[i % couleurs.length];
4400    const left = Math.random() * 100;
4401    const delay = Math.random() * 0.6;
4402    const dur = 1.8 + Math.random() * 1.6;
4403    const size = 6 + Math.random() * 8;
4404    const rot = Math.random() * 360;
4405    confettisHtml += `<div style="position:absolute; top:-20px; left:${left}%; width:${size}px; height:${size * 0.4}px;
4406      background:${c}; opacity:0; transform:rotate(${rot}deg);
4407      animation:confettiFall ${dur}s linear ${delay}s infinite;"></div>`;
4408  }
4409
4410  function contenuBadge(b) {
4411    const visuel = b.insolite
4412      ? badgeArtHtml(b.badgeId, { size: 210, label: b.label })
4413      : (b.palierIndex != null ? rankArtHtml(b.palierIndex, { size: 210 }) : sportUiIcon('trophy', '', 'ui-icon-art'));
4414    const sousTitre = b.palierNom
4415      ? `<div style="font-size:16px; color:#dfff35; font-weight:800; margin-top:6px;letter-spacing:1.5px;text-transform:uppercase">${escapeHtml(b.palierNom)}</div>`
4416      : (b.insolite ? `<div style="font-size:13px; color:#bdb6d8; font-weight:600; letter-spacing:1px; margin-top:2px">BADGE INSOLITE</div>` : '');
4417    return `
4418      <div class="deblocage-rayons"></div>
4419      <div style="position:relative; z-index:2; text-align:center; padding:0 24px">
4420        <div style="font-size:13px; color:#dfff35; font-weight:900; letter-spacing:3px; text-transform:uppercase; margin-bottom:10px; animation:deblFadeDown 0.6s ease both">Nouveau trophée débloqué</div>
4421        <div class="deblocage-badge" style="width:210px;height:210px;margin:auto;line-height:1;filter:drop-shadow(0 22px 35px rgba(255,121,0,.42))">${visuel}</div>
4422        <div style="font-family:'Bricolage Grotesque',sans-serif;font-size:30px;font-weight:900;color:#fff;margin-top:8px;text-transform:uppercase;animation:deblFadeUp 0.6s ease 0.15s both">${escapeHtml(b.label)}</div>
4423        ${sousTitre}
4424        ${badges.length > 1 ? `<div style="font-size:12px; color:#8a84a0; margin-top:20px">${index + 1} / ${badges.length} · touche pour continuer</div>` : `<div style="font-size:12px; color:#8a84a0; margin-top:20px">touche pour fermer</div>`}
4425      </div>`;
4426  }
4427
4428  function afficher() {
4429    overlay.innerHTML = `<div style="position:absolute; inset:0; pointer-events:none">${confettisHtml}</div>` + contenuBadge(badges[index]);
4430  }
4431
4432  function suivant() {
4433    index++;
4434    if (index >= badges.length) {
4435      overlay.style.opacity = '0';
4436      setTimeout(() => { overlay.remove(); if (onFini) onFini(); }, 300);
4437    } else {
4438      afficher();
4439    }
4440  }
4441
4442  overlay.onclick = suivant;
4443  afficher();
4444  document.body.appendChild(overlay);
4445  // forcer le fade-in
4446  requestAnimationFrame(() => { overlay.style.opacity = '1'; });
4447  // Vibration légère si supportée (pas de son)
4448  if (navigator.vibrate) { try { navigator.vibrate(60); } catch (e) {} }
4449}
4450
4451// 🆙 Animation plein écran de montée de niveau. Style « ascension » : fond sombre,
4452// rayons, numéro de niveau géant qui surgit, titre du niveau, confettis dorés.
4453function celebrerLevelUp(info) {
4454  if (!info) return;
4455  if (document.getElementById('deblocageOverlay')) return;
4456
4457  const overlay = document.createElement('div');
4458  overlay.id = 'deblocageOverlay';
4459  overlay.style.cssText = `position:fixed; inset:0; z-index:99999; display:flex; align-items:center; justify-content:center;
4460    background:radial-gradient(circle at center, rgba(30,22,8,0.94), rgba(8,6,12,0.98)); backdrop-filter:blur(3px);
4461    opacity:0; transition:opacity 0.3s ease; overflow:hidden; cursor:pointer;`;
4462
4463  const couleurs = ['#F39200', '#FFD23F', '#FFB30F', '#FFE08A', '#E8920A'];
4464  let confettisHtml = '';
4465  for (let i = 0; i < 80; i++) {
4466    const c = couleurs[i % couleurs.length];
4467    const left = Math.random() * 100;
4468    const delay = Math.random() * 0.6;
4469    const dur = 1.8 + Math.random() * 1.6;
4470    const size = 6 + Math.random() * 9;
4471    const rot = Math.random() * 360;
4472    confettisHtml += `<div style="position:absolute; top:-20px; left:${left}%; width:${size}px; height:${size * 0.4}px;
4473      background:${c}; opacity:0; transform:rotate(${rot}deg);
4474      animation:confettiFall ${dur}s linear ${delay}s infinite;"></div>`;
4475  }
4476
4477  overlay.innerHTML = `
4478    <div style="position:absolute; inset:0; pointer-events:none">${confettisHtml}</div>
4479    <div class="deblocage-rayons" style="background:repeating-conic-gradient(from 0deg, rgba(255,210,63,0.16) 0deg 8deg, transparent 8deg 16deg)"></div>
4480    <div style="position:relative; z-index:2; text-align:center; padding:0 24px">
4481      <div style="font-size:14px; color:#FFD23F; font-weight:800; letter-spacing:4px; text-transform:uppercase; margin-bottom:14px; animation:deblFadeDown 0.6s ease both">⬆ Niveau supérieur ⬆</div>
4482      <div class="deblocage-badge" style="font-size:96px; line-height:1; font-weight:900; color:#FFD23F; text-shadow:0 0 30px rgba(255,178,15,0.7)">${info.niveau}</div>
4483      <div style="font-size:13px; color:#bdb6d8; font-weight:600; letter-spacing:1px; margin-top:8px">NIVEAU ${info.niveau}</div>
4484      <div style="font-size:26px; font-weight:800; color:#fff; margin-top:6px; animation:deblFadeUp 0.6s ease 0.15s both">${escapeHtml(info.titre)}</div>
4485      <div style="font-size:12px; color:#8a84a0; margin-top:22px">touche pour fermer</div>
4486    </div>`;
4487
4488  overlay.onclick = () => { overlay.style.opacity = '0'; setTimeout(() => overlay.remove(), 300); };
4489  document.body.appendChild(overlay);
4490  requestAnimationFrame(() => { overlay.style.opacity = '1'; });
4491  if (navigator.vibrate) { try { navigator.vibrate([60, 40, 80]); } catch (e) {} }
4492}
4493
4494// Rattrapage (coach uniquement) : calcule et enregistre les badges de TOUS les
4495// joueurs actifs. Utile pour les joueurs qui n'ont rien déclaré depuis l'arrivée
4496// des badges. Insère uniquement ce qui manque (la contrainte d'unicité protège
4497// des doublons). Nécessite la policy RLS "badges_joueurs insertion coach".
4498async function rattrapageBadgesTousJoueurs() {
4499  if (!isCoach()) { toast('Réservé au coach', true); return; }
4500  const btn = $('btnRattrapageBadges');
4501  if (btn) { btn.disabled = true; btn.textContent = '⏳ Calcul en cours...'; }
4502
4503  const saison = getSaisonCourante();
4504  const joueurs = (listeSure(cache.joueurs)).filter(j => j.statut === 'actif');
4505  const dejaParJoueur = {};
4506  (listeSure(cache.badgesJoueurs)).forEach(x => {
4507    if (x.saison !== saison) return;
4508    (dejaParJoueur[x.joueur_id] = dejaParJoueur[x.joueur_id] || []).push(x);
4509  });
4510
4511  const aInserer = [];
4512  joueurs.forEach(j => {
4513    const badges = calculerBadges(j.id, saison);
4514    const deja = dejaParJoueur[j.id] || [];
4515    badges.forEach(b => {
4516      if (!b.debloque) return;
4517      if (b.type === 'exploit') {
4518        if (!deja.some(x => x.badge_id === b.id && x.palier == null)) {
4519          aInserer.push({ joueur_id: j.id, badge_id: b.id, palier: null, saison });
4520        }
4521      } else {
4522        for (let p = 1; p <= b.niveau; p++) {
4523          if (!deja.some(x => x.badge_id === b.id && x.palier === p)) {
4524            aInserer.push({ joueur_id: j.id, badge_id: b.id, palier: p, saison });
4525          }
4526        }
4527      }
4528    });
4529  });
4530
4531  if (aInserer.length === 0) {
4532    toast('Tous les badges sont déjà à jour ✅');
4533    if (btn) { btn.disabled = false; btn.textContent = '🔄 Recalculer les badges de tous les joueurs'; }
4534    return;
4535  }
4536
4537  // Insertion par lots de 200 pour éviter une requête trop grosse
4538  let inseres = 0, erreur = null;
4539  for (let i = 0; i < aInserer.length; i += 200) {
4540    const lot = aInserer.slice(i, i + 200);
4541    const { data, error } = await db.from('badges_joueurs').insert(lot).select(COLONNES_BADGE_JOUEUR);
4542    if (error) { erreur = error; break; }
4543    if (data) inseres += data.length;
4544  }
4545
4546  if (erreur) {
4547    toastErreur(erreur);
4548    if (btn) { btn.disabled = false; btn.textContent = '🔄 Recalculer les badges de tous les joueurs'; }
4549    return;
4550  }
4551
4552  await reloadTables(['badges_joueurs']);
4553  toast(`✅ ${inseres} badge${inseres > 1 ? 's' : ''} enregistré${inseres > 1 ? 's' : ''} pour l'académie`);
4554  if (btn) { btn.disabled = false; btn.textContent = '🔄 Recalculer les badges de tous les joueurs'; }
4555  render();
4556}
4557
4558// Version silencieuse, lancée automatiquement à l'ouverture de l'app par un coach.
4559// Enregistre les badges déjà mérités par tous les joueurs, sans toast ni bouton.
4560// Tourne une seule fois par session (flag _rattrapageBadgesFait).
4561let _rattrapageBadgesFait = false;
4562function attendrePlageInactive() {
4563  return new Promise(resolve => {
4564    if (typeof requestIdleCallback === 'function') {
4565      requestIdleCallback(() => resolve(), { timeout: 120 });
4566    } else {
4567      setTimeout(resolve, 0);
4568    }
4569  });
4570}
4571async function rattrapageBadgesAuto() {
4572  if (_rattrapageBadgesFait) return;
4573  if (!isCoach()) return; // seul le coach a accès aux données de tous les joueurs (RLS)
4574  _rattrapageBadgesFait = true;
4575
4576  const saison = getSaisonCourante();
4577  const joueurs = (listeSure(cache.joueurs)).filter(j => j.statut === 'actif');
4578  const dejaParJoueur = {};
4579  (listeSure(cache.badgesJoueurs)).forEach(x => {
4580    if (x.saison !== saison) return;
4581    (dejaParJoueur[x.joueur_id] = dejaParJoueur[x.joueur_id] || []).push(x);
4582  });
4583
4584  const aInserer = [];
4585  for (let joueurIndex = 0; joueurIndex < joueurs.length; joueurIndex++) {
4586    const j = joueurs[joueurIndex];
4587    const badges = calculerBadges(j.id, saison);
4588    const deja = dejaParJoueur[j.id] || [];
4589    badges.forEach(b => {
4590      if (!b.debloque) return;
4591      if (b.type === 'exploit') {
4592        if (!deja.some(x => x.badge_id === b.id && x.palier == null)) {
4593          aInserer.push({ joueur_id: j.id, badge_id: b.id, palier: null, saison });
4594        }
4595      } else {
4596        for (let p = 1; p <= b.niveau; p++) {
4597          if (!deja.some(x => x.badge_id === b.id && x.palier === p)) {
4598            aInserer.push({ joueur_id: j.id, badge_id: b.id, palier: p, saison });
4599          }
4600        }
4601      }
4602    });
4603    // Le rattrapage est une tâche de fond : rendre régulièrement la main au
4604    // navigateur évite de bloquer navigation, scroll et gestes sur mobile.
4605    if ((joueurIndex + 1) % 4 === 0) await attendrePlageInactive();
4606  }
4607
4608  if (aInserer.length === 0) return;
4609  let inseres = 0;
4610  for (let i = 0; i < aInserer.length; i += 200) {
4611    const lot = aInserer.slice(i, i + 200);
4612    const { data, error } = await db.from('badges_joueurs').insert(lot).select(COLONNES_BADGE_JOUEUR);
4613    if (error) { journal.warn('[rattrapage auto]', error); break; }
4614    if (data) inseres += data.length;
4615  }
4616  if (inseres > 0) {
4617    await reloadTables(['badges_joueurs']);
4618    demanderRender();
4619  }
4620}
4621
4622// Coach : ouvre un modal pour changer directement le classement d'un joueur
4623function openChangerClassementCoach(joueurId) {
4624  const j = joueurById(joueurId);
4625  if (!j) { toast('Joueur introuvable', true); return; }
4626  const html = `<div class="modal-header">
4627    <h3 class="modal-title">Changer le classement</h3>
4628    <button class="modal-close" data-ui-click="closeModal()">×</button>
4629  </div>
4630  <div class="text-faint mb-2" style="font-size:13px">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)} — classement actuel : <strong>${escapeHtml(j.classement || 'NC')}</strong></div>
4631  <div class="form-row">
4632    <label class="form-label">Nouveau classement</label>
4633    <select id="nouveauClassementCoach">
4634      ${CLASSEMENTS.map(c => `<option value="${c}" ${j.classement === c ? 'selected' : ''}>${c}</option>`).join('')}
4635    </select>
4636  </div>
4637  <div class="text-faint" style="font-size:11px; margin-top:6px">Une montée déclenchera une notification à tout l'académie 🎉</div>
4638  <div style="margin-top:16px; display:flex; gap:8px; justify-content:flex-end">
4639    <button class="btn" style="background:var(--bg-input)" data-ui-click="closeModal()">Annuler</button>
4640    <button class="btn" data-ui-click="confirmChangerClassementCoach(${escapeHtml(encoderArgumentTexteUI(j.id))})">Appliquer</button>
4641  </div>`;
4642  showModal(html);
4643}
4644
4645async function confirmChangerClassementCoach(joueurId) {
4646  const nouveau = $('nouveauClassementCoach').value;
4647  const execution = await executerAvecVerrou($('modalBody'),
4648    () => db.rpc('coach_set_classement', { p_joueur_id: joueurId, p_nouveau: nouveau }), 'Application…');
4649  if (execution.ignoree) return;
4650  const { error } = execution.valeur;
4651  if (error) { journal.error(error); toastErreur(error); return; }
4652  closeModal();
4653  toast('Classement mis à jour');
4654  await reloadTables(['joueurs', 'classement_historique', 'classement_demandes']);
4655  render();
4656  loadNotifications();
4657}
4658
4659// Joueur : ouvre un modal pour PROPOSER un nouveau classement (validation coach requise)
4660function openDemanderClassementJoueur() {
4661  const jid = profile && profile.joueur_id;
4662  const j = jid ? joueurById(jid) : null;
4663  if (!j) { toast('Profil introuvable', true); return; }
4664  // Demande déjà en attente ?
4665  const enAttente = (listeSure(cache.classementDemandes)).find(d => d.joueur_id === j.id && d.statut === 'en_attente');
4666  const html = `<div class="modal-header">
4667    <h3 class="modal-title">Déclarer un changement de classement</h3>
4668    <button class="modal-close" data-ui-click="closeModal()">×</button>
4669  </div>
4670  <div class="text-faint mb-2" style="font-size:13px">Classement actuel : <strong>${escapeHtml(j.classement || 'NC')}</strong></div>
4671  ${enAttente ? `<div class="alert warning" style="margin-bottom:12px"><span class="icon">⏳</span><div class="alert-content">Tu as déjà demandé le classement ${escapeHtml(enAttente.nouveau_classement)}. Ton classement officiel reste ${escapeHtml(j.classement || 'NC')} jusqu'à sa publication FFT et sa vérification par le coach${enAttente.date_publication_prevue ? `, à partir du ${formatDate(enAttente.date_publication_prevue)}` : ''}.</div></div>` : ''}
4672  <div class="form-row">
4673    <label class="form-label">Mon nouveau classement</label>
4674    <select id="nouveauClassementJoueur">
4675      ${CLASSEMENTS.map(c => `<option value="${c}" ${j.classement === c ? 'selected' : ''}>${c}</option>`).join('')}
4676    </select>
4677  </div>
4678  <div class="text-faint" style="font-size:11px; margin-top:6px">Ta demande sera enregistrée comme provisoire. Le coach pourra confirmer le classement officiel après sa publication FFT, le premier mardi du mois suivant.</div>
4679  <div style="margin-top:16px; display:flex; gap:8px; justify-content:flex-end">
4680    <button class="btn" style="background:var(--bg-input)" data-ui-click="closeModal()">Annuler</button>
4681    <button class="btn" data-ui-click="confirmDemanderClassementJoueur()">Envoyer la demande</button>
4682  </div>`;
4683  showModal(html);
4684}
4685
4686async function confirmDemanderClassementJoueur() {
4687  const nouveau = $('nouveauClassementJoueur').value;
4688  const execution = await executerAvecVerrou($('modalBody'),
4689    () => db.functions.invoke('demander-classement', {
4690      body: { nouveau_classement: nouveau }
4691    }), 'Envoi…');
4692  if (execution.ignoree) return;
4693  const { error } = execution.valeur;
4694  if (error) { journal.error(error); toastErreur(error); return; }
4695  closeModal();
4696  toast('Demande provisoire enregistrée');
4697
4698  await reloadTables(['classement_demandes']);
4699  render();
4700}
4701
4702// Coach : valider ou refuser une demande de classement
4703async function traiterDemandeClassement(demandeId, accepter) {
4704  const demande = (listeSure(cache.classementDemandes)).find(d => d.id === demandeId);
4705  if (accepter && demande && !TennisDomainRules.classementPeutEtreConfirme(demande.date_publication_prevue, new Date())) {
4706    toast(`Classement vérifiable à partir du ${formatDate(demande.date_publication_prevue)}.`, true);
4707    return;
4708  }
4709  const { error } = await db.rpc('coach_traiter_demande_classement', { p_demande_id: demandeId, p_accepter: accepter });
4710  if (error) { journal.error(error); toastErreur(error); return; }
4711  toast(accepter ? 'Classement officiel confirmé' : 'Demande refusée');
4712  await reloadTables(['joueurs', 'classement_historique', 'classement_demandes']);
4713  render();
4714  loadNotifications();
4715}
4716
4717
4718// Une perf = victoire (m.victoire = true) contre un adversaire dont le classement
4719// est strictement supérieur à celui du joueur (au sens FFT, plus haut dans CLASSEMENTS).
4720// Période : trimestre en cours (saison FFT sept-août).
4721
4722function computeBellePerfs(periode) {
4723  if (!isCoach()) return [];
4724
4725  const p = periode || getTrimestreCourant();
4726  const [debut, fin] = getPeriodeDates(p);
4727
4728  return TennisDomainRules.listerBellesPerfs(cache.matches, debut, fin, {
4729    joueur: match => joueurById(match.joueur_id),
4730    classement: (match, joueur) => classementADate(joueur.id, match.date)
4731  }).filter(perf => perf.joueur && joueurDansEnvCourant(perf.joueur));
4732}
4733
4734
4735function computeAlertes() {
4736  if (!isCoach()) return [];
4737  if (isPreAcademieEnv()) return [];
4738  if (isPoleEnv() && getSaisonAnalyse() !== getSaisonCourante()) return [];
4739  const palmyre = !isPoleEnv();
4740  const joueurs = joueursActifsDansEnvCourant();
4741  if (palmyre) return TennisDomainRules.trierAlertes(TennisDomainRules.calculerAlertesPalmyre(joueurs, cache.matches));
4742  const maintenant = Date.now();
4743  const aujourdHui = dateLocaleISO(new Date(maintenant));
4744  const debutSaison = TennisDomainRules.bornesSaison(getSaisonCourante())[0];
4745  const prorataObjectif = getProrataObjectif();
4746  const alertes = joueurs.flatMap(joueur => TennisDomainRules.calculerAlertesJoueur({
4747    joueur,
4748    groupe: cache.config[joueur.groupe],
4749    preAcademie: estPreAcademie(joueur),
4750    matches: matchsDuJoueur(joueur.id),
4751    matchsSaison: matchesSaisonJoueur(joueur.id),
4752    opportunitesJouables: listeSure(cache.tournois).filter(tournoi => tournoi.date_debut >= debutSaison && tournoi.date_debut <= aujourdHui && tournoiCorrespondAuJoueur(tournoi, joueur, { sexe:true, age:true, classement:true })).length,
4753    prorataObjectif,
4754    maintenant,
4755    debutSaison
4756  }));
4757  return TennisDomainRules.trierAlertes(alertes);
4758}
4759
4760// Libellés humains pour les types d'alertes
4761const ALERT_TYPE_LABELS = {
4762  sous_engagement: 'Sous-engagement',
4763  defaites_consecutives: 'Défaites consécutives',
4764  faible_taux_victoires: 'Taux de victoires faible',
4765  inactivite: 'Inactivité prolongée',
4766  aucun_match: 'Aucun match',
4767  sans_groupe: 'Sans groupe'
4768};
4769
4770// Index id→joueur pour un lookup O(1) au lieu de parcourir le tableau à chaque appel.
4771// Reconstruit automatiquement si la référence du tableau ou sa taille change
4772// (couvre les cas loadAllData, ajout/suppression de joueur).
4773let _joueurIndexRef = null;
4774let _joueurIndexSize = -1;
4775let _joueurIndex = new Map();
4776function joueurById(id) {
4777  if (!id) return undefined;
4778  if (cache.joueurs !== _joueurIndexRef || cache.joueurs.length !== _joueurIndexSize) {
4779    _joueurIndex = new Map(cache.joueurs.map(j => [j.id, j]));
4780    _joueurIndexRef = cache.joueurs;
4781    _joueurIndexSize = cache.joueurs.length;
4782  }
4783  return _joueurIndex.get(id);
4784}
4785
4786// Index de l'historique de classement par joueur. Les perfs et contre-perfs
4787// consultent le classement à la date de chaque match : sans cet index, chacune de
4788// ces consultations reparcourt l'historique complet de toute l'académie.
4789let _historiqueClassementRef = null;
4790let _historiqueClassementSize = -1;
4791let _historiqueClassementParJoueur = new Map();
4792function historiqueClassementDuJoueur(joueurId) {
4793  const historique = listeSure(cache.classementHistorique);
4794  if (historique !== _historiqueClassementRef || historique.length !== _historiqueClassementSize) {
4795    _historiqueClassementParJoueur = new Map();
4796    historique.forEach(changement => {
4797      if (!_historiqueClassementParJoueur.has(changement.joueur_id)) {
4798        _historiqueClassementParJoueur.set(changement.joueur_id, []);
4799      }
4800      _historiqueClassementParJoueur.get(changement.joueur_id).push(changement);
4801    });
4802    _historiqueClassementRef = historique;
4803    _historiqueClassementSize = historique.length;
4804  }
4805  return _historiqueClassementParJoueur.get(joueurId) || [];
4806}
4807
4808// Index des matchs par joueur : évite un parcours complet de cache.matches pour
4809// chaque joueur dans les dashboards, alertes et scores de groupe.
4810let _matchsIndexRef = null;
4811let _matchsIndexSize = -1;
4812let _matchsParJoueur = new Map();
4813// Invalidation explicite : évite de dépendre uniquement du changement de référence
4814// ou de longueur du tableau. Toute évolution future qui modifierait un match en place
4815// pourra appeler ce garde-fou sans connaître l'implémentation de l'index.
4816function invaliderIndexMatchs() {
4817  _matchsIndexRef = null;
4818  _matchsIndexSize = -1;
4819  _matchsParJoueur = new Map();
4820}
4821function matchsDuJoueur(joueurId) {
4822  if (cache.matches !== _matchsIndexRef || cache.matches.length !== _matchsIndexSize) {
4823    _matchsParJoueur = new Map();
4824    cache.matches.forEach(match => {
4825      if (!_matchsParJoueur.has(match.joueur_id)) _matchsParJoueur.set(match.joueur_id, []);
4826      _matchsParJoueur.get(match.joueur_id).push(match);
4827    });
4828    _matchsIndexRef = cache.matches;
4829    _matchsIndexSize = cache.matches.length;
4830  }
4831  return _matchsParJoueur.get(joueurId) || [];
4832}
4833
4834// Les badges alimentent XP, niveau, profil et synchronisation. Leur dédoublonnage
4835// ne doit pas être recalculé à chaque rendu ou pour chaque joueur du dashboard.
4836let _badgesIndexRef = null;
4837let _badgesIndexSize = -1;
4838let _badgesParJoueurSaison = new Map();
4839function assurerIndexBadges() {
4840  const badges = listeSure(cache.badgesJoueurs);
4841  if (badges === _badgesIndexRef && badges.length === _badgesIndexSize) return;
4842  _badgesParJoueurSaison = new Map();
4843  TennisDomainRules.dedoublonnerBadges(badges).forEach(badge => {
4844    const cle = `${badge.joueur_id}|${badge.saison}`;
4845    if (!_badgesParJoueurSaison.has(cle)) _badgesParJoueurSaison.set(cle, []);
4846    _badgesParJoueurSaison.get(cle).push(badge);
4847  });
4848  _badgesIndexRef = badges;
4849  _badgesIndexSize = badges.length;
4850}
4851function badgesDuJoueurPourSaison(joueurId, saison) {
4852  assurerIndexBadges();
4853  return _badgesParJoueurSaison.get(`${joueurId}|${saison}`) || [];
4854}
4855function badgesDuJoueur(joueurId) {
4856  return groupCollection('badgesParJoueur', 'badgesJoueurs', b => b.joueur_id).get(joueurId) || [];
4857}
4858function matchsCompletsDuJoueur(joueurId) {
4859  return groupCollection('matchesSaisonParJoueur', 'matchesSaison', m => m.joueur_id).get(joueurId) || [];
4860}
4861
4862// Index légers pour les collections consultées plusieurs fois dans les écrans
4863// bilans, assiduité et présence. Ils restent dérivés du cache : aucune seconde
4864// source de vérité, et une nouvelle référence/longueur provoque leur reconstruction.
4865const _collectionIndexes = new Map();
4866function indexCollection(cacheKey, keyFn) {
4867  const collection = cache[cacheKey] || [];
4868  const precedent = _collectionIndexes.get(cacheKey);
4869  if (!precedent || precedent.ref !== collection || precedent.size !== collection.length) {
4870    const index = new Map();
4871    collection.forEach(item => {
4872      const key = keyFn(item);
4873      if (key != null) index.set(key, item);
4874    });
4875    _collectionIndexes.set(cacheKey, { ref: collection, size: collection.length, index });
4876    return index;
4877  }
4878  return precedent.index;
4879}
4880
4881function seanceCoursById(id) {
4882  return indexCollection('seancesCours', s => s.id).get(id);
4883}
4884function creneauCoursById(id) {
4885  return indexCollection('creneauxCours', c => c.id).get(id);
4886}
4887
4888const _groupedCollectionIndexes = new Map();
4889function groupCollection(indexName, cacheKey, keyFn) {
4890  const collection = cache[cacheKey] || [];
4891  const precedent = _groupedCollectionIndexes.get(indexName);
4892  if (!precedent || precedent.ref !== collection || precedent.size !== collection.length) {
4893    const groupes = new Map();
4894    collection.forEach(item => {
4895      const key = keyFn(item);
4896      if (key == null) return;
4897      if (!groupes.has(key)) groupes.set(key, []);
4898      groupes.get(key).push(item);
4899    });
4900    _groupedCollectionIndexes.set(indexName, { ref: collection, size: collection.length, groupes });
4901    return groupes;
4902  }
4903  return precedent.groupes;
4904}
4905
4906function creneauPalmyreById(id) {
4907  return indexCollection('creneauxPalmyre', c => c.id).get(id);
4908}
4909function creneauxPalmyreDuJoueurEvenement(joueurId, evenementId) {
4910  return groupCollection('creneauxPalmyreParJoueurEvenement', 'creneauxPalmyre', c => `${c.joueur_id}|${c.evenement_id}`)
4911    .get(`${joueurId}|${evenementId}`) || [];
4912}
4913function creneauxPalmyreDeEvenement(evenementId) {
4914  return groupCollection('creneauxPalmyreParEvenement', 'creneauxPalmyre', c => c.evenement_id)
4915    .get(evenementId) || [];
4916}
4917function axesPalmyreDuJoueur(joueurId) {
4918  return groupCollection('axesPalmyreParJoueur', 'axesPalmyre', a => a.joueur_id).get(joueurId) || [];
4919}
4920function bilansPalmyreDuJoueur(joueurId) {
4921  return groupCollection('bilansPalmyreParJoueur', 'bilansEvenement', b => b.joueur_id).get(joueurId) || [];
4922}
4923function observationsPalmyreDuJoueurEvenement(joueurId, evenementId) {
4924  return groupCollection('observationsPalmyreParJoueurEvenement', 'observationsPalmyre', o => `${o.joueur_id || ''}|${o.evenement_id}`)
4925    .get(`${joueurId || ''}|${evenementId}`) || [];
4926}
4927function observationsPalmyreDuJour(evenementId, date) {
4928  return groupCollection('observationsPalmyreParEvenementJour', 'observationsPalmyre', o => `${o.evenement_id}|${String(o.created_at || '').slice(0, 10)}`)
4929    .get(`${evenementId}|${date}`) || [];
4930}
4931function observationPalmyreById(id) {
4932  return indexCollection('observationsPalmyre', o => o.id).get(id);
4933}
4934function bilanPalmyreDuJoueurEvenement(joueurId, evenementId) {
4935  return indexCollection('bilansEvenement', b => `${b.joueur_id}|${b.evenement_id}`).get(`${joueurId}|${evenementId}`) || null;
4936}
4937function debriefPalmyreDuJour(evenementId, date) {
4938  return indexCollection('debriefsPalmyre', d => `${d.evenement_id}|${d.date}`).get(`${evenementId}|${date}`) || null;
4939}
4940
4941let _affectationsCoursRef = null;
4942let _affectationsCoursSize = -1;
4943let _joueursParCreneau = new Map();
4944let _joueursAvecCours = new Set();
4945function assurerIndexAffectationsCours() {
4946  const affectations = listeSure(cache.creneauxCoursJoueurs);
4947  if (affectations === _affectationsCoursRef && affectations.length === _affectationsCoursSize) return;
4948  _joueursParCreneau = new Map();
4949  _joueursAvecCours = new Set();
4950  affectations.forEach(a => {
4951    if (!_joueursParCreneau.has(a.creneau_id)) _joueursParCreneau.set(a.creneau_id, []);
4952    _joueursParCreneau.get(a.creneau_id).push(a.joueur_id);
4953    _joueursAvecCours.add(a.joueur_id);
4954  });
4955  _affectationsCoursRef = affectations;
4956  _affectationsCoursSize = affectations.length;
4957}
4958function idsJoueursDuCreneau(creneauId) {
4959  assurerIndexAffectationsCours();
4960  return _joueursParCreneau.get(creneauId) || [];
4961}
4962
4963let _presencesRef = null;
4964let _presencesSize = -1;
4965let _presencesParSeance = new Map();
4966let _presencesParJoueur = new Map();
4967let _presenceParSeanceJoueur = new Map();
4968function assurerIndexPresences() {
4969  const presences = listeSure(cache.presences);
4970  if (presences === _presencesRef && presences.length === _presencesSize) return;
4971  _presencesParSeance = new Map();
4972  _presencesParJoueur = new Map();
4973  _presenceParSeanceJoueur = new Map();
4974  presences.forEach(p => {
4975    if (!_presencesParSeance.has(p.seance_id)) _presencesParSeance.set(p.seance_id, []);
4976    if (!_presencesParJoueur.has(p.joueur_id)) _presencesParJoueur.set(p.joueur_id, []);
4977    _presencesParSeance.get(p.seance_id).push(p);
4978    _presencesParJoueur.get(p.joueur_id).push(p);
4979    _presenceParSeanceJoueur.set(`${p.seance_id}:${p.joueur_id}`, p);
4980  });
4981  _presencesRef = presences;
4982  _presencesSize = presences.length;
4983}
4984function presencesBrutesDeSeance(seanceId) {
4985  assurerIndexPresences();
4986  return _presencesParSeance.get(seanceId) || [];
4987}
4988function presencesDuJoueur(joueurId) {
4989  assurerIndexPresences();
4990  return _presencesParJoueur.get(joueurId) || [];
4991}
4992function presenceDeSeancePourJoueur(seanceId, joueurId) {
4993  assurerIndexPresences();
4994  return _presenceParSeanceJoueur.get(`${seanceId}:${joueurId}`);
4995}
4996
4997let _entrainementsRef = null;
4998let _entrainementsSize = -1;
4999let _entrainementsParJoueur = new Map();
5000let _entrainementParId = new Map();
5001function assurerIndexEntrainements() {
5002  const entrainements = listeSure(cache.entrainements);
5003  if (entrainements !== _entrainementsRef || entrainements.length !== _entrainementsSize) {
5004    _entrainementsParJoueur = new Map();
5005    _entrainementParId = new Map();
5006    entrainements.forEach(e => {
5007      if (!_entrainementsParJoueur.has(e.joueur_id)) _entrainementsParJoueur.set(e.joueur_id, []);
5008      _entrainementsParJoueur.get(e.joueur_id).push(e);
5009      _entrainementParId.set(e.id, e);
5010    });
5011    _entrainementsRef = entrainements;
5012    _entrainementsSize = entrainements.length;
5013  }
5014}
5015function entrainementsDuJoueur(joueurId) {
5016  assurerIndexEntrainements();
5017  return _entrainementsParJoueur.get(joueurId) || [];
5018}
5019function entrainementById(id) {
5020  assurerIndexEntrainements();
5021  return _entrainementParId.get(id);
5022}
5023
5024let _bilansRef = null;
5025let _bilansSize = -1;
5026let _bilanParJoueurSaisonTrimestre = new Map();
5027let _bilanParId = new Map();
5028let _bilansParJoueur = new Map();
5029function assurerIndexBilans() {
5030  const bilans = listeSure(cache.bilans);
5031  if (bilans !== _bilansRef || bilans.length !== _bilansSize) {
5032    _bilanParJoueurSaisonTrimestre = new Map();
5033    _bilanParId = new Map();
5034    _bilansParJoueur = new Map();
5035    bilans.forEach(b => {
5036      _bilanParJoueurSaisonTrimestre.set(`${b.joueur_id}:${b.saison}:${b.trimestre}`, b);
5037      _bilanParId.set(b.id, b);
5038      if (!_bilansParJoueur.has(b.joueur_id)) _bilansParJoueur.set(b.joueur_id, []);
5039      _bilansParJoueur.get(b.joueur_id).push(b);
5040    });
5041    _bilansRef = bilans;
5042    _bilansSize = bilans.length;
5043  }
5044}
5045function bilanDuJoueur(joueurId, saison, trimestre) {
5046  assurerIndexBilans();
5047  return _bilanParJoueurSaisonTrimestre.get(`${joueurId}:${saison}:${trimestre}`);
5048}
5049function bilanById(id) {
5050  assurerIndexBilans();
5051  return _bilanParId.get(id);
5052}
5053function bilansDuJoueur(joueurId) {
5054  assurerIndexBilans();
5055  return _bilansParJoueur.get(joueurId) || [];
5056}
5057
5058// Mémoïsation de statsJoueur : le calcul refait un filter complet de cache.matches
5059// à chaque appel. On cache les résultats par joueur, invalidés si cache.matches change
5060// (référence ou taille différente → rechargement ou ajout/suppression de match).
5061let _statsRef = null;
5062let _statsSize = -1;
5063let _statsCache = new Map();
5064function statsJoueur(id) {
5065  if (cache.matches !== _statsRef || cache.matches.length !== _statsSize) {
5066    _statsCache = new Map();
5067    _statsRef = cache.matches;
5068    _statsSize = cache.matches.length;
5069  }
5070  if (_statsCache.has(id)) return _statsCache.get(id);
5071
5072  const ms = matchsDuJoueur(id);
5073  // WO exclus des compteurs (pas un match joué). derniers5 garde tout (affichage).
5074  const compte = comptesMatchs(ms);
5075  const result = {
5076    ...compte,
5077    derniers5: ms.slice(0, 5)
5078  };
5079  _statsCache.set(id, result);
5080  return result;
5081}
5082
5083// ============================================================
5084// NAVIGATION & RENDU
5085// ============================================================
5086function iconifyUi(root) {
5087  if (!root) return;
5088  const symbolRe = /(?:\p{Extended_Pictographic}\uFE0F?|[✓✗✕⚠⬆⬇])/gu;
5089  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
5090  const nodes = [];
5091  while (walker.nextNode()) {
5092    const node = walker.currentNode;
5093    if (!node.parentElement || node.parentElement.closest('script,style,textarea,option,svg,.ui-icon')) continue;
5094    symbolRe.lastIndex = 0;
5095    if (symbolRe.test(node.nodeValue || '')) nodes.push(node);
5096  }
5097  nodes.forEach(node => {
5098    const text = node.nodeValue || '';
5099    const fragment = document.createDocumentFragment();
5100    let last = 0;
5101    text.replace(symbolRe, (symbol, offset) => {
5102      if (offset > last) fragment.appendChild(document.createTextNode(text.slice(last, offset)));
5103      const holder = document.createElement('span');
5104      holder.innerHTML = sportUiIcon(EMOJI_ICON_KEYS[symbol] || 'spark');
5105      fragment.appendChild(holder.firstElementChild);
5106      last = offset + symbol.length;
5107      return symbol;
5108    });
5109    if (last < text.length) fragment.appendChild(document.createTextNode(text.slice(last)));
5110    node.replaceWith(fragment);
5111  });
5112}
5113
5114let sportIconObserver = null;
5115function startSportIconObserver() {
5116  if (!document.body || sportIconObserver) return;
5117  iconifyUi(document.body);
5118  sportIconObserver = new MutationObserver(records => {
5119    sportIconObserver.disconnect();
5120    records.forEach(record => {
5121      record.addedNodes.forEach(node => {
5122        if (node.nodeType === Node.TEXT_NODE) iconifyUi(node.parentElement);
5123        else if (node.nodeType === Node.ELEMENT_NODE && !node.closest('.ui-icon')) iconifyUi(node);
5124      });
5125    });
5126    sportIconObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
5127  });
5128  sportIconObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
5129}
5130setTimeout(startSportIconObserver, 0);
5131
5132function navLabelText(label) {
5133  return String(label || '').replace(/^\S+\s+/, '');
5134}
5135
5136function renderMobileRoleNav(items) {
5137  const host = $('mobileRoleNav');
5138  if (!host) return;
5139  const entry = (key, label, iconKey = key, action = '') => {
5140    const active = !action && key === etatApp.ecran;
5141    const attr = action ? `data-mobile-action="${escapeHtml(action)}"` : `data-mobile-screen="${key}"`;
5142    return `<button type="button" class="mobile-role-item ${active ? 'active' : ''} ${action ? 'action' : ''}" ${attr}>${sportNavIcon(iconKey)}<span>${escapeHtml(label)}</span></button>`;
5143  };
5144  if (isCoach()) {
5145    if (isAcademieEnv()) {
5146      host.innerHTML = entry('dashboard', 'Dashboard', 'dashboard')
5147        + entry('presence', 'Présences', 'presence')
5148        + entry('planningcours', 'Planning', 'planning')
5149        + entry('bilans', 'Bilans', 'reports')
5150        + entry('matches', 'Matches', 'matches');
5151      host.querySelectorAll('[data-mobile-screen]').forEach(button => {
5152        button.addEventListener('click', () => navigate(button.dataset.mobileScreen));
5153      });
5154      host.dataset.availableScreens = items.map(([key]) => key).join(',');
5155      return;
5156    }
5157    const disponibles = new Set(items.map(([key]) => key));
5158    const groupeScreen = disponibles.has('groupes') ? 'groupes' : 'matches';
5159    const groupeLabel = groupeScreen === 'groupes' ? 'Groupes' : 'Matches';
5160    const groupeIcon = groupeScreen === 'groupes' ? 'group' : 'matches';
5161    const alerteScreen = disponibles.has('alertes') ? 'alertes' : 'perfs';
5162    host.innerHTML = entry('dashboard', 'Vue globale', 'dashboard')
5163      + entry('players', 'Joueurs', 'players')
5164      + entry(groupeScreen, groupeLabel, groupeIcon)
5165      + entry(alerteScreen, alerteScreen === 'alertes' ? 'Alertes' : 'Perfs', alerteScreen === 'alertes' ? 'alerts' : 'performances')
5166      + entry('more', 'Plus', 'more', 'more');
5167  } else {
5168    const joueurConnecte = profile?.joueur_id ? joueurById(profile.joueur_id) : null;
5169    const navigationPreAcademie = !!joueurConnecte && estPreAcademie(joueurConnecte);
5170    host.innerHTML = entry('my', 'Accueil', 'profile')
5171      + entry('myprofile', 'Progression', 'performances')
5172      + entry('matches', 'Mes matchs', 'matches')
5173      + entry('calendar', 'Tournois', 'tournaments')
5174      + (navigationPreAcademie ? '' : entry('more', 'Plus', 'more', 'more'));
5175  }
5176  host.querySelectorAll('[data-mobile-screen]').forEach(button => {
5177    button.addEventListener('click', () => navigate(button.dataset.mobileScreen));
5178  });
5179  host.querySelectorAll('[data-mobile-action]').forEach(button => {
5180    button.addEventListener('click', () => {
5181      if (button.dataset.mobileAction === 'more') openMobileMoreMenu();
5182    });
5183  });
5184  host.dataset.availableScreens = items.map(([key]) => key).join(',');
5185}
5186
5187function openMobileMoreMenu() {
5188  const items = [];
5189  if (isCoach()) {
5190    const candidates = [
5191      ['perfs', 'Performances', 'performances', 'Belles perfs et montées au classement'],
5192      ['matches', 'Matches', 'matches', 'Historique sportif des joueurs'],
5193      ['calendar', 'Tournois', 'tournaments', 'Créer et suivre les propositions'],
5194      ['presence', 'Présences', 'presence', 'Faire l’appel des groupes'],
5195      ['bilans', 'Bilans', 'reports', 'Préparer les synthèses joueurs'],
5196      ['planningcours', 'Planning', 'planning', 'Organiser les cours de l’académie'],
5197      ['planning', 'Planning', 'planning', 'Organiser la semaine du stage'],
5198      ['observations', 'Observations', 'observations', 'Retrouver les notes du staff'],
5199      ['debrief', 'Débrief', 'debrief', 'Synthèse collective de la journée'],
5200      ['groupes', 'Groupes', 'group', 'Effectifs, niveaux et objectifs'],
5201      ['alertes', 'Alertes', 'alerts', 'Priorités et joueurs à accompagner'],
5202      ['overview', 'Analyse détaillée', 'dashboard', 'Toutes les statistiques de l’académie'],
5203      ['transport', 'Transport', 'transport', 'Organiser les déplacements']
5204    ];
5205    const disponibles = new Set((($('mobileRoleNav') || {}).dataset || {}).availableScreens?.split(',') || []);
5206    const dejaVisible = new Set(['dashboard', 'players']);
5207    if (disponibles.has('groupes')) dejaVisible.add('groupes'); else dejaVisible.add('matches');
5208    if (disponibles.has('alertes')) dejaVisible.add('alertes'); else dejaVisible.add('perfs');
5209    candidates.forEach(item => {
5210      if (disponibles.has(item[0]) && !dejaVisible.has(item[0])) items.push(item);
5211    });
5212  } else {
5213    const candidates = [
5214      ['mygroupe', 'Mon groupe', 'group', 'Joueurs et dynamique collective'], ['badges', 'Mes badges', 'badges', 'Récompenses et paliers'],
5215      ['mescours', 'Planning Académie', 'presence', 'Mes cours, horaires et présences'], ['entrainements', 'Mes séances libres', 'training', 'Entraînements personnels déclarés'],
5216      ['mybilans', 'Mes bilans', 'reports', 'Synthèses partagées par le coach'], ['monplanning', 'Planning Palmyre', 'planning', 'Programme de mon stage']
5217    ];
5218    const disponibles = new Set((($('mobileRoleNav') || {}).dataset || {}).availableScreens?.split(',') || []);
5219    candidates.forEach(item => { if (disponibles.has(item[0])) items.push(item); });
5220  }
5221  const html = `<div class="modal-header"><div><div class="ux-home-kicker">${isCoach() ? 'Outils coach' : 'Mon espace'}</div><h3 class="modal-title">Plus</h3></div><button class="modal-close" data-ui-click="closeModal()">×</button></div>
5222    <div class="modal-body"><div class="ux-more-grid">${items.map(([screen, label, icon, description]) => `<button type="button" class="ux-more-btn" data-ui-click="closeModal(); navigate(${escapeHtml(encoderArgumentTexteUI(screen))})">${sportNavIcon(icon)}<span><strong>${escapeHtml(label)}</strong><small>${escapeHtml(description || '')}</small></span></button>`).join('')}</div></div>`;
5223  showModal(html);
5224}
5225
5226function buildNavMenu() {
5227  // Pré-Académie est un environnement permanent : il reste accessible même
5228  // lorsqu'aucun joueur n'y est actuellement rattaché.
5229  const items = [];
5230  if (isCoach()) {
5231    const palmyre = !isPoleEnv();          // édition Palmyre
5232    const preAca = isPreAcademieEnv();     // catégorie Pré-Académie
5233    const academie = isAcademieEnv();      // pôle compétition « classique »
5234    items.push(['dashboard', '📊 Dashboard']);
5235    // Validations + Alertes : Académie uniquement (ni Pré-Académie ni Palmyre)
5236    if (academie) {
5237      const enAttente = cache.joueurs.filter(j => j.statut === 'en_attente_validation').length;
5238      if (enAttente > 0) {
5239        items.push(['validations', `🆕 À valider (${enAttente})`]);
5240      }
5241      const nbAlertes = (listeSure(cache.alertes)).length;
5242      items.push(['alertes', `🚨 Alertes${nbAlertes > 0 ? ' (' + nbAlertes + ')' : ''}`]);
5243    }
5244    // Perfs : partout (la Pré-Académie conserve la gamification)
5245    const nbPerfs = (listeSure(cache.bellePerfs)).length;
5246    items.push(['perfs', `🔥 Perfs${nbPerfs > 0 ? ' (' + nbPerfs + ')' : ''}`]);
5247    // Groupes, Vue d'ensemble, Bilans : masqués en Pré-Académie (pas de groupes ni de bilans)
5248    if (!preAca) items.push(['groupes', '📋 Groupes']);
5249    if (!preAca) items.push(['overview', '📊 Vue d\'ensemble']);
5250    items.push(['players', '👥 Joueurs']);
5251    items.push(['matches', '🎾 Matches']);
5252    // Tournois : Académie + Pré-Académie (les tournois Palmyre sont gérés sur place)
5253    if (!palmyre) {
5254      items.push(['calendar', '📅 Tournois']);
5255    }
5256    if (!preAca) items.push(['bilans', '📋 Bilans']);
5257    // Présence : Académie uniquement. Planning : Académie + Pré-Académie.
5258    if (academie) items.push(['presence', '✅ Présence']);
5259    if (academie) items.push(['planningcours', '📆 Planning']);
5260    // Observations : carnet de notes du coach, en mode Palmyre uniquement
5261    if (palmyre) {
5262      items.push(['observations', '📝 Observations']);
5263      items.push(['debrief', '🌙 Débrief']);
5264      items.push(['planning', '📅 Planning']);
5265      items.push(['transport', '🚗 Transport']);
5266    }
5267    // Invitations : déplacé dans le menu utilisateur (plus dans la barre)
5268  } else {
5269    const monJoueur = profile && profile.joueur_id ? joueurById(profile.joueur_id) : null;
5270    const estPreAca = monJoueur && estPreAcademie(monJoueur);
5271    // Le joueur a-t-il un bilan Palmyre publié ?
5272    const aBilanPalmyre = (listeSure(cache.bilansEvenement)).some(b => b.joueur_id === (profile && profile.joueur_id) && b.statut === 'publie');
5273    // Le planning opérationnel ne reste visible que pour une édition en cours ou à venir
5274    // à laquelle le joueur est rattaché. Les anciens créneaux ne rouvrent jamais une archive.
5275    const monJoueurPalmyre = profile && profile.joueur_id ? joueurById(profile.joueur_id) : null;
5276    const aPlanningPalmyre = editionsPlanningPalmyreJoueur(monJoueurPalmyre).length > 0;
5277    items.push(['my', '👤 Accueil']);
5278    items.push(['myprofile', '📈 Progression']);
5279    if (!estPreAca) items.push(['mygroupe', '🤝 Mon groupe']);
5280    if (!estPreAca) items.push(['mescours', '📆 Planning Académie']);
5281    items.push(['calendar', '📅 Tournois']);
5282    items.push(['matches', '🎾 Mes matches']);
5283    if (!estPreAca) items.push(['entrainements', '🏋️ Mes séances libres']);
5284    if (!estPreAca) items.push(['badges', '🏅 Mes badges']);
5285    if (!estPreAca || aBilanPalmyre) items.push(['mybilans', '📋 Mes bilans']);
5286    // Planning Palmyre : exceptionnel (stage), placé en fin de liste, visible seulement si programmé
5287    if (aPlanningPalmyre) items.push(['monplanning', '🏖️ Planning Palmyre']);
5288  }
5289  const alertePrimaire = isAcademieEnv() ? 'alertes' : 'perfs';
5290  const planningPrimaire = isPoleEnv() ? 'planningcours' : 'planning';
5291  const navigationPrincipale = isCoach()
5292      ? (isAcademieEnv() ? [
5293        ['dashboard', 'Dashboard', 'dashboard'], ['presence', 'Présences', 'presence'],
5294        ['planningcours', 'Planning', 'planning'],
5295        ['bilans', 'Bilans', 'reports'], ['matches', 'Matches', 'matches']
5296      ] : [
5297        ['dashboard', 'Vue globale', 'dashboard'], ['players', 'Joueurs', 'players'],
5298        [alertePrimaire, isAcademieEnv() ? 'Alertes' : 'Perfs', isAcademieEnv() ? 'alerts' : 'perfs'],
5299        ...(!isPreAcademieEnv() ? [[planningPrimaire, 'Planning', 'planning']] : [])
5300      ])
5301    : [
5302        ['my', 'Accueil', 'profile'], ['myprofile', 'Progression', 'performances'],
5303        ['matches', 'Mes matchs', 'matches'], ['calendar', 'Tournois', 'tournaments']
5304      ];
5305  const iconeNavigation = {
5306    dashboard: 'dashboard', validations: 'alerts', alertes: 'alerts', perfs: 'performances',
5307    groupes: 'group', overview: 'dashboard', players: 'players', matches: 'matches',
5308    calendar: 'tournaments', bilans: 'reports', presence: 'presence', planningcours: 'planning',
5309    observations: 'observations', notes: 'observations', debrief: 'debrief', planning: 'planning', transport: 'transport',
5310    my: 'profile', myprofile: 'performances', mygroupe: 'group', mescours: 'presence',
5311    entrainements: 'training', badges: 'badges', mybilans: 'reports', monplanning: 'planning'
5312  };
5313  const ordreJoueurLarge = ['my', 'myprofile', 'matches', 'calendar', 'mygroupe', 'badges', 'mescours', 'entrainements', 'mybilans', 'monplanning'];
5314  const itemsParEcran = new Map(items.map(([key, label]) => [key, navLabelText(label)]));
5315  const ordreLarge = isCoach()
5316    ? (isAcademieEnv()
5317      ? navigationPrincipale.map(([key]) => key)
5318      : [...navigationPrincipale.map(([key]) => key), ...items.map(([key]) => key)])
5319    : ordreJoueurLarge;
5320  const vus = new Set();
5321  const navigationLarge = ordreLarge
5322    .filter(key => itemsParEcran.has(key) && !vus.has(key) && vus.add(key))
5323    .map(key => [key, itemsParEcran.get(key), iconeNavigation[key] || 'dashboard']);
5324  $('nav-menu').innerHTML = navigationLarge.map(([key, label, icon]) =>
5325    `<button class="nav-btn ${key === etatApp.ecran ? 'active' : ''}" data-screen="${key}">${sportNavIcon(icon)}<span>${escapeHtml(label)}</span></button>`
5326  ).join('');
5327  document.querySelectorAll('.nav-btn').forEach(b => {
5328    b.addEventListener('click', () => navigate(b.dataset.screen));
5329  });
5330  renderMobileRoleNav(items);
5331
5332  // Sélecteur d'environnement (Académie / éditions Palmyre…) — coach uniquement pour l'instant,
5333  // et seulement s'il existe au moins un événement actif.
5334  renderEnvSelector();
5335
5336  // Rafraîchir l'affichage de la version dans le footer du menu (asynchrone, silencieux)
5337  loadAppVersion();
5338}
5339
5340// Affiche le sélecteur d'environnement dans le header. Le raccourci Palmyre est
5341// permanent ; il ouvre l'édition active ou explique clairement qu'il n'y en a pas.
5342function renderEnvSelector() {
5343  const host = $('envSelector');
5344  if (!host) return;
5345  // Le sélecteur d'environnement est permanent pour le coach : Pré-Académie
5346  // ne disparaît pas lors du passage annuel si sa population devient vide.
5347  if (!isCoach()) {
5348    host.style.display = 'none';
5349    host.innerHTML = '';
5350    return;
5351  }
5352  host.style.display = 'block';
5353  const pill = (cle, label, actif, iconKey) =>
5354      `<button class="env-pill ${actif ? 'active' : ''}" data-ui-click="setCurrentEnv(${escapeHtml(encoderArgumentTexteUI(cle))})">${sportNavIcon(iconKey)}<span>${escapeHtml(label)}</span></button>`;
5355  let html = `<div class="env-bar">`;
5356  // Ordre : Pré-Académie · Académie · Palmyre
5357  html += pill('pre_academie', 'Pré-Académie', etatApp.environnement === 'pre_academie', 'pre_academie');
5358  html += pill('pole', 'Académie', etatApp.environnement === 'pole', 'pole');
5359  html += `<button class="env-pill ${getActiveEvenement() ? 'active' : ''}" data-ui-click="ouvrirPalmyreCoach()">${sportNavIcon('event')}<span>Palmyre</span></button>`;
5360  html += `</div>`;
5361  host.innerHTML = html;
5362}
5363
5364function navigate(screen, keepFilter = false, groupeDetail = null) {
5365  const ecranPrecedent = etatApp.ecran || (isCoach() ? 'dashboard' : 'my');
5366  if (!_navigationDepuisHistorique) memoriserContexteNavigation();
5367  const ecransCoach = new Set([
5368    'dashboard', 'validations', 'alertes', 'perfs', 'groupes', 'overview',
5369    'players', 'notes', 'bilans', 'observations', 'debrief', 'presence',
5370    'planningcours', 'planning', 'transport', 'invitations', 'settings'
5371  ]);
5372  if (!isCoach() && ecransCoach.has(screen)) {
5373    journal.warn('[navigation] écran coach refusé :', screen);
5374    screen = 'my';
5375    keepFilter = false;
5376  }
5377  const joueurConnecte = !isCoach() && profile?.joueur_id ? joueurById(profile.joueur_id) : null;
5378  if (joueurConnecte && estPreAcademie(joueurConnecte)) {
5379    const ecransPreAcademie = new Set(['my', 'myprofile', 'matches', 'calendar']);
5380    if (!ecransPreAcademie.has(screen)) {
5381      journal.warn('[navigation] écran masqué en Pré-Académie :', screen);
5382      screen = 'my';
5383      keepFilter = false;
5384    }
5385  }
5386  const ecransOperationnelsCourants = new Set(['validations', 'alertes', 'presence', 'planningcours', 'mescours']);
5387  if (isPoleEnv() && ecransOperationnelsCourants.has(screen) && getSaisonAnalyse() !== getSaisonCourante()) {
5388    chargerSaisonAnalyse(getSaisonCourante()).then(ok => { if (ok) navigate(screen, keepFilter); });
5389    return;
5390  }
5391  if (screen === 'players' && !keepFilter) {
5392    etatApp.reinitialiserVueJoueurs();
5393  }
5394  if (screen === 'groupes') {
5395    etatApp.definirGroupeDetail(groupeDetail);
5396  }
5397  if (!etatApp.definirEcran(screen)) return;
5398  if (!_navigationDepuisHistorique && screen !== ecranPrecedent) {
5399    history.pushState(etatNavigationCourant({ ecran: screen, scrollY: 0, modal: null }), '', window.location.href);
5400  }
5401  buildNavMenu();
5402  render();
5403  window.scrollTo(0, 0);
5404}
5405
5406async function refresh() {
5407  await loadAllData();
5408  render();
5409}
5410
5411// Un joueur non validé ne voit qu'un écran d'attente et ne peut utiliser aucune fonction.
5412function joueurEnAttente() {
5413  if (isCoach()) return false;
5414  const j = (profile && profile.joueur_id) ? joueurById(profile.joueur_id) : null;
5415  return !!(j && j.statut === 'en_attente_validation');
5416}
5417function renderEcranAttenteValidation(j) {
5418  const prenom = j && j.prenom ? escapeHtml(j.prenom) : '';
5419  return `<div class="empty" style="padding-top:60px">
5420    <span class="icon" style="font-size:48px">⏳</span>
5421    <h2 style="font-size:18px; color:var(--text); margin:16px 0 8px">Inscription en cours de validation</h2>
5422    <div style="max-width:400px; margin:0 auto; font-size:14px; line-height:1.6">
5423      Salut <strong>${prenom}</strong> ! Ton inscription a bien été enregistrée.<br><br>
5424      Ton coach va prochainement examiner ta fiche et t'affecter à un groupe (Tremplin, Accession Élite ou Élite).<br><br>
5425      <span class="text-faint">Tu pourras utiliser l'application (matches, entraînements…) dès que ton inscription sera validée. Reviens dans quelques jours, ou contacte ton coach.</span>
5426    </div>
5427  </div>`;
5428}
5429
5430let dernierEcranRendu = '';
5431function render() {
5432  const app = $('app');
5433  const restaurerPosition = dernierEcranRendu === etatApp.ecran;
5434  const positionAvantRendu = restaurerPosition ? window.scrollY : 0;
5435  const focusAvantRendu = restaurerPosition && document.activeElement?.id ? document.activeElement.id : '';
5436  app.dataset.screen = etatApp.ecran || '';
5437  app.dataset.role = isCoach() ? 'coach' : 'joueur';
5438  // Garde globale : un joueur non validé → écran d'attente, aucune fonction accessible.
5439  if (joueurEnAttente()) {
5440    app.innerHTML = renderEcranAttenteValidation(joueurById(profile.joueur_id));
5441    destroyChartsHorsDom();
5442    actualiserAuditActionsUI();
5443    return;
5444  }
5445  if (etatApp.ecran === 'dashboard') app.innerHTML = renderDashboard();
5446  else if (etatApp.ecran === 'validations') app.innerHTML = renderValidations();
5447  else if (etatApp.ecran === 'alertes') app.innerHTML = renderAlertes();
5448  else if (etatApp.ecran === 'perfs') app.innerHTML = renderPerfs();
5449  else if (etatApp.ecran === 'groupes') app.innerHTML = renderGroupes();
5450  else if (etatApp.ecran === 'overview') app.innerHTML = renderOverview();
5451  else if (etatApp.ecran === 'players') app.innerHTML = renderPlayers();
5452  else if (etatApp.ecran === 'notes') app.innerHTML = renderNotesCoach();
5453  else if (etatApp.ecran === 'my') app.innerHTML = renderPlayerHome();
5454  else if (etatApp.ecran === 'myprofile') app.innerHTML = renderPlayerProgression();
5455  else if (etatApp.ecran === 'mygroupe') app.innerHTML = renderMyGroupeScreen();
5456  else if (etatApp.ecran === 'matches') app.innerHTML = renderMatches();
5457  else if (etatApp.ecran === 'entrainements') app.innerHTML = renderMesEntrainements();
5458  else if (etatApp.ecran === 'bilans') app.innerHTML = isPoleEnv() ? renderBilansCoach() : renderBilansPalmyre();
5459  else if (etatApp.ecran === 'observations') app.innerHTML = renderObservationsPalmyre();
5460  else if (etatApp.ecran === 'debrief') app.innerHTML = renderDebriefPalmyre();
5461  else if (etatApp.ecran === 'presence') app.innerHTML = renderPresenceCours();
5462  else if (etatApp.ecran === 'planningcours') app.innerHTML = renderPlanningCours();
5463  else if (etatApp.ecran === 'mescours') app.innerHTML = renderMesCours();
5464  else if (etatApp.ecran === 'planning') app.innerHTML = renderPlanningPalmyre();
5465  else if (etatApp.ecran === 'transport') app.innerHTML = renderTransportPalmyre();
5466  else if (etatApp.ecran === 'monplanning') app.innerHTML = renderMonPlanningJoueur();
5467  else if (etatApp.ecran === 'mybilans') app.innerHTML = renderMyBilans();
5468  else if (etatApp.ecran === 'badges') app.innerHTML = renderMesBadges();
5469  else if (etatApp.ecran === 'calendar') app.innerHTML = renderCalendar();
5470  else if (etatApp.ecran === 'invitations') app.innerHTML = renderInvitations();
5471  else if (etatApp.ecran === 'settings') app.innerHTML = renderSettings();
5472
5473  // Le DOM de l'écran précédent vient d'être remplacé : libérer toutes les
5474  // instances dont le canvas n'existe plus avant de créer les nouveaux charts.
5475  destroyChartsHorsDom();
5476
5477  const bandeauPartiel = renderBandeauDonneesPartielles();
5478  if (bandeauPartiel) app.insertAdjacentHTML('afterbegin', bandeauPartiel);
5479
5480  associerLabelsFormulaires(app);
5481  actualiserAuditActionsUI();
5482
5483  // Post-render : graphiques inline
5484  setTimeout(() => {
5485    if (etatApp.ecran === 'dashboard' && document.getElementById('dashChartVolume')) {
5486      renderVolumeChart('dashChartVolume');
5487    }
5488    if ((etatApp.ecran === 'my' || etatApp.ecran === 'myprofile') && profile.joueur_id) {
5489      const j = joueurById(profile.joueur_id);
5490      if (j) {
5491        const bornesMP = getPeriodeDates(periodes.myProfile);
5492        if (document.getElementById('myChartClassement')) renderClassementChart('myChartClassement', j);
5493        if (document.getElementById('myChartRadar')) renderRadarBilans('myChartRadar', j);
5494        if (document.getElementById('myChartForme')) renderFormeChart('myChartForme', j);
5495        if (document.getElementById('myChartActivite')) renderActiviteChart('myChartActivite', j, bornesMP);
5496        if (document.getElementById('myChartCumul')) renderCumulChart('myChartCumul', j, etatApp.ecran === 'myprofile' ? null : bornesMP);
5497      }
5498    }
5499    // Hydratation météo : remplit les placeholders <div id="meteoCompact_*">
5500    hydrateMeteoPlaceholders();
5501    if (restaurerPosition) {
5502      window.scrollTo({ top: positionAvantRendu, behavior: 'auto' });
5503      if (focusAvantRendu) document.getElementById(focusAvantRendu)?.focus({ preventScroll: true });
5504    }
5505  }, 30);
5506
5507  const fab = $('fab');
5508  fab.style.display = 'none';
5509  dernierEcranRendu = etatApp.ecran;
5510}
5511
5512function renderBandeauDonneesPartielles() {
5513  const indisponibles = listeSure(cache.donneesIndisponibles);
5514  if (indisponibles.length === 0) return '';
5515  const labels = indisponibles.map(label => escapeHtml(String(label).replace(/_/g, ' '))).join(', ');
5516  return `<div class="partial-data-banner" role="status" aria-live="polite">
5517    <div><strong>Données temporairement partielles</strong>Impossible d’actualiser : ${labels}. Les autres écrans restent utilisables ; réessaie lorsque la connexion est meilleure.</div>
5518  </div>`;
5519}
5520
5521function renderUxScreenHeading(kicker, titre) {
5522  return `<div class="ux-screen-heading"><div class="ux-home-kicker">${escapeHtml(kicker)}</div><h2>${escapeHtml(titre)}</h2></div>`;
5523}
5524
5525function estCreneauDuCoachConnecte(creneau) {
5526  if (!creneau || !creneau.coach || !profile) return false;
5527  const normaliser = TennisDomainRules.normaliserTexteRecherche;
5528  const coach = normaliser(creneau.coach);
5529  return coach === normaliser(profile.prenom || '') || coach === normaliser(`${profile.prenom || ''} ${profile.nom || ''}`.trim());
5530}
5531
5532function renderCoachCockpit(joueursActifs, matchsPeriode, alertes) {
5533  const comptes = comptesMatchs(matchsPeriode);
5534  const perfsCockpit = computeBellePerfs(periodes.dashboard);
5535  const joueursCockpitIds = new Set(joueursActifs.map(joueur => joueur.id));
5536  const demandesClassementCockpit = isPoleEnv() ? (listeSure(cache.classementDemandes))
5537    .filter(d => d.statut === 'en_attente' && joueursCockpitIds.has(d.joueur_id))
5538    .sort((a, b) => (a.date_publication_prevue || '').localeCompare(b.date_publication_prevue || ''))
5539    .slice(0, 4) : [];
5540  const prochainesSeances = isAcademieEnv() ? (listeSure(cache.seancesCours))
5541    .filter(s => s.date >= dateLocaleISO(new Date()) && estCreneauDuCoachConnecte(creneauCoursById(s.creneau_id)))
5542    .sort((a, b) => (a.date || '').localeCompare(b.date || '')) : [];
5543  const rendezVous = prochainesSeances.slice(0, 3)
5544    .map(seance => ({ seance, creneau: creneauCoursById(seance.creneau_id) }))
5545    .filter(item => item.creneau);
5546  const ordreSeverite = { danger: 0, warning: 1, info: 2 };
5547  const priorites = (listeSure(alertes)).slice().sort((a, b) => (ordreSeverite[a.type] ?? 9) - (ordreSeverite[b.type] ?? 9)).slice(0, 4);
5548  const monteesCockpit = monteesClassementPeriode(periodes.dashboard);
5549  const joueursCockpitEnAttente = !isPoleEnv() ? [] : (listeSure(cache.joueurs))
5550    .filter(joueur => joueur.statut === 'en_attente_validation' && joueurDansEnvCourant(joueur));
5551  const bilansCockpit = isPoleEnv()
5552    ? (listeSure(cache.bilans)).filter(bilan => joueursCockpitIds.has(bilan.joueur_id) && bilan.saison === getSaisonCourante())
5553    : (listeSure(cache.bilansEvenement)).filter(bilan => joueursCockpitIds.has(bilan.joueur_id) && bilan.evenement_id === getActiveEvenement()?.id);
5554  const bilansAPreparer = bilansCockpit.filter(bilan => bilan.statut === 'brouillon').length;
5555  const ecranPlanning = isPoleEnv() ? 'planningcours' : 'planning';
5556  const groupesCockpit = isAcademieEnv() ? ['tremplin', 'accession', 'elite'].map(key => {
5557    const joueurs = joueursActifs.filter(j => j.groupe === key);
5558    const ids = new Set(joueurs.map(j => j.id));
5559    const matchs = matchsPeriode.filter(m => ids.has(m.joueur_id));
5560    const stats = comptesMatchs(matchs);
5561    const alertesGroupe = (listeSure(alertes)).filter(a => a.groupe === key).length;
5562    return { key, nom: (cache.config[key] || {}).nom || key, joueurs: joueurs.length, matchs: stats.total, taux: stats.total ? stats.tauxVictoire : null, alertes: alertesGroupe };
5563  }) : [];
5564  const nomEnvironnement = isPreAcademieEnv() ? 'Pré-Académie' : (isAcademieEnv() ? 'Académie' : 'Palmyre');
5565  const decisionCards = [
5566    ...joueursCockpitEnAttente.slice(0, 2).map(joueur => ({
5567      titre: `${joueur.prenom || ''} ${joueur.nom || ''}`.trim() || 'Nouveau joueur', detail: 'Compte complet, en attente de validation et d’affectation', badge: 'À valider', kind: 'validation', type: 'warning'
5568    })),
5569    ...priorites.slice(0, Math.max(0, 4 - joueursCockpitEnAttente.length)).map(alerte => {
5570      const joueur = joueurById(alerte.joueurId) || {};
5571      return { titre: `${joueur.prenom || 'Joueur'} ${joueur.nom || ''}`.trim(), detail: alerte.text || 'Suivi joueur à consulter', badge: ALERT_TYPE_LABELS[alerte.alertType] || 'À étudier', kind: 'joueur', joueurId: alerte.joueurId, type: alerte.type || 'info' };
5572    })
5573  ];
5574  if (bilansAPreparer && decisionCards.length < 4) decisionCards.push({ titre: `${bilansAPreparer} bilan${bilansAPreparer > 1 ? 's' : ''} à finaliser`, detail: 'Les synthèses enregistrées en brouillon sont prêtes à être relues', badge: 'À traiter', kind: 'bilans', type: 'warning' });
5575  return `<section class="ux-coach-cockpit" aria-labelledby="coach-home-title">
5576    <div class="ux-home-heading"><div><div class="ux-home-kicker">Pilotage ${escapeHtml(nomEnvironnement)}</div><h2 class="ux-home-title" id="coach-home-title">Mon cockpit</h2><div class="ux-coach-subtitle">Les décisions utiles, sans chercher dans toute l'application.</div></div><button class="ux-section-cta neutral" data-ui-click="navigate('overview')"><img src="assets/v4/nav/dashboard.webp" alt=""><span>Vue détaillée</span><b>→</b></button></div>
5577    ${renderPeriodeSelector(periodes.dashboard, 'setPeriodeDashboard', { afficherSaison: false })}
5578    <div class="ux-summary-grid coach">
5579      <button class="ux-summary-card coach matches" data-ui-click="navigate('matches')"><img src="assets/v4/nav/matches.webp" alt=""><div><div class="ux-summary-label">Matches joués</div><div class="ux-summary-value">${comptes.total}</div><div class="ux-summary-detail">${escapeHtml(getPeriodeLabel(periodes.dashboard))}</div></div></button>
5580      <button class="ux-summary-card coach wins" data-ui-click="navigate('matches')"><img src="assets/v4/nav/performances.webp" alt=""><div><div class="ux-summary-label">Taux de victoires</div><div class="ux-summary-value">${comptes.total ? comptes.tauxVictoire + '%' : '—'}</div><div class="ux-summary-detail">WO exclus des statistiques</div></div></button>
5581      <button class="ux-summary-card coach players" data-ui-click="navigate('players')"><img src="assets/v4/nav/players.webp" alt=""><div><div class="ux-summary-label">Joueurs actifs</div><div class="ux-summary-value">${joueursActifs.length}</div><div class="ux-summary-detail">${isAcademieEnv() ? 'Tous les groupes' : nomEnvironnement} →</div></div></button>
5582      <button class="ux-summary-card coach alerts ${(listeSure(alertes)).length ? 'has-alerts' : ''}" data-ui-click="navigate('alertes')"><img src="assets/v4/nav/alerts.webp" alt=""><div><div class="ux-summary-label">Alertes</div><div class="ux-summary-value">${(listeSure(alertes)).length}</div><div class="ux-summary-detail">${(listeSure(alertes)).length ? 'À traiter maintenant →' : 'Aucune urgence'}</div></div></button>
5583    </div>
5584    <div class="ux-cockpit-workspace">
5585      <section class="ux-cockpit-panel ux-steering-panel" aria-labelledby="cockpit-steering-title">
5586        <div class="ux-cockpit-panel-head"><div><strong id="cockpit-steering-title">Pilotage rapide</strong><span>Priorités joueurs préparées pour toi</span></div>${(listeSure(alertes)).length ? `<button class="ux-section-cta danger" data-ui-click="navigate('alertes')">Voir les ${(listeSure(alertes)).length} alertes</button>` : `<b>${decisionCards.length} décision${decisionCards.length > 1 ? 's' : ''}</b>`}</div>
5587        <div class="ux-cockpit-tabs" aria-label="Catégories de pilotage"><span class="active">À traiter</span><button data-ui-click="navigate('alertes')">À surveiller</button><button data-ui-click="openCoachPerformanceSection('belles')">Progressions</button></div>
5588        ${decisionCards.length ? `<div class="ux-decision-list">${decisionCards.map(decision => {
5589          const contenu = `<i aria-hidden="true"></i><span><strong>${escapeHtml(decision.titre)}</strong><small>${escapeHtml(decision.detail)}</small></span><em>${escapeHtml(decision.badge)} →</em>`;
5590          if (decision.kind === 'validation') return `<button class="ux-decision-row ${escapeHtml(decision.type)}" data-ui-click="navigate('validations')">${contenu}</button>`;
5591          if (decision.kind === 'bilans') return `<button class="ux-decision-row ${escapeHtml(decision.type)}" data-ui-click="navigate('bilans')">${contenu}</button>`;
5592          return `<button class="ux-decision-row ${escapeHtml(decision.type)}" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(decision.joueurId))})">${contenu}</button>`;
5593        }).join('')}</div>` : `<div class="ux-cockpit-empty"><strong>Tout est à jour</strong><span>Aucune décision urgente dans ${escapeHtml(nomEnvironnement)}.</span></div>`}
5594      </section>
5595      <div class="ux-cockpit-side">
5596        <section class="ux-cockpit-panel" aria-labelledby="cockpit-actions-title">
5597          <div class="ux-cockpit-panel-head"><div><strong id="cockpit-actions-title">Actions essentielles</strong><span>Les raccourcis utiles dans cet environnement</span></div></div>
5598          <div class="ux-actions coach ${isPoleEnv() ? 'has-notes' : ''}">
5599            <button class="ux-action primary" data-ui-click="navigate('players')"><img src="assets/v4/nav/players.webp" alt=""><span>Suivre mes joueurs<small>Fiches, progression et objectifs</small></span></button>
5600            ${isPoleEnv() ? `<button class="ux-action" data-ui-click="navigate('calendar')">
5600<img src="assets/v4/nav/tournaments.webp" alt=""><span>Tournois<small>Liste, synchronisation et création</small></span></button>` : ''}
5601            ${isAcademieEnv()
5602              ? `<button class="ux-action" data-ui-click="navigate('presence')"><img src="assets/v4/nav/presence.webp" alt=""><span>Gérer les présences<small>Présents, absents et retards</small></span></button>`
5603              : (!isPoleEnv()
5604                  ? `<button class="ux-action" data-ui-click="navigate('debrief')"><img src="assets/v4/nav/debrief.webp" alt=""><span>Préparer le débrief<small>Synthèse de la journée intensive</small></span></button>`
5605                  : '')}
5606            ${isPoleEnv() ? `<button class="ux-action" data-ui-click="navigate('notes')"><img src="assets/v4/menu/coach_notes.webp" alt=""><span>Notes joueurs<small>Saisir et consulter les notes privées</small></span></button>` : ''}
5607          </div>
5608        </section>
5609        <section class="ux-cockpit-panel ux-progress-overview" aria-labelledby="cockpit-progress-title">
5610          <div class="ux-cockpit-panel-head"><div><strong id="cockpit-progress-title">Signaux de progression</strong><span>Sans action obligatoire · ${perfsCockpit.length + monteesCockpit.length} ${perfsCockpit.length + monteesCockpit.length > 1 ? 'signaux' : 'signal'}</span></div><button class="ux-section-cta compact success" data-ui-click="navigate('perfs')"><span>Toutes les performances</span><b>→</b></button></div>
5611          <button data-ui-click="openCoachPerformanceSection('belles')"><span>Belles perfs<small>${perfsCockpit.length ? `${perfsCockpit.length} signalée${perfsCockpit.length > 1 ? 's' : ''} sur la période` : 'Aucune sur cette période'}</small></span><em>${perfsCockpit.length} →</em></button>
5612          ${isPoleEnv() ? `<button data-ui-click="openCoachPerformanceSection('montees')"><span>Très belles perfs<small>Montées de classement officielles depuis la dernière publication FFT</small></span><em>${monteesCockpit.length} →</em></button>` : ''}
5613          ${demandesClassementCockpit.length ? `<button data-ui-click="navigate('perfs')"><span>Classements à vérifier<small>À vérifier sur Ten’Up après publication</small></span><em>${demandesClassementCockpit.length} →</em></button>` : ''}
5614        </section>
5615      </div>
5616    </div>
5617    ${groupesCockpit.length ? `<div class="ux-coach-section-head"><div><div class="ux-home-kicker">Lecture collective</div><div class="sub-title">Santé des groupes</div></div><button class="ux-section-cta compact" data-ui-click="navigate('groupes')"><img src="assets/v4/nav/group.webp" alt=""><span>Ouvrir les groupes</span><b>→</b></button></div><div class="ux-group-pulse-grid">${groupesCockpit.map(g => `<button class="ux-group-pulse ${escapeHtml(g.key)}" data-ui-click="navigate('groupes', true, ${escapeHtml(encoderArgumentTexteUI(g.key))})"><div class="ux-group-pulse-top"><strong>${escapeHtml(g.nom)}</strong>${g.alertes ? `<span class="ux-group-alert-count">${g.alertes} alerte${g.alertes > 1 ? 's' : ''}</span>` : '<span class="ux-group-ok">À jour</span>'}</div><div class="ux-group-pulse-stats"><span><b>${g.joueurs}</b> joueurs</span><span><b>${g.matchs}</b> matchs</span><span><b>${g.taux === null ? '—' : g.taux + '%'}</b> victoires</span></div></button>`).join('')}</div>` : ''}
5618    ${rendezVous.length ? `<div class="ux-coach-section-head"><div><div class="ux-home-kicker">Organisation</div><div class="sub-title">Prochaines séances par groupe · mes créneaux</div></div><button class="ux-section-cta compact" data-ui-click="navigate(${escapeHtml(encoderArgumentTexteUI(ecranPlanning))})"><img src="assets/v4/nav/planning.webp" alt=""><span>Tout le planning</span><b>→</b></button></div><div class="ux-upcoming-grid">${rendezVous.map(({ seance, creneau }) => { const d = new Date(`${seance.date}T12:00:00`); return `<button class="ux-course-card ux-course-button" data-ui-click="navigate(${escapeHtml(encoderArgumentTexteUI(ecranPlanning))})"><div class="ux-course-date"><strong>${d.getDate()}</strong>${d.toLocaleDateString('fr-FR', { month: 'short' }).replace('.', '')}</div><div><div class="ux-course-title">${escapeHtml(creneau.nom || 'Groupe')}</div><div class="ux-course-meta">${escapeHtml(creneau.heure_debut || '')}–${escapeHtml(creneau.heure_fin || '')} · 👤 ${escapeHtml(creneau.coach || 'À attribuer')}</div></div><span>→</span></button>`; }).join('')}</div>` : ''}
5619  </section>`;
5620}
5621
5622function renderDashboard() {
5623  if (!isCoach()) return renderPlayerHome();
5624  // Séparer joueurs actifs et en attente
5625  const joueursActifs = joueursActifsDansEnvCourant();
5626  const joueursEnAttente = cache.joueurs.filter(j => j.statut === 'en_attente_validation');
5627  const donneesEnv = TennisDomainRules.donneesDashboardEnvironnement(joueursActifs, cache.matches, isPoleEnv());
5628  const joueursCompet = donneesEnv.joueursCompet;
5629  // En mode Palmyre, le compteur "joueurs" = participants à l'édition.
5630  const totalJoueurs = donneesEnv.totalJoueurs;
5631  const now = Date.now();
5632
5633  // Matchs de l'académie sur le périmètre courant.
5634  const matchesCompet = donneesEnv.matchesCompet;
5635
5636  // Période d'analyse.
5637  // - En mode académie : filtrage par dates de la période sélectionnée (trimestre/saison).
5638  // - En mode Palmyre : on compte TOUS les matchs du périmètre (déjà restreints à
5639  //   l'édition via appliquerPerimetreMatchs), sans filtre de date — un match peut
5640  //   être rattaché à l'édition même si sa date est hors de la fenêtre théorique.
5641  let matchsPeriode;
5642  if (isPoleEnv()) {
5643    const [pDebut, pFin] = getPeriodeDates(periodes.dashboard);
5644    matchsPeriode = matchesCompet.filter(m => TennisDomainRules.dateDansIntervalle(m.date, pDebut, pFin));
5645  } else {
5646    matchsPeriode = matchesCompet;
5647  }
5648  // WO exclus des compteurs (un forfait n'est pas un match joué).
5649  const comptePeriode = comptesMatchs(matchsPeriode);
5650  const matchesPeriodeCount = comptePeriode.total;
5651  const tauxVictoirePeriode = comptePeriode.tauxVictoire;
5652
5653  const totalMatches = comptesMatchs(matchesCompet).total;
5654  const alertes = cache.alertes;
5655
5656  let html = '';
5657  html += renderCoachCockpit(joueursActifs, matchsPeriode, alertes);
5658  html += `<details class="ux-dashboard-details" style="margin-top:24px"><summary class="btn btn-full">Afficher le tableau de bord détaillé</summary><div style="margin-top:16px">`;
5659
5660  // Bandeau d'environnement Palmyre (contexte visuel quand on n'est pas sur l'académie)
5661  if (!isPoleEnv()) {
5662    const ev = getActiveEvenement();
5663    const nbParticipants = joueursActifs.length;
5664    html += `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)">
5665      <div style="font-weight:700; font-size:15px">🏖️ ${ev ? escapeHtml(ev.nom) : 'Palmyre'}</div>
5666      <div class="text-faint" style="font-size:12px; margin-top:2px">Vue sur toute la semaine · ${nbParticipants} participant${nbParticipants > 1 ? 's' : ''} · seuls les matchs de l'événement sont comptés</div>
5667    </div>`;
5668  }
5669
5670  if (isPoleEnv() && joueursEnAttente.length > 0) {
5671    html += `<div class="alert warning" style="margin-bottom:16px; cursor:pointer; align-items:center" data-ui-click="navigate('validations')">
5672      <span class="icon">🆕</span>
5673      <div class="alert-content">
5674        <strong>${joueursEnAttente.length} nouvelle${joueursEnAttente.length > 1 ? 's' : ''} inscription${joueursEnAttente.length > 1 ? 's' : ''} à valider</strong>
5675        <div style="font-size:11px; margin-top:2px">Touche pour examiner et affecter à un groupe →</div>
5676      </div>
5677    </div>`;
5678  }
5679
5680  // Demandes provisoires : le classement officiel ne change qu'après la
5681  // publication FFT et une vérification explicite du coach sur Ten'Up.
5682  const joueursEnvIds = new Set(joueursActifs.map(joueur => joueur.id));
5683  const demandesClass = isPoleEnv() ? (listeSure(cache.classementDemandes))
5684    .filter(d => d.statut === 'en_attente' && joueursEnvIds.has(d.joueur_id)) : [];
5685  if (demandesClass.length > 0) {
5686    html += `<div class="card" style="margin-bottom:16px; border-left:3px solid var(--orange)">
5687      <div class="section-title" style="margin-top:0">📊 Classements à vérifier (${demandesClass.length})</div>`;
5688    demandesClass.forEach(d => {
5689      const jr = joueurById(d.joueur_id);
5690      const nom = jr ? `${jr.prenom} ${jr.nom}` : 'Joueur';
5691      const montee = getRankIndex(d.nouveau_classement) > getRankIndex(d.ancien_classement);
5692      const disponible = TennisDomainRules.classementPeutEtreConfirme(d.date_publication_prevue, new Date());
5693      html += `<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; padding:8px 0; border-bottom:1px solid var(--border)">
5694        <div style="font-size:14px">
5695          <strong>${escapeHtml(nom)}</strong> demande <strong>${escapeHtml(d.ancien_classement || 'NC')}</strong> → <strong class="${montee ? 'success' : ''}">${escapeHtml(d.nouveau_classement)}</strong> ${montee ? '📈' : ''}
5696          <div class="text-faint" style="font-size:11px;margin-top:3px">${disponible ? 'Publié : vérifie le classement sur Ten’Up avant confirmation.' : `Publication FFT prévue le ${formatDate(d.date_publication_prevue)}.`}</div>
5697        </div>
5698        <div style="display:flex; gap:6px">
5699          ${disponible ? `<button class="btn btn-sm" data-ui-click="traiterDemandeClassement(${escapeHtml(encoderArgumentTexteUI(d.id))}, true)">✓ Confirmer officiel</button>` : `<button class="btn btn-sm" disabled title="Disponible le ${formatDate(d.date_publication_prevue)}">
5699⏳ En attente FFT</button>`}
5700          <button class="btn btn-sm btn-danger" data-ui-click="traiterDemandeClassement(${escapeHtml(encoderArgumentTexteUI(d.id))}, false)">✕ Refuser</button>
5701        </div>
5702      </div>`;
5703    });
5704    html += `</div>`;
5705  }
5706
5707  // Sélecteur de période
5708  html += renderPeriodeSelector(periodes.dashboard, 'setPeriodeDashboard', { afficherSaison: false });
5709
5710  const nbPerfsDash = computeBellePerfs(periodes.dashboard).length;
5711  html += `
5712    <div class="stat-grid">
5713      <div class="stat-card clickable" data-ui-click="navigate('players')" title="Voir la liste des joueurs"><div class="stat-label">${isPoleEnv() ? 'Joueurs actifs' : 'Participants'}</div><div class="stat-value orange">${totalJoueurs}</div><div class="stat-sub">${isAcademieEnv() ? 'académie compét →' : (isPreAcademieEnv() ? 'pré-académie →' : 'à la Palmyre →')}</div></div>
5714      <div class="stat-card clickable" data-ui-click="navigate('matches')" title="Voir tous les matches"><div class="stat-label">${isPoleEnv() ? `Matches ${periodes.dashboard === 'saison' ? 'saison' : periodes.dashboard}` : 'Matches semaine'}</div><div class="stat-value">${matchesPeriodeCount}</div><div class="stat-sub">${isPoleEnv() ? `${totalMatches} au total →` : 'sur le stage →'}</div></div>
5715      <div class="stat-card clickable" data-ui-click="navigate('matches')" title="Voir le détail des matches"><div class="stat-label">Taux victoires</div><div class="stat-value ${isAcademieEnv() && tauxVictoirePeriode >= 55 ? 'success' : ''}">${tauxVictoirePeriode}%</div><div class="stat-sub">${isAcademieEnv() ? 'objectif 55% →' : (isPreAcademieEnv() ? 'sur la période →' : 'sur le stage →')}</div></div>
5716      ${isAcademieEnv()
5717        ? `<div class="stat-card ${alertes.length > 0 ? 'clickable' : ''}" ${alertes.length > 0 ? `data-ui-click="navigate('alertes')"` : ''} title="${alertes.length > 0 ? 'Voir le détail des alertes' : ''}"><div class="stat-label">Alertes</div><div class="stat-value ${alertes.length > 0 ? 'danger' : ''}">${alertes.length}</div><div class="stat-sub">${alertes.length > 0 ? 'voir détail →' : 'à traiter'}</div></div>`
5718        : `<div class="stat-card clickable" data-ui-click="navigate('perfs')" title="Voir les perfs"><div class="stat-label">Perfs</div><div class="stat-value success">${nbPerfsDash}</div><div class="stat-sub">belles perfs →</div></div>`}
5719    </div>
5720  `;
5721
5722  // Section "Groupes" : uniquement en Académie (pas de groupes en Pré-Académie ni en Palmyre)
5723  if (isAcademieEnv()) {
5724  html += `<div class="section-title">Groupes</div><div class="group-grid">`;
5725  ['tremplin','accession','elite'].forEach(key => {
5726    const g = cache.config[key];
5727    if (!g) return;
5728    const count = joueursActifs.filter(j => j.groupe === key).length;
5729    const pct = g.places > 0 ? Math.min(100, Math.round((count / g.places) * 100)) : 0;
5730    const cls = pct >= 90 ? '' : pct >= 50 ? 'warning' : 'danger';
5731    html += `<div class="group-card ${escapeHtml(key)} clickable" data-ui-click="setPlayerFilter(${escapeHtml(encoderArgumentTexteUI(key))}); navigate('players', true)" title="Voir les joueurs de ce groupe"><span class="badge badge-${escapeHtml(key)}">${escapeHtml(g.nom)}</span>
5732      <div class="group-count">${count}<span class="total"> / ${g.places} places</span></div>
5733      <div class="progress-bar"><div class="progress-fill ${cls}" style="width:${pct}%"></div></div>
5734      <div class="text-faint" style="font-size:11px; margin-top:6px">Objectif ${g.objectifMatches} matches/an →</div></div>`;
5735  });
5736  html += `</div>`;
5737  }
5738
5739  // ====================== BELLES PERFS (au-dessus des alertes) ======================
5740  const bellePerfs = computeBellePerfs(periodes.dashboard);
5741  if (bellePerfs.length > 0) {
5742    html += `<div class="section-title" style="display:flex; justify-content:space-between; align-items:center">
5743      <span style="display:inline-flex;align-items:center;gap:8px">${progressArtHtml('perfs',{size:34})} Belles perfs <span class="text-faint" style="font-weight:400; text-transform:none; letter-spacing:0; font-size:12px">— ${getPeriodeLabel(periodes.dashboard)}</span></span>
5744      ${bellePerfs.length > 5 ? `<button class="btn btn-sm" data-ui-click="navigate('perfs')" style="text-transform:none; letter-spacing:0; font-size:11px">Voir toutes (${bellePerfs.length}) →</button>` : ''}
5745    </div>`;
5746    bellePerfs.slice(0, 5).forEach(p => {
5747      const groupBadge = p.groupe
5748        ? `<span class="badge badge-${p.groupe}" style="font-size:10px; margin-left:6px">${escapeHtml((cache.config[p.groupe] || {}).nom || p.groupe)}</span>`
5749        : '';
5750      const ecartLabel = p.ecart >= 3 ? ' <span class="badge" style="background:#dfff35;color:#071d1f;font-size:10px">exceptionnelle</span>' : '';
5751      html += `<div class="alert success" data-ui-click="openModalMatch(${escapeHtml(encoderArgumentTexteUI(p.match.id))})" style="margin-bottom:6px; cursor:pointer">
5752        ${progressArtHtml('perfs',{size:42})}
5753        <div class="alert-content">
5754          <span><strong>${escapeHtml(p.joueur.prenom)} ${escapeHtml(p.joueur.nom)}</strong> (${escapeHtml(p.classementEpoque || p.joueur.classement)}) bat <strong>${escapeHtml(p.adversaire)}</strong> (${escapeHtml(p.adversaire_class)})${p.estDouble ? ` <span class="format-double-badge">Double</span>` : ''}${ecartLabel}</span>${groupBadge}
5755          <div class="text-faint" style="font-size:11px; margin-top:2px">${formatDate(p.date)}${p.estDouble ? ' · double' : ''}${p.score ? ' · ' + escapeHtml(p.score) : ''}</div>
5756        </div>
5757      </div>`;
5758    });
5759  }
5760
5761  if (isPoleEnv() && alertes.length > 0) {
5762    html += `<div class="section-title" style="display:flex; justify-content:space-between; align-items:center">
5763      <span>Alertes</span>
5764      ${alertes.length > 5 ? `<button class="btn btn-sm" data-ui-click="navigate('alertes')" style="text-transform:none; letter-spacing:0; font-size:11px">Voir toutes (${alertes.length}) →</button>` : ''}
5765    </div>`;
5766    alertes.slice(0, 5).forEach(a => {
5767      const groupBadge = a.groupe
5768        ? `<span class="badge badge-${a.groupe}" style="font-size:10px; margin-left:6px">${escapeHtml((cache.config[a.groupe] || {}).nom || a.groupe)}</span>`
5769        : '';
5770      html += `<div class="alert ${a.type}" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(a.joueurId))})" style="margin-bottom:6px">
5771        ${a.alertType === 'inactivite' ? `<span class="inactivity-art">${progressArtHtml('inactivite',{size:42})}</span>` : progressArtHtml(a.art || 'objectif_groupe',{size:42})}
5772        <div class="alert-content"><span>${escapeHtml(a.text)}</span>${groupBadge}</div></div>`;
5773    });
5774  }
5775
5776  const derniers = matchesCompet.slice(0, 5);
5777  if (derniers.length > 0) {
5778    html += `<div class="section-title">Activité matches <span class="text-faint" style="font-weight:400; text-transform:none; letter-spacing:0; font-size:12px">— saison ${getSaisonAnalyse()}</span></div>`;
5779    html += `<div class="card" style="height:240px; padding:14px"><canvas id="dashChartVolume"></canvas></div>`;
5780  }
5781
5782  if (derniers.length > 0) {
5783    html += `<div class="section-title">Derniers matches</div><div class="card">`;
5784    derniers.forEach(m => {
5785      const j = joueurById(m.joueur_id);
5786      if (!j) return;
5787      html += `<div class="match-row clickable" data-ui-click="openModalMatch(${escapeHtml(encoderArgumentTexteUI(m.id))})" title="Cliquer pour voir/modifier ce match">
5788        <div class="match-result-row">
5789          <span class="match-opponent"><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong> vs ${escapeHtml(m.adversaire)} (${escapeHtml(m.adversaire_class || '?')})</span>
5790          ${resultatMatchHtml(m)}
5791        </div>
5792        <div class="match-meta">${escapeHtml(m.tournoi || 'Match')} · ${formatDate(m.date)}</div>
5793      </div>`;
5794    });
5795    html += `</div>`;
5796  }
5797
5798  if (totalJoueurs === 0) {
5799    html += `<div class="empty"><span class="icon">🎾</span>Aucun joueur dans l'académie pour le moment.<br>
5800      <button class="btn btn-primary mt-2" data-ui-click="navigate('players'); setTimeout(()=>openModalPlayer(), 100)">+ Ajouter un joueur</button></div>`;
5801  }
5802
5803  return html + `</div></details>`;
5804}
5805
5806// ============================================================
5807// SCREEN: VALIDATIONS (coach) - Joueurs en attente d'affectation
5808// ============================================================
5809function renderValidations() {
5810  if (!isCoach()) return '';
5811  const enAttente = cache.joueurs.filter(j => j.statut === 'en_attente_validation');
5812
5813  if (enAttente.length === 0) {
5814    return `<div class="empty">
5815      <span class="icon">✓</span>
5816      Aucune inscription en attente.<br>
5817      <span class="text-faint">Les nouvelles inscriptions joueur apparaîtront ici.</span>
5818    </div>`;
5819  }
5820
5821  let html = `<div class="section-title">Inscriptions à valider (${enAttente.length})</div>`;
5822  html += `<div class="text-dim mb-2" style="font-size:13px">Examine chaque inscription, affecte le joueur à un groupe et définis son objectif. Tu peux aussi refuser une inscription.</div>`;
5823
5824  enAttente.forEach(j => {
5825    const age = computeAge(j);
5826    const isMineur = age !== null && age < 18;
5827    html += `<div class="card" data-ui-click="openValidationModal(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="cursor:pointer">
5828      <div class="row-between mb-1">
5829        <div>
5830          <strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong>
5831          ${age !== null ? `<span class="text-dim"> · ${age} ans${isMineur ? ' (mineur)' : ''}</span>` : ''}
5832        </div>
5833        <span class="badge badge-warning">🆕 nouveau</span>
5834      </div>
5835      <div class="text-dim" style="font-size:13px">
5836        Classement déclaré : <strong>${escapeHtml(j.classement || 'NC')}</strong> · ${j.sexe === 'F' ? 'Féminin' : 'Masculin'}
5837      </div>
5838      ${isMineur && (!j.parent_telephone && !j.parent_email) ? `<div class="alert danger" style="margin-top:8px; padding:6px 10px; font-size:12px"><span class="icon">⚠</span><div>Mineur sans contact parental complet</div></div>` : ''}
5839      <div class="text-faint" style="font-size:11px; margin-top:6px">Touche pour examiner →</div>
5840    </div>`;
5841  });
5842
5843  return html;
5844}
5845
5846function openValidationModal(joueurId) {
5847  const j = joueurById(joueurId);
5848  if (!j) return;
5849  const age = computeAge(j);
5850  const isMineur = age !== null && age < 18;
5851
5852  let html = `
5853    <div class="modal-header">
5854      <h3 class="modal-title">Valider l'inscription</h3>
5855      <button class="modal-close" data-ui-click="closeModal()">×</button>
5856    </div>
5857
5858    <div class="flex gap-1 mb-2" style="align-items:center">
5859      ${avatarHtml(j, 'lg')}
5860      <div class="flex-1">
5861        <div style="font-size:17px; font-weight:700">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</div>
5862        <div class="text-dim" style="font-size:13px">${age !== null ? age + ' ans' : 'âge ?'} · ${j.sexe === 'F' ? 'Féminin' : 'Masculin'}${isMineur ? ' · <span style="color:var(--warning-text)">mineur</span>' : ''}</div>
5863      </div>
5864    </div>
5865
5866    <div class="section-title" style="margin-top:0">Informations saisies par le joueur</div>
5867    <div class="card" style="font-size:13px; line-height:1.6">
5868      <div><strong>Date de naissance :</strong> ${j.date_naissance ? formatDate(j.date_naissance) : 'non renseignée'}</div>
5869      <div><strong>Téléphone :</strong>
5869 ${escapeHtml(j.telephone || 'non renseigné')}</div>
5870      <div><strong>Classement déclaré :</strong> ${escapeHtml(j.classement || 'NC')}</div>
5871      <div><strong>Points forts :</strong> ${(j.points_forts && j.points_forts.length) ? j.points_forts.map(escapeHtml).join(', ') : '<span class="text-faint">non renseignés</span>'}</div>
5872      <div><strong>Axes de progrès :</strong> ${(j.axes_progres && j.axes_progres.length) ? j.axes_progres.map(escapeHtml).join(', ') : '<span class="text-faint">non renseignés</span>'}</div>
5873    </div>
5874
5875    ${isMineur ? `
5876    <div class="section-title">Contact parental</div>
5877    <div class="card" style="font-size:13px; line-height:1.6">
5878      <div><strong>Nom :</strong> ${escapeHtml(j.parent_nom || 'non renseigné')}</div>
5879      <div><strong>Téléphone :</strong> ${escapeHtml(j.parent_telephone || 'non renseigné')}</div>
5880      <div><strong>Email :</strong> ${escapeHtml(j.parent_email || 'non renseigné')}</div>
5881    </div>` : ''}
5882
5883    <div class="section-title">Consentements</div>
5884    <div class="card" style="font-size:13px; line-height:1.6">
5885      <div>${j.consentement_rgpd ? '✓' : '✗'} <strong>RGPD :</strong> ${j.consentement_rgpd ? 'accepté' : 'NON accepté'}</div>
5886      ${isMineur ? `<div>${j.consentement_parental ? '✓' : '○'} <strong>Consentement parental :</strong> ${j.consentement_parental ? 'déclaré' : 'non coché'}</div>` : ''}
5887      ${j.consentement_date ? `<div class="text-faint" style="font-size:11px; margin-top:4px">Date : ${formatDate(j.consentement_date)}</div>` : ''}
5888    </div>
5889
5890    <div class="section-title">Décision</div>
5891    <div class="form-row">
5892      <label class="form-label">Affecter au groupe</label>
5893      <div class="pill-group" id="validGroupe">
5894        ${Object.keys(cache.config).filter(k => typeof cache.config[k] === 'object' && cache.config[k].nom).map(k => `
5895          <span class="pill" data-value="${k}" data-ui-click="selectPill(this, 'validGroupe')">${escapeHtml(cache.config[k].nom)}</span>
5896        `).join('')}
5897      </div>
5898    </div>
5899    <div class="form-row">
5900      <label class="form-label">Objectif de classement</label>
5901      <select id="validObjectif">
5902        <option value="">-</option>
5903        ${CLASSEMENTS.map(c => `<option value="${c}">${c}</option>`).join('')}
5904      </select>
5905    </div>
5906    <div class="form-row">
5907      <label class="form-label">Note du coach (optionnel)</label>
5908      <textarea id="validNotes" rows="2" placeholder="Première impression, remarques..."></textarea>
5909    </div>
5910
5911    <div class="flex gap-1 mt-2">
5912      <button class="btn btn-danger" data-ui-click="rejectJoueur(${escapeHtml(encoderArgumentTexteUI(j.id))})">Refuser</button>
5913      <button class="btn flex-1" data-ui-click="closeModal()">Plus tard</button>
5914      <button class="btn btn-primary flex-1" data-ui-click="validateJoueur(${escapeHtml(encoderArgumentTexteUI(j.id))})">✓ Valider et affecter</button>
5915    </div>
5916    <div class="mt-1" style="text-align:center">
5917      <button class="btn btn-sm" data-ui-click="validateJoueur(${escapeHtml(encoderArgumentTexteUI(j.id))}, true)" style="color:var(--text-dim)">✓ Valider sans groupe (accès à l'app, affectation plus tard)</button>
5918    </div>
5919  `;
5920
5921  showModal(html);
5922}
5923
5924async function validateJoueur(joueurId, sansGroupe) {
5925  let groupe = null;
5926  if (!sansGroupe) {
5927    const groupePill = document.querySelector('#validGroupe .pill.active');
5928    if (!groupePill) {
5929      toast('Choisis un groupe d\'abord (ou utilise « Valider sans groupe »)', true);
5930      return;
5931    }
5932    groupe = groupePill.dataset.value;
5933  }
5934  const objectif = $('validObjectif') ? $('validObjectif').value : '';
5935  const notes = $('validNotes') ? $('validNotes').value.trim() : '';
5936
5937  const { error } = await updateConfirmee(db.from('joueurs').update({
5938    statut: 'actif',
5939    groupe: groupe,
5940    objectif: objectif || null,
5941    notes_coach: notes || null,
5942    updated_at: new Date().toISOString()
5943  }).eq('id', joueurId));
5944
5945  if (error) { toastErreur(error); return; }
5946  closeModal();
5947  toast(sansGroupe ? 'Joueur validé (sans groupe)' : 'Joueur validé et affecté');
5948
5949  // Envoi email de bienvenue au joueur (+ parent si mineur)
5950  const joueur = joueurById(joueurId);
5951  if (joueur) {
5952    if (sansGroupe) {
5953      emailInscriptionValidee({ ...joueur, statut: 'actif', groupe: null }, null);
5954    } else {
5955      const groupeNom = (cache.config[groupe] || {}).nom || groupe;
5956      emailInscriptionValidee({ ...joueur, statut: 'actif', groupe }, groupeNom);
5957    }
5958  }
5959
5960  await refresh();
5961}
5962
5963async function rejectJoueur(joueurId) {
5964  const j = joueurById(joueurId);
5965  if (!j || j.statut !== 'en_attente_validation') {
5966    toast('Cette inscription n’est plus en attente. Recharge la page.');
5967    return;
5968  }
5969  if (!(await demanderConfirmation(`Refuser l'inscription de ${j.prenom} ${j.nom} ?\n\nLa fiche et le compte de connexion seront supprimés définitivement. La personne pourra ensuite se réinscrire avec la même adresse email.`, { titre: 'Refuser l’inscription', libelle: 'Refuser et supprimer' }))) return;
5970
5971  const { error } = await db.functions.invoke('reject-registration', {
5972    body: { joueur_id: joueurId }
5973  });
5974  if (error) { toastErreur(error); return; }
5975  closeModal();
5976  toast('Inscription refusée et compte supprimé');
5977  await refresh();
5978}
5979
5980// ============================================================
5981// SCREEN: ALERTES (coach) - Toutes les alertes avec filtres
5982// ============================================================
5983const alertFilters = etatApp.filtresAlertes;
5984
5985function renderAlertes() {
5986  if (!isCoach()) return '';
5987  const palmyre = !isPoleEnv();
5988  const alertes = listeSure(cache.alertes);
5989  const entete = renderUxScreenHeading(palmyre ? 'Suivi de l’édition' : 'Pilotage de l’académie', palmyre ? 'Joueurs à surveiller' : 'Alertes utiles');
5990
5991  if (alertes.length === 0) {
5992    return entete + `<div class="empty">
5993      <span class="icon">✓</span>
5994      <strong>Aucune alerte en cours</strong><br>
5995      <span class="text-faint">${palmyre ? 'Aucun signal sportif préoccupant sur cette édition.' : "Toute l'académie est dans le vert. Continue comme ça !"}</span>
5996    </div>`;
5997  }
5998
5999  // Compteurs par type pour les filtres
6000  const countByType = {};
6001  const countByGroupe = { tremplin: 0, accession: 0, elite: 0, sans_groupe: 0 };
6002  alertes.forEach(a => {
6003    countByType[a.alertType] = (countByType[a.alertType] || 0) + 1;
6004    if (a.groupe) countByGroupe[a.groupe] = (countByGroupe[a.groupe] || 0) + 1;
6005    else countByGroupe.sans_groupe++;
6006  });
6007
6008  // Filtrage
6009  let visibles = alertes;
6010  if (!palmyre && alertFilters.groupe !== 'all') {
6011    if (alertFilters.groupe === 'sans_groupe') visibles = visibles.filter(a => !a.groupe);
6012    else visibles = visibles.filter(a => a.groupe === alertFilters.groupe);
6013  }
6014  if (alertFilters.type !== 'all') visibles = visibles.filter(a => a.alertType === alertFilters.type);
6015
6016  // Tri : danger d'abord, puis warning, puis info, puis par date desc
6017  const severityOrder = { danger: 0, warning: 1, info: 2 };
6018  visibles = visibles.slice().sort((a, b) => {
6019    const s = severityOrder[a.type] - severityOrder[b.type];
6020    if (s !== 0) return s;
6021    return (b.date || '').localeCompare(a.date || '');
6022  });
6023
6024  let html = entete + `<div class="section-title">${alertes.length} alerte${alertes.length > 1 ? 's' : ''} en cours</div>`;
6025
6026  // Filtres groupe
6027  html += `<div class="card mb-2" style="padding:12px">
6028    ${palmyre ? '' : `<div class="text-faint mb-1" style="font-size:11px; text-transform:uppercase; letter-spacing:1px">Filtrer par académie</div>
6029    <div class="pill-group">
6030      <span class="pill ${alertFilters.groupe === 'all' ? 'active' : ''}" data-ui-click="setAlertFilter('groupe', 'all')">Tous (${alertes.length})</span>
6031      ${['tremplin', 'accession', 'elite'].map(g => `
6032        <span class="pill ${alertFilters.groupe === g ? 'active' : ''}" data-ui-click="setAlertFilter('groupe', ${escapeHtml(encoderArgumentTexteUI(g))})" style="${alertFilters.groupe === g ? 'border-color: var(--' + (g === 'tremplin' ? 'success' : g === 'accession' ? 'info' : 'purple') + ')' : ''}">${escapeHtml((cache.config[g] || {}).nom || g)} (${countByGroupe[g] || 0})</span>
6033      `).join('')}
6034      ${countByGroupe.sans_groupe > 0 ? `<span class="pill ${alertFilters.groupe === 'sans_groupe' ? 'active' : ''}" data-ui-click="setAlertFilter('groupe', 'sans_groupe')">Sans groupe (${countByGroupe.sans_groupe})</span>` : ''}
6035    </div>`}
6036    <div class="text-faint mt-2 mb-1" style="font-size:11px; text-transform:uppercase; letter-spacing:1px">Filtrer par type</div>
6037    <div class="pill-group">
6038      <span class="pill ${alertFilters.type === 'all' ? 'active' : ''}" data-ui-click="setAlertFilter('type', 'all')">Tous</span>
6039      ${Object.keys(countByType).map(t => `
6040        <span class="pill ${alertFilters.type === t ? 'active' : ''}" data-ui-click="setAlertFilter('type', ${escapeHtml(encoderArgumentTexteUI(t))})">${escapeHtml(ALERT_TYPE_LABELS[t] || t)} (${countByType[t]})</span>
6041      `).join('')}
6042    </div>
6043  </div>`;
6044
6045  if (visibles.length === 0) {
6046    html += `<div class="empty"><span class="icon">🔎</span>Aucune alerte ne correspond à ces filtres.</div>`;
6047    return html;
6048  }
6049
6050  visibles.forEach(a => {
6051    const groupColor = a.groupe === 'tremplin' ? 'success' : a.groupe === 'accession' ? 'info' : a.groupe === 'elite' ? 'purple' : null;
6052    const groupBadge = palmyre ? '<span class="badge badge-warning">Palmyre</span>' : groupColor
6053      ? `<span class="badge badge-${a.groupe}" style="font-size:10px">${escapeHtml((cache.config[a.groupe] || {}).nom || a.groupe)}</span>`
6054      : `<span class="badge badge-warning" style="font-size:10px">sans groupe</span>`;
6055
6056    html += `<div class="alert ${a.type}" style="margin-bottom:8px; cursor:pointer" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(a.joueurId))})">
6057      ${a.alertType === 'inactivite' ? `<span class="inactivity-art">${progressArtHtml('inactivite',{size:42})}</span>` : progressArtHtml(a.art || 'objectif_groupe',{size:42})}
6058      <div class="alert-content">
6059        <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px">
6060          <strong style="flex:1">${escapeHtml(a.text)}</strong>
6061          ${groupBadge}
6062        </div>
6063        <div class="text-faint" style="font-size:11px; margin-top:4px">${escapeHtml(ALERT_TYPE_LABELS[a.alertType] || a.alertType)} · cliquer pour ouvrir la fiche joueur</div>
6064      </div>
6065    </div>`;
6066  });
6067
6068  return html;
6069}
6070
6071function setAlertFilter(key, value) {
6072  etatApp.definirFiltreAlerte(key, value);
6073  render();
6074}
6075
6076
6077const perfFilters = etatApp.filtresPerfs;
6078
6079function setPerfFilter(key, value) {
6080  etatApp.definirFiltrePerf(key, value);
6081  render();
6082}
6083
6084function openCoachPerformanceSection(section) {
6085  if (!isCoach()) return;
6086  navigate('perfs');
6087  setTimeout(()=>document.getElementById(section === 'montees' ? 'perfs-montees' : 'perfs-belles')?.scrollIntoView({ behavior: 'smooth', block: 'start' }),60);
6088}
6089
6090function setMatchResultFilter(value) {
6091  etatApp.definirFiltreResultat(value);
6092  render();
6093}
6094
6095function monteesClassementPeriode(periode) {
6096  if (!isPoleEnv()) return [];
6097  const [debut, fin] = getPeriodeDates(periode);
6098  const joueursEnvIds = new Set(joueursActifsDansEnvCourant().map(joueur => joueur.id));
6099  return (listeSure(cache.classementHistorique))
6100    .filter(h => TennisDomainRules.dateDansIntervalle(h.date_changement, debut, fin)
6101      && joueursEnvIds.has(h.joueur_id)
6102      && h.source !== 'import'  // les montées importées ne sont pas des montées « à célébrer »
6103      && (TennisDomainRules.ecartClassements(h.ancien_classement, h.nouveau_classement) || 0) > 0)
6104    .sort((a, b) => (b.date_changement || '').localeCompare(a.date_changement || ''));
6105}
6106
6107function renderMonteesSection(periode) {
6108  const montees = monteesClassementPeriode(periode);
6109
6110  if (montees.length === 0) return '';
6111
6112  let html = `<div class="section-title" id="perfs-montees" style="display:flex; align-items:center; gap:10px;scroll-margin-top:150px">
6113    <img class="section-art-icon" src="assets/v4/nav/performances.webp" alt=""><span>Montées de classement</span> <span class="badge" style="background:var(--success-bg); color:var(--success-text)">${montees.length}</span>
6114  </div>`;
6115  html += `<div class="card" style="padding:8px; margin-bottom:16px">`;
6116  montees.forEach((h, i) => {
6117    const j = joueurById(h.joueur_id);
6118    const nom = j ? `${j.prenom} ${j.nom}` : 'Joueur';
6119    const gain = TennisDomainRules.ecartClassements(h.ancien_classement, h.nouveau_classement) || 0;
6120    html += `<div class="match-row ${j ? 'clickable' : ''}" ${j ? `data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(h.joueur_id))})"` : ''} style="${i < montees.length - 1 ? 'border-bottom:1px solid var(--border)' : ''}">
6121      <div class="match-result-row">
6122        <span style="display:flex;align-items:center;gap:8px">${progressArtHtml('perfs', { size: 27 })}<strong>${escapeHtml(nom)}</strong></span>
6123        <span class="success" style="font-weight:600">${escapeHtml(h.ancien_classement || 'NC')} → ${escapeHtml(h.nouveau_classement)}${gain > 1 ? ` (+${gain})` : ''}</span>
6124      </div>
6125      <div class="match-meta">${formatDate(h.date_changement)}</div>
6126    </div>`;
6127  });
6128  html += `</div>`;
6129  return html;
6130}
6131
6132function renderPerfs() {
6133  if (!isCoach()) return '';
6134  const entete = renderUxScreenHeading('Dynamique sportive', 'Performances');
6135  const perfs = computeBellePerfs(periodes.perfs);
6136  const labelPeriode = getPeriodeLabel(periodes.perfs);
6137
6138  const selector = renderPeriodeSelector(periodes.perfs, 'setPeriodePerfs');
6139  const monteesBloc = renderMonteesSection(periodes.perfs);
6140
6141  if (perfs.length === 0) {
6142    return entete + selector + monteesBloc + `<div class="empty" id="perfs-belles" style="scroll-margin-top:150px">
6143      ${progressArtHtml('perfs',{size:64})}
6144      <strong>Aucune belle perf sur cette période</strong><br>
6145      <span class="text-faint">Une perf = victoire sur un adversaire mieux classé. Les perfs de <strong>${labelPeriode}</strong> s'afficheront ici dès qu'un joueur en réalise une.</span>
6146    </div>`;
6147  }
6148
6149  const countByGroupe = { tremplin: 0, accession: 0, elite: 0, sans_groupe: 0 };
6150  const countBySexe = { F: 0, M: 0, autre: 0 };
6151  perfs.forEach(p => {
6152    if (p.groupe) countByGroupe[p.groupe] = (countByGroupe[p.groupe] || 0) + 1;
6153    else countByGroupe.sans_groupe++;
6154    const s = p.joueur.sexe;
6155    if (s === 'F' || s === 'M') countBySexe[s]++;
6156    else countBySexe.autre++;
6157  });
6158
6159  let visibles = perfs;
6160  if (perfFilters.groupe !== 'all') {
6161    if (perfFilters.groupe === 'sans_groupe') visibles = visibles.filter(p => !p.groupe);
6162    else visibles = visibles.filter(p => p.groupe === perfFilters.groupe);
6163  }
6164  if (perfFilters.sexe !== 'all') {
6165    visibles = visibles.filter(p => p.joueur.sexe === perfFilters.sexe);
6166  }
6167
6168  let html = entete + selector + monteesBloc + `<div class="section-title" id="perfs-belles" style="display:flex; align-items:center; gap:10px;scroll-margin-top:150px">
6169    <span style="display:inline-flex;align-items:center;gap:8px">${progressArtHtml('perfs',{size:36})} Belles perfs</span>
6170    <span class="text-faint" style="font-weight:400; text-transform:none; letter-spacing:0; font-size:13px">— ${labelPeriode}</span>
6171  </div>`;
6172
6173  // Filtres
6174  html += `<div class="card mb-2" style="padding:12px">
6175    <div class="text-faint mb-1" style="font-size:11px; text-transform:uppercase; letter-spacing:1px">Filtrer par académie</div>
6176    <div class="pill-group">
6177      <span class="pill ${perfFilters.groupe === 'all' ? 'active' : ''}" data-ui-click="setPerfFilter('groupe', 'all')">Tous (${perfs.length})</span>
6178      ${['tremplin','accession','elite'].map(g => {
6179        if (countByGroupe[g] === 0) return '';
6180        const color = g === 'tremplin' ? 'success' : g === 'accession' ? 'info' : 'purple';
6181        const isActive = perfFilters.groupe === g;
6182        return `<span class="pill ${isActive ? 'active' : ''}" data-ui-click="setPerfFilter('groupe', ${escapeHtml(encoderArgumentTexteUI(g))})" style="${isActive ? 'border-color: var(--' + color + '); background: var(--' + color + '-bg); color: var(--' + color + ')' : ''}">${escapeHtml((cache.config[g] || {}).nom || g)} (${countByGroupe[g]})</span>`;
6183      }).join('')}
6184      ${countByGroupe.sans_groupe > 0 ? `<span class="pill ${perfFilters.groupe === 'sans_groupe' ? 'active' : ''}" data-ui-click="setPerfFilter('groupe', 'sans_groupe')">Sans groupe (${countByGroupe.sans_groupe})</span>` : ''}
6185    </div>
6186    <div class="text-faint mt-2 mb-1" style="font-size:11px; text-transform:uppercase; letter-spacing:1px">Filtrer par sexe</div>
6187    <div class="pill-group">
6188      <span class="pill ${perfFilters.sexe === 'all' ? 'active' : ''}" data-ui-click="setPerfFilter('sexe', 'all')">Tous</span>
6189      ${countBySexe.F > 0 ? `<span class="pill ${perfFilters.sexe === 'F' ? 'active' : ''}" data-ui-click="setPerfFilter('sexe', 'F')">Féminin (${countBySexe.F})</span>` : ''}
6190      ${countBySexe.M > 0 ? `<span class="pill ${perfFilters.sexe === 'M' ? 'active' : ''}" data-ui-click="setPerfFilter('sexe', 'M')">Masculin (${countBySexe.M})</span>` : ''}
6191    </div>
6192  </div>`;
6193
6194  if (visibles.length === 0) {
6195    html += `<div class="empty"><span class="icon">🔎</span>Aucune perf ne correspond à ces filtres.</div>`;
6196    return html;
6197  }
6198
6199  visibles.forEach(p => {
6200    const groupBadge = p.groupe
6201      ? `<span class="badge badge-${p.groupe}" style="font-size:10px">${escapeHtml((cache.config[p.groupe] || {}).nom || p.groupe)}</span>`
6202      : `<span class="badge badge-warning" style="font-size:10px">sans groupe</span>`;
6203    const ecartLabel = p.ecart >= 3 ? ' <span class="badge" style="background:#dfff35;color:#071d1f;font-size:10px">exceptionnelle</span>' : '';
6204    const ecartTxt = p.ecart === 1 ? '+1 échelon' : `+${p.ecart} échelons`;
6205
6206    html += `<div class="alert success" data-ui-click="openModalMatch(${escapeHtml(encoderArgumentTexteUI(p.match.id))})" style="margin-bottom:8px; cursor:pointer">
6207      ${progressArtHtml('perfs',{size:44})}
6208      <div class="alert-content">
6209        <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px">
6210          <div style="flex:1">
6211            <strong>${escapeHtml(p.joueur.prenom)} ${escapeHtml(p.joueur.nom)}</strong> (${escapeHtml(p.classementEpoque || p.joueur.classement)}) bat <strong>${escapeHtml(p.adversaire)}</strong> (${escapeHtml(p.adversaire_class)})${p.estDouble ? ` <span class="format-double-badge">Double</span>` : ''}${ecartLabel}
6212          </div>
6213          ${groupBadge}
6214        </div>
6215        <div class="text-faint" style="font-size:11px; margin-top:4px">${formatDate(p.date)} · ${ecartTxt}${p.estDouble ? ' · double' : ''}${p.score ? ' · ' + escapeHtml(p.score) : ''} · cliquer pour voir le match</div>
6216      </div>
6217    </div>`;
6218  });
6219
6220  return html;
6221}
6222
6223
6224// ============================================================
6225// SCREEN: GROUPES (coach) - Tableaux de bord par groupe avec score santé
6226// ============================================================
6227
6228function setGroupeDetail(key) {
6229  etatApp.definirGroupeDetail(key);
6230  window.scrollTo(0, 0);
6231  render();
6232}
6233
6234// Calcule le score santé d'un groupe sur 100, sur la PÉRIODE donnée.
6235// Score périodisé : Engagement (35) + Victoires (30) + Perfs (20)
6236// + progression de classement signée (-15 à +15). En Palmyre, la progression
6237// est inapplicable et les 85 points restants sont renormalisés sur 100.
6238// Les alertes ne font pas partie du score (état temps réel, affiché à part).
6239// Config synthétique du pseudo-groupe Palmyre (objectif lu sur l'édition active).
6240function palmyreGroupConfig() {
6241  const ev = getActiveEvenement();
6242  return {
6243    nom: (ev && ev.nom) || 'Palmyre',
6244    objectifMatches: (ev && ev.objectif_matches != null) ? ev.objectif_matches : 20,
6245    places: null
6246  };
6247}
6248
6249function computeGroupHealth(key, periode) {
6250  periode = periode || getTrimestreCourant();
6251  // Pseudo-groupe Palmyre : tous les participants à l'édition, quel que soit leur
6252  // groupe académie. On synthétise une config (objectif basé sur la durée de l'édition).
6253  const isPalmyreGroup = (key === '__palmyre__');
6254  const g = isPalmyreGroup
6255    ? palmyreGroupConfig()
6256    : cache.config[key];
6257  if (!g || typeof g !== 'object') return null;
6258  const joueurs = isPalmyreGroup
6259    ? joueursActifsDansEnvCourant()
6260    : cache.joueurs.filter(j => j.groupe === key && TennisDomainRules.estJoueurActif(j));
6261  if (joueurs.length === 0) {
6262    return { score: null, label: 'Vide', color: 'text-faint', joueurs: [], details: {} };
6263  }
6264
6265  // Un groupe explicitement configuré hors compétition n'a pas de score santé.
6266  if (g.horsCompet) {
6267    return { score: null, label: 'Hors compét', color: 'text-faint', joueurs, details: {}, horsCompet: true };
6268  }
6269
6270  // En Académie, un nouveau joueur sans match sportif depuis moins de 30 jours est
6271  // en grâce : il reste visible dans le groupe mais ne pénalise aucun critère moyen.
6272  // En Palmyre, les créneaux planifiés font foi et cette grâce ne s'applique pas.
6273  const joueursEvalues = isPalmyreGroup
6274    ? joueurs
6275    : joueurs.filter(j => !TennisDomainRules.estEnGrace(j, matchesSaisonJoueur(j.id), Date.now()));
6276
6277  const now = Date.now();
6278  const [perDebut, perFin] = getPeriodeDates(periode);
6279  const periodeFuture = !isPalmyreGroup && TennisDomainRules.etatIntervalle(perDebut, perFin) === 'avenir';
6280  // Matchs d'un joueur sur la période
6281  const matchsPeriodeJoueur = (jid) => matchsHorsWO(matchsDuJoueur(jid))
6282    .filter(m => TennisDomainRules.dateDansIntervalle(m.date, perDebut, perFin));
6283
6284  // ======= Critère 1 : Engagement moyen (35 pts) =======
6285  // Matchs joués sur la période / objectif proratisé sur cette même période.
6286  // Plancher : l'objectif de référence ne descend jamais sous 1 match, sinon en tout
6287  // début de période un seul match suffirait à exploser le ratio (division par ~0).
6288  // Cas Palmyre : le prorata temporel n'a pas de sens pour un stage court et borné.
6289  // L'objectif est désormais PAR JOUEUR = nombre de créneaux (rencontres) qui lui sont
6290  // planifiés dans l'édition. C'est plus juste qu'un objectif fixe commun : un joueur
6291  // inscrit sur plus de tournois/tours a mécaniquement plus de matchs attendus.
6292  // Repli : si un joueur n'a AUCUN créneau planifié, on retombe sur l'objectif fixe de
6293  // l'édition (réglable dans les paramètres) pour ne pas fausser sa lecture.
6294  const objectifFixe = isPalmyreGroup
6295    ? g.objectifMatches
6296    : Math.max(1, getObjectifPeriode(g.objectifMatches, periode));
6297
6298  // Nombre de créneaux planifiés d'un joueur sur l'édition Palmyre active.
6299  const evPalmyre = isPalmyreGroup ? getActiveEvenement() : null;
6300  const creneauxPlanifiesJoueur = (jid) => {
6301    if (!evPalmyre) return 0;
6302    return creneauxPalmyreDuJoueurEvenement(jid, evPalmyre.id).length;
6303  };
6304
6305  const donneesJoueurs = joueursEvalues.map(joueur => {
6306    const matchsPeriode = matchsPeriodeJoueur(joueur.id);
6307    const compte = TennisDomainRules.statistiquesSportives(
6308      matchsPeriode,
6309      match => classementADate(joueur.id, match.date)
6310    );
6311    let objectifJoueur = objectifFixe;
6312    if (isPalmyreGroup) {
6313      const nbCreneaux = creneauxPlanifiesJoueur(joueur.id);
6314      objectifJoueur = nbCreneaux > 0 ? nbCreneaux : objectifFixe;
6315    }
6316    return {
6317      matchs: compte.total,
6318      objectif: objectifJoueur,
6319      victoires: compte.victoires,
6320      total: compte.total,
6321      perfs: compte.perfs,
6322      progression: isPalmyreGroup ? 0 : progressionClassementPeriode(joueur.id, periode)
6323    };
6324  });
6325  const sante = TennisDomainRules.calculerSanteGroupe({
6326    joueurs: donneesJoueurs,
6327    palmyre: isPalmyreGroup,
6328    periodeFuture,
6329    debutPeriode: !isPalmyreGroup && estDebutDePeriode(periode)
6330  });
6331  const { score, label, color } = sante;
6332
6333  // Alertes : hors score, état temps réel (toujours "maintenant", pas la période)
6334  const alertesGroupe = (listeSure(cache.alertes)).filter(a => a.groupe === key).length;
6335
6336  return {
6337    score, label, color, joueurs,
6338    periode,
6339    joueursEvalues: joueursEvalues.length,
6340    alertesGroupe,
6341    details: sante.details
6342  };
6343}
6344
6345// === Vue principale : aiguillage liste ou détail ===
6346function renderGroupes() {
6347  if (!isCoach()) return '';
6348  // En mode Palmyre : un seul "groupe" = les participants. On va directement au détail.
6349  if (!isPoleEnv()) {
6350    return renderUxScreenHeading('Collectif', 'Participants') + renderGroupeDetail('__palmyre__');
6351  }
6352  const groupesConfig = ['tremplin', 'accession', 'elite'].filter(k => {
6353    const g = cache.config[k];
6354    return g && typeof g === 'object' && g.nom;
6355  });
6356  if (groupesConfig.length === 0) {
6357    return renderUxScreenHeading('Collectif', 'Mes groupes') + `<div class="empty"><span class="icon">📋</span>Aucun groupe configuré.<br>
6358      <span class="text-faint">Va dans Réglages pour configurer tes groupes (places, objectifs).</span></div>`;
6359  }
6360  if (etatApp.groupeDetail && groupesConfig.includes(etatApp.groupeDetail)) {
6361    return renderUxScreenHeading('Collectif', escapeHtml((cache.config[etatApp.groupeDetail] || {}).nom || 'Mon groupe')) + renderGroupeDetail(etatApp.groupeDetail);
6362  }
6363  return renderUxScreenHeading('Collectif', 'Mes groupes') + renderGroupesListe(groupesConfig);
6364}
6365
6366// === Vue 1 : liste des 3 groupes en cartes ===
6367function renderGroupesListe(groupesConfig) {
6368  let html = `<div class="text-faint mb-2" style="font-size:12px">
6369    <span class="sort-hint-mark"></span>Clique sur un groupe pour voir son tableau de bord détaillé.
6370  </div>`;
6371
6372  html += `<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(280px, 1fr)); gap:12px">`;
6373
6374  // Score affiché sur les étiquettes = trimestre en cours (le "moment").
6375  // Le détail d'un groupe permet de choisir une autre temporalité (saison, autre T).
6376  const periodeListe = getTrimestreCourant();
6377  const labelPeriode = getPeriodeLabel(periodeListe);
6378
6379  groupesConfig.forEach(key => {
6380    const g = cache.config[key];
6381    const health = computeGroupHealth(key, periodeListe);
6382    const horsCompet = !!g.horsCompet;
6383    const color = key === 'tremplin' ? 'success' : key === 'accession' ? 'info' : 'purple';
6384    const scoreDisplay = horsCompet
6385      ? `<div style="font-size:36px; font-weight:700; color:var(--orange-text)">${health.joueurs.length}</div>
6386         <div class="text-faint" style="font-size:12px; margin-top:2px">joueur${health.joueurs.length > 1 ? 's' : ''} suivi${health.joueurs.length > 1 ? 's' : ''}</div>`
6387      : (health.score !== null
6388        ? `<div style="font-size:36px; font-weight:700; color:var(--${health.color})">${health.score}<span style="font-size:14px; color:var(--text-dim); font-weight:400">/100</span></div>
6389           <div class="text-faint" style="font-size:12px; margin-top:2px">${escapeHtml(health.label)} · ${escapeHtml(labelPeriode)}</div>`
6390        : `<div class="text-faint" style="font-size:14px; padding:24px 0">${health.joueurs.length ? escapeHtml(health.label || 'Pas assez de recul') : 'Aucun joueur'}</div>`);
6391
6392    html += `<div class="card group-card clickable" data-ui-click="setGroupeDetail(${escapeHtml(encoderArgumentTexteUI(key))})"
6393      style="cursor:pointer; padding:20px; border-top:3px solid var(--${color}); transition:all 0.2s">
6394      <div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:12px">
6395        <div>
6396          <span class="badge badge-${key}" style="font-size:11px">${escapeHtml(g.nom)}</span>
6397          <div class="text-faint mt-1" style="font-size:11px">${horsCompet ? 'hors compétition · détection' : (health.joueurs.length + '/' + (g.places || '?') + ' places · objectif ' + g.objectifMatches + ' matches/an')}</div>
6398        </div>
6399      </div>
6400      <div style="text-align:center; padding:12px 0">${scoreDisplay}</div>
6401      ${(!horsCompet && health.score !== null) ? `
6402        <div class="text-faint" style="font-size:11px; text-align:center; margin-top:8px">
6403          Engagement ${health.details.engagement.valeur} · Victoires ${health.details.victoires.valeur} · ${health.details.perfs.valeur}${health.details.progression.applicable ? ' · Classement ' + health.details.progression.valeur : ''}
6404        </div>` : ''}
6405    </div>`;
6406  });
6407
6408  html += `</div>`;
6409  return html;
6410}
6411
6412// === Vue 2 : détail d'un groupe ===
6413function renderGroupeDetail(key) {
6414  const isPalmyreGroup = (key === '__palmyre__');
6415  const g = isPalmyreGroup
6416    ? palmyreGroupConfig()
6417    : cache.config[key];
6418  const horsCompet = (!isPalmyreGroup) && !!g.horsCompet;
6419  const health = computeGroupHealth(key, periodes.groupe);
6420  const color = isPalmyreGroup ? 'orange' : key === 'tremplin' ? 'success' : key === 'accession' ? 'info' : 'purple';
6421
6422  let html = '';
6423  // Conteneur de contexte groupe : permet de colorer tous les section-title de cet
6424  // écran selon la couleur du groupe (via CSS .groupe-ctx-xxx .section-title).
6425  const grpClasse = isPalmyreGroup ? 'palmyre' : key;
6426  html += `<div class="groupe-ctx groupe-ctx-${grpClasse}">`;
6427  if (!isPalmyreGroup) {
6428    html += `<div style="margin-bottom:16px">
6429      <button class="btn btn-sm" data-ui-click="setGroupeDetail(null)">← Retour aux groupes</button>
6430    </div>`;
6431  }
6432
6433  // Sélecteur de période (pilote le score santé ET les sections ci-dessous)
6434  if (!horsCompet) {
6435    html += renderPeriodeSelector(periodes.groupe, 'setPeriodeGroupe');
6436  }
6437
6438  // En-tête identité
6439  html += `<div class="card" style="padding:20px; border-top:4px solid var(--${color}); margin-bottom:12px">
6440    <div style="display:flex; justify-content:space-between; align-items:flex-start; flex-wrap:wrap; gap:12px">
6441      <div>
6442        <div style="display:flex; align-items:center; gap:10px; margin-bottom:6px">
6443          <span class="badge badge-${isPalmyreGroup ? 'orange' : key}" style="font-size:13px; padding:4px 10px">${isPalmyreGroup ? '🏖️ ' : ''}${escapeHtml(g.nom)}</span>
6444          <h2 style="margin:0; font-size:20px">Tableau de bord</h2>
6445        </div>
6446        <div class="text-faint" style="font-size:13px">
6447          ${isPalmyreGroup
6448            ? `${health.joueurs.length} participant${health.joueurs.length > 1 ? 's' : ''} · stage intensif`
6449            : horsCompet
6450            ? `${health.joueurs.length} joueur${health.joueurs.length > 1 ? 's' : ''} suivi${health.joueurs.length > 1 ? 's' : ''} · hors compétition (détection)`
6451            : `${health.joueurs.length}/${g.places || '?'} places · objectif ${g.objectifMatches} matches/an`}
6452        </div>
6453      </div>
6454      ${health.score !== null ? `
6455        <div style="text-align:right">
6456          <div style="font-size:42px; font-weight:700; color:var(--${health.color}); line-height:1">${health.score}<span style="font-size:16px; color:var(--text-dim); font-weight:400">/100</span></div>
6457          <div class="text-faint" style="font-size:12px; margin-top:2px">Score santé · <strong style="color:var(--${health.color})">${escapeHtml(health.label)}</strong></div>
6458          <div class="text-faint" style="font-size:11px">
6458${escapeHtml(getPeriodeLabel(periodes.groupe))}</div>
6459        </div>` : ''}
6460    </div>
6461  </div>`;
6462
6463  // Bandeau alertes temps réel (hors score, état actuel du groupe)
6464  if (!horsCompet && health.alertesGroupe > 0) {
6465    html += `<div class="card clickable" data-ui-click="navigate('alertes')" style="margin-bottom:12px; border-left:3px solid var(--danger); display:flex; align-items:center; gap:10px; padding:12px; cursor:pointer">
6466      <span style="font-size:18px">🚨</span>
6467      <div style="flex:1; font-size:13px"><strong>${health.alertesGroupe} alerte${health.alertesGroupe > 1 ? 's' : ''}</strong> en cours sur ce groupe <span class="text-faint">(état actuel, toutes périodes)</span></div>
6468      <span class="text-faint">→</span>
6469    </div>`;
6470  }
6471
6472  if (health.joueurs.length === 0) {
6473    html += `<div class="empty"><span class="icon">👥</span>Aucun joueur dans ce groupe pour le moment.</div>`;
6474    html += `</div>`;
6475    return html;
6476  }
6477
6478  // Décomposition du score santé (uniquement pour les groupes compét)
6479  if (!horsCompet) {
6480  html += `<div class="section-title">Décomposition du score · ${escapeHtml(getPeriodeLabel(periodes.groupe))}</div>`;
6481  html += `<div class="card" style="padding:16px">`;
6482  ['engagement', 'victoires', 'perfs', 'progression'].forEach(k => {
6483    const d = health.details[k];
6484    // En Palmyre, le critère progression de classement n'est pas applicable → on ne l'affiche pas.
6485    if (k === 'progression' && d && d.applicable === false) return;
6486    const labelMap = {
6487      engagement: { art: 'matchs', label: 'Engagement (matches joués vs objectif de la période)' },
6488      victoires: { art: 'victoires', label: 'Taux victoires moyen (sur la période)' },
6489      perfs: { art: 'perfs', label: 'Perfs sur la période' },
6490      progression: { art: 'sans_faute', label: 'Progression de classement (montées / descentes)' }
6491    };
6492    const meta = labelMap[k];
6493    if (k === 'progression') {
6494      // Critère signé : barre centrée, vert si bonus, rouge si malus
6495      const pts = d.points;
6495 // de -15 à +15
6496      const pctAbs = Math.round((Math.abs(pts) / d.max) * 100);
6497      const signe = pts > 0 ? '+' : '';
6498      const barColor = pts > 0 ? 'success' : pts < 0 ? 'danger' : '';
6499      const ptsColor = pts > 0 ? 'success' : pts < 0 ? 'danger' : 'text-faint';
6500      html += `<div style="margin-bottom:14px">
6501        <div style="display:flex; justify-content:space-between; align-items:baseline; gap:8px; margin-bottom:4px">
6502          <div style="display:flex;align-items:center;gap:8px;font-size:13px">${progressArtHtml(meta.art,{size:34})}<strong>${escapeHtml(meta.label)}</strong> <span class="text-faint" style="font-size:11px">(${escapeHtml(d.valeur)})</span></div>
6503          <div style="font-size:13px; font-weight:600" class="${ptsColor}">${signe}${pts}/${d.max}</div>
6504        </div>
6505        <div class="progress-bar" style="height:6px; background:var(--bg-input)"><div class="progress-fill ${barColor}" style="width:${pctAbs}%; ${pts < 0 ? 'margin-left:auto' : ''}"></div></div>
6506      </div>`;
6507    } else {
6508      const pct = Math.round((d.points / d.max) * 100);
6509      const barColor = pct >= 75 ? 'success' : pct >= 50 ? 'warning' : 'danger';
6510      html += `<div style="margin-bottom:14px">
6511        <div style="display:flex; justify-content:space-between; align-items:baseline; gap:8px; margin-bottom:4px">
6512          <div style="display:flex;align-items:center;gap:8px;font-size:13px">${progressArtHtml(meta.art,{size:34})}<strong>${escapeHtml(meta.label)}</strong> <span class="text-faint" style="font-size:11px">(${escapeHtml(d.valeur)})</span></div>
6513          <div style="font-size:13px; font-weight:600">${d.points}/${d.max}</div>
6514        </div>
6515        <div class="progress-bar" style="height:6px"><div class="progress-fill ${barColor}" style="width:${pct}%"></div></div>
6516      </div>`;
6517    }
6518  });
6519  html += `</div>`;
6520  }
6521
6522  // Calculer stats par joueur pour locomotives / à accompagner, sur la PÉRIODE choisie
6523  const [perDeb, perFin] = getPeriodeDates(periodes.groupe);
6524  const stats = health.joueurs.map(j => {
6525    const matchesJoueur = matchsDuJoueur(j.id);
6526    const compteGlobal = comptesMatchs(matchesJoueur);
6527    const total = compteGlobal.total;
6528    const tauxV = compteGlobal.tauxVictoire;
6529
6530    // Matchs de la PÉRIODE (pour activité, taux V période, perfs). WO déjà exclus ci-dessus.
6531    const msPeriode = matchsHorsWO(matchesJoueur)
6532      .filter(m => TennisDomainRules.dateDansIntervalle(m.date, perDeb, perFin));
6533    const comptePeriode = TennisDomainRules.statistiquesSportives(
6534      msPeriode,
6535      match => classementADate(j.id, match.date)
6536    );
6537    const matchesPeriode = comptePeriode.total;
6538    const tauxVPeriode = comptePeriode.tauxVictoire;
6539
6540    // Belles perfs de la période (victoire sur un adversaire mieux classé)
6541    const perfsPeriode = comptePeriode.perfs;
6542    const progressionPeriode = progressionClassementPeriode(j.id, periodes.groupe);
6543
6544    // Engagement SAISON (conservé pour le ranking locomotives, suivi annuel)
6545    const matchesSaison = matchesSaisonJoueur(j.id);
6546    let scoreEngagement = 0;
6547    const enGraceSaison = TennisDomainRules.estEnGrace(j, matchesSaison, Date.now());
6548    if (enGraceSaison) {
6549      scoreEngagement = -1; // en grâce, ignoré dans le ranking
6550    } else if (isPalmyreGroup) {
6551      // Palmyre : objectif PAR JOUEUR = ses créneaux planifiés dans l'édition (repli sur
6552      // l'objectif fixe si aucun créneau). Cohérent avec computeGroupHealth.
6553      const ev = getActiveEvenement();
6554      const nbCreneaux = ev ? creneauxPalmyreDuJoueurEvenement(j.id, ev.id).length : 0;
6555      const objJoueur = nbCreneaux > 0 ? nbCreneaux : g.objectifMatches;
6556      scoreEngagement = Math.min(1, matchesSaison / Math.max(1, objJoueur));
6557    } else {
6558      const objectifAttendu = g.objectifMatches * getProrataObjectif();
6559      scoreEngagement = objectifAttendu > 0 ? matchesSaison / objectifAttendu : 0;
6560    }
6561
6562    // Engagement PÉRIODE (pour le tableau récap, suit le sélecteur)
6563    let scoreEngagementPeriode = 0;
6564    if (TennisDomainRules.estEnGrace(j, matchesSaison, Date.now())) {
6565      scoreEngagementPeriode = -1; // en grâce (badge visuel "nouveau")
6566    } else if (isPalmyreGroup) {
6567      // Palmyre : objectif PAR JOUEUR = ses créneaux planifiés dans l'édition (repli sur
6568      // l'objectif fixe si aucun créneau). Cohérent avec computeGroupHealth.
6569      const ev = getActiveEvenement();
6570      const nbCreneaux = ev ? creneauxPalmyreDuJoueurEvenement(j.id, ev.id).length : 0;
6571      const objJoueur = nbCreneaux > 0 ? nbCreneaux : g.objectifMatches;
6572      scoreEngagementPeriode = Math.min(1, matchesPeriode / Math.max(1, objJoueur));
6573    } else {
6574      // Plancher à 1 match pour éviter des ratios absurdes en tout début de période.
6575      const objectifPer = Math.max(1, getObjectifPeriode(g.objectifMatches, periodes.groupe));
6576      scoreEngagementPeriode = Math.min(1, matchesPeriode / objectifPer);
6577    }
6578
6579    return {
6580      joueur: j,
6581      matchesAnnee: matchesSaison,
6582      matchesPeriode,
6583      total, tauxV,
6584      tauxVPeriode,
6585      perfsPeriode,
6586      contrePerfsPeriode: computeContrePerfs(j.id, periodes.groupe).length,
6587      progressionClass: Math.max(0, progressionPeriode), // montées seulement (0 si descente)
6588      descenteClass: Math.max(0, -progressionPeriode), // échelons perdus (0 si montée/stable)
6589      scoreEngagement,
6590      scoreEngagementPeriode
6591    };
6592  });
6593
6594  // ===== À accompagner : difficulté sportive sur la période =====
6595  // Deux signaux : contre-perfs (≥ 2) ET/OU descente de classement.
6596  // Score de difficulté = contre-perfs + 2 points par échelon perdu (la descente
6597  // pèse plus lourd car c'est un signal fort). Trié du plus en difficulté au moins.
6598  const elligibles = stats.filter(s => s.scoreEngagement >= 0);
6599  // Taille des listes pondérée par l'effectif éligible : environ 1/3 du groupe,
6600  // avec un minimum de 1 et un maximum de 3. Évite de classer la moitié d'un petit
6601  // groupe en "locomotive" tout en restant utile sur les petits effectifs.
6602  const tailleListe = Math.max(1, Math.min(3, Math.round(elligibles.length / 3)));
6603  stats.forEach(s => {
6604    s.scoreDifficulte = s.contrePerfsPeriode + s.descenteClass * 2;
6605  });
6606  const aAccompagner = stats
6607    .filter(s => s.contrePerfsPeriode >= 2 || s.descenteClass >= 1)
6608    .sort((a, b) => b.scoreDifficulte - a.scoreDifficulte)
6609    .slice(0, tailleListe);
6610
6611  // ===== Locomotives : meilleurs DU GROUPE sur un score de dynamique combiné =====
6612  // Score = moyenne équilibrée de 4 dimensions normalisées sur le max du groupe :
6613  //   activité (matchs période), taux de victoire période, belles perfs période,
6614  //   progression de classement sur la période (montées).
6615  // Relatif au groupe → il y a toujours une locomotive, même sans atteindre l'objectif.
6616  // Candidats : joueurs avec au moins 3 matchs sur la période (sinon non significatif).
6617  // NB : les deux listes mesurent des choses différentes (dynamique vs contre-perfs),
6618  // donc un joueur PEUT être dans les deux — ce n'est pas contradictoire.
6619  const candidatsLoco = elligibles.filter(s => s.matchesPeriode >= 3);
6620  const maxActivite = Math.max(1, ...candidatsLoco.map(s => s.matchesPeriode));
6621  const maxTauxV = Math.max(1, ...candidatsLoco.map(s => s.tauxVPeriode));
6622  const maxPerfs = Math.max(1, ...candidatsLoco.map(s => s.perfsPeriode));
6623  const maxProg = Math.max(1, ...candidatsLoco.map(s => s.progressionClass));
6624  candidatsLoco.forEach(s => {
6625    const nA = s.matchesPeriode / maxActivite;
6626    const nT = s.tauxVPeriode / maxTauxV;
6627    const nP = s.perfsPeriode / maxPerfs;
6628    const nProg = s.progressionClass / maxProg;
6629    s.scoreDynamique = (nA + nT + nP + nProg) / 4;
6630  });
6631  const locomotives = candidatsLoco
6632    .slice()
6633    .sort((a, b) => b.scoreDynamique - a.scoreDynamique)
6634    .slice(0, tailleListe);
6635
6636  // N'afficher la section que s'il y a au moins 3 joueurs éligibles et au moins une
6637  // des deux listes non vide.
6638  if (elligibles.length >= 3 && (locomotives.length > 0 || aAccompagner.length > 0)) {
6639    // Légende explicative
6640    html += `<div class="text-faint" style="font-size:11px; margin-top:12px; line-height:1.5">
6641      <strong>Locomotives</strong> : les joueurs les plus dynamiques du groupe (activité, victoires, belles perfs et progression de classement combinées). <strong>À accompagner</strong> : ceux en difficulté sportive — contre-perfs (défaites contre un adversaire moins bien classé) et/ou descente de classement.
6642    </div>`;
6643    html += `<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(280px, 1fr)); gap:12px; margin-top:8px">`;
6644
6645    // Locomotives (dynamique du groupe)
6646    html += `<div>
6647      <div class="section-title" style="margin-top:0">🚀 Locomotives</div>
6648      <div class="card" style="padding:8px">`;
6649    if (locomotives.length === 0) {
6650      html += `<div class="text-faint" style="font-size:12px; text-align:center; padding:10px">Pas encore assez de matchs pour désigner une locomotive.</div>`;
6651    } else {
6652      locomotives.forEach((s, idx) => {
6653        const medal = idx === 0 ? '🥇' : idx === 1 ? '🥈' : '🥉';
6654        // Détail : progression · perfs · % V · matchs (sur la période)
6655        const detail = [];
6656        if (s.progressionClass > 0) detail.push(`📈 +${s.progressionClass}`);
6657        if (s.perfsPeriode > 0) detail.push(`${s.perfsPeriode} perf${s.perfsPeriode > 1 ? 's' : ''}`);
6658        detail.push(`${s.tauxVPeriode}% V`);
6659        detail.push(`${s.matchesPeriode} match${s.matchesPeriode > 1 ? 's' : ''}`);
6660        html += `<div class="match-row clickable" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(s.joueur.id))})" style="border-bottom:1px solid var(--border)">
6661          <div class="match-result-row">
6662            <span><strong>${medal} ${escapeHtml(s.joueur.prenom)} ${escapeHtml(s.joueur.nom)}</strong> <span class="text-faint" style="font-size:11px">(${escapeHtml(s.joueur.classement)})</span></span>
6663          </div>
6664          <div class="match-meta">${detail.join(' · ')}</div>
6665        </div>`;
6666      });
6667    }
6668    html += `</div></div>`;
6669
6670    // À accompagner (contre-perfs et/ou descente de classement)
6671    html += `<div>
6672      <div class="section-title" style="margin-top:0">⚠️ À accompagner</div>
6673      <div class="card" style="padding:8px">`;
6674    if (aAccompagner.length === 0) {
6675      html += `<div class="text-faint" style="font-size:12px; text-align:center; padding:10px">Rien à signaler 👍</div>`;
6676    } else {
6677      aAccompagner.forEach(s => {
6678        // Badge : on affiche les signaux présents (descente + contre-perfs)
6679        const badges = [];
6680        if (s.descenteClass > 0) badges.push(`📉 -${s.descenteClass}`);
6681        if (s.contrePerfsPeriode > 0) badges.push(`${s.contrePerfsPeriode} contre-perf${s.contrePerfsPeriode > 1 ? 's' : ''}`);
6682        const motifs = [];
6683        if (s.descenteClass > 0) motifs.push('descente de classement');
6684        if (s.contrePerfsPeriode >= 2) motifs.push('défaites contre moins bien classé');
6685    html += `<div class="match-row clickable" data-ui-click="openContrePerfsModal(${escapeHtml(encoderArgumentTexteUI(s.joueur.id))}, ${escapeHtml(encoderArgumentTexteUI(periodes.groupe))})" style="border-bottom:1px solid var(--border)" title="Voir le détail des contre-perfs">
6686          <div class="match-result-row">
6687            <span><strong>${escapeHtml(s.joueur.prenom)} ${escapeHtml(s.joueur.nom)}</strong> <span class="text-faint" style="font-size:11px">(${escapeHtml(s.joueur.classement)})</span></span>
6688            <span class="danger" style="font-weight:600">${badges.join(' · ')}</span>
6689          </div>
6690          <div class="match-meta">${motifs.join(' · ')} · cliquer pour le détail</div>
6691        </div>`;
6692      });
6693    }
6694    html += `</div></div>`;
6695
6696    html += `</div>`;
6697  }
6698
6699  // Liste complète des joueurs du groupe (triée par classement)
6700  html += `<div class="section-title">Tous les joueurs du groupe (${health.joueurs.length}) · ${escapeHtml(getPeriodeLabel(periodes.groupe))}</div>`;
6701  html += `<div class="card" style="padding:0; overflow-x:auto">
6702    <table class="overview-table" style="width:100%; border-collapse:collapse; font-size:12px">
6703      <thead style="background:var(--bg-section)">
6704        <tr>
6705          <th style="text-align:left; padding:8px; color:var(--text-dim)">Joueur</th>
6706          <th style="text-align:center; padding:8px; color:var(--text-dim); width:70px">Classt</th>
6707          <th style="text-align:center; padding:8px; color:var(--text-dim); width:90px">Matches${horsCompet ? '' : `<div style="font-weight:400; font-size:10px; text-transform:none">/ objectif</div>`}</th>
6708          <th style="text-align:center; padding:8px; color:var(--text-dim); width:60px">% V</th>
6709          ${horsCompet ? '' : `<th style="text-align:center; padding:8px; color:var(--text-dim); width:80px">Engagement</th>`}
6710        </tr>
6711      </thead><tbody>`;
6712
6713  // Objectif de matchs attendu sur la période (pour la colonne Matches, hors hors-compét).
6714  // En Palmyre, l'objectif est PAR JOUEUR (ses créneaux planifiés), calculé dans la boucle.
6715  const objMatchesPeriode = horsCompet ? 0 : (isPalmyreGroup ? null : Math.max(1, getObjectifBasePeriodeEntier(g.objectifMatches, periodes.groupe)));
6716  const evPalmyreAff = isPalmyreGroup ? getActiveEvenement() : null;
6717  const objMatchesJoueur = (jid) => {
6718    if (!isPalmyreGroup) return objMatchesPeriode;
6719    const nb = evPalmyreAff ? creneauxPalmyreDuJoueurEvenement(jid, evPalmyreAff.id).length : 0;
6720    return nb > 0 ? nb : g.objectifMatches;
6721  };
6722
6723  stats.slice().sort((a, b) => getRankIndex(b.joueur.classement) - getRankIndex(a.joueur.classement)).forEach(s => {
6724    const j = s.joueur;
6725    const objAffiche = isPalmyreGroup ? objMatchesJoueur(j.id) : objMatchesPeriode;
6726    const ratio = s.scoreEngagementPeriode >= 0 ? Math.round(s.scoreEngagementPeriode * 100) + '%' : '—';
6727    const cls = s.scoreEngagementPeriode < 0 ? 'text-faint' : s.scoreEngagementPeriode >= 0.8 ? 'success' : s.scoreEngagementPeriode < 0.5 ? 'danger' : '';
6728    const tauxCls = s.matchesPeriode > 0 && s.tauxVPeriode >= 55 ? 'success' : '';
6729    html += `<tr class="overview-row" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(j.id
6729))})" style="cursor:pointer; border-top:1px solid var(--border)">
6730      <td style="padding:8px"><strong>${escapeHtml(j.prenom)}</strong> ${escapeHtml(j.nom)}</td>
6731      <td style="text-align:center; padding:8px"><strong class="orange">${escapeHtml(j.classement || 'NC')}</strong></td>
6732      <td style="text-align:center; padding:8px">${horsCompet ? s.matchesPeriode : s.matchesPeriode + '/' + objAffiche}</td>
6733      <td style="text-align:center; padding:8px" class="${tauxCls}"><strong>${s.matchesPeriode > 0 ? s.tauxVPeriode + '%' : '—'}</strong></td>
6734      ${horsCompet ? '' : `<td style="text-align:center; padding:8px" class="${cls}"><strong>${ratio}</strong></td>`}
6735    </tr>`;
6736  });
6737  html += `</tbody></table></div>`;
6738
6739  if (!horsCompet) {
6740    // La colonne « objectif » affiche l'objectif PLEIN du trimestre (objectif annuel
6741    // réparti selon la saisonnalité), NON proratisé dans le temps → les 4 trimestres
6742    // somment exactement à l'objectif annuel. L'Engagement, lui, reste proratisé dans
6743    // le temps (avancement vs objectif attendu à ce jour).
6744    const labelObj = periodes.groupe === 'saison'
6745      ? 'objectif de la saison'
6746      : 'objectif du trimestre (réparti selon la saisonnalité)';
6747    html += `<div class="text-faint mt-2" style="font-size:11px; text-align:center">
6748      Matches : nombre réalisé / ${labelObj} · Engagement : avancement vs objectif attendu à ce jour · % V sur la période · « — » = aucun match
6749    </div>`;
6750  }
6751
6752  html += `</div>`; // fermeture .groupe-ctx
6753  return html;
6754}
6755
6756
6757// ============================================================
6758// SCREEN: VUE D'ENSEMBLE (coach)
6759// Tableau exhaustif unique, triable en cliquant sur les en-têtes
6760// Tri par défaut : par classement (du meilleur au moins bien classé)
6761// ============================================================
6762
6763const overviewSort = etatApp.triOverview;
6764
6765function setOverviewSort(col) {
6766  etatApp.basculerTriOverview(col);
6767  render();
6768}
6769
6770function renderOverview() {
6771  if (!isCoach()) return '';
6772  const entete = renderUxScreenHeading('Analyse de l’académie', 'Vue d’ensemble');
6773  const joueursActifs = joueursActifsDansEnvCourant();
6774
6775  // En mode Palmyre : ne montrer que les participants à l'édition.
6776  if (!isPoleEnv()) {
6777    if (joueursActifs.length === 0) {
6778      const ev = getActiveEvenement();
6779      return entete + `<div class="empty"><span class="icon">🏖️</span>Aucun participant à ${ev ? escapeHtml(ev.nom) : 'la Palmyre'}.<br>
6780        <span class="text-faint">Coche « Participe à la Palmyre » sur la fiche des joueurs concernés.</span></div>`;
6781    }
6782  }
6783
6784  if (joueursActifs.length === 0) {
6785    return entete + `<div class="empty"><span class="icon">📊</span>Aucun joueur actif à afficher.<br>
6786      <span class="text-faint">Crée ou valide des joueurs pour voir la vue d'ensemble de l'académie.</span></div>`;
6787  }
6788
6789  const now = Date.now();
6790  const trimSaison = getSaisonCourante();
6791  const trimNum = getTrimestreCourant();
6792  // En Palmyre, la fenêtre de calcul des perfs = toute l'édition (pas le trimestre).
6793  const [debut, fin] = getPeriodeDates(isPoleEnv() ? trimNum : 'saison');
6794
6795  // Calculer les données enrichies pour chaque joueur
6796  const rows = joueursActifs.map(j => {
6797    const stats = statsJoueur(j.id);
6798    const idxJoueur = getRankIndex(j.classement);
6799    const matchesJoueur = matchsDuJoueur(j.id);
6800    const dernierMatch = matchesJoueur.length > 0 ? matchesJoueur[0] : null;
6801
6802    // Perfs du trimestre courant
6803    const matchesPeriode = matchesJoueur.filter(m => TennisDomainRules.dateDansIntervalle(m.date, debut, fin));
6804    const perfsT = TennisDomainRules.statistiquesSportives(
6805      matchesPeriode,
6806      match => classementADate(j.id, match.date)
6807    ).perfs;
6808
6809    return {
6810      joueur: j,
6811      stats,
6812      classementIdx: idxJoueur,
6813      age: computeAge(j),
6814      sexe: j.sexe || '',
6815      groupe: j.groupe || '',
6816      groupeOrder: { tremplin: 1, accession: 2, elite: 3 }[j.groupe] || 99,
6817      perfsT,
6818      dernierMatch,
6819      dernierMatchMs: dernierMatch ? new Date(dernierMatch.date).getTime() : 0
6820    };
6821  });
6822
6823  // Tri dynamique selon overviewSort
6824  rows.sort((a, b) => {
6825    let va, vb;
6826    switch (overviewSort.col) {
6827      case 'nom':
6828        va = (a.joueur.nom + ' ' + a.joueur.prenom).toLowerCase();
6829        vb = (b.joueur.nom + ' ' + b.joueur.prenom).toLowerCase();
6830        break;
6831      case 'sexe':
6832        va = a.sexe; vb = b.sexe;
6833        break;
6834      case 'age':
6835        va = a.age !== null ? a.age : 999;
6836        vb = b.age !== null ? b.age : 999;
6837        break;
6838      case 'classement':
6839        va = a.classementIdx; vb = b.classementIdx;
6840        break;
6841      case 'groupe':
6842        va = a.groupeOrder; vb = b.groupeOrder;
6843        break;
6844      case 'matches':
6845        va = a.stats.total; vb = b.stats.total;
6846        break;
6847      case 'taux':
6848        va = a.stats.tauxVictoire; vb = b.stats.tauxVictoire;
6849        break;
6850      case 'perfs':
6851        va = a.perfsT; vb = b.perfsT;
6852        break;
6853      case 'dernier':
6854        va = a.dernierMatchMs; vb = b.dernierMatchMs;
6855        break;
6856      default:
6857        va = a.classementIdx; vb = b.classementIdx;
6858    }
6859    if (typeof va === 'string') {
6860      const cmp = va.localeCompare(vb);
6861      return overviewSort.dir === 'asc' ? cmp : -cmp;
6862    }
6863    return overviewSort.dir === 'asc' ? va - vb : vb - va;
6864  });
6865
6866  // Helper pour rendre un en-tête de colonne triable
6867  const sortHeader = (col, label, width) => {
6868    const isActive = overviewSort.col === col;
6869    const arrow = isActive ? (overviewSort.dir === 'asc' ? ' ↑' : ' ↓') : '';
6870    const style = `text-align:center; padding:8px; font-weight:600; color:${isActive ? 'var(--orange)' : 'var(--text-dim)'}; ${width ? 'width:' + width + ';' : ''} cursor:pointer; user-select:none`;
6871    return `<th style="${style}" data-ui-click="setOverviewSort(${escapeHtml(encoderArgumentTexteUI(col))})" title="Cliquer pour trier">${escapeHtml(label)}${arrow}</th>`;
6872  };
6873
6874  let html = entete + `<div class="text-faint mb-2" style="font-size:12px">
6875    <span class="sort-hint-mark"></span>Clique sur un en-tête de colonne pour trier le tableau. Re-cliquer inverse l'ordre.
6876  </div>`;
6877
6878  html += `<div class="card" style="padding:0; overflow-x:auto">
6879    <table class="overview-table" style="width:100%; border-collapse:collapse; font-size:12px; min-width:900px">
6880      <thead style="background:var(--bg-section); position:sticky; top:0">
6881        <tr>
6882          ${sortHeader('nom', 'Nom').replace('text-align:center', 'text-align:left')}
6883          ${sortHeader('sexe', 'Sexe', '50px')}
6884          ${sortHeader('age', 'Âge', '50px')}
6885          ${sortHeader('classement', 'Classt', '70px')}
6886          ${sortHeader('groupe', 'Groupe', '110px')}
6887          ${sortHeader('matches', 'Matches', '90px')}
6888          ${sortHeader('taux', '% V', '60px')}
6889          ${sortHeader('perfs', isPoleEnv() ? 'Perfs T' : 'Perfs', '70px')}
6890          ${sortHeader('dernier', 'Dernier match', '110px')}
6891        </tr>
6892      </thead>
6893      <tbody>`;
6894
6895  rows.forEach(r => {
6896    const j = r.joueur;
6897    const groupKey = (j.groupe && cache.config[j.groupe] && typeof cache.config[j.groupe] === 'object') ? j.groupe : null;
6898    const g = groupKey ? cache.config[groupKey] : null;
6899    const groupBadge = groupKey
6900      ? `<span class="badge badge-${groupKey}" style="font-size:10px">${escapeHtml(g.nom)}</span>`
6901      : `<span class="text-faint">—</span>`;
6902    const sexeIcon = r.sexe === 'F'
6903      ? '<span class="gender-art"><img src="assets/v4/gender/female.webp" alt="Femme"><b>F</b></span>'
6904      : r.sexe === 'M' ? '<span class="gender-art"><img src="assets/v4/gender/male.webp" alt="Homme"><b>M</b></span>' : '—';
6905    const dernierMatchStr = r.dernierMatch ? formatDate(r.dernierMatch.date) : '—';
6906    const dernierMatchCls = r.dernierMatch
6907      ? ((now - r.dernierMatchMs) > 45*24*3600*1000 ? 'text-faint' : '')
6908      : 'text-faint';
6909    // Objectif cible : en Palmyre, c'est l'objectif de l'édition active (pas celui du groupe).
6910    const objCible = !isPoleEnv() ? palmyreGroupConfig().objectifMatches : (g ? g.objectifMatches : 0);
6911    const objMatches = objCible > 0 ? `/${objCible}` : '';
6912    const matchesCls = objCible > 0
6913      ? (r.stats.total >= objCible * 0.8 ? 'success' : r.stats.total >= objCible * 0.5 ? '' : 'danger')
6914      : '';
6915    const tauxCls = r.stats.tauxVictoire >= 55 ? 'success' : '';
6916
6917    html += `<tr class="overview-row" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(j.id
6917))})" style="cursor:pointer; border-top:1px solid var(--border)">
6918      <td style="padding:8px"><strong>${escapeHtml(j.prenom)}</strong> ${escapeHtml(j.nom)}</td>
6919      <td style="text-align:center; padding:8px">${sexeIcon}</td>
6920      <td style="text-align:center; padding:8px">${r.age !== null ? r.age : '—'}</td>
6921      <td style="text-align:center; padding:8px"><strong class="orange">${escapeHtml(j.classement || 'NC')}</strong></td>
6922      <td style="text-align:center; padding:8px">${groupBadge}</td>
6923      <td style="text-align:center; padding:8px" class="${matchesCls}">${r.stats.total}${objMatches}</td>
6924      <td style="text-align:center; padding:8px" class="${tauxCls}"><strong>${r.stats.tauxVictoire}%</strong></td>
6925      <td style="text-align:center; padding:8px">${r.perfsT > 0 ? `<strong style="display:inline-flex;align-items:center;justify-content:center;gap:3px;color:var(--success-text)">${progressArtHtml('perfs',{size:22})}${r.perfsT}</strong>` : '<span class="text-faint">0</span>'}</td>
6926      <td style="text-align:center; padding:8px" class="${dernierMatchCls}">${dernierMatchStr}</td>
6927    </tr>`;
6928  });
6929
6930  html += `</tbody></table></div>`;
6931
6932  html += `<div class="text-faint mt-2" style="font-size:11px; text-align:center">
6933    ${rows.length} joueur${rows.length > 1 ? 's' : ''} actif${rows.length > 1 ? 's' : ''} · Perfs ${isPoleEnv() ? 'T' : ''} = victoires sur mieux classés ${isPoleEnv() ? `ce trimestre (${trimNum} ${trimSaison})` : 'pendant le stage'}
6934  </div>`;
6935
6936  return html;
6937}
6938
6939// ============================================================
6940// BILANS PALMYRE (bilan de fin de semaine d'une édition)
6941// ============================================================
6942
6943// Stats d'un joueur sur l'édition Palmyre active (matchs, V, D, perfs).
6944// ============================================================
6945// PLANNING & LOGISTIQUE PALMYRE
6946// ============================================================
6947
6948// Liste ordonnée des jours de l'édition (du date_debut au date_fin).
6949function joursEdition(ev) {
6950  if (!ev || !ev.date_debut || !ev.date_fin) return [];
6951  const jours = [];
6952  const d = new Date(ev.date_debut + 'T00:00:00');
6953  const fin = new Date(ev.date_fin + 'T00:00:00');
6954  let guard = 0;
6955  while (d <= fin && guard < 60) {
6956    // dateLocaleISO : formatage en heure locale (évite le décalage d'un jour
6957    // que provoque toISOString(), qui convertit en UTC).
6958    jours.push(dateLocaleISO(d));
6959    d.setDate(d.getDate() + 1);
6960    guard++;
6961  }
6962  return jours;
6963}
6964const JOURS_SEMAINE = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
6965function libelleJourCourt(dateStr) {
6966  const d = new Date(dateStr + 'T00:00:00');
6967  return JOURS_SEMAINE[d.getDay()] + ' ' + String(d.getDate()).padStart(2, '0') + '/' + String(d.getMonth() + 1).padStart(2, '0');
6968}
6969function creneauxDuJoueurJour(joueurId, jour) {
6970  const ev = getActiveEvenement();
6971  if (!ev) return [];
6972  return (listeSure(cache.creneauxPalmyre))
6973    .filter(c => c.joueur_id === joueurId && c.jour === jour && c.evenement_id === ev.id)
6974    .sort((a, b) => (a.heure || '').localeCompare(b.heure || ''));
6975}
6976
6977// ============================================================
6978// PRÉSENCE AUX COURS (académie) — étape 2a : gestion du planning type
6979// ============================================================
6980const JOURS_SEMAINE_COURS = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
6981
6982// Formate une Date en YYYY-MM-DD en heure LOCALE (évite le décalage de fuseau
6983// horaire que provoque toISOString(), qui convertit en UTC et peut reculer d'un jour).
6984function dateLocaleISO(d) {
6985  return TennisFormatters.dateLocaleISO(d);
6986}
6987
6988// Lundi de la semaine en cours (format YYYY-MM-DD). Semaine lundi→dimanche.
6989function getLundiSemaine(ref) {
6990  const d = ref ? new Date(ref) : new Date();
6991  d.setHours(0, 0, 0, 0);
6992  const jour = d.getDay(); // 0=dim..6=sam
6993  const decalage = (jour === 0) ? -6 : (1 - jour); // ramener au lundi
6994  d.setDate(d.getDate() + decalage);
6995  return dateLocaleISO(d);
6996}
6997
6998// Dimanche de la semaine en cours (borne fin de semaine calendaire).
6999function getVendrediSemaine(ref) {
7000  const lundi = new Date(getLundiSemaine(ref));
7001  lundi.setDate(lundi.getDate() + 4);
7002  return dateLocaleISO(lundi);
7003}
7004
7005function finLocaleSeanceCours(seance, creneau) {
7006  if (!seance?.date) return null;
7007  const heureFin = /^\d{2}:\d{2}/.test(creneau?.heure_fin || '')
7008    ? String(creneau.heure_fin).slice(0, 5)
7009    : '23:59';
7010  const fin = new Date(`${seance.date}T${heureFin}:00`);
7011  return Number.isNaN(fin.getTime()) ? null : fin;
7012}
7013
7014function seanceCoursEstTerminee(seance, creneau, reference = new Date()) {
7015  const fin = finLocaleSeanceCours(seance, creneau);
7016  return fin ? fin.getTime() <= reference.getTime() : seance?.date < dateLocaleISO(reference);
7017}
7018
7019function joueurPeutRegulariserPresence(seance, reference = new Date()) {
7020  if (!seance?.date) return false;
7021  const lundiSuivant = new Date(`${getLundiSemaine(`${seance.date}T12:00:00`)}T00:00:00`);
7022  lundiSuivant.setDate(lundiSuivant.getDate() + 7);
7023  return reference.getTime() < lundiSuivant.getTime();
7024}
7025
7026// Séances de la semaine en cours (lundi→vendredi, les cours n'ont pas lieu le week-end).
7027function seancesDeLaSemaine() {
7028  const lundi = getLundiSemaine();
7029  const vendredi = getVendrediSemaine();
7030  return (listeSure(cache.seancesCours))
7031    .filter(s => TennisDomainRules.dateDansIntervalle(s.date, lundi, vendredi))
7032    .map(s => {
7033      const cr = creneauCoursById(s.creneau_id);
7034      return { ...s, creneau: cr };
7035    })
7036    .filter(s => s.creneau) // ignore les séances dont le créneau a été supprimé
7037    .sort((a, b) => (a.date.localeCompare(b.date)) || ((a.creneau.heure_debut || '').localeCompare(b.creneau.heure_debut || '')));
7038}
7039
7040function presencesDeSeance(seanceId) {
7041  return presencesBrutesDeSeance(seanceId)
7042    .map(p => ({ ...p, joueur: joueurById(p.joueur_id) }))
7043    .filter(p => p.joueur)
7044    .sort((a, b) => getRankIndex(b.joueur.classement) - getRankIndex(a.joueur.classement));
7045}
7046
7047const PRESENCE_STATUTS = {
7048  present:        { label: 'Présent', court: 'P', icone: '✅', cls: 'success' },
7049  absent:         { label: 'Absent', court: 'A', icone: '❌', cls: 'danger' },
7050  retard:         { label: 'Retard', court: 'R', icone: '⏱️', cls: 'warning' },
7051  non_renseigne:  { label: 'Non renseigné', court: '—', icone: '⬜', cls: 'text-faint' }
7052};
7053
7054function joueursDuCreneau(creneauId) {
7055  return idsJoueursDuCreneau(creneauId)
7056    .map(joueurById)
7057    .filter(Boolean)
7058    .sort((a, b) => getRankIndex(b.classement) - getRankIndex(a.classement));
7059}
7060
7061function renderPresenceCours() {
7062  if (!isCoach()) return '';
7063  const creneaux = (listeSure(cache.creneauxCours));
7064  const seancesActivesCount = creneaux.filter(c => c.actif).length;
7065
7066  let html = renderUxScreenHeading('Suivi des cours', 'Présences') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)">
7067    <div style="font-weight:700; font-size:15px">✅ Présence aux cours</div>
7068    <div class="text-faint" style="font-size:12px; margin-top:2px">Fais l'appel sur les séances de la semaine. Les créneaux se gèrent dans l'onglet « Planning ».</div>
7069  </div>`;
7070  html += `<div class="mb-2"><button class="btn btn-full" data-ui-click="openModalExportPresences()"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">Exporter les présences</button></div>`;
7071
7072  const lundi = getLundiSemaine();
7073  const vendredi = getVendrediSemaine();
7074  const lundiSuivant = getLundiSemaine(Date.now() + 6048e5);
7075  const vendrediSuivant = getVendrediSemaine(Date.now() + 6048e5);
7076  const toutesLesSeances = (listeSure(cache.seancesCours))
7077    .filter(s => TennisDomainRules.dateDansIntervalle(s.date, lundi, vendrediSuivant))
7078    .map(s => ({ ...s, creneau: creneauCoursById(s.creneau_id) }))
7079    .filter(s => s.creneau)
7080    .sort((a, b) => (a.date.localeCompare(b.date)) || ((a.creneau.heure_debut || '').localeCompare(b.creneau.heure_debut || '')));
7081  const periodes = [
7082    { titre: 'Cette semaine', debut: lundi, fin: vendredi },
7083    { titre: 'Semaine à venir', debut: lundiSuivant, fin: vendrediSuivant }
7084  ];
7085  if (new Date().getDay() === 0) periodes.reverse();
7086  const todayStr = dateLocaleISO(new Date());
7087  periodes.forEach(periode => {
7088    const seances = toutesLesSeances.filter(s => TennisDomainRules.dateDansIntervalle(s.date, periode.debut, periode.fin));
7089    html += `<div class="section-title">${periode.titre} <span class="text-faint" style="font-weight:400; text-transform:none; letter-spacing:0; font-size:12px">— du ${formatDate(periode.debut)} au ${formatDate(periode.fin)}</span></div>`;
7090    if (seances.length === 0) {
7091      html += `<div class="card text-faint" style="text-align:center; padding:18px; font-size:13px">Aucune séance sur cette période. ${seancesActivesCount === 0 ? 'Crée un créneau actif dans l\'onglet « Planning ».' : 'Les séances se génèrent automatiquement.'}</div>`;
7092      return;
7093    }
7094    seances.forEach(s => {
7095      const presences = presencesDeSeance(s.id);
7096      const statsPresence = TennisDomainRules.statistiquesPresences(presences);
7097      const complet = statsPresence.renseignes === statsPresence.total && statsPresence.total > 0;
7098      let etatBadge;
7099      if (statsPresence.total === 0) etatBadge = `<span class="text-faint" style="font-size:11px">aucun joueur</span>`;
7100      else if (complet) etatBadge = `<span class="badge badge-success">Appel complet · ${statsPresence.presents}/${statsPresence.total} présents</span>`;
7101      else if (statsPresence.renseignes > 0) etatBadge = `<span class="badge badge-warning">${statsPresence.renseignes}/${statsPresence.total} renseignés</span>`;
7102      else etatBadge = `<span class="badge badge-info">Appel à faire</span>`;
7103
7104      html += `<div class="card" style="padding:12px 14px; margin-bottom:8px; cursor:pointer" data-ui-click="openModalAppel(${escapeHtml(encoderArgumentTexteUI(s.id))})">
7105        <div style="display:flex; justify-content:space-between; align-items:center; gap:10px">
7106          <div style="min-width:0">
7107            <div style="font-weight:600; font-size:14px">${escapeHtml(s.creneau.nom)} ${s.date === todayStr ? '<span class="badge badge-info" style="font-size:10px">ce jour</span>' : ''}</div>
7108            <div class="text-faint" style="font-size:12px; margin-top:2px">${libelleJourCourt(s.date)} · 🕐 ${escapeHtml(s.creneau.heure_debut)}–${escapeHtml(s.creneau.heure_fin)}</div>
7109            <div style="margin-top:6px">${etatBadge}</div>
7110          </div>
7111          <span class="text-faint" style="font-size:18px; flex-shrink:0">›</span>
7112        </div>
7113      </div>`;
7114    });
7115  });
7116  return html;
7117}
7118
7119function renderPlanningCours() {
7120  if (!isCoach()) return '';
7121  const creneaux = (listeSure(cache.creneauxCours)).filter(c => c.actif).slice()
7122    .sort((a, b) => (a.jour_semaine - b.jour_semaine) || (a.heure_debut || '').localeCompare(b.heure_debut || ''));
7123
7124  let html = renderUxScreenHeading('Organisation', 'Planning de la semaine') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)">
7125    <div style="font-weight:700; font-size:15px">📆 Planning de la semaine</div>
7126    <div class="text-faint" style="font-size:12px; margin-top:2px">La grille type des cours de l'académie, du lundi au vendredi. Touche un créneau pour le modifier ou gérer ses joueurs.</div>
7127  </div>`;
7128  html += `<div class="mb-2" style="display:flex; gap:8px"><button class="btn btn-primary" style="flex:1" data-ui-click="openModalCreneauCours()">+ Ajouter un créneau</button><button class="btn" data-ui-click="exportPlanningPDF()"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">PDF</button></div>`;
7129
7130  if (creneaux.length === 0) {
7131    html += `<div class="card text-faint" style="text-align:center; padding:24px; font-size:13px">Aucun créneau pour le moment. Crée ton premier créneau de cours (jour, horaire, joueurs).</div>`;
7132    return html;
7133  }
7134
7135  const joursGrille = [1, 2, 3, 4, 5];
7136  html += renderGrilleCreneaux(creneaux, joursGrille);
7137
7138  return html;
7139}
7140
7141function heureEnMinutes(hhmm) {
7142  const [h, m] = (hhmm || '0:0').split(':').map(n => parseInt(n) || 0);
7143  return h * 60 + m;
7144}
7145
7146function renderGrilleCreneaux(creneaux, joursGrille) {
7147  const parJour = {};
7148  joursGrille.forEach(js => { parJour[js] = []; });
7149  creneaux.forEach(c => { if (parJour[c.jour_semaine]) parJour[c.jour_semaine].push(c); });
7150
7151  let minMin = Infinity, maxMin = -Infinity;
7152  creneaux.forEach(c => {
7153    minMin = Math.min(minMin, heureEnMinutes(c.heure_debut));
7154    maxMin = Math.max(maxMin, heureEnMinutes(c.heure_fin));
7155  });
7156  if (!isFinite(minMin)) { minMin = 9 * 60; maxMin = 22 * 60; }
7157  const debutGrille = Math.floor(minMin / 60) * 60;
7158  const finGrille = Math.ceil(maxMin / 60) * 60;
7159  const PX_PAR_MIN = 1.1;
7160  const hauteurTotale = (finGrille - debutGrille) * PX_PAR_MIN;
7161
7162  let axe = `<div class="planning-axe" style="height:${hauteurTotale}px">`;
7163  for (let m = debutGrille; m <= finGrille; m += 60) {
7164    const top = (m - debutGrille) * PX_PAR_MIN;
7165    const h = Math.floor(m / 60);
7166    axe += `<div class="planning-heure" style="top:${top}px">${String(h).padStart(2, '0')}h</div>`;
7167  }
7168  axe += `</div>`;
7169
7170  let cols = '';
7171  joursGrille.forEach(js => {
7172    let creneauxHtml = '';
7173    for (let m = debutGrille; m <= finGrille; m += 60) {
7174      const top = (m - debutGrille) * PX_PAR_MIN;
7175      creneauxHtml += `<div class="planning-ligne" style="top:${top}px"></div>`;
7176    }
7177    const duJour = parJour[js].slice().sort((a, b) => {
7178      const da = heureEnMinutes(a.heure_debut), dbb = heureEnMinutes(b.heure_debut);
7179      if (da !== dbb) return da - dbb;
7180      return heureEnMinutes(a.heure_fin) - heureEnMinutes(b.heure_fin);
7181    });
7182    // Attribution des lanes
7183    const laneFin = [];        // minute de fin du dernier créneau de chaque lane
7184    const laneDe = new Map();  // id créneau -> index de lane
7185    duJour.forEach(c => {
7186      const deb = heureEnMinutes(c.heure_debut);
7187      const fin = heureEnMinutes(c.heure_fin);
7188      let lane = 0;
7189      while (lane < laneFin.length && laneFin[lane] > deb) lane++;
7190      laneDe.set(c.id, lane);
7191      laneFin[lane] = fin;
7192    });
7193    // Calcul du nombre de lanes de CHAQUE cluster (créneaux connectés par chevauchement)
7194    const nbLanesPour = new Map();
7195    duJour.forEach(c => {
7196      const deb = heureEnMinutes(c.heure_debut);
7197      const fin = heureEnMinutes(c.heure_fin);
7198      // lanes occupées par les créneaux qui chevauchent c (c compris)
7199      const lanesCluster = new Set();
7200      duJour.forEach(o => {
7201        const od = heureEnMinutes(o.heure_debut), of = heureEnMinutes(o.heure_fin);
7202        if (od < fin && of > deb) lanesCluster.add(laneDe.get(o.id)); // chevauchement strict
7203      });
7204      nbLanesPour.set(c.id, Math.max(1, lanesCluster.size));
7205    });
7206
7207    duJour.forEach(c => {
7208      const debut = heureEnMinutes(c.heure_debut);
7209      const fin = heureEnMinutes(c.heure_fin);
7210      const top = (debut - debutGrille) * PX_PAR_MIN;
7211      const hauteur = Math.max(28, (fin - debut) * PX_PAR_MIN - 3);
7212      const nbJoueurs = joueursDuCreneau(c.id).length;
7213      const nbLanes = nbLanesPour.get(c.id) || 1;
7214      const lane = laneDe.get(c.id) || 0;
7215      const widthPct = 100 / nbLanes;
7216      const leftPct = widthPct * lane;
7217      const coachLigne = c.coach ? ` · 👤 ${escapeHtml(c.coach)}` : '';
7218      creneauxHtml += `<div class="planning-creneau ${c.actif ? '' : 'inactif'}" style="top:${top}px; height:${hauteur}px; left:calc(${leftPct}% + 2px); width:calc(${widthPct}% - 4px); right:auto" data-ui-click="openModalCreneauCours(${escapeHtml(encoderArgumentTexteUI(c.id))})" title="${escapeHtml(c.nom)}${c.coach ? ' · ' + escapeHtml(c.coach) : ''} · ${escapeHtml(c.heure_debut)}–${escapeHtml(c.heure_fin)}">
7219          <div class="planning-creneau-nom">${escapeHtml(c.nom)}</div>
7220          <div class="planning-creneau-h">${escapeHtml(c.heure_debut)}–${escapeHtml(c.heure_fin)} · 👥${nbJoueurs}${coachLigne}${c.actif ? '' : ' · inactif'}</div>
7221        </div>`;
7222    });
7223    cols += `<div class="planning-col">
7224        <div class="planning-col-head">${JOURS_SEMAINE_COURS[js]}</div>
7225        <div class="planning-col-body" style="height:${hauteurTotale}px">${creneauxHtml}</div>
7226      </div>`;
7227  });
7228
7229  return `<div class="planning-grille-wrap">
7230      <div class="planning-axe-col">
7231        <div class="planning-col-head" style="visibility:hidden">.</div>
7232        ${axe}
7233      </div>
7234      <div class="planning-grille">${cols}</div>
7235    </div>`;
7236}
7237
7238// Modal d'appel : statuer la présence de chaque joueur d'une séance.
7239function openModalAppel(seanceId) {
7240  const seance = seanceCoursById(seanceId);
7241  if (!seance) return;
7242  const creneau = creneauCoursById(seance.creneau_id);
7243  const presences = presencesDeSeance(seanceId);
7244
7245  let lignes = presences.map(p => {
7246    const boutons = ['present', 'absent', 'retard'].map(st => {
7247      const meta = PRESENCE_STATUTS[st];
7248      const actif = p.statut === st;
7249      // Clic sur le statut déjà actif → on le réinitialise (non_renseigne).
7250      const cible = actif ? 'non_renseigne' : st;
7251      return `<button class="presence-btn ${meta.cls} ${actif ? 'actif' : ''}" data-ui-click="setPresence(${escapeHtml(encoderArgumentTexteUI(seanceId))},${escapeHtml(encoderArgumentTexteUI(p.joueur_id))},${escapeHtml(encoderArgumentTexteUI(cible))})" title="${actif ? 'Annuler' : meta.label}" aria-label="${actif ? `Annuler ${meta.label.toLowerCase()}` : meta.label}" aria-pressed="${actif}">${meta.icone}</button>`;
7252    }).join('');
7253    const declParJoueur = p.declare_par === 'joueur' && p.statut !== 'non_renseigne'
7254      ? `<span class="text-faint" style="font-size:10px"> · déclaré par le joueur</span>` : '';
7255    const justificationRetard = p.statut === 'retard' && p.justification
7256      ? `<span class="presence-delay-reason">${escapeHtml(p.justification)}</span>` : '';
7257    return `<div style="display:flex; justify-content:space-between; align-items:center; gap:8px; padding:8px 0; border-bottom:1px solid var(--border)">
7258        <span style="min-width:0; font-size:13px"><span style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><strong>${escapeHtml(p.joueur.prenom)}</strong> ${escapeHtml(p.joueur.nom)}${declParJoueur}</span>${justificationRetard}</span>
7259        <span style="display:flex; gap:4px; flex-shrink:0">${boutons}</span>
7260      </div>`;
7261  }).join('');
7262
7263  if (presences.length === 0) {
7264    lignes = `<div class="text-faint" style="padding:12px; text-align:center; font-size:13px">Aucun joueur attendu sur cette séance.</div>`;
7265  }
7266
7267  const html = `<div class="modal-header"><h3 class="modal-title">Appel · ${escapeHtml(creneau ? creneau.nom : '')}</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
7268    <div class="modal-body">
7269      <div class="text-faint mb-2" style="font-size:12px">${libelleJourCourt(seance.date)}${creneau ? ` · 🕐 ${escapeHtml(creneau.heure_debut)}–${escapeHtml(creneau.heure_fin)}` : ''}</div>
7270      <div class="presence-legend">
7271        <span class="success">✅ Présent</span><span class="danger">❌ Absent</span><span class="warning">⏱️ Retard</span>
7272      </div>
7273      <div class="card" style="padding:4px 12px; max-height:55vh; overflow-y:auto">${lignes}</div>
7274      <div class="mt-2"><button class="btn btn-full" data-ui-click="closeModal()">Terminer</button></div>
7275    </div>`;
7276  showModal(html);
7277}
7278
7279// Enregistre/modifie la présence d'un joueur (côté coach = fait foi).
7280async function setPresence(seanceId, joueurId, statut) {
7281  const existante = presenceDeSeancePourJoueur(seanceId, joueurId);
7282  const obj = { statut, declare_par: 'coach' };
7283  if (existante) {
7284    const { data, error } = await updateConfirmee(db.from('presences').update(obj).eq('id', existante.id), 'id,updated_at');
7285    if (error) { toastErreur(error); return; }
7286    // Mise à jour locale ciblée (pas de rechargement global)
7287    existante.statut = statut;
7288    existante.declare_par = 'coach';
7289    existante.updated_at = data[0]?.updated_at || existante.updated_at;
7290  } else {
7291    const res = await db.from('presences').insert({ seance_id: seanceId, joueur_id: joueurId, ...obj }).select(COLONNES_PRESENCE).single();
7292    if (res.error) { toastErreur(res.error); return; }
7293    // Ajout local de la nouvelle présence
7294    if (res.data) (cache.presences = listeSure(cache.presences)).push(res.data);
7295  }
7296  // Ré-affichage : on rafraîchit l'écran sous-jacent (badges d'état des séances)
7297  // puis on ré-ouvre le modal d'appel par-dessus, le tout depuis le cache local.
7298  render();
7299  openModalAppel(seanceId);
7300}
7301
7302// ============================================================
7303// CÔTÉ JOUEUR : « Mes cours » — déclaration de présence en amont
7304// ============================================================
7305function renderMesCours() {
7306  const joueurId = profile && profile.joueur_id;
7307  if (!joueurId) return '';
7308
7309  const aujourdHui = dateLocaleISO(new Date());
7310  const historiquePresence = (listeSure(cache.presences)).filter(presence => {
7311    if (presence.joueur_id !== joueurId) return false;
7312    const seance = seanceCoursById(presence.seance_id);
7313    return !!seance && seance.date <= aujourdHui;
7314  });
7315  const bilanPresence = TennisDomainRules.statistiquesPresences(historiquePresence);
7316
7317  let html = renderUxScreenHeading('Mon académie', 'Mes présences') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)">
7318    <div style="font-weight:700; font-size:15px">Mes présences</div>
7319    <div class="text-faint" style="font-size:12px; margin-top:2px">Ton créneau Académie reste régulier : indique simplement si tu seras présent, absent ou en retard cette semaine.</div>
7320  </div>
7321  <div class="ux-presence-summary" aria-label="Bilan de présence de la saison">
7322    <div class="ux-presence-stat present"><strong>${bilanPresence.present}</strong><span>Présences</span></div>
7323    <div class="ux-presence-stat absent"><strong>${bilanPresence.absent}</strong><span>Absences</span></div>
7324    <div class="ux-presence-stat retard"><strong>${bilanPresence.retard}</strong><span>Retards</span></div>
7325  </div>
7326  <div class="ux-presence-note">Saison en cours · un retard reste compté comme une présence effective dans le taux d’assiduité.</div>`;
7327
7328  const lundi = getLundiSemaine();
7329  const vendredi = getVendrediSemaine();
7330  const finAffichage=new Date().getDay()?vendredi:getLundiSemaine(Date.now()+864e5);
7331  const maintenant = new Date();
7332  const mesSeances = (listeSure(cache.seancesCours))
7333    .filter(s => TennisDomainRules.dateDansIntervalle(s.date, lundi, finAffichage))
7334    .map(s => {
7335      const cr = creneauCoursById(s.creneau_id);
7336      const presence = presenceDeSeancePourJoueur(s.id, joueurId);
7337      return { ...s, creneau: cr, presence };
7338    })
7339    .filter(s => s.creneau && s.presence)
7340    .filter(s => !seanceCoursEstTerminee(s, s.creneau, maintenant)
7341      || (s.presence.statut === 'non_renseigne' && joueurPeutRegulariserPresence(s, maintenant)))
7342    .sort((a, b) => (a.date.localeCompare(b.date)) || ((a.creneau.heure_debut || '').localeCompare(b.creneau.heure_debut || '')));
7343
7344  html += `<div class="section-title">Mes cours <span class="text-faint" style="font-weight:400; text-transform:none; letter-spacing:0; font-size:12px">— du ${formatDate(lundi)} au ${formatDate(finAffichage)}</span></div>`;
7345
7346  if (mesSeances.length === 0) {
7347    html += `<div class="card text-faint" style="text-align:center; padding:18px; font-size:13px">Aucun cours prévu pour toi cette semaine.</div>`;
7348    return html;
7349  }
7350
7351  const todayStr = aujourdHui;
7352  mesSeances.forEach(s => {
7353    const st = s.presence.statut;
7354    const estTerminee = seanceCoursEstTerminee(s, s.creneau, maintenant);
7355    const regularisationOuverte = joueurPeutRegulariserPresence(s, maintenant);
7356    const boutons = ['present', 'absent', 'retard'].map(code => {
7357      const meta = PRESENCE_STATUTS[code];
7358      const actif = st === code;
7359      const cible = actif ? 'non_renseigne' : code;
7360      return `<button class="presence-btn ${meta.cls} ${actif ? 'actif' : ''}" ${regularisationOuverte ? '' : 'disabled style="opacity:0.3; cursor:default"'} data-ui-click="setPresenceJoueur(${escapeHtml(encoderArgumentTexteUI(s.id))},${escapeHtml(encoderArgumentTexteUI(cible))})" title="${actif ? 'Annuler' : meta.label}" aria-label="${actif ? `Annuler ${meta.label.toLowerCase()}` : meta.label}" aria-pressed="${actif}">${meta.icone}</button>`;
7361    }).join('');
7362    // Libellé du statut courant
7363    let statutLabel;
7364    if (st === 'non_renseigne') statutLabel = estTerminee
7365      ? `<span style="color:var(--orange-text); font-size:11px; font-weight:700">À régulariser avant dimanche soir</span>`
7366      : `<span class="text-faint" style="font-size:11px">À renseigner</span>`;
7367    else {
7368      const meta = PRESENCE_STATUTS[st];
7369      const parQui = s.presence.declare_par === 'coach' ? ' (validé coach)' : '';
7370      statutLabel = `<span class="${meta.cls}" style="font-size:11px; font-weight:600">${meta.icone} ${meta.label}${parQui}</span>`;
7371    }
7372
7373    html += `<div class="card" style="padding:12px 14px; margin-bottom:8px">
7374        <div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap">
7375          <div style="min-width:0">
7376            <div style="font-weight:600; font-size:14px">${escapeHtml(s.creneau.nom)} ${s.date === todayStr ? '<span class="badge badge-info" style="font-size:10px">ce jour</span>' : ''}</div>
7377            <div class="text-faint" style="font-size:12px; margin-top:2px">${libelleJourCourt(s.date)} · 🕐 ${escapeHtml(s.creneau.heure_debut)}–${escapeHtml(s.creneau.heure_fin)}</div>
7378            <div style="margin-top:4px">${statutLabel}</div>
7379          </div>
7380          <span style="display:flex; gap:4px; flex-shrink:0">${boutons}</span>
7381        </div>
7382      </div>`;
7383  });
7384
7385  html += `<div class="text-faint" style="font-size:11px; text-align:center; margin-top:4px">✅ Présent · ❌ Absent · ⏱️ Retard · un oubli reste régularisable jusqu’au dimanche soir</div>`;
7386  return html;
7387}
7388
7389// Le joueur déclare sa propre présence (vaut par défaut, le coach peut corriger).
7390function openRetardJustification(seanceId) {
7391  const html = `<div class="modal-header"><h3 class="modal-title">Signaler un retard</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
7392    <div class="modal-body">
7393      <div class="text-dim mb-2" style="font-size:13px">Indique brièvement la raison de ton retard pour prévenir ton coach.</div>
7394      <div class="form-row"><label class="form-label" for="retardJustification">Justification obligatoire</label><textarea id="retardJustification" maxlength="500" rows="4" placeholder="Ex. sortie tardive du collège, problème de transport…"></textarea></div>
7395      <button class="btn btn-primary btn-full" data-ui-click="savePresenceRetard(${escapeHtml(encoderArgumentTexteUI(seanceId))})">Confirmer mon retard</button>
7396    </div>`;
7397  showModal(html);
7398  setTimeout(() => document.getElementById('retardJustification')?.focus(), 50);
7399}
7400
7401async function savePresenceRetard(seanceId) {
7402  const justification = (document.getElementById('retardJustification')?.value || '').trim();
7403  if (!justification) { toast('La justification du retard est obligatoire', true); return; }
7404  await setPresenceJoueur(seanceId, 'retard', justification);
7405}
7406
7407async function setPresenceJoueur(seanceId, statut, justification = '') {
7408  const joueurId = profile && profile.joueur_id;
7409  if (!joueurId) return;
7410  if (statut === 'retard' && !String(justification || '').trim()) { openRetardJustification(seanceId); return; }
7411  const existante = presenceDeSeancePourJoueur(seanceId, joueurId);
7412  if (!existante) { toast('Séance introuvable', true); return; }
7413  const seance = seanceCoursById(seanceId);
7414  if (!seance || !joueurPeutRegulariserPresence(seance)) {
7415    toast('La période de régularisation est terminée : demande à ton coach.', true);
7416    return;
7417  }
7418
7419  const xpAvant = calculerXP(joueurId).xp;
7420  const etatAvant = { niveau: niveauDepuisXP(xpAvant).niveau, xp: xpAvant };
7421  const { data, error } = await updateConfirmee(db.from('presences')
7422    .update({ statut, declare_par: 'joueur', justification: statut === 'retard' ? String(justification).trim() : null })
7423    .eq('id', existante.id), 'id,declare_joueur_at,updated_at');
7424  if (error) { toastErreur(error); return; }
7425  existante.statut = statut; existante.declare_par = 'joueur';
7426  existante.justification = statut === 'retard' ? String(justification).trim() : null;
7427  existante.declare_joueur_at = data[0]?.declare_joueur_at || existante.declare_joueur_at;
7428  existante.updated_at = data[0]?.updated_at || existante.updated_at;
7429  if (statut === 'absent' || statut === 'retard') {
7430    await sendEmailEvent(statut === 'absent' ? 'presence_absence_coach' : 'presence_retard_coach', existante.id);
7431  }
7432  if (statut === 'retard') closeModal();
7433  const confirmations = {
7434    present: 'Présence enregistrée',
7435    absent: 'Absence enregistrée',
7436    retard: 'Retard et justification enregistrés',
7437    non_renseigne: 'Réponse annulée'
7438  };
7439  toast(confirmations[statut] || 'Réponse enregistrée');
7440  await synchroniserBadgesEtCelebrer(joueurId, etatAvant);
7441  render();
7442}
7443
7444// Génère (sans doublon) un rappel in-app pour les cours imminents non renseignés.
7445// Appelée à l'ouverture de l'app côté joueur. Pas de serveur : déclenché à l'usage.
7446async function genererRappelsPresenceJoueur() {
7447  const joueurId = profile && profile.joueur_id;
7448  if (!joueurId || !session) return;
7449  const todayStr = dateLocaleISO(new Date());
7450  const demain = new Date(); demain.setDate(demain.getDate() + 1);
7451  const demainStr = dateLocaleISO(demain);
7452
7453  // Séances aujourd'hui ou demain où je suis attendu avec un statut non renseigné
7454  const seancesImminentes = (listeSure(cache.seancesCours)).filter(s => s.date === todayStr || s.date === demainStr);
7455  const aCreer = [];
7456  seancesImminentes.forEach(s => {
7457    const presence = presenceDeSeancePourJoueur(s.id, joueurId);
7458    if (!presence || presence.statut !== 'non_renseigne') return; // déjà positionné → pas de rappel
7459    // Doublon ? une notif rappel existe déjà pour cette séance ?
7460    const dejaNotifiee = (listeSure(cache.notifications)).some(n => n.type === 'rappel_presence_cours' && n.lien_id === s.id);
7461    if (dejaNotifiee) return;
7462    const creneau = creneauCoursById(s.creneau_id);
7463    if (!creneau) return;
7464    const quand = s.date === todayStr ? 'ce jour' : 'demain';
7465    aCreer.push({
7466      user_id: session.user.id,
7467      type: 'rappel_presence_cours',
7468      titre: '📆 Cours à venir',
7469      message: `${creneau.nom} ${quand} à ${creneau.heure_debut} : indique si tu seras présent.`,
7470      lien_type: 'seance',
7471      lien_id: s.id
7472    });
7473  });
7474  if (aCreer.length === 0) return;
7475  const { error } = await db.from('notifications').insert(aCreer);
7476  if (error) { journal.warn('[rappel présence] insert:', error); return; }
7477  // Recharger les notifs pour afficher le badge immédiatement
7478  await loadNotifications();
7479}
7480
7481function activerRechercheJoueursCreneau() {
7482  const recherche = document.getElementById('rechercheJoueursCreneau');
7483  if (!recherche) return;
7484  recherche.addEventListener('input', () => {
7485    const terme = TennisDomainRules.normaliserTexteRecherche(recherche.value || '');
7486    document.querySelectorAll('.creneau-player-choice').forEach(ligne => {
7487      ligne.style.display = terme !== '' && !TennisDomainRules.normaliserTexteRecherche(ligne.dataset.recherche || '').includes(terme) ? 'none' : '';
7488    });
7489  });
7490}
7491
7492function rechercheJoueursCreneauHtml() {
7493  return `<input type="search" id="rechercheJoueursCreneau" autocomplete="off" placeholder="Rechercher par nom ou prénom…" style="margin-bottom:8px">`;
7494}
7495
7496async function openModalCreneauCours(creneauId) {
7497  const c = creneauId ? (listeSure(cache.creneauxCours)).find(x => x.id === creneauId) : null;
7498  const { data: profilsCoachs, error: coachsError } = await db.from('profiles').select('id,prenom,nom').eq('role', 'coach').order('prenom');
7499  const coachs = coachsError ? [profile] : listeSure(profilsCoachs);
7500  const coachCourant = c?.coach || `${profile?.prenom || ''} ${profile?.nom || ''}`.trim();
7501  const nomsCoachs = coachs.map(coach => `${coach.prenom || ''} ${coach.nom || ''}`.trim()).filter(Boolean);
7502  if (coachCourant && !nomsCoachs.includes(coachCourant)) nomsCoachs.push(coachCourant);
7503  const optionsCoachs = nomsCoachs.map(nom => `<option value="${escapeHtml(nom)}" ${nom === coachCourant ? 'selected' : ''}>${escapeHtml(nom)}</option>`).join('');
7504  const dejaIds = new Set(c ? (listeSure(cache.creneauxCoursJoueurs)).filter(a => a.creneau_id === c.id).map(a => a.joueur_id) : []);
7505  const joueursDisponibles = listeSure(cache.joueurs)
7506    .filter(TennisDomainRules.estJoueurActif)
7507    .sort((a, b) => `${a.nom || ''} ${a.prenom || ''}`.localeCompare(`${b.nom || ''} ${b.prenom || ''}`, 'fr'));
7508  const choixJoueurs = joueursDisponibles.map(j => `<label class="creneau-player-choice" data-recherche="${escapeHtml(`${j.prenom || ''} ${j.nom || ''}`)}">
7509      <input type="checkbox" class="creneau-joueur-cb" value="${escapeHtml(j.id)}" ${dejaIds.has(j.id) ? 'checked' : ''}>
7510      <span><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong><small>${escapeHtml(j.classement || 'NC')} · ${escapeHtml(estPreAcademie(j) ? 'Pré-Académie' : ((cache.config[j.groupe] || {}).nom || j.groupe || 'Académie'))}</small></span>
7511    </label>`).join('');
7512  const jourSel = c ? c.jour_semaine : 1;
7513  const optionsJours = [1, 2, 3, 4, 5].map(js =>
7514    `<option value="${js}" ${jourSel === js ? 'selected' : ''}>${JOURS_SEMAINE_COURS[js]}</option>`
7515  ).join('');
7516  const deleteBtn = c ? `<div class="mt-1"><button class="btn btn-danger btn-full" data-ui-click="deleteCreneauCours(${escapeHtml(encoderArgumentTexteUI(c.id))})">Supprimer ce créneau</button></div>` : '';
7517
7518  const html = `<div class="modal-header"><h3 class="modal-title">
7518${c ? 'Modifier' : 'Nouveau'} créneau</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
7519    <div class="modal-body">
7520      <div class="form-row"><label class="form-label">Nom du créneau</label><input type="text" id="creneauNom" value="${c ? escapeHtml(c.nom) : ''}" placeholder="Ex. : Élite jeunes, Accession lundi…"></div>
7521      <div class="form-row"><label class="form-label">Coach</label><select id="creneauCoach">${optionsCoachs}</select></div>
7522      <div class="form-row"><label class="form-label">Jour</label><select id="creneauJour">${optionsJours}</select></div>
7523      <div style="display:flex; gap:10px">
7524        <div class="form-row" style="flex:1"><label class="form-label">Début</label><input type="time" id="creneauDebut" value="${c ? c.heure_debut : '18:00'}"></div>
7525        <div class="form-row" style="flex:1"><label class="form-label">Fin</label><input type="time" id="creneauFin" value="${c ? c.heure_fin : '19:30'}"></div>
7526      </div>
7527      <div class="form-row"><label style="display:flex; align-items:center; gap:8px; font-size:13px; cursor:pointer"><input type="checkbox" id="creneauActif" ${!c || c.actif ? 'checked' : ''}> Créneau actif (génère des séances)</label></div>
7528      <div class="form-row"><div class="form-label">Joueurs affectés <span class="text-faint">(Académie et Pré-Académie · optionnel)</span></div>
7529        ${rechercheJoueursCreneauHtml()}
7530        <div class="creneau-player-picker">${choixJoueurs || '<div class="text-faint" style="padding:12px">Aucun joueur actif.</div>'}</div>
7531      </div>
7532    <div class="mt-2"><button class="btn btn-primary btn-full" data-ui-click="saveCreneauCours(${c ? escapeHtml(encoderArgumentTexteUI(c.id)) : 'null'})">${c ? 'Enregistrer' : 'Créer le créneau'}</button></div>
7533      ${deleteBtn}
7534    </div>`;
7535  showModal(html);
7536  activerRechercheJoueursCreneau();
7537}
7538
7539async function saveCreneauCours(creneauId) {
7540  const nom = (document.getElementById('creneauNom').value || '').trim();
7541  const jour = parseInt(document.getElementById('creneauJour').value);
7542  const debut = document.getElementById('creneauDebut').value;
7543  const fin = document.getElementById('creneauFin').value;
7544  const actif = document.getElementById('creneauActif').checked;
7545  const joueurIds = Array.from(document.querySelectorAll('.creneau-joueur-cb')).filter(cb => cb.checked).map(cb => cb.value);
7546  if (!nom) { toast('Donne un nom au créneau', true); return; }
7547  if (isNaN(jour)) { toast('Choisis un jour', true); return; }
7548  if (!debut || !fin) { toast('Indique les horaires', true); return; }
7549  if (fin <= debut) { toast('L\'heure de fin doit être après le début', true); return; }
7550
7551  const obj = { nom, jour_semaine: jour, heure_debut: debut, heure_fin: fin, actif };
7552  const coach = (document.getElementById('creneauCoach') || {}).value;
7553  obj.coach = coach ? coach.trim() : null;
7554  let error, nouvelId = creneauId;
7555  if (creneauId) {
7556    ({ error } = await updateConfirmee(db.from('creneaux_cours').update(obj).eq('id', creneauId)));
7557  } else {
7558    const res = await db.from('creneaux_cours').insert(obj).select('id').single();
7559    error = res.error;
7560    nouvelId = res.data ? res.data.id : null;
7561  }
7562  if (error) { toastErreur(error); return; }
7563  const { error: joueursError } = await db.rpc('coach_remplacer_joueurs_creneau', {
7564    p_creneau_id: nouvelId,
7565    p_joueur_ids: joueurIds
7566  });
7567  if (joueursError) {
7568    // En création, ne jamais laisser un créneau vide alors que le coach avait
7569    // demandé des joueurs dans le même formulaire.
7570    if (!creneauId && nouvelId) await db.from('creneaux_cours').delete().eq('id', nouvelId);
7571    toastErreur(joueursError);
7572    return;
7573  }
7574  const { error: generationError } = await db.rpc('generer_seances_semaine', { p_lundi: getLundiSemaine() });
7575  if (generationError) { toastErreur(generationError); return; }
7576  closeModal();
7577  toast(creneauId ? 'Créneau et joueurs modifiés' : 'Créneau et joueurs créés 🎾');
7578  await reloadTables(['creneaux_cours', 'creneaux_cours_joueurs', 'seances_cours', 'presences']);
7579  render();
7580}
7581
7582async function deleteCreneauCours(creneauId) {
7583  if (!(await demanderConfirmation('Retirer ce créneau du planning ? Son historique de présences sera conservé.'))) return;
7584  // Le cache des séances est borné à la saison analysée. On interroge donc la
7585  // base afin de ne pas tenter de supprimer un créneau lié à une saison archivée.
7586  const { data: seancesExistantes, error: verificationError } = await db.from('seances_cours')
7587    .select('id')
7588    .eq('creneau_id', creneauId)
7589    .limit(1);
7590  if (verificationError) { toastErreur(verificationError); return; }
7591  const possedeHistorique = listeSure(seancesExistantes).length > 0;
7592  const resultat = possedeHistorique
7593    ? await updateConfirmee(db.from('creneaux_cours').update({ actif: false }).eq('id', creneauId))
7594    : await db.from('creneaux_cours').delete().eq('id', creneauId);
7595  if (resultat.error) { toastErreur(resultat.error); return; }
7596  closeModal();
7597  toast(possedeHistorique ? 'Créneau retiré du planning · historique conservé' : 'Créneau supprimé');
7598  await reloadTables(['creneaux_cours', 'creneaux_cours_joueurs']);
7599  render();
7600}
7601
7602function openModalJoueursCreneau(creneauId) {
7603  const c = (listeSure(cache.creneauxCours)).find(x => x.id === creneauId);
7604  if (!c) return;
7605  const dejaIds = (listeSure(cache.creneauxCoursJoueurs))
7606    .filter(ccj => ccj.creneau_id === creneauId)
7607    .map(ccj => ccj.joueur_id);
7608  const joueursActifs = (listeSure(cache.joueurs))
7609    .filter(TennisDomainRules.estJoueurActif)
7610    .sort((a, b) => (a.nom || '').localeCompare(b.nom || ''));
7611
7612  const lignes = joueursActifs.map(j => {
7613    const checked = dejaIds.includes(j.id) ? 'checked' : '';
7614    return `<label class="creneau-player-choice" data-recherche="${escapeHtml(`${j.prenom || ''} ${j.nom || ''}`)}" style="display:flex; align-items:center; gap:10px; padding:8px 4px; border-bottom:1px solid var(--border); cursor:pointer; font-size:13px">
7615        <input type="checkbox" class="creneau-joueur-cb" value="${j.id}" ${checked}>
7616        <span style="flex:1"><strong>${escapeHtml(j.prenom)}</strong> ${escapeHtml(j.nom)} <span class="text-faint">· ${escapeHtml(j.classement || 'NC')} · ${escapeHtml(estPreAcademie(j) ? 'Pré-Académie' : ((cache.config[j.groupe] && cache.config[j.groupe].nom) || j.groupe || 'Académie'))}</span></span>
7617      </label>`;
7618  }).join('');
7619
7620  const html = `<div class="modal-header"><h3 class="modal-title">Joueurs · ${escapeHtml(c.nom)}</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
7621    <div class="modal-body">
7622      <div class="text-faint mb-2" style="font-size:12px">Coche les joueurs attendus à ce créneau, Académie et Pré-Académie confondues (${JOURS_SEMAINE_COURS[c.jour_semaine]} ${escapeHtml(c.heure_debut)}–${escapeHtml(c.heure_fin)}).</div>
7623      ${rechercheJoueursCreneauHtml()}
7624      <div class="card" style="padding:4px 12px; max-height:50vh; overflow-y:auto">${lignes || '<div class="text-faint" style="padding:12px">Aucun joueur actif.</div>'}</div>
7625      <div class="mt-2"><button class="btn btn-primary btn-full" data-ui-click="saveJoueursCreneau(${escapeHtml(encoderArgumentTexteUI(creneauId))})">Enregistrer la liste</button></div>
7626    </div>`;
7627  showModal(html);
7628  activerRechercheJoueursCreneau();
7629}
7630
7631async function saveJoueursCreneau(creneauId) {
7632  const checkboxes = document.querySelectorAll('.creneau-joueur-cb');
7633  const nouveauxIds = Array.from(checkboxes).filter(cb => cb.checked).map(cb => cb.value);
7634  const { error } = await db.rpc('coach_remplacer_joueurs_creneau', {
7635    p_creneau_id: creneauId,
7636    p_joueur_ids: nouveauxIds
7637  });
7638  if (error) { toastErreur(error); return; }
7639  const { error: generationError } = await db.rpc('generer_seances_semaine', { p_lundi: getLundiSemaine() });
7640  if (generationError) { toastErreur(generationError); return; }
7641  closeModal();
7642  toast('Liste des joueurs enregistrée');
7643  await reloadTables(['creneaux_cours_joueurs', 'seances_cours', 'presences']);
7644  render();
7645}
7646
7647// Vue Planning : grille participants × jours.
7648function renderPlanningPalmyre() {
7649  if (!isCoach()) return '';
7650  const ev = getActiveEvenement();
7651  if (!ev) return `<div class="empty"><span class="icon">📅</span>Aucune édition active.</div>`;
7652
7653  const participants = joueursActifsDansEnvCourant()
7654    .sort((a, b) => (a.prenom || '').localeCompare(b.prenom || ''));
7655  if (participants.length === 0) {
7656    return `<div class="empty"><span class="icon">📅</span>Aucun participant à ${escapeHtml(ev.nom)}.</div>`;
7657  }
7658  const jours = joursEdition(ev);
7659
7660  let html = renderUxScreenHeading('Organisation terrain', 'Planning Palmyre') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)">
7661    <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px">
7662      <div>
7663        <div style="font-weight:700; font-size:15px">📅 Planning · ${escapeHtml(ev.nom)}</div>
7664        <div class="text-faint" style="font-size:12px; margin-top:2px">Programme les créneaux (heure + lieu) de chaque participant. Touche une case pour ajouter.</div>
7665      </div>
7666      <button class="btn" style="white-space:nowrap" data-ui-click="exportPlanningPalmyrePDF()"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">PDF</button>
7667    </div>
7668  </div>`;
7669
7670  // Filtre par joueur : permet au coach de vérifier le planning complet d'un joueur
7671  // (repérer un oubli). '' = tous.
7672  const filtreValide = etatApp.planningJoueur && participants.some(j => j.id === etatApp.planningJoueur);
7673  const participantsAffiches = filtreValide ? participants.filter(j => j.id === etatApp.planningJoueur) : participants;
7674  html += `<div style="margin-bottom:10px; display:flex; align-items:center; gap:8px; flex-wrap:wrap">
7675    <select data-ui-change="setPlanningFiltreJoueur(this.value)" style="flex:1; min-width:180px">
7676      <option value="">Tous les joueurs (${participants.length})</option>
7677      ${participants.map(j => `<option value="${j.id}" ${etatApp.planningJoueur === j.id ? 'selected' : ''}>${escapeHtml(j.prenom)} ${escapeHtml(j.nom || '')}</option>`).join('')}
7678    </select>`;
7679  if (filtreValide) {
7680    const nbCr = creneauxPalmyreDuJoueurEvenement(etatApp.planningJoueur, ev.id).length;
7681    html += `<span class="text-faint" style="font-size:12px; white-space:nowrap">${nbCr} créneau${nbCr > 1 ? 'x' : ''} cette semaine</span>`;
7682  }
7683  html += `</div>`;
7684
7685  // Grille scrollable horizontalement
7686  html += `<div style="overflow-x:auto; -webkit-overflow-scrolling:touch"><table style="border-collapse:collapse; font-size:12px; min-width:100%">
7687    <thead><tr>
7688      <th style="position:sticky; left:0; background:var(--bg-section); z-index:2; text-align:left; padding:8px; min-width:90px; border:1px solid var(--border)">Joueur</th>
7689      ${jours.map(j => `<th style="text-align:center; padding:8px; min-width:120px; border:1px solid var(--border); background:var(--bg-section)">${escapeHtml(libelleJourCourt(j))}</th>`).join('')}
7690    </tr></thead><tbody>`;
7691
7692  participantsAffiches.forEach(j => {
7693    html += `<tr>
7694      <td style="position:sticky; left:0; background:var(--bg-card); z-index:1; font-weight:600; padding:8px; border:1px solid var(--border)">${escapeHtml(j.prenom)}</td>`;
7695    jours.forEach(jour => {
7696      const crs = creneauxDuJoueurJour(j.id, jour);
7697      const contenu = crs.map(c => {
7698        const fond = fondResultatCreneaux([c]) || 'background:var(--orange-bg, rgba(243,146,0,0.12));';
7699        return `<div data-ui-click="event.stopPropagation(); openModalCreneau(${escapeHtml(encoderArgumentTexteUI(j.id))},${escapeHtml(encoderArgumentTexteUI(jour))},${escapeHtml(encoderArgumentTexteUI(c.id))})" style="cursor:pointer; ${fond} border-radius:5px; padding:3px 5px; margin-bottom:3px">
7700          <strong>${escapeHtml(c.heure || '')}</strong>${c.lieu ? ' · ' + escapeHtml(c.lieu) : ''}${badgeTypePublic(c.type_public)}${badgeResultatCreneau(c)}
7701        </div>`;
7702      }).join('');
7703      html += `<td data-ui-click="openModalCreneau(${escapeHtml(encoderArgumentTexteUI(j.id))},${escapeHtml(encoderArgumentTexteUI(jour))})" style="cursor:pointer; vertical-align:top; padding:4px; border:1px solid var(--border); min-width:120px">
7704        ${contenu}
7705        <div class="text-faint" style="font-size:14px; text-align:center; padding:2px">+</div>
7706      </td>`;
7707    });
7708    html += `</tr>`;
7709  });
7710  html += `</tbody></table></div>`;
7711  return html;
7712}
7713
7714// Export PDF du planning Palmyre GLOBAL (tous les participants), en tableau
7715// joueurs × jours, A4 paysage, avec pagination automatique.
7716async function exportPlanningPalmyrePDF() { return appelerExportPdf('exportPlanningPalmyrePDF'); }
7717
7718// Résultat d'un créneau : UNIQUEMENT ce que le coach a coché lui-même.
7719// Volontairement PAS de déduction depuis les matchs saisis par les joueurs : un enfant
7720// peut saisir son match le lendemain, ce qui ferait apparaître (ou changer) un résultat
7721// après coup sur le planning. Une seule source de vérité : le coach.
7722function resultatCreneau(c) {
7723  if (!c) return null;
7724  if (c.resultat === 'V' || c.resultat === 'D') return { code: c.resultat };
7725  return null;
7726}
7727
7728// Pastille de résultat, très visible (vert = victoire, rouge = défaite).
7729function badgeResultatCreneau(c) {
7730  const r = resultatCreneau(c);
7731  if (!r) return '';
7732  const cfg = r.code === 'V'
7733    ? { bg: '#16a34a', txt: '✅ V' }
7734    : { bg: '#dc2626', txt: '❌ D' };
7735  return ` <span style="display:inline-block; font-size:10px; font-weight:800; padding:2px 7px; border-radius:999px; background:${cfg.bg}; color:#fff; white-space:nowrap">${cfg.txt}</span>`;
7736}
7737
7738// Couleur de fond d'une case de planning selon le résultat (lisible d'un coup d'œil).
7739function fondResultatCreneaux(creneaux) {
7740  const res = (listeSure(creneaux)).map(c => resultatCreneau(c)).filter(Boolean);
7741  if (res.length === 0) return '';
7742  const v = res.filter(r => r.code === 'V').length;
7743  const d = res.filter(r => r.code === 'D').length;
7744  if (v && !d) return 'background:rgba(22,163,74,0.18); border-left:3px solid #16a34a;';
7745  if (d && !v) return 'background:rgba(220,38,38,0.16); border-left:3px solid #dc2626;';
7746  return 'background:rgba(234,179,8,0.14); border-left:3px solid #eab308;'; // mixte V+D
7747}
7748
7749// Options d'heures pour le planning Palmyre : créneaux de 15 min de 07h00 à 22h00,
7750// au format "HHhMM" (compatible avec les données existantes). Une valeur hors grille
7751// (ancien créneau) est ajoutée comme option pour ne pas la perdre à l'édition.
7752function optionsHeuresPalmyre(selected) {
7753  const vals = [];
7754  for (let h = 7; h <= 22; h++) {
7755    for (let m = 0; m < 60; m += 15) {
7756      if (h === 22 && m > 0) break;
7757      vals.push(`${String(h).padStart(2, '0')}h${String(m).padStart(2, '0')}`);
7758    }
7759  }
7760  let opts = '<option value="">— choisir —</option>';
7761  opts += vals.map(v => `<option value="${v}" ${selected === v ? 'selected' : ''}>${v}</option>`).join('');
7762  if (selected && !vals.includes(selected)) {
7763    opts += `<option value="${escapeHtml(selected)}" selected>${escapeHtml(selected)} (hors liste)</option>`;
7764  }
7765  return opts;
7766}
7767
7768// Tuile "type de tournoi" (enfants / adultes) affichée sur un créneau du planning Palmyre.
7769// Pastilles pleines à couleurs franches et opposées pour une différenciation immédiate.
7770function badgeTypePublic(t) {
7771  if (!t) return '';
7772  return ` <span style="display:inline-block; font-size:10px; font-weight:700; padding:2px 8px; border-radius:999px; background:#6366f1; color:#fff; letter-spacing:.3px; white-space:nowrap">${escapeHtml(t)}
7772</span>`;
7773}
7774
7775// Catégories de tournoi proposées dans le planning Palmyre (liste éditable).
7776const CATEGORIES_TOURNOI = [
7777  'SM', 'SD', 'DM', 'DD', 'DX', 'TMC',
7778  '9-10 ans', '11-12 ans', '13-14 ans', '15-16 ans', '17-18 ans',
7779  'Seniors', '+35', '+45'
7780];
7781function optionsCategoriesTournoi(selected) {
7782  let opts = '<option value="">— aucune —</option>';
7783  opts += CATEGORIES_TOURNOI.map(cat => `<option value="${escapeHtml(cat)}" ${selected === cat ? 'selected' : ''}>${escapeHtml(cat)}</option>`).join('');
7784  if (selected && !CATEGORIES_TOURNOI.includes(selected)) {
7785    opts += `<option value="${escapeHtml(selected)}" selected>${escapeHtml(selected)} (hors liste)</option>`;
7786  }
7787  return opts;
7788}
7789
7790// Modale d'ajout/édition d'un créneau.
7791function openModalCreneau(joueurId, jour, creneauId) {
7792  const j = joueurById(joueurId);
7793  const ev = getActiveEvenement();
7794  if (!j || !ev) { toast('Joueur ou édition introuvable', true); return; }
7795  const c = creneauId ? creneauPalmyreById(creneauId) : null;
7796  const isEdit = !!c;
7797
7798  let html = `<div class="modal-header">
7799    <h3 class="modal-title">${isEdit ? 'Modifier' : 'Ajouter'} un créneau</h3>
7800    <button class="modal-close" data-ui-click="closeModal()">×</button>
7801  </div>
7802  <div class="text-faint mb-2" style="font-size:13px">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)} · ${escapeHtml(libelleJourCourt(jour))}</div>
7803  <form data-ui-submit="saveCreneau(event, ${escapeHtml(encoderArgumentTexteUI(joueurId))}, ${escapeHtml(encoderArgumentTexteUI(jour))}, ${escapeHtml(encoderArgumentTexteUI(creneauId || ''))})">
7804    <div class="grid-2">
7805      <div class="form-row"><label class="form-label">Heure</label>
7806        <select name="heure" style="width:100%">${optionsHeuresPalmyre(c ? (c.heure || '') : '')}</select></div>
7807      <div class="form-row"><label class="form-label">Lieu</label>
7808        <select name="lieu" style="width:100%">
7809          <option value="">— choisir —</option>
7810          ${LIEUX_PALMYRE.map(l => `<option value="${escapeHtml(l)}" ${c && c.lieu === l ? 'selected' : ''}>${escapeHtml(l)}</option>`).join('')}
7811          ${c && c.lieu && !LIEUX_PALMYRE.includes(c.lieu) ? `<option value="${escapeHtml(c.lieu)}" selected>${escapeHtml(c.lieu)} (ancien)</option>` : ''}
7812        </select></div>
7813    </div>
7814    <div class="form-row"><label class="form-label">Type de tournoi (optionnel)</label>
7815      <select name="type_public" style="width:100%">${optionsCategoriesTournoi(c ? (c.type_public || '') : '')}</select></div>
7816    <div class="form-row"><label class="form-label">Résultat (optionnel)</label>
7817      <div class="pill-group" id="pillResultatCreneau">
7818        <span class="pill success ${c && c.resultat === 'V' ? 'active' : ''}" data-value="V" data-ui-click="selectPillToggle(this, 'pillResultatCreneau')">✅ Victoire</span>
7819        <span class="pill danger ${c && c.resultat === 'D' ? 'active' : ''}" data-value="D" data-ui-click="selectPillToggle(this, 'pillResultatCreneau')">❌ Défaite</span>
7820      </div>
7821    </div>
7822    <div class="form-row"><label class="form-label">Note (optionnel)</label>
7823      <input type="text" name="note" value="${c ? escapeHtml(c.note || '') : ''}" placeholder="Précision..."></div>
7824    <div class="flex gap-1 mt-2">
7825      ${isEdit ? `<button type="button" class="btn btn-danger" data-ui-click="deleteCreneau(${escapeHtml(encoderArgumentTexteUI(creneauId))},${escapeHtml(encoderArgumentTexteUI(joueurId))},${escapeHtml(encoderArgumentTexteUI(jour))})">Supprimer</button>` : ''}
7826      <button type="button" class="btn flex-1" data-ui-click="closeModal()">Annuler</button>
7827      <button type="submit" class="btn btn-primary flex-1">${isEdit ? 'Enregistrer' : 'Ajouter'}</button>
7828    </div>
7829  </form>`;
7830  showModal(html);
7831  const formulaireBilan = document.getElementById('formBilanPalmyre');
7832  formulaireBilan?.addEventListener('input', () => {
7833    const brouillon = capturerBrouillonBilan(joueurId);
7834    if (brouillon) gestionnaireBrouillons.conserverBilan(brouillon);
7835  });
7836}
7837
7838async function saveCreneau(e, joueurId, jour, creneauId) {
7839  e.preventDefault();
7840  const f = e.target;
7841  const ev = getActiveEvenement();
7842  if (!ev) { toast('Édition introuvable', true); return; }
7843  const heure = f.heure.value.trim();
7844  const lieu = f.lieu.value.trim();
7845  if (!heure && !lieu) { toast('Renseigne au moins une heure ou un lieu', true); return; }
7846  const type_public = (f.type_public && f.type_public.value.trim()) || null;
7847  const resPill = document.querySelector('#pillResultatCreneau .pill.active');
7848  const resultat = resPill ? resPill.dataset.value : null;
7849
7850  const obj = {
7851    evenement_id: ev.id,
7852    joueur_id: joueurId,
7853    jour: jour,
7854    heure: heure || null,
7855    lieu: lieu || null,
7856    type_public: type_public,
7857    resultat: resultat,
7858    note: f.note.value.trim() || null
7859  };
7860
7861  let error;
7862  const execution = await executerAvecVerrou(f, () => creneauId
7863    ? updateConfirmee(db.from('creneaux_palmyre').update(obj).eq('id', creneauId))
7864    : db.from('creneaux_palmyre').insert(obj));
7865  if (execution.ignoree) return;
7866  ({ error } = execution.valeur);
7867  if (error) { toastErreur(error); return; }
7868  closeModal();
7869  toast(creneauId ? 'Créneau modifié' : 'Créneau ajouté');
7870  await reloadTables(['creneaux_palmyre']);
7871  render();
7872}
7873
7874async function deleteCreneau(creneauId, joueurId, jour) {
7875  if (!(await demanderConfirmation('Supprimer ce créneau ?'))) return;
7876  const { error } = await db.from('creneaux_palmyre').delete().eq('id', creneauId);
7877  if (error) { toastErreur(error); return; }
7878  closeModal();
7879  toast('Créneau supprimé');
7880  await reloadTables(['creneaux_palmyre']);
7881  render();
7882}
7883
7884// Vue Transport : regroupement par jour → par lieu → joueurs (pour le rôle chauffeur).
7885function renderTransportPalmyre() {
7886  if (!isCoach()) return '';
7887  const ev = getActiveEvenement();
7888  if (!ev) return `<div class="empty"><span class="icon"><img src="assets/v4/nav/transport.webp" alt="" style="width:62px;height:62px;object-fit:contain"></span>Aucune édition active.</div>`;
7889
7890  const creneaux = creneauxPalmyreDeEvenement(ev.id).filter(c => c.lieu);
7891  if (creneaux.length === 0) {
7892    return `<div class="empty"><span class="icon"><img src="assets/v4/nav/transport.webp" alt="" style="width:62px;height:62px;object-fit:contain"></span>Aucun déplacement programmé.<br><span class="text-faint">Ajoute des créneaux avec un lieu dans le Planning.</span></div>`;
7893  }
7894
7895  let html = renderUxScreenHeading('Organisation terrain', 'Transport') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)">
7896    <div style="font-weight:700; font-size:15px;display:flex;align-items:center;gap:8px"><img class="inline-art-icon" src="assets/v4/nav/transport.webp" alt="" style="width:30px;height:30px">Logistique transport · ${escapeHtml(ev.nom)}</div>
7897    <div class="text-faint" style="font-size:12px; margin-top:2px">Qui emmener, quand et où. Regroupé par jour puis par lieu.</div>
7898  </div>`;
7899
7900  const jours = joursEdition(ev).filter(j => creneaux.some(c => c.jour === j));
7901  jours.forEach(jour => {
7902    const crsJour = creneaux.filter(c => c.jour === jour);
7903    // Regrouper par lieu
7904    const parLieu = {};
7905    crsJour.forEach(c => { (parLieu[c.lieu] = parLieu[c.lieu] || []).push(c); });
7906
7907    html += `<div class="section-title">${escapeHtml(libelleJourCourt(jour))}</div>`;
7908    Object.keys(parLieu).sort().forEach(lieu => {
7909      const liste = parLieu[lieu].sort((a, b) => (a.heure || '').localeCompare(b.heure || ''));
7910      html += `<div class="card mb-1" style="padding:10px 12px">
7911        <div style="font-weight:600; margin-bottom:6px"><span class="venue-mark" aria-hidden="true"></span>${escapeHtml(lieu)} <span class="text-faint" style="font-size:12px">· ${liste.length} joueur${liste.length > 1 ? 's' : ''}</span></div>
7912        ${liste.map(c => {
7913          const j = joueurById(c.joueur_id);
7914          // Transport = qui va où et à quelle heure. Le résultat du match n'a rien à
7915          // faire ici : il n'apparaît que sur le planning.
7916          return `<div style="display:flex; justify-content:space-between; padding:3px 0; font-size:13px; border-top:1px solid var(--border)">
7917            <span>${j ? escapeHtml(j.prenom + ' ' + j.nom) : '?'}</span>
7918            <span class="text-faint">${escapeHtml(c.heure || '')}${badgeTypePublic(c.type_public)}</span>
7919          </div>`;
7920        }).join('')}
7921      </div>`;
7922    });
7923  });
7924  return html;
7925}
7926
7927// Vue joueur : son planning personnel du stage (lecture seule), par jour.
7928function renderMonPlanningJoueur() {
7929  if (!profile || !profile.joueur_id) return `<div class="empty">Profil joueur non trouvé.</div>`;
7930  const joueur = joueurById(profile.joueur_id);
7931  const editionsVisibles = editionsPlanningPalmyreJoueur(joueur);
7932  if (editionsVisibles.length === 0) {
7933    return `<div class="empty"><span class="icon">📅</span>Aucun planning Palmyre en cours ou à venir.</div>`;
7934  }
7935  const edition = editionsVisibles.find(ev => creneauxPalmyreDuJoueurEvenement(profile.joueur_id, ev.id).length > 0)
7936    || editionsVisibles[0];
7937  const mesCreneaux = creneauxPalmyreDuJoueurEvenement(profile.joueur_id, edition.id);
7938
7939  if (mesCreneaux.length === 0) {
7940    return `<div class="empty"><span class="icon">📅</span>Aucun créneau programmé pour le moment.</div>`;
7941  }
7942
7943  const creneaux = mesCreneaux;
7944
7945  let html = renderUxScreenHeading('Mon séjour', 'Planning Palmyre') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)">
7946    <div style="font-weight:700; font-size:15px">🏖️ Mon planning · ${escapeHtml(edition.nom || 'Palmyre')}</div>
7947    <div class="text-faint" style="font-size:12px; margin-top:2px">Tes créneaux programmés par le coach (heure et lieu).</div>
7948  </div>`;
7949
7950  if (creneaux.length === 0) {
7951    html += `<div class="empty"><span class="icon">📅</span>Tu n'as aucun créneau programmé.</div>`;
7952    return html;
7953  }
7954
7955  // Regrouper par jour, dans l'ordre chronologique
7956  const jours = [...new Set(creneaux.map(c => c.jour))].sort();
7957  jours.forEach(jour => {
7958    const crs = creneaux.filter(c => c.jour === jour).sort((a, b) => (a.heure || '').localeCompare(b.heure || ''));
7959    html += `<div class="section-title">${escapeHtml(libelleJourCourt(jour))}</div>`;
7960    html += `<div class="card" style="padding:0; overflow:hidden">`;
7961    crs.forEach(c => {
7962      html += `<div style="display:flex; align-items:center; gap:12px; padding:11px 14px; border-bottom:1px solid var(--border)">
7963        <div style="font-weight:700; font-size:15px; color:var(--orange-text); min-width:60px">${escapeHtml(c.heure || '—')}</div>
7964        <div style="flex:1">
7965          ${c.lieu ? `<div style="font-weight:600"><span class="venue-mark"></span>${escapeHtml(c.lieu)}</div>` : ''}
7966          ${(c.type_public || (isCoach() && resultatCreneau(c))) ? `<div style="margin-top:3px">${badgeTypePublic(c.type_public)}${isCoach() ? badgeResultatCreneau(c) : ''}</div>` : ''}
7967          ${c.note ? `<div class="text-faint" style="font-size:12px; margin-top:2px">${escapeHtml(c.note)}</div>` : ''}
7968        </div>
7969      </div>`;
7970    });
7971    html += `</div>`;
7972  });
7973  return html;
7974}
7975
7976function setPlanningFiltreJoueur(value) {
7977  etatApp.definirPlanningJoueur(value);
7978  render();
7979}
7980
7981// ============================================================
7982// CARNET D'OBSERVATIONS PALMYRE (notes de travail du coach)
7983// ============================================================
7984
7985const OBS_THEMES = [
7986  { cle: 'tactique', label: 'Tactique', icone: '🎯' },  { cle: 'technique', label: 'Technique', icone: '🎾' },
7987  { cle: 'mental', label: 'Mental', icone: '🧠' },
7988  { cle: 'physique', label: 'Physique', icone: '💪' },
7989  { cle: 'groupe', label: 'Vie de groupe', icone: '👥' }
7990];
7991
7992// Thèmes proposés à la SAISIE sur un joueur : "Vie de groupe" en est exclu,
7993// car il n'a de sens que dans les notes collectives (pas rattaché à un joueur).
7994const OBS_THEMES_JOUEUR = OBS_THEMES.filter(t => t.cle !== 'groupe');
7995
7996// Lieux du stage de la Palmyre (utilisés pour les observations ET le planning).
7997// Liste fixe fournie par le coach.
7998const LIEUX_PALMYRE = ['La Palmyre', 'Royan', 'Breuillet', 'Vaux-sur-Mer', 'Saint-Georges', 'Saujon', 'Médis'];
7999function obsThemeMeta(cle) { return OBS_THEMES.find(t => t.cle === cle) || { label: cle, icone: '•' }; }
8000
8001// ============================================================
8002// AXES DE TRAVAIL PALMYRE (consignes envoyées au joueur)
8003// Contrairement aux observations (privées coach), un axe est PARTAGÉ avec le joueur :
8004// il le voit sur son profil, reçoit une notification, et coche "fait" quand c'est fini.
8005// ============================================================
8006// Axes d'un joueur.
8007// - Côté COACH : on filtre sur l'édition courante (il travaille dans un environnement donné).
8008// - Côté JOUEUR : on NE filtre PAS par édition. La RLS ne lui renvoie déjà que SES axes, et
8009//   toute tentative de deviner "son" édition est fragile (une fois le stage terminé, la
8010//   résolution retombait sur une autre édition active et ses axes disparaissaient).
8011function axesJoueur(joueurId) {
8012  const tous = axesPalmyreDuJoueur(joueurId);
8013  const evCoach = getActiveEvenement(); // null pour un joueur
8014  const liste = isCoach()
8015    ? (evCoach ? tous.filter(a => a.evenement_id === evCoach.id) : tous.filter(a => !a.evenement_id))
8016    : tous;
8017  return liste.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
8018}
8019
8020// Les axes réalisés restent archivés en base pour le suivi, mais ne font plus partie
8021// de la liste de travail quotidienne du joueur ni de celle du coach.
8022function axesActifsJoueur(joueurId) {
8023  return axesJoueur(joueurId).filter(axe => !axe.fait);
8024}
8025
8026// Coach : envoie un axe de travail au joueur (+ notification).
8027async function envoyerAxeTravail(joueurId) {
8028  if (!isCoach()) return;
8029  const ev = getActiveEvenement();
8030  const el = document.getElementById('axeTexte');
8031  const texte = (el && el.value || '').trim();
8032  if (!texte) { toast('Écris l\'axe de travail', true); return; }
8033
8034  const { error } = await db.from('axes_travail_palmyre').insert({
8035    evenement_id: ev ? ev.id : null,
8036    joueur_id: joueurId,
8037    coach_id: session.user.id,
8038    texte: texte
8039  });
8040  if (error) { toastErreur(error); return; }
8041
8042  // Notification au joueur. Un échec ici ne doit PAS être silencieux : sans ça, le coach
8043  // croit que le joueur a été prévenu alors qu'il n'a rien reçu.
8044  const j = joueurById(joueurId);
8045  let notifEnvoyee = false;
8046  let notifProbleme = '';
8047  if (!j || !j.user_id) {
8048    notifProbleme = 'ce joueur n\'a pas encore de compte';
8049  } else {
8050    const { error: errNotif } = await db.from('notifications').insert({
8051      user_id: j.user_id,
8052      type: 'axe_travail',
8053      titre: '🎯 Nouvel axe de travail',
8054      message: `Ton coach te propose cet axe de travail : ${texte}`,
8055      lien_type: 'axe_travail',
8056      lien_id: null
8057    });
8058    if (errNotif) { notifProbleme = errNotif.message; journal.warn('[axe] notif:', errNotif); }
8059    else notifEnvoyee = true;
8060  }
8061
8062  if (el) el.value = '';
8063  if (notifEnvoyee) {
8064    toast('Axe envoyé au joueur 🎯');
8065  } else {
8066    toast('Axe enregistré, mais le joueur n\'a pas été notifié (' + notifProbleme + ')', true);
8067  }
8068  await reloadTables(['axes_travail_palmyre']);
8069  const box = document.getElementById('axesListe');
8070  if (box) box.innerHTML = renderAxesListe(joueurId);
8071}
8072
8073// Liste des axes d'un joueur (vue coach : suivi du "fait").
8074function renderAxesListe(joueurId) {
8075  const axes = axesActifsJoueur(joueurId);
8076  if (axes.length === 0) return `<div class="text-faint" style="font-size:12px">Aucun axe en attente.</div>`;
8077  return axes.map(a => `<div style="display:flex; align-items:flex-start; gap:8px; padding:8px 0; border-bottom:1px solid var(--border)">
8078    <span style="font-size:14px">${a.fait ? '✅' : '⏳'}</span>
8079    <div style="flex:1; min-width:0">
8080      <div style="font-size:13px; ${a.fait ? 'opacity:.65' : ''}">${escapeHtml(a.texte)}</div>
8081      <div class="text-faint" style="font-size:11px; margin-top:2px">
8082        ${a.fait ? 'Fait' + (a.fait_at ? ' le ' + formatDate(a.fait_at) : '') : 'En attente'}
8083      </div>
8084    </div>
8085    <button class="btn" style="padding:2px 8px; font-size:11px" aria-label="Supprimer l’axe ${escapeHtml(a.axe || a.titre || '')}" data-ui-click="supprimerAxe(${escapeHtml(encoderArgumentTexteUI(a.id))}, ${escapeHtml(encoderArgumentTexteUI(joueurId))})">×</button>
8086  </div>`).join('');
8087}
8088
8089async function supprimerAxe(axeId, joueurId) {
8090  if (!(await demanderConfirmation('Supprimer cet axe de travail ?'))) return;
8091  const { error } = await db.from('axes_travail_palmyre').delete().eq('id', axeId);
8092  if (error) { toastErreur(error); return; }
8093  toast('Axe supprimé');
8094  await reloadTables(['axes_travail_palmyre']);
8095  const box = document.getElementById('axesListe');
8096  if (box) box.innerHTML = renderAxesListe(joueurId);
8097}
8098
8099// Joueur : coche un axe comme fait (ou décoche).
8100async function toggleAxeFait(axeId, fait) {
8101  const { error } = await updateConfirmee(db.from('axes_travail_palmyre')
8102    .update({ fait: fait, fait_at: fait ? new Date().toISOString() : null })
8103    .eq('id', axeId));
8104  if (error) { toastErreur(error); return; }
8105  toast(fait ? 'Bravo, axe validé ! 💪' : 'Axe remis en attente');
8106  await reloadTables(['axes_travail_palmyre']);
8107  render();
8108}
8109
8110// Joueur : bloc "Mes axes de travail" affiché sur son profil (env. Palmyre).
8111function observationsGroupe() {
8112  const ev = getActiveEvenement();
8113  if (!ev) return [];
8114  return observationsPalmyreDuJoueurEvenement('', ev.id).slice()
8115    .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
8116}
8117
8118const selectionsPalmyre = etatApp.selectionsPalmyre;
8119const debriefThemes = etatApp.themesDebrief;
8120function selectObsGroupeTheme(el) {
8121  document.querySelectorAll('#obsGroupePills .pill').forEach(p => p.classList.remove('active'));
8122  el.classList.add('active');
8123  etatApp.definirThemeObservation('groupe', el.dataset.theme);
8124}
8125
8126function openCarnetGroupe() {
8127  const ev = getActiveEvenement();
8128  if (!ev) return;
8129  etatApp.definirThemeObservation('groupe', 'groupe');
8130  const notes = observationsGroupe();
8131  let html = `<div class="modal-header">
8132    <h3 class="modal-title">👥 Notes de groupe</h3>
8133    <button class="modal-close" data-ui-click="closeModal()">×</button>
8134  </div>
8135  <div class="card mb-2" style="padding:12px">
8136    <div class="text-faint" style="font-size:12px; margin-bottom:8px">Ce qui concerne tout le monde (ambiance, météo, organisation, consigne collective…). Ces notes alimentent aussi le débrief du soir.</div>
8137    <div class="pill-group" id="obsGroupePills" style="margin-bottom:10px">
8138      ${OBS_THEMES.map(t => `<span class="pill ${t.cle === 'groupe' ? 'active' : ''}" data-theme="${t.cle}" data-ui-click="selectObsGroupeTheme(this)">${t.icone} ${t.label}</span>`).join('')}
8139    </div>
8140    <textarea id="obsGroupeTexte" rows="3" placeholder="Ex : grosse chaleur, groupe fatigué en fin de journée..." style="width:100%"></textarea>
8141    <button class="btn btn-primary btn-full mt-1" data-ui-click="ajouterObservationGroupe()">+ Ajouter la note</button>
8142  </div>
8143  <div class="section-title">Notes de la semaine</div>
8144  <div id="obsGroupeHisto">${renderObsGroupeHisto()}</div>`;
8145  showModal(html);
8146}
8147
8148function renderObsGroupeHisto() {
8149  const notes = observationsGroupe();
8150  if (notes.length === 0) return `<div class="text-faint" style="font-size:12px">Aucune note de groupe.</div>`;
8151  return notes.map(o => {
8152    const m = obsThemeMeta(o.theme);
8153    const d = o.created_at ? new Date(o.created_at) : null;
8154    const dateStr = d ? `${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}
8154:${String(d.getMinutes()).padStart(2,'0')}` : '';
8155    return `<div class="card mb-1" style="padding:10px 12px">
8156      <div style="display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:4px">
8157        <span class="badge" style="font-size:10px">${m.icone} ${escapeHtml(m.label)}</span>
8158        <span class="text-faint" style="font-size:11px">${dateStr} · <a href="#" data-ui-click="event.preventDefault(); supprimerObservationGroupe(${escapeHtml(encoderArgumentTexteUI(o.id))})" style="color:var(--danger)">supprimer</a></span>
8159      </div>
8160      <div style="font-size:13px">${escapeHtml(o.texte)}</div>
8161    </div>`;
8162  }).join('');
8163}
8164
8165async function ajouterObservationGroupe() {
8166  const ev = getActiveEvenement();
8167  if (!ev) { toast('Édition introuvable', true); return; }
8168  const texte = (document.getElementById('obsGroupeTexte').value || '').trim();
8169  if (!texte) { toast('Écris la note', true); return; }
8170  const { error } = await db.from('observations_palmyre').insert({
8171    evenement_id: ev.id,
8172    joueur_id: null,          // note collective
8173    theme: selectionsPalmyre.groupeTheme,
8174    texte: texte,
8175    coach_id: session.user.id
8176  });
8177  if (error) { toastErreur(error); return; }
8178  document.getElementById('obsGroupeTexte').value = '';
8179  toast('Note de groupe ajoutée 👥');
8180  await reloadTables(['observations_palmyre']);
8181  const h = document.getElementById('obsGroupeHisto');
8182  if (h) h.innerHTML = renderObsGroupeHisto();
8183}
8184
8185async function supprimerObservationGroupe(obsId) {
8186  if (!(await demanderConfirmation('Supprimer cette note ?'))) return;
8187  const { error } = await db.from('observations_palmyre').delete().eq('id', obsId);
8188  if (error) { toastErreur(error); return; }
8189  toast('Note supprimée');
8190  await reloadTables(['observations_palmyre']);
8191  const h = document.getElementById('obsGroupeHisto');
8192  if (h) h.innerHTML = renderObsGroupeHisto();
8193}
8194
8195// Rendu markdown léger pour la synthèse du débrief (titres, gras, listes).
8196// SÉCURITÉ : on échappe TOUT le HTML d'abord, puis on ne réintroduit que nos balises.
8197function markdownLeger(txt) {
8198  if (!txt) return '';
8199  const lignes = escapeHtml(String(txt)).split('\n');
8200  let html = '';
8201  let dansListe = false;
8202  const inline = (s) => s
8203    .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
8204    .replace(/(^|[\s(])\*(?!\s)(.+?)(?<!\s)\*(?=[\s.,;:)]|$)/g, '$1<em>$2</em>');
8205
8206  const fermerListe = () => { if (dansListe) { html += '</ul>'; dansListe = false; } };
8207
8208  lignes.forEach(l => {
8209    const t = l.trim();
8210    if (!t) { fermerListe(); return; }
8211
8212    // Titres (#, ##, ###)
8213    const mTitre = t.match(/^(#{1,4})\s+(.*)$/);
8214    if (mTitre) {
8215      fermerListe();
8216      const niv = mTitre[1].length;
8217      const taille = niv === 1 ? 15 : (niv === 2 ? 14 : 13);
8218      const marge = niv === 1 ? '0 0 8px' : '14px 0 6px';
8219      html += `<div style="font-weight:700; font-size:${taille}px; margin:${marge}; color:var(--orange)">${inline(mTitre[2])}</div>`;
8220      return;
8221    }
8222    // Puces (- ou *) et listes numérotées (1. 2. …)
8223    const mPuce = t.match(/^[-*•]\s+(.*)$/);
8224    const mNum = t.match(/^\d+[.)]\s+(.*)$/);
8225    if (mPuce || mNum) {
8226      if (!dansListe) { html += '<ul style="margin:4px 0 4px 18px; padding:0">'; dansListe = true; }
8227      html += `<li style="margin:3px 0">${inline((mPuce || mNum)[1])}</li>`;
8228      return;
8229    }
8230    // Paragraphe
8231    fermerListe();
8232    html += `<div style="margin:6px 0">${inline(t)}</div>`;
8233  });
8234  fermerListe();
8235  return html;
8236}
8237
8238// ============================================================
8239// DÉBRIEF DU SOIR (Palmyre) — synthèse IA des observations de la journée.
8240// PRIVÉ COACH. Une synthèse par jour et par édition, éditable / supprimable / relançable.
8241// ============================================================
8242// Thèmes retenus pour la synthèse. Par défaut : tactique + mental + vie de groupe
8243// (le débrief collectif du soir ne porte pas sur la technique individuelle),
8244// mais le coach peut élargir s'il le souhaite.
8245function toggleDebriefTheme(cle) {
8246  etatApp.basculerThemeDebrief(cle);
8247  render();
8248}
8249
8250function setDebriefDate(value) {
8251  etatApp.definirDateDebrief(value);
8252  render();
8253}
8254
8255function debriefDuJour(evId, date) {
8256  return debriefPalmyreDuJour(evId, date);
8257}
8258
8259// Observations prises un jour donné (tous joueurs), pour l'édition active.
8260function observationsDuJour(evId, date) {
8261  return observationsPalmyreDuJour(evId, date).slice()
8262    .sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
8263}
8264
8265function renderDebriefPalmyre() {
8266  if (!isCoach()) return '';
8267  const ev = getActiveEvenement();
8268  if (!ev) return `<div class="empty"><span class="icon"><img src="assets/v4/nav/debrief.webp" alt="" style="width:62px;height:62px;object-fit:contain"></span>Aucune édition active.</div>`;
8269
8270  const jours = joursEdition(ev);
8271  const auj = dateLocaleISO(new Date());
8272  const date = etatApp.initialiserDateDebrief(jours, auj);
8273
8274  const obsTous = observationsDuJour(ev.id, date);
8275  const obs = obsTous.filter(o => debriefThemes.includes(o.theme));
8276  const deb = debriefDuJour(ev.id, date);
8277
8278  let html = renderUxScreenHeading('Synthèse collective', 'Débrief du soir') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)">
8279    <div style="font-weight:700; font-size:15px;
8279display:flex;align-items:center;gap:8px"><img class="inline-art-icon" src="assets/v4/nav/debrief.webp" alt="" style="width:30px;height:30px">Débrief du soir</div>
8280    <div class="text-faint" style="font-size:12px; margin-top:2px">Synthèse des observations de la journée (tous les joueurs), pour animer ton débrief collectif. Privé : les joueurs n'y ont pas accès.</div>
8281  </div>`;
8282
8283  // Sélecteur de jour
8284  html += `<div class="mb-2">
8285    <select data-ui-change="setDebriefDate(this.value)" style="width:100%">
8286      ${jours.map(jr => `<option value="${jr}" ${jr === date ? 'selected' : ''}>${escapeHtml(libelleJourCourt(jr))}${jr === auj ? " (aujourd'hui)" : ''}</option>`).join('')}
8287    </select>
8288  </div>`;
8289
8290  // Thèmes pris en compte dans la synthèse
8291  html += `<div class="card mb-2" style="padding:12px">
8292    <div style="font-size:12px; font-weight:600; margin-bottom:6px">Thèmes à synthétiser</div>
8293    <div class="text-faint" style="font-size:11px; margin-bottom:8px">Le débrief collectif porte en général sur le mental et la tactique. Tu peux élargir si besoin.</div>
8294    <div class="pill-group">
8295      ${OBS_THEMES.map(t => {
8296        const actif = debriefThemes.includes(t.cle);
8297        const n = obsTous.filter(o => o.theme === t.cle).length;
8298    return `<span class="pill ${actif ? 'active' : ''}" data-ui-click="toggleDebriefTheme(${escapeHtml(encoderArgumentTexteUI(t.cle))})">${t.icone} ${t.label}${n ? ` (${n})` : ''}</span>`;
8299      }).join('')}
8300    </div>
8301  </div>`;
8302
8303  // Matière première
8304  html += `<div class="card mb-2" style="padding:12px">
8305    <div style="font-size:13px; font-weight:600; margin-bottom:6px;display:flex;align-items:center;gap:7px"><img class="inline-art-icon" src="assets/v4/nav/observations.webp" alt="" style="width:27px;height:27px">${obs.length} note${obs.length > 1 ? 's' : ''} retenue${obs.length > 1 ? 's' : ''} ce jour-là${obsTous.length !== obs.length ? ` <span class="text-faint" style="font-weight:400">(sur ${obsTous.length})</span>` : ''}</div>`;
8306  if (obs.length === 0) {
8307    html += `<div class="text-faint" style="font-size:12px">${obsTous.length === 0 ? 'Aucune observation prise ce jour. Prends des notes sur les joueurs (ou une note de groupe) dans l\'onglet Observations, puis reviens ici.' : 'Aucune note dans les thèmes sélectionnés. Coche d\'autres thèmes ci-dessus.'}</div>`;
8308  } else {
8309    const parTheme = {};
8310    obs.forEach(o => { (parTheme[o.theme] = parTheme[o.theme] || []).push(o); });
8311    html += `<div style="display:flex; gap:6px; flex-wrap:wrap">` +
8312      Object.keys(parTheme).map(t => {
8313        const m = obsThemeMeta(t);
8314        return `<span class="badge" style="font-size:11px">${m.icone} ${escapeHtml(m.label)} (${parTheme[t].length})</span>`;
8315      }).join('') + `</div>`;
8316  }
8317  html += `</div>`;
8318
8319  // Synthèse
8320  if (deb) {
8321    html += `<div class="card mb-2" style="padding:12px">
8322      <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; gap:8px">
8323        <div style="font-weight:700; font-size:14px;display:flex;align-items:center;gap:7px"><img class="inline-art-icon" src="assets/v4/nav/reports.webp" alt="" style="width:28px;height:28px">Synthèse</div>
8324        <div style="display:flex; gap:6px;flex-wrap:wrap;justify-content:flex-end">
8325          <button class="btn" style="padding:3px 8px; font-size:11px" data-ui-click="editerDebrief()"><img class="btn-action-art" src="assets/v4/nav/observations.webp" alt="">Modifier</button>
8326          <button class="btn" style="padding:3px 8px; font-size:11px" data-ui-click="genererDebriefIA(true)"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Relancer</button>
8327          <button class="btn" style="padding:3px 8px; font-size:11px;color:var(--danger-text)" data-ui-click="supprimerDebrief()">Supprimer</button>
8328        </div>
8329      </div>
8330      <div id="debriefContenu" style="font-size:13px; line-height:1.6">${markdownLeger(deb.texte || '')}</div>
8331      <div class="text-faint" style="font-size:11px; margin-top:8px">Généré le ${formatDate(deb.updated_at || deb.created_at)}${deb.edite ? ' · modifié à la main' : ''}</div>
8332    </div>`;
8333  } else if (obs.length > 0) {
8334    html += `<button class="btn btn-primary btn-full mb-2" data-ui-click="genererDebriefIA(false)"><img class="btn-action-art" src="assets/v4/nav/debrief.webp" alt="">Générer la synthèse du soir</button>`;
8335  }
8336
8337  return html;
8338}
8339
8340// Génère (ou régénère) la synthèse via l'Edge Function "debrief-ia".
8341async function genererDebriefIA(relance) {
8342  if (!isCoach()) return;
8343  const ev = getActiveEvenement();
8344  if (!ev) { toast('Édition introuvable', true); return; }
8345  const date = selectionsPalmyre.debriefDate || dateLocaleISO(new Date());
8346  const obs = observationsDuJour(ev.id, date).filter(o => debriefThemes.includes(o.theme));
8347  if (obs.length === 0) { toast('Aucune note à synthétiser dans les thèmes sélectionnés', true); return; }
8348  if (relance && !(await demanderConfirmation('Régénérer la synthèse ? La version actuelle sera remplacée.', { titre: 'Remplacer la synthèse', libelle: 'Régénérer' }))) return;
8349
8350  toast('Synthèse en cours... ✨');
8351  let synthese = '';
8352  try {
8353    const { data, error } = await db.functions.invoke('debrief-ia', {
8354      body: { evenement_id: ev.id, date, themes: [...debriefThemes] }
8355    });
8356    if (error) { toastErreur(error); return; }
8357    if (!data || !data.synthese) { toast('Synthèse vide, réessaie', true); return; }
8358    synthese = data.synthese;
8359  } catch (e) {
8360    toast('Synthèse indisponible : ' + e.message, true);
8361    return;
8362  }
8363
8364  const existant = debriefDuJour(ev.id, date);
8365  const obj = {
8366    evenement_id: ev.id,
8367    date: date,
8368    texte: synthese,
8369    edite: false,
8370    coach_id: session.user.id,
8371    updated_at: new Date().toISOString()
8372  };
8373  const { error: errSave } = existant
8374    ? await updateConfirmee(db.from('debriefs_palmyre').update(obj).eq('id', existant.id))
8375    : await db.from('debriefs_palmyre').insert(obj);
8376  if (errSave) { toastErreur(errSave); return; }
8377
8378  toast('Synthèse générée ✨');
8379  await reloadTables(['debriefs_palmyre']);
8380  render();
8381}
8382
8383// Édition manuelle de la synthèse.
8384function editerDebrief() {
8385  const ev = getActiveEvenement();
8386  if (!ev) return;
8387  const date = selectionsPalmyre.debriefDate || dateLocaleISO(new Date());
8388  const deb = debriefDuJour(ev.id, date);
8389  if (!deb) return;
8390  const brouillon = gestionnaireBrouillons.lireFormulaire('debrief', deb.id) || {};
8391  const html = `<div class="modal-header">
8392      <h3 class="modal-title" style="display:flex;align-items:center;gap:10px"><img class="modal-title-art" src="assets/v4/nav/observations.webp" alt="">Modifier la synthèse</h3>
8393      <button class="modal-close" data-ui-click="closeModal()">×</button>
8394    </div>
8395    <textarea id="debriefEdit" rows="16" style="width:100%; font-size:13px; line-height:1.5">${escapeHtml(brouillon.texte || deb.texte || '')}</textarea>
8396    <button class="btn btn-primary btn-full mt-1" data-ui-click="sauverDebriefEdite(${escapeHtml(encoderArgumentTexteUI(deb.id))})">Enregistrer</button>`;
8397  showModal(html);
8398  $('debriefEdit')?.addEventListener('input', event => {
8399    gestionnaireBrouillons.enregistrerFormulaire('debrief', deb.id, { texte: event.target.value });
8400  });
8401}
8402
8403async function sauverDebriefEdite(id) {
8404  const texte = (document.getElementById('debriefEdit').value || '').trim();
8405  if (!texte) { toast('La synthèse est vide', true); return; }
8406  const { error } = await updateConfirmee(db.from('debriefs_palmyre')
8407    .update({ texte: texte, edite: true, updated_at: new Date().toISOString() })
8408    .eq('id', id));
8409  if (error) { toastErreur(error); return; }
8410  gestionnaireBrouillons.lireFormulaire('debrief', id, true);
8411  closeModal();
8412  toast('Synthèse mise à jour');
8413  await reloadTables(['debriefs_palmyre']);
8414  render();
8415}
8416
8417async function supprimerDebrief() {
8418  const ev = getActiveEvenement();
8419  if (!ev) return;
8420  const date = selectionsPalmyre.debriefDate || dateLocaleISO(new Date());
8421  const deb = debriefDuJour(ev.id, date);
8422  if (!deb) return;
8423  if (!(await demanderConfirmation('Supprimer cette synthèse ?'))) return;
8424  const { error } = await db.from('debriefs_palmyre').delete().eq('id', deb.id);
8425  if (error) { toastErreur(error); return; }
8426  toast('Synthèse supprimée');
8427  await reloadTables(['debriefs_palmyre']);
8428  render();
8429}
8430
8431// Observations d'un joueur sur l'édition active (les plus récentes d'abord).
8432function observationsJoueur(joueurId) {
8433  const ev = getActiveEvenement();
8434  if (!ev) return [];
8435  return observationsPalmyreDuJoueurEvenement(joueurId, ev.id).slice()
8436    .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
8437}
8438
8439// Vue dédiée : liste des participants avec accès rapide à leur carnet.
8440function renderObservationsPalmyre() {
8441  if (!isCoach()) return '';
8442  const ev = getActiveEvenement();
8443  if (!ev) return `<div class="empty"><span class="icon">📝</span>Aucune édition active.</div>`;
8444
8445  const participants = joueursActifsDansEnvCourant();
8446  if (participants.length === 0) {
8447    return `<div class="empty"><span class="icon">📝</span>Aucun participant à ${escapeHtml(ev.nom)}.</div>`;
8448  }
8449
8450  let html = renderUxScreenHeading('Suivi individuel', 'Observations') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)">
8451    <div style="font-weight:700; font-size:15px">📝 Carnet d'observations · ${escapeHtml(ev.nom)}</div>
8452    <div class="text-faint" style="font-size:12px; margin-top:2px">Tes notes de travail prises en direct, par joueur et par thème. Visibles par toi uniquement.</div>
8453  </div>`;
8454
8455  const nbGroupe = observationsGroupe().length;
8456  html += `<div class="card mb-2" data-ui-click="openCarnetGroupe()" style="padding:12px 14px; cursor:pointer; display:flex; align-items:center; gap:10px; border-left:3px solid var(--orange)">
8457    <span style="font-size:20px">👥</span>
8458    <div style="flex:1">
8459      <div style="font-weight:700; font-size:14px">Notes de groupe</div>
8460      <div class="text-faint" style="font-size:11px; margin-top:2px">Ce qui concerne tout le monde · ${nbGroupe > 0 ? nbGroupe + ' note' + (nbGroupe > 1 ? 's' : '') : 'aucune note'}</div>
8461    </div>
8462    <span class="text-faint" style="font-size:18px">→</span>
8463  </div>`;
8464
8465  html += `<div class="card" style="padding:0; overflow:hidden">`;
8466  participants.sort((a, b) => getRankIndex(b.classement) - getRankIndex(a.classement)).forEac
8466h(j => {
8467    const obs = observationsJoueur(j.id);
8468    const nb = obs.length;
8469    html += `<div class="match-row" data-ui-click="openCarnetObservations(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="cursor:pointer; display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid var(--border)">
8470      <div style="flex:1">
8471        <div><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong> <span class="text-faint" style="font-size:12px">${escapeHtml(j.classement || 'NC')}</span></div>
8472        <div class="text-faint" style="font-size:11px; margin-top:2px">${nb > 0 ? nb + ' note' + (nb > 1 ? 's' : '') : 'aucune note'}</div>
8473      </div>
8474      <span class="text-faint" style="font-size:18px">→</span>
8475    </div>`;
8476  });
8477  html += `</div>`;
8478  return html;
8479}
8480
8481// Carnet d'un joueur : ajout rapide (thème + texte) + historique horodaté.
8482function openCarnetObservations(joueurId, retourAuBilan) {
8483  const j = joueurById(joueurId);
8484  const ev = getActiveEvenement();
8485  if (!j || !ev) { toast('Joueur ou édition introuvable', true); return; }
8486  // Chaque nouvelle saisie repart sur Tactique afin que le thème affiché et le
8487  // thème enregistré restent identiques après avoir ouvert un autre carnet.
8488  etatApp.definirThemeObservation('ajout', 'tactique');
8489  const obs = observationsJoueur(joueurId);
8490
8491  let html = `<div class="modal-header">
8492    <h3 class="modal-title">📝 ${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</h3>
8493    ${retourAuBilan
8494      ? `<button class="modal-close" data-ui-click="openModalBilanPalmyre(${escapeHtml(encoderArgumentTexteUI(joueurId))})">×</button>`
8495      : '<button class="modal-close" data-ui-click="closeModal()">×</button>'}
8496  </div>`;
8497  if (retourAuBilan) {
8498    html += `<button class="btn btn-sm btn-full mb-2" data-ui-click="openModalBilanPalmyre(${escapeHtml(encoderArgumentTexteUI(joueurId))})">← Retour au bilan</button>`;
8499  }
8500
8501  // Zone d'ajout rapide
8502  html += `<div class="card mb-2" style="padding:12px">
8503    <div class="text-faint" style="font-size:12px; margin-bottom:8px">Nouvelle observation</div>
8504    <div class="pill-group" id="obsThemePills" style="margin-bottom:10px">
8505      ${OBS_THEMES_JOUEUR.map((t, i) => `<span class="pill ${i === 0 ? 'active' : ''}" data-theme="${t.cle}" data-ui-click="selectObsTheme(this)">${t.icone} ${t.label}</span>`).join('')}
8506    </div>
8507    <div class="grid-2" style="margin-bottom:10px">
8508      <div>
8509        <label class="form-label">Lieu</label>
8510        <select id="obsLieu" style="width:100%">
8511          <option value="">— non précisé —</option>
8512          ${LIEUX_PALMYRE.map(l => `<option value="${escapeHtml(l)}">${escapeHtml(l)}</option>`).join('')}
8513        </select>
8514      </div>
8515      <div>
8516        <label class="form-label">Classement adversaire</label>
8517        <select id="obsAdvClass" style="width:100%">
8518          <option value="">— non précisé —</option>
8519          ${CLASSEMENTS.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('')}
8520        </select>
8521      </div>
8522    </div>
8523    <textarea id="obsTexte" rows="3" placeholder="Ce que tu observes en direct..." style="width:100%"></textarea>
8524    <button class="btn btn-primary btn-full mt-1" data-ui-click="ajouterObservation(${escapeHtml(encoderArgumentTexteUI(joueurId))})">+ Ajouter la note</button>
8525  </div>
8526
8527  <div class="card mb-2" style="padding:12px; border-left:3px solid var(--orange)">
8528    <div style="font-weight:700; font-size:13px; margin-bottom:4px">🎯 Axe de travail (envoyé au joueur)</div>
8529    <div class="text-faint" style="font-size:11px; margin-bottom:8px">À faire juste après le match, en autonomie. Le joueur reçoit une notification, le voit sur son profil et coche quand c'est fait.</div>
8530    <textarea id="axeTexte" rows="2" placeholder="Ex : 10 min de volées avec un partenaire, 20 lancers sur cible..." style="width:100%"></textarea>
8531    <button class="btn btn-primary btn-full mt-1" data-ui-click="envoyerAxeTravail(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img class="btn-action-art" src="assets/v4/nav/observations.webp" alt="">Envoyer au joueur</button>
8532    <div id="axesListe" style="margin-top:10px">${renderAxesListe(joueurId)}</div>
8533  </div>`;
8534
8535  // Historique
8536  html += `<div class="section-title">Historique de la semaine</div>`;
8537  html += `<div id="obsHistorique">${renderObsHistorique(joueurId)}</div>`;
8538
8539  showModal(html);
8540}
8541
8542// Rendu de l'historique (séparé pour rafraîchir sans rouvrir la modale).
8543function renderObsHistorique(joueurId) {
8544  const obs = observationsJoueur(joueurId);
8545  if (obs.length === 0) {
8546    return `<div class="text-faint text-center" style="padding:16px; font-size:13px">Aucune observation pour le moment.</div>`;
8547  }
8548  let html = '<div style="display:flex; flex-direction:column; gap:8px">';
8549  obs.forEach(o => {
8550    const meta = obsThemeMeta(o.theme);
8551    const d = o.created_at ? new Date(o.created_at) : null;
8552    const dateStr = d ? `${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}` : '';
8553    // Sous-ligne lieu / classement adversaire (affichés seulement si renseignés)
8554    const infos = [];
8555    if (o.lieu) infos.push(`📍 ${escapeHtml(o.lieu)}`);
8556    if (o.adversaire_class) infos.push(`🎾 adv. ${escapeHtml(o.adversaire_class)}`);
8557    const infosLigne = infos.length ? `<div class="text-faint" style="font-size:11px; margin-bottom:4px">${infos.join(' · ')}</div>` : '';
8558    html += `<div class="card" style="padding:10px 12px">
8559      <div class="row-between" style="margin-bottom:4px">
8560        <span class="badge badge-orange" style="font-size:10px">${meta.icone} ${escapeHtml(meta.label)}</span>
8561        <span class="text-faint" style="font-size:11px">${dateStr} · <span data-ui-click="openEditObservation(${escapeHtml(encoderArgumentTexteUI(o.id))},${escapeHtml(encoderArgumentTexteUI(joueurId))})" style="cursor:pointer; color:var(--orange-text)">modifier</span> · <span data-ui-click="supprimerObservation(${escapeHtml(encoderArgumentTexteUI(o.id))},${escapeHtml(encoderArgumentTexteUI(joueurId))})" style="cursor:pointer; color:var(--danger-text)">supprimer</span></span>
8562      </div>
8563      ${infosLigne}
8564      <div style="font-size:13px; white-space:pre-wrap">${escapeHtml(o.texte)}</div>
8565    </div>`;
8566  });
8567  html += '</div>';
8568  return html;
8569}
8570
8571function selectObsTheme(el) {
8572  document.querySelectorAll('#obsThemePills .pill').forEach(p => p.classList.remove('active'));
8573  el.classList.add('active');
8574  etatApp.definirThemeObservation('ajout', el.dataset.theme);
8575}
8576
8577async function ajouterObservation(joueurId) {
8578  const ev = getActiveEvenement();
8579  if (!ev) { toast('Édition introuvable', true); return; }
8580  const texte = (document.getElementById('obsTexte').value || '').trim();
8581  if (!texte) { toast('Écris une observation', true); return; }
8582  const lieu = (document.getElementById('obsLieu') || {}).value || null;
8583  const advClass = (document.getElementById('obsAdvClass') || {}).value || null;
8584
8585  const { error } = await db.from('observations_palmyre').insert({
8586    evenement_id: ev.id,
8587    joueur_id: joueurId,
8588    theme: selectionsPalmyre.ajoutTheme,
8589    texte: texte,
8590    lieu: lieu,
8591    adversaire_class: advClass,
8592    coach_id: session.user.id
8593  });
8594  if (error) { toastErreur(error); return; }
8595
8596  document.getElementById('obsTexte').value = '';
8597  toast('Observation ajoutée');
8598  await reloadTables(['observations_palmyre']);
8599  // Rafraîchir uniquement l'historique
8600  const histo = document.getElementById('obsHistorique');
8601  if (histo) histo.innerHTML = renderObsHistorique(joueurId);
8602}
8603
8604async function supprimerObservation(obsId, joueurId) {
8605  if (!(await demanderConfirmation('Supprimer cette observation ?'))) return;
8606  const { error } = await db.from('observations_palmyre').delete().eq('id', obsId);
8607  if (error) { toastErreur(error); return; }
8608  toast('Observation supprimée');
8609  await reloadTables(['observations_palmyre']);
8610  const histo = document.getElementById('obsHistorique');
8611  if (histo) histo.innerHTML = renderObsHistorique(joueurId);
8612}
8613
8614// Ouvre une modale d'édition d'une observation existante (thème, lieu, classement adv., texte).
8615function openEditObservation(obsId, joueurId) {
8616  const o = observationPalmyreById(obsId);
8617  if (!o) { toast('Observation introuvable', true); return; }
8618  etatApp.definirThemeObservation('edition', o.theme || 'tactique');
8619
8620  let html = `<div class="modal-header">
8621    <h3 class="modal-title">✏️ Modifier l'observation</h3>
8622    <button class="modal-close" data-ui-click="closeModal()">×</button>
8623  </div>`;
8624  html += `<div class="card" style="padding:12px">
8625    <div class="pill-group" id="obsEditThemePills" style="margin-bottom:10px">
8626      ${OBS_THEMES_JOUEUR.map(t => `<span class="pill ${o.theme === t.cle ? 'active' : ''}" data-theme="${t.cle}" data-ui-click="selectObsEditTheme(this)">${t.icone} ${t.label}</span>`).join('')}
8627    </div>
8628    <div class="grid-2" style="margin-bottom:10px">
8629      <div>
8630        <label class="form-label">Lieu</label>
8631        <select id="obsEditLieu" style="width:100%">
8632          <option value="">— non précisé —</option>
8633          ${LIEUX_PALMYRE.map(l => `<option value="${escapeHtml(l)}" ${o.lieu === l ? 'selected' : ''}>${escapeHtml(l)}</option>`).join('')}
8634        </select>
8635      </div>
8636      <div>
8637        <label class="form-label">Classement adversaire</label>
8638        <select id="obsEditAdvClass" style="width:100%">
8639          <option value="">— non précisé —</option>
8640          ${CLASSEMENTS.map(c => `<option value="${escapeHtml(c)}" ${o.adversaire_class === c ? 'selected' : ''}>${escapeHtml(c)}</option>`).join('')}
8641        </select>
8642      </div>
8643    </div>
8644    <textarea id="obsEditTexte" rows="4" style="width:100%">${escapeHtml(o.texte || '')}</textarea>
8645    <div style="display:flex; gap:8px; margin-top:10px">
8646      <button class="btn" style="flex:1" data-ui-click="openCarnetObservations(${escapeHtml(encoderArgumentTexteUI(joueurId))})">Annuler</button>
8647      <button class="btn btn-primary" style="flex:1" data-ui-click="enregistrerEditObservation(${escapeHtml(encoderArgumentTexteUI(obsId))},${escapeHtml(encoderArgumentTexteUI(joueurId))})">Enregistrer</button>
8648    </div>
8649  </div>`;
8650  showModal(html);
8651}
8652
8653function selectObsEditTheme(el) {
8654  document.querySelectorAll('#obsEditThemePills .pill').forEach(p => p.classList.remove('active'));
8655  el.classList.add('active');
8656  etatApp.definirThemeObservation('edition', el.dataset.theme);
8657}
8658
8659async function enregistrerEditObservation(obsId, joueurId) {
8660  const texte = (document.getElementById('obsEditTexte').value || '').trim();
8661  if (!texte) { toast('Le texte ne peut pas être vide', true); return; }
8662  const lieu = (document.getElementById('obsEditLieu') || {}).value || null;
8663  const advClass = (document.getElementById('obsEditAdvClass') || {}).value || null;
8664
8665  const { error } = await updateConfirmee(db.from('observations_palmyre').update({
8666    theme: selectionsPalmyre.editTheme,
8667    texte: texte,
8668    lieu: lieu,
8669    adversaire_class: advClass
8670  }).eq('id', obsId));
8671  if (error) { toastErreur(error); return; }
8672
8673  toast('Observation modifiée');
8674  await reloadTables(['observations_palmyre']);
8675  // Revenir au carnet du joueur
8676  openCarnetObservations(joueurId);
8677}
8678
8679function statsPalmyreJoueur(joueurId) {
8680  const ev = getActiveEvenement();
8681  if (!ev) return { total: 0, victoires: 0, defaites: 0, perfs: 0 };
8682  // WO exclus : un forfait n'est pas un match joué (ni une victoire) dans les stats.
8683  const ms = matchsCompletsDuJoueur(joueurId).filter(m => m.evenement_id === ev.id);
8684  return TennisDomainRules.statistiquesSportives(ms, m => classementADate(joueurId, m.date));
8685}
8686
8687// Récupère le bilan d'événement existant pour un joueur sur l'édition active.
8688function bilanPalmyreDuJoueur(joueurId) {
8689  const ev = getActiveEvenement();
8690  if (!ev) return null;
8691  return bilanPalmyreDuJoueurEvenement(joueurId, ev.id);
8692}
8693
8694function renderBilansPalmyre() {
8695  if (!isCoach()) return '';
8696  const ev = getActiveEvenement();
8697  if (!ev) return `<div class="empty"><span class="icon">🏖️</span>Aucune édition active.</div>`;
8698
8699  const participants = joueursActifsDansEnvCourant();
8700  if (participants.length === 0) {
8701    return `<div class="empty"><span class="icon">🏖️</span>Aucun participant à ${escapeHtml(ev.nom)}.<br>
8702      <span class="text-faint">Coche « Participe à la Palmyre » sur la fiche des joueurs concernés.</span></div>`;
8703  }
8704
8705  let html = renderUxScreenHeading('Évaluation', 'Bilans Palmyre') + `<div class="card mb-2" style="padding:12px 14px; border-left:3px solid var(--orange)">
8706    <div style="font-weight:700; font-size:15px">🏖️ Bilans de fin de semaine · ${escapeHtml(ev.nom)}</div>
8707    <div class="text-faint" style="font-size:12px; margin-top:2px">Un bilan par participant : 5 axes notés + commentaire. Les stats sont calculées automatiquement.</div>
8708  </div>`;
8709
8710  // Comparaison inter-éditions disponible ?
8711  const autresEditions = (listeSure(cache.evenements)).filter(e => e.id !== ev.id);
8712  if (autresEditions.length > 0) {
8713    html += `<div class="card clickable mb-2" data-ui-click="openComparaisonEditions()" style="padding:12px 14px; display:flex; align-items:center; gap:10px; cursor:pointer">
8714      <span style="font-size:20px">📊</span>
8715      <div style="flex:1"><strong>Comparer les éditions</strong><div class="text-faint" style="font-size:11px">Voir l'évolution des participants d'une année sur l'autre</div></div>
8716      <span class="text-faint" style="font-size:18px">→</span>
8717    </div>`;
8718  }
8719
8720  html += `<div class="card" style="padding:0; overflow:hidden">`;
8721  participants.sort((a, b) => getRankIndex(b.classement) - getRankIndex(a.classement)).forEach(j => {
8722    const bilan = bilanPalmyreDuJoueur(j.id);
8723    const stats = statsPalmyreJoueur(j.id);
8724    html += `<div class="match-row" data-ui-click="openModalBilanPalmyre(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="cursor:pointer; display:flex; align-items:center; gap:10px; padding:12px 14px; border-bottom:1px solid var(--border)">
8725      <div style="flex:1">
8726        <div><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong> <span class="text-faint" style="font-size:12px">${escapeHtml(j.classement || 'NC')}</span></div>
8727        <div class="text-faint" style="font-size:11px; margin-top:2px">${stats.total} match${stats.total > 1 ? 's' : ''} · ${stats.victoires}V ${stats.defaites}D${stats.perfs > 0 ? ' · ' + stats.perfs + ' perf' + (stats.perfs > 1 ? 's' : '') : ''}</div>
8728      </div>
8729      ${!bilan
8730        ? `<span class="badge" style="font-size:10px; background:var(--bg-input); color:var(--text-dim)">À faire</span>`
8731        : (bilan.statut === 'publie'
8732            ? `<span class="badge badge-success" style="font-size:10px">✓ Publié</span>`
8733            : `<span class="badge badge-warning" style="font-size:10px">✎ Brouillon</span>`)}
8734    </div>`;
8735  });
8736  html += `</div>`;
8737  return html;
8738}
8739
8740function openModalBilanPalmyre(joueurId) {
8741  const j = joueurById(joueurId);
8742  const ev = getActiveEvenement();
8743  if (!j || !ev) { toast('Joueur ou édition introuvable', true); return; }
8744  const bilan = bilanPalmyreDuJoueur(joueurId);
8745  const stats = statsPalmyreJoueur(joueurId);
8746
8747  // Un brouillon de saisie prime sur les valeurs enregistrées (retour du carnet).
8748  const draft = gestionnaireBrouillons.prendreBilan(joueurId);
8749  const estPublie = !!(bilan && bilan.statut === 'publie');
8750
8751  const axes = [
8752    ['note_technique', 'Technique / tactique'],
8753    ['note_physique', 'Physique'],
8754    ['note_mental', 'Mental'],
8755    ['note_performance', 'Performance'],
8756    ['note_savoir_etre', 'Savoir-être / vivre']
8757  ];
8758
8759  let html = `<div class="modal-header">
8760    <h3 class="modal-title">🏖️ Bilan ${escapeHtml(ev.nom)} — ${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</h3>
8761    <button class="modal-close" data-ui-click="closeModal()">×</button>
8762  </div>
8763  <form id="formBilanPalmyre" data-ui-submit="saveBilanPalmyre(event, ${escapeHtml(encoderArgumentTexteUI(joueurId))})">
8764
8765    <div class="section-title">Synthèse chiffrée de la semaine</div>
8766    <div class="card" style="font-size:13px">
8767      <div class="grid-2">
8768        <div><strong>Matches joués :</strong> ${stats.total}</div>
8769        <div><strong>Victoires :</strong> ${stats.victoires}</div>
8770        <div><strong>Défaites :</strong> ${stats.defaites}</div>
8771        <div><strong>Perfs :</strong> ${stats.perfs}</div>
8772      </div>
8773      <div class="text-faint" style="font-size:11px; margin-top:8px">Calculé automatiquement à partir des matchs Palmyre saisis pour ce joueur.</div>
8774    </div>
8775    ${observationsJoueur(joueurId).length > 0 ? `<button type="button" class="btn btn-sm btn-full mb-2" data-ui-click="ouvrirObsDepuisBilan(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img class="btn-action-art" src="assets/v4/nav/observations.webp" alt="">Consulter mes observations (${observationsJoueur(joueurId).length}) pour m'aider à décider</button>` : ''}
8776
8777    <div class="section-title">Évaluation 5 axes (notation 1-10)</div>
8778    ${axes.map(([name, label]) => {
8779      const val = draft ? draft.notes[name] : (bilan && bilan[name] ? bilan[name] : 5);
8780      const valLabel = draft ? draft.notes[name] : (bilan && bilan[name] ? bilan[name] : '-');
8781      return `
8782      <div class="form-row">
8783        <label class="form-label">${label} : <span id="bpVal_${name}" class="text-orange">${valLabel}</span>/10</label>
8784        <input type="range" name="${name}" min="1" max="10" step="1" value="${val}" data-ui-input="updateRangeValue(this, ${escapeHtml(encoderArgumentTexteUI(`bpVal_${name}`))})" style="width:100%">
8785      </div>`;
8786    }).join('')}
8787
8788    <div class="section-title">Commentaire du coach</div>
8789    <button type="button" class="btn btn-sm btn-full mb-1" id="btnBilanIA" data-ui-click="genererBilanIA(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Proposer un bilan (IA)</button>
8790    <div class="text-faint" style="font-size:11px; margin-bottom:6px">Une proposition est rédigée à partir de tes observations et des résultats de la semaine, avec un paragraphe par domaine. Elle est entièrement modifiable avant enregistrement.</div>
8791    <div class="form-row">
8792      <textarea name="commentaire_coach" id="bilanPalmyreCommentaire" rows="14" placeholder="Bilan de la semaine, progrès observés, points à travailler, état d'esprit...">${escapeHtml(draft ? draft.commentaire : (bilan ? (bilan.commentaire_coach || '') : ''))}</textarea>
8793    </div>
8794
8795    <div class="flex gap-1 mt-2">
8796      ${bilan ? `<button type="button" class="btn btn-danger" data-ui-click="deleteBilanPalmyre(${escapeHtml(encoderArgumentTexteUI(bilan.id))})">Supprimer</button>` : ''}
8797      ${bilan ? `<button type="button" class="btn" data-ui-click="exportBilanPalmyrePDF(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">PDF</button>` : ''}
8798      <button type="button" class="btn flex-1" data-ui-click="saveBrouillonBilanPalmyre(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img class="btn-action-art" src="assets/v4/nav/reports.webp" alt="">Brouillon</button>
8799      <button type="submit" class="btn btn-primary flex-1">${estPublie ? 'Enregistrer' : '<img class="btn-action-art" src="assets/v4/nav/reports.webp" alt="">Publier'}</button>
8800    </div>
8801    <div class="text-faint" style="font-size:11px; text-align:center; margin-top:6px">
8802      « Brouillon » enregistre sans rien envoyer : tu peux revenir plus tard. ${estPublie ? 'Ce bilan est déjà publié : le joueur y a accès.' : '« Publier » rend le bilan visible au joueur et prévient la famille (notification + email).'}
8803    </div>
8804  </form>`;
8805  showModal(html);
8806}
8807
8808// Capture l'état courant du formulaire de bilan (notes + commentaire), pour le restaurer
8809// après un aller-retour vers le carnet d'observations.
8810function capturerBrouillonBilan(joueurId) {
8811  const f = document.getElementById('formBilanPalmyre');
8812  if (!f) return null;
8813  const notes = {};
8814  ['note_technique', 'note_physique', 'note_mental', 'note_performance', 'note_savoir_etre']
8815    .forEach(n => { notes[n] = f[n] ? parseInt(f[n].value, 10) : 5; });
8816  return { joueurId, notes, commentaire: f.commentaire_coach ? f.commentaire_coach.value : '' };
8817}
8818
8819// Ouvre le carnet d'observations DEPUIS le bilan, sans perdre la saisie en cours.
8820function ouvrirObsDepuisBilan(joueurId) {
8821  gestionnaireBrouillons.conserverBilan(capturerBrouillonBilan(joueurId));
8822  openCarnetObservations(joueurId, true);
8823}
8824
8825// Propose un bilan individuel rédigé par l'IA (un paragraphe par domaine), à partir des
8826// observations du coach et des résultats du joueur sur l'édition. Le texte atterrit dans
8827// le champ commentaire : le coach le relit, le modifie et l'enregistre lui-même.
8828// La clé API reste côté serveur (Edge Function "bilan-ia"), jamais dans le navigateur.
8829async function genererBilanIA(joueurId) {
8830  if (!isCoach()) return;
8831  const ev = getActiveEvenement();
8832  if (!ev) { toast('Édition introuvable', true); return; }
8833  const j = joueurById(joueurId);
8834  if (!j) { toast('Joueur introuvable', true); return; }
8835
8836  const zone = document.getElementById('bilanPalmyreCommentaire');
8837  if (!zone) return;
8838  // Ne jamais écraser un texte déjà saisi sans accord explicite.
8839  if (zone.value.trim() && !(await demanderConfirmation('Remplacer le commentaire actuel par une nouvelle proposition ?', { titre: 'Remplacer le commentaire', libelle: 'Remplacer' }))) return;
8840
8841  const obs = observationsJoueur(joueurId);
8842  const stats = statsPalmyreJoueur(joueurId);
8843  const matchs = (listeSure(cache.matchesSaison))
8844    .filter(m => m.joueur_id === joueurId && m.evenement_id === ev.id)
8845    .sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')));
8846
8847  if (obs.length === 0 && matchs.length === 0) {
8848    toast('Aucune observation ni match sur ce stage : rien à synthétiser', true);
8849    return;
8850  }
8851
8852  const btn = document.getElementById('btnBilanIA');
8853  if (btn) { btn.disabled = true; btn.textContent = '✨ Rédaction en cours...'; }
8854
8855  try {
8856    const { data, error } = await db.functions.invoke('bilan-ia', {
8857      body: { joueur_id: joueurId, evenement_id: ev.id }
8858    });
8859    if (error) { toastErreur(error); return; }
8860    if (!data || !data.bilan) { toast('Proposition vide, réessaie', true); return; }
8861    zone.value = data.bilan;
8862    toast('Proposition générée, à toi de l\'ajuster ✏️');
8863  } catch (e) {
8864    toast('Proposition indisponible : ' + e.message, true);
8865  } finally {
8866    if (btn) { btn.disabled = false; btn.textContent = '✨ Proposer un bilan (IA)'; }
8867  }
8868}
8869
8870// Publication : rend le bilan visible au joueur et prévient la famille.
8871async function saveBilanPalmyre(e, joueurId) {
8872  e.preventDefault();
8873  await enregistrerBilanPalmyre(joueurId, 'publie');
8874}
8875
8876// Brouillon : enregistre le travail en cours SANS rien envoyer au joueur ni aux parents.
8877// Le joueur ne voit que les bilans au statut "publie" (filtrage déjà en place côté joueur).
8878async function saveBrouillonBilanPalmyre(joueurId) {
8879  await enregistrerBilanPalmyre(joueurId, 'brouillon');
8880}
8881
8882async function enregistrerBilanPalmyre(joueurId, statut) {
8883  const f = document.getElementById('formBilanPalmyre');
8884  if (!f) { toast('Formulaire introuvable', true); return; }
8885  const verrou = verrouillerSoumission(f, statut === 'publie' ? 'Publication…' : 'Enregistrement…');
8886  if (!verrou) return;
8887  const ev = getActiveEvenement();
8888  if (!ev) { deverrouillerSoumission(verrou); toast('Édition introuvable', true); return; }
8889  const stats = statsPalmyreJoueur(joueurId);
8890  const bilan = bilanPalmyreDuJoueur(joueurId);
8891  const dejaPublie = !!(bilan && bilan.statut === 'publie');
8892
8893  const num = (v) => v === '' || v == null ? null : parseInt(v, 10);
8894  const obj = {
8895    evenement_id: ev.id,
8896    joueur_id: joueurId,
8897    note_technique: num(f.note_technique.value),
8898    note_physique: num(f.note_physique.value),
8899    note_mental: num(f.note_mental.value),
8900    note_performance: num(f.note_performance.value),
8901    note_savoir_etre: num(f.note_savoir_etre.value),
8902    matches_total: stats.total,
8903    victoires: stats.victoires,
8904    defaites: stats.defaites,
8905    perfs: stats.perfs,
8906    commentaire_coach: f.commentaire_coach.value.trim(),
8907    statut: statut
8908  };
8909
8910  let error;
8911  let bilanEnregistreId = bilan?.id || null;
8912  try {
8913    if (bilan) {
8914      obj.updated_at = new Date().toISOString();
8915      ({ error } = await updateConfirmee(db.from('bilans_evenement').update(obj).eq('id', bilan.id)));
8916    } else {
8917      const creation = await db.from('bilans_evenement').insert(obj).select('id').single();
8918      error = creation.error;
8919      bilanEnregistreId = creation.data?.id || null;
8920    }
8921  } finally {
8922    deverrouillerSoumission(verrou);
8923  }
8924  if (error) { toastErreur(error); return; }
8925
8926  // Notification + email UNIQUEMENT à la première publication : un brouillon ne prévient
8927  // personne, et corriger un bilan déjà publié ne re-sollicite pas la famille.
8928  const j = joueurById(joueurId);
8929  const doitPrevenir = (statut === 'publie') && !dejaPublie;
8930
8931  if (doitPrevenir && j && j.user_id) {
8932    try {
8933      await db.from('notifications').insert({
8934        user_id: j.user_id,
8935        type: 'bilan_publie',
8936        titre: `🏖️ Ton bilan ${ev.nom} est disponible`,
8937        message: `Ton coach a publié ton bilan de fin de semaine de ${ev.nom}. Va le consulter dans l'app !`,
8938        lien_type: 'bilan_evenement',
8939        lien_id: ev.id
8940      });
8941    } catch (e) { journal.warn('[bilan palmyre] notif:', e); }
8942  }
8943
8944  // Email du bilan (stage intensif : on prévient le joueur + parents, comme en compét).
8945  if (doitPrevenir && j) {
8946    emailBilanPalmyrePublie(j, { ...ev, bilan_id: bilanEnregistreId }, stats);
8947  }
8948
8949  gestionnaireBrouillons.effacerFormulaire('bilan', joueurId); // la saisie est enregistrée, plus rien à restaurer
8950  closeModal();
8951  toast(statut === 'brouillon'
8952    ? 'Brouillon enregistré (rien n\'a été envoyé au joueur) 💾'
8953    : (dejaPublie ? 'Bilan mis à jour' : 'Bilan publié 📤'));
8954  await reloadTables(['bilans_evenement']);
8955  render();
8956}
8957
8958async function deleteBilanPalmyre(bilanId) {
8959  if (!(await demanderConfirmation('Supprimer ce bilan ?'))) return;
8960  const { error } = await db.from('bilans_evenement').delete().eq('id', bilanId);
8961  if (error) { toastErreur(error); return; }
8962  closeModal();
8963  toast('Bilan supprimé');
8964  await reloadTables(['bilans_evenement']);
8965  render();
8966}
8967
8968// Export PDF d'un bilan Palmyre (calqué sur exportBilanPDF de l'académie).
8969async function exportBilanPalmyrePDF(joueurId) { return appelerExportPdf('exportBilanPalmyrePDF', joueurId); }
8970
8971// Comparaison inter-éditions : pour chaque participant présent sur plusieurs
8972// éditions, montre l'évolution de ses notes moyennes et de ses stats.
8973function openComparaisonEditions() {
8974  const editions = (listeSure(cache.evenements)).slice().sort((a, b) => (a.date_debut || '').localeCompare(b.date_debut || ''));
8975  const bilans = listeSure(cache.bilansEvenement);
8976  if (editions.length < 2) {
8977    toast('Il faut au moins deux éditions pour comparer', true);
8978    return;
8979  }
8980
8981  // Joueurs ayant au moins un bilan sur une édition
8982  const joueurIds = [...new Set(bilans.map(b => b.joueur_id))];
8983  const moyenne = (b) => {
8984    const notes = [b.note_technique, b.note_physique, b.note_mental, b.note_performance, b.note_savoir_etre].filter(n => n != null);
8985    return notes.length ? (notes.reduce((a, c) => a + c, 0) / notes.length) : null;
8986  };
8987
8988  let html = `<div class="modal-header">
8989    <h3 class="modal-title">📊 Comparaison des éditions</h3>
8990    <button class="modal-close" data-ui-click="closeModal()">×</button>
8991  </div>
8992  <div class="text-faint mb-2" style="font-size:12px">Évolution de la note moyenne (5 axes) et du ratio de victoires par participant.</div>`;
8993
8994  if (joueurIds.length === 0) {
8995    html += `<div class="empty"><span class="icon">📊</span>Aucun bilan enregistré pour le moment.</div>`;
8996    showModal(html);
8997    return;
8998  }
8999
9000  html += `<div class="card" style="padding:0; overflow-x:auto"><table class="overview-table" style="width:100%; border-collapse:collapse; font-size:12px">
9001    <thead style="background:var(--bg-section)"><tr>
9002      <th style="text-align:left; padding:8px">Joueur</th>
9003      ${editions.map(ed => `<th style="text-align:center; padding:8px">${escapeHtml(ed.nom)}</th>`).join('')}
9004    </tr></thead><tbody>`;
9005
9006  joueurIds.forEach(jid => {
9007    const j = joueurById(jid);
9008    if (!j) return;
9009    html += `<tr style="border-top:1px solid var(--border)"><td style="padding:8px"><strong>${escapeHtml(j.prenom)}</strong> ${escapeHtml(j.nom)}</td>`;
9010    editions.forEach(ed => {
9011      const b = bilans.find(x => x.joueur_id === jid && x.evenement_id === ed.id);
9012      if (!b) {
9013        html += `<td style="text-align:center; padding:8px; color:var(--text-faint)">–</td>`;
9014      } else {
9015        const moy = moyenne(b);
9016        const ratio = b.matches_total > 0 ? Math.round((b.victoires / b.matches_total) * 100) : 0;
9017        html += `<td style="text-align:center; padding:8px">
9018          <div><strong>${moy != null ? moy.toFixed(1) : '–'}</strong><span class="text-faint" style="font-size:10px">/10</span></div>
9019          <div class="text-faint" style="font-size:10px">${b.victoires}V ${b.defaites}D · ${ratio}%</div>
9020        </td>`;
9021      }
9022    });
9023    html += `</tr>`;
9024  });
9025  html += `</tbody></table></div>`;
9026  showModal(html);
9027}
9028
9029function renderBilansCoach() {
9030  const entete = renderUxScreenHeading('Évaluation individuelle', 'Bilans des joueurs');
9031  // Les bilans trimestriels concernent l'académie : on ne garde QUE les joueurs
9032  // affectés à un groupe de compétition (Tremplin / Accession Élite / Élite).
9033  // Les joueurs sans groupe sont exclus.
9034  const GROUPES_COMPET = ORDRE_GROUPES;
9035  const joueursActifs = cache.joueurs.filter(j =>
9036    (!j.statut || j.statut === 'actif') && GROUPES_COMPET.includes(j.groupe)
9037  );
9038  if (joueursActifs.length === 0) {
9039    return entete + `<div class="empty"><span class="icon">📋</span>Aucun joueur en groupe compétition.<br><span class="text-faint">Affecte des joueurs à un groupe (Tremplin, Accession Élite ou Élite) pour saisir leurs bilans.</span></div>`;
9040  }
9041
9042  // Filtre saison
9043  const saisonsExistantes = [...new Set(cache.bilans.map(b => b.saison))].sort().reverse();
9044  const saisonCourante = getSaisonCourante();
9045  if (!saisonsExistantes.includes(saisonCourante)) saisonsExistantes.unshift(saisonCourante);
9046  const filtreSaison = $('filtreSaisonBilan')?.value || saisonCourante;
9047
9048  let html = entete + `<div class="card mb-2"><div class="form-row" style="margin-bottom:0">
9049    <label class="form-label">Saison</label>
9050    <select id="filtreSaisonBilan" data-ui-change="render()">
9051      ${saisonsExistantes.map(s => `<option value="${s}" ${s === filtreSaison ? 'selected' : ''}>${s}</option>`).join('')}
9052    </select>
9053  </div></div>`;
9054
9055  // Tableau : ligne par joueur, colonne par trimestre
9056  html += `<div class="section-title">Matrice des bilans · saison ${filtreSaison}</div>`;
9057  html += `<div class="text-dim mb-2" style="font-size:13px">Touche une cellule pour créer ou éditer un bilan trimestriel.</div>`;
9058
9059  html += `<div class="card" style="padding:0; overflow-x:auto">
9060    <table style="width:100%; border-collapse:collapse; font-size:13px">
9061      <thead>
9062        <tr style="background:var(--bg-input); border-bottom:1px solid var(--border)">
9063          <th style="text-align:left; padding:10px 12px; min-width:140px">Joueur</th>
9064          ${['T1','T2','T3','T4'].map(t => `<th style="padding:10px 8px; text-align:center; min-width:70px">${t}</th>`).join('')}
9065          <th style="padding:10px 8px; text-align:center; min-width:80px; border-left:2px solid var(--border)">🏆 Saison</th>
9066        </tr>
9067      </thead>
9068      <tbody>
9069        ${joueursActifs.map(j => {
9070          const cells = ['T1','T2','T3','T4'].map(t => {
9071            const bilan = bilanDuJoueur(j.id, filtreSaison, t);
9072            if (!bilan) {
9073          return `<td style="padding:6px; text-align:center; border-bottom:1px solid var(--border)"><button class="btn btn-sm" data-ui-click="openModalBilan(null, ${escapeHtml(encoderArgumentTexteUI(j.id
9073))}, ${escapeHtml(encoderArgumentTexteUI(filtreSaison))}, ${escapeHtml(encoderArgumentTexteUI(t))})" style="padding:4px 8px; font-size:11px">+</button></td>`;
9074            }
9075            const statutColor = bilan.statut === 'accepte' ? 'success' : bilan.statut === 'commente' ? 'info' : bilan.statut === 'publie' ? 'warning' : 'danger';
9076            const statutLabel = { brouillon: '✎ brouillon', publie: '📤 publié', commente: '💬 commenté', accepte: '✓ accepté' }[bilan.statut] || bilan.statut;
9077            return `<td style="padding:6px; text-align:center; border-bottom:1px solid var(--border); cursor:pointer" data-ui-click="openModalBilan(${escapeHtml(encoderArgumentTexteUI(bilan.id))})"><span class="badge badge-${statutColor}" style="font-size:10px">${statutLabel}</span></td>`;
9078          }).join('');
9079          // Cellule "Saison" : bilan annuel (trimestre ANNUEL)
9080          const bilanAnnuel = bilanDuJoueur(j.id, filtreSaison, 'ANNUEL');
9081          let cellSaison;
9082          if (!bilanAnnuel) {
9083        cellSaison = `<td style="padding:6px; text-align:center; border-bottom:1px solid var(--border); border-left:2px solid var(--border)"><button class="btn btn-sm" data-ui-click="openModalBilan(null, ${escapeHtml(encoderArgumentTexteUI(j.id))}, ${escapeHtml(encoderArgumentTexteUI(filtreSaison))}, 'ANNUEL')" style="padding:4px 8px; font-size:11px" title="Bilan de saison">+</button></td>`;
9084          } else {
9085            const sc = bilanAnnuel.statut === 'accepte' ? 'success' : bilanAnnuel.statut === 'commente' ? 'info' : bilanAnnuel.statut === 'publie' ? 'warning' : 'danger';
9086            const sl = { brouillon: '✎ brouillon', publie: '📤 publié', commente: '💬 commenté', accepte: '✓ accepté' }[bilanAnnuel.statut] || bilanAnnuel.statut;
9087            cellSaison = `<td style="padding:6px; text-align:center; border-bottom:1px solid var(--border); border-left:2px solid var(--border); cursor:pointer" data-ui-click="openModalBilan(${escapeHtml(encoderArgumentTexteUI(bilanAnnuel.id))})"><span class="badge badge-${sc}" style="font-size:10px">${sl}</span></td>`;
9088          }
9089          return `<tr><td style="padding:10px 12px; border-bottom:1px solid var(--border)"><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong><br><span class="text-faint" style="font-size:11px">${escapeHtml(j.classement || 'NC')} · ${cache.config[j.groupe] ? cache.config[j.groupe].nom : 'sans groupe'}</span></td>${cells}${cellSaison}</tr>`;
9090        }).join('')}
9091      </tbody>
9092    </table>
9093  </div>`;
9094
9095  return html;
9096}
9097
9098// ============================================================
9099// SYNTHÈSE ANNUELLE — agrège les bilans trimestriels publiés d'une saison
9100// ============================================================
9101// Les 5 axes notés du bilan (clés DB note_<axe>), dans l'ordre d'affichage.
9102const BILAN_AXES = [
9103  { cle: 'performance', label: 'Performance' },
9104  { cle: 'volume', label: 'Volume / engagement' },
9105  { cle: 'technique', label: 'Technique / tactique' },
9106  { cle: 'physique', label: 'Physique' },
9107  { cle: 'mental', label: 'Mental' }
9108];
9109const ORDRE_TRIMESTRES = ['T1', 'T2', 'T3', 'T4'];
9110
9111// Construit la synthèse de la saison pour un joueur à partir des bilans
9112// trimestriels PUBLIÉS (statut 'publie' ou 'commente' ou 'accepte' — bref non brouillon).
9113function syntheseAnnuelle(joueurId, saison) {
9114  const trimestriels = (listeSure(cache.bilans))
9115    .filter(b => b.joueur_id === joueurId && b.saison === saison
9116      && ORDRE_TRIMESTRES.includes(b.trimestre)
9117      && b.statut && b.statut !== 'brouillon')
9118    .sort((a, b) => ORDRE_TRIMESTRES.indexOf(a.trimestre) - ORDRE_TRIMESTRES.indexOf(b.trimestre));
9119
9120  const evolution = TennisDomainRules.syntheseEvolutionBilans(trimestriels, BILAN_AXES);
9121  const { axes, phrase, enHausse, enBaisse, stables } = evolution;
9122
9123  // Évolution du classement : premier classement_debut connu → dernier classement_fin connu.
9124  let classementDebut = null, classementFin = null;
9125  trimestriels.forEach(b => {
9126    if (b.classement_debut && !classementDebut) classementDebut = b.classement_debut;
9127    if (b.classement_fin) classementFin = b.classement_fin;
9128  });
9129  // Fallback : classement actuel du joueur si rien dans les bilans.
9130  const joueur = joueurById(joueurId);
9131  if (!classementFin && joueur) classementFin = joueur.classement;
9132  let gainClassement = null;
9133  if (classementDebut && classementFin) {
9134    gainClassement = TennisDomainRules.ecartClassements(classementDebut, classementFin); // >0 = progression
9135  }
9136
9137  // Cumul des stats de jeu sur la saison (à partir des matchs réels, pas des bilans).
9138  const [sa1, sa2] = saison.split('-');
9139  const ms = matchesEntreDate(joueurId, `${sa1}-09-01`, `${sa2}-08-31`);
9140  const compteSaison = TennisDomainRules.statistiquesSportives(
9141    ms,
9142    joueur ? m => classementADate(joueurId, m.date) : null
9143  );
9144  const perfs = compteSaison.perfs;
9145  const victoires = compteSaison.victoires;
9146  const tauxVictoire = compteSaison.total ? compteSaison.tauxVictoire : null;
9147
9148  return {
9149    nbTrimestriels: trimestriels.length,
9150    trimestriels,
9151    axes,
9152    classementDebut, classementFin, gainClassement,
9153    nbMatchs: compteSaison.total, victoires, perfs, tauxVictoire,
9154    phrase, enHausse, enBaisse, stables
9155  };
9156}
9157
9158// ============================================================
9159// MODAL: ÉDITION BILAN (coach)
9160// ============================================================
9161function openModalBilan(bilanId, presetJoueurId, presetSaison, presetTrimestre) {
9162  if (!isCoach()) return;
9163
9164  const b = bilanId ? bilanById(bilanId) : null;
9165  const isEdit = !!b;
9166
9167  // Sélection du joueur / saison / trimestre
9168  const joueurId = b ? b.joueur_id : presetJoueurId || '';
9169  const saison = b ? b.saison : presetSaison || getSaisonCourante();
9170  const trimestre = b ? b.trimestre : presetTrimestre || getTrimestreC
9170ourant();
9171  // La période est-elle IMPOSÉE ? (clic sur le + d'une colonne de la grille, ou édition)
9172  // Si oui, on n'affiche pas de sélecteur navigable. Si on vient du bouton "+"
9173  // flottant (création libre, sans presetTrimestre), on laisse choisir.
9174  const periodeImposee = isEdit || !!presetTrimestre;
9175  const notesDecision = joueurId
9176    ? listeSure(cache.notesCoach).filter(note => note.joueur_id === joueurId).slice(0, 8)
9177    : [];
9178
9179  // Calcul auto des stats de la période (si trimestre/joueur défini)
9180  let statsAuto = { matches: 0, victoires: 0, perfs: 0, ressenti: null };
9181  if (joueurId && saison && trimestre) {
9182    // Pour le bilan ANNUEL, la période couvre toute la saison (1er sept → 31 août).
9183    let dDeb, dFin;
9184    if (trimestre === 'ANNUEL') {
9185      const [sa1, sa2] = saison.split('-');
9186      dDeb = `${sa1}-09-01`; dFin = `${sa2}-08-31`;
9187    } else {
9188      [dDeb, dFin] = getTrimestreDates(saison, trimestre);
9189    }
9190    const ms = matchesEntreDate(joueurId, dDeb, dFin);
9191    const compte = TennisDomainRules.statistiquesSportives(
9192      ms,
9193      m => classementADate(joueurId, m.date)
9194    );
9195    statsAuto.matches = compte.total;
9196    statsAuto.victoires = compte.victoires;
9197    // "Perf" = victoire contre adversaire mieux classé (heuristique simple)
9198    statsAuto.perfs = compte.perfs;
9199    statsAuto.ressenti = statsRessenti(ms); // info de lecture (moyenne, V/D, tendance)
9200  }
9201
9202  const joueurs = cache.joueurs.filter(TennisDomainRules.estJoueurActif);
9203  const groupes = groupesOrdonnes();
9204
9205  const html = `
9206    <div class="modal-header">
9207      <h3 class="modal-title">${isEdit ? (b.trimestre === 'ANNUEL' ? '🏆 Bilan annuel ' + b.saison : 'Bilan ' + b.saison + ' · ' + b.trimestre) : (trimestre === 'ANNUEL' ? '🏆 Nouveau bilan annuel' : 'Nouveau bilan')}</h3>
9208      <button class="modal-close" data-ui-click="closeModal()">×</button>
9209    </div>
9210
9211    ${!isEdit ? `
9212    <div class="grid-2">
9213      <div class="form-row">
9214        <label class="form-label">Joueur *</label>
9215        <select id="bilanJoueurId" required data-ui-change="onBilanPeriodeChange()">
9216          <option value="">Sélectionner...</option>
9217          ${joueurs.map(j => `<option value="${j.id}" ${joueurId === j.id ? 'selected' : ''}>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</option>`).join('')}
9218        </select>
9219      </div>
9220      <div class="form-row">
9221        <label class="form-label">Saison</label>
9222        <input type="text" id="bilanSaison" value="${escapeHtml(saison)}" pattern="[0-9]{4}-[0-9]{4}" required data-ui-change="onBilanPeriodeChange()">
9223      </div>
9224    </div>
9225    <div class="form-row">
9226      <label class="form-label">${trimestre === 'ANNUEL' ? 'Période' : 'Trimestre'}</label>
9227      ${periodeImposee ? `
9228        <div class="pill-group"><span class="pill active" style="cursor:default">${getTrimestreLabel(trimestre)}</span></div>
9229        <div id="bilanTrimestrePills" style="display:none"><span class="pill active" data-value="${trimestre}"></span></div>
9230      ` : `
9231        <div class="pill-group" id="bilanTrimestrePills">
9232          ${['T1','T2','T3','T4'].map(t => `<span class="pill ${t === trimestre ? 'active' : ''}" data-value="${t}" data-ui-click="selectPill(this, 'bilanTrimestrePills'); onBilanPeriodeChange()">${getTrimestreLabel(t)}</span>`).join('')}
9233        </div>
9234      `}
9235    </div>
9236    ` : `
9237    <div class="card" style="font-size:13px">
9238      <strong>${escapeHtml(joueurById(b.joueur_id)?.prenom)} ${escapeHtml(joueurById(b.joueur_id)?.nom)}</strong> · ${escapeHtml(b.saison)} · ${escapeHtml(b.trimestre)}
9239    </div>
9240    <input type="hidden" id="bilanJoueurId" value="${b.joueur_id}">
9241    <input type="hidden" id="bilanSaison" value="${b.saison}">
9242    <div id="bilanTrimestrePills" style="display:none"><span class="pill active" data-value="${b.trimestre}"></span></div>
9243    `}
9244
9245    <div class="section-title">Synthèse chiffrée de la période</div>
9246    <div id="bilanStatsZone" class="card" style="font-size:13px">
9247      <div id="bilanStatsContent">
9248        <div class="grid-2">
9249          <div><strong>Matches joués :</strong> <span id="statMatches">${b ? b.matches_periode : statsAuto.matches}</span></div>
9250          <div><strong>Victoires :</strong> <span id="statVictoires">${b ? b.victoires_periode : statsAuto.victoires}</span></div>
9251          <div><strong>
9251Perfs :</strong> <span id="statPerfs">${b ? b.perfs_periode : statsAuto.perfs}</span></div>
9252          <div><strong>Classement :</strong>
9253            <input type="text" id="bilanClassDebut" value="${b ? escapeHtml(b.classement_debut || '') : ''}" placeholder="début" style="width:60px; display:inline-block; padding:4px 6px; font-size:12px"> →
9254            <input type="text" id="bilanClassFin" value="${b ? escapeHtml(b.classement_fin || '') : ''}" placeholder="fin" style="width:60px; display:inline-block; padding:4px 6px; font-size:12px">
9255          </div>
9256        </div>
9257        <div id="bilanRessenti" style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border)">
9258          <strong style="font-size:13px">Ressenti du joueur :</strong>
9259          <div style="margin-top:4px">${renderRessentiBloc(statsAuto.ressenti)}</div>
9260        </div>
9261      </div>
9262      <div class="text-faint" style="font-size:11px; margin-top:8px">Les chiffres matches/victoires/perfs sont calculés automatiquement à partir des matches saisis. Les classements début/fin sont à renseigner manuellement.</div>
9263    </div>
9264
9265    <div id="bilanSyntheseAnnuelle" style="display:${trimestre === 'ANNUEL' ? 'block' : 'none'}">
9266      <div class="section-title">📊 Synthèse de l'année (d'après les bilans trimestriels)</div>
9267      <div id="bilanSyntheseContent" class="card" style="font-size:13px">
9268        ${joueurId ? renderSyntheseAnnuelle(joueurId, saison) : '<div class="text-faint">Sélectionne un joueur.</div>'}
9269      </div>
9270    </div>
9271
9272    <div class="section-title">📝 Notes coach utiles à la décision</div>
9273    <div class="card" style="font-size:12px">
9274      ${notesDecision.length ? notesDecision.map(note => `<div style="padding:7px 0;border-bottom:1px solid var(--border)"><div>${escapeHtml(note.contenu)}</div><div class="text-faint" style="margin-top:3px">${formatDateTime(note.created_at)}</div></div>`).join('') : '<div class="text-faint">Aucune note enregistrée pour ce joueur.</div>'}
9275      <div class="text-faint" style="margin-top:8px">Lecture seule : ajoute ou modifie les notes depuis l’onglet Notes.</div>
9276    </div>
9277
9278    <div class="section-title">Évaluation 5 axes (notation 1-10)</div>
9279    ${[
9280      ['performance','Performance sportive'],
9281      ['volume','Volume / engagement'],
9282      ['technique','Technique / Tactique'],
9283      ['physique','Physique'],
9284      ['mental','Mental / attitude']
9285    ].map(([key, label]) => `
9286      <div class="form-row">
9287        <label class="form-label">${label} : <span id="noteVal_${key}" class="text-orange">${b && b['note_'+key] ? b['note_'+key] : '-'}</span>/10</label>
9288        <input type="range" min="0" max="10" step="1" id="bilanNote_${key}" value="${b && b['note_'+key] ? b['note_'+key] : 5}" data-ui-input="updateRangeValue(this, ${escapeHtml(encoderArgumentTexteUI(`noteVal_${key}`))})" style="width:100%">
9289      </div>
9290    `).join('')}
9291
9292    <div class="section-title">Commentaire coach</div>
9293    <div class="form-row">
9294      <textarea id="bilanCommentaire" rows="5" placeholder="Synthèse du trimestre : points forts observés, axes de travail, état d'esprit, événements marquants...">${b ? escapeHtml(b.commentaire_coach || '') : ''}</textarea>
9295    </div>
9296
9297    <div class="section-title">Objectifs trimestre suivant</div>
9298    <div class="form-row">
9299      <textarea id="bilanObjectifs" rows="3" placeholder="3-5 objectifs SMART (Spécifiques, Mesurables, Atteignables, Réalistes, Temporels)">${b ? escapeHtml(b.objectifs_suivants || '') : ''}</textarea>
9300    </div>
9301
9302    <div class="section-title">Décision</div>
9303    <div class="form-row">
9304      <label class="form-label">Décision pour le trimestre suivant</label>
9305      <div class="pill-group" id="bilanDecisionPills">
9306        ${[
9307          ['a_decider','À décider', ''],
9308          ['maintien','Maintien', 'success'],
9309          ['montee','Montée', 'success'],
9310          ['descente','Descente', 'danger'],
9311          ['sortie','Sortie de l\'académie', 'danger']
9312        ].map(([key, label, cls]) => `<span class="pill ${cls} ${b && b.decision === key ? 'active' : ''}" data-value="${key}" data-ui-click="selectPill(this, 'bilanDecisionPills')">${label}</span>`).join('')}
9313      </div>
9314    </div>
9315    <div class="form-row">
9316      <label class="form-label">Groupe destination (si montée/descente)</label>
9317      <div class="pill-group" id="bilanGroupePills">
9318        <span class="pill ${b && !b.groupe_suivant ? 'active' : ''}" data-value="" data-ui-click="selectPill(this, 'bilanGroupePills')">Inchangé</span>
9319        ${groupes.map(g => `<span class="pill ${b && b.groupe_suivant === g ? 'active' : ''}" data-value="${g}" data-ui-click="selectPill(this, 'bilanGroupePills')">${escapeHtml(cache.config[g].nom)}</span>`).join('')}
9320      </div>
9321    </div>
9322
9323    ${b && b.statut !== 'brouillon' ? `
9324    <div class="section-title">Côté joueur</div>
9325    <div class="card" style="font-size:13px">
9326      <div><strong>Statut :</strong> ${b.statut === 'accepte' ? '✓ Accepté' : b.statut === 'commente' ? '💬 Commenté (non encore accepté)' : '📤 Publié, en attente'}</div>
9327      ${b.commentaire_joueur ? `<div style="margin-top:8px; padding:8px; background:var(--bg-input); border-radius:var(--radius-sm); border-left:3px solid var(--info)"><div class="text-faint" style="font-size:11px; margin-bottom:4px">Commentaire du joueur :</div>${escapeHtml(b.commentaire_joueur)}</div>` : ''}
9328      ${b.date_acceptation ? `<div class="text-faint" style="font-size:11px; margin-top:6px">Accepté le ${formatDate(b.date_acceptation)}</div>` : ''}
9329    </div>
9330    ` : ''}
9331
9332    <div class="flex gap-1 mt-2" style="flex-wrap:wrap">
9333      ${isEdit ? `<button type="button" class="btn btn-danger" data-ui-click="deleteBilan(${escapeHtml(encoderArgumentTexteUI(b.id))})">Supprimer</button>` : ''}
9334      ${isEdit && b.statut !== 'brouillon' ? `<button type="button" class="btn" data-ui-click="exportBilanPDF(${escapeHtml(encoderArgumentTexteUI(b.id))})"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">PDF</button>` : ''}
9335      <button type="button" class="btn flex-1" data-ui-click="closeModal()">Annuler</button>
9336      <button type="button" class="btn flex-1" data-ui-click="saveBilan(${escapeHtml(encoderArgumentTexteUI(bilanId || ''))}, 'brouillon')"><img class="btn-action-art" src="assets/v4/nav/reports.webp" alt="">${isEdit && b.statut === 'brouillon' ? 'Enregistrer brouillon' : 'Brouillon'}</button>
9337      <button type="button" class="btn btn-primary flex-1" data-ui-click="saveBilan(${escapeHtml(encoderArgumentTexteUI(bilanId || ''))}, 'publie')"><img class="btn-action-art" src="assets/v4/nav/reports.webp" alt="">Publier au joueur</button>
9338    </div>
9339  `;
9340
9341  showModal(html);
9342
9343  // Si on ouvre/crée un bilan ANNUEL, dessiner le graphique d'évolution des axes.
9344  if (trimestre === 'ANNUEL' && joueurId) {
9345    setTimeout(() => drawSyntheseAxesChart(joueurId, saison), 60);
9346  }
9347}
9348
9349// Recalcule les stats auto quand joueur/saison/période change dans la modale
9350function refreshBilanStats() {
9351  const joueurId = $('bilanJoueurId')?.value;
9352  const saison = $('bilanSaison')?.value;
9353  const triPill = document.querySelector('#bilanTrimestrePills .pill.active');
9354  const trimestre = triPill?.dataset.value;
9355  if (!joueurId || !saison || !trimestre) return;
9356  let dDeb, dFin;
9357  if (trimestre === 'ANNUEL') {
9358    const [sa1, sa2] = saison.split('-');
9359    dDeb = `${sa1}-09-01`; dFin = `${sa2}-08-31`;
9360  } else {
9361    [dDeb, dFin] = getTrimestreDates(saison, trimestre);
9362  }
9363  const ms = matchesEntreDate(joueurId, dDeb, dFin);
9364  const compte = TennisDomainRules.statistiquesSportives(
9365    ms,
9366    match => classementADate(joueurId, match.date)
9367  );
9368  const v = compte.victoires;
9369  const perfs = compte.perfs;
9370  if ($('statMatches')) $('statMatches').textContent = compte.total;
9371  if ($('statVictoires')) $('statVictoires').textContent = v;
9372  if ($('statPerfs')) $('statPerfs').textContent = perfs;
9373  // Mettre à jour le bloc ressenti
9374  const ressentiZone = $('bilanRessenti');
9375  if (ressentiZone) {
9376    const inner = ressentiZone.querySelector('div');
9377    if (inner) inner.innerHTML = renderRessentiBloc(statsRessenti(ms));
9378  }
9379}
9380
9381// Construit le HTML de la synthèse annuelle (hors graphique, dessiné après).
9382function renderSyntheseAnnuelle(joueurId, saison) {
9383  const s = syntheseAnnuelle(joueurId, saison);
9384  if (s.nbTrimestriels === 0) {
9385    return `<div class="text-faint" style="font-style:italic">Aucun bilan trimestriel publié pour ${escapeHtml(saison)}. La synthèse annuelle s'enrichira au fur et à mesure que tu publies les bilans T1 à T4.</div>`;
9386  }
9387
9388  // Évolution du classement
9389  let classementBloc = '';
9390  if (s.classementDebut || s.classementFin) {
9391    let tendanceCl = '';
9392    if (s.gainClassement != null) {
9393      if (s.gainClassement > 0) tendanceCl = `<span style="color:var(--success-text,#1d8a5a)">↗ +${s.gainClassement} échelon${s.gainClassement > 1 ? 's' : ''}</span>`;
9394      else if (s.gainClassement < 0) tendanceCl = `<span style="color:var(--danger-text,#c0392b)">↘ ${s.gainClassement} échelon${s.gainClassement < -1 ? 's' : ''}</span>`;
9395      else tendanceCl = `<span class="text-faint">→ stable</span>`;
9396    }
9397    classementBloc = `<div style="margin-bottom:10px"><strong>Classement sur l'année :</strong> ${escapeHtml(s.classementDebut || '?')} → ${escapeHtml(s.classementFin || '?')} ${tendanceCl}</div>`;
9398  }
9399
9400  // Tendance par axe (flèches + delta) sous le graphique
9401  const axesLignes = s.axes.filter(a => a.points.length > 0).map(a => {
9402    const fl = a.tendance === 'hausse' ? '↗' : a.tendance === 'baisse' ? '↘' : '→';
9403    const col = a.tendance === 'hausse' ? 'var(--success-text,#1d8a5a)' : a.tendance === 'baisse' ? 'var(--danger-text,#c0392b)' : 'var(--text-faint,#8a9794)';
9404    const suite = a.points.map(p => `${p.trimestre}:${p.note}`).join(' → ');
9405    return `<div style="display:flex; justify-content:space-between; padding:3px 0; font-size:12px">
9406      <span>${escapeHtml(a.label)} <span class="text-faint">(${suite})</span></span>
9407      <span style="color:${col}; font-weight:600">${fl} ${a.delta > 0 ? '+' : ''}${a.delta}</span>
9408    </div>`;
9409  }).join('');
9410
9411  return `
9412    ${classementBloc}
9413    <div style="margin-bottom:6px"><strong>Stats de la saison :</strong> ${s.nbMatchs} match${s.nbMatchs > 1 ? 'es' : ''} · ${s.victoires} victoire${s.victoires > 1 ? 's' : ''} · ${s.perfs} perf${s.perfs > 1 ? 's' : ''}${s.tauxVictoire != null ? ` · ${s.tauxVictoire}% de victoires` : ''}</div>
9414    <div style="font-weight:600; font-size:12px; margin:10px 0 4px">Évolution des 5 axes (T1 → T4)</div>
9415    <div style="height:200px; position:relative"><canvas id="bilanAxesChart"></canvas></div>
9416    <div style="margin-top:10px; padding-top:8px; border-top:1px solid var(--border)">${axesLignes}</div>
9417    <div style="margin-top:10px; padding:8px 10px; background:var(--accent-bg,#e8eef7); border-radius:6px; font-size:12px">
9418      <strong>Tendance générale :</strong> ${escapeHtml(s.phrase)}
9419    </div>
9420    <div class="text-faint" style="font-size:11px; margin-top:6px">Synthèse basée sur ${s.nbTrimestriels} bilan${s.nbTrimestriels > 1 ? 's' : ''} trimestriel${s.nbTrimestriels > 1 ? 's' : ''} publié${s.nbTrimestriels > 1 ? 's' : ''}.</div>
9421  `;
9422}
9423
9424// Dessine le graphique d'évolution des axes (appelé après insertion du HTML).
9425function drawSyntheseAxesChart(joueurId, saison) {
9426  const canvas = document.getElementById('bilanAxesChart');
9427  if (!canvas) return;
9428  if (!avecChartJs('bilanAxesChart', () => drawSyntheseAxesChart(joueurId, saison))) return;
9429  destroyChart('bilanAxesChart');
9430  const s = syntheseAnnuelle(joueurId, saison);
9431  if (s.nbTrimestriels === 0) return;
9432
9433  // Axe X = trimestres présents (union de tous les points)
9434  const labels = ORDRE_TRIMESTRES.filter(t => s.trimestriels.some(b => b.trimestre === t));
9435  const couleursGraphique = chartColors();
9436  const palette = [
9437    couleursGraphique.lineOrange,
9438    couleursGraphique.blueColor,
9439    couleursGraphique.tealColor,
9440    couleursGraphique.purpleColor,
9441    couleursGraphique.lossColor
9442  ];
9443  const datasets = s.axes.map((a, i) => ({
9444    label: a.label,
9445    data: labels.map(t => {
9446      const p = a.points.find(pt => pt.trimestre === t);
9447      return p ? p.note : null;
9448    }),
9449    borderColor: palette[i % palette.length],
9450    backgroundColor: 'transparent',
9451    tension: 0.3,
9452    spanGaps: true,
9453    borderWidth: 2,
9454    pointRadius: 3
9455  }));
9456
9457  chartInstances.bilanAxesChart = new window.Chart(canvas.getContext('2d'), {
9458    type: 'line',
9459    data: { labels, datasets },
9460    options: {
9461      responsive: true, maintainAspectRatio: false,
9462      scales: {
9463        x: {
9464          grid: { color: couleursGraphique.gridColor },
9465          ticks: { color: couleursGraphique.textColor }
9466        },
9467        y: {
9468          min: 0,
9469          max: 10,
9470          grid: { color: couleursGraphique.gridColor },
9471          ticks: { stepSize: 2, color: couleursGraphique.textColor }
9472        }
9473      },
9474      plugins: {
9475        legend: {
9476          position: 'bottom',
9477          labels: { color: couleursGraphique.textColor, boxWidth: 12, font: { size: 10 } }
9478        },
9479        tooltip: {
9480          backgroundColor: couleursGraphique.tooltipBg,
9481          borderColor: couleursGraphique.lineOrange,
9482          borderWidth: 1
9483        }
9484      }
9485    }
9486  });
9487}
9488
9489// Appelée quand la période du bilan change (remplace l'appel direct à refreshBilanStats).
9490// Affiche/masque la zone de synthèse annuelle et la (re)calcule si besoin.
9491function onBilanPeriodeChange() {
9492  refreshBilanStats();
9493  const triPill = document.querySelector('#bilanTrimestrePills .pill.active');
9494  const periode = triPill ? triPill.dataset.value : null;
9495  const zone = $('bilanSyntheseAnnuelle');
9496  const joueurId = $('bilanJoueurId')?.value;
9497  const saison = $('bilanSaison')?.value;
9498  if (!zone) return;
9499  if (periode === 'ANNUEL') {
9500    zone.style.display = 'block';
9501    const contenu = $('bilanSyntheseContent');
9502    if (contenu && joueurId && saison) {
9503      contenu.innerHTML = renderSyntheseAnnuelle(joueurId, saison);
9504      setTimeout(() => drawSyntheseAxesChart(joueurId, saison), 50);
9505    }
9506  } else {
9507    zone.style.display = 'none';
9508  }
9509}
9510
9511async function saveBilan(id, statut) {
9512  const joueurId = $('bilanJoueurId').value;
9513  const saison = $('bilanSaison').value.trim();
9514  const triPill = document.querySelector('#bilanTrimestrePills .pill.active');
9515  if (!joueurId) { toast('Sélectionne un joueur', true); return; }
9516  if (!saison || !/^\d{4}-\d{4}
9516$/.test(saison)) { toast('Saison invalide (format AAAA-AAAA)', true); return; }
9517  if (!triPill) { toast('Sélectionne un trimestre', true); return; }
9518  const trimestre = triPill.dataset.value;
9519
9520  let dDeb, dFin;
9521  if (trimestre === 'ANNUEL') {
9522    const [sa1, sa2] = saison.split('-');
9523    dDeb = `${sa1}-09-01`; dFin = `${sa2}-08-31`;
9524  } else {
9525    [dDeb, dFin] = getTrimestreDates(saison, trimestre);
9526  }
9527  const ms = matchesEntreDate(joueurId, dDeb, dFin);
9528  const compte = TennisDomainRules.statistiquesSportives(
9529    ms,
9530    match => classementADate(joueurId, match.date)
9531  );
9532  const v = compte.victoires;
9533  const perfs = compte.perfs;
9534
9535  const decisionPill = document.querySelector('#bilanDecisionPills .pill.active');
9536  const groupePill = document.querySelector('#bilanGroupePills .pill.active');
9537
9538  const obj = {
9539    joueur_id: joueurId,
9540    saison,
9541    trimestre,
9542    date_debut: dDeb,
9543    date_fin: dFin,
9544    note_performance: parseInt($('bilanNote_performance').value) || null,
9545    note_volume: parseInt($('bilanNote_volume').value) || null,
9546    note_technique: parseInt($('bilanNote_technique').value) || null,
9547    note_physique: parseInt($('bilanNote_physique').value) || null,
9548    note_mental: parseInt($('bilanNote_mental').value) || null,
9549    classement_debut: $('bilanClassDebut').value.trim() || null,
9550    classement_fin: $('bilanClassFin').value.trim() || null,
9551    matches_periode: compte.total,
9552    victoires_periode: v,
9553    perfs_periode: perfs,
9554    commentaire_coach: $('bilanCommentaire').value.trim() || null,
9555    objectifs_suivants: $('bilanObjectifs').value.trim() || null,
9556    decision: decisionPill ? decisionPill.dataset.value : 'a_decider',
9557    groupe_actuel: joueur ? joueur.groupe : null,
9558    groupe_suivant: groupePill && groupePill.dataset.value ? groupePill.dataset.value : null,
9559    statut: statut,
9560    updated_at: new Date().toISOString()
9561  };
9562
9563  let error;
9564  if (!id) obj.created_by = session.user.id;
9565  const execution = await executerAvecVerrou($('modalBody'), () => id
9566    ? updateConfirmee(db.from('bilans').update(obj).eq('id', id))
9567    : db.from('bilans').insert(obj).select('id').single(), statut === 'publie' ? 'Publication…' : 'Enregistrement…');
9568  if (execution.ignoree) return;
9569  ({ error } = execution.valeur);
9570
9571  if (error) {
9572    if (error.code === '23505') toast('Un bilan existe déjà pour ce joueur/saison/trimestre', true);
9573    else toastErreur(error);
9574    return;
9575  }
9576  closeModal();
9577  toast(statut === 'publie' ? 'Bilan publié au joueur' : 'Brouillon enregistré');
9578
9579  // Si c'est une publication (pas un brouillon), envoyer l'email au joueur (+ parent si mineur)
9580  if (statut === 'publie') {
9581    const joueur = joueurById(obj.joueur_id);
9582    if (joueur) {
9583      emailBilanPublie({ ...obj, id: id || execution.valeur?.data?.id || null }, joueur);
9584    }
9585  }
9586
9587  await refresh();
9588}
9589
9590async function deleteBilan(id) {
9591  if (!(await demanderConfirmation('Supprimer définitivement ce bilan ?'))) return;
9592  const { error } = await db.from('bilans').delete().eq('id', id);
9593  if (error) { toastErreur(error); return; }
9594  closeModal();
9595  toast('Bilan supprimé');
9596  await refresh();
9597}
9598
9599// ============================================================
9600// EXPORT PDF d'un bilan
9601// ============================================================
9602// ============================================================
9603// HELPER PDF — Couleurs + bandeau d'en-tête commun (portrait A4)
9604// ============================================================
9605// Palette partagée par tous les exports PDF
9606let exportsPdf = null;
9607async function obtenirExportsPdf() {
9608  if (exportsPdf) return exportsPdf;
9609  if (!await chargerVendor('pdfexports') || !window.TennisPdfExports) return null;
9610  const outilsPdf = TennisPdfExports.creerOutils({ window, document });
9611  exportsPdf = TennisPdfExports.creerExportsApplication({
9612    cache,
9613    chargerJsPdf,
9614    toast,
9615    getActiveEvenement,
9616    isCoach,
9617    joueursActifsDansEnvCourant,
9618    joursEdition,
9619    libelleJourCourt,
9620    creneauxDuJoueurJour,
9621    resultatCreneau,
9622    joueurById,
9623    bilanPalmyreDuJoueur,
9624    formatDate,
9625    heureEnMinutes,
9626    joueursDuCreneau,
9627    dateLocaleISO,
9628    getPeriodeDates,
9629    getPeriodeLabel,
9630    getSaisonCourante,
9631    getSaisonAnalyse,
9632    getSaisonDates,
9633    creneauCoursById,
9634    presenceDeSeancePourJoueur,
9635    closeModal,
9636    bilanById,
9637    computeAge,
9638    normaliserTelephone,
9639    TennisDomainRules,
9640    TennisFormatters,
9641    document,
9642    outilsPdf,
9643    PDF_COLORS: outilsPdf.couleurs
9644  });
9645  return exportsPdf;
9646}
9647
9648async function appelerExportPdf(methode, ...args) {
9649  const api = await obtenirExportsPdf();
9650  if (!api || typeof api[methode] !== 'function') return false;
9651  return api[methode](...args);
9652}
9653
9654// Export PDF de la grille calendaire du planning (paysage A4).
9655async function exportPlanningPDF() { return appelerExportPdf('exportPlanningPDF'); }
9656
9657// Modal de sélection pour l'export des présences (un, plusieurs ou tous les joueurs).
9658function openModalExportPresences() {
9659  if (!isCoach()) return;
9660  const joueurs = joueursActifsDansEnvCourant()
9661    .sort((a, b) => (a.nom || '').localeCompare(b.nom || ''));
9662  if (joueurs.length === 0) { toast('Aucun joueur actif à exporter', true); return; }
9663
9664  const choixJoueurs = joueurs.map(j => `<label class="creneau-player-choice"><input type="checkbox" class="exp-pres-joueur" value="${escapeHtml(j.id)}" checked><span><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong><small>${escapeHtml(j.classement || 'NC')}</small></span></label>`).join('');
9665  const html = `<div class="modal-header"><h3 class="modal-title">Export présences (PDF)</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
9666    <div class="modal-body">
9667      <div class="form-row"><div class="form-label">Joueurs</div>
9668        <label class="creneau-player-choice"><input type="checkbox" id="expPresTous" checked data-ui-change="toggleTousJoueursExportPresences(this.checked)"><span><strong>Tous les joueurs actifs</strong><small>Décoche pour choisir individuellement</small></span></label>
9669        <div class="creneau-player-picker">${choixJoueurs}</div>
9670      </div>
9671      <div class="form-row"><label class="form-label">Période</label>
9672        <select id="expPresPeriode" data-ui-change="document.getElementById('expPresPlage').style.display = this.value === 'plage' ? 'flex' : 'none'">
9673          <option value="saison">Saison complète</option>
9674          <option value="T1">Trimestre 1</option>
9675          <option value="T2">Trimestre 2</option>
9676          <option value="T3">Trimestre 3</option>
9677          <option value="T4">Trimestre 4</option>
9678          <option value="plage">Plage personnalisée…</option>
9679        </select>
9680      </div>
9681      <div class="form-row" id="expPresPlage" style="display:none; gap:10px">
9682        <div style="flex:1"><label class="form-label">Du</label><input type="date" id="expPresDu"></div>
9683        <div style="flex:1"><label class="form-label">Au</label><input type="date" id="expPresAu"></div>
9684      </div>
9685      <div class="mt-2"><button class="btn btn-primary btn-full" data-ui-click="exportPresencesPDF()"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">Générer le PDF</button></div>
9686    </div>`;
9687  showModal(html);
9688}
9689
9690function toggleTousJoueursExportPresences(checked) {
9691  document.querySelectorAll('.exp-pres-joueur').forEach(caseJoueur => { caseJoueur.checked = Boolean(checked); });
9692}
9693
9694// Export PDF des présences d'un joueur sur une période (coach only).
9695async function exportPresencesPDF() { return appelerExportPdf('exportPresencesPDF'); }
9696
9697async function exportBilanPDF(bilanId) { return appelerExportPdf('exportBilanPDF', bilanId); }
9698
9699
9700// ============================================================
9701// EXPORT PDF — Charte de consentement individuelle (1 joueur)
9702// ============================================================
9703async function exportChartConsentementPDF(joueurId) { return appelerExportPdf('exportChartConsentementPDF', joueurId); }
9704
9705
9706// ============================================================
9707// EXPORT PDF — Registre global des consentements (tous les joueurs)
9708// ============================================================
9709async function exportRegistreConsentementsPDF() { return appelerExportPdf('exportRegistreConsentementsPDF'); }
9710
9711
9712// ============================================================
9713// EXPORT PDF — Programme de tournois d'un joueur
9714// ============================================================
9715// Génère un PDF de la prog d'un joueur (tournois groupés par mois), partageable
9716// et imprimable. Accessible côté coach (joueur sélectionné) et côté joueur (sa prog).
9717async function exportProgrammePDF(joueurId) { return appelerExportPdf('exportProgrammePDF', joueurId); }
9718
9719
9720// ============================================================
9721// SCREEN: MES BADGES (joueur) — gamification
9722// ============================================================
9723// Rend une carte pour un badge (à palier ou exploit), avec barre de progression.
9724function renderBadgeCard(badge) {
9725  if (badge.type === 'exploit') {
9726    const ok = badge.debloque;
9727    return `<div class="card" style="padding:12px; opacity:${ok ? '1' : '0.55'}; ${ok ? 'border:1px solid var(--success-text,#1d8a5a)' : ''}">
9728      <div style="display:flex; align-items:center; gap:10px">
9729        <div>${badgeArtHtml(badge.id, { size: 70, locked: !ok, label: badge.label })}</div>
9730        <div style="flex:1; min-width:0">
9731          <div style="font-weight:600; font-size:13px">${escapeHtml(badge.label)} ${ok ? '<span style="color:var(--success-text,#1d8a5a)">✓</span>' : ''}</div>
9732          <div class="text-faint" style="font-size:11px">${escapeHtml(badge.desc || '')}</div>
9733        </div>
9734        ${badge.detail ? `<div style="font-weight:700; font-size:14px; color:var(--orange-text,#e67e22); white-space:nowrap">${escapeHtml(badge.detail)}</div>` : ''}
9735      </div>
9736    </div>`;
9737  }
9738  // Badge à paliers — barre unique avec les 4 médailles équiréparties.
9739  const ok = badge.niveau > 0;
9740  const nbPaliers = badge.paliers.length;
9741  // Largeur remplie : chaque palier occupe une part égale de la barre. On remplit
9742  // les segments atteints + la fraction de progression dans le segment courant.
9743  const partSegment = 100 / nbPaliers;
9744  let largeur;
9745  if (badge.prochainSeuil == null) {
9746    largeur = 100; // palier max atteint
9747  } else {
9748    largeur = badge.niveau * partSegment + (badge.progression / 100) * partSegment;
9749  }
9750  largeur = Math.max(0, Math.min(100, largeur));
9751
9752  // Médailles positionnées à la fin de chaque segment (équiréparties).
9753  const medailles = badge.paliers.map((seuil, i) => {
9754    const atteint = badge.niveau >= (i + 1);
9755    const posX = ((i + 1) / nbPaliers) * 100;
9756    return `<div style="position:absolute; left:${posX}%; top:50%; transform:translate(-50%,-50%); text-align:center; z-index:2">
9757        <div>${rankArtHtml(i, { size: 46, locked: !atteint })}</div>
9758        <div style="font-size:10px; font-weight:600; margin-top:1px; ${atteint ? 'color:var(--orange-text,#e67e22)' : 'color:var(--text-faint,#8a9794)'}">${seuil}${badge.unite}</div>
9759      </div>`;
9760  }).join('');
9761
9762  const prochainTxt = badge.prochainSeuil != null
9763    ? `Plus que ${Math.max(0, badge.prochainSeuil - badge.valeur)}${badge.unite} avant le palier ${BADGE_PALIERS_NOMS[badge.niveau]}`
9764    : `Palier maximal atteint`;
9765
9766  return `<div class="card" style="padding:14px 14px 10px">
9767    <div style="display:flex; align-items:baseline; justify-content:space-between; gap:10px; margin-bottom:6px">
9768      <div style="display:flex;align-items:center;gap:9px;font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:15px">${progressArtHtml(badge.id,{size:46})} ${escapeHtml(badge.label)}</div>
9769      <div style="font-size:24px; font-weight:800; color:var(--orange-text,#e67e22); line-height:1">${badge.valeur}${badge.unite}</div>
9770    </div>
9771    <div class="text-faint" style="font-size:11px; margin-bottom:25px">${ok ? 'Palier actuel : ' + badge.nom_palier : 'Pas encore débloqué'}</div>
9772    <div style="position:relative;height:58px;margin:0 18px">
9773      <!-- rail -->
9774      <div class="badge-progress-track"></div>
9775      <!-- remplissage -->
9776      <div class="badge-progress-track"><div class="badge-progress-fill" style="width:${largeur}%"></div></div>
9777      ${largeur >
9777 1 ? `<span class="progress-fireball" style="left:${largeur}%">${progressArtHtml('serie',{size:34})}</span>` : ''}
9778      <!-- médailles -->
9779      ${medailles}
9780    </div>
9781    <div class="text-faint" style="font-size:11px; margin-top:2px; text-align:center">${prochainTxt}</div>
9782  </div>`;
9783}
9784
9785// Version légère : un badge vitrine est saisonnier. Un choix conservé sur la fiche
9786// ne doit jamais faire réapparaître dans le groupe un badge gagné l'an dernier.
9787function badgeVitrineSimple(joueurId) {
9788  const j = joueurById(joueurId);
9789  if (!j || !j.badge_vitrine) return '';
9790  const def = BADGES_CATALOGUE.find(b => b.id === j.badge_vitrine && b.piste === 'Insolites');
9791  if (!def) return '';
9792  const obtenuCetteSaison = badgesDuJoueurPourSaison(joueurId, getSaisonCourante())
9793    .some(badge => badge.badge_id === j.badge_vitrine);
9794  if (!obtenuCetteSaison) return '';
9795  return `<span title="${escapeHtml(def.label)}" style="display:inline-flex">${badgeArtHtml(def.id, { size: 28, label: def.label })}</span>`;
9796}
9797
9798// Renvoie le petit badge HTML "vitrine" d'un joueur (insolite choisi), ou '' si aucun
9799// ou s'il n'est plus débloqué. Utilisé sur la fiche perso et la fiche vue par le coach.
9800function badgeVitrineHtml(j) {
9801  if (!j || !j.badge_vitrine) return '';
9802  const def = BADGES_CATALOGUE.find(b => b.id === j.badge_vitrine && b.piste === 'Insolites');
9803  if (!def) return '';
9804  // Vérifier que le badge est toujours débloqué (sinon on ne l'affiche pas)
9805  const badges = calculerBadges(j.id, getSaisonCourante());
9806  const b = badges.find(x => x.id === j.badge_vitrine && x.debloque);
9807  if (!b) return '';
9808  return `<span class="profile-showcase-badge" title="${escapeHtml(def.desc || '')}">${badgeArtHtml(def.id, { size: 42 })}<span>${escapeHtml(def.label)}</span></span>`;
9809}
9810
9811// Fil d'actualité : derniers badges débloqués par les AUTRES joueurs de l'académie.
9812// Une ligne par joueur, avec ses médailles en icônes (compact). Saison en cours.
9813function toggleFilBadgesGroupe(groupe) {
9814  const ouvert = etatApp.basculerGroupeBadges(groupe);
9815  const corps = document.getElementById('filGrp_' + groupe);
9816  const chevron = document.getElementById('filGrpChevron_' + groupe);
9817  if (corps) corps.style.display = ouvert ? 'grid' : 'none';
9818  if (chevron) chevron.textContent = ouvert ? '▲' : '▼';
9819}
9820
9821function renderFilBadges() {
9822  const saison = getSaisonCourante();
9823  const monJoueurId = profile && profile.joueur_id;
9824  // Badges de la saison, hors joueur connecté (on ne se voit pas soi-même)
9825  const entries = (listeSure(cache.badgesJoueurs)).filter(x => x.saison === saison && x.joueur_id !== monJoueurId);
9826  if (entries.length === 0) return '';
9827
9828  // Regrouper par joueur
9829  const parJoueur = {};
9830  entries.forEach(e => {
9831    if (!parJoueur[e.joueur_id]) parJoueur[e.joueur_id] = { joueur_id: e.joueur_id, items: [], dernier: e.obtenu_le || '' };
9832    parJoueur[e.joueur_id].items.push(e);
9833    if ((e.obtenu_le || '') > parJoueur[e.joueur_id].dernier) parJoueur[e.joueur_id].dernier = e.obtenu_le || '';
9834  });
9835
9836  const annu = cache.annuaire || {};
9837  const annuGrp = cache.annuaireGroupe || {};
9838
9839  // Construire la ligne (nom + icônes) pour un joueur donné
9840  const construireLigneJoueur = (ligne) => {
9841    let nom = annu[ligne.joueur_id];
9842    if (!nom) {
9843      const jou = joueurById(ligne.joueur_id);
9844      nom = jou ? `${jou.prenom}${jou.nom ? ' ' + jou.nom.charAt(0).toUpperCase() + '.' : ''}` : 'Un joueur';
9845    }
9846    const meilleurPalier = {};
9847    const exploits = [];
9848    ligne.items.forEach(it => {
9849      const def = BADGES_CATALOGUE.find(b => b.id === it.badge_id);
9850      if (!def) return;
9851      if (it.palier) {
9852        if (!meilleurPalier[it.badge_id] || it.palier > meilleurPalier[it.badge_id].palier) meilleurPalier[it.badge_id] = { palier: it.palier, def };
9853      } else exploits.push(def);
9854    });
9855    const iconesHtml = [];
9856    exploits.forEach(def => iconesHtml.push(`<span title="${escapeHtml(def.label + (def.desc ? ' — ' + def.desc : ''))}" style="cursor:help;display:inline-flex">${badgeArtHtml(def.id, { size: 36 })}</span>`));
9857    Object.values(meilleurPalier).forEach(({ palier, def }) => {
9858      const nomP = BADGE_PALIERS_NOMS[palier - 1] || '';
9859      iconesHtml.push(`<span title="${escapeHtml(def.label + ' — ' + nomP)}" style="cursor:help;display:inline-flex">${rankArtHtml(palier - 1, { size: 34 })}</span>`);
9860    });
9861    const icones = iconesHtml.slice(0, 8);
9862    if (icones.length === 0) return '';
9863    return `<div class="card" style="padding:8px 12px; display:flex; align-items:center; gap:10px">
9864        <div style="flex:1; min-width:0; font-size:13px"><strong>${escapeHtml(nom)}</strong></div>
9865        <div style="font-size:20px; letter-spacing:2px; white-space:nowrap">${icones.join('')}</div>
9866      </div>`;
9867  };
9868
9869  // Regrouper les joueurs par groupe (via l'annuaire des groupes)
9870  const joueursParGroupe = {};
9871  Object.values(parJoueur).forEach(ligne => {
9872    const grp = annuGrp[ligne.joueur_id] || (joueurById(ligne.joueur_id) || {}).groupe || 'autres';
9873    (joueursParGroupe[grp] = joueursParGroupe[grp] || []).push(ligne);
9874  });
9875
9876  // Compter les médailles par groupe (nombre d'entrées badges)
9877  const medaillesParGroupe = {};
9878  entries.forEach(e => {
9879    const grp = annuGrp[e.joueur_id] || (joueurById(e.joueur_id) || {}).groupe || 'autres';
9880    medaillesParGroupe[grp] = (medaillesParGroupe[grp] || 0) + 1;
9881  });
9882
9883  // Ordre des groupes (compét d'abord, puis autres)
9884  const groupesPresents = Object.keys(joueursParGroupe);
9885  const ordonnes = [...ORDRE_GROUPES.filter(g => groupesPresents.includes(g)),
9886                    ...groupesPresents.filter(g => !ORDRE_GROUPES.includes(g))];
9887
9888  let html = `<div class="section-title" style="display:flex;align-items:center;gap:9px">${rankArtHtml(2,{size:36})}Dernières médailles de l'académie</div>`;
9889  html += `<div style="display:grid; grid-template-columns:1fr; gap:6px; margin-bottom:14px">`;
9890
9891  ordonnes.forEach(grp => {
9892    const lignes = joueursParGroupe[grp].sort((a, b) => String(b.dernier).localeCompare(String(a.dernier)));
9893    const nomGroupe = (cache.config[grp] && cache.config[grp].nom) || (grp === 'autres' ? 'Autres' : grp);
9894    const nbMed = medaillesParGroupe[grp] || 0;
9895    const ouvert = etatApp.groupeBadgesDeplie(grp);
9896
9897    // En-tête de groupe repliable
9898    html += `<div class="card" style="padding:10px 12px; cursor:pointer; background:var(--bg-section)" data-ui-click="toggleFilBadgesGroupe(${escapeHtml(encoderArgumentTexteUI(grp))})">
9899      <div style="display:flex; align-items:center; justify-content:space-between; gap:10px">
9900        <div style="font-weight:700; font-size:13px">${escapeHtml(nomGroupe)}</div>
9901        <div style="display:flex; align-items:center; gap:8px">
9902          <span class="badge" style="background:var(--orange-text,#e67e22); color:#fff; font-size:11px">${nbMed} médaille${nbMed > 1 ? 's' : ''}</span>
9903          <span id="filGrpChevron_${grp}" style="font-size:11px; color:var(--text-faint)">${ouvert ? '▲' : '▼'}</span>
9904        </div>
9905      </div>
9906    </div>`;
9907    // Corps du groupe (joueurs)
9908    html += `<div id="filGrp_${grp}" style="display:${ouvert ? 'grid' : 'none'}; grid-template-columns:1fr; gap:6px; margin-left:8px">`;
9909    lignes.forEach(ligne => { html += construireLigneJoueur(ligne); });
9910    html += `</div>`;
9911  });
9912
9913  html += `</div>`;
9914  return html;
9915}
9916
9917function renderMesBadges() {
9918  if (!profile || !profile.joueur_id) {
9919    return `<div class="empty"><span class="icon"><img src="assets/v4/nav/badges.webp" alt="" style="width:62px;height:62px;object-fit:contain"></span>Les badges sont réservés aux joueurs.</div>`;
9920  }
9921  const j = joueurById(profile.joueur_id);
9922  const saison = getSaisonCourante();
9923  const badges = calculerBadges(profile.joueur_id, saison);
9924
9925  // Compteur de badges débloqués (paliers atteints + exploits)
9926  const nbDebloques = badges.filter(b => b.debloque).length;
9927  const nbTotal = badges.length;
9928
9929  // Grouper par piste
9930  const pistes = ['Assiduité', 'Compétiteur', 'Performance', 'Objectif', 'Insolites'];
9931
9932  let html = renderUxScreenHeading('Mes défis', 'Mes badges') + `<div class="screen-header"><h2 class="screen-title" style="display:flex;align-items:center;gap:10px"><img class="modal-title-art" src="assets/v4/nav/badges.webp" alt="">Mes badges</h2>
9933    <div class="text-faint" style="font-size:13px">Saison ${escapeHtml(saison)} · ${nbDebloques}/${nbTotal} débloqués</div></div>`;
9934
9935  // Badge vitrine : insolites débloqués, choix mis en avant sur le profil
9936  const insolitesDebloques = badges.filter(b => b.piste === 'Insolites' && b.debloque);
9937  const vitrineActuelle = j && j.badge_vitrine ? badges.find(b => b.id === j.badge_vitrine && b.debloque) : null;
9938  html += `<div class="card" style="padding:12px; margin-bottom:14px; background:var(--accent-bg); border-color:color-mix(in srgb,var(--accent-text) 28%,transparent)">
9939    <div style="font-weight:600; font-size:13px; margin-bottom:6px"><img class="inline-art-icon" src="assets/v4/nav/badges.webp" alt=""> Mon badge mis en avant sur le profil</div>`;
9940  if (insolitesDebloques.length === 0) {
9941    html += `<div class="text-faint" style="font-size:12px">Débloque un badge insolite pour pouvoir l'afficher fièrement sur ton profil !</div>`;
9942  } else {
9943    html += `<div class="text-faint" style="font-size:12px; margin-bottom:8px">${vitrineActuelle ? `Actuellement : <strong style="display:inline-flex;align-items:center;gap:4px">${badgeArtHtml(vitrineActuelle.id, { size: 30 })}${escapeHtml(vitrineActuelle.label)}</strong>` : 'Aucun badge sélectionné'}</div>`;
9944    html += `<div style="display:flex; flex-wrap:wrap; gap:6px">`;
9945    insolitesDebloques.forEach(b => {
9946      const actif = j && j.badge_vitrine === b.id;
9947      html += `<button class="btn btn-sm" data-ui-click="setBadgeVitrine(${escapeHtml(encoderArgumentTexteUI(b.id))})" style="font-size:12px; ${actif ? 'background:var(--orange-text,#e67e22); color:white' : ''}">${badgeArtHtml(b.id, { size: 34 })}${escapeHtml(b.label)}${actif ? ' ✓' : ''}</button>`;
9948    });
9949    if (vitrineActuelle) {
9950      html += `<button class="btn btn-sm" data-ui-click="setBadgeVitrine('')" style="font-size:12px">Retirer</button>`;
9951    }
9952    html += `</div>`;
9953  }
9954  html += `</div>`;
9955
9956  // Fil d'actualité de l'académie : derniers badges débloqués par tous les joueurs
9957  html += renderFilBadges();
9958
9959  // Couleur de bandeau par piste
9960  const pisteCouleur = {
9961    'Assiduité': '#2e86de', 'Compétiteur': '#e67e22', 'Performance': '#8e44ad',
9962    'Objectif': '#16a085', 'Insolites': '#c0392b'
9963  };
9964
9965  pistes.forEach(piste => {
9966    const lot = badges.filter(b => b.piste === piste);
9967    if (lot.length === 0) return;
9968    const coul = pisteCouleur[piste] || '#555';
9969    // Bandeau coloré de rubrique
9970    html += `<div class="badge-piste-title" style="--piste-color:${coul}">
9971      <img src="assets/v4/nav/${PISTE_ART_ASSETS[piste] || 'badges'}.webp" alt="">
9972      <span style="font-weight:700; font-size:15px; letter-spacing:0.3px">${piste}</span>
9973    </div>`;
9974
9975    if (piste === 'Insolites') {
9976      // Tuiles 3 par ligne, grosse icône
9977      html += `<div class="insolite-grid">`;
9978      lot.forEach(b => { html += renderInsoliteTuile(b); });
9979      html += `</div>`;
9980    } else {
9981      html += `<div style="display:grid; grid-template-columns:1fr; gap:8px; margin-bottom:6px">`;
9982      lot.forEach(b => { html += renderBadgeCard(b); });
9983      html += `</div>`;
9984    }
9985  });
9986
9987  html += `<div class="text-faint" style="font-size:11px; margin-top:8px; text-align:center">Les compteurs portent sur la saison en cours et se mettent à jour automatiquement.</div>`;
9988  return html;
9989}
9990
9991// Rareté d'un badge insolite parmi les joueurs visibles, sur la saison active.
9992function raretebadge(badgeId) {
9993  const totalJoueurs = Object.keys(cache.annuaire || {}).length;
9994  if (totalJoueurs < 5) return null; // pas significatif sur un trop petit effectif
9995  const saison = getSaisonCourante();
9996  const possesseurs = new Set(
9997    (listeSure(cache.badgesJoueurs))
9998      .filter(x => x.badge_id === badgeId && x.saison === saison)
9999      .map(x => x.joueur_id)
10000  );
10001  const pct = Math.round((possesseurs.size / totalJoueurs) * 100);
10002  let mention, couleur;
10003  if (possesseurs.size === 0) { mention = 'Inédit'; couleur = '#b8860b'; }
10004  else if (pct <= 5) { mention = 'Légendaire'; couleur = '#b8860b'; }
10005  else if (pct <= 15) { mention = 'Très rare'; couleur = '#7F77DD'; }
10006  else if (pct <= 35) { mention = 'Rare'; couleur = '#378ADD'; }
10007  else if (pct <= 60) { mention = 'Peu commun'; couleur = '#1D9E75'; }
10008  else { mention = 'Commun'; couleur = '#888'; }
10009  return { pct, mention, couleur, possesseurs: possesseurs.size, total: totalJoueurs };
10010}
10011
10012function renderInsoliteTuile(b) {
10013  const ok = b.debloque;
10014  const r = raretebadge(b.id);
10015  const rareteHtml = r
10016    ? `<div style="font-size:10px; font-weight:700; margin-top:4px; color:${r.couleur}">${r.mention}${r.possesseurs > 0 ? ' · ' + r.pct + '%' : ''}</div>`
10017    : '';
10018  return `<div class="card insolite-card ${ok ? 'unlocked' : ''}" style="text-align:center">
10019    ${badgeArtHtml(b.id, { size: 88, locked: !ok, label: b.label })}
10020    <div style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:15px;margin-top:3px;line-height:1.1">${escapeHtml(b.label)}</div>
10021    <div class="text-faint" style="font-size:10px; margin-top:5px; line-height:1.25">${escapeHtml(b.desc || '')}</div>
10022    ${rareteHtml}
10023    ${ok ? '<div style="color:var(--success-text,#1d8a5a);font-size:11px;font-weight:800;margin-top:7px;text-transform:uppercase;letter-spacing:.7px">✓ débloqué</div>' : ''}
10024  </div>`;
10025}
10026
10027// Enregistre le badge insolite que le joueur met en avant sur son profil.
10028// Sécurité : on vérifie que le badge est bien un insolite RÉELLEMENT débloqué.
10029async function setBadgeVitrine(badgeId) {
10030  if (!profile || !profile.joueur_id) return;
10031  if (badgeId) {
10032    const badges = calculerBadges(profile.joueur_id, getSaisonCourante());
10033    const b = badges.find(x => x.id === badgeId && x.piste === 'Insolites' && x.debloque);
10034    if (!b) { toast('Ce badge n\'est pas (encore) débloqué', true); return; }
10035  }
10036  const { error } = await updateConfirmee(db.from('joueurs').update({ badge_vitrine: badgeId || null }).eq('id', profile.joueur_id));
10037  if (error) { toastErreur(error); return; }
10038  // Mettre à jour le cache local pour un retour immédiat
10039  const jl = joueurById(profile.joueur_id);
10040  if (jl) jl.badge_vitrine = badgeId || null;
10041  toast(badgeId ? 'Badge mis en avant sur ton profil ✨' : 'Badge retiré du profil');
10042  render();
10043}
10044
10045// ============================================================
10046// SCREEN: MES BILANS (joueur)
10047// ============================================================
10048function libelleArretBilan(bilan) {
10049  const date = String(bilan && (bilan.updated_at || bilan.created_at) || '').slice(0, 10);
10050  return date ? `Chiffres arrêtés au ${formatDate(date)}` : 'Instantané établi lors de la publication';
10051}
10052
10053function renderMyBilans() {
10054  if (!profile || !profile.joueur_id) {
10055    return `<div class="empty">Profil joueur non trouvé.</div>`;
10056  }
10057  const mesBilans = bilansDuJoueur(profile.joueur_id);
10058
10059  // Bilans Palmyre (publiés) — RLS ne renvoie que ceux du joueur connecté.
10060  const mesBilansPalmyre = bilansPalmyreDuJoueur(profile.joueur_id).filter(b => b.statut === 'publie');
10061
10062  if (mesBilans.length === 0 && mesBilansPalmyre.length === 0) {
10063    return `<div class="empty"><span class="icon">📋</span>Aucun bilan disponible.<br><span class="text-faint">Ton coach n'a pas encore publié de bilan pour toi.</span></div>`;
10064  }
10065
10066  let html = renderUxScreenHeading('Retour du coach', 'Mes bilans');
10067
10068  // --- Bilans Palmyre (en premier, mis en avant) ---
10069  if (mesBilansPalmyre.length > 0) {
10070    html += `<div class="section-title">🏖️ Bilans Palmyre</div>`;
10071    mesBilansPalmyre.forEach(b => {
10072      const ev = (listeSure(cache.evenements)).find(e => e.id === b.evenement_id)
10073        || (listeSure(cache.evenementsTous)).find(e => e.id === b.evenement_id);
10074      const nomEd = ev ? ev.nom : 'Palmyre';
10075      html += `<div class="card" data-ui-click="openMyBilanPalmyreDetail(${escapeHtml(encoderArgumentTexteUI(b.id))})" style="cursor:pointer; border-left:3px solid var(--orange)">
10076        <div class="row-between mb-1">
10077          <strong>🏖️ ${escapeHtml(nomEd)}</strong>
10078          <span class="badge badge-orange" style="background:var(--orange); color:#fff">Stage</span>
10079        </div>
10080        <div class="text-dim" style="font-size:13px">
10081          ${b.matches_total || 0} matchs · ${b.victoires || 0} V · ${b.defaites || 0} D${(b.perfs || 0) > 0 ? ` · ${b.perfs} perf${b.perfs > 1 ? 's' : ''}` : ''}
10082        </div>
10083        <div class="text-faint" style="font-size:11px; margin-top:4px">${libelleArretBilan(b)}</div>
10084        <div class="text-faint" style="font-size:11px; margin-top:6px">Touche pour consulter →</div>
10085      </div>`;
10086    });
10087  }
10088
10089  // --- Bilans trimestriels (académie) ---
10090  if (mesBilans.length > 0) {
10091    const parSaison = {};
10092    mesBilans.forEach(b => {
10093      if (!parSaison[b.saison]) parSaison[b.saison] = [];
10094      parSaison[b.saison].push(b);
10095    });
10096    Object.keys(parSaison).sort().reverse().forEach(saison => {
10097      html += `<div class="section-title">Saison ${saison}</div>`;
10098      parSaison[saison].sort((a, b) => a.trimestre.localeCompare(b.trimestre)).forEach(b => {
10099        const statutLabel = { publie: '📤 À consulter', commente: '💬 Commenté', accepte: '✓ Accepté' }[b.statut] || b.statut;
10100        const statutColor = b.statut === 'accepte' ? 'success' : b.statut === 'commente' ? 'info' : 'warning';
10101        html += `<div class="card" data-ui-click="openMyBilanDetail(${escapeHtml(encoderArgumentTexteUI(b.id))})" style="cursor:pointer">
10102          <div class="row-between mb-1">
10103            <strong>${getTrimestreLabel(b.trimestre)}</strong>
10104            <span class="badge badge-${statutColor}">${statutLabel}</span>
10105          </div>
10106          <div class="text-dim" style="font-size:13px">
10107            ${b.matches_periode} matches · ${b.victoires_periode} V · ${b.perfs_periode} perfs
10108            ${b.classement_debut && b.classement_fin ? ` · ${escapeHtml(b.classement_debut)} → ${escapeHtml(b.classement_fin)}` : ''}
10109          </div>
10110          <div class="text-faint" style="font-size:11px; margin-top:4px">${libelleArretBilan(b)}</div>
10111          <div class="text-faint" style="font-size:11px; margin-top:6px">Touche pour consulter →</div>
10112        </div>`;
10113      });
10114    });
10115  }
10116
10117  return html;
10118}
10119
10120// Détail d'un bilan Palmyre côté joueur (lecture seule).
10121function openMyBilanPalmyreDetail(bilanId) {
10122  const b = (listeSure(cache.bilansEvenement)).find(x => x.id === bilanId);
10123  if (!b) { toast('Bilan introuvable', true); return; }
10124  const ev = (listeSure(cache.evenements)).find(e => e.id === b.evenement_id)
10125    || (listeSure(cache.evenementsTous)).find(e => e.id === b.evenement_id);
10126  const nomEd = ev ? ev.nom : 'Palmyre';
10127
10128  const axes = [
10129    ['note_technique', 'Technique / tactique'],
10130    ['note_physique', 'Physique'],
10131    ['note_mental', 'Mental'],
10132    ['note_performance', 'Performance'],
10133    ['note_savoir_etre', 'Savoir-être / vivre']
10134  ];
10135
10136  let html = `<div class="modal-header">
10137    <h3 class="modal-title">🏖️ Mon bilan ${escapeHtml(nomEd)}</h3>
10138    <button class="modal-close" data-ui-click="closeModal()">×</button>
10139  </div>
10140  <div class="card mb-2" style="font-size:13px">
10141    <div class="grid-2">
10142      <div><strong>Matches :</strong> ${b.matches_total || 0}</div>
10143      <div><strong>Victoires :</strong> ${b.victoires || 0}</div>
10144      <div><strong>Défaites :</strong> ${b.defaites || 0}</div>
10145      <div><strong>Perfs :</strong> ${b.perfs || 0}</div>
10146    </div>
10147    <div class="text-faint" style="font-size:11px; margin-top:8px">${libelleArretBilan(b)}</div>
10148  </div>
10149  <div class="section-title">Évaluation</div>`;
10150
10151  axes.forEach(([name, label]) => {
10152    const val = b[name];
10153    const pct = val ? (val / 10) * 100 : 0;
10154    const color = val >= 7 ? 'var(--success)' : val >= 5 ? 'var(--orange)' : 'var(--danger)';
10155    html += `<div style="margin-bottom:10px">
10156      <div class="row-between" style="font-size:13px"><span>${label}</span><strong>${val != null ? val : '-'}/10</strong></div>
10157      <div class="progress-bar" style="margin-top:4px"><div class="progress-fill" style="width:${pct}%; background:${color}"></div></div>
10158    </div>`;
10159  });
10160
10161  if (b.commentaire_coach) {
10162    html += `<div class="section-title">Commentaire du coach</div>
10163      <div class="card" style="font-size:13px; white-space:pre-wrap">${escapeHtml(b.commentaire_coach)}</div>`;
10164  }
10165
10166  html += `<div class="flex gap-1 mt-2"><button type="button" class="btn btn-full" data-ui-click="closeModal()">Fermer</button></div>`;
10167  showModal(html);
10168}
10169
10170// Vue détaillée d'un bilan côté joueur
10171function openMyBilanDetail(bilanId) {
10172  const b = bilanById(bilanId);
10173  if (!b) return;
10174
10175  const notes = [
10176    ['Performance sportive', b.note_performance],
10177    ['Volume / engagement', b.note_volume],
10178    ['Technique / Tactique', b.note_technique],
10179    ['Physique', b.note_physique],
10180    ['Mental / attitude', b.note_mental]
10181  ];
10182
10183  const decisionLabels = {
10184    a_decider: { txt: 'À décider', cls: '' },
10185    maintien: { txt: 'Maintien dans le groupe', cls: 'success' },
10186    montee: { txt: 'Montée de groupe 🎉', cls: 'success' },
10187    descente: { txt: 'Descente de groupe', cls: 'danger' },
10188    sortie: { txt: 'Sortie de l\'académie', cls: 'danger' }
10189  };
10190  const dec = decisionLabels[b.decision] || decisionLabels.a_decider;
10191
10192  let html = `
10193    <div class="modal-header">
10194      <h3 class="modal-title">Bilan ${escapeHtml(b.saison)} · ${getTrimestreLabel(b.trimestre)}</h3>
10195      <button class="modal-close" data-ui-click="closeModal()">×</button>
10196    </div>
10197
10198    <div class="section-title" style="margin-top:0">Synthèse de la période</div>
10199    <div class="stat-grid">
10200      <div class="stat-card"><div class="stat-label">Matches</div><div class="stat-value">${b.matches_periode}</div></div>
10201      <div class="stat-card"><div class="stat-label">Victoires</div><div class="stat-value success">${b.victoires_periode}</div></div>
10202      <div class="stat-card"><div class="stat-label">Perfs</div><div class="stat-value orange">${b.perfs_periode}</div></div>
10203      ${b.classement_debut || b.classement_fin ? `<div class="stat-card"><div class="stat-label">Classement</div><div class="stat-value" style="font-size:18px">${escapeHtml(b.classement_debut || '?')}<br><span style="font-size:12px; color:var(--text-faint)">↓</span><br>${escapeHtml(b.classement_fin || '?')}</div></div>` : ''}
10204    </div>
10205
10206    <div class="section-title">Évaluation du coach</div>
10207    ${notes.map(([label, val]) => `
10208      <div class="eval-row" style="margin-bottom:12px">
10209        <div style="display:flex; justify-content:space-between; font-size:13px; margin-bottom:4px">
10210          <span>${label}</span>
10211          <span style="font-weight:700; color:var(--orange-text)">${val !== null && val !== undefined ? val + '/10' : '-'}</span>
10212        </div>
10213        <div class="progress-bar"><div class="progress-fill ${val < 5 ? 'danger' : val < 7 ? 'warning' : ''}" style="width:${(val || 0) * 10}%"></div></div>
10214      </div>
10215    `).join('')}
10216
10217    ${b.commentaire_coach ? `
10218    <div class="section-title">Commentaire du coach</div>
10219    <div class="card" style="font-size:13px; line-height:1.6; white-space:pre-wrap">${escapeHtml(b.commentaire_coach)}</div>
10220    ` : ''}
10221
10222    ${b.objectifs_suivants ? `
10223    <div class="section-title">Objectifs trimestre suivant</div>
10224    <div class="card" style="font-size:13px; line-height:1.6; white-space:pre-wrap; border-left:3px solid var(--orange)">${escapeHtml(b.objectifs_suivants)}</div>
10225    ` : ''}
10226
10227    <div class="section-title">Décision</div>
10228    <div class="alert ${dec.cls || 'info'}" style="margin-bottom:0">
10229      <span class="icon">${b.decision === 'montee' ? '↑' : b.decision === 'descente' ? '↓' : b.decision === 'sortie' ? '✗' : '→'}</span>
10230      <div class="alert-content"><strong>${dec.txt}</strong></div>
10231    </div>
10232
10233    <div class="section-title">Mon retour</div>
10234    <div class="form-row">
10235      <label class="form-label">Commentaire (optionnel)</label>
10236      <textarea id="commentaireJoueur" rows="3" placeholder="Tu peux noter tes propres ressentis, questions, ou réactions sur ce bilan...">${escapeHtml(b.commentaire_joueur || '')}</textarea>
10237    </div>
10238
10239    ${b.statut === 'accepte' ? `
10240    <div class="alert success" style="margin-bottom:16px">
10241      <span class="icon">✓</span>
10242      <div class="alert-content">Tu as accepté ce bilan le ${formatDate(b.date_acceptation)}.<br><span style="font-size:11px">Ce bilan est désormais archivé en lecture seule.</span></div>
10243    </div>
10244    ` : ''}
10245
10246    <div class="flex gap-1 mt-2">
10247      <button class="btn flex-1" data-ui-click="closeModal()">Fermer</button>
10248      <button class="btn" data-ui-click="exportBilanPDF(${escapeHtml(encoderArgumentTexteUI(b.id))})"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">PDF</button>
10249      ${b.statut !== 'accepte' ? `<button class="btn" data-ui-click="saveMyBilanComment(${escapeHtml(encoderArgumentTexteUI(b.id))}, false)"><img class="btn-action-art" src="assets/v4/nav/observations.webp" alt="">Enregistrer commentaire</button>` : ''}
10250      ${b.statut !== 'accepte' ? `<button class="btn btn-primary" data-ui-click="saveMyBilanComment(${escapeHtml(encoderArgumentTexteUI(b.id))}, true)">✓ J'ai pris connaissance et j'accepte</button>` : ''}
10251    </div>
10252  `;
10253
10254  showModal(html);
10255}
10256
10257async function saveMyBilanComment(bilanId, accepter) {
10258  const commentaire = $('commentaireJoueur').value.trim();
10259  const bilan = bilanById(bilanId);
10260  if (!bilan) return;
10261
10262  const update = {
10263    commentaire_joueur: commentaire || null,
10264    updated_at: new Date().toISOString()
10265  };
10266
10267  if (accepter) {
10268    update.statut = 'accepte';
10269    update.date_acceptation = new Date().toISOString();
10270  } else if (commentaire && bilan.statut === 'publie') {
10271    update.statut = 'commente';
10272  }
10273
10274  const { error } = await updateConfirmee(db.from('bilans').update(update).eq('id', bilanId));
10275  if (error) { toastErreur(error); return; }
10276  closeModal();
10277  toast(accepter ? 'Bilan accepté' : 'Commentaire enregistré');
10278  await refresh();
10279}
10280
10281// ============================================================
10282// SCREEN: MY (vue joueur)
10283// ============================================================
10284// Bloc visuel niveau + barre d'XP pour la fiche du joueur.
10285function renderNiveauXP(joueurId) {
10286  const { xp } = calculerXP(joueurId);
10287  const n = niveauDepuisXP(xp);
10288  const pct = n.progression;
10289  const sousTexte = n.max
10290    ? 'Niveau maximum atteint 🏆'
10291    : `${n.restant} XP avant <strong>${escapeHtml(n.suivant.titre)}</strong>`;
10292  return `
10293    <div class="xp-card" data-ui-click="openNiveauxModal()" style="padding:16px; margin-bottom:12px; cursor:pointer; border-radius:0; position:relative; overflow:visible;
10294      background:linear-gradient(135deg, #2a1d3a 0%, #1a1424 55%, #241a12 100%); border:1px solid rgba(255,210,63,0.25)">
10295      <div class="xp-card-glow"></div>
10296      <div class="xp-card-main" style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; position:relative; z-index:2">
10297        <div style="display:flex; align-items:center; gap:12px">
10298          <div class="xp-niveau-pastille" style="flex-shrink:0">${levelArtHtml(n.niveau,{size:72})}</div>
10299          <div>
10300            <div style="font-size:10px; font-weight:800; letter-spacing:1.5px; text-transform:uppercase; color:#FFD23F">Niveau ${n.niveau}</div>
10301            <div style="font-size:16px; font-weight:900; color:#fff">${escapeHtml(n.titre)}</div>
10302          </div>
10303        </div>
10304        <div style="text-align:right">
10305          <div style="font-size:18px; font-weight:900; color:#FFD23F">
10305${xp.toLocaleString('fr-FR')}</div>
10306          <div style="font-size:10px; color:#b0a8c8">XP saison</div>
10307        </div>
10308      </div>
10309      <div class="xp-track" style="height:10px; background:rgba(255,255,255,0.08); border-radius:0; overflow:visible; position:relative; z-index:2">
10310        <div class="xp-fill" style="height:100%; width:${pct}%; border-radius:0; position:relative; overflow:hidden;
10311          background:linear-gradient(90deg, #F39200, #FFD23F); box-shadow:0 0 10px rgba(255,210,63,0.5); transition:width 0.9s cubic-bezier(0.22,1,0.36,1)">
10312          <div class="xp-shimmer"></div>
10313        </div>
10314        ${pct > 1 ? `<span class="progress-fireball" style="left:${pct}%">${progressArtHtml('serie',{size:38})}</span>` : ''}
10315      </div>
10316      <div style="font-size:11px; margin-top:8px; text-align:center; color:#b0a8c8; position:relative; z-index:2">${sousTexte} · <span style="color:#FFD23F; text-decoration:underline">voir les niveaux</span></div>
10317    </div>`;
10318}
10319
10320// Modale : grille complète des niveaux + barème d'XP.
10321function openNiveauxModal() {
10322  const joueurId = profile && profile.joueur_id;
10323  if (!joueurId) return;
10324  const xp = calculerXP(joueurId).xp;
10325  const n = niveauDepuisXP(xp);
10326
10327  let lignes = '';
10328  NIVEAUX.forEach(niv => {
10329    const atteint = niv.niveau <= n.niveau;
10330    const actuel = niv.niveau === n.niveau;
10331    lignes += `<div class="level-row ${actuel ? 'current' : ''} ${atteint ? '' : 'locked'}">
10332      <div>${levelArtHtml(niv.niveau,{size:50})}</div>
10333      <div style="flex:1; min-width:0">
10334        <div style="font-family:'Bricolage Grotesque',sans-serif;font-size:14px;font-weight:${actuel ? '900' : '700'};color:${atteint ? 'var(--text)' : 'var(--text-dim)'}">${escapeHtml(niv.titre)}${actuel ? ' · toi' : ''}</div>
10335        <div class="text-faint" style="font-size:10px">${niv.seuil.toLocaleString('fr-FR')} XP</div>
10336      </div>
10337      ${atteint ? sportUiIcon('check') : sportUiIcon('lock')}
10338    </div>`;
10339  });
10340
10341  const bareme = [
10342    ['seances', 'Déclarer une séance libre', '+10 XP'],
10343    ['check', 'Répondre pour une présence', '+5 XP par séance'],
10344    ['matchs', 'Jouer un match', '+20 XP'],
10345    ['victoires', 'Gagner un match', '+15 XP'],
10346    ['perfs', 'Belle perf (par échelon d\'écart)', '+20 XP · plafond 12 échelons'],
10347    ['objectif_groupe', 'Débloquer un badge insolite', '+50 XP'],
10348    ['serie', 'Palier bronze / argent / or / diamant', '+40 → +300 XP']
10349  ];
10350  let baremeHtml = '';
10351  bareme.forEach(([ic, label, val]) => {
10352    baremeHtml += `<div style="display:flex; align-items:center; gap:8px; padding:6px 0; font-size:13px; border-bottom:1px solid var(--border)">
10353      ${progressArtHtml(ic,{size:38})}
10354      <span style="flex:1">${escapeHtml(label)}</span>
10355      <span style="font-weight:700; color:var(--orange-text); white-space:nowrap">${escapeHtml(val)}</span>
10356    </div>`;
10357  });
10358
10359  const html = `<div class="modal-header">
10360      <h3 class="modal-title">${sportUiIcon('trend')} Niveaux & XP</h3>
10361      <button class="modal-close" data-ui-click="closeModal()">×</button>
10362    </div>
10363    <div class="modal-body">
10364      <div class="text-faint" style="font-size:12px; margin-bottom:12px; text-align:center">
10365        Tu es <strong style="color:var(--orange-text)">Niveau ${n.niveau} · ${escapeHtml(n.titre)}</strong> avec <strong>${xp.toLocaleString('fr-FR')} XP</strong> cette saison.
10366        ${n.max ? 'Tu es au sommet 🏆' : `Plus que ${n.restant} XP pour ${escapeHtml(n.suivant.titre)}.`}
10367      </div>
10368      <div class="section-title">${sportUiIcon('trend')} Les niveaux</div>
10369      ${lignes}
10370      <div class="section-title" style="margin-top:18px">${progressArtHtml('serie',{size:34})} Comment gagner de l'XP</div>
10371      ${baremeHtml}
10372      <div class="text-faint" style="font-size:11px; margin-top:12px; text-align:center; line-height:1.4">
10373        L'XP récompense ton activité sur le court. Elle est remise à zéro à chaque nouvelle saison.
10374      </div>
10375    </div>`;
10376  showModal(html);
10377}
10378
10379function prochainesSeancesJoueur(joueurId) {
10380  const maintenant = new Date();
10381  return (listeSure(cache.seancesCours))
10382    .map(seance => ({
10383      seance,
10384      creneau: creneauCoursById(seance.creneau_id),
10385      presence: presenceDeSeancePourJoueur(seance.id, joueurId)
10386    }))
10387    .filter(item => item.creneau && item.presence && !seanceCoursEstTerminee(item.seance, item.creneau, maintenant))
10388    .sort((a, b) => (a.seance.date || '').localeCompare(b.seance.date || '') || (a.creneau.heure_debut || '').localeCompare(b.creneau.heure_debut || ''));
10389}
10390
10391// Lecture sportive commune de la forme récente. La forme n'est ni une note
10392// subjective ni la statistique de toute la saison : c'est le taux de victoires
10393// sur les 10 derniers matchs réellement joués (WO exclus).
10394function diagnosticFormeRecente(matches) {
10395  const fenetre = matchsHorsWO(listeSure(matches)).slice(0, 10);
10396  const compte = comptesMatchs(fenetre);
10397  let libelle = 'En attente de matchs';
10398  if (fenetre.length >= 3) {
10399    if (compte.tauxVictoire >= 70) libelle = 'Excellente dynamique';
10400    else if (compte.tauxVictoire >= 55) libelle = 'Dynamique positive';
10401    else if (compte.tauxVictoire >= 45) libelle = 'Résultats équilibrés';
10402    else if (compte.tauxVictoire >= 30) libelle = 'Résultats à consolider';
10403    else libelle = 'Série difficile';
10404  }
10405  return { matches: fenetre, compte, disponible: fenetre.length >= 3, libelle };
10406}
10407
10408function renderPlayerHome() {
10409  if (!profile || !profile.joueur_id) return `<div class="empty">Profil joueur non trouvé. Contacte ton coach.</div>`;
10410  const j = joueurById(profile.joueur_id);
10411  if (!j) return `<div class="empty">Ta fiche joueur n'a pas pu être chargée.</div>`;
10412  if (j.statut === 'en_attente_validation') return renderEcranAttenteValidation(j);
10413  const estPreAca = estPreAcademie(j);
10414  const stats = statsJoueur(j.id);
10415  const prochainsCours = estPreAca ? [] : prochainesSeancesJoueur(j.id);
10416  const forme = diagnosticFormeRecente(matchsDuJoueur(j.id));
10417  const prochainsTournois = (listeSure(cache.tournois))
10418    .filter(t => (listeSure(t.inscrits)).includes(j.id) && (t.date_fin || t.date_debut) >= dateLocaleISO(new Date()))
10419    .sort((a, b) => (a.date_debut || '').localeCompare(b.date_debut || ''))
10420    .slice(0, 4);
10421  const dateAccueil = new Date().toLocaleDateString('fr-FR', { weekday: 'short', day: '2-digit', month: 'short' }).replace('.', '');
10422
10423  let html = `<section class="ux-player-home" aria-labelledby="player-home-title">
10424    <div class="ux-home-heading"><div><div class="ux-home-kicker">Bonjour ${escapeHtml(j.prenom)}</div><h2 class="ux-home-title" id="player-home-title">${estPreAca ? 'Mes tournois et mes matchs' : "Ma saison en un coup d'œil"}</h2></div><div class="ux-home-date">${escapeHtml(dateAccueil)}</div></div>
10425    ${estPreAca ? '' : renderNiveauXP(j.id)}
10426    <div class="sub-title">Mes statistiques</div>
10427    <div class="ux-summary-grid player">
10428      ${estPreAca ? '' : `<button class="ux-summary-card" data-ui-click="openDemanderClassementJoueur()"><div class="ux-summary-label">Classement</div><div class="ux-summary-value" style="color:var(--sport-orange)">${escapeHtml(j.classement || 'NC')}</div><div class="ux-summary-detail">Objectif ${escapeHtml(j.objectif || '—')}</div></button>`}
10429      <button class="ux-summary-card" data-ui-click="navigate('matches')"><div class="ux-summary-label">Matches</div><div class="ux-summary-value">${stats.total}</div><div class="ux-summary-detail">Cette saison</div></button>
10430      <button class="ux-summary-card" data-ui-click="navigate('matches')"><div class="ux-summary-label">Victoires</div><div class="ux-summary-value" style="color:var(--success-text)">${stats.tauxVictoire}%</div><div class="ux-summary-detail">WO exclus</div></button>
10431    </div>
10432    ${estPreAca ? '' : `<button class="ux-form-card" data-ui-click="navigate('myprofile')"><img src="assets/v4/progress/serie.webp" alt=""><span><strong>Dynamique récente : ${escapeHtml(forme.libelle.toLowerCase())}</strong><small>${forme.matches.length ? `${forme.compte.victoires} V · ${forme.compte.defaites} D sur les ${forme.matches.length} derniers matchs joués · WO exclus` : 'Joue ton premier match pour lancer le suivi'}</small></span><em>→</em></button>`}`;
10433
10434  if (prochainsTournois.length) {
10435    html += `<div class="sub-title">${prochainsTournois.length > 1 ? 'Mes prochains tournois' : 'Mon prochain tournoi'}</div>`;
10436    prochainsTournois.forEach(tournoi => {
10437      const participants = autresInscritsNomsPole(tournoi, j.id);
10438      html += `<button class="ux-event-card" data-ui-click="openModalTournoi(${escapeHtml(encoderArgumentTexteUI(tournoi.id))})"><img src="assets/v4/nav/tournaments.webp" alt=""><span><strong>${escapeHtml(tournoi.nom || tournoi.lieu || 'Tournoi')}</strong><small>Inscrit · ${formatDate(tournoi.date_debut)}${tournoi.lieu ? ` · ${escapeHtml(tournoi.lieu)}` : ''}</small>${participants.length ? `<small class="tournament-participants">🎾 Autres inscrits de l’Académie : ${escapeHtml(participants.join(', '))}</small>` : ''}</span><em>→</em></button>`;
10439    });
10440  } else {
10441    html += `<div class="sub-title">Mes prochains tournois</div><button class="ux-event-card ux-empty-card" data-ui-click="navigate('calendar')">
10441<img src="assets/v4/nav/tournaments.webp" alt=""><span><strong>Aucun tournoi inscrit à venir</strong><small>Consulte les propositions du coach et inscris-toi lorsqu'une date te convient.</small></span><em>→</em></button>`;
10442  }
10443
10444  if (!estPreAca) {
10445    html += `<div class="sub-title">Mes prochains cours Académie</div>`;
10446    if (prochainsCours.length) {
10447    prochainsCours.forEach(({ seance, creneau, presence }) => {
10448      const d = new Date(`${seance.date}T12:00:00`);
10449      const statut = PRESENCE_STATUTS[presence.statut] || PRESENCE_STATUTS.non_renseigne;
10450      const presentActif = presence.statut === 'present';
10451      const absentActif = presence.statut === 'absent';
10452      const retardActif = presence.statut === 'retard';
10453      html += `<article class="ux-course-card"><div class="ux-course-date"><strong>${d.getDate()}</strong>${d.toLocaleDateString('fr-FR', { month: 'short' }).replace('.', '')}</div><div><div class="ux-course-title">${escapeHtml(creneau.nom || 'Cours Académie')}</div><div class="ux-course-meta">${escapeHtml(creneau.heure_debut || '')}–${escapeHtml(creneau.heure_fin || '')} · ${escapeHtml(statut.label || 'À renseigner')}</div></div><button class="ux-home-link" data-ui-click="navigate('mescours')" aria-label="Voir les détails du cours">Voir le cours</button><div class="ux-presence-actions"><button class="present ${presentActif ? 'active' : ''}" aria-pressed="${presentActif}" data-ui-click="setPresenceJoueur(${escapeHtml(encoderArgumentTexteUI(seance.id))},${escapeHtml(encoderArgumentTexteUI(presentActif ? 'non_renseigne' : 'present'))})">${presentActif ? '✓ ' : ''}Présent</button><button class="absent ${absentActif ? 'active' : ''}" aria-pressed="${absentActif}" data-ui-click="setPresenceJoueur(${escapeHtml(encoderArgumentTexteUI(seance.id))},${escapeHtml(encoderArgumentTexteUI(absentActif ? 'non_renseigne' : 'absent'))})">${absentActif ? '✓ ' : ''}Absent</button><button class="retard ${retardActif ? 'active' : ''}" aria-pressed="${retardActif}" data-ui-click="setPresenceJoueur(${escapeHtml(encoderArgumentTexteUI(seance.id))},${escapeHtml(encoderArgumentTexteUI(retardActif ? 'non_renseigne' : 'retard'))})">${retardActif ? '✓ ' : ''}Retard</button></div></article>`;
10454    });
10455    } else {
10456      html += `<button class="ux-course-card ux-course-button ux-empty-card" data-ui-click="navigate('mescours')"><div class="ux-course-date">—</div><div><div class="ux-course-title">Aucun cours programmé prochainement</div><div class="ux-course-meta">Le prochain créneau apparaîtra ici dès que le planning sera publié.</div></div><span>→</span></button>`;
10457    }
10458  }
10459  html += `<div class="sub-title">${estPreAca ? 'Ajouter un match' : 'Déclarer mon activité'}</div><div class="ux-actions">
10460      <button class="ux-action primary" data-ui-click="openModalMatch()"><img src="assets/v4/nav/matches.webp" alt=""><span>Déclarer un match<small>Résultat, surface et ressenti</small></span></button>
10461      ${estPreAca ? '' : `<button class="ux-action secondary" data-ui-click="openModalEntrainement()"><img src="assets/v4/nav/training.webp" alt=""><span>Déclarer une séance libre<small>Hors cours Académie</small></span></button>`}
10462    </div>`;
10463  html += `</section>`;
10464  return html;
10465}
10466
10467function renderPlayerProgression() {
10468  if (!profile || !profile.joueur_id) return `<div class="empty">Profil joueur non trouvé. Contacte ton coach.</div>`;
10469  const j = joueurById(profile.joueur_id);
10470  if (!j) return `<div class="empty">Ta fiche joueur n'a pas pu être chargée.</div>`;
10471  if (j.statut === 'en_attente_validation') return renderEcranAttenteValidation(j);
10472
10473  const stats = statsJoueur(j.id);
10474  const perfs = TennisDomainRules.listerPerformancesJoueur(
10475    matchsDuJoueur(j.id),
10476    match => classementADate(j.id, match.date),
10477    'ecart'
10478  );
10479  const matchsJoues = matchsHorsWO(matchsDuJoueur(j.id));
10480  const forme = diagnosticFormeRecente(matchsJoues);
10481  const formeRecente = forme.matches;
10482  const compteForme = forme.compte;
10483  const formeDisponible = forme.disponible;
10484  const progressionFft = progressionClassementPeriode(j.id, 'saison');
10485  const progressionFftLabel = progressionFft > 0
10486    ? `+${progressionFft}`
10487    : progressionFft < 0 ? String(progressionFft) : 'Stable';
10488  const surfaces = [
10489    ['dur', 'Dur'], ['terre', 'Terre battue'], ['indoor', 'Indoor']
10490  ].map(([code, label]) => {
10491    const liste = matchsJoues.filter(match => match.surface === code);
10492    const compte = comptesMatchs(liste);
10493    return { code, label, total: compte.total, taux: compte.total ? compte.tauxVictoire : 0 };
10494  });
10495  const axes = axesActifsJoueur(j.id);
10496  const objectif = (listeSure(j.axes_progres))[0] || 'Ton prochain axe prioritaire sera défini avec ton coach.';
10497  const objectifsHtml = axes.length
10498    ? axes.map(axe => `<div class="ux-objective-card"><img src="assets/v4/progress/objectif_groupe-v2.webp" alt=""><div class="ux-objective-copy"><strong>${escapeHtml(axe.texte)}</strong><small>Axe partagé par ton coach</small></div><label class="ux-objective-check"><input type="checkbox" data-ui-change="toggleAxeFait(${escapeHtml(encoderArgumentTexteUI(axe.id))}, this.checked)" aria-label="Marquer cet axe comme réalisé"><span>Terminé</span></label></div>`).join('')
10499    : `<div class="ux-objective-card"><img src="assets/v4/progress/objectif_groupe-v2.webp" alt=""><div class="ux-objective-copy"><strong>${escapeHtml(objectif)}</strong><small>À travailler lors de tes prochaines séances</small></div></div>`;
10500
10501  let html = `<section class="ux-player-progression" aria-labelledby="player-progress-title">
10502    <div class="ux-home-heading"><div><div class="ux-home-kicker">Ma progression</div><h2 class="ux-home-title" id="player-progress-title">Objectifs et dynamique</h2></div></div>
10503    <div class="ux-progress-metrics">
10504      <button data-ui-click="navigate('matches')"><strong>${perfs.length}</strong><span>Belles perfs</span></button>
10505      <button data-ui-click="navigate('matches')"><strong>${formeDisponible ? compteForme.tauxVictoire + '%' : '—'}</strong><span>${formeDisponible ? 'Forme récente' : 'Pas assez de matchs'}</span><small>${formeDisponible ? `${compteForme.victoires} V · ${compteForme.defaites} D · ${formeRecente.length} derniers matchs` : `${formeRecente.length}/3 matchs requis`} · WO exclus</small></button>
10506      <div><strong>${escapeHtml(progressionFftLabel)}</strong><span>Progression FFT${progressionFft ? ` · ${Math.abs(progressionFft)} échelon${Math.abs(progressionFft) > 1 ? 's' : ''}` : ''}</span></div>
10507    </div>
10508    <p class="ux-form-explainer"><strong>${escapeHtml(forme.libelle)}</strong> · La forme récente correspond au pourcentage de victoires sur tes 10 derniers matchs joués. Elle démarre après 3 matchs et exclut les WO.</p>
10509    <div class="ux-chart-title"><strong>Évolution du classement</strong><span>Saison complète</span></div>
10510    <div class="ux-chart-card">
10510<canvas id="myChartClassement"></canvas></div>`;
10511
10512  if (stats.total >= 3) {
10513    html += `<div class="ux-chart-title"><strong>Forme sur les 10 derniers matchs</strong><span>${stats.tauxVictoire}% saison</span></div><div class="ux-chart-card compact"><canvas id="myChartForme"></canvas></div>`;
10514  } else {
10515    html += `<div class="ux-form-card ux-empty-card"><img src="assets/v4/progress/serie.webp" alt=""><span><strong>Forme récente</strong><small>Trois matchs joués sont nécessaires pour afficher la courbe.</small></span></div>`;
10516  }
10517
10518  html += `<div class="ux-chart-title"><strong>Victoires et défaites cumulées</strong><span>Saison complète · WO exclus</span></div><div class="ux-chart-card compact"><canvas id="myChartCumul"></canvas></div>`;
10519
10520  html += `<div class="ux-chart-title"><strong>Résultats selon la surface</strong><span>${matchsJoues.length} matchs</span></div><div class="ux-surface-card">${surfaces.map(surface => `<div class="ux-surface-row"><span>${escapeHtml(surface.label)}</span><div><i style="width:${surface.total ? surface.taux : 0}%"></i></div><strong>${surface.total ? surface.taux + '%' : '—'}</strong></div>`).join('')}</div>
10521    <div class="ux-chart-title"><strong>Objectif prioritaire</strong><span>${axes.length ? `${axes.length} en cours` : 'À définir'}</span></div>
10522    <div class="ux-objective-list">${objectifsHtml}</div>
10523  </section>`;
10524  return html;
10525}
10526
10527// ============================================================
10528// PRÉFÉRENCES DE NOTIFICATIONS EMAIL — UI partagée joueur / coach
10529// ============================================================
10530// mode : 'joueur' (affiche les 5 prefs joueur) ou 'coach' (affiche la pref coach)
10531// opts : { noTitle: true } pour ne pas afficher le titre (cas modale qui a déjà son header)
10532function renderPreferencesNotifs(mode, opts = {}) {
10533  const userId = session && session.user && session.user.id;
10534  if (!userId) return '';
10535
10536  const prefs = (cache.preferencesNotifs && cache.preferencesNotifs[userId]) || {};
10537  // Helper : pour le joueur, défaut activé (true sauf si explicitement false).
10538  // Pour le coach (opt-in), défaut désactivé (true seulement si explicitement true).
10539  const isOn = (key) => mode === 'coach' ? (prefs[key] === true) : (prefs[key] !== false);
10540
10541  // Liste des préférences à afficher selon le mode
10542  const items = mode === 'joueur' ? [
10543    { key: 'email_bilan_publie', icon: '📋', label: 'Bilan trimestriel publié', help: 'Quand le coach publie ton bilan trimestriel' },
10544    { key: 'email_inscription_validee', icon: '✓', label: 'Inscription validée', help: 'Quand le coach valide ton inscription à l\'académie (1 seule fois)' },
10545    { key: 'email_rappel_tournoi', icon: '⏰', label: 'Rappel tournoi (J-28)', help: 'Rappel automatique 28 jours avant un tournoi auquel tu es inscrit' },
10546    { key: 'email_match_ajoute_par_coach', icon: '🎾', label: 'Match ajouté par le coach', help: 'Quand le coach saisit un match à ta place' },
10547    { key: 'email_belle_perf', icon: '🔥', label: 'Belle perf', help: 'Quand tu signes une victoire sur un joueur nettement mieux classé (écart ≥ 2 échelons)' }
10548  ] : [
10549    { key: 'email_coach_match', icon: '🎾', label: 'Match saisi par un joueur', help: 'À chaque nouveau match joué déclaré par un joueur de l\'académie' },
10550    { key: 'email_coach_presence', icon: '⏱️', label: 'Absences et retards', help: 'Quand un joueur annonce une absence ou un retard à un cours' },
10551    { key: 'email_coach_perf', icon: '🔥', label: 'Belle perf d\'un joueur', help: 'Quand un joueur de l\'académie signe une victoire sur un adversaire mieux classé' },
10552    { key: 'email_coach_entrainement', icon: '🎾', label: 'Entraînement déclaré', help: 'Quand un joueur déclare une séance d\'entraînement' },
10553    { key: 'email_coach_demande_classement', icon: '📊', label: 'Demande de classement', help: 'Quand un joueur déclare un changement de classement à valider' },
10554    { key: 'email_rappel_bilan_trimestre', icon: '📋', label: 'Rappels clôture trimestre', help: 'Rappels J-30, J-15 et J-7 avant la fin du trimestre pour ne pas oublier les bilans' }
10555  ];
10556
10557  let html = '';
10558  if (!opts.noTitle) {
10559    html += `<div class="section-title">🔔 Mes préférences d'emails</div>`;
10560  }
10561  html += `<div class="card" style="padding:14px">
10562    <div class="text-faint mb-2" style="font-size:12px">
10563      ${mode === 'joueur'
10564        ? 'Décoche les emails que tu ne veux plus recevoir. Les notifications dans l\'app continueront d\'apparaître dans tous les cas.'
10565        : 'Coche les emails que tu souhaites recevoir. Par défaut, aucun email n\'est envoyé. Les notifications dans l\'app apparaissent dans tous les cas.'}
10566    </div>`;
10567
10568  items.forEach(item => {
10569    const on = isOn(item.key);
10570    html += `<label style="display:flex; align-items:flex-start; gap:10px; padding:10px 0; border-top:1px solid var(--border); cursor:pointer">
10571          <input type="checkbox" ${on ? 'checked' : ''} data-ui-change="toggleNotifPref(${escapeHtml(encoderArgumentTexteUI(item.key))}, this.checked)" style="margin-top:3px; width:auto; flex-shrink:0">
10572      <div style="flex:1">
10573        <div style="font-size:13px; font-weight:600">${item.icon} ${escapeHtml(item.label)}</div>
10574        <div class="text-faint" style="font-size:11px; margin-top:2px">${escapeHtml(item.help)}</div>
10575      </div>
10576    </label>`;
10577  });
10578
10579  html += `</div>`;
10580
10581  return html;
10582}
10583
10584// Sauvegarde de la préférence : upsert dans la table
10585async function toggleNotifPref(key, value) {
10586  if (!session) return;
10587  const userId = session.user.id;
10588
10589  // Construire l'objet à upsert : on inclut user_id + le champ modifié
10590  const update = { user_id: userId, [key]: value };
10591
10592  // Upsert : insère si pas de ligne, sinon update
10593  const { data, error } = await db.from('preferences_notifications')
10594    .upsert(update, { onConflict: 'user_id' })
10595    .select()
10596    .single();
10597
10598  if (error) {
10599    toastErreur(error);
10600    return;
10601  }
10602
10603  // Mise à jour du cache local
10604  cache.preferencesNotifs[userId] = data;
10605  toast(value ? 'Email activé' : 'Email désactivé');
10606}
10607
10608
10609// ============================================================
10610// SECTION JOUEUR : 🎯 Mes objectifs du trimestre
10611// ============================================================
10612// Affiche les objectifs fixés par le coach dans le dernier bilan publié/accepté/commenté,
10613// avec barres de progression automatiques sur les critères mesurables.
10614function renderMyGroupeScreen() {
10615  const j = profile.joueur_id ? joueurById(profile.joueur_id) : null;
10616  if (!j) {
10617    return `<div class="empty"><span class="icon">🤝</span>Profil introuvable.</div>`;
10618  }
10619  if (!j.groupe || !cache.config[j.groupe]) {
10620    return `<div class="empty"><span class="icon">🤝</span>Tu n'es pas encore affecté à un groupe.<br>
10621      <span class="text-faint">Ton coach t'assignera à un groupe prochainement.</span></div>`;
10622  }
10623
10624  const g = cache.config[j.groupe];
10625  const dyn = cache.dynamiqueGroupe;
10626  const nbMembres = dyn ? (Number(dyn.nb_membres) || 0) : 0;
10627  const nbMatches = dyn ? (Number(dyn.nb_matches) || 0) : 0;
10628  const nbVictoires = dyn ? (Number(dyn.nb_victoires) || 0) : 0;
10629  const perfs = listeSure(cache.perfsGroupe);
10630  const monteesGroupe = listeSure(cache.monteesGroupe);
10631  const objectif = cache.objectifGroupe;
10632  const prochainsTournois = listeSure(cache.prochainsTournoisGroupe);
10633  const membres = listeSure(cache.membresGroupe);
10634
10635  // En-tête du groupe
10636  let html = renderUxScreenHeading('Mon collectif', g.nom) + `<div class="card" style="padding:18px; margin-bottom:12px; text-align:center; background:linear-gradient(135deg, var(--bg-card), var(--bg-input))">
10637    <div style="font-size:32px; line-height:1">🤝</div>
10638    <div style="font-size:20px; font-weight:700; margin-top:6px">${escapeHtml(g.nom)}</div>
10639    <div class="text-faint" style="font-size:13px; margin-top:2px">${nbMembres} joueur${nbMembres > 1 ? 's' : ''} dans ton groupe</div>
10640  </div>`;
10641
10642  // ===== Les joueurs du groupe (nom + classement + photo) =====
10643  if (membres.length > 0) {
10644    html += `<div class="section-title">👥 Les joueurs du groupe</div>`;
10645    html += `<div class="card" style="padding:8px 14px">`;
10646    membres.forEach((m, i) => {
10647      const estMoi = m.est_moi;
10648      const nbM = Number(m.nb_matches_saison) || 0;
10649      html += `<div style="display:flex; align-items:center; gap:12px; padding:10px 0;${i < membres.length - 1 ? ' border-bottom:1px solid var(--border)' : ''}">
10650        ${avatarHtml(m, 'md')}
10651        <div style="flex:1; min-width:0">
10652          <div style="font-size:14px; font-weight:${estMoi ? '700' : '500'}">${escapeHtml(m.prenom)} ${escapeHtml(m.nom)}${estMoi ? ' <span style="font-size:11px; color:var(--orange-text); font-weight:600">(toi)</span>' : ''} ${badgeVitrineSimple(m.id)}</div>
10653          <div class="text-faint" style="font-size:11px; margin-top:2px">🎾 ${nbM} match${nbM > 1 ? 's' : ''} cette saison</div>
10654        </div>
10655        <div style="font-size:13px; font-weight:600; color:var(--orange-text); flex-shrink:0">${escapeHtml(m.classement || 'NC')}</div>
10656      </div>`;
10657    });
10658    html += `</div>`;
10659  }
10660
10661  // ===== Objectif collectif de matches (saison) =====
10662  if (g.objectifMatches && objectif) {
10663    const matchesSaison = Number(objectif.nb_matches_saison) || 0;
10664    const membresObj = Number(objectif.nb_membres) || nbMembres || 1;
10665    const objectifTotal = g.objectifMatches * membresObj; // objectif annuel par joueur × nb de joueurs
10666    const pct = objectifTotal > 0 ? Math.min(100, Math.round((matchesSaison / objectifTotal) * 100)) : 0;
10667    html += `<div class="section-title">🎯 Objectif collectif de la saison</div>`;
10668    html += `<div class="card" style="padding:14px; margin-bottom:12px">
10669      <div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:8px">
10670        <span style="font-size:14px; font-weight:600">${matchesSaison} / ${objectifTotal} matches</span>
10671        <span style="font-size:13px; font-weight:700; color:var(--orange-text)">${pct}%</span>
10672      </div>
10673      <div style="background:var(--bg-input); border-radius:10px; height:12px; overflow:hidden">
10674        <div style="width:${pct}%; height:100%; background:linear-gradient(90deg, var(--orange), var(--success)); border-radius:10px; transition:width 0.3s"></div>
10675      </div>
10676      <div class="text-faint" style="font-size:11px; margin-top:8px; text-align:center">
10677        ${pct >= 100 ? 'Objectif atteint, bravo à tout le groupe ! 🎉' : `Chaque match que tu joues fait avancer le groupe vers son objectif 💪`}
10678      </div>
10679    </div>`;
10680  }
10681
10682  // ===== Prochain rendez-vous du groupe (tournois avec membres inscrits) =====
10683  if (prochainsTournois.length > 0) {
10684    html += `<div class="section-title">📅 Vos prochains rendez-vous</div>`;
10685    prochainsTournois.forEach(t => {
10686      const nbG = Number(t.nb_inscrits_groupe) || 0;
10687      const noms = listeSure(t.participants);
10688      const tournoiId = t.tournoi_id;
10689      const tournoiLocal = cache.tournois.find(tournoi => tournoi.id === tournoiId);
10690      const dejaInscrit = !!(profile.joueur_id && tournoiLocal && listeSure(tournoiLocal.inscrits).includes(profile.joueur_id));
10691      html += `<div class="card clickable" data-ui-click="openModalTournoi(${escapeHtml(encoderArgumentTexteUI(tournoiId))})" style="cursor:pointer; padding:12px; margin-bottom:8px">
10692        <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:10px">
10693          <div style="flex:1; min-width:0">
10694            <div style="font-size:14px; font-weight:600">${escapeHtml(t.nom)}</div>
10695            <div class="text-faint" style="font-size:12px; margin-top:2px">📆 ${formatDate(t.date_debut)}${t.lieu ? ' · 📍 ' + escapeHtml(t.lieu) : ''}</div>
10696          </div>
10697          <div style="flex-shrink:0; text-align:center; background:var(--bg-input); border-radius:var(--radius-sm); padding:6px 10px">
10698            <div style="font-size:18px; font-weight:700; color:var(--orange-text); line-height:1">${nbG}</div>
10699            <div class="text-faint" style="font-size:10px">du groupe</div>
10700          </div>
10701        </div>
10702        ${noms.length > 0 ? `<div style="font-size:12px; margin-top:8px; padding-top:8px; border-top:1px solid var(--border)"><span class="text-faint">Participants de l'académie :</span> ${noms.map(n => escapeHtml(n)).join(', ')}</div>` : ''}
10703        <div style="font-size:12px; color:var(--success-text); margin-top:8px">🎾 Vous serez ${nbG} du groupe à y participer${nbG > 1 ? ' — encouragez-vous !' : ''}</div>
10704        <div style="font-size:12px; color:var(--orange-text); font-weight:700; margin-top:8px">${dejaInscrit ? 'Voir le tournoi et gérer mon inscription' : 'Voir le tournoi et m’inscrire'} →</div>
10705      </div>`;
10706    });
10707  }
10708
10709  // Bloc dynamique collective
10710  html += `<div class="section-title">⚡ La dynamique du groupe ce trimestre</div>`;
10711  html += `<div class="card" style="padding:14px; margin-bottom:12px">
10712    <div style="display:flex; gap:10px">
10713      <div style="flex:1; text-align:center; background:var(--bg-input); border-radius:var(--radius-sm); padding:14px">
10714        <div style="font-size:28px; font-weight:700; color:var(--orange-text); line-height:1.1">${nbMatches}</div>
10715        <div class="text-faint" style="font-size:11px; margin-top:3px">matches joués ensemble</div>
10716      </div>
10717      <div style="flex:1; text-align:center; background:var(--bg-input); border-radius:var(--radius-sm); padding:14px">
10718        <div style="font-size:28px; font-weight:700; color:var(--success-text); line-height:1.1">${nbVictoires}</div>
10719        <div class="text-faint" style="font-size:11px; margin-top:3px">victoires du groupe</div>
10720      </div>
10721    </div>`;
10722  if (nbMatches === 0) {
10723    html += `<div class="text-faint" style="font-size:12px; text-align:center; padding:8px 6px 2px">Le groupe n'a pas encore joué ce trimestre. À vous de lancer la dynamique ! 🔥</div>`;
10724  }
10725  html += `</div>`;
10726
10727  // ===== Faits marquants du groupe =====
10728  const faits = [];
10729  if (nbMatches > 0) {
10730    const tauxV = Math.round((nbVictoires / nbMatches) * 100);
10731    if (tauxV >= 60) faits.push(`🔥 Le groupe gagne ${tauxV}% de ses matches ce trimestre !`);
10732    if (nbMatches >= 10) faits.push(`💪 Déjà ${nbMatches} matches joués ce trimestre, quelle énergie !`);
10733  }
10734  if (perfs.length >= 3) faits.push(`🏆 ${perfs.length} belles perfs réalisées ce trimestre !`);
10735  if (monteesGroupe.length > 0) faits.push(`📈 ${monteesGroupe.length} montée${monteesGroupe.length > 1 ? 's' : ''} de classement dans le groupe ce trimestre !`);
10736  if (faits.length > 0) {
10737    html += `<div class="section-title">✨ Faits marquants</div>`;
10738    html += `<div class="card" style="padding:14px; margin-bottom:12px">`;
10739    faits.forEach((f, i) => {
10740      html += `<div style="font-size:13px; padding:6px 0;${i < faits.length - 1 ? ' border-bottom:1px solid var(--border)' : ''}">${f}</div>`;
10741    });
10742    html += `</div>`;
10743  }
10744
10745  // Mur des belles perfs du groupe : victoires marquantes + montées validées.
10746  html += `<div class="section-title">🏆 Le mur des très belles perfs</div>`;
10747  if (perfs.length === 0 && monteesGroupe.length === 0) {
10748    html += `<div class="card" style="padding:16px; text-align:center">
10749      <div class="text-faint" style="font-size:13px">Pas encore de belle perf ce trimestre.</div>
10750      <div class="text-faint" style="font-size:12px; margin-top:4px">Une belle perf, c'est une victoire contre un joueur mieux classé. À vous de jouer ! 💪</div>
10751    </div>`;
10752  } else {
10753    monteesGroupe.forEach(montee => {
10754      html += `<div class="card" style="padding:12px; margin-bottom:8px; display:flex; gap:12px; align-items:center; border-left:3px solid var(--success)">
10755        <div style="font-size:24px; flex-shrink:0">📈</div>
10756        <div style="flex:1; min-width:0">
10757          <div style="font-size:13px"><strong>${escapeHtml(montee.prenom)}</strong> monte de <strong>${escapeHtml(montee.ancien_classement || 'NC')}</strong> à <strong style="color:var(--success-text)">${escapeHtml(montee.nouveau_classement)}</strong> 👏</div>
10758          <div class="text-faint" style="font-size:11px; margin-top:2px">Très belle perf · montée de classement${montee.date_montee ? ` · ${formatDate(montee.date_montee)}` : ''}</div>
10759        </div>
10760      </div>`;
10761    });
10762    perfs.forEach(p => {
10763      const dateStr = p.match_date ? formatDate(p.match_date) : '';
10764      html += `<div class="card" style="padding:12px; margin-bottom:8px; display:flex; gap:12px; align-items:center">
10765        <div style="font-size:24px; flex-shrink:0">🎾</div>
10766        <div style="flex:1; min-width:0">
10767          <div style="font-size:13px"><strong>${escapeHtml(p.prenom)}</strong> a battu un <strong style="color:var(--orange-text)">${escapeHtml(p.adversaire_class)}</strong> 👏${p.est_double ? ` <span class="format-double-badge">Double</span>` : ''}</div>
10768          ${dateStr ? `<div class="text-faint" style="font-size:11px; margin-top:2px">${dateStr}${p.est_double ? ' · double' : ''}</div>` : (p.est_double ? `<div class="text-faint" style="font-size:11px; margin-top:2px">double</div>` : '')}
10769        </div>
10770      </div>`;
10771    });
10772  }
10773
10774  html += `<div class="text-faint" style="font-size:12px; text-align:center; margin-top:14px; font-style:italic">Vous progressez ensemble 💪</div>`;
10775
10776  return html;
10777}
10778
10779
10780// ============================================================
10781// SCREEN: PLAYERS (coach)
10782// ============================================================
10783const playerFilters = etatApp.filtresJoueurs;
10784
10785function setPlayerFilter(value) {
10786  etatApp.definirFiltreGroupeJoueur(value);
10787  render();
10788}
10789
10790function setPlayerFilterField(field, value) {
10791  playerFilters[field] = value;
10792  render();
10793}
10794
10795function resetAllPlayerFilters() {
10796  etatApp.reinitialiserVueJoueurs();
10797  render();
10798}
10799
10800function toggleAdvancedFilters() {
10801  etatApp.basculerFiltresAvances();
10802  render();
10803}
10804
10805// Vrai si au moins un filtre est actif (autre que "Tous")
10806function hasActiveFilters() {
10807  return etatApp.filtreGroupeJoueur !== 'all'
10808    || playerFilters.search.trim() !== ''
10809    || playerFilters.sexe !== 'all'
10810    || playerFilters.ageMin !== ''
10811    || playerFilters.ageMax !== ''
10812    || playerFilters.classMin !== ''
10813    || playerFilters.classMax !== '';
10814}
10815
10816// Applique tous les filtres sur un tableau de joueurs
10817function filterPlayers(joueurs) {
10818  let result = joueurs;
10819
10820  // Filtre groupe — ignoré en mode Palmyre (pas de groupes au stage, seuls les
10821  // participants sont listés ; la recherche par nom reste active).
10822  if (isPoleEnv()) {
10823    if (etatApp.filtreGroupeJoueur === 'sans_groupe') {
10824      result = result.filter(j => !j.groupe || !cache.config[j.groupe] || typeof cache.config[j.groupe] !== 'object');
10825    } else if (etatApp.filtreGroupeJoueur !== 'all') {
10826      result = result.filter(j => j.groupe === etatApp.filtreGroupeJoueur);
10827    }
10828  }
10829
10830  return TennisDomainRules.filtrerJoueurs(result, playerFilters, new Date());
10831}
10832
10833function renderPlayers() {
10834  if (!isCoach()) return '';
10835  const entete = renderUxScreenHeading('Suivi permanent', 'Mes joueurs')
10836    + (isPreAcademieEnv() ? `<div class="flex mb-2" style="justify-content:flex-end"><button class="btn btn-primary" data-ui-click="openModalPlayer()">+ Ajouter un joueur</button></div>` : '');
10837  // Exclure les joueurs en attente de validation + restreindre à la catégorie courante
10838  // (Académie : hors Pré-Académie ; Pré-Académie : uniquement Pré-Académie).
10839  let joueursVisibles = joueursActifsDansEnvCourant();
10840  const joueursArchives = cache.joueurs.filter(j => j.statut === 'archive' && joueurDansEnvCourant(j));
10841
10842  // En mode Palmyre : ne montrer que les participants à l'édition.
10843  if (!isPoleEnv()) {
10844    const ev = getActiveEvenement();
10845    if (joueursVisibles.length === 0) {
10846      return entete + `<div class="empty"><span class="icon">🏖️</span>Aucun participant à ${ev ? escapeHtml(ev.nom) : 'la Palmyre'}.<br>
10847        <span class="text-faint">Coche « Participe à la Palmyre » sur la fiche des joueurs concernés (modifiable depuis n'importe quel environnement).</span></div>`;
10848    }
10849  }
10850
10851  if (joueursVisibles.length === 0 && cache.joueurs.length === 0) {
10852    return entete + `<div class="empty"><span class="icon">👥</span>Aucun joueur enregistré.<br>
10853      <span class="text-faint">Communique le code d'accès de l'académie aux joueurs pour qu'ils s'inscrivent, puis valide leur fiche.</span></div>`;
10854  }
10855
10856  if (joueursVisibles.length === 0 && isPreAcademieEnv()) {
10857    return entete + `<div class="empty"><span class="icon">🌱</span>Aucun joueur actuellement en Pré-Académie.<br>
10858      <span class="text-faint">Cet espace reste disponible toute l’année. Un joueur actif, sans groupe Académie et âgé de 11 ans ou moins en âge tennistique, y apparaîtra automatiquement.</span></div>`;
10859  }
10860
10861  if (joueursVisibles.length === 0) {
10862    return entete + `<div class="empty"><span class="icon">⏳</span>Tous les joueurs sont en attente de validation.<br>
10863      <span class="text-faint">Va dans l'onglet <strong>À valider</strong> pour les traiter.</span></div>`;
10864  }
10865
10866  // Compteurs par groupe
10867  const countByGroup = { tremplin: 0, accession: 0, elite: 0, sans_groupe: 0 };
10868  joueursVisibles.forEach(j => {
10869    if (j.groupe && cache.config[j.groupe] && typeof cache.config[j.groupe] === 'object') {
10870      countByGroup[j.groupe] = (countByGroup[j.groupe] || 0) + 1;
10871    } else {
10872      countByGroup.sans_groupe++;
10873    }
10874  });
10875  const joueursActifsFiltres = filterPlayers(joueursVisibles);
10876
10877  let html = entete;
10878
10879  // ====================== BARRE DE FILTRES ======================
10880  // Bloc 1 : recherche par nom (toujours visible)
10881  html += `<div class="card mb-2" style="padding:10px 12px">
10882    <div style="display:flex; gap:8px; align-items:center; margin-bottom:10px">
10883      <input type="text" id="playerSearch" placeholder="Rechercher par nom ou prénom..."
10884        value="${escapeHtml(playerFilters.search)}"
10885        data-ui-input="playerFilters.search = this.value; debouncedSearchRender()"
10886        style="flex:1; padding:8px 12px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px">
10887      <button class="btn btn-sm" data-ui-click="toggleAdvancedFilters()" title="Filtres avancés">
10888        🔧 ${etatApp.filtresAvancesOuverts ? '−' : '+'}
10889      </button>
10890    </div>
10891
10892    ${isPoleEnv() ? `<div class="pill-group" style="margin:0">
10893      <span class="pill ${etatApp.filtreGroupeJoueur === 'all' ? 'active' : ''}" data-ui-click="setPlayerFilter('all')">Tous (${joueursVisibles.length})</span>
10894      ${['tremplin','accession','elite'].map(g => {
10895        if (countByGroup[g] === 0) return '';
10896        const color = g === 'tremplin' ? 'success' : g === 'accession' ? 'info' : 'purple';
10897        const isActive = etatApp.filtreGroupeJoueur === g;
10898        return `<span class="pill ${isActive ? 'active' : ''}" data-ui-click="setPlayerFilter(${escapeHtml(encoderArgumentTexteUI(g))})" style="${isActive ? 'border-color: var(--' + color + '); background: var(--' + color + '-bg); color: var(--' + color + ')' : ''}">${escapeHtml((cache.config[g] || {}).nom || g)} (${countByGroup[g]})</span>`;
10899      }).join('')}
10900      ${countByGroup.sans_groupe > 0 ? `<span class="pill ${etatApp.filtreGroupeJoueur === 'sans_groupe' ? 'active' : ''}" data-ui-click="setPlayerFilter('sans_groupe')">Sans groupe (${countByGroup.sans_groupe})</span>` : ''}
10901      ${joueursArchives.length > 0 ? `<span class="pill ${etatApp.filtreGroupeJoueur === 'archive' ? 'active' : ''}" data-ui-click="setPlayerFilter('archive')" style="${etatApp.filtreGroupeJoueur === 'archive' ? 'border-color: var(--text-dim); background: var(--bg-input); color: var(--text-dim)' : ''}">📦 Archivés (${joueursArchives.length})</span>` : ''}
10902    </div>` : ''}
10903
10904    ${etatApp.filtresAvancesOuverts ? `
10905      <div style="margin-top:12px; padding-top:12px; border-top:1px solid var(--border)">
10906        <div class="text-faint" style="font-size:11px; text-transform:uppercase; letter-spacing:1px; margin-bottom:6px">Sexe</div>
10907        <div class="pill-group" style="margin:0 0 10px 0">
10908          <span class="pill ${playerFilters.sexe === 'all' ? 'active' : ''}" data-ui-click="setPlayerFilterField('sexe', 'all')">Tous</span>
10909          <span class="pill ${playerFilters.sexe === 'F' ? 'active' : ''}" data-ui-click="setPlayerFilterField('sexe', 'F')">Féminin</span>
10910          <span class="pill ${playerFilters.sexe === 'M' ? 'active' : ''}" data-ui-click="setPlayerFilterField('sexe', 'M')">Masculin</span>
10911        </div>
10912
10913        <div class="text-faint" style="font-size:11px; text-transform:uppercase; letter-spacing:1px; margin-bottom:6px">Âge</div>
10914        <div style="display:flex; gap:8px; align-items:center; margin-bottom:10px">
10915          <input type="number" min="7" max="25" placeholder="Min" value="${escapeHtml(playerFilters.ageMin)}"
10916            data-ui-change="setPlayerFilterField('ageMin', this.value)"
10917            style="width:70px; padding:6px 10px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px">
10918          <span class="text-faint" style="font-size:12px">à</span>
10919          <input type="number" min="7" max="25" placeholder="Max" value="${escapeHtml(playerFilters.ageMax)}"
10920            data-ui-change="setPlayerFilterField('ageMax', this.value)"
10921            style="width:70px; padding:6px 10px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px">
10922          <span class="text-faint" style="font-size:12px">ans</span>
10923        </div>
10924
10925        <div class="text-faint" style="font-size:11px; text-transform:uppercase; letter-spacing:1px; margin-bottom:6px">Classement</div>
10926        <div style="display:flex; gap:8px; align-items:center">
10927          <select data-ui-change="setPlayerFilterField('classMin', this.value)"
10928            style="padding:6px 10px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px">
10929            <option value="">Min</option>
10930            ${CLASSEMENTS.map(c => `<option value="${c}" ${playerFilters.classMin === c ? 'selected' : ''}>${c}</option>`).join('')}
10931          </select>
10932          <span class="text-faint" style="font-size:12px">à</span>
10933          <select data-ui-change="setPlayerFilterField('classMax', this.value)"
10934            style="padding:6px 10px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px">
10935            <option value="">Max</option>
10936            ${CLASSEMENTS.map(c => `<option value="${c}" ${playerFilters.classMax === c ? 'selected' : ''}>${c}</option>`).join('')}
10937          </select>
10938        </div>
10939      </div>
10940    ` : ''}
10941
10942    ${hasActiveFilters() ? `
10943      <div style="margin-top:10px; padding-top:10px; border-top:1px solid var(--border); display:flex; justify-content:space-between; align-items:center; gap:8px">
10944        <span class="text-faint" style="font-size:12px">${joueursActifsFiltres.length} joueur${joueursActifsFiltres.length > 1 ? 's' : ''} affiché${joueursActifsFiltres.length > 1 ? 's' : ''} sur ${joueursVisibles.length} au total</span>
10945        <button class="btn btn-sm" data-ui-click="resetAllPlayerFilters()" style="font-size:11px">↻ Réinitialiser</button>
10946      </div>
10947    ` : ''}
10948  </div>`;
10949
10950  // ====================== AFFICHAGE DES JOUEURS ======================
10951  // Cas spécial : filtre "archivés" → on affiche la liste des joueurs archivés
10952  if (etatApp.filtreGroupeJoueur === 'archive') {
10953    html += `<div class="text-faint" style="font-size:12px; margin-bottom:8px; padding:0 4px">📦 Joueurs archivés : sortis de l'académie actif, historique conservé. Clique pour consulter ou réactiver.</div>`;
10954    const archivesAffiches = filterPlayers(joueursArchives);
10955    if (archivesAffiches.length === 0) {
10956      html += `<div class="empty"><span class="icon">📦</span>Aucun joueur archivé ne correspond à ces filtres.</div>`;
10957      return html;
10958    }
10959    archivesAffiches
10960      .sort((a, b) => (a.nom + a.prenom).localeCompare(b.nom + b.prenom))
10961      .forEach(j => {
10962        const age = computeAge(j);
10963        const stats = statsJoueur(j.id);
10964        html += `<div class="player-row" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(j.id
10964))})" style="opacity:0.7">
10965          ${avatarHtml(j, 'md')}
10966          <div class="player-info">
10967            <div class="player-name">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)} <span class="badge" style="background:var(--bg-input); color:var(--text-dim)">${escapeHtml(j.classement || 'NC')}</span></div>
10968            <div class="player-meta">${age !== null ? age + ' ans · ' : ''}${stats.total} matches joués · historique conservé</div>
10969          </div>
10970        </div>`;
10971      });
10972    return html;
10973  }
10974
10975  const joueursAffiches = joueursActifsFiltres;
10976
10977  if (joueursAffiches.length === 0) {
10978    html += `<div class="empty">
10979      <span class="icon">🔎</span>
10980      <strong>Aucun joueur ne correspond à ces filtres</strong><br>
10981      <span class="text-faint">Essaie d'élargir tes critères ou réinitialise les filtres.</span>
10982    </div>`;
10983    return html;
10984  }
10985
10986  // En Palmyre, l'objectif de matchs affiché est celui de l'édition (pas du groupe).
10987  const objCibleAff = !isPoleEnv() ? palmyreGroupConfig().objectifMatches : null;
10988
10989  // Affichage par groupe (ordre tremplin/accession/elite)
10990  ['tremplin','accession','elite'].forEach(key => {
10991    const g = cache.config[key];
10992    if (!g || typeof g !== 'object' || !g.nom) return;
10993    const joueurs = joueursAffiches.filter(j => j.groupe === key);
10994    if (joueurs.length === 0) return;
10995
10996    html += `<div class="section-title grp-${key}">${escapeHtml(g.nom)} <span style="font-weight:400; opacity:0.85;">(${joueurs.length})</span></div>`;
10997    joueurs.forEach(j => {
10998      const stats = statsJoueur(j.id);
10999      const objMatches = objCibleAff != null ? objCibleAff : g.objectifMatches;
11000      const pct = objMatches > 0 ? Math.min(100, Math.round((stats.total / objMatches) * 100)) : 0;
11001      const cls = pct >= 80 ? '' : pct >= 50 ? 'warning' : 'danger';
11002      const linkBadge = j.user_id ? '<span class="badge badge-success" title="Compte lié">✓</span>' : '<span class="badge badge-warning" title="Pas de compte">○</span>';
11003      const age = computeAge(j);
11004
11005      html += `<div class="player-row" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(j.id))})">
11006        ${avatarHtml(j, 'md')}
11007        <div class="player-info">
11008          <div class="player-name">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)} <span class="badge badge-${key}">${escapeHtml(j.classement)}</span> ${linkBadge}</div>
11009          <div class="player-meta">${age !== null ? age + ' ans · ' : ''}${stats.total}/${objMatches} matches · ${stats.tauxVictoire}% V</div>
11010          <div class="progress-bar"><div class="progress-fill ${cls}" style="width:${pct}%"></div></div>
11011        </div>
11012      </div>`;
11013    });
11014  });
11015
11016  const sansGroupe = joueursAffiches.filter(j => !j.groupe || !cache.config[j.groupe] || typeof cache.config[j.groupe] !== 'object');
11017  if (sansGroupe.length > 0) {
11018    html += `<div class="section-title">Sans groupe</div>`;
11019    sansGroupe.forEach(j => {
11020      const age = computeAge(j);
11021      const stats = statsJoueur(j.id);
11022      const progressionPalmyre = objCibleAff != null;
11023      const pct = progressionPalmyre && objCibleAff > 0
11024        ? Math.min(100, Math.round((stats.total / objCibleAff) * 100))
11025        : 0;
11026      const cls = pct >= 80 ? '' : pct >= 50 ? 'warning' : 'danger';
11027      const linkBadge = j.user_id ? '<span class="badge badge-success" title="Compte lié">✓</span>' : '<span class="badge badge-warning" title="Pas de compte">○</span>';
11028      html += `<div class="player-row" data-ui-click="openPlayerDetail(${escapeHtml(encoderArgumentTexteUI(j.id))})">
11029        ${avatarHtml(j, 'md')}
11030        <div class="player-info">
11031          <div class="player-name">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)} ${progressionPalmyre ? `<span class="badge">${escapeHtml(j.classement || 'NC')}</span> ${linkBadge}` : ''}</div>
11032          <div class="player-meta">${age !== null ? age + ' ans · ' : ''}${progressionPalmyre ? `${stats.total}/${objCibleAff} matches · ${stats.tauxVictoire}% V` : escapeHtml(j.classement || 'NC')}</div>
11033          ${progressionPalmyre ? `<div class="progress-bar"><div class="progress-fill ${cls}" style="width:${pct}%"></div></div>` : ''}
11034        </div>
11035      </div>`;
11036    });
11037  }
11038  return html;
11039}
11040
11041// Debounce pour la recherche : on attend 200ms avant de re-render (évite de redessiner à chaque touche)
11042let searchDebounceTimer = null;
11043function debouncedSearchRender() {
11044  clearTimeout(searchDebounceTimer);
11045  searchDebounceTimer = setTimeout(() => {
11046    render();
11047    // Restaurer le focus dans le champ de recherche après re-render
11048    setTimeout(() => {
11049      const inp = document.getElementById('playerSearch');
11050      if (inp) {
11051        inp.focus();
11052        // Curseur en fin de texte
11053        const v = inp.value;
11054        inp.value = '';
11055        inp.value = v;
11056      }
11057    }, 20);
11058  }, 200);
11059}
11060
11061// Calcule l'âge depuis date_naissance, fallback sur j.age si pas de date
11062function computeAge(j) {
11063  if (j.date_naissance) {
11064    const calcule = TennisDomainRules.ageCivil(j.date_naissance, new Date());
11065    if (calcule != null) return calcule;
11066  }
11067  return j.age || null;
11068}
11069
11070// Âge TENNISTIQUE (catégorie d'âge FFT). Contrairement à l'âge civil, il ne se
11071// calcule PAS sur la date de naissance exacte mais sur l'ANNÉE de naissance,
11072// soustraite de l'année de FIN de la saison tennistique en cours (le « millésime »).
11073// Ex. saison 2025-2026 → millésime 2026 ;
11073 un joueur né en 2012 a 14 ans « au sens
11074// tennis » pendant toute la saison, quel que soit son jour d'anniversaire. C'est
11075// cette valeur (et non l'âge civil) qui détermine les catégories d'éligibilité
11076// aux tournois (11/12 ans, 13/14 ans, U16…). Repli sur j.age si l'année de
11077// naissance est inconnue.
11078function computeAgeTennistique(j) {
11079  return TennisDomainRules.ageTennistique(j, getSaisonCourante());
11080}
11081
11082// Libellé d'âge pour les fiches joueur : âge civil, complété de l'âge tennistique
11083// UNIQUEMENT quand il diffère (cas des enfants nés en fin d'année). Renvoie '' si
11084// l'âge civil est inconnu (le rendu affiche alors « âge non renseigné »).
11085function ageLabel(j) {
11086  const civil = computeAge(j);
11087  if (civil == null) return '';
11088  const tennis = computeAgeTennistique(j);
11089  if (tennis != null && tennis !== civil) {
11090    return `${civil} ans · ${tennis} ans tennistiques`;
11091  }
11092  return `${civil} ans`;
11093}
11094
11095// Groupes considérés comme « Académie » (affectation manuelle par le coach).
11096// Toute affectation manuelle PRIME sur la règle d'âge : un joueur rangé ici par
11097// le coach reste en Académie quel que soit son âge (on ne casse pas les profils
11098// déjà constitués par Martin).
11099const GROUPES_ACADEMIE = ['tremplin', 'accession', 'elite'];
11100
11101function categorieJoueur(j) {
11102  return TennisDomainRules.categorieJoueur(j, computeAgeTennistique(j), GROUPES_ACADEMIE);
11103}
11104
11105function estPreAcademie(j) {
11106  return categorieJoueur(j) === 'pre_academie';
11107}
11108
11109function joueurDansEnvCourant(j) {
11110  return TennisDomainRules.joueurDansCategorie(j, etatApp.environnement, computeAgeTennistique(j), GROUPES_ACADEMIE);
11111}
11112
11113function joueursActifsDansEnvCourant() {
11114  return TennisDomainRules.joueursDansPerimetre(cache.joueurs, {
11115    environnement: etatApp.environnement,
11116    ageTennistique: computeAgeTennistique,
11117    groupesAcademie: GROUPES_ACADEMIE
11118  });
11119}
11120
11121// ============================================================
11122// SCREEN: MATCHES
11123// ============================================================
11124function renderMatches() {
11125  let matchesList = cache.matches.slice();
11126  const selectionSaison = `${renderUxScreenHeading(isCoach() ? 'Suivi sportif' : 'Historique sportif', isCoach() ? 'Matchs des joueurs' : 'Mes matchs')}<div class="card mb-2" style="padding:6px">${renderSaisonSelector() || `<div class="text-faint" style="text-align:center;font-size:12px">Saison ${getSaisonAnalyse()}</div>`}</div>`;
11127
11128  if (isCoach() && cache.joueurs.length > 0) {
11129    // Coach voit tout, peut filtrer
11130    const filterVal = $('filterJoueur')?.value || '';
11131    if (filterVal) matchesList = matchesList.filter(m => m.joueur_id === filterVal);
11132
11133    // Liste du sélecteur : en environnement Palmyre, ne proposer que les participants
11134    // à l'édition (le bug affichait l'ensemble des joueurs de l'app). En académie : tous.
11135    const joueursListe = joueursActifsDansEnvCourant()
11136      .slice()
11137      .sort((a, b) => (a.prenom || '').localeCompare(b.prenom || ''));
11138
11139    let html = `${selectionSaison}<div class="card mb-2"><div class="form-row" style="margin-bottom:0">
11140      <label class="form-label">Filtrer par joueur</label>
11141      <select id="filterJoueur" data-ui-change="render()">
11142        <option value="">Tous les joueurs</option>
11143        ${joueursListe.map(j => `<option value="${j.id}" ${filterVal === j.id ? 'selected' : ''}>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</option>`).join('')}
11144      </select></div></div>`;
11145
11146    // Filtre victoire/défaite : seulement quand un joueur est sélectionné
11147    if (filterVal) {
11148      // Les WO restent AFFICHÉS dans la liste mais ne sont pas comptés (pas un match joué).
11149      const compte = comptesMatchs(matchesList);
11150      const nbTotal = compte.total;
11151      const nbWin = compte.victoires;
11152      const nbLoss = compte.defaites;
11153      const nbWO = matchesList.length - nbTotal;
11154      if (etatApp.filtreResultat === 'win') matchesList = matchesList.filter(m => m.victoire && !estWalkover(m));
11155      else if (etatApp.filtreResultat === 'loss') matchesList = matchesList.filter(m => !m.victoire && !estWalkover(m));
11156      html += `<div class="pill-group" style="margin-bottom:12px">
11157        <span class="pill ${etatApp.filtreResultat === 'all' ? 'active' : ''}" data-ui-click="setMatchResultFilter('all')">Tous (${nbTotal} joué${nbTotal > 1 ? 's' : ''}${nbWO ? ` · ${nbWO} WO` : ''})</span>
11158        <span class="pill ${etatApp.filtreResultat === 'win' ? 'active' : ''}" data-ui-click="setMatchResultFilter('win')">✅ Victoires (${nbWin})</span>
11159        <span class="pill ${etatApp.filtreResultat === 'loss' ? 'active' : ''}" data-ui-click="setMatchResultFilter('loss')">❌ Défaites (${nbLoss})</span>
11160      </div>`;
11161    }
11162
11163    if (matchesList.length === 0) {
11164      html += `<div class="empty">${progressArtHtml('matchs',{size:64})}<strong>Aucun match</strong></div>`;
11165      return html;
11166    }
11167
11168    html += `<div class="section-title">${matchesList.length} match${matchesList.length > 1 ? 'es' : ''}</div><div class="card">`;
11169    matchesList.forEach(m => {
11170      const j = joueurById(m.joueur_id);
11171      if (!j) return;
11172      const classementJoueurEpoque = classementADate(m.joueur_id, m.date) || j.classement || '?';
11173      html += `<div class="match-row" data-ui-click="openMatchDetail(${escapeHtml(encoderArgumentTexteUI(m.id))})" style="cursor:pointer">
11174        <div class="match-result-row">
11175          <span class="match-opponent"><strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong> (${escapeHtml(classementJoueurEpoque)}) vs ${escapeHtml(m.adversaire)} (${escapeHtml(m.adversaire_class || '?')})</span>
11176          ${resultatMatchHtml(m)}
11177        </div>
11178        <div class="match-meta">${escapeHtml(m.tournoi || 'Match')} · ${formatDate(m.date)}${m.format === 'double' ? ` · <span class="format-double-badge">Double</span>` : ''}${m.ressenti ? ' · ' + m.ressenti + '/5' : ''}${matchEvenementNom(m) ? ` · <span class="badge badge-orange" style="font-size:10px"><img class="palmyre-stamp-icon" src="assets/v4/nav/palmyre.webp" alt="">${escapeHtml(matchEvenementNom(m))}</span>` : ''}</div>
11179      </div>`;
11180    });
11181    html += `</div>`;
11182    return html;
11183  } else {
11184    // Vue joueur : ses matches (déjà filtrés par RLS)
11185    const joueurId = profile && profile.joueur_id;
11186    const importTenup = joueurId ? `<button class="ux-import-tenup" data-ui-click="openModalImportMatchsTenup(${escapeHtml(encoderArgumentTexteUI(joueurId))})"><img src="assets/v4/menu/update.webp" alt=""><span><strong>Importer mes matchs Ten'Up</strong><small>Ajouter mon historique depuis Ten'Up</small></span></button>` : '';
11187    if (matchesList.length === 0) {
11188      const aide = getSaisonAnalyse() === getSaisonCourante()
11189        ? '<br><span class="text-faint">Déclare ton premier match depuis l’accueil.</span>'
11190        : `<br><span class="text-faint">Aucun match enregistré pour ${getSaisonAnalyse()}.</span>`;
11191      return `${selectionSaison}${importTenup}<div class="empty">${progressArtHtml('matchs',{size:64})}<strong>Aucun match enregistré</strong>${aide}</div>`;
11192    }
11193
11194    // Compteurs sportifs : les WO restent visibles dans « Tous », mais ne sont ni
11195    // des matchs joués, ni des victoires/défaites (même règle que côté coach).
11196    const comptes = comptesMatchs(matchesList);
11197    const nbTotal = comptes.total;
11198    const nbWin = comptes.victoires;
11199    const nbLoss = nbTotal - nbWin;
11200    const matchsPalmyre = matchesList.filter(m => m.evenement_id);
11201    const nbPalmyre = matchsPalmyre.length;
11202    const nbPalmyreJoues = matchsHorsWO(matchsPalmyre).length;
11203    const nbWO = matchesList.length - nbTotal;
11204
11205    // Filtre par résultat (+ filtre Palmyre)
11206    let visibles = matchesList;
11207    if (etatApp.filtreResultat === 'win') visibles = matchesList.filter(m => m.victoire && !estWalkover(m));
11208    else if (etatApp.filtreResultat === 'loss') visibles = matchesList.filter(m => !m.victoire && !estWalkover(m));
11209    else if (etatApp.filtreResultat === 'palmyre') visibles = matchesList.filter(m => m.evenement_id);
11210
11211    // Barre de filtres (pills) — la pill Palmyre n'apparaît que si le joueur a des matchs Palmyre
11212    let html = `${selectionSaison}${importTenup}<div class="pill-group" style="margin-bottom:12px">
11213      <span class="pill ${etatApp.filtreResultat === 'all' ? 'active' : ''}" data-ui-click="setMatchResultFilter('all')">Tous (${nbTotal} joué${nbTotal > 1 ? 's' : ''}${nbWO ? ` · ${nbWO} WO` : ''})</span>
11214      <span class="pill ${etatApp.filtreResultat === 'win' ? 'active' : ''}" data-ui-click="setMatchResultFilter('win')">✅ Victoires (${nbWin})</span>
11215      <span class="pill ${etatApp.filtreResultat === 'loss' ? 'active' : ''}" data-ui-click="setMatchResultFilter('loss')">❌ Défaites (${nbLoss})</span>
11216      ${nbPalmyre > 0 ? `<span class="pill ${etatApp.filtreResultat === 'palmyre' ? 'active' : ''}" data-ui-click="setMatchResultFilter('palmyre')" style="${etatApp.filtreResultat === 'palmyre' ? 'border-color: var(--orange-text); background: var(--orange-bg, rgba(243,146,0,0.12)); color: var(--orange-text)' : ''}">🏖️ Palmyre (${nbPalmyreJoues} joué${nbPalmyreJoues > 1 ? 's' : ''}${nbPalmyre > nbPalmyreJoues ? ` · ${nbPalmyre - nbPalmyreJoues} WO` : ''})</span>` : ''}
11217    </div>`;
11218
11219    if (visibles.length === 0) {
11220      html += `<div class="empty">${progressArtHtml('matchs',{size:64})}<strong>Aucun match dans cette catégorie</strong></div>`;
11221      return html;
11222    }
11223
11224    html += `<div class="section-title">${visibles.length} match${visibles.length > 1 ? 'es' : ''}</div><div class="card">`;
11225    visibles.forEach(m => {
11226      const monClassementEpoque = classementADate(m.joueur_id, m.date) || (joueurById(m.joueur_id) || {}).classement || '?';
11227      html += `<div class="match-row" data-ui-click="openMatchDetail(${escapeHtml(encoderArgumentTexteUI(m.id))})" style="cursor:pointer">
11228        <div class="match-result-row">
11229          <span class="match-opponent"><strong>Moi</strong> ${escapeHtml(monClassementEpoque)} · vs ${escapeHtml(m.adversaire)} (${escapeHtml(m.adversaire_class || '?')})</span>
11230          ${resultatMatchHtml(m)}
11231        </div>
11232        <div class="match-meta">${escapeHtml(m.tournoi || 'Match')} · ${formatDate(m.date)}${m.format === 'double' ? ` · <span class="format-double-badge">Double</span>` : ''}${m.ressenti ? ' · ' + m.ressenti + '/5' : ''}${matchEvenementNom(m) ? ` · <span class="badge badge-orange" style="font-size:10px"><img class="palmyre-stamp-icon" src="assets/v4/nav/palmyre.webp" alt="">${escapeHtml(matchEvenementNom(m))}</span>` : ''}</div>
11233      </div>`;
11234    });
11235    html += `</div>`;
11236    return html;
11237  }
11238}
11239
11240// ============================================================
11241// SCREEN: MES ENTRAÎNEMENTS (joueur) — liste filtrée par période
11242// ============================================================
11243function renderMesEntrainements() {
11244  const joueurId = profile && profile.joueur_id;
11245  if (!joueurId) return `<div class="empty"><span class="icon">🏋️</span>Réservé aux joueurs.</div>`;
11246
11247  const tous = entrainementsDuJoueur(joueurId);
11248
11249  // Sélecteur de période (Saison / T1..T4), comme sur la fiche
11250  let html = renderUxScreenHeading('Mon activité', 'Mes séances libres') + renderPeriodeSelector(periodes.mesEntrainements, 'setPeriodeMesEntrainements');
11251  const [deb, fin] = getPeriodeDates(periodes.mesEntrainements);
11252  const label = getPeriodeLabel(periodes.mesEntrainements);
11253
11254  const liste = tous
11255    .filter(e => TennisDomainRules.dateDansIntervalle(e.date, deb, fin))
11256    .sort((a, b) => (b.date || '').localeCompare(a.date || ''));
11257
11258  if (tous.length === 0) {
11259    html += `<div class="empty"><span class="icon">🏋️</span>Aucune séance libre déclarée.<br><span class="text-faint">Déclare ta première séance depuis l’accueil.</span></div>`;
11260    return html;
11261  }
11262
11263  // Compteurs sur la période
11264  const statsEntrainements = TennisDomainRules.statistiquesEntrainements(liste);
11265  const nb = statsEntrainements.total;
11266  const heuresTxt = statsEntrainements.heuresTexte;
11267
11268  html += `<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:12px">
11269    <div class="card" style="text-align:center; padding:12px"><div style="font-size:22px; font-weight:800; color:var(--orange-text)">${nb}</div><div class="text-faint" style="font-size:11px">séance${nb > 1 ? 's' : ''}</div></div>
11270    <div class="card" style="text-align:center; padding:12px"><div style="font-size:22px; font-weight:800; color:var(--orange-text)">${heuresTxt}</div><div class="text-faint" style="font-size:11px">au total</div></div>
11271  </div>`;
11272
11273  if (nb === 0) {
11274    html += `<div class="empty"><span class="icon">🏋️</span>Aucun entraînement sur ${escapeHtml(label.toLowerCase())}.</div>`;
11275    return html;
11276  }
11277
11278  html += `<div class="section-title">${nb} entraînement${nb > 1 ? 's' : ''} <span style="font-weight:400; opacity:0.85; font-size:12px">— ${escapeHtml(label.toLowerCase())}</span></div><div class="card">`;
11279  liste.forEach(en => {
11280    const meta = ENTRAINEMENT_TYPES[en.type] || ENTRAINEMENT_TYPES.seul;
11281    const dureeTxt = en.duree_min ? `${Math.floor(en.duree_min / 60)}h${en.duree_min % 60 ? String(en.duree_min % 60).padStart(2, '0') : ''}` : '';
11282    const heureTxt = en.heure_debut ? ` · ${escapeHtml(en.heure_debut)}` : '';
11283    const extrait = en.contenu ? ` <span class="text-faint">· ${escapeHtml(en.contenu.length > 32 ? en.contenu.slice(0, 32) + '…' : en.contenu)}</span>` : '';
11284    html += `<div data-ui-click="openModalEntrainement(${escapeHtml(encoderArgumentTexteUI(en.id))})" style="display:flex; justify-content:space-between; align-items:center; gap:8px; padding:9px 0; cursor:pointer; font-size:13px; border-bottom:1px solid var(--border)">
11285        <span style="min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap">${meta.icone} ${meta.label}${dureeTxt ? ` <span class="text-faint">(${dureeTxt})</span>` : ''}${extrait}</span>
11286        <span style="flex-shrink:0; display:flex; align-items:center; gap:8px">
11287          <span class="text-faint" style="font-size:11px; white-space:nowrap">${formatDate(en.date)}${heureTxt}</span>
11288          <span class="text-faint" style="font-size:15px">›</span>
11289        </span>
11290      </div>`;
11291  });
11292  html += `</div>`;
11293  return html;
11294}
11295
11296// ============================================================
11297// SCREEN: CALENDAR
11298// ============================================================
11299function renderCalendar() {
11300  let html = renderUxScreenHeading(isCoach() ? 'Programmation sportive' : 'Compétition', isCoach() ? 'Tournois proposés' : 'Mes tournois');
11301
11302  if (isCoach()) {
11303    const derniereTentative = cache.importsTournois[0] || null;
11304    const dernierImport = cache.importsTournois.find(importTournoi =>
11305      ['success', 'partial'].includes(importTournoi?.statut) && importTournoi.finished_at
11306    ) || null;
11307    const aujourdHui = dateLocaleISO(new Date());
11308    const incomplets = cache.tournois.filter(t => t?.tenup_id && !t?.categorie && (t.date_fin || t.date_debut || '') >= aujourdHui).length;
11309    html += `<div class="flex gap-1 mb-2" style="justify-content:flex-end;flex-wrap:wrap">
11310      <button class="btn btn-sm" data-ui-click="openTenupSyncHistory()">Diagnostic Ten’Up${cache.rapprochementsTournois.length ? ` · ${cache.rapprochementsTournois.length} rapprochement(s)` : ''}</button>
11311      <button class="btn btn-primary btn-sm" data-ui-click="openModalTournoi()"><img class="btn-action-art" src="assets/v4/nav/tournaments.webp" alt="">${isPreAcademieEnv() ? '+ Nouveau plateau rouge' : '+ Nouveau tournoi'}</button>
11312      ${isPreAcademieEnv() ? '' : `<button id="btn-tenup-sync" class="btn btn-sm" data-ui-click="runTenupSync()"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Synchroniser avec Ten’Up</button>
11313      <button class="btn btn-sm" data-ui-click="openModalImportTenup()"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Importer des tournois</button>`}
11314    </div>${derniereTentative?.statut === 'failed' ? `<div class="danger mb-1" style="font-size:11px;text-align:right">Dernière tentative échouée : ${formatDateTime(derniereTentative.finished_at || derniereTentative.started_at)} · ${escapeHtml(TennisDomainRules.libellerErreurTenup(derniereTentative.error_summary))} · nouvelle tentative automatique différée pour protéger le quota</div>` : ''}${dernierImport ? `<div class="text-faint mb-2" style="font-size:11px;text-align:right">Dernière synchronisation terminée : ${formatDateTime(dernierImport.finished_at)}${dernierImport.statut === 'partial' ? ' · résultat partiel' : ''}${incomplets !== null ? ` · ${incomplets} fiche${incomplets > 1 ? 's' : ''} à compléter` : ''}</div>` : ''}`;
11315  }
11316
11317  const joueurConnecte = !isCoach() && profile?.joueur_id ? joueurById(profile.joueur_id) : null;
11318  const vuePreAcademie = isPreAcademieEnv() || (!!joueurConnecte && estPreAcademie(joueurConnecte));
11319  const tournoisEnvironnement = (vuePreAcademie
11320    ? cache.tournois.filter(TennisDomainRules.tournoiPourPreAcademie)
11321    : (isAcademieEnv() ? cache.tournois.filter(t =>
11321 t?.pedagogie_json?.environnement !== 'pre_academie') : cache.tournois))
11322    .filter(t => (t.date_fin || t.date_debut || '') >= dateLocaleISO(new Date()));
11323  if (tournoisEnvironnement.length === 0) {
11324    html += `<div class="empty"><span class="icon">📅</span>Aucun tournoi planifié.<br><span class="text-faint">${isCoach() ? 'Propose un tournoi pour le rendre disponible aux joueurs concernés.' : 'Les prochains tournois proposés par ton coach apparaîtront ici.'}</span></div>`;
11325    return html;
11326  }
11327
11328  // ===== Déterminer le scope + afficher le sélecteur =====
11329  let mode = 'all';            // 'all' (vue d'ensemble) | 'player' (prog d'un joueur)
11330  let perspectiveId = null;
11331  let perspectiveLabel = '';
11332
11333  if (isCoach()) {
11334    const actifs = cache.joueurs
11335      .filter(j => (j.statut === 'actif' || !j.statut) && joueurDansEnvCourant(j))
11336      .filter(j => isPoleEnv() || j.participe_palmyre)
11337      .slice()
11338      .sort((a, b) => (a.prenom || '').localeCompare(b.prenom || ''));
11339    const filtreAvecInscrits = etatApp.calendarPlayerId === 'with_registrations';
11340    const selVal = filtreAvecInscrits ? 'with_registrations' : ((etatApp.calendarPlayerId && actifs.some(j => j.id === etatApp.calendarPlayerId)) ? etatApp.calendarPlayerId : 'all');
11341    if (selVal !== 'all' && !filtreAvecInscrits) {
11342      mode = 'player';
11343      perspectiveId = selVal;
11344      const selJoueur = actifs.find(j => j.id === selVal);
11345      perspectiveLabel = selJoueur ? selJoueur.prenom : '';
11346    }
11347    html += `<div class="card mb-2" style="padding:10px 14px">
11348      <div class="flex gap-1 mb-1" style="flex-wrap:wrap">
11349        <button class="btn btn-sm ${selVal === 'all' ? 'btn-primary' : ''}" data-ui-click="setCalendarPlayer('all')">Tous les tournois</button>
11350        <button class="btn btn-sm ${filtreAvecInscrits ? 'btn-primary' : ''}" data-ui-click="setCalendarPlayer('with_registrations')">Avec des inscrits</button>
11351      </div>
11352      <label style="font-size:12px; font-weight:600; display:block; margin-bottom:6px">🎾 Filtrer par joueur</label>
11353      <select data-ui-change="setCalendarPlayer(this.value)" style="width:100%">
11354        <option value="" ${selVal === 'all' || filtreAvecInscrits ? 'selected' : ''}>Choisir un joueur…</option>
11355        ${actifs.map(j => `<option value="${j.id}" ${j.id === selVal ? 'selected' : ''}>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}${j.classement ? ' (' + escapeHtml(j.classement) + ')' : ''}</option>`).join('')}
11356      </select>
11357    </div>`;
11358  } else {
11359    // Joueur : bascule « Mes tournois » / « Tous »
11360    if (etatApp.calendarMineOnly) {
11361      mode = 'player';
11362      perspectiveId = profile && profile.joueur_id;
11363      const moi = perspectiveId ? joueurById(perspectiveId) : null;
11364      perspectiveLabel = moi ? moi.prenom : '';
11365    }
11366    html += `<div class="card mb-2" style="padding:6px; display:flex; gap:6px">
11367      <button class="btn btn-sm ${etatApp.calendarMineOnly ? 'btn-primary' : ''}" style="flex:1" data-ui-click="setCalendarMineOnly(true)"><img class="btn-action-art" src="assets/v4/nav/tournaments.webp" alt="">Mes tournois</button>
11368      <button class="btn btn-sm ${!etatApp.calendarMineOnly ? 'btn-primary' : ''}" style="flex:1" data-ui-click="setCalendarMineOnly(false)"><img class="btn-action-art" src="assets/v4/nav/planning.webp" alt="">Tous</button>
11369    </div>`;
11370
11371    // Filtres intelligents : seulement en mode « Tous » (sur sa prog perso, inutile)
11372    if (!etatApp.calendarMineOnly && profile && profile.joueur_id) {
11373      const j = joueurById(profile.joueur_id);
11374      if (j) {
11375        const f = getFiltresTournoi();
11376        const age = computeAge(j);
11377        const labelSexe = j.sexe === 'F' ? '♀ Féminins' : j.sexe === 'M' ? '♂ Masculins' : '⚥ Mon sexe';
11378        const labelAge = age != null ? `🎂 Mon âge (${age} ans +)` : '🎂 Mon âge';
11379        const labelClass = `🏆 Mon niveau (${escapeHtml(j.classement || 'NC')})`;
11380        const pill = (actif, label, critere) =>
11381          `<span class="pill ${actif ? 'active' : ''}" data-ui-click="toggleFiltreTournoi(${escapeHtml(encoderArgumentTexteUI(critere))})" style="${actif ? 'border-color:var(--orange-text); background:var(--orange-bg,rgba(243,146,0,0.12)); color:var(--orange-text)' : ''}">${actif ? '✓ ' : ''}${label}</span>`;
11382        // Nombre de tournois masqués par les filtres actifs
11383        const criteres = getFiltresTournoi();
11384        const anyActif = criteres.sexe || criteres.age || criteres.classement;
11385        let nbMasques = 0;
11386        if (anyActif) nbMasques = tournoisEnvironnement.filter(t => !tournoiCorrespondAuJoueur(t, j, criteres)).length;
11387        html += `<div class="card mb-2" style="padding:12px 14px">
11388          <div style="font-size:12px; color:var(--text-dim); margin-bottom:8px">Filtrer les tournois adaptés à mon profil :</div>
11389          <div class="pill-group" style="display:flex; flex-wrap:wrap; gap:6px">
11390            ${j.sexe ? pill(f.sexe, labelSexe, 'sexe') : ''}
11391            ${pill(f.age, labelAge, 'age')}
11392            ${pill(f.classement, labelClass, 'classement')}
11393          </div>
11394          ${anyActif && nbMasques > 0 ? `<div class="text-faint" style="font-size:11px; margin-top:8px">${nbMasques} tournoi${nbMasques > 1 ? 's' : ''} masqué${nbMasques > 1 ? 's' : ''} hors de ton profil · désactive un filtre pour en voir plus</div>` : ''}
11395          ${!anyActif ? `<div class="text-faint" style="font-size:11px; margin-top:8px">Aucun filtre actif : tous les tournois sont affichés</div>` : ''}
11396        </div>`;
11397      }
11398    }
11399  }
11400
11401  // ===== Construire l'ensemble de tournois selon le mode =====
11402  let tournois;
11403  if (mode === 'player') {
11404    tournois = perspectiveId ? tournoisEnvironnement.filter(t => (listeSure(t.inscrits)).includes(perspectiveId)) : [];
11405  } else {
11406    tournois = isCoach() ? tournoisEnvironnement.slice() : filterTournoisParGenre(tournoisEnvironnement);
11407  }
11408  if (isCoach() && etatApp.calendarPlayerId === 'with_registrations') {
11409    tournois = tournois.filter(t => listeSure(t.inscrits).length > 0);
11410  }
11411  if (!isCoach() && mode === 'all') {
11412    const aujourdHui = dateLocaleISO(new Date());
11413    // Le catalogue de découverte ne propose que les inscriptions encore
11414    // pertinentes. Un tournoi déjà commencé reste visible dans « Mes tournois »
11415    // lorsque le joueur y est inscrit, et dans la vue coach pour son suivi.
11416    tournois = tournois.filter(t => t.date_debut && t.date_debut >= aujourdHui);
11417  }
11418  tournois = tournois.slice().sort((a, b) => (a.date_debut || '').localeCompare(b.date_debut || ''));
11419
11420  // Bouton d'export PDF (mode prog d'un joueur, s'il a au moins un tournoi)
11421  if (mode === 'player' && perspectiveId && tournois.length > 0) {
11422    html += `<div class="mb-2" style="text-align:right">
11423      <button class="btn btn-sm" data-ui-click="exportProgrammePDF(${escapeHtml(encoderArgumentTexteUI(perspectiveId))})"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">Exporter le programme en PDF</button>
11424    </div>`;
11425  }
11426
11427  // ===== Rendu groupé par mois =====
11428  html += renderMoisGroupes(tournois, { mode, perspectiveId, perspectiveLabel });
11429
11430  return html;
11431}
11432
11433function setCalendarPlayer(id) {
11434  etatApp.definirCalendarPlayer(id || 'all');
11435  render();
11436}
11437
11438function setCalendarMineOnly(v) {
11439  etatApp.definirCalendarMineOnly(v);
11440  render();
11441}
11442
11443async function runTenupSync() {
11444  if (!isCoach()) return;
11445  const bouton = document.getElementById('btn-tenup-sync');
11446  if (bouton?.disabled) return;
11447  if (bouton) { bouton.disabled = true; bouton.setAttribute('aria-busy','true'); }
11448  toast('Synchronisation Ten’Up en cours…');
11449  try {
11450    const { data, error } = await db.functions.invoke('tenup-sync', { body:{ mode:'sync' } });
11451    if (error || data?.error) {
11452      let diagnostic = data || {};
11453      if (error?.context && typeof error.context.json === 'function') {
11454        try { diagnostic = { ...diagnostic, ...(await error.context.clone().json()) }; } catch (_) { /* réponse non JSON */ }
11455      }
11456      const code = diagnostic?.error || error?.message || 'SYNC_FAILED';
11457      const message = `${TennisDomainRules.libellerErreurTenup(code, diagnostic)} La dernière copie reste disponible.`;
11458      toast(message, true);
11459      await refresh();
11460      return;
11461    }
11462    if (data?.queued) {
11463      toast(data.alreadyQueued
11464        ? 'La synchronisation Ten’Up est déjà en cours.'
11465        : 'Synchronisation Ten’Up demandée. Le PC la traitera en arrière-plan dans moins d’une minute.');
11466      await refresh();
11467      return;
11468    }
11469    const completude = Number.isFinite(Number(data.detailed)) && Number.isFinite(Number(data.incomplete))
11470      ? ` · ${data.detailed} avec épreuves · ${data.incomplete} sans épreuve publiée`
11471      : '';
11472    const etat = data.partial ? 'Synchronisation terminée partiellement' : 'Synchronisation terminée';
11473    toast(`${etat} · ${data.created} nouveau(x) · ${data.updated} mis à jour · ${data.duplicate} doublon(s)${completude}`, !!data.partial);
11474    await refresh();
11475  } finally {
11476    if (bouton) { bouton.disabled = false; bouton.removeAttribute('aria-busy'); }
11477  }
11478}
11479
11480function openTenupSyncHistory() {
11481  if (!isCoach()) return;
11482  const runs = cache.importsTournois;
11483  const merges = cache.rapprochementsTournois;
11484  const tournoisById = new Map(cache.tournois.map(t => [t.id,t]));
11485  const html = `<div class="modal-header"><h3 class="modal-title">Suivi Ten’Up</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
11486    <div class="section-title">Rapprochements à valider (${merges.length})</div>
11487    ${merges.length ? merges.map(m =>
11487 `<div class="card mb-1"><strong>${escapeHtml(tournoisById.get(m.source_tournament_id)?.nom || 'Tournoi importé')}</strong><div class="text-dim" style="font-size:12px">Correspond probablement à ${escapeHtml(tournoisById.get(m.target_tournament_id)?.nom || 'une fiche existante')} · confiance ${Math.round(Number(m.confidence)*100)} %</div><div class="flex gap-1 mt-1"><button class="btn btn-sm" data-ui-click="resolveTournamentMerge(${escapeHtml(encoderArgumentTexteUI(m.source_tournament_id))},${escapeHtml(encoderArgumentTexteUI(m.target_tournament_id))},false)">Distincts</button><button class="btn btn-primary btn-sm" data-ui-click="resolveTournamentMerge(${escapeHtml(encoderArgumentTexteUI(m.source_tournament_id))},${escapeHtml(encoderArgumentTexteUI(m.target_tournament_id))},true)">Fusionner</button></div></div>`).join('') : '<div class="empty">Aucun doublon à vérifier.</div>'}
11488    <div class="section-title mt-2">Diagnostic des synchronisations</div>
11489    ${runs.length ? runs.map(r => `<div class="card mb-1" style="font-size:12px"><div class="flex-between"><strong>${escapeHtml(r.mode)}</strong><span class="badge">${escapeHtml(r.statut)}</span></div><div class="text-dim">${formatDateTime(r.started_at)} · ${r.created_count} créé(s) · ${r.updated_count} mis à jour · ${r.duplicate_count} doublon(s) · ${r.rejected_count} rejet(s)</div>${r.error_summary?`<div class="danger">${escapeHtml(r.error_summary)}</div>`:''}</div>`).join('') : '<div class="empty">Aucun historique.</div>'}`;
11490  showModal(html);
11491}
11492
11493async function resolveTournamentMerge(sourceId,targetId,accept) {
11494  if (!isCoach()) return;
11495  const { error } = await db.rpc('merge_tournaments',{p_source:sourceId,p_target:targetId,p_accept:!!accept});
11496  if (error) { toastErreur(error); return; }
11497  closeModal(); toast(accept ? 'Tournois fusionnés, liens locaux conservés' : 'Fiches conservées séparément'); await refresh();
11498}
11499
11500// Détecte les tournois qui se chevauchent (périodes qui se recoupent).
11501// Retourne un Set d'ids de tournois ayant au moins un chevauchement avec un autre.
11502function detecterChevauchements(tournois) {
11503  return TennisDomainRules.idsTournoisEnChevauchement(tournois);
11504}
11505
11506// Vue calendrier groupée par mois, adaptée au périmètre choisi.
11507function renderMoisGroupes(tournois, ctx) {
11508  const { mode, perspectiveLabel } = ctx;
11509  const isCoachView = isCoach();
11510
11511  // Cas vide
11512  if (tournois.length === 0) {
11513    if (mode === 'player') {
11514      const msg = isCoachView
11515        ? `${escapeHtml(perspectiveLabel)} n'est inscrit à aucun tournoi pour l'instant.`
11516        : `Tu n'es inscrit à aucun tournoi pour l'instant.`;
11517      const sub = isCoachView
11518        ? `Passe sur « Tous les joueurs » pour voir les tournois à lui proposer.`
11519        : `Passe sur « Tous » pour découvrir les tournois et t'inscrire.`;
11520      return `<div class="empty"><span class="icon">🗓️</span>${msg}<br><span class="text-faint">${sub}</span></div>`;
11521    }
11522    return `<div class="empty"><span class="icon">🗓️</span>Aucun tournoi à afficher.${isCoachView ? '' : '<br><span class="text-faint">Les filtres en masquent peut-être : désactive-en pour tout voir.</span>'}</div>`;
11523  }
11524
11525  const MOIS_NOMS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
11526  const now = new Date();
11527  const cleCourante = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
11528
11529  // Grouper par mois (par date de début réelle du tournoi).
11530  const todayStr = dateLocaleISO(new Date());
11531  const parMois = {};
11532  tournois.forEach(t => {
11533    const d = new Date(t.date_debut);
11534    const cle = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
11535    if (!parMois[cle]) parMois[cle] = [];
11536    parMois[cle].push(t);
11537  });
11538  const clesAvecTournois = Object.keys(parMois).sort();
11539  const premiere = clesAvecTournois[0];
11540  const derniere = clesAvecTournois[clesAvecTournois.length - 1];
11541
11542  // Plage de mois selon le mode
11543  function ymPlus(y, m, n) { const t = y * 12 + (m - 1) + n; return [Math.floor(t / 12), (t % 12) + 1]; }
11544  let cleDebut, cleFin;
11545  if (mode === 'player') {
11546    // tournée vers l'avenir : mois courant → au moins 6 mois (révèle les trous à venir)
11547    const [hy, hm] = ymPlus(now.getFullYear(), now.getMonth() + 1, 5);
11548    const horizonFin = `${hy}-${String(hm).padStart(2, '0')}`;
11549    // Début = mois courant, MAIS on recule si un tournoi est encore EN COURS (commencé
11550    // un mois précédent et pas terminé) pour qu'il reste visible à sa place réelle.
11551    cleDebut = cleCourante;
11552    tournois.forEach(t => {
11553      const finStr = t.date_fin || t.date_debut;
11554      if (finStr >= todayStr && t.date_debut < cleCourante + '-01') {
11555        const cleDebutTournoi = t.date_debut.slice(0, 7); // 'YYYY-MM'
11556        if (cleDebutTournoi < cleDebut) cleDebut = cleDebutTournoi;
11557      }
11558    });
11559    cleFin = derniere > horizonFin ? derniere : horizonFin;
11560  } else {
11561    // vue d'ensemble : du plus tôt (ou mois courant) au plus tard (ou mois courant)
11562    cleDebut = premiere < cleCourante ? premiere : cleCourante;
11563    cleFin = derniere > cleCourante ? derniere : cleCourante;
11564  }
11565
11566  // Générer les mois continus
11567  const moisAffiches = [];
11568  let [y, m] = cleDebut.split('-').map(Number);
11569  const [yFin, mFin] = cleFin.split('-').map(Number);
11570  let garde = 0;
11571  while ((y < yFin || (y === yFin && m <= mFin)) && garde < 120) {
11572    moisAffiches.push(`${y}-${String(m).padStart(2, '0')}`);
11573    m++; if (m > 12) { m = 1; y++; }
11574    garde++;
11575  }
11576
11577  // Chevauchements : seulement en mode joueur (sur tous les tournois ce serait du bruit)
11578  const chevauchements = mode === 'player' ? detecterChevauchements(tournois) : new Set();
11579
11580  // Bandeau d'alerte "aucun tournoi à venir" (mode joueur)
11581  let html = '';
11582  if (mode === 'player') {
11583    const aujourdhui = Date.now() - 24 * 3600 * 1000;
11584    const futurs = tournois.filter(t => new Date(t.date_fin || t.date_debut).getTime() >= aujourdhui);
11585    if (futurs.length === 0) {
11586      html += `<div class="card mb-2" style="padding:10px 14px; border-left:3px solid var(--warning)">
11587        <div style="font-size:13px; color:var(--warning-text); font-weight:600">⚠️ Aucun tournoi à venir</div>
11588        <div class="text-faint" style="font-size:12px; margin-top:2px">${isCoachView ? `${escapeHtml(perspectiveLabel)} n'a rien de programmé dans les prochains mois — l'occasion de lui suggérer un tournoi.` : `Tu n'as rien de programmé pour le moment. Pense à t'inscrire à un tournoi !`}</div>
11589      </div>`;
11590    }
11591  }
11592
11593  // Rendu des mois
11594  moisAffiches.forEach(cle => {
11595    const [annee, mois] = cle.split('-').map(Number);
11596    let tournoisDuMois = parMois[cle] || [];
11597    // En mode « Mes tournois », on n'affiche jamais un tournoi déjà TERMINÉ (date de fin
11598    // passée). Un mois antérieur n'est dans la plage que pour montrer un tournoi EN COURS.
11599    if (mode === 'player') {
11600      tournoisDuMois = tournoisDuMois.filter(t => (t.date_fin || t.date_debut) >= todayStr);
11601    }
11602    const estMoisCourant = cle === cleCourante;
11603
11604    html += `<div style="display:flex; justify-content:space-between; align-items:center; background:var(--bg-input); ${estMoisCourant ? 'border-left:4px solid var(--orange); ' : ''}border-radius:var(--radius-sm); padding:9px 13px; margin:16px 0 8px">
11605      <span style="font-size:15px; font-weight:700">${MOIS_NOMS[mois - 1]} <span style="color:var(--text-dim); font-weight:500; font-size:13px">${annee}</span></span>
11606      <span style="font-size:11px; color:var(--text-dim)">${estMoisCourant ? 'ce mois-ci · ' : ''}${tournoisDuMois.length > 0 ? tournoisDuMois.length + ' tournoi' + (tournoisDuMois.length > 1 ? 's' : '') : '—'}</span>
11607    </div>`;
11608
11609    if (tournoisDuMois.length === 0) {
11610      html += `<div class="card" style="padding:10px 14px; opacity:0.5; text-align:center"><span class="text-faint" style="font-size:12px">Aucun tournoi</span></div>`;
11611    } else {
11612      tournoisDuMois.forEach(t => {
11613        html += renderTournoiMois(t, { mode, chevauche: chevauchements.has(t.id), isCoachView });
11614      });
11615    }
11616  });
11617
11618  return html;
11619}
11620
11621// Carte d'un tournoi dans la vue par mois. Le rendu s'adapte au contexte :
11622// - coach + 'all'   : badge d'occupation (nb inscrits, repère les tournois déserts)
11623// - mode 'player'   : nb de joueurs de l'académie + alerte chevauchement (conflit perso)
11624// - joueur + 'all'  : met en avant ses inscriptions + bouton s'inscrire
11625function renderTournoiMois(t, opts) {
11626  const { mode, chevauche, isCoachView } = opts;
11627  const dateStr = t.date_fin && t.date_fin !== t.date_debut
11628    ? `${formatDate(t.date_debut)} → ${formatDate(t.date_fin)}`
11629    : formatDate(t.date_debut);
11630  const inscrits = listeSure(t.inscrits);
11631  const nbInscrits = inscrits.length;
11632  const monJoueurId = profile && profile.joueur_id;
11633  const jeSuisInscrit = monJoueurId && inscrits.includes(monJoueurId);
11634  const isPast = new Date(t.date_fin || t.date_debut).getTime() < Date.now() - 24 * 3600 * 1000;
11635
11636  let actionBtn = '';
11637  if (!isCoachView && !isPast && monJoueurId) {
11638    actionBtn = jeSuisInscrit
11639      ? `<button class="btn btn-sm" data-ui-click="event.stopPropagation(); toggleTournoiInscription(${escapeHtml(encoderArgumentTexteUI(t.id))}, false)">↩ Me désinscrire</button>`
11640      : `<button class="btn btn-sm btn-primary" data-ui-click="event.stopPropagation(); toggleTournoiInscription(${escapeHtml(encoderArgumentTexteUI(t.id))}, true)">+ Je m'inscris</button>`;
11641  }
11642
11643  // Badge / infos / couleur de bordure selon le contexte
11644  let topBadge = '';
11645  let infoLine = '';
11646  let borderColor;
11647  const nomsInscrits = inscritsNomsPole(t);
11648  const ligneInscrits = nomsInscrits.length > 0
11649    ? `<div class="tournament-participants">🎾 Inscrits : ${escapeHtml(nomsInscrits.join(', '))}</div>`
11650    : (nbInscrits > 0 ? `<div class="tournament-participants">🎾 ${nbInscrits} inscrit${nbInscrits > 1 ? 's' : ''} de l'académie</div>` : '');
11651  if (isCoachView && mode === 'all') {
11652    topBadge = nbInscrits > 0
11653      ? `<span class="badge badge-success" style="font-size:10px">${nbInscrits} inscrit${nbInscrits > 1 ? 's' : ''}</span>`
11654      : `<span class="badge" style="font-size:10px; background:var(--bg-input); color:var(--text-dim)">0 inscrit</span>`;
11655    infoLine = ligneInscrits;
11656    borderColor = nbInscrits > 0 ? 'var(--success)' : 'var(--border-strong)';
11657  } else if (mode === 'player') {
11658    infoLine = ligneInscrits;
11659    borderColor = isPast ? 'var(--border-strong)' : 'var(--success)';
11660  } else {
11661    topBadge = nbInscrits > 0 ? `<span class="badge badge-success" style="font-size:10px">${nbInscrits} inscrit${nbInscrits > 1 ? 's' : ''}</span>` : '';
11662    infoLine = ligneInscrits;
11663    borderColor = jeSuisInscrit ? 'var(--success)' : 'var(--border-strong)';
11664  }
11665
11666  // En mode joueur, un tournoi passé affiche un badge « passé »
11667  const rightBadge = (isPast && mode === 'player') ? '<span class="badge" style="font-size:10px; background:var(--bg-input); color:var(--text-dim)">passé</span>' : topBadge;
11668  const estImportTenup = t.source_creation === 'tenup_sync' || Boolean(t.tenup_id);
11669  const statutVisible = t.statut_local && !(estImportTenup && t.statut_local === 'a_verifier')
11670    ? ` · ${escapeHtml(TennisTournamentsTenup.STATUTS_TOURNOI[t.statut_local] || t.statut_local)}`
11671    : '';
11672  const provenance = t.nature === 'competition_interne_club'
11673    ? '<span class="text-faint" style="font-size:10px">Compétition interne du club</span>'
11674    : estImportTenup
11675      ? ''
11676      : '<span class="text-faint" style="font-size:10px">Officiel FFT</span>';
11677  const sceauFft = estImportTenup
11678    ? '<span class="fft-import-stamp" title="Import officiel FFT depuis Ten’Up" aria-label="Import officiel FFT vérifié">FFT</span>'
11679    : '';
11680  const categorieUtile = t.categorie && TennisDomainRules.normaliserTexteRecherche(t.categorie) !== TennisDomainRules.normaliserTexteRecherche(t.nom)
11681    ? escapeHtml(t.categorie)
11682    : (estImportTenup ? 'Détail des épreuves non disponible dans l’application' : '');
11683  const categoriesHtml = categorieUtile && t.categorie
11684    ? `${estImportTenup ? '<div class="text-faint" style="font-size:10px; margin-top:5px">Épreuve officielle publiée sur Ten’Up :</div>' : ''}<div class="tournament-category-list">${String(t.categorie).split('|').map((categorie) => categorie.trim()).filter(Boolean).map((categorie) => `<span class="tournament-category-chip">${escapeHtml(categorie)}</span>`).join('')}</div>`
11685    : (categorieUtile ? `<div class="text-faint" style="font-size:11px; margin-top:3px">${categorieUtile}</div>` : '');
11686  const distanceBouliac = Number.isFinite(Number(t.distance_km))
11687    ? ` · ${Math.round(Number(t.distance_km))} km de Bouliac`
11688    : '';
11689
11690  return `<div class="card clickable tournament-card-layout" data-ui-click="openModalTournoi(${escapeHtml(encoderArgumentTexteUI(t.id))})" style="cursor:pointer; padding:12px; margin-bottom:6px; ${isPast ? 'opacity:0.6; ' : ''}border-left:3px solid ${borderColor}">
11691    <div class="tournament-card-title">
11692      <strong style="font-size:14px">${escapeHtml(t.nom)}</strong>${provenance || statutVisible ? `<div style="margin-top:3px">${provenance}${statutVisible}</div>` : ''}
11693      <div class="tournament-card-badges">${rightBadge}${sceauFft}</div>
11694    </div>
11695    <div class="text-dim" style="font-size:12px">📅 ${dateStr}${t.lieu ? ' · 📍 ' + escapeHtml(t.lieu) : ''}${distanceBouliac}</div>
11696    ${categoriesHtml}
11697    ${infoLine}
11698    ${chevauche ? `<div style="margin-top:6px; font-size:11px; color:var(--warning-text)">⚠️ Même période qu'un autre tournoi</div>` : ''}
11699    ${(jeSuisInscrit && !isCoachView && mode === 'all') ? `<div style="margin-top:6px; font-size:11px; color:var(--success-text)">✓ Tu y es inscrit</div>` : ''}
11700    ${actionBtn ? `<div style="margin-top:10px">${actionBtn}</div>` : ''}
11701  </div>`;
11702}
11703
11704async function toggleTournoiInscription(tournoiId, inscrire) {
11705  if (!profile || !profile.joueur_id) {
11706    toast('Profil joueur introuvable', true);
11707    return;
11708  }
11709  const t = cache.tournois.find(x => x.id === tournoiId);
11710  if (!t) return;
11711
11712  const { error } = await db.rpc('set_my_tournament_registration', {
11713    p_tournament_id: tournoiId,
11714    p_status: inscrire ? 'a_confirmer' : 'forfait'
11715  });
11716
11717  if (error) { toastErreur(error); return; }
11718  toast(inscrire ? 'Inscription à confirmer enregistrée 🎾' : 'Forfait enregistré');
11719  await refresh();
11720}
11721
11722// ============================================================
11723// SCREEN: INVITATIONS (coach)
11724// ============================================================
11725function renderInvitations() {
11726  const invitations = listeSure(cache.invitations);
11727  const joueursSansCompte = cache.joueurs.filter(j =>
11727 !j.user_id);
11728
11729  let html = '';
11730
11731  // Cas spécial : aucun joueur du tout dans la base
11732  if (cache.joueurs.length === 0) {
11733    return `<div class="empty">
11734      <span class="icon">👥</span>
11735      <strong>Aucun joueur dans la base</strong><br>
11736      <span class="text-faint">Va d'abord dans l'onglet <strong>Joueurs</strong> et crée des fiches joueurs.<br>Ensuite tu pourras générer des codes d'invitation pour qu'ils se créent un compte.</span>
11737      <br><button class="btn btn-primary mt-2" data-ui-click="navigate('players')">→ Aller dans Joueurs</button>
11738    </div>`;
11739  }
11740
11741  if (joueursSansCompte.length > 0) {
11742    html += `<div class="section-title">Joueurs sans compte (${joueursSansCompte.length})</div>`;
11743    html += `<div class="auth-info" style="font-size:13px;line-height:1.5;margin-bottom:12px"><strong>Un parent avec plusieurs enfants conserve un seul compte et un seul email.</strong><br>Génère le code du nouvel enfant. Le parent se connecte ensuite à son compte existant et choisit <strong>Mon profil → Mes enfants → Ajouter un enfant</strong>. Il ne doit pas refaire une inscription.</div>`;
11744    joueursSansCompte.forEach(j => {
11745      const existingInvite = invitations.find(i => i.joueur_id === j.id && !i.utilise && new Date(i.expires_at) > new Date());
11746      html += `<div class="player-row" style="cursor:default">
11747        ${avatarHtml(j, 'md')}
11748        <div class="player-info">
11749          <div class="player-name">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</div>
11750          ${existingInvite ? `<button type="button" class="invite-code" data-ui-click="copyCodeFromElement(this)" title="Cliquer pour copier">${escapeHtml(existingInvite.code)}</button>
11751            <div class="text-faint" style="font-size:11px">Expire le ${formatDate(existingInvite.expires_at)} · clic pour copier</div>` :
11752            `<button class="btn btn-sm btn-primary mt-1" data-ui-click="genererInvitation(${escapeHtml(encoderArgumentTexteUI(j.id))})"><img class="btn-action-art" src="assets/v4/menu/invitations.webp" alt="">Générer un code</button>`}
11753        </div>
11754      </div>`;
11755    });
11756  }
11757
11758  if (invitations.length > 0) {
11759    html += `<div class="section-title">Historique des invitations</div><div class="card">`;
11760    invitations.slice(0, 20).forEach(i => {
11761      const j = joueurById(i.joueur_id);
11762      const expire = new Date(i.expires_at) < new Date();
11763      const status = i.utilise ? 'utilisé' : (expire ? 'expiré' : 'actif');
11764      const badge = i.utilise ? 'badge-success' : (expire ? 'badge-danger' : 'badge-warning');
11765      html += `<div class="match-row">
11766        <div class="match-result-row">
11767          <span><strong>${j ? escapeHtml(j.prenom + ' ' + j.nom) : '?'}</strong> · <code style="font-size:12px">${escapeHtml(i.code)}</code></span>
11768          <span style="display:flex;align-items:center;gap:8px"><span class="badge ${badge}">${status}</span>${!i.utilise && !expire ? `<button class="btn btn-sm btn-danger" data-ui-click="revoquerInvitation(${escapeHtml(encoderArgumentTexteUI(i.id))})">Révoquer</button>` : ''}</span>
11769        </div>
11770        <div class="match-meta">créé ${formatDate(i.created_at)}${i.utilise ? '' : ' · expire ' + formatDate(i.expires_at)}</div>
11771      </div>`;
11772    });
11773    html += `</div>`;
11774  }
11775
11776  if (joueursSansCompte.length === 0 && invitations.length === 0) {
11777    html += `<div class="empty"><span class="icon">✓</span>Tous tes joueurs ont déjà un compte.<br><span class="text-faint">Crée de nouveaux joueurs dans l'onglet Joueurs pour générer d'autres invitations.</span></div>`;
11778  }
11779
11780  return html;
11781}
11782
11783function copyCode(code) {
11784  if (!code) { toast('Aucun code configuré', true); return; }
11785  navigator.clipboard.writeText(code).then(() => toast('Code copié : ' + code));
11786}
11787
11788// La valeur est lue depuis le texte du bouton : aucune donnée dynamique n'est
11789// interpolée dans du JavaScript inline, ce qui supprime la classe d'injection liée
11790// aux quotes décodées dans les attributs HTML.
11791function copyCodeFromElement(element) {
11792  copyCode(element ? element.textContent.trim() : '');
11793}
11794
11795async function genererInvitation(joueurId) {
11796  // Générer un code lisible (8 chars alphanum sans ambiguïté)
11797  const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
11798  let code = '';
11799  for (let i = 0; i < 8; i++) code += chars[Math.floor(Math.random() * chars.length)];
11800
11801  const { error } = await db.from('invitations').insert({
11802    code,
11803    joueur_id: joueurId
11804  });
11805
11806  if (error) { toastErreur(error); return; }
11807  toast('Code généré : ' + code);
11808  await refresh();
11809}
11810
11811async function revoquerInvitation(invitationId) {
11812  if (!isCoach()) return;
11813  if (!(await demanderConfirmation('Révoquer ce code d’invitation ? Il ne pourra plus être utilisé.', { titre: 'Révoquer l’invitation', libelle: 'Révoquer' }))) return;
11814  const { error } = await db.from('invitations').delete().eq('id', invitationId);
11815  if (error) { toastErreur(error); return; }
11816  toast('Invitation révoquée');
11817  await refresh();
11818}
11819
11820// ============================================================
11821// GESTION DES ÉDITIONS PALMYRE (réglages)
11822// ============================================================
11823
11824// Liste les éditions (actives et inactives), chacune éditable.
11825function renderEditionsSettings() {
11826  // On lit toutes les éditions afin que le coach puisse aussi réactiver une archive.
11827  const editions = (cache.evenementsTous || listeSure(cache.evenements));
11828  const editionOuverte = editions.find(ed => ed.actif) || null;
11829  let html = `<div class="card">
11830    <div class="text-dim mb-1" style="font-size:13px">Gère les éditions du stage Palmyre (ou d'autres événements). Les dates servent à proposer automatiquement l'estampillage des matchs joués pendant la période.</div>
11831    <div style="margin:10px 0">
11832      <button class="btn btn-primary btn-sm" data-ui-click="openModalEdition()" ${editionOuverte ? 'disabled aria-disabled="true"' : ''}>+ Nouvelle édition</button>
11833      ${editionOuverte ? `<div class="auth-info mt-1" style="font-size:12px">Archive d’abord « ${escapeHtml(editionOuverte.nom)} » pour pouvoir créer une nouvelle édition. Son historique restera conservé.</div>` : ''}
11834    </div>`;
11835
11836  if (editions.length === 0) {
11837    html += `<div class="text-faint" style="font-size:13px">Aucune édition pour le moment.</div></div>`;
11838    return html;
11839  }
11840
11841  html += `<div style="display:flex; flex-direction:column; gap:8px">`;
11842  editions.forEach(ed => {
11843    const periode = (ed.date_debut && ed.date_fin)
11844      ? `${formatDate(ed.date_debut)} → ${formatDate(ed.date_fin)}`
11845      : 'dates non renseignées';
11846    html += `<div style="display:flex; align-items:center; gap:10px; padding:10px; border:1px solid var(--border); border-radius:8px; ${ed.actif ? '' : 'opacity:0.55'}">
11847      <div style="flex:1; cursor:pointer" data-ui-click="openModalEdition(${escapeHtml(encoderArgumentTexteUI(ed.id))})">
11848        <div style="font-weight:600">🏖️ ${escapeHtml(ed.nom)} ${ed.actif ? '' : '<span class="text-faint" style="font-size:11px">(inactive)</span>'}</div>
11849        <div class="text-faint" style="font-size:12px">${periode}</div>
11850      </div>
11851      <button class="btn btn-sm" data-ui-click="toggleEditionActive(${escapeHtml(encoderArgumentTexteUI(ed.id))}, ${ed.actif ? 'false' : 'true'})">${ed.actif ? 'Archiver' : 'Réactiver'}</button>
11852      <button class="btn btn-sm" data-ui-click="openModalEdition(${escapeHtml(encoderArgumentTexteUI(ed.id))})">Modifier</button>
11853    </div>`;
11854  });
11855  html += `</div></div>`;
11856  return html;
11857}
11858
11859function openModalEdition(editionId) {
11860  const editions = (cache.evenementsTous || listeSure(cache.evenements));
11861  const ed = editionId ? editions.find(e => e.id === editionId) : null;
11862  const isEdit = !!ed;
11863
11864  if (!isEdit) {
11865    const editionOuverte = editions.find(item => item.actif);
11866    if (editionOuverte) {
11867      toast(`Archive d’abord « ${editionOuverte.nom} » avant de créer une nouvelle édition Palmyre.`, true);
11868      return;
11869    }
11870  }
11871
11872  const html = `<div class="modal-header">
11873    <h3 class="modal-title">${isEdit ? 'Modifier l’édition' : 'Nouvelle édition'}</h3>
11874    <button class="modal-close" data-ui-click="closeModal()">×</button>
11875  </div>
11876  <form data-ui-submit="saveEdition(event, ${escapeHtml(encoderArgumentTexteUI(editionId || ''))})">
11877    <div class="form-row"><label class="form-label">Nom de l'édition *</label>
11878      <input type="text" name="nom" required value="${ed ? escapeHtml(ed.nom) : ''}" placeholder="La Palmyre 2027"></div>
11879    <div class="grid-2">
11880      <div class="form-row"><label class="form-label">Date de début *</label>
11881        <input type="date" name="date_debut" required value="${ed ? (ed.date_debut || '') : ''}"></div>
11882      <div class="form-row"><label class="form-label">Date de fin *</label>
11883        <input type="date" name="date_fin" required value="${ed ? (ed.date_fin || '') : ''}"></div>
11884    </div>
11885    <div class="form-row"><label class="form-label">Objectif de matchs (sur la semaine) *</label>
11886      <input type="number" name="objectif_matches" required min="1" max="100" value="${ed && ed.objectif_matches != null ? ed.objectif_matches : 20}">
11887      <div class="text-faint" style="font-size:11px; margin-top:3px">Nombre de matchs visé par participant sur toute la durée du stage (sert au score d'engagement).</div></div>
11888    <div class="form-row" style="display:flex; align-items:center; gap:10px">
11889      <input type="checkbox" name="actif" id="edActif" ${!ed || ed.actif ? 'checked' : ''} style="width:18px; height:18px">
11890      <label for="edActif" style="margin:0; cursor:pointer; font-size:13px">Édition active (ouverte depuis le menu Palmyre)</label>
11891    </div>
11892    <div class="flex gap-1 mt-2">
11893      <button type="button" class="btn flex-1" data-ui-click="closeModal()">Annuler</button>
11894      <button type="submit" class="btn btn-primary flex-1">${isEdit ? 'Enregistrer' : 'Créer'}</button>
11895    </div>
11896  </form>`;
11897  showModal(html);
11898}
11899
11900async function saveEdition(e, editionId) {
11901  e.preventDefault();
11902  const f = e.target;
11903  const nom = f.nom.value.trim();
11904  const dateDebut = f.date_debut.value;
11905  const dateFin = f.date_fin.value;
11906
11907  if (dateFin < dateDebut) {
11908    toast('La date de fin doit être après la date de début', true);
11909    return;
11910  }
11911  if (!editionId) {
11912    const editionOuverte = (cache.evenementsTous || listeSure(cache.evenements)).find(item => item.actif);
11913    if (editionOuverte) {
11914      toast(`Création impossible : archive d’abord « ${editionOuverte.nom} ».`, true);
11915      return;
11916    }
11917  }
11918  const verrou = verrouillerSoumission(f);
11919  if (!verrou) return;
11920
11921  // Clé technique : générée à partir du nom (slug) pour une nouvelle édition.
11922  const obj = {
11923    nom,
11924    date_debut: dateDebut,
11925    date_fin: dateFin,
11926    objectif_matches: parseInt(f.objectif_matches.value, 10) || 20,
11927    // Une nouvelle édition devient toujours l’unique environnement Palmyre ouvert.
11928    actif: editionId ? f.actif.checked : true
11929  };
11930
11931  let error;
11932  try {
11933    if (editionId) {
11934      ({ error } = await updateConfirmee(db.from('evenements').update(obj).eq('id', editionId)));
11935    } else {
11936      // Clé unique : slug du nom + suffixe aléatoire court pour éviter les collisions.
11937      const slug = nom.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
11938        .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 30) || 'edition';
11939      obj.cle = `${slug}_${Math.random().toString(36).slice(2, 6)}`;
11940      obj.type = 'palmyre';
11941      ({ error } = await db.from('evenements').insert(obj));
11942    }
11943  } finally {
11944    deverrouillerSoumission(verrou);
11945  }
11946  if (error) { toastErreur(error); return; }
11947  closeModal();
11948  toast(editionId ? 'Édition mise à jour' : 'Édition créée');
11949  await reloadTables(['evenements']);
11950  render();
11951}
11952
11953async function toggleEditionActive(editionId, actif) {
11954  if (actif) {
11955    const autreEditionOuverte = (cache.evenementsTous || listeSure(cache.evenements))
11956      .find(item => item.actif && item.id !== editionId);
11957    if (autreEditionOuverte) {
11958      toast(`Réactivation impossible : archive d’abord « ${autreEditionOuverte.nom} ».`, true);
11959      return;
11960    }
11961  }
11962  const { error } = await updateConfirmee(db.from('evenements').update({ actif }).eq('id', editionId));
11963  if (error) { toastErreur(error); return; }
11964  const edition = (cache.evenementsTous || listeSure(cache.evenements)).find(item => item.id === editionId);
11965  if (!actif && edition && etatApp.environnement === edition.cle) {
11966    etatApp.definirEnvironnement('pole');
11967  }
11968  toast(actif ? 'Édition réactivée' : 'Édition archivée');
11969  await reloadTables(['evenements']);
11970  render();
11971}
11972
11973// ============================================================
11974// SCREEN: SETTINGS (coach)
11975// ============================================================
11976function renderSettings() {
11977  // Le code annuel simplifie l'accueil d'une promotion nombreuse. Il n'active
11978  // jamais directement un joueur : chaque demande reste validée par un coach.
11979  let html = `<div class="section-title">Accès des joueurs</div>`;
11980  html += `<div class="card">
11981    <div class="text-dim mb-1" style="font-size:13px">Diffuse ce code annuel aux joueurs autorisés à demander leur inscription. Chaque demande reste bloquée jusqu'à sa validation ou son refus par un coach.</div>
11982    ${cache.codeAccesPole
11983      ? `<button type="button" class="invite-code" style="margin-top:10px;cursor:pointer" data-ui-click="copyCodeFromElement(this)" title="Cliquer pour copier">${escapeHtml(cache.codeAccesPole)}</button>`
11984      : `<div class="alert danger" style="margin-top:10px"><div><strong>Code joueurs non configuré</strong><div style="font-size:11px;margin-top:3px">L'inscription joueur reste bloquée tant que le code annuel n'est pas défini.</div></div></div>`}
11985    <div class="flex gap-1 mt-1" style="flex-wrap:wrap">
11986      <button class="btn btn-primary btn-sm" data-ui-click="changeCodeAcces()"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Changer le code annuel</button>
11987      <button class="btn btn-sm" data-ui-click="navigate('invitations')"><img class="btn-action-art" src="assets/v4/menu/invitations.webp" alt="">Invitations individuelles (optionnel)</button>
11988    </div>
11989  </div>`;
11990
11991  // Section : Code d'accès coach
11992  html += `<div class="section-title">Code d'accès coach</div>`;
11993  html += `<div class="card">
11994    <div class="text-dim mb-1" style="font-size:13px">Ce code permet à un <strong>autre coach</strong> de créer son propre compte coach. Ne le communique qu'à des personnes de confiance : un compte coach a accès à toutes les données de l'académie.</div>
11995    ${cache.codeAccesCoach
11996      ? `<button type="button" class="invite-code" style="margin-top:10px;cursor:pointer" data-ui-click="copyCodeFromElement(this)" title="Cliquer pour copier">${escapeHtml(cache.codeAccesCoach)}</button>`
11997      : `<div class="alert danger" style="margin-top:10px"><div><strong>Code coach non configuré</strong><div style="font-size:11px;margin-top:3px">La ligne <code>code_acces_coach</code> est absente ou inaccessible. La création d'un coach doit rester bloquée côté serveur.</div></div></div>`}
11998    <button class="btn btn-sm mt-1" data-ui-click="changeCodeCoach()"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Changer le code coach</button>
11999  </div>`;
12000
12001  html += `<div class="section-title">Groupes de l'académie</div>`;
12002  // Iterer dans l'ordre logique : tremplin → accession → élite
12003  const groupOrder = ['tremplin', 'accession', 'elite'];
12004  groupOrder.forEach(key => {
12005    const g = cache.config[key];
12006    if (!g || typeof g !== 'object' || !g.nom) return;
12007    html += `<div class="card">
12008      <div class="row-between mb-1">
12009        <strong>${escapeHtml(g.nom)}</strong>
12010        <span class="badge badge-${key}">${escapeHtml(key)}</span>
12011      </div>
12012      <div class="grid-2">
12013        <div>
12014          <label class="form-label">Places max</label>
12015          <input type="number" min="1" max="50" value="${g.places}" data-config-key="${key}" data-field="places" data-ui-change="updateGroupeConfig(this)">
12016        </div>
12017        <div>
12018          <label class="form-label">Matches/an objectif</label>
12019          <input type="number" min="0" max="200" value="${g.objectifMatches}" data-config-key="${key}" data-field="objectifMatches" data-ui-change="updateGroupeConfig(this)">
12020        </div>
12021      </div>
12022    </div>`;
12023  });
12024
12025  html += `<div class="section-title">🏖️ Éditions Palmyre</div>`;
12026  html += renderEditionsSettings();
12027
12028  html += `<div class="section-title">🔒 Registre RGPD</div>`;
12029  html += `<div class="card">
12030    <div class="text-dim mb-1" style="font-size:13px">Génère le registre des consentements de tous les joueurs actifs de l'académie, exportable en PDF. Utile en cas de contrôle CNIL ou pour archivage administratif.</div>
12031    <div style="margin-top:10px">
12032      <button class="btn btn-primary btn-sm" data-ui-click="exportRegistreConsentementsPDF()"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">Télécharger le registre global (PDF)</button>
12033    </div>
12034  </div>`;
12035
12036  html += `<div class="section-title">🏅 Badges</div>`;
12037  html += `<div class="card">
12038    <div class="text-dim mb-1" style="font-size:13px">Recalcule et enregistre les badges déjà mérités par <strong>tous</strong> les joueurs (utile pour les joueurs qui n'ont rien déclaré depuis l'arrivée des badges). Le fil d'actualité et les badges se rempliront pour tout le monde.</div>
12039    <div style="margin-top:10px">
12040      <button class="btn btn-primary btn-sm" id="btnRattrapageBadges" data-ui-click="rattrapageBadgesTousJoueurs()"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Recalculer les badges de tous les joueurs</button>
12041    </div>
12042  </div>`;
12043
12044  html += `<div class="section-title">Informations</div><div class="card text-dim" style="font-size:13px">
12045    <p><strong style="color:var(--orange-text)">Académie de Tennis Bouliac</strong></p>
12046    <p>Application centralisée et synchronisée via Supabase. Les données sont stockées en toute sécurité, accessibles depuis n'importe quel appareil après connexion.</p>
12047    <p>Connecté en tant que : <strong>${escapeHtml(profile.email)}</strong></p>
12048    <p class="text-faint" style="margin-top:12px" id="appVersionLine">Version : chargement...</p>
12049  </div>`;
12050
12051  // Affichage depuis la source de version partagée.
12052  setTimeout(loadAppVersion, 50);
12053
12054  return html;
12055}
12056
12057// Affiche la version partagée dans tous les emplacements concernés.
12058async function loadAppVersion() {
12059  const settingsLine = document.getElementById('appVersionLine');
12060  const authFooter = document.getElementById('authVersionFooter');
12061  const navFooter = document.getElementById('navVersionFooter');
12062  // Si aucun des éléments n'est présent, rien à faire
12063  if (!settingsLine && !authFooter && !navFooter) return;
12064  const versionLabel = APP_VERSION_CURRENT || 'inconnue';
12065  if (settingsLine) settingsLine.textContent = `Version ${versionLabel}`;
12066  if (authFooter) authFooter.textContent = `Version ${versionLabel}`;
12067  if (navFooter) navFooter.textContent = versionLabel;
12068}
12069
12070function changeCodeAcces() {
12071  showModalCodeAcces('joueur');
12072}
12073
12074function changeCodeCoach() {
12075  showModalCodeAcces('coach');
12076}
12077
12078function showModalCodeAcces(type) {
12079  const coach = type === 'coach';
12080  const valeur = coach ? (cache.codeAccesCoach || '') : (cache.codeAccesPole || '');
12081  const boutonEnregistrer = coach
12082    ? '<button class="btn btn-primary flex-1" data-ui-click="saveCodeCoach()">Enregistrer</button>'
12083    : '<button class="btn btn-primary flex-1" data-ui-click="saveCodeAcces()">Enregistrer</button>';
12084  showModal(`<div class="modal-header">
12085    <h3 class="modal-title">${coach ? 'Code d’accès coach' : 'Code d’accès joueurs'}</h3>
12086    <button class="modal-close" data-ui-click="closeModal()" aria-label="Fermer">×</button>
12087  </div>
12088  <div class="alert warning"><div>Ce code donne accès à l'inscription ${coach ? 'avec le rôle coach. Réserve-le strictement aux coachs autorisés.' : 'des joueurs de l’académie.'}</div></div>
12089  <div class="form-row">
12090    <label class="form-label" for="nouveauCodeAcces">Nouveau code</label>
12091    <input type="text" id="nouveauCodeAcces" value="${escapeHtml(valeur)}" minlength="${coach ? 6 : 4}" autocomplete="off" autocapitalize="characters" spellcheck="false">
12092  </div>
12093  <div class="flex gap-1 mt-2">
12094    <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button>
12095    ${boutonEnregistrer}
12096  </div>`);
12097}
12098
12099async function saveCodeAcces() {
12100  const nouveau = $('nouveauCodeAcces')?.value || '';
12101  if (!nouveau || nouveau.trim().toUpperCase() === cache.codeAccesPole) { closeModal(); return; }
12102  const codeUp = nouveau.trim().toUpperCase();
12103  if (codeUp.length < 4) { toast('Code trop court (min 4 caractères)', true); return; }
12104
12105  const { error } = await updateConfirmee(db.from('config').update({
12106    valeur: codeUp,
12107    updated_at: new Date().toISOString()
12108  }).eq('cle', 'code_acces_pole'), 'cle');
12109
12110  if (error) { toastErreur(error); return; }
12111  cache.codeAccesPole = codeUp;
12112  closeModal();
12113  toast('Code d’accès joueurs mis à jour');
12114  render();
12115}
12116
12117async function saveCodeCoach() {
12118  const nouveau = $('nouveauCodeAcces')?.value || '';
12119  if (!nouveau || nouveau.trim().toUpperCase() === cache.codeAccesCoach) { closeModal(); return; }
12120  const codeUp = nouveau.trim().toUpperCase();
12121  if (codeUp.length < 6) { toast('Code trop court (min 6 caractères)', true); return; }
12122
12123  const { error } = await updateConfirmee(db.from('config').update({
12124    valeur: codeUp,
12125    updated_at: new Date().toISOString()
12126  }).eq('cle', 'code_acces_coach'), 'cle');
12127
12128  if (error) { toastErreur(error); return; }
12129  cache.codeAccesCoach = codeUp;
12130  closeModal();
12131  toast('Code coach mis à jour');
12132  render();
12133}
12134
12135async function updateGroupeConfig(input) {
12136  const key = input.dataset.configKey;
12137  const field = input.dataset.field;
12138  const value = parseInt(input.value) || 0;
12139  const { data, error } = await db.rpc('coach_update_groupe_config', {
12140    p_groupe: key, p_champ: field, p_valeur: value
12141  });
12142  if (error) { toastErreur(error); return; }
12143  // La RPC renvoie le document verrouillé et fusionné en base : on ne conserve
12144  // jamais la copie potentiellement périmée qui était ouverte dans le navigateur.
12145  cache.config = data || cache.config;
12146  toast('Réglage enregistré');
12147}
12148
12149// ============================================================
12150// MODAL: PLAYER (coach)
12151// ============================================================
12152function openModalPlayer(joueurId) {
12153  if (!isCoach()) return;
12154  const j = joueurId ? joueurById(joueurId) : null;
12155  const isEdit = !!j;
12156  const creationPreAcademie = !isEdit && isPreAcademieEnv();
12157
12158  const html = `
12159    <div class="modal-header">
12160      <h3 class="modal-title">${isEdit ? 'Modifier joueur' : 'Nouveau joueur'}</h3>
12161      <button class="modal-close" data-ui-click="closeModalDirect()">×</button>
12162    </div>
12163    <form data-ui-submit="savePlayer(event, ${escapeHtml(encoderArgumentTexteUI(joueurId || ''))})">
12164      <div class="grid-2">
12165        <div class="form-row"><label class="form-label">Prénom *</label><input type="text" name="prenom" required value="${j ? escapeHtml(j.prenom) : ''}"></div>
12166        <div class="form-row"><label class="form-label">Nom *</label><input type="text" name="nom" required value="${j ? escapeHtml(j.nom) : ''}"></div>
12167      </div>
12168      <div class="grid-2">
12169        <div class="form-row"><label class="form-label">Âge${creationPreAcademie ? ' *' : ''}</label><input type="number" name="age" min="7" max="${creationPreAcademie ? '11' : '25'}" ${creationPreAcademie ? 'required' : ''} value="${j ? (computeAge(j) || '') : ''}"></div>
12170        <div class="form-row"><label class="form-label">Sexe</label><select name="sexe">
12171          <option value="M" ${j && j.sexe==='M'?'selected':''}>Masculin</option>
12172          <option value="F" ${j && j.sexe==='F'?'selected':''}>Féminin</option>
12173        </select></div>
12174      </div>
12175      <div class="grid-2">
12176        ${j ? `
12177        <div class="form-row">
12178          <label class="form-label">Classement</label>
12179          <div style="display:flex; align-items:center; gap:8px; padding:8px 0">
12180            <strong class="orange" style="font-size:15px">${escapeHtml(j.classement || 'NC')}</strong>
12181            <button type="button" class="btn btn-sm" style="background:var(--bg-input)" data-ui-click="openChangerClassementCoach(${escapeHtml(encoderArgumentTexteUI(j.id))})">Changer</button>
12182          </div>
12183        </div>` : `
12184        <div class="form-row"><label class="form-label">Classement</label><select name="classement">
12185          ${CLASSEMENTS.map(c => `<option value="${c}">${c}</option>`).join('')}
12186        </select></div>`}
12187        <div class="form-row"><label class="form-label">Objectif</label><select name="objectif">
12188          ${CLASSEMENTS.map(c => `<option value="${c}" ${j && j.objectif===c?'selected':''}>${c}</option>`).join('')}
12189        </select></div>
12190      </div>
12191      <div class="form-row">
12192        <label class="form-label">Groupe</label>
12193        <div class="pill-group" id="pillGroupe">
12194          <span class="pill ${!j || !j.groupe ? 'active' : ''}" data-value="" data-ui-click="selectPill(this, 'pillGroupe')">— Sans groupe</span>
12195          ${creationPreAcademie ? '' : groupesOrdonnes().map(k => `<span class="pill ${j && j.groupe===k?'active':''}" data-value="${k}" data-ui-click="selectPill(this, 'pillGroupe')">${escapeHtml(cache.config[k].nom)}</span>`).join('')}
12196        </div>
12197        <div class="text-faint" style="font-size:11px; margin-top:4px">${creationPreAcademie ? 'Le joueur sera créé directement en Pré-Académie.' : '« Sans groupe » + 11 ans ou moins (âge tennistique) → le joueur passe en Pré-Académie.'}</div>
12198      </div>
12199      <div class="form-row">
12200        <label style="display:flex; align-items:center; gap:10px; cursor:pointer; font-size:14px">
12201          <input type="checkbox" name="participe_palmyre" ${j && j.participe_palmyre ? 'checked' : ''} style="width:auto; margin:0">
12202          <span>🏖️ Participe à la Palmyre</span>
12203        </label>
12204        <div class="text-faint" style="font-size:11px; margin-top:4px">À cocher chaque année pour les joueurs qui partent au stage.</div>
12205      </div>
12206      <div class="form-row"><label class="form-label">Téléphone/Email parent</label><input type="text" name="contact" value="${j ? escapeHtml(j.contact || '') : ''}"></div>
12207      <div class="form-row"><label class="form-label">Points forts (virgules)</label><input type="text" name="points_forts" value="${j ? escapeHtml((listeSure(j.points_forts)).join(', ')) : ''}"></div>
12208      <div class="form-row"><label class="form-label">Axes progrès (virgules)</label><input type="text" name="axes_progres" value="${j ? escapeHtml((listeSure(j.axes_progres)).join(', ')) : ''}"></div>
12209      <div class="flex gap-1 mt-2">
12210        ${isEdit ? `<button type="button" class="btn btn-danger" data-ui-click="deletePlayer(${escapeHtml(encoderArgumentTexteUI(j.id))})">Supprimer</button>` : ''}
12211        <button type="button" class="btn flex-1" data-ui-click="closeModalDirect()">Annuler</button>
12212        <button type="submit" class="btn btn-primary flex-1">${isEdit ? 'Enregistrer' : 'Créer'}</button>
12213      </div>
12214    </form>`;
12215  showModal(html);
12216}
12217
12218async function savePlayer(e, id) {
12219  e.preventDefault();
12220  const f = e.target;
12221  const verrou = verrouillerSoumission(f);
12222  if (!verrou) return;
12223  const groupePill = document.querySelector('#pillGroupe .pill.active');
12224
12225  // État Palmyre AVANT modification (pour détecter une nouvelle sélection).
12226  const ancienJoueur = id ? joueurById(id) : null;
12227  const participaitAvant = ancienJoueur ? !!ancienJoueur.participe_palmyre : false;
12228
12229  const obj = {
12230    prenom: f.prenom.value.trim(),
12231    nom: f.nom.value.trim(),
12232    age: parseInt(f.age.value) || null,
12233    sexe: f.sexe.value,
12234    objectif: f.objectif.value,
12235    groupe: groupePill && groupePill.dataset.value ? groupePill.dataset.value : null,
12236    contact: f.contact.value.trim(),
12237    points_forts: f.points_forts.value.split(',').map(s => s.trim()).filter(Boolean),
12238    axes_progres: f.axes_progres.value.split(',').map(s => s.trim()).filter(Boolean),
12239    participe_palmyre: f.participe_palmyre ? !!f.participe_palmyre.checked : false
12240  };
12241  // Le classement n'est saisi qu'à la création (champ select présent uniquement alors).
12242  // En édition, il se modifie exclusivement via le workflow (openChangerClassementCoach).
12243  if (!id && f.classement) {
12244    obj.classement = f.classement.value;
12245  }
12246
12247  let error;
12248  try {
12249    if (id) {
12250      obj.updated_at = new Date().toISOString();
12251      ({ error } = await updateConfirmee(db.from('joueurs').update(obj).eq('id', id)));
12252    } else {
12253      ({ error } = await db.from('joueurs').insert(obj));
12254    }
12255  } finally {
12256    deverrouillerSoumission(verrou);
12257  }
12258
12259  if (error) { toastErreur(error); return; }
12260
12261  // Nouvelle sélection Palmyre (décoché → coché) : notif in-app + email enthousiaste.
12262  const participeMaintenant = obj.participe_palmyre;
12263  if (participeMaintenant && !participaitAvant) {
12264    const edition = (listeSure(cache.evenements))[0] || null;
12265    notifierSelectionPalmyre(id, edition);
12266  }
12267
12268  closeModalDirect();
12269  toast(id ? 'Joueur modifié' : 'Joueur créé');
12270  await refresh();
12271}
12272
12273// Notifie un joueur de sa sélection à la Palmyre (in-app + email).
12274async function notifierSelectionPalmyre(joueurId, edition) {
12275  const j = joueurById(joueurId);
12276  if (!j || !j.user_id) return; // pas de compte → pas de notif
12277  const nomEd = edition ? edition.nom : 'la Palmyre';
12278  const periode = (edition && edition.date_debut && edition.date_fin)
12279    ? ` (${formatDate(edition.date_debut)} → ${formatDate(edition.date_fin)})`
12280    : '';
12281  // Notif in-app
12282  try {
12283    await db.from('notifications').insert({
12284      user_id: j.user_id,
12285      type: 'bilan_publie', // type générique réutilisé (pas de type dédié en base)
12286      titre: `🏖️ Félicitations, tu es sélectionné pour ${nomEd} !`,
12287      message: `Ton coach t'a sélectionné pour ${nomEd}${periode}. Prépare-toi pour une superbe semaine de tennis !`,
12288      lien_type: 'palmyre',
12289      lien_id: edition ? edition.id : null
12290    });
12291  } catch (err) { journal.warn('[palmyre] notif:', err); }
12292  // Email enthousiaste
12293  await sendEmailEvent('palmyre_selection', joueurId);
12294}
12295
12296async function deletePlayer(id) {
12297  if (!(await demanderConfirmation('Supprimer ce joueur et tous ses matches ?', { titre: 'Suppression définitive', libelle: 'Supprimer le joueur' }))) return;
12298  const { error } = await db.from('joueurs').delete().eq('id', id);
12299  if (error) { toastErreur(error); return; }
12300  closeModal();
12301  toast('Joueur supprimé');
12302  await refresh();
12303}
12304
12305// Archive un joueur : il quitte l'académie mais son historique (matches, bilans) est conservé.
12306// Réversible via reactivatePlayer().
12307async function archivePlayer(id) {
12308  const j = joueurById(id);
12309  if (!j) return;
12310  if (!(await demanderConfirmation(`Archiver ${j.prenom} ${j.nom} ?\n\nLe joueur sortira de l'académie actif (listes, groupes, alertes) mais tout son historique sera conservé. Tu pourras le réactiver à tout moment.`, { titre: 'Archiver le joueur', libelle: 'Archiver', danger: false }))) return;
12311  const { error } = await updateConfirmee(db.from('joueurs').update({ statut: 'archive' }).eq('id', id));
12312  if (error) { toastErreur(error); return; }
12313  closeModal();
12314  toast(`${j.prenom} ${j.nom} archivé`);
12315  await refresh();
12316}
12317
12318// Retire un joueur de son groupe : il reste ACTIF dans l'académie mais sans groupe,
12319// en attente d'une réaffectation. Son historique (matchs, bilans) est conservé.
12320async function retirerDuGroupe(id) {
12321  const j = joueurById(id);
12322  if (!j) return;
12323  if (!(await demanderConfirmation(`Retirer ${j.prenom} ${j.nom} de son groupe ?\n\nIl reste un joueur actif de l'académie mais sans groupe, en attente d'une nouvelle affectation. Son historique est conservé.`, { titre: 'Retirer du groupe', libelle: 'Retirer', danger: false }))) return;
12324  const { error } = await updateConfirmee(db.from('joueurs').update({ groupe: null }).eq('id', id));
12325  if (error) { toastErreur(error); return; }
12326  closeModal();
12327  toast(`${j.prenom} retiré de son groupe`);
12328  await refresh();
12329}
12330
12331// Réactive un joueur archivé : il revient dans l'académie actif.
12332async function reactivatePlayer(id) {
12333  const j = joueurById(id);
12334  if (!j) return;
12335  const { error } = await updateConfirmee(db.from('joueurs').update({ statut: 'actif' }).eq('id', id));
12336  if (error) { toastErreur(error); return; }
12337  closeModal();
12338  toast(`${j.prenom} ${j.nom} réactivé`);
12339  await refresh();
12340}
12341
12342// Active/désactive la participation Palmyre d'un joueur directement depuis sa fiche.
12343// Inscription : confirmation légère (déclenche notif + email). Désinscription : directe.
12344async function togglePalmyreParticipation(id) {
12345  const j = joueurById(id);
12346  if (!j) return;
12347  const edition = (listeSure(cache.evenements))[0] || null;
12348  const nomEd = edition ? edition.nom : 'la Palmyre';
12349  const nouveauStatut = !j.participe_palmyre;
12350
12351  if (nouveauStatut) {
12352    // Inscription : on confirme car ça notifie le joueur (notif + email).
12353    if (!(await demanderConfirmation(`Inscrire ${j.prenom} à ${nomEd} ?\n\nIl recevra une notification et un email de confirmation.`, { titre: 'Confirmer l’inscription', libelle: 'Inscrire', danger: false }))) return;
12354  }
12355
12356  const { error } = await updateConfirmee(db.from('joueurs').update({ participe_palmyre: nouveauStatut }).eq('id', id));
12357  if (error) { toastErreur(error); return; }
12358
12359  // Notif + email enthousiaste uniquement à l'inscription (passage à true).
12360  if (nouveauStatut) {
12361    notifierSelectionPalmyre(id, edition);
12362    toast(`${j.prenom} inscrit à ${nomEd} 🏖️`);
12363  } else {
12364    toast(`${j.prenom} retiré de ${nomEd}`);
12365  }
12366
12367  await reloadTables(['joueurs']);
12368  // Rafraîchir la fiche ouverte
12369  openPlayerDetail(id);
12370}
12371
12372function openPlayerDetail(id) {
12373  const j = joueurById(id);
12374  if (!j) return;
12375  enregistrerDetailNavigation('player', id);
12376  etatApp.definirJoueurDetail(id);
12377  const stats = statsJoueur(id);
12378  const g = cache.config[j.groupe];
12379  const agePD = computeAge(j);
12380
12381  let html = `<div class="modal-header">${boutonRetourDetail('Retour')}<h3 class="modal-title">Fiche joueur</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
12382    <div class="flex gap-1 mb-2" style="align-items:center">
12383      <div data-ui-click="openAvatarUpload(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="cursor:pointer; position:relative" title="Modifier la photo">
12384        ${avatarHtml(j, 'lg')}
12385          <div style="position:absolute;bottom:-7px;right:-8px;width:30px;height:30px;display:flex;align-items:center;justify-content:center;filter:drop-shadow(0 4px 6px rgba(0,0,0,.42))"><img src="assets/v4/nav/camera.webp" alt="Modifier la photo" style="width:32px;height:32px;object-fit:contain"></div>
12386      </div>
12387      <div class="flex-1">
12388        <div style="font-size:17px; font-weight:700">${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}${j.statut === 'archive' ? ' <span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:500;color:var(--text-dim);background:var(--bg-input);padding:2px 8px;vertical-align:middle"><img class="inline-art-icon" src="assets/v4/menu/player_archive.webp" alt="" style="width:20px;height:20px">Archivé</span>' : ''}</div>
12389        <div class="text-dim" style="font-size:13px">${agePD !== null ? ageLabel(j) : 'âge non renseigné'} · ${j.sexe === 'F' ? 'Féminin' : 'Masculin'}</div>
12390        <div style="margin-top:4px; display:flex; gap:6px; flex-wrap:wrap; align-items:center">
12391          ${isPoleEnv() && g ? `<span class="badge badge-${j.groupe}">${escapeHtml(g.nom)}</span>` : ''}
12392          ${badgeVitrineHtml(j)}
12393          ${(listeSure(cache.evenements)).length > 0 ? `<span data-ui-click="togglePalmyreParticipation(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="cursor:pointer; font-size:11px; font-weight:600; padding:3px 9px; border-radius:10px; ${j.participe_palmyre ? 'background:var(--orange); color:#fff' : 'background:transparent; color:var(--text-dim); border:1px dashed var(--border)'}" title="${j.participe_palmyre ? 'Retirer de la Palmyre' : 'Inscrire à la Palmyre'}">🏖️ Palmyre${j.participe_palmyre ? ' ✓' : ''}</span>` : ''}
12394        </div>
12395      </div>
12396      <div style="text-align:center; flex-shrink:0; padding:6px 12px; background:var(--bg-section); border-radius:8px">
12397        <div class="text-faint" style="font-size:10px; font-weight:600; letter-spacing:0.5px">CLASST</div>
12398        <div style="font-size:20px; font-weight:800; color:var(--orange-text)">${escapeHtml(j.classement || 'NC')}</div>
12399        ${isPoleEnv() && j.objectif ? `<div class="text-faint" style="font-size:10px">objectif ${escapeHtml(j.objectif)}</div>` : ''}
12400      </div>
12401      ${isCoach() ? `<button class="btn btn-sm" data-ui-click="openModalPlayer(${escapeHtml(encoderArgumentTexteUI(id))})" title="Modifier le joueur" aria-label="Modifier le joueur"><img class="btn-action-art" src="assets/v4/menu/edit_player.webp" alt=""></button>` : ''}
12402    </div>
12403    ${isCoach() ? `<div class="player-context-actions" aria-label="Actions rapides pour ${escapeHtml(j.prenom)}">
12404      <button type="button" class="player-context-action" data-player-shortcut="match"><img src="assets/v4/nav/matches.webp" alt=""><span>Match</span></button>
12405      ${isPoleEnv() ? `<button type="button" class="player-context-action" data-player-shortcut="note"><img src="assets/v4/menu/coach_notes.webp" alt=""><span>Note</span></button>` : ''}
12406      <button type="button" class="player-context-action" data-player-shortcut="report"><img src="assets/v4/nav/reports.webp" alt=""><span>Bilan</span></button>
12407      <button type="button" class="player-context-action" data-player-shortcut="edit"><img src="assets/v4/menu/edit_player.webp" alt=""><span>Modifier</span></button>
12408    </div>` : ''}`;
12409
12410  // Import de matchs Ten'Up (coach) : action de gestion, placée juste sous les tuiles
12411  if (isCoach() && j.statut !== 'archive' && getSaisonAnalyse() === getSaisonCourante()) {
12412    html += `<div class="mt-2"><button class="btn btn-sm btn-full" data-ui-click="closeModalDirect(); openModalImportMatchsTenup(${escapeHtml(encoderArgumentTexteUI(j.id
12412))})"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Importer des matchs depuis Ten'Up</button>
12413      <div class="text-faint" style="font-size:11px; margin-top:4px; text-align:center">Copie-colle l'historique des matchs depuis la page Ten'Up du joueur</div></div>`;
12414  }
12415
12416  // Graphiques & stats détaillées (vue coach enrichie)
12417  html += `<div class="category-title"><span class="ct-icon">📊</span><span class="ct-label">Statistiques</span></div>`;
12418  html += renderPeriodeSelector(periodes.playerDetail, 'setPeriodePlayerDetail');
12419
12420  // Stats de la période sélectionnée pour ce joueur
12421  const [ppDebut, ppFin] = getPeriodeDates(periodes.playerDetail);
12422  const matchsPeriodeJTous = matchsDuJoueur(j.id).filter(m => TennisDomainRules.dateDansIntervalle(m.date, ppDebut, ppFin));
12423  // Stats hors WO (un forfait n'est pas un match joué). La liste affichée plus bas garde
12424  // les WO ; seuls les compteurs les excluent.
12425  const matchsPeriodeJ = matchsHorsWO(matchsPeriodeJTous);
12426  const comptePeriodeJ = TennisDomainRules.statistiquesSportives(
12427    matchsPeriodeJTous,
12428    match => classementADate(j.id, match.date)
12429  );
12430  const vP = comptePeriodeJ.victoires;
12431  const dP = comptePeriodeJ.defaites;
12432  const tauxP = comptePeriodeJ.tauxVictoire;
12433  // Belles perfs de ce joueur sur la période
12434  const perfsJoueur = TennisDomainRules.listerPerformancesJoueur(
12435    matchsPeriodeJTous,
12436    match => classementADate(j.id, match.date),
12437    'date'
12438  );
12439
12440  html += `<div class="stat-grid">
12441    <div class="stat-card"><div class="stat-label">Matches</div><div class="stat-value">${matchsPeriodeJ.length}</div></div>
12442    <div class="stat-card"><div class="stat-label">Victoires</div><div class="stat-value success">${vP}</div></div>
12443    <div class="stat-card"><div class="stat-label">Défaites</div><div class="stat-value">${dP}</div></div>
12444    <div class="stat-card"><div class="stat-label">Taux V</div><div class="stat-value ${tauxP >= 55 ? 'success' : ''}">${tauxP}%</div></div>
12445  </div>`;
12446
12447  if (perfsJoueur.length > 0) {
12448    html += `<div class="card" style="padding:10px 14px; margin-top:8px; border-left:3px solid var(--success)">
12449      <div style="font-size:13px;display:flex;align-items:center;gap:7px">${progressArtHtml('perfs',{size:28})}<strong>${perfsJoueur.length} belle${perfsJoueur.length > 1 ? 's' : ''} perf${perfsJoueur.length > 1 ? 's' : ''}</strong> sur ${getPeriodeLabel(periodes.playerDetail).toLowerCase()}</div>
12450      <div class="text-faint" style="font-size:11px; margin-top:4px">${perfsJoueur.slice(0, 3).map(p => `vs ${escapeHtml(p.match.adversaire)} (${escapeHtml(p.classementAdversaire)})`).join(' · ')}${perfsJoueur.length > 3 ? '…' : ''}</div>
12451    </div>`;
12452  }
12453
12454  // Ressenti (note déclarative du joueur sur ses matchs) — sur la période
12455  const ressentiJ = statsRessenti(matchsPeriodeJ);
12456  if (ressentiJ.renseigne) {
12457    html += `<div class="sub-title">Ressenti</div>`;
12458    html += `<div class="card" style="padding:10px 14px">${renderRessentiBloc(ressentiJ)}</div>`;
12459  }
12460
12461  // Taux de victoire par surface (sur la période) — affiché seulement si au moins
12462  // un match de la période a une surface renseignée.
12463  const SURFACES_META = { terre: { label: 'Terre battue', icone: '🟠' }, dur: { label: 'Dur', icone: '🔵' }, indoor: { label: 'Indoor', icone: '🏠' } };
12464  const statsSurfaces = TennisDomainRules.statistiquesSportivesParCle(
12465    matchsPeriodeJTous,
12466    match => match && match.surface,
12467    Object.keys(SURFACES_META)
12468  );
12469  if (statsSurfaces.length > 0) {
12470    html += `<div class="sub-title">Par surface</div>`;
12471    html += `<div class="card" style="padding:10px 14px">`;
12472    statsSurfaces.forEach(compteSurface => {
12473      const surf = compteSurface.cle;
12474      const v = compteSurface.victoires;
12475      const taux = compteSurface.tauxVictoire;
12476      html += `<div style="display:flex; justify-content:space-between; align-items:center; padding:4px 0; font-size:13px">
12477        <span>${SURFACES_META[surf].icone} ${SURFACES_META[surf].label}</span>
12478        <span><strong class="${taux >= 55 ? 'success' : ''}">${taux}%</strong> <span class="text-faint" style="font-size:11px">(${v}V / ${compteSurface.total})</span></span>
12479      </div>`;
12480    });
12481    html += `</div>`;
12482  }
12483
12484  if (isPoleEnv()) {
12485  // Assiduité (entraînements déclarés sur la période)
12486  html += renderAssiduiteSection(j.id, [ppDebut, ppFin], getPeriodeLabel(periodes.playerDetail));
12487
12488  // Activité matches (volume V/D sur la période, comme la fiche joueur)
12489  html += `<div class="sub-title">Activité matches</div>`;
12490  if (matchsPeriodeJ.length > 0) {
12491    html += `<div class="card" style="height:220px; padding:12px">
12491<canvas id="pdChartActivite"></canvas></div>`;
12492  } else {
12493    html += `<div class="card text-faint" style="font-size:13px; text-align:center; padding:16px">Aucun match sur cette période.</div>`;
12494  }
12495
12496  // Victoires cumulées (suit le sélecteur de période, comme l'activité)
12497  html += `<div class="sub-title" style="display:flex;align-items:center;gap:7px">${progressArtHtml('victoires',{size:27})}Victoires cumulées</div>`;
12498  if (matchsPeriodeJ.length > 0) {
12499    html += `<div class="card" style="height:220px; padding:12px"><canvas id="pdChartCumul"></canvas></div>`;
12500  } else {
12501    html += `<div class="card text-faint" style="font-size:13px; text-align:center; padding:16px">Aucun match sur cette période.</div>`;
12502  }
12503
12504  // ===== Suivi global (toutes périodes, hors sélecteur) =====
12505  html += `<div class="category-title"><span class="ct-icon">📈</span><span class="ct-label">Suivi global</span><span style="font-size:11px; font-weight:400; color:var(--text-faint); letter-spacing:0; text-transform:none">toutes périodes</span></div>`;
12506  // Évolution du classement (toute l'histoire)
12507  html += `<div class="sub-title">Évolution du classement</div>`;
12508  if (isCoach() && isPoleEnv() && j.statut !== 'archive') {
12509    html += `<div class="mb-1"><button class="btn btn-sm btn-full" data-ui-click="closeModalDirect(); openModalImportClassement(${escapeHtml(encoderArgumentTexteUI(j.id))})"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Importer l'historique de classement</button></div>`;
12510  }
12511  html += `<div class="card" style="height:220px; padding:12px"><canvas id="pdChartClassement"></canvas></div>`;
12512
12513  // Forme récente (5 derniers matches, indépendant de la période)
12514  if (stats.total > 0) {
12515    html += `<div class="sub-title">Forme récente</div>`;
12516    html += `<div class="card" style="height:160px; padding:12px"><canvas id="pdChartForme"></canvas></div>`;
12517  }
12518
12519  // Profil 5 axes (derniers bilans) — masqué pour la Pré-Académie (pas de bilans → pas d'araignée).
12520  if (!estPreAcademie(j)) {
12521    html += `<div class="sub-title">Profil 5 axes</div>`;
12522    html += `<div id="pdChartRadar_selector"></div>`;
12523    html += `<div class="card" style="height:300px; padding:12px"><canvas id="pdChartRadar"></canvas></div>`;
12524  }
12525
12526  // Tournois auxquels ce joueur est inscrit
12527  const tournoisJoueur = cache.tournois.filter(t => (listeSure(t.inscrits)).includes(j.id))
12528    .sort((a, b) => (a.date_debut || '').localeCompare(b.date_debut || ''));
12529  const now = Date.now();
12530  const tournoisAvenir = tournoisJoueur.filter(t => new Date(t.date_fin || t.date_debut).getTime() >= now - 24*3600*1000);
12531  const tournoisPasses = tournoisJoueur.filter(t => new Date(t.date_fin || t.date_debut).getTime() < now - 24*3600*1000);
12532
12533  html += `<div class="category-title"><span class="ct-icon">🏆</span><span class="ct-label">Tournois — ${tournoisJoueur.length} inscription${tournoisJoueur.length > 1 ? 's' : ''}</span></div>`;
12534  if (tournoisJoueur.length === 0) {
12535    html += `<div class="card text-faint" style="font-size:13px; text-align:center; padding:16px">Aucun tournoi déclaré pour ce joueur.</div>`;
12536  } else {
12537    if (tournoisAvenir.length > 0) {
12538      html += `<div class="text-faint" style="font-size:11px; text-transform:uppercase; letter-spacing:1px; margin:6px 0">À venir (${tournoisAvenir.length})</div>`;
12539      tournoisAvenir.forEach(t => {
12540        const dateStr = t.date_fin && t.date_fin !== t.date_debut ? `${formatDate(t.date_debut)} → ${formatDate(t.date_fin)}` : formatDate(t.date_debut);
12541        html += `<div class="card" data-ui-click="closeModalDirect(); navigate('calendar'); setTimeout(()=>openModalTournoi(${escapeHtml(encoderArgumentTexteUI(t.id))}), 200)" style="cursor:pointer; padding:10px 12px; margin-bottom:6px">
12542          <div style="font-weight:600; font-size:13px">${escapeHtml(t.nom)}</div>
12543          <div class="text-dim" style="font-size:12px">${dateStr}${t.lieu ? ' · ' + escapeHtml(t.lieu) : ''}</div>
12544        </div>`;
12545      });
12546    }
12547    if (tournoisPasses.length > 0) {
12548      html += `<div class="text-faint" style="font-size:11px; text-transform:uppercase; letter-spacing:1px; margin:10px 0 6px">Passés (${tournoisPasses.length})</div>`;
12549      tournoisPasses.slice(0, 5).forEach(t => {
12550        const dateStr = t.date_fin && t.date_fin !== t.date_debut ? `${formatDate(t.date_debut)} → ${formatDate(t.date_fin)}` : formatDate(t.date_debut);
12551        html += `<div class="card" style="padding:8px 12px; margin-bottom:4px; opacity:0.7">
12552          <div style="font-size:12px">${escapeHtml(t.nom)} <span class="text-faint">· ${dateStr}</span></div>
12553        </div>`;
12554      });
12555      if (tournoisPasses.length > 5) {
12556        html += `<div class="text-faint" style="font-size:11px; text-align:center">+ ${tournoisPasses.length - 5} autres tournois passés</div>`;
12557      }
12558    }
12559  }
12560  }
12561
12562  // Catégorie Matchs (les derniers matches ; l'import est remonté sous les tuiles)
12563  if (stats.derniers5.length > 0) {
12564    html += `<div class="category-title"><span class="ct-icon">🎾</span><span class="ct-label">Matchs</span></div>`;
12565    html += `<div class="sub-title">Derniers matches</div><div class="card">`;
12566    stats.derniers5.forEach(m => {
12567      html += `<div class="match-row clickable" data-ui-click="openModalMatch(${escapeHtml(encoderArgumentTexteUI(m.id))})" title="Cliquer pour modifier">
12568        <div class="match-result-row"><span class="match-opponent">vs ${escapeHtml(m.adversaire)} (${escapeHtml(m.adversaire_class || '?')})</span>${resultatMatchHtml(m)}</div>
12569        <div class="match-meta">${escapeHtml(m.tournoi || 'Match')} · ${formatDate(m.date)}</div>
12570      </div>`;
12571    });
12572    html += `</div>`;
12573  }
12574
12575  // Les notes privées appartiennent au suivi Académie. En Palmyre, les
12576  // observations du stage sont l'unique carnet de suivi afin d'éviter les doublons.
12577  if (isPoleEnv()) {
12578    html += `<div class="category-title" id="playerCoachNotes"><span class="ct-icon ct-icon-art"><img src="assets/v4/menu/coach_notes.webp" alt=""></span><span class="ct-label">Notes du coach</span></div>`;
12579    html += renderNotesCoachSection(j.id);
12580  }
12581  if (isAcademieEnv()) {
12582    html += `<div class="category-title"><span class="ct-icon">🎯</span><span class="ct-label">Axes de travail partagés</span></div>
12583      <div class="card" style="padding:14px;margin-bottom:8px">
12584        <div class="text-faint" style="font-size:12px;margin-bottom:8px">Visible par le joueur dans sa progression.</div>
12585        <textarea id="axeTexte" rows="2" placeholder="Ex. : travailler la deuxième balle et le retour croisé" style="width:100%"></textarea>
12586        <button class="btn btn-primary btn-full mt-1" data-ui-click="envoyerAxeTravail(${escapeHtml(encoderArgumentTexteUI(j.id))})">Envoyer au joueur</button>
12587        <div id="axesListe" style="margin-top:10px">${renderAxesListe(j.id)}</div>
12588      </div>`;
12589  }
12590
12591  // La gestion administrative appartient à l'Académie, jamais au suivi Palmyre.
12592  if (isPoleEnv()) {
12593  // Consentements RGPD + bouton génération charte PDF
12594  const isMineurDetail = computeAge(j) !== null && computeAge(j) < 18;
12595  html += `<div class="category-title"><span class="ct-icon ct-icon-art"><img src="assets/v4/menu/account.webp" alt=""></span><span class="ct-label">Administratif</span></div>
12596  <div class="sub-title">Consentements RGPD</div>
12597  <div class="card" style="padding:14px">
12598    <div style="font-size:13px; line-height:1.7">
12599      <div>${j.consentement_rgpd ? '<span class="success">✓</span>' : '<span class="danger">✗</span>'} <strong>RGPD :</strong> ${j.consentement_rgpd ? 'accepté' : 'NON accepté'}</div>
12600      ${isMineurDetail ? `<div>${j.consentement_parental ? '<span class="success">✓</span>' : '<span class="warning">○</span>'} <strong>Consentement parental :</strong> ${j.consentement_parental ? 'déclaré' : 'non coché'}</div>` : ''}
12601      ${j.consentement_date ? `<div class="text-faint" style="font-size:11px; margin-top:6px">Date du consentement : ${formatDate(j.consentement_date)}</div>` : ''}
12602    </div>
12603    <div style="margin-top:12px; text-align:center">
12604      <button class="btn btn-sm" data-ui-click="exportChartConsentementPDF(${escapeHtml(encoderArgumentTexteUI(j.id))})"><img class="btn-action-art" src="assets/v4/menu/consent_download.webp" alt="">Télécharger la charte de consentement (PDF)</button>
12605    </div>
12606  </div>`;
12607
12608  // Action archivage / réactivation (coach uniquement)
12609  if (isCoach()) {
12610    if (j.statut === 'archive') {
12611      html += `<div class="card" style="padding:14px; margin-top:8px; border-left:3px solid var(--text-dim)">
12612      <div class="text-dim" style="font-size:13px; margin-bottom:10px;display:flex;align-items:flex-start;gap:8px"><img class="inline-art-icon" src="assets/v4/menu/player_archive.webp" alt="" style="width:30px;height:30px">Ce joueur est <strong>archivé</strong> : il n'apparaît plus dans les listes actives, les groupes ni les alertes. Son historique est conservé.</div>
12613      <button class="btn btn-primary btn-sm btn-full" data-ui-click="reactivatePlayer(${escapeHtml(encoderArgumentTexteUI(j.id
12613))})"><img class="btn-action-art" src="assets/v4/menu/update.webp" alt="">Réactiver ce joueur</button>
12614      </div>`;
12615    } else {
12616      // Actions sur un joueur actif
12617      const aUnGroupe = j.groupe && cache.config[j.groupe];
12618      html += `<div class="mt-2" style="display:flex; flex-direction:column; gap:8px; align-items:center">`;
12619      if (aUnGroupe) {
12620        html += `<button class="btn btn-sm" data-ui-click="retirerDuGroupe(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="color:var(--text-dim)"><img class="btn-action-art" src="assets/v4/nav/group.webp" alt="">Retirer du groupe (le mettre en attente d'affectation)</button>`;
12621      }
12622      html += `<button class="btn btn-sm" data-ui-click="archivePlayer(${escapeHtml(encoderArgumentTexteUI(j.id))})" style="color:var(--text-dim)"><img class="btn-action-art" src="assets/v4/menu/player_archive.webp" alt="">Archiver (fin de saison / départ de l'académie)</button>
12623      </div>`;
12624    }
12625  }
12626  }
12627
12628  showModal(html);
12629  const modalBody = $('modalBody');
12630  modalBody?.querySelectorAll('[data-player-shortcut]').forEach(button => {
12631    button.addEventListener('click', () => {
12632      const action = button.dataset.playerShortcut;
12633      if (action === 'match') { closeModalDirect(); openModalMatch(); }
12634      else if (action === 'report') {
12635        closeModalDirect();
12636        if (isPoleEnv()) openModalBilan(null, j.id);
12637        else openModalBilanPalmyre(j.id);
12638      }
12639      else if (action === 'edit') openModalPlayer(j.id);
12640      else if (action === 'note') modalBody.querySelector('#playerCoachNotes')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
12641    });
12642  });
12643  // Rendu post-affichage
12644  setTimeout(() => {
12645    if (document.getElementById('pdChartClassement')) renderClassementChart('pdChartClassement', j);
12646    if (document.getElementById('pdChartActivite')) renderActiviteChart('pdChartActivite', j, [ppDebut, ppFin]);
12647    if (document.getElementById('pdChartCumul')) renderCumulChart('pdChartCumul', j, [ppDebut, ppFin]);
12648    if (document.getElementById('pdChartForme')) renderFormeChart('pdChartForme', j);
12649    if (document.getElementById('pdChartRadar')) renderRadarBilans('pdChartRadar', j);
12650  }, 50);
12651}
12652
12653
12654// ============================================================
12655// NOTES COACH — Observations libres sur un joueur (coachs uniquement)
12656// ============================================================
12657
12658function renderNotesCoach() {
12659  if (!isCoach()) return '';
12660  const joueurs = joueursActifsDansEnvCourant().slice()
12661    .sort((a, b) => `${a.nom || ''} ${a.prenom || ''}`.localeCompare(`${b.nom || ''} ${b.prenom || ''}`));
12662  const compteur = joueurId => listeSure(cache.notesCoach).filter(note => note.joueur_id === joueurId).length;
12663  return `${renderUxScreenHeading('Suivi individuel', 'Notes coach')}
12664    <div class="card mb-2" style="padding:12px 14px">
12665      <div style="font-weight:700">Choisir un joueur</div>
12666      <div class="text-faint" style="font-size:12px;margin-top:3px">Les notes restent privées entre coachs et sont reprises en lecture dans les bilans.</div>
12667    </div>
12668    <div class="players-list">${joueurs.map(joueur => {
12669      const nb = compteur(joueur.id);
12670      return `<button type="button" class="player-row card notes-player-row" data-ui-click="openNotePlayer(${escapeHtml(encoderArgumentTexteUI(joueur.id))})">
12671        <div class="player-info"><div class="player-name">${escapeHtml(joueur.prenom)} ${escapeHtml(joueur.nom)}</div>
12672        <div class="player-meta">${escapeHtml(joueur.classement || 'NC')} · ${nb} note${nb > 1 ? 's' : ''}</div></div>
12673        <span class="notes-player-arrow" aria-hidden="true">→</span>
12674      </button>`;
12675    }).join('') || '<div class="empty">Aucun joueur actif.</div>'}</div>`;
12676}
12677
12678function openNotePlayer(joueurId) {
12679  openPlayerDetail(joueurId);
12680  setTimeout(() => document.getElementById('playerCoachNotes')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80);
12681}
12682
12683function renderNotesCoachSection(joueurId) {
12684  // Visible uniquement aux coachs.
12685  if (!isCoach()) return '';
12686
12687  const notes = (listeSure(cache.notesCoach)).filter(n => n.joueur_id === joueurId);
12688
12689  let html = `<div class="text-faint" style="font-size:12px; margin:-4px 0 10px"><img class="private-note-mark" src="assets/v4/menu/account.webp" alt="">Ces notes sont visibles uniquement par les coachs, jamais par le joueur ni les parents.</div>`;
12690
12691  // Zone de saisie nouvelle note
12692  html += `<div class="card" style="padding:14px; margin-bottom:8px">
12693    <textarea id="newNoteContent_${joueurId}" placeholder="Ajouter une observation (ex. vu blessé à l'épaule mardi, parents inquiets sur le rythme scolaire, très motivé en ce moment...)"
12694      style="width:100%; min-height:60px; padding:8px 10px; background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px; resize:vertical; font-family:inherit"></textarea>
12695    <div style="text-align:right; margin-top:8px">
12696      <button class="btn btn-primary btn-sm" data-ui-click="addNoteCoach(${escapeHtml(encoderArgumentTexteUI(joueurId))})">+ Ajouter la note</button>
12697    </div>
12698  </div>`;
12699
12700  if (notes.length === 0) {
12701    html += `<div class="card text-faint" style="font-size:12px; text-align:center; padding:14px">
12702      Aucune note pour ce joueur pour le moment.
12703    </div>`;
12704    return html;
12705  }
12706
12707  // Liste des notes existantes (chronologique inverse — plus récentes en haut)
12708  notes.forEach(n => {
12709    const isMe = n.auteur_id === session.user.id;
12710    const auteurLabel = isMe ? (profile.prenom || 'Toi') : 'Autre coach';
12711    const dateStr = formatDateTime(n.created_at);
12712    html += `<div class="card" style="padding:12px; margin-bottom:6px; border-left:3px solid var(--orange)">
12713      <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px; margin-bottom:6px">
12714        <div class="text-faint" style="font-size:11px">
12715          <strong style="color:var(--text)">${escapeHtml(auteurLabel)}</strong> · ${dateStr}
12716        </div>
12717        ${isMe ? `<button class="btn btn-sm" data-ui-click="deleteNoteCoach(${escapeHtml(encoderArgumentTexteUI(n.id))}, ${escapeHtml(encoderArgumentTexteUI(joueurId))})" title="Supprimer cette note" style="padding:2px 8px; font-size:11px">×</button>` : ''}
12718      </div>
12719      <div style="font-size:13px; line-height:1.5; white-space:pre-wrap">${escapeHtml(n.contenu)}</div>
12720    </div>`;
12721  });
12722
12723  return html;
12724}
12725
12726// Format date+heure pour les notes
12727function formatDateTime(iso) {
12728  return TennisFormatters.formatDateTime(iso);
12729}
12730
12731async function addNoteCoach(joueurId) {
12732  const textarea = document.getElementById('newNoteContent_' + joueurId);
12733  if (!textarea) return;
12734  const contenu = textarea.value.trim();
12735  if (!contenu) {
12736    toast('La note ne peut pas être vide', true);
12737    return;
12738  }
12739  if (contenu.length > 5000) {
12740    toast('Note trop longue (max 5000 caractères)', true);
12741    return;
12742  }
12743
12744  const { error } = await db.from('notes_joueur').insert({
12745    joueur_id: joueurId,
12746    auteur_id: session.user.id,
12747    contenu
12748  });
12749
12750  if (error) {
12751    toastErreur(error);
12752    return;
12753  }
12754
12755  // Recharger les notes + rafraîchir l'affichage de la fiche
12756  const { data: notesData } = await db.from('notes_joueur').select(COLONNES_NOTE_COACH).order('created_at', { ascending: false });
12757  cache.notesCoach = listeSure(notesData);
12758  toast('Note ajoutée');
12759  closeModal();
12760  openPlayerDetail(joueurId);
12761}
12762
12763async function deleteNoteCoach(noteId, joueurId) {
12764  if (!(await demanderConfirmation('Supprimer cette note ?'))) return;
12765
12766  const { error } = await db.from('notes_joueur').delete().eq('id', noteId);
12767  if (error) {
12768    toastErreur(error);
12769    return;
12770  }
12771
12772  const { data: notesData } = await db.from('notes_joueur').select(COLONNES_NOTE_COACH).order('created_at', { ascending: false });
12773  cache.notesCoach = listeSure(notesData);
12774  toast('Note supprimée');
12775  closeModal();
12776  openPlayerDetail(joueurId);
12777}
12778
12779
12780// ============================================================
12781// MODAL: ENTRAÎNEMENT (déclaration par le joueur)
12782// ============================================================
12783const ENTRAINEMENT_TYPES = {
12784  seul:        { label: 'Seul', icone: '🎯' },
12785  partenaire:  { label: 'Partenaire', icone: '🤝' }
12786};
12787
12788// Section "Assiduité" réutilisable (coach + joueur). Suit la période via bornes [debut, fin].
12789// Étape 1 : ligne "Entraînements" (nb déclaré). La ligne "Présence aux cours" viendra en étape 2.
12790function renderAssiduiteSection(joueurId, bornes, periodeLabel) {
12791  const [debut, fin] = listeSure(bornes);
12792  const entrainements = entrainementsDuJoueur(joueurId).filter(e => {
12793    if (debut && fin) return TennisDomainRules.dateDansIntervalle(e.date, debut, fin);
12794    return true;
12795  });
12796  const statsEntrainements = TennisDomainRules.statistiquesEntrainements(entrainements);
12797  const nbEntrainements = statsEntrainements.total;
12798  // Détail par type (pour info, en petit)
12799  const detailType = Object.keys(statsEntrainements.parType)
12800    .map(t => `${(ENTRAINEMENT_TYPES[t] || ENTRAINEMENT_TYPES.seul).icone} ${statsEntrainements.parType[t]}`)
12801    .join('  ');
12802
12803  // Présence aux cours sur la période : on regarde les séances passées/du jour
12804  // où le joueur était attendu (présence existante) et dont le statut est tranché.
12805  const afficherPresenceCours = isPoleEnv();
12806  const todayStr = dateLocaleISO(new Date());
12807  const idSeancesPeriode = new Set(
12808    (afficherPresenceCours ? listeSure(cache.seancesCours) : [])
12809      .filter(s => {
12810        if (debut && fin) {
12811          const finEffective = fin < todayStr ? fin : todayStr;
12812          return TennisDomainRules.dateDansIntervalle(s.date, debut, finEffective);
12813        }
12814        return TennisDomainRules.isoDate(s.date) <= todayStr; // on ne compte pas les cours futurs
12815      })
12816      .map(s => s.id)
12817  );
12818  const mesPresences = presencesDuJoueur(joueurId)
12819    .filter(p => idSeancesPeriode.has(p.seance_id));
12820  const statsPresence = TennisDomainRules.statistiquesPresences(mesPresences);
12821  const nbCoursAttendus = statsPresence.total;
12822  const nbPresents = statsPresence.presents;
12823  const nbRenseignes = statsPresence.renseignes;
12824  const tauxPresence = statsPresence.tauxPresence;
12825
12826  // La section n'apparaît que s'il y a au moins un entraînement OU un cours attendu sur la période.
12827  if (nbEntrainements === 0 && nbCoursAttendus === 0) return '';
12828
12829  let html = `<div class="section-title">Assiduité <span class="text-faint" style="font-weight:400; text-transform:none; letter-spacing:0; font-size:12px">— ${periodeLabel}</span></div>`;
12830  html += `<div class="card" style="padding:10px 14px">`;
12831  // Ligne entraînements
12832  if (nbEntrainements > 0) {
12833    html += `<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; padding:4px 0; font-size:13px">
12834        <span>🎾 Entraînements déclarés <strong>${nbEntrainements}</strong></span>
12835        ${detailType ? `<span class="text-faint" style="font-size:11px; white-space:nowrap">${detailType}</span>` : ''}
12836      </div>`;
12837  }
12838  // Ligne présence aux cours
12839  if (nbCoursAttendus > 0) {
12840    const detail = tauxPresence !== null
12841      ? `<span class="text-faint" style="font-size:11px; white-space:nowrap"><strong class="${tauxPresence >= 80 ? 'success' : tauxPresence < 50 ? 'danger' : ''}">${tauxPresence}%</strong> · ${nbPresents}/${nbRenseignes} présent${nbPresents > 1 ? 's' : ''}</span>`
12842      : `<span class="text-faint" style="font-size:11px">non renseigné</span>`;
12843    html += `<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; padding:4px 0; font-size:13px">
12844        <span>✅ Présence aux cours <strong>${nbCoursAttendus}</strong></span>
12845        ${detail}
12846      </div>`;
12847  }
12848  html += `</div>`;
12849  return html;
12850}
12851
12852function openModalEntrainement(entrainementId) {
12853  const e = entrainementId ? entrainementById(entrainementId) : null;
12854  const today = dateLocaleISO(new Date());
12855  const pills = Object.keys(ENTRAINEMENT_TYPES).map(k => {
12856    const meta = ENTRAINEMENT_TYPES[k];
12857    const active = e && e.type === k ? 'active' : (!e && k === 'seul' ? 'active' : '');
12858    return `<span class="pill ${active}" data-value="${k}" data-ui-click="selectPill(this, 'pillEntrainementType')">${meta.icone} ${meta.label}</span>`;
12859  }).join('');
12860
12861  const btnLabel = e ? 'Enregistrer' : 'Déclarer la séance';
12862  const deleteBtn = e ? `<div class="mt-1"><button class="btn btn-danger btn-full" data-ui-click="supprimerEntrainement(${escapeHtml(encoderArgumentTexteUI(e.id))})">Supprimer</button></div>` : '';
12863  // Heure pré-remplie : celle enregistrée (édition) sinon l'heure courante (nouvelle séance)
12864  const now = new Date();
12865  const heureDefaut = (e && e.heure_debut) ? e.heure_debut
12866    : `${String(now.getHours()).padStart(2, '0')}
12866:${String(now.getMinutes()).padStart(2, '0')}`;
12867
12868  const html = `<div class="modal-header"><h3 class="modal-title">${e ? 'Modifier la séance libre' : 'Déclarer une séance libre'}</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
12869    <div class="modal-body">
12870      <div class="form-row"><label class="form-label">Date</label><input type="date" id="entrainementDate" value="${e ? e.date : today}" max="${today}"></div>
12871      <div class="form-row"><label class="form-label">Type de séance</label>
12872        <div class="pill-group" id="pillEntrainementType">${pills}</div>
12873      </div>
12874      <div class="form-row"><label class="form-label">Durée</label>
12875        <select id="entrainementDuree">
12876          <option value="30" ${e && e.duree_min === 30 ? 'selected' : ''}>30 min</option>
12877          <option value="60" ${e && e.duree_min === 60 ? 'selected' : (!e ? 'selected' : '')}>1h</option>
12878          <option value="90" ${e && e.duree_min === 90 ? 'selected' : ''}>1h30</option>
12879          <option value="120" ${e && e.duree_min === 120 ? 'selected' : ''}>2h</option>
12880        </select>
12881      </div>
12882      <div class="form-row"><label class="form-label">Heure de début</label><input type="time" id="entrainementHeure" value="${heureDefaut}"></div>
12883      <div class="form-row"><label class="form-label">Contenu de la séance <span class="text-faint" style="font-weight:400; text-transform:none">(optionnel)</span></label>
12884        <textarea id="entrainementContenu" rows="3" placeholder="Ex. : 30 min de service, panier coup droit, jeu au filet…">${e ? escapeHtml(e.contenu || '') : ''}</textarea>
12885      </div>
12886    <div class="mt-2"><button class="btn btn-primary btn-full" data-ui-click="saveEntrainement(${e ? escapeHtml(encoderArgumentTexteUI(e.id)) : 'null'})">${btnLabel}</button></div>
12887      ${deleteBtn}
12888    </div>`;
12889  showModal(html);
12890}
12891
12892async function saveEntrainement(entrainementId) {
12893  const dateEl = document.getElementById('entrainementDate');
12894  const typePill = document.querySelector('#pillEntrainementType .pill.active');
12895  const contenuEl = document.getElementById('entrainementContenu');
12896  if (!dateEl || !dateEl.value) { toast('Indique une date', true); return; }
12897  if (!typePill) { toast('Choisis un type de séance', true); return; }
12898
12899  // Le joueur déclare pour lui-même ; le coach (s'il utilise ce modal) n'est pas concerné.
12900  const joueurId = profile && profile.joueur_id ? profile.joueur_id : null;
12901  if (!joueurId) { toast('Action réservée aux joueurs', true); return; }
12902  if (joueurEnAttente()) { toast('Ton inscription est en attente de validation par le coach.', true); return; }
12903
12904  const obj = {
12905    joueur_id: joueurId,
12906    date: dateEl.value,
12907    type: typePill.dataset.value,
12908    contenu: contenuEl ? contenuEl.value.trim() : '',
12909    duree_min: (() => { const d = document.getElementById('entrainementDuree'); return d ? parseInt(d.value) || null : null; })(),
12910    heure_debut: (() => { const h = document.getElementById('entrainementHeure'); return h && h.value ? h.value : null; })()
12911  };
12912
12913  let error, nouvelId = entrainementId;
12914  const execution = await executerAvecVerrou($('modalBody'), async () => {
12915    if (entrainementId) return updateConfirmee(db.from('entrainements').update(obj).eq('id', entrainementId));
12916    return db.from('entrainements').insert(obj).select('id').single();
12917  });
12918  if (execution.ignoree) return;
12919  error = execution.valeur.error;
12920  if (!entrainementId) nouvelId = execution.valeur.data ? execution.valeur.data.id : null;
12921  if (error) { toastErreur(error); return; }
12922
12923  // Notification au coach uniquement à la création (pas en modification)
12924  if (!entrainementId && nouvelId) {
12925    const joueur = joueurById(joueurId);
12926    if (joueur) await notifierEntrainementCoach(obj, joueur, nouvelId);
12927  }
12928
12929  closeModal();
12930  toast(entrainementId ? 'Entraînement modifié' : 'Entraînement déclaré 🎾');
12931  // Niveau AVANT prise en compte (le cache reflète encore l'état précédent)
12932  const etatAvantEnt = (!isCoach() && joueurId === profile.joueur_id)
12933    ? (() => { const x = calculerXP(joueurId).xp; return { niveau: niveauDepuisXP(x).niveau, xp: x }; })()
12934    : null;
12935  await reloadTables(['entrainements']);
12936  // Détecter et enregistrer d'éventuels nouveaux badges (assiduité, etc.) + level-up + gain XP
12937  await synchroniserBadgesEtCelebrer(joueurId, etatAvantEnt);
12938  render();
12939}
12940
12941async function supprimerEntrainement(entrainementId) {
12942  if (!(await demanderConfirmation('Supprimer cet entraînement ?'))) return;
12943  const { error } = await db.from('entrainements').delete().eq('id', entrainementId);
12944  if (error) { toastErreur(error); return; }
12945  closeModal();
12946  toast('Entraînement supprimé');
12947  await reloadTables(['entrainements']);
12948  render();
12949}
12950
12951// Notifie les coachs (in-app + email) qu'un entraînement a été déclaré.
12952async function notifierEntrainementCoach(entrainement, joueur, entrainementId) {
12953  if (!joueur || joueur.groupe === 'tremplin') return;
12954  try {
12955    const { data, error } = await db.rpc('notifier_entrainement_coach', {
12956      p_joueur_id: joueur.id,
12957      p_type: entrainement.type || 'seul',
12958      p_date: entrainement.date,
12959      p_entrainement_id: entrainementId
12960    });
12961    if (error) { journal.warn('[entrainement coach] notif error:', error); return; }
12962    await sendEmailEvent('entrainement_coach', entrainementId);
12963  } catch (e) {
12964    journal.warn('[entrainement coach] exception:', e);
12965  }
12966}
12967
12968
12969// ============================================================
12970// MODAL: MATCH
12971// ============================================================
12972function effacerBrouillonMatch() {
12973  gestionnaireBrouillons.effacerMatch(session?.user?.id || '');
12974}
12975
12976function contenuBrouillonMatch(form) {
12977  if (!form || form.dataset.matchId || form.dataset.restoring === '1') return;
12978  const fields = {};
12979  Array.from(form.elements).forEach(element => {
12980    if (!element.name || element.type === 'submit' || element.type === 'button') return;
12981    fields[element.name] = element.type === 'checkbox' ? element.checked : element.value;
12982  });
12983  const actif = id => document.querySelector(`#${id} .pill.active`)?.dataset.value ?? null;
12984  return {
12985    fields,
12986    pills: {
12987      surface: actif('pillSurface'),
12988      format: actif('pillFormat'),
12989      resultat: actif('pillVictoire'),
12990      ressenti: actif('pillRessenti')
12991    }
12992  };
12993}
12994
12995function sauvegarderBrouillonMatch(form) {
12996  const contenu = contenuBrouillonMatch(form);
12997  if (contenu) gestionnaireBrouillons.enregistrerMatch(session?.user?.id || '', contenu);
12998}
12999
13000function planifierBrouillonMatch(form) {
13001  gestionnaireBrouillons.planifierMatch(session?.user?.id || '', () => contenuBrouillonMatch(form));
13002}
13003
13004function restaurerBrouillonMatch(form) {
13005  if (!form || form.dataset.matchId) return false;
13006  const brouillon = gestionnaireBrouillons.lireMatch(session?.user?.id || '');
13007  if (!brouillon) return false;
13008  form.dataset.restoring = '1';
13009  Object.entries(brouillon.fields || {}).forEach(([name, value]) => {
13010    const element = form.elements.namedItem(name);
13011    if (!element || element instanceof RadioNodeList) return;
13012    if (element.type === 'checkbox') element.checked = !!value;
13013    else if (element.tagName !== 'SELECT' || Array.from(element.options).some(option => option.value === String(value))) {
13014      element.value = value ?? '';
13015    }
13016  });
13017  const activer = (id, value, action) => {
13018    if (value == null) return;
13019    const bouton = document.querySelector(`#${id} .pill[data-value="${CSS.escape(String(value))}"]`);
13020    if (bouton) action(bouton);
13021  };
13022  activer('pillSurface', brouillon.pills?.surface, bouton => selectPillToggle(bouton, 'pillSurface'));
13023  activer('pillFormat', brouillon.pills?.format, bouton => setFormatMatch(bouton.dataset.value));
13024  activer('pillVictoire', brouillon.pills?.resultat, bouton => selectPill(bouton, 'pillVictoire'));
13025  activer('pillRessenti', brouillon.pills?.ressenti, bouton => selectPill(bouton, 'pillRessenti'));
13026  delete form.dataset.restoring;
13027  majCasePalmyre(form.date?.value || '');
13028  syncScore();
13029  majNotesPlaceholder();
13030  return true;
13031}
13032
13033function openModalMatch(matchId) {
13034  const m = matchId ? (cache.matchesSaison || cache.matches).find(x => x.id === matchId) : null;
13035  const isEdit = !!m;
13036  if (isEdit) enregistrerDetailNavigation('match', matchId);
13037
13038  // Joueur peut seulement saisir pour lui-même
13039  // Coach peut choisir le joueur (en Palmyre : uniquement les participants à l'édition)
13040  let joueursSelectables;
13041  if (isCoach()) {
13042    joueursSelectables = TennisDomainRules.joueursSelectablesPourMatch({
13043      joueursTous: cache.joueurs,
13044      joueursEnvironnement: joueursActifsDansEnvCourant(), joueurIdExistant: m?.joueur_id || null
13045    });
13046  } else {
13047    joueursSelectables = cache.joueurs.filter(j => j.id === profile.joueur_id);
13048  }
13049  if (joueursSelectables.length === 0) {
13050    toast('Aucun joueur disponible', true);
13051    return;
13052  }
13053
13054  const preselect = m ? m.joueur_id : (joueursSelectables.length === 1 ? joueursSelectables[0].id : '');
13055  const choixTournoiInitial = TennisTournamentsTenup.choixTournoisMatch({
13056    tournaments: cache.tournois,
13057    registrations: cache.inscriptionsTournois,
13058    player: joueurById(preselect),
13059    matchDate: m ? m.date : dateLocaleISO(new Date()),
13060    compatible: (tournoi, joueur) => tournoiCorrespondAuJoueur(tournoi, joueur, { sexe:true, age:true, classement:true })
13061  });
13062  const tournoiLie = m?.tournament_id ? cache.tournois.find(t => t.id === m.tournament_id) : null;
13063  const selectionTournoiInitiale = m?.tournament_id || choixTournoiInitial.preselectionId || '';
13064
13065  // --- Case "Match de la Palmyre" ---
13066  // Affichée si une édition existe ET que le joueur présélectionné y participe.
13067  // En édition de match, on respecte l'état existant (evenement_id déjà posé).
13068  // Côté coach saisissant DEPUIS l'environnement Palmyre, l'estampillage reste
13069  // automatique (géré dans saveMatch) : la case sert surtout au joueur et au
13070  // coach quand il saisit depuis l'académie.
13071  const editionPalmyre = (listeSure(cache.evenements))[0] || null; // la plus récente (tri date_debut desc)
13072  const joueurPreselect = preselect ? joueurById(preselect) : null;
13073  const joueurParticipe = joueurPreselect ? !!joueurPreselect.participe_palmyre : false;
13074  const dateMatch = m ? m.date : dateLocaleISO(new Date());
13075  const dateDansFenetre = (ed, d) => !!ed && TennisDomainRules.dateDansIntervalle(d, ed.date_debut, ed.date_fin);
13076  // Affichage de la case "Match de la Palmyre" :
13077  // - Coach : on l'affiche dès qu'une édition existe et que le joueur participe (il peut
13078  //   rattacher/corriger a posteriori), sauf en environnement Palmyre où c'est automatique.
13079  // - Joueur : on ne l'affiche QUE si la date du match tombe dans la période du stage
13080  //   (en création : la date du jour ; en édition : la date du match). Hors de cette
13081  //   fenêtre, la case n'a pas de sens et est masquée.
13082  const baseVisible = !!editionPalmyre && joueurParticipe && isPoleEnv();
13083  const montrerCasePalmyre = baseVisible && (
13084    isCoach() ? true : (dateDansFenetre(editionPalmyre, dateMatch) || (isEdit && !!(m && m.evenement_id)))
13085  );
13086  const palmyreCoche = isEdit
13087    ? !!(m && m.evenement_id)
13088    : dateDansFenetre(editionPalmyre, dateMatch);
13089
13090  const html = `<div class="modal-header">${isEdit ? boutonRetourDetail('Retour') : ''}<h3 class="modal-title">${isEdit ? 'Modifier match' : 'Nouveau match'}</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
13091    <form id="matchForm" data-match-id="${escapeHtml(matchId || '')}">
13092      <div id="matchFormErrors" class="alert danger" role="alert" tabindex="-1" hidden></div>
13093      ${joueursSelectables.length > 1 ? `<div class="form-row"><label class="form-label">Joueur *</label><select name="joueur_id" required>
13094        <option value="">Sélectionner...</option>
13095        ${joueursSelectables.map(j => `<option value="${j.id}" ${preselect === j.id ? 'selected' : ''}>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</option>`).join('')}
13096      </select></div>` : `<input type="hidden" name="joueur_id" value="${preselect}">`}
13097      <div class="grid-2">
13098        <div class="form-row"><label class="form-label">Date *</label><input type="date" name="date" required max="${dateLocaleISO(new Date())}" value="${m ? m.date : dateLocaleISO(new Date())}" data-match-change="date"></div>
13099        <div class="form-row"><label class="form-label">Type</label><select name="type">
13100          <option value="Tournoi" ${m && m.type==='Tournoi'?'selected':''}>Tournoi</option>
13101          <option value="Championnat" ${m && m.type==='Championnat'?'selected':''}>Championnat</option>
13102          <option value="Match libre" ${m && m.type==='Match libre'?'selected':''}>Match libre — homologué FFT</option>
13103        </select></div>
13104      </div>
13105      <div class="form-row"><label class="form-label">Tournoi</label><div id="matchTournamentSelector">${TennisTournamentsTenup.selecteurMatchHtml(choixTournoiInitial, selectionTournoiInitiale)}</div>
13106        <input type="text" id="matchTournamentFallback" name="tournoi" value="${m ? escapeHtml(m.tournoi || tournoiLie?.nom || '') : ''}" placeholder="Nom du tournoi ou lieu" style="display:${m && !m.tournament_id && m.tournoi ? '' : 'none'};margin-top:8px">
13107        <div class="text-faint" style="font-size:11px;margin-top:5px">Les inscriptions sont proposées en premier. Un tournoi récent peut être saisi jusqu’à 45 jours après sa fin.</div>
13108      </div>
13109      <div class="form-row"><label class="form-label">Surface <span class="text-faint" style="font-weight:400; text-transform:none">(optionnel)</span></label>
13110        <div class="pill-group" id="pillSurface">
13111          <button type="button" class="pill match-form-pill ${m && m.surface==='terre' ? 'active' : ''}" data-value="terre" data-match-action="surface">${matchChoiceIcon('clay')} Terre battue</button>
13112          <button type="button" class="pill match-form-pill ${m && m.surface==='dur' ? 'active' : ''}" data-value="dur" data-match-action="surface">${matchChoiceIcon('hard')} Dur</button>
13113          <button type="button" class="pill match-form-pill ${m && m.surface==='indoor' ? 'active' : ''}" data-value="indoor" data-match-action="surface">${matchChoiceIcon('indoor')} Indoor</button>
13114        </div>
13115      </div>
13116      <div class="form-row"><label class="form-label">Type de match</label>
13117        <div class="pill-group" id="pillFormat">
13118          <button type="button" class="pill match-form-pill ${!m || m.format !== 'double' ? 'active' : ''}" data-value="simple" data-match-action="format">${matchChoiceIcon('single')} Simple</button>
13119          <button type="button" class="pill match-form-pill ${m && m.format === 'double' ? 'active' : ''}" data-value="double" data-match-action="format">${matchChoiceIcon('double')} Double</button>
13120        </div>
13121      </div>
13122      <div class="grid-2">
13123        <div class="form-row"><label class="form-label" id="lblAdversaire1">Adversaire *</label><input type="text" name="adversaire" required value="${m ? escapeHtml((m.adversaire || '').split(' / ')[0]) : ''}"></div>
13124        <div class="form-row"><label class="form-label">Classt adv.</label><select name="adversaire_class">
13125          ${CLASSEMENTS.map(c => `<option value="${c}" ${m && m.adversaire_class===c?'selected':''}>${c}</option>`).join('')}
13126        </select></div>
13127      </div>
13128      <div class="grid-2" id="blocAdversaire2" style="display:${m && m.format === 'double' ? 'grid' : 'none'}">
13129        <div class="form-row"><label class="form-label">Partenaire adverse</label><input type="text" name="adversaire2_nom" value="${m && m.adversaire2_nom ? escapeHtml(m.adversaire2_nom) : ''}" placeholder="Nom Prénom"></div>
13130        <div class="form-row"><label class="form-label">Classt partenaire</label><select name="adversaire2_class">
13131          <option value="">—</option>
13132          ${CLASSEMENTS.map(c => `<option value="${c}" ${m && m.adversaire2_class===c?'selected':''}>${c}</option>`).join('')}
13133        </select></div>
13134      </div>
13135      <div class="form-row"><label class="form-label">Résultat *</label>
13136        <div class="pill-group" id="pillVictoire">
13137          <button type="button" class="pill match-form-pill success ${!m || m.victoire ? 'active' : ''}" data-value="true" data-match-action="resultat">${matchChoiceIcon('win')} Victoire</button>
13138          <button type="button" class="pill match-form-pill danger ${m && !m.victoire ? 'active' : ''}" data-value="false" data-match-action="resultat">${matchChoiceIcon('loss')} Défaite</button>
13139        </div>
13140      </div>
13141      <div class="form-row"><label class="form-label">Score</label>${scoreWidgetHtml(m)}</div>
13142      <div class="form-row" style="display:flex; align-items:center; gap:10px; background:var(--bg-input); padding:10px 12px; border-radius:8px">
13143        <input type="checkbox" name="walkover" id="matchWalkover" ${m && m.walkover ? 'checked' : ''} style="width:18px; height:18px; flex-shrink:0">
13144        ${matchChoiceIcon('wo')}<label for="matchWalkover" style="margin:0; font-size:13px; cursor:pointer">Forfait (WO) : l'adversaire ne s'est pas présenté<div class="text-faint" style="font-size:11px">Le match n'est pas compté dans les statistiques (ni victoire, ni match joué), mais reste dans l'historique.</div></label>
13145      </div>
13146      <div class="form-row"><label class="form-label">Ressenti</label>
13147        <div class="pill-group" id="pillRessenti">
13148          ${[
13149            [1,'Très dur','#ef6464'],[2,'Difficile','#ff9b54'],[3,'Moyen','#f1d84a'],[4,'Bon','#79dc78'],[5,'Excellent','#35d49a']
13150          ].map(([n,label,color]) => `<button type="button" class="pill ${m && m.ressenti===n?'active':''}" style="--mood-color:${color}" data-value="${n}" data-match-action="ressenti" title="${label} — ${n} sur 5" aria-label="Ressenti ${label}, ${n} sur 5"><span class="feeling-number">${n}</span><span class="feeling-label">${label}</span></button>`).join('')}
13151        </div>
13152      </div>
13153      <div class="form-row"><label class="form-label match-field-label">${matchChoiceIcon('positive')} Point positif</label><textarea name="point_positif" id="matchPositif" rows="2" placeholder="Ce qui a bien marché, ce dont tu es fier...">${m ? escapeHtml(m.point_positif || '') : ''}</textarea></div>
13154      <div class="form-row"><label class="form-label match-field-label">${matchChoiceIcon('improve')} Point à améliorer</label><textarea name="point_negatif" id="matchNegatif" rows="2" placeholder="Ce que tu veux travailler pour la prochaine fois...">${m ? escapeHtml(m.point_negatif || '') : ''}</textarea></div>
13155      ${baseVisible ? `<div class="form-row" id="casePalmyreWrap" data-debut="${escapeHtml(editionPalmyre.date_debut || '')}" data-fin="${escapeHtml(editionPalmyre.date_fin || '')}" data-coach="${isCoach() ? '1' : '0'}" data-edit-rattache="${isEdit && m && m.evenement_id ? '1' : '0'}" style="display:${montrerCasePalmyre ? 'flex' : 'none'}; align-items:center; gap:10px; background:var(--orange-bg, rgba(243,146,0,0.10)); padding:10px 12px; border-radius:8px">
13156        <input type="checkbox" name="est_palmyre" id="estPalmyre" ${palmyreCoche ? 'checked' : ''} style="width:18px; height:18px; flex-shrink:0">
13157        <label for="estPalmyre" style="margin:0; cursor:pointer; font-size:13px">🏖️ Match joué à la <strong>${escapeHtml(editionPalmyre.nom)}</strong></label>
13158      </div>` : ''}
13159      <div class="flex gap-1 mt-2">
13160        ${isEdit ? `<button type="button" class="btn btn-danger" data-match-action="delete" data-match-delete-id="${escapeHtml(m.id)}">Supprimer</button>` : ''}
13161        <button type="button" class="btn flex-1" data-match-action="cancel">Annuler</button>
13162        <button type="submit" class="btn btn-primary flex-1">${isEdit ? 'Enregistrer' : 'Ajouter'}</button>
13163      </div>
13164    </form>`;
13165  showModal(html);
13166  initialiserInteractionsFormulaireMatch();
13167  synchroniserTournoiMatch(document.getElementById('matchForm'));
13168}
13169
13170function initialiserInteractionsFormulaireMatch() {
13171  const form = document.getElementById('matchForm');
13172  if (!form) return;
13173
13174  form.querySelectorAll('.pill').forEach(pill => {
13175    pill.setAttribute('aria-pressed', pill.classList.contains('active') ? 'true' : 'false');
13176  });
13177
13178  form.addEventListener('submit', event =>
13178 saveMatch(event, form.dataset.matchId || ''));
13179  form.addEventListener('click', event => {
13180    const cible = event.target.closest('[data-match-action]');
13181    if (!cible || !form.contains(cible)) return;
13182    const action = cible.dataset.matchAction;
13183    if (action === 'surface') selectPillToggle(cible, 'pillSurface');
13184    else if (action === 'format') setFormatMatch(cible.dataset.value);
13185    else if (action === 'resultat') {
13186      selectPill(cible, 'pillVictoire');
13187      majNotesPlaceholder();
13188    } else if (action === 'ressenti') selectPill(cible, 'pillRessenti');
13189    else if (action === 'cancel') { effacerBrouillonMatch(); closeModal(); }
13190    else if (action === 'delete') deleteMatch(cible.dataset.matchDeleteId);
13191    if (!form.dataset.matchId) planifierBrouillonMatch(form);
13192  });
13193  form.addEventListener('change', event => {
13194    if (event.target.matches('[data-match-change="date"]')) { majCasePalmyre(event.target.value); actualiserSelecteurTournoiMatch(form); }
13195    if (event.target.matches('select[name="joueur_id"]')) actualiserSelecteurTournoiMatch(form);
13196    if (event.target.matches('[data-match-change="tournament"]')) synchroniserTournoiMatch(form);
13197    if (event.target.matches('[data-match-score]')) syncScore();
13198    if (!form.dataset.matchId) sauvegarderBrouillonMatch(form);
13199  });
13200  form.addEventListener('input', event => {
13201    if (event.target.matches('[data-match-score]')) syncScore();
13202    const erreurs = document.getElementById('matchFormErrors');
13203    if (erreurs && !erreurs.hidden) erreurs.hidden = true;
13204    if (!form.dataset.matchId) planifierBrouillonMatch(form);
13205  });
13206  if (restaurerBrouillonMatch(form)) toast('Brouillon de match restauré');
13207}
13208
13209function actualiserSelecteurTournoiMatch(form) {
13210  const joueur = joueurById(form?.joueur_id?.value);
13211  const choix = TennisTournamentsTenup.choixTournoisMatch({ tournaments:cache.tournois, registrations:cache.inscriptionsTournois, player:joueur, matchDate:form?.date?.value, compatible:(tournoi,j)=>tournoiCorrespondAuJoueur(tournoi,j,{sexe:true,age:true,classement:true}) });
13212  const slot = document.getElementById('matchTournamentSelector');
13213  const ancienne = form?.tournament_id?.value || '';
13214  if (slot) slot.innerHTML = TennisTournamentsTenup.selecteurMatchHtml(choix, ancienne || choix.preselectionId);
13215  synchroniserTournoiMatch(form);
13216}
13217
13218function synchroniserTournoiMatch(form) {
13219  const id = form?.tournament_id?.value || '';
13220  const fallback = document.getElementById('matchTournamentFallback');
13221  const hidden = form?.tenup_tournament_id;
13222  const tournoi = cache.tournois.find(t => t.id === id);
13223  if (fallback) {
13224    fallback.style.display = id === '__missing__' ? '' : 'none';
13225    if (tournoi) fallback.value = tournoi.nom || '';
13226    else if (!id && fallback.style.display === 'none') fallback.value = '';
13227  }
13228  if (hidden) hidden.value = tournoi?.tenup_id || tournoi?.tenup_code || '';
13229}
13230
13231// Recalcule la visibilité de la case "Match de la Palmyre" quand la date du match change.
13232// - Coach : la case reste toujours visible (il peut rattacher/corriger a posteriori).
13233// - Joueur : visible seulement si la date tombe dans la fenêtre du stage, OU si le match
13234//   était déjà rattaché à la Palmyre (pour pouvoir le décocher). Si on masque la case,
13235//   on décoche pour ne pas enregistrer un rattachement involontaire.
13236function majCasePalmyre(dateVal) {
13237  const wrap = document.getElementById('casePalmyreWrap');
13238  if (!wrap) return;
13239  const debut = wrap.dataset.debut || '';
13240  const fin = wrap.dataset.fin || '';
13241  const estCoach = wrap.dataset.coach === '1';
13242  const editRattache = wrap.dataset.editRattache === '1';
13243  const d = (dateVal || '').slice(0, 10);
13244  const dansFenetre = debut && fin && d >= debut && d <= fin;
13245  const visible = estCoach ? true : (dansFenetre || editRattache);
13246  wrap.style.display = visible ? 'flex' : 'none';
13247  const chk = document.getElementById('estPalmyre');
13248  if (chk) {
13249    if (!visible) {
13250      chk.checked = false; // masqué → pas de rattachement
13251    } else if (!estCoach && !editRattache) {
13252      // Joueur, création dans la fenêtre : on coche par défaut (cohérent avec l'ouverture).
13253      chk.checked = dansFenetre;
13254    }
13255  }
13256}
13257
13258// Bascule Simple / Double dans le formulaire de match : affiche ou masque le bloc
13259// du 2e adversaire, et adapte le libellé du premier.
13260function setFormatMatch(format) {
13261  document.querySelectorAll('#pillFormat .pill').forEach(p => {
13262    const actif = p.dataset.value === format;
13263    p.classList.toggle('active', actif);
13264    p.setAttribute('aria-pressed', actif ? 'true' : 'false');
13265  });
13266  const bloc2 = document.getElementById('blocAdversaire2');
13267  if (bloc2) bloc2.style.display = format === 'double' ? 'grid' : 'none';
13268  const lbl1 = document.getElementById('lblAdversaire1');
13269  if (lbl1) lbl1.textContent = format === 'double' ? 'Adversaire 1 *' : 'Adversaire *';
13270}
13271
13272// Widget de saisie du score à la Ten'Up : une case par jeu, tie-break et super
13273// tie-break gérés. Assemble en coulisses la chaîne canonique "6/4 3/6 7/6(5)" dans
13274// le champ caché name="score" (rétrocompatible : aucun changement de stockage).
13275function scoreWidgetHtml(m) {
13276  const sets = parseScoreSets(m ? (m.score || '') : '');
13277  const rawTokens = (m && m.score) ? String(m.score).trim().split(/\s+/) : [];
13278  const tbOf = (i) => { const t = rawTokens[i] || ''; const mt = t.match(/\((\d+)\)/); return mt ? mt[1] : ''; };
13279  const superTB = !!(sets[2] && Math.max(sets[2].moi, sets[2].adv) >= 10);
13280  const vA = (i) => sets[i] ? sets[i].moi : '';
13281  const vB = (i) => sets[i] ? sets[i].adv : '';
13282  const estTie = (i) => sets[i] && Math.abs(sets[i].moi - sets[i].adv) === 1
13283    && [3, 4, 5, 7].includes(Math.max(sets[i].moi, sets[i].adv));
13284  const box = (id, val) => `<input type="number" id="${id}" min="0" max="20" inputmode="numeric" value="${val}" data-match-score style="width:46px;text-align:center;padding:8px 4px">`;
13285  const row = (i) => {
13286    const n = i + 1;
13287    const tbVisible = estTie(i) && !(i === 2 && superTB);
13288    return `<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
13289      <span id="set${n}lbl" style="width:70px;font-size:12px;color:var(--text-dim)">${(i === 2 && superTB) ? 'Super TB' : 'Set ' + n}</span>
13290      ${box('set' + n + 'a', vA(i))}<span style="opacity:.6">/</span>${box('set' + n + 'b', vB(i))}
13291      <input type="number" id="tb${n}" placeholder="TB" min="0" max="30" inputmode="numeric" value="${tbOf(i)}" data-match-score title="Points du perdant au jeu décisif" style="width:52px;text-align:center;padding:8px 4px;display:${tbVisible ? '' : 'none'}">
13292    </div>`;
13293  };
13294  return `<div id="scoreWidget">
13295    ${row(0)}${row(1)}${row(2)}
13296    <label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-dim);margin-top:2px;cursor:pointer">
13297      <input type="checkbox" id="superTB" ${superTB ? 'checked' : ''} data-match-score style="width:16px;height:16px"> 3ᵉ set = super tie-break (10 pts)
13298    </label>
13299    <input type="hidden" name="score" id="scoreHidden" value="${m ? escapeHtml(m.score || '') : ''}">
13300    <div id="scoreApercu" class="text-faint" style="font-size:11px;margin-top:4px">${m && m.score ? 'Score : ' + escapeHtml(m.score) : ''}</div>
13301    <div id="scoreErreur" class="danger" role="alert" style="font-size:11px;margin-top:4px" hidden></div>
13302  </div>`;
13303}
13304
13305// Recompose la chaîne de score depuis les cases, met à jour le champ caché,
13306// gère l'affichage des tie-breaks, et déduit automatiquement Victoire/Défaite.
13307function syncScore() {
13308  const superTB = !!(document.getElementById('superTB') && document.getElementById('superTB').checked);
13309  const tokens = [];
13310  for (let i = 1; i <= 3; i++) {
13311    const a = document.getElementById('set' + i + 'a');
13312    const b = document.getElementById('set' + i + 'b');
13313    const tb = document.getElementById('tb' + i);
13314    const lbl = document.getElementById('set' + i + 'lbl');
13315    if (!a || !b) continue;
13316    const na = parseInt(a.value, 10), nb = parseInt(b.value, 10);
13317    const superRow = (i === 3 && superTB);
13318    if (lbl) lbl.textContent = superRow ? 'Super TB' : 'Set ' + i;
13319    const isTie = !superRow && Math.abs(na - nb) === 1 && [3, 4, 5, 7].includes(Math.max(na, nb));
13320    if (tb) tb.style.display = isTie ? '' : 'none';
13321    if (a.value.trim() === '' || b.value.trim() === '') continue;
13322    if (isNaN(na) || isNaN(nb)) continue;
13323    let token = na + '/' + nb;
13324    if (isTie && tb && tb.value.trim() !== '' && !isNaN(parseInt(tb.value, 10))) {
13325      token += '(' + parseInt(tb.value, 10) + ')';
13326    }
13327    tokens.push(token);
13328  }
13329  const scoreStr = tokens.join(' ');
13330  const hidden = document.getElementById('scoreHidden');
13331  if (hidden) hidden.value = scoreStr;
13332  const apercu = document.getElementById('scoreApercu');
13333  if (apercu) apercu.textContent = scoreStr ? 'Score : ' + scoreStr : '';
13334  const analyse = TennisDomainRules.analyserScoreSaisi(scoreStr);
13335  const erreurScore = document.getElementById('scoreErreur');
13336  if (erreurScore) {
13337    erreurScore.textContent = scoreStr && !analyse.valide ? analyse.erreurs[0] : '';
13338    erreurScore.hidden = !erreurScore.textContent;
13339  }
13340  // Déduction automatique du résultat uniquement depuis un score valide.
13341  if (analyse.valide && analyse.victoireDeduite != null) {
13342    const gagne = analyse.victoireDeduite;
13343    const grp = document.getElementById('pillVictoire');
13344    if (grp) grp.querySelectorAll('.pill').forEach(p => {
13345      const actif = (p.dataset.value === 'true') === gagne;
13346      p.classList.toggle('active', actif);
13347      p.setAttribute('aria-pressed', actif ? 'true' : 'false');
13348    });
13349  }
13350  majNotesPlaceholder();
13351}
13352
13353function afficherErreursMatch(erreurs) {
13354  const zone = document.getElementById('matchFormErrors');
13355  const liste = Array.isArray(erreurs) ? erreurs.filter(Boolean) : [];
13356  if (!zone || !liste.length) return;
13357  zone.innerHTML = `<strong>Vérifie la fiche :</strong><ul style="margin:6px 0 0;padding-left:18px">${liste.map(erreur => `<li>${escapeHtml(erreur)}</li>`).join('')}</ul>`;
13358  zone.hidden = false;
13359  zone.focus();
13360}
13361
13362// Nudge growth mindset (non-bloquant) : quand le résultat est une défaite, on invite
13363// le joueur à noter un point positif / un apprentissage dans le champ Notes.
13364function majNotesPlaceholder() {
13365  const el = document.getElementById('matchNegatif');
13366  if (!el) return;
13367  const perdu = !!document.querySelector('#pillVictoire .pill.active[data-value="false"]');
13368  el.placeholder = perdu
13369    ? 'Qu\'as-tu appris de ce match ? Que veux-tu travailler ?'
13370    : 'Ce que tu veux travailler pour la prochaine fois...';
13371}
13372
13373async function saveMatch(e, id) {
13374  e.preventDefault();
13375  if (joueurEnAttente()) { toast('Ton inscription est en attente de validation par le coach.', true); return; }
13376  const f = e.target;
13377  const victoirePill = document.querySelector('#pillVictoire .pill.active');
13378  const ressentiPill = document.querySelector('#pillRessenti .pill.active');
13379  const surfacePill = document.querySelector('#pillSurface .pill.active');
13380  const formatPill = document.querySelector('#pillFormat .pill.active');
13381  const format = formatPill ? formatPill.dataset.value : 'simple';
13382  const ancienMatch = id ? (cache.matchesSaison || listeSure(cache.matches)).find(match => match.id === id) : null;
13383
13384  // Adversaire : en double, on combine "Nom1 / Nom2" et on stocke le 2e séparément.
13385  const adv1 = f.adversaire.value.trim();
13386  const adv2 = (f.adversaire2_nom && f.adversaire2_nom.value.trim()) || '';
13387  const adv2Class = (f.adversaire2_class && f.adversaire2_class.value) || null;
13388  const saisie = {
13389    joueur_id: f.joueur_id.value,
13390    date: f.date.value,
13391    type: f.type.value,
13392    tournoi: f.tournoi.value.trim(),
13393    adversaire: adv1,
13394    adversaire_class: f.adversaire_class.value,
13395    adversaire2_nom: adv2 || null,
13396    adversaire2_class: adv2Class,
13397    format,
13398    victoire: victoirePill ? victoirePill.dataset.value === 'true' : null,
13399    score: f.score.value.trim(),
13400    surface: surfacePill ? surfacePill.dataset.value : null,
13401    ressenti: ressentiPill ? parseInt(ressentiPill.dataset.value) : null,
13402    point_positif: f.point_positif ? f.point_positif.value.trim() : null,
13403    point_negatif: f.point_negatif ? f.point_negatif.value.trim() : null,
13404    walkover: !!(f.walkover && f.walkover.checked),
13405    // "notes" n'est plus saisi par le joueur, mais on le PRÉSERVE : l'import Ten'Up y
13406    // écrit "WO", et estWalkover() s'en sert pour détecter les forfaits (impacte les
13407    // belles perfs, l'XP et les badges). Le vider casserait ces calculs.
13408    notes: ancienMatch && ancienMatch.notes ? ancienMatch.notes : ''
13409  };
13410
13411  // Certains imports historiques ont conservé le score dans l'ordre du vainqueur,
13412  // même lorsque le joueur a perdu. Une édition descriptive (surface, ressenti,
13413  // notes...) doit rester possible tant que score et résultat n'ont pas été touchés.
13414  // Dès que l'un de ces deux champs change, la cohérence stricte redevient obligatoire.
13415  const resultatSportifInchange = TennisDomainRules.resultatSportifInchange(ancienMatch, saisie);
13416  const preparation = TennisDomainRules.preparerMatchSaisi(saisie, {
13417    dateMax: dateLocaleISO(new Date()),
13418    autoriserResultatHistorique: resultatSportifInchange
13419  });
13420  if (!preparation.valide) {
13421    afficherErreursMatch(preparation.erreurs);
13422    toast(preparation.erreurs.join(' · '), true);
13423    return;
13424  }
13425  const obj = preparation.match;
13426  const tournamentId = f.tournament_id?.value || '';
13427  obj.tournament_id = tournamentId && tournamentId !== '__missing__' ? tournamentId : null;
13428  obj.tenup_tournament_id = obj.tournament_id ? (f.tenup_tournament_id?.value || null) : null;
13429  if (obj.tournament_id) {
13430    const tournoiSelectionne = cache.tournois.find(t => t.id === obj.tournament_id);
13431    obj.tournoi = tournoiSelectionne?.nom || obj.tournoi;
13432  }
13433  // Défense côté interface en complément des policies RLS : un joueur ne peut
13434  // jamais détourner le formulaire pour écrire un match sur une autre fiche.
13435  if (!isCoach() && obj.joueur_id !== profile.joueur_id) {
13436    toast('Tu peux saisir uniquement tes propres matchs.', true);
13437    return;
13438  }
13439
13440  // --- Estampillage Palmyre ---
13441  // 1) Coach saisissant DEPUIS l'environnement Palmyre : rattachement automatique.
13442  // 2) Sinon, si la case "Match de la Palmyre" est présente : cochée = rattaché à
13443  //    l'édition la plus récente, décochée = match académie (evenement_id null).
13444  const evActif = getActiveEvenement();
13445  if (evActif) {
13446    obj.evenement_id = evActif.id;
13447  } else if (f.est_palmyre) {
13448    const edition = (listeSure(cache.evenements))[0] || null;
13449    obj.evenement_id = (f.est_palmyre.checked && edition) ? edition.id : null;
13450  }
13451
13452  const verrou = verrouillerSoumission(f);
13453  if (!verrou) return;
13454
13455  let error;
13456  let nouveauMatchId = null;
13457  try {
13458    if (id) {
13459      let requete = db.from('matches').update(obj).eq('id', id);
13460      if (ancienMatch?.updated_at) requete = requete.eq('updated_at', ancienMatch.updated_at);
13461      ({ error } = await updateConfirmee(requete, 'id,updated_at'));
13462    } else {
13463      obj.created_by = session.user.id;
13464      const res = await db.from('matches').insert(obj).select('id').single();
13465      error = res.error;
13466      nouveauMatchId = res.data ? res.data.id : null;
13467      if (nouveauMatchId) obj.id = nouveauMatchId;
13468    }
13469  } finally {
13470    deverrouillerSoumission(verrou);
13471  }
13472  if (error) { toastErreur(error); return; }
13473  if (!id) effacerBrouillonMatch();
13474  closeModal();
13475  toast(id ? 'Match modifié' : 'Match ajouté');
13476
13477  // Envois email — uniquement à la création (pas à la modification) pour éviter le spam
13478  if (!id) {
13479    const joueur = joueurById(obj.joueur_id);
13480    if (joueur) {
13481      // 1) Match ajouté par le coach pour un autre joueur (pas pour lui-même)
13482      const coachAjouteMatchPourAutre = isCoach() && joueur.id !== profile.joueur_id;
13483      if (coachAjouteMatchPourAutre) {
13484        await emailMatchAjouteParCoach(obj, joueur);
13485      }
13486      // Un match réellement joué et saisi par le joueur est envoyé aux coachs
13487      // qui ont activé cette préférence. Les WO ne sont pas des matchs joués.
13488      const matchSaisiParLeJoueur = !isCoach() && joueur.id === profile.joueur_id && !obj.walkover;
13489      if (matchSaisiParLeJoueur && nouveauMatchId) {
13490        await sendEmailEvent('match_coach', nouveauMatchId);
13491      }
13492      // 2) Perf notable : victoire contre mieux classé d'au moins 2 échelons
13493      //    (les victoires par forfait/WO ne comptent pas comme une perf sportive)
13494      const perfCreee = detailBellePerf(obj, joueur.id);
13495      if (perfCreee) {
13496          if (perfCreee.ecart >= 2) {
13497            await emailPerfNotable(obj, joueur, perfCreee.ecart);
13498          }
13499          // 3) Notifier le COACH de TOUTE belle perf (écart ≥ 1), in-app + email.
13500          //    Passe par une fonction SECURITY DEFINER (le joueur n'a pas le droit
13501          //    d'insérer une notif pour le coach ni d'accéder à son email).
13502          await notifierPerfCoach(obj, joueur, nouveauMatchId);
13503      }
13504    }
13505  }
13506
13507  // Niveau AVANT prise en compte du match (cache encore à l'état précédent)
13508  const etatAvantMatch = (obj.joueur_id && !isCoach() && obj.joueur_id === profile.joueur_id)
13509    ? (() => { const x = calculerXP(obj.joueur_id).xp; return { niveau: niveauDepuisXP(x).niveau, xp: x }; })()
13510    : null;
13511  await reloadTables(["matches"]);
13512  // Détecter/enregistrer de nouveaux badges pour le joueur concerné par le match.
13513  // On ne félicite (animation) que si c'est le joueur lui-même qui a saisi (pas le coach).
13514  if (obj.joueur_id) {
13515    if (!isCoach() && obj.joueur_id === profile.joueur_id) {
13516      await synchroniserBadgesEtCelebrer(obj.joueur_id, etatAvantMatch);
13517    } else {
13518      await synchroniserBadges(obj.joueur_id);
13519    }
13520  }
13521  // Feedback encourageant quand le JOUEUR lui-même saisit une DÉFAITE (growth mindset) :
13522  // on recadre la défaite en progrès, et on célèbre le beau combat le cas échéant.
13523  if (obj.joueur_id && !isCoach() && obj.joueur_id === profile.joueur_id && !obj.victoire) {
13524    const combat = estBelleDefaite(obj, obj.joueur_id);
13525    setTimeout(() => toast(combat
13526      ? '🥊 Beau combat ! Tu as pris un set à un joueur mieux classé. C\'est comme ça qu\'on progresse 👏'
13527      : '💪 Bravo d\'avoir joué et saisi ton match. C\'est en jouant qu\'on progresse.'), 1200);
13528  }
13529  render();
13530}
13531
13532async function deleteMatch(id) {
13533  if (!(await demanderConfirmation('Supprimer ce match ?'))) return;
13534  const { error } = await db.from('matches').delete().eq('id', id);
13535  if (error) { toastErreur(error); return; }
13536  closeModal();
13537  toast('Match supprimé');
13538  await reloadTables(["matches"]);
13539  render();
13540}
13541
13542function openMatchDetail(id) { openModalMatch(id); }
13543
13544// ============================================================
13545// MODAL: TOURNOI (coach)
13546// ============================================================
13547function openModalTournoi(tournoiId) {
13548  const t = tournoiId ? cache.tournois.find(x => x.id === tournoiId) : null;
13549  const isEdit = !!t;
13550  const creationPreAcademie = !isEdit && isPreAcademieEnv();
13551
13552  if (!isCoach() && !isEdit) {
13553    toast('Les tournois officiels sont importés depuis Ten’Up. Utilise « Tournoi introuvable » lors de la saisie d’un match.', true);
13554    return;
13555  }
13556
13557  // Côté joueur : pas d'édition d'un tournoi existant (juste créer ou consulter)
13558  if (!isCoach() && isEdit) {
13559    // On affiche les détails en lecture seule
13560    const dateStr = t.date_fin && t.date_fin !== t.date_debut ? `${formatDate(t.date_debut)} → ${formatDate(t.date_fin)}` : formatDate(t.date_debut);
13561    const inscrits = (listeSure(t.inscrits));
13562    const jeSuisInscrit = profile.joueur_id && inscrits.includes(profile.joueur_id);
13563    const isPast = new Date(t.date_fin || t.date_debut).getTime() < Date.now() - 24*3600*1000;
13564
13565    const html = `<div class="modal-header"><h3 class="modal-title">${escapeHtml(t.nom)}</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
13566      <div class="card" style="font-size:13px; line-height:1.7">
13567        <div><strong>Dates :</strong> ${dateStr}</div>
13568        ${t.lieu ? `<div><strong>Lieu :</strong> ${escapeHtml(t.lieu)}</div>` : ''}
13569        ${t.categorie ? `<div><strong>Catégorie :</strong> ${escapeHtml(t.categorie)}</div>` : ''}
13570        ${(function () {
13571          const noms = inscritsNomsPoleCollectifs(t);
13572          if (noms.length > 0) return `<div><strong>Inscrits de l'académie (${noms.length}) :</strong><br>${escapeHtml(noms.join(', '))}</div>`;
13573          if (inscrits.length > 0) return `<div><strong>Inscrits de l'académie :</strong> ${inscrits.length}</div>`;
13574          return `<div><strong>Inscrits de l'académie :</strong> aucun pour l'instant</div>`;
13575        })()}
13576      </div>
13577      <div id="meteoEnrichieSlot"></div>
13578      ${jeSuisInscrit ? `<div class="alert success" style="margin-top:12px"><span class="icon">✓</span><div class="alert-content">Tu es inscrit à ce tournoi.</div></div>` : ''}
13579      <div class="mt-2" style="text-align:center">
13580        <a href="${escapeHtml(t.tenup_url || tenupTournoiUrl(t.tenup_id || t.tenup_code))}" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm" style="display:inline-block; text-decoration:none">🎾 Voir le tournoi sur Ten'Up ↗</a>
13581      </div>
13582      <div class="flex gap-1 mt-2">
13583        <button class="btn flex-1" data-ui-click="closeModal()">Fermer</button>
13584        ${!isPast ? (jeSuisInscrit
13585          ? `<button class="btn" data-ui-click="toggleTournoiInscription(${escapeHtml(encoderArgumentTexteUI(t.id))}, false); closeModal()">↩ Me désinscrire</button>`
13586          : `<button class="btn btn-primary" data-ui-click="toggleTournoiInscription(${escapeHtml(encoderArgumentTexteUI(t.id))}, true); closeModal()">+ Je m'inscris</button>`
13587        ) : ''}
13588      </div>`;
13589    showModal(html);
13590    // Hydrate la météo (asynchrone). Date pertinente : début si à venir, aujourd'hui si en cours, rien si terminé.
13591    const dMeteo1 = dateMeteoTournoi(t);
13592    if (t.lieu && dMeteo1) hydrateMeteoEnrichie('meteoEnrichieSlot', t.lieu, dMeteo1);
13593    return;
13594  }
13595
13596  // Côté coach : édition complète. Côté joueur en mode "création" : formulaire simplifié
13597  const showInscrits = isCoach();
13598  const officielVerrouille = !!(t && t.nature === 'officiel_fft' && t.source_creation === 'tenup_sync');
13599  const html = `<div class="modal-header"><h3 class="modal-title">${isEdit ? 'Modifier tournoi' : 'Nouveau tournoi'}</h3><button class="modal-close" data-ui-click="closeModal()">×</button></div>
13600    <form data-ui-submit="saveTournoi(event, ${escapeHtml(encoderArgumentTexteUI(tournoiId || ''))})" data-official-locked="${officielVerrouille?'1':'0'}" data-inscrits-snapshot="${escapeHtml(JSON.stringify(listeSure((t && t.inscrits))))}">
13601      ${officielVerrouille?'<div class="alert">Les informations officielles sont maîtrisées par Ten’Up. Seules les données pédagogiques et les inscriptions Académie sont modifiables ici.</div>':''}
13602      <div class="form-row"><label class="form-label">Nom *</label><input type="text" name="nom" required ${officielVerrouille?'readonly':''} value="${t ? escapeHtml(t.nom) : ''}" placeholder="${creationPreAcademie ? 'Ex. Plateau rouge de Bouliac' : 'Ex. Compétition interne du club'}"></div>
13603      <div class="grid-2">
13604        <div class="form-row"><label class="form-label">Date début *</label><input type="date" name="date_debut" required ${officielVerrouille?'readonly':''} value="${t ? t.date_debut : ''}"></div>
13605        <div class="form-row"><label class="form-label">Date fin</label><input type="date" name="date_fin" ${officielVerrouille?'readonly':''} value="${t ? t.date_fin || '' : ''}"></div>
13606      </div>
13607      <div class="form-row"><label class="form-label">Lieu</label><input type="text" name="lieu" ${officielVerrouille?'readonly':''} value="${t ? escapeHtml(t.lieu || '') : ''}" placeholder="Ville, club..."></div>
13608      ${creationPreAcademie ? `<input type="hidden" name="categorie" value="Plateau rouge"><div class="form-row"><label class="form-label">Type</label><div class="card" style="padding:10px 12px">Plateau rouge <span class="text-faint">· création locale (hors Ten’Up)</span></div></div>` : `<div class="form-row"><label class="form-label">Catégorie/détails</label><input type="text" name="categorie" ${officielVerrouille?'readonly':''} value="${t ? escapeHtml(t.categorie || '') : ''}" placeholder="Ex. 15/16 ans, 15/3 à 30"></div>`}
13609      <div class="form-row"><label class="form-label">Note pédagogique Académie</label><textarea name="note_pedagogique" rows="3" placeholder="Objectif sportif, consigne ou contexte local…">${escapeHtml(t?.pedagogie_json?.note || '')}</textarea></div>
13610
13611      ${showInscrits ? `
13612        <div class="form-row"><label class="form-label">Joueurs inscrits</label><div id="inscritsCheck" style="max-height:200px; overflow-y:auto">
13613          ${cache.joueurs.filter(TennisDomainRules.estJoueurActif).filter(j => !creationPreAcademie || joueurDansEnvCourant(j)).map(j => {
13614            const registration=t?TennisTournamentsTenup.registrationPour(cache.inscriptionsTournois,t.id,j.id):null;
13615            const statut=registration?.statut || ((t&&listeSure(t.inscrits).includes(j.id
13615))?'inscrit':'refuse');
13616            return `<div style="display:grid;grid-template-columns:minmax(120px,1fr) minmax(145px,190px);gap:8px;align-items:center;padding:6px 0"><span>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)} <span class="text-faint">(${escapeHtml(j.classement || 'NC')})</span></span><select data-registration-player="${j.id}">${Object.entries(TennisTournamentsTenup.STATUTS_INSCRIPTION).map(([value,label])=>`<option value="${value}" ${value===statut?'selected':''}>${escapeHtml(label)}</option>`).join('')}</select></div>`;
13617          }).join('') || '<div class="text-faint">Aucun joueur</div>'}
13618        </div></div>
13619      ` : `
13620        <div class="auth-info">En créant ce tournoi, tu seras automatiquement inscrit. Les autres joueurs pourront s'y inscrire ensuite depuis leur app.</div>
13621      `}
13622
13623      ${isEdit ? `<div class="mt-2" style="text-align:center">
13624        <a href="${escapeHtml(t.tenup_url || tenupTournoiUrl(t.tenup_id || t.tenup_code))}" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm" style="display:inline-block; text-decoration:none">🎾 Voir le tournoi sur Ten'Up ↗</a>
13625      </div>` : ''}
13626
13627      ${isEdit ? `<div id="meteoEnrichieSlot"></div>` : ''}
13628
13629      <div class="flex gap-1 mt-2">
13630        ${isEdit && isCoach() ? `<button type="button" class="btn btn-danger" data-ui-click="deleteTournoi(${escapeHtml(encoderArgumentTexteUI(t.id))})">Supprimer</button>` : ''}
13631        <button type="button" class="btn flex-1" data-ui-click="closeModal()">Annuler</button>
13632        <button type="submit" class="btn btn-primary flex-1">${isEdit ? 'Enregistrer' : 'Créer'}</button>
13633      </div>
13634    </form>`;
13635  showModal(html);
13636  // Hydrate la météo si on est en édition d'un tournoi existant avec lieu
13637  const dMeteo2 = isEdit ? dateMeteoTournoi(t) : null;
13638  if (isEdit && t.lieu && dMeteo2) hydrateMeteoEnrichie('meteoEnrichieSlot', t.lieu, dMeteo2);
13639}
13640
13641async function saveTournoi(e, id) {
13642  e.preventDefault();
13643  if (!isCoach()) { toast('Action réservée au coach', true); return; }
13644  const f = e.target;
13645  const verrou = verrouillerSoumission(f);
13646  if (!verrou) return;
13647
13648  const statutsInscription = Array.from(document.querySelectorAll('#inscritsCheck [data-registration-player]')).map(select => ({ playerId:select.dataset.registrationPlayer, statut:select.value }));
13649  const inscrits = statutsInscription.filter(row => ['inscrit','a_confirmer','liste_attente'].includes(row.statut)).map(row => row.playerId);
13650
13651  const obj = {
13652    nom: f.nom.value.trim(),
13653    date_debut: f.date_debut.value,
13654    date_fin: f.date_fin.value || f.date_debut.value,
13655    lieu: f.lieu.value.trim(),
13656    categorie: f.categorie.value.trim(),
13657    pedagogie_json: { ...(id ? cache.tournois.find(x=>x.id===id)?.pedagogie_json : {}), note:f.note_pedagogique?.value.trim() || '', ...(!id && isPreAcademieEnv() ? { environnement:'pre_academie' } : {}) },
13658    updated_at: new Date().toISOString()
13659  };
13660
13661  let error;
13662  let tournoiSauveId = id || null;
13663  try {
13664    if (id) {
13665      if (isCoach()) {
13666        if (f.dataset.officialLocked === '1') {
13667          ({ error } = await updateConfirmee(db.from('tournois').update({ pedagogie_json:obj.pedagogie_json, updated_at:obj.updated_at }).eq('id',id)));
13668        } else {
13669        let inscritsSnapshot = [];
13670        try { inscritsSnapshot = JSON.parse(f.dataset.inscritsSnapshot || '[]'); } catch (_) { inscritsSnapshot = []; }
13671        ({ error } = await db.rpc('coach_modifier_tournoi', {
13672          p_tournoi_id: id,
13673          p_nom: obj.nom,
13674          p_date_debut: obj.date_debut,
13675          p_date_fin: obj.date_fin,
13676          p_lieu: obj.lieu,
13677          p_categorie: obj.categorie,
13678          p_inscrits_snapshot: inscritsSnapshot,
13679          p_inscrits_souhaites: inscrits
13680        }));
13681        }
13682      }
13683    } else {
13684      obj.inscrits = inscrits;
13685      obj.created_by = session.user.id;
13686      obj.nature = 'competition_interne_club';
13687      obj.source_creation = 'academie';
13688      obj.statut_local = 'a_venir';
13689      const creation = await db.from('tournois').insert(obj).select('id').single();
13690      error = creation.error;
13691      tournoiSauveId = creation.data?.id || null;
13692    }
13693  } finally {
13694    deverrouillerSoumission(verrou);
13695  }
13696  if (error) { toastErreur(error); return; }
13697  if (isCoach() && tournoiSauveId) {
13698    for (const row of statutsInscription) {
13699      const avant=cache.inscriptionsTournois.find(r=>r.tournament_id===tournoiSauveId&&r.player_id===row.playerId)?.statut;
13700      if (avant===row.statut) continue;
13701      if (!avant && row.statut==='refuse') continue;
13702      const resultat = await db.rpc('coach_set_tournament_registration',{p_tournament_id:tournoiSauveId,p_player_id:row.playerId,p_status:row.statut});
13703      if (resultat.error) { toastErreur(resultat.error); return; }
13704    }
13705  }
13706  closeModal();
13707  toast(id ? 'Tournoi modifié' : 'Tournoi créé');
13708  await refresh();
13709}
13710
13711async function deleteTournoi(id) {
13712  if (!(await demanderConfirmation('Supprimer ce tournoi ?'))) return;
13713  const { error } = await db.from('tournois').delete().eq('id', id);
13714  if (error) { toastErreur(error); return; }
13715  closeModal();
13716  toast('Tournoi supprimé');
13717  await refresh();
13718}
13719
13720// ============================================================
13721// IMPORT TEN'UP (coach)
13722// ============================================================
13723// Workflow :
13724// 1) openModalImportTenup : ouvre la modale avec drop zone
13725// 2) handleTenupFile : lit le PDF, extrait le texte via pdf.js
13726// 3) parseTenupText : parse le texte en liste de tournois
13727// 4) showTenupPreview : affiche la prévisualisation avec checkboxes catégories
13728// 5) confirmTenupImport : insère en base, gère les doublons
13729
13730function openModalImportTenup() {
13731  if (!isCoach()) return;
13732  gestionnaireBrouillons.definirTournoisTenup([]);
13733
13734  const html = `<div class="modal-header">
13735      <h3 class="modal-title">Import Ten'Up</h3>
13736      <button class="modal-close" data-ui-click="closeModal()">×</button>
13737    </div>
13738    <div class="auth-info mb-2">
13739      Glisse-dépose ici un export PDF de Ten'Up (recherche de tournois). L'app va extraire automatiquement les tournois et te proposer de sélectionner les catégories à conserver pour chacun.
13740    </div>
13741    <div id="tenupDropZone" style="border:2px dashed var(--border-strong); border-radius:var(--radius); padding:30px 20px; text-align:center; cursor:pointer; transition:all 0.2s" data-ui-drop="onTenupDrop(event)" data-ui-dragover="onTenupDragOver(event, this)" data-ui-dragleave="onTenupDragLeave(event, this)" data-ui-click="document.getElementById('tenupFileInput').click()">
13742      <div style="font-size:36px">📄</div>
13743      <div style="margin-top:8px; font-weight:600">Glisse ton PDF Ten'Up ici</div>
13744      <div class="text-faint" style="font-size:12px; margin-top:4px">ou clique pour parcourir</div>
13745      <input type="file" id="tenupFileInput" accept="application/pdf" style="display:none" data-ui-change="onTenupFileSelect(event)">
13746    </div>
13747    <div id="tenupStatus" style="margin-top:14px; font-size:13px; min-height:20px"></div>
13748    <div id="tenupPreview" style="margin-top:14px"></div>
13749    <div class="flex gap-1 mt-2">
13750      <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button>
13751      <button class="btn btn-primary flex-1" id="tenupImportBtn" data-ui-click="confirmTenupImport()" style="display:none">✓ Importer la sélection</button>
13752    </div>`;
13753  showModal(html);
13754}
13755
13756function onTenupDrop(e) {
13757  e.preventDefault();
13758  const zone = document.getElementById('tenupDropZone');
13759  if (zone) { zone.style.borderColor = 'var(--border-strong)'; zone.style.background = 'transparent'; }
13760  const file = e.dataTransfer.files[0];
13761  if (file) handleTenupFile(file);
13762}
13763
13764function onTenupDragOver(e, zone) {
13765  e.preventDefault();
13766  zone.style.borderColor = 'var(--orange)';
13767  zone.style.background = 'rgba(243,146,0,0.05)';
13768}
13769
13770function onTenupDragLeave(e, zone) {
13771  zone.style.borderColor = 'var(--border-strong)';
13772  zone.style.background = 'transparent';
13773}
13774
13775function onTenupFileSelect(e) {
13776  const file = e.target.files[0];
13777  if (file) handleTenupFile(file);
13778}
13779
13780async function handleTenupFile(file) {
13781  const status = document.getElementById('tenupStatus');
13782  if (!file.name.toLowerCase().endsWith('.pdf') && file.type !== 'application/pdf') {
13783    status.innerHTML = '<span style="color:var(--danger-text)">⚠ Le fichier doit être un PDF</span>';
13784    return;
13785  }
13786  status.innerHTML = '<span class="text-faint">Chargement du lecteur PDF…</span>';
13787  if (!await chargerPdfJs()) {
13788    status.innerHTML = '<span style="color:var(--danger-text)">Le lecteur PDF n’a pas pu être chargé. Vérifie ta connexion puis réessaie.</span>';
13789    return;
13790  }
13791
13792  status.innerHTML = '<span class="text-faint">⏳ Lecture du PDF...</span>';
13793
13794  try {
13795    const arrayBuffer = await file.arrayBuffer();
13796    const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer }).promise;
13797    let fullText = '';
13798    for (let i = 1; i <= pdf.numPages; i++) {
13799      const page = await pdf.getPage(i);
13800      const content = await page.getTextContent();
13801      // Reconstituer le texte avec sauts de ligne approximatifs (basé sur les coordonnées Y)
13802      let lastY = null;
13803      content.items.forEach(item => {
13804        const y = Math.round(item.transform[5]);
13805        if (lastY !== null && Math.abs(y - lastY) > 2) fullText += '\n';
13806        else if (lastY !== null) fullText += ' ';
13807        fullText += item.str;
13808        lastY = y;
13809      });
13810      fullText += '\n\n';
13811    }
13812
13813    status.innerHTML = `<span class="text-faint">📄 ${pdf.numPages} page${pdf.numPages>1?'s':''} lue${pdf.numPages>1?'s':''}, analyse en cours...</span>`;
13814
13815    const tournois = parseTenupText(fullText);
13816    if (tournois.length === 0) {
13817      status.innerHTML = '<span style="color:var(--warning-text)">⚠ Aucun tournoi trouvé dans ce PDF. Vérifie que c\'est bien un export Ten\'Up.</span>';
13818      return;
13819    }
13820    gestionnaireBrouillons.definirTournoisTenup(tournois);
13821    status.innerHTML = `<span style="color:var(--success-text)">✓ ${tournois.length} tournoi${tournois.length>1?'s':''} détecté${tournois.length>1?'s':''}</span>`;
13822    showTenupPreview(tournois);
13823  } catch (err) {
13824    journal.error('Erreur lecture PDF:', err);
13825    status.innerHTML = '<span style="color:var(--danger-text)">⚠ Erreur de lecture : ' + escapeHtml(err.message || 'inconnue') + '</span>';
13826  }
13827}
13828
13829// ============================================================
13830// PARSER TEN'UP
13831// ============================================================
13832// Format type observé :
13833//   CLUB ORGANISATEUR EN MAJUSCULES
13834//   Nom du tournoi
13835//   DD/MM/YYYY au DD/MM/YYYY
13836//   JUGE-ARBITRE : ...
13837//   SURFACE(S) : ...
13838//   PRIX EN ESPÈCE : ...
13839//   PRIX EN LOTS : ...
13840//   INSCRIPTIONS / PAIEMENT EN LIGNE : ...
13841//   CODE : T XXXX
13842//   ENGAGEMENTS :
13843//   email@...
13844//   INSTALLATIONS : NOM_LIEU
13845//   Adresse
13846//   Code postal Ville
13847//   Tel
13848//   Catégorie Epreuve Classement Droits
13849//   <lignes catégories>
13850
13851function parseTenupText(text) {
13852  return TennisDomainRules.parseTenupTournois(text);
13853}
13854
13855function parseTenupMatchs(text) {
13856  return TennisDomainRules.parseTenupMatchs(text, getSaisonCourante());
13857}
13858
13859function openModalImportMatchsTenup(joueurId) {
13860  const j = joueurById(joueurId);
13861  if (!j) { toast('Joueur introuvable', true); return; }
13862  if (!isCoach() && profile.joueur_id !== joueurId) { toast('Action non autorisée', true); return; }
13863
13864  const titre = isCoach() ? `Importer les matchs de ${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}` : 'Importer mes matchs depuis Ten\'Up';
13865  const html = `
13866    <div class="modal-header">
13867      <h3 class="modal-title">${titre}</h3>
13868      <button class="modal-close" data-ui-click="closeModal()">×</button>
13869    </div>
13870    <div class="auth-info mb-2" style="font-size:13px">
13871      Sur Ten'Up, ouvre la page <strong>Palmarès / Résultats</strong> ${isCoach() ? 'du joueur' : 'de ton compte'}, sélectionne la liste des matchs, copie-la, puis colle-la ci-dessous.
13872    </div>
13873    <textarea id="tenupMatchsRaw" rows="8" style="width:100%; font-family:monospace; font-size:12px" placeholder="Colle ici le texte copié depuis Ten'Up…"></textarea>
13874    <input type="hidden" id="tenupMatchsJoueurId" value="${joueurId}">
13875    <div class="flex gap-1 mt-2">
13876      <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button>
13877      <button class="btn btn-primary flex-1" data-ui-click="previewImportMatchs()">Analyser</button>
13878    </div>
13879    <div id="tenupMatchsPreview" style="margin-top:14px"></div>
13880  `;
13881  showModal(html);
13882}
13883
13884async function chargerMatchsExistantsImport(joueurId, matchsImportes) {
13885  const dates = (listeSure(matchsImportes)).map(match => match.date)
13886    .filter(date => /^\d{4}-\d{2}-\d{2}$/.test(date)).sort();
13887  if (!dates.length) return { data: [], error: null };
13888  const debut = TennisDomainRules.bornesSaison(TennisDomainRules.saisonPourDate(`${dates[0]}T12:00:00`))[0];
13889  const fin = TennisDomainRules.bornesSaison(TennisDomainRules.saisonPourDate(`${dates[dates.length - 1]}T12:00:00`))[1];
13890  const resultat = await db.from('matches').select(COLONNES_MATCH)
13891    .eq('joueur_id', joueurId)
13892    .gte('date', debut).lte('date', fin);
13893  if (resultat.error) return resultat;
13894
13895  const parId = new Map();
13896  [...listeSure(cache.matches), ...listeSure(cache.matchesSaison), ...listeSure(resultat.data)]
13897    .filter(match => match && match.id && match.joueur_id === joueurId && match.date >= debut && match.date <= fin)
13898    .forEach(match => parId.set(match.id, match));
13899  return { ...resultat, data: Array.from(parId.values()) };
13900}
13901
13902function tournoiOfficielPourMatchImporte(match) {
13903  const cle = TennisDomainRules.cleTournoi(match && match.tournoi);
13904  if (!cle || !match?.date) return null;
13905  return listeSure(cache.tournois).find(tournoi =>
13906    TennisDomainRules.cleTournoi(tournoi.nom) === cle
13907    && tournoi.date_debut <= match.date
13908    && (tournoi.date_fin || tournoi.date_debut) >= match.date
13909  ) || null;
13910}
13911
13912async function previewImportMatchs() {
13913  const raw = (document.getElementById('tenupMatchsRaw')?.value || '').trim();
13914  const joueurId = document.getElementById('tenupMatchsJoueurId')?.value;
13915  const preview = document.getElementById('tenupMatchsPreview');
13916  if (!preview) return;
13917
13918  if (!raw) { preview.innerHTML = `<div class="text-faint" style="font-size:13px">Colle d'abord le texte copié depuis Ten'Up.</div>`; return; }
13919
13920  const parsed = parseTenupMatchs(raw);
13921  if (parsed.length === 0) {
13922    preview.innerHTML = `<div class="card" style="padding:12px; border-left:3px solid var(--warning)">
13923      <strong style="font-size:13px; color:var(--warning-text)">Aucun match détecté</strong>
13924      <div class="text-faint" style="font-size:12px; margin-top:4px">Le format n'a pas été reconnu. Vérifie que tu as bien copié la liste des matchs (avec les noms, classements, scores et dates).</div>
13925    </div>`;
13926    return;
13927  }
13928
13929  const { data: existants = [], error: erreurExistants } = await chargerMatchsExistantsImport(joueurId, parsed);
13930  if (erreurExistants) {
13931    journal.warn('[import TenUp prévisualisation]', erreurExistants);
13932    preview.innerHTML = '<div class="card" style="padding:12px;color:var(--danger-text)">Impossible de vérifier les doublons pour le moment. Réessaie dans quelques instants.</div>';
13933    return;
13934  }
13935  const jImport = joueurById(joueurId);
13936  const jImportPalmyre = jImport && jImport.participe_palmyre;
13937  const dejaVus = new Set();
13938  const vide = (v) => v === null || v === undefined || String(v).trim() === '';
13939
13940  let nbNouveaux = 0, nbCompleter = 0, nbDoublons = 0, nbArchives = 0;
13941  const rows = parsed.map(mt => {
13942    const archive = mt._horsSaison;
13943    if (archive) nbArchives++;
13944    let statut, grise = false;
13945    const trouve = trouverMatchExistant(mt, existants.filter(e => !dejaVus.has(e.id)));
13946    if (!trouve) {
13947      nbNouveaux++; statut = `<span style="color:var(--success-text)">à importer${archive ? ' · archive' : ''}</span>`;
13948    } else {
13949      dejaVus.add(trouve.match.id);
13950      const e = trouve.match;
13951      const tournoiOfficiel = tournoiOfficielPourMatchImporte(mt);
13952      const apporte = Object.keys(TennisDomainRules.champsFusionMatchTenup(e, mt, trouve.probable || trouve.enrichissement)).length > 0 ||
13953        (tournoiOfficiel && e.tournament_id !== tournoiOfficiel.id) ||
13954        (vide(e.evenement_id) && jImportPalmyre && editionPalmyrePourDate(mt.date));
13955      if (apporte) {
13956        nbCompleter++; statut = `<span style="color:var(--warning-text)">à compléter${archive ? ' · archive' : ''}</span>`;
13957      } else {
13958        nbDoublons++; statut = trouve.probable ? '<span style="color:var(--warning-text)">doublon probable</span>' : '<span class="text-faint">déjà présent</span>'; grise = true;
13959      }
13960    }
13961    const issueLabel = mt.victoire ? '<span style="color:var(--success-text)">V</span>' : '<span style="color:var(--danger-text)">D</span>';
13962    const advAffiche = vide(mt.adversaire) ? '<span class="text-faint">(adversaire inconnu)</span>' : escapeHtml(mt.adversaire) + ` <span class="text-faint">(${escapeHtml(mt.adversaire_class)})</span>`;
13963    return `<tr style="${grise ? 'opacity:0.45' : ''}">
13964      <td style="padding:4px 6px; font-size:11px">${formatDate(mt.date)}</td>
13965      <td style="padding:4px 6px; font-size:11px">${advAffiche}</td>
13966      <td style="padding:4px 6px; font-size:11px; text-align:center">${issueLabel}</td>
13967      <td style="padding:4px 6px; font-size:11px">${escapeHtml(mt.score || (mt._wo ? 'WO' : ''))}</td>
13968      <td style="padding:4px 6px; font-size:11px">${statut}</td>
13969    </tr>`;
13970  }).join('');
13971  const nbActifs = nbNouveaux + nbCompleter;
13972
13973  preview.innerHTML = `
13974    <div class="card" style="padding:10px 14px; margin-bottom:10px">
13975      <strong style="font-size:13px">${parsed.length} match${parsed.length > 1 ? 's' : ''} détecté${parsed.length > 1 ? 's' : ''}</strong>
13976      <div class="text-faint" style="font-size:12px; margin-top:2px">${nbNouveaux} à importer${nbCompleter > 0 ? ' · ' + nbCompleter + ' à compléter' : ''} · ${nbDoublons} déjà présent${nbDoublons > 1 ? 's' : ''}${nbArchives > 0 ? ' · ' + nbArchives + ' en archive' : ''}</div>
13977      ${nbCompleter > 0 ? `<div class="text-faint" style="font-size:11px; margin-top:4px">« À compléter » : un match déjà présent auquel il manque une info (nom d'adversaire, score…) que cet import apporte. Les données déjà saisies ne sont jamais écrasées.</div>` : ''}
13978      ${nbArchives > 0 ? `<div class="text-faint" style="font-size:11px; margin-top:4px">Les matchs des saisons précédentes seront importés à leur date d'origine et resteront exclus des statistiques de la saison actuelle.</div>` : ''}
13979    </div>
13980    <div style="max-height:260px; overflow:auto; border:1px solid var(--border); border-radius:var(--radius-sm)">
13981      <table style="width:100%; border-collapse:collapse">
13982        <thead><tr style="background:var(--bg-input); position:sticky; top:0">
13983          <th style="padding:6px; font-size:10px; text-align:left">Date</th>
13984          <th style="padding:6px; font-size:10px; text-align:left">Adversaire</th>
13985          <th style="padding:6px; font-size:10px">Issue</th>
13986          <th style="padding:6px; font-size:10px; text-align:left">Score</th>
13987          <th style="padding:6px; font-size:10px; text-align:left">Statut</th>
13988        </tr></thead>
13989        <tbody>${rows}</tbody>
13990      </table>
13991    </div>
13992    <button class="btn btn-primary" style="width:100%; margin-top:12px" data-ui-click='doImportMatchsTenup()' ${nbActifs === 0 ? 'disabled' : ''}>
13993      ${nbActifs === 0 ? 'Rien à importer ni compléter' : 'Importer / compléter ' + nbActifs + ' match' + (nbActifs > 1 ? 's' : '')}
13994    </button>
13995  `;
13996  preview.dataset.parsed = JSON.stringify(parsed);
13997}
13998
13999function cleTournoi(nom) {
14000  return TennisDomainRules.cleTournoi(nom);
14001}
14002
14003function trouverMatchExistant(mt,e) {
14004  return TennisDomainRules.trouverMatchExistant(mt,e);
14005}
14006
14007async function doImportMatchsTenup() {
14008  const preview = document.getElementById('tenupMatchsPreview');
14009  const joueurId = document.getElementById('tenupMatchsJoueurId')?.value;
14010  if (!preview || !joueurId) return;
14011  if (!isCoach() && profile.joueur_id !== joueurId) { toast('Action non autorisée', true); return; }
14012
14013  let parsed;
14014  try { parsed = JSON.parse(preview.dataset.parsed || '[]'); } catch (e) { parsed = []; }
14015  if (parsed.length === 0) { toast('Rien à importer', true); return; }
14016
14017  const { data: existants = [], error: erreurExistants } = await chargerMatchsExistantsImport(joueurId, parsed);
14018  if (erreurExistants) { toastErreur(erreurExistants); return; }
14019
14020  const jImport = joueurById(joueurId);
14021  const peutPalmyre = jImport && jImport.participe_palmyre;
14022
14023  const aInserer = [];
14024  const aCompleter = [];
14025  const rejetsImport = [];
14026  const dejaVus = new Set();
14027
14028  parsed.forEach(mt => {
14029    const dispo = existants.filter(e => !dejaVus.has(e.id));
14030    const trouve = trouverMatchExistant(mt, dispo);
14031
14032    if (!trouve) {
14033      const edition = peutPalmyre ? editionPalmyrePourDate(mt.date) : null;
14034      const tournoiOfficiel = tournoiOfficielPourMatchImporte(mt);
14035      const candidat = {
14036        joueur_id: joueurId,
14037        date: mt.date,
14038        type: mt.type,
14039        tournoi: mt.tournoi,
14040        tournament_id: tournoiOfficiel?.id || null,
14041        tenup_tournament_id: tournoiOfficiel?.tenup_id || null,
14042        adversaire: mt.adversaire,
14043        adversaire_class: mt.adversaire_class,
14044        adversaire2_nom: mt.adversaire2_nom || null,
14045        adversaire2_class: mt.adversaire2_class || null,
14046        format: mt.format || 'simple',
14047        victoire: mt.victoire,
14048        score: mt.score,
14049        ressenti: null,
14050        notes: mt.notes || '',
14051        walkover: !!mt._wo,
14052        evenement_id: edition ? edition.id : null,
14053        created_by: session.user.id
14054      };
14055      const preparation = TennisDomainRules.preparerMatchSaisi(candidat, {
14056        dateMax: dateLocaleISO(new Date()),
14057        autoriserResultatHistorique: true,
14058        autoriserScoreIncompletHistorique: mt._abandon === true
14059      });
14060      if (preparation.valide) aInserer.push(preparation.match);
14061      else rejetsImport.push({ match: mt, erreurs: preparation.erreurs });
14062      return;
14063    }
14064
14065    const e = trouve.match;
14066    dejaVus.add(e.id);
14067    const patch = TennisDomainRules.champsFusionMatchTenup(e, mt, trouve.probable || trouve.enrichissement);
14068    const tournoiOfficiel = tournoiOfficielPourMatchImporte(mt);
14069    if (tournoiOfficiel && (trouve.probable || !e.tournament_id)) {
14070      patch.tournament_id = tournoiOfficiel.id;
14071      patch.tenup_tournament_id = tournoiOfficiel.tenup_id || null;
14072    }
14073    const vide = (v) => v === null || v === undefined || String(v).trim() === '';
14074    if (vide(e.evenement_id) && peutPalmyre) {
14075      const edition = editionPalmyrePourDate(mt.date);
14076      if (edition) patch.evenement_id = edition.id;
14077    }
14078
14079    if (Object.keys(patch).length > 0) aCompleter.push({ id: e.id, patch });
14080  });
14081
14082  if (aInserer.length === 0 && aCompleter.length === 0) {
14083    const messageRejets = rejetsImport.length
14084      ? ` · ${rejetsImport.length} ligne${rejetsImport.length > 1 ? 's' : ''}
14084 invalide${rejetsImport.length > 1 ? 's' : ''}`
14085      : '';
14086    toast(`Aucun nouveau match, aucun à compléter${messageRejets}`, true);
14087    if (rejetsImport.length) journal.warn('[import matchs rejetés]', rejetsImport);
14088    return;
14089  }
14090
14091  if (aInserer.length > 0) {
14092    const { error } = await db.from('matches').insert(aInserer);
14093    if (error) {
14094      journal.warn('[import TenUp insertion]', {
14095        code: error.code,
14096        message: error.message,
14097        details: error.details,
14098        hint: error.hint,
14099        nombreMatchs: aInserer.length,
14100        types: [...new Set(aInserer.map(match => match.type))]
14101      });
14102      const contrainteType = error.code === '23514' && /matches_type_check/i.test(`${error.message || ''} ${error.details || ''}`);
14103      toast(contrainteType
14104        ? 'Un type de match Ten’Up n’est pas encore accepté. Aucun match n’a été importé ; préviens le coach avec cette capture.'
14105        : `Import impossible (${error.code || 'erreur inconnue'}). Aucun match n’a été importé ; réessaie ou transmets ce code au coach.`, true);
14106      return;
14107    }
14108  }
14109  let nbCompletes=0;
14110  for (const c of aCompleter) {
14111    const { error } = await updateConfirmee(db.from('matches').update(c.patch).eq('id', c.id));
14112    if (error) { journal.warn('[import complétion]', c.id, error); continue; }
14113    nbCompletes++;
14114  }
14115
14116  closeModal();
14117  const bouts = [];
14118  if (aInserer.length) bouts.push(`${aInserer.length} importé${aInserer.length > 1 ? 's' : ''}`);
14119  if (nbCompletes) bouts.push(`${nbCompletes} complété${nbCompletes > 1 ? 's' : ''}`);
14120  if (rejetsImport.length) {
14121    bouts.push(`${rejetsImport.length} rejeté${rejetsImport.length > 1 ? 's' : ''}`);
14122    journal.warn('[import matchs rejetés]', rejetsImport);
14123  }
14124  toast(bouts.join(' · ') || 'Rien à faire');
14125  await reloadTables(['matches']);
14126  render();
14127}
14128
14129function showTenupPreview(tournois) {
14130  const preview = document.getElementById('tenupPreview');
14131  const importBtn = document.getElementById('tenupImportBtn');
14132  if (!preview) return;
14133
14134  // Détection des doublons existants en base
14135  // 1) Par code Ten'Up exact (le plus sûr)
14136  const codesExistants = new Set(cache.tournois.filter(t => t.tenup_code).map(t => t.tenup_code));
14137  // 2) Par signature nom+date+lieu (rattrape les imports antérieurs avec d'autres codes)
14138  const signaturesExistantes = new Set(cache.tournois.map(t => {
14139    return `${(t.nom || '').toLowerCase().trim()}|${t.date_debut || ''}|${(t.lieu || '').toLowerCase().trim()}`;
14140  }));
14141
14142  let html = '<div class="section-title" style="margin-top:0">Prévisualisation</div>';
14143  html += '<div class="text-faint mb-2" style="font-size:12px">Décoche les catégories qui ne te concernent pas. Les catégories jeunes (11-18 ans) sont pré-sélectionnées.</div>';
14144
14145  tournois.forEach((t, idx) => {
14146    // Doublon si même code OU même signature nom+date+lieu
14147    const signature = `${(t.nom || '').toLowerCase().trim()}|${t.date_debut || ''}|${(t.lieu || '').toLowerCase().trim()}`;
14148    const doublon = (t.code && codesExistants.has(t.code)) || signaturesExistantes.has(signature);
14149    html += `<div class="card" style="margin-bottom:10px; padding:12px; ${doublon ? 'opacity:0.5; border-left:3px solid var(--warning)' : ''}">
14150      <div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px">
14151        <div style="flex:1">
14152          <label style="display:flex; gap:8px; align-items:flex-start; cursor:${doublon ? 'not-allowed' : 'pointer'}">
14153            <input type="checkbox" data-tournoi-idx="${idx}" ${doublon ? 'disabled' : 'checked'} style="margin-top:4px; width:auto">
14154            <div style="flex:1">
14155              <strong style="font-size:14px">${escapeHtml(t.nom)}</strong>
14156              ${doublon ? '<span class="badge badge-warning" style="font-size:10px; margin-left:6px">déjà importé</span>' : ''}
14157              <div class="text-dim" style="font-size:12px; margin-top:2px">
14158                ${escapeHtml(t.club)}${t.lieu ? ' · ' + escapeHtml(t.lieu) : ''}
14159              </div>
14160              <div class="text-faint" style="font-size:11px">
14161                ${t.date_debut ? formatDate(t.date_debut) : '?'} → ${t.date_fin ? formatDate(t.date_fin) : '?'}
14162                ${t.code ? ' · ' + escapeHtml(t.code) : ''}
14163              </div>
14164            </div>
14165          </label>
14166        </div>
14167      </div>
14168      ${!doublon && t.categories.length > 0 ? `
14169        <div style="margin-top:10px; padding-left:24px; border-left:1px solid var(--border)">
14170          <div class="text-faint" style="font-size:11px; text-transform:uppercase; letter-spacing:1px; margin-bottom:6px">Catégories à conserver</div>
14171          ${t.categories.map((cat, catIdx) => `
14172            <label style="display:inline-flex; gap:6px; align-items:center; padding:4px 8px; margin:2px; background:var(--bg-input); border-radius:var(--radius-sm); cursor:pointer; font-size:12px">
14173              <input type="checkbox" data-cat-idx="${idx}-${catIdx}" ${cat.jeune ? 'checked' : ''} style="width:auto; margin:0">
14174              ${escapeHtml(cat.label)}
14175            </label>
14176          `).join('')}
14177        </div>
14178      ` : ''}
14179      ${!doublon && t.categories.length === 0 ? `<div class="text-faint" style="font-size:11px; margin-top:8px">Aucune catégorie détectée — le tournoi sera importé avec son nom seul.</div>` : ''}
14180    </div>`;
14181  });
14182
14183  preview.innerHTML = html;
14184  importBtn.style.display = '';
14185}
14186
14187async function confirmTenupImport() {
14188  const tournoisParses = gestionnaireBrouillons.lireTournoisTenup();
14189  if (tournoisParses.length === 0) return;
14190
14191  // Récupérer les sélections
14192  const selectionTournois = Array.from(document.querySelectorAll('input[data-tournoi-idx]:checked'))
14193    .map(c => parseInt(c.dataset.tournoiIdx));
14194  if (selectionTournois.length === 0) {
14195    toast('Aucun tournoi sélectionné', true);
14196    return;
14197  }
14198
14199  // Pour chaque tournoi sélectionné, récupérer ses catégories cochées
14200  const catCheckboxes = document.querySelectorAll('input[data-cat-idx]:checked');
14201  const catsByTournoi = {};
14202  catCheckboxes.forEach(c => {
14203    const [tIdx, cIdx] = c.dataset.catIdx.split('-').map(Number);
14204    if (!catsByTournoi[tIdx]) catsByTournoi[tIdx] = [];
14205    if (tournoisParses[tIdx]?.categories?.[cIdx]) catsByTournoi[tIdx].push(tournoisParses[tIdx].categories[cIdx].label);
14206  });
14207
14208  const btn = document.getElementById('tenupImportBtn');
14209  btn.disabled = true;
14210  btn.textContent = 'Import en cours...';
14211
14212  const toInsert = [];
14213  selectionTournois.forEach(idx => {
14214    const t = tournoisParses[idx];
14215    if (!t) return;
14216    const cats = catsByTournoi[idx] || [];
14217    const categorieStr = cats.length > 0 ? cats.join(' · ') : null;
14218    toInsert.push({
14219      nom: t.nom,
14220      date_debut: t.date_debut,
14221      date_fin: t.date_fin || t.date_debut,
14222      lieu: t.lieu || null,
14223      categorie: categorieStr,
14224      tenup_code: t.code || null,
14225      inscrits: [],
14226      created_by: session.user.id
14227    });
14228  });
14229
14230  if (toInsert.length === 0) {
14231    toast('Rien à importer', true);
14232    btn.disabled = false;
14233    btn.textContent = '✓ Importer la sélection';
14234    return;
14235  }
14236
14237  // L'import officiel passe par la fonction serveur : journal, rapprochement et
14238  // unicité Ten’Up sont ainsi identiques à la synchronisation automatique.
14239  const { data, error } = await db.functions.invoke('tenup-sync', {
14240    body:{ mode:'import', records:toInsert.map(t => ({...t,id:t.tenup_code,tenup_id:t.tenup_code})) }
14241  });
14242  if (error || data?.error) {
14243    journal.error('TenUp import error:', error || data.error);
14244    toast('Import indisponible : aucune donnée existante n’a été modifiée.', true);
14245    btn.disabled = false;
14246    btn.textContent = '✓ Importer la sélection';
14247    return;
14248  }
14249
14250  gestionnaireBrouillons.definirTournoisTenup([]);
14251  closeModal();
14252  toast(`${data.created} nouveau(x) · ${data.updated} mis à jour · ${data.duplicate} doublon(s) · ${data.anomalies} à vérifier · ${data.rejected} rejet(s)`);
14253  await refresh();
14254}
14255
14256// Notifie chaque joueur actif (avec compte) dont le profil correspond à au moins un
14257// des tournois importés, selon le filtre sexe + âge + classement. Une seule notification
14258// par joueur, listant le nombre de tournois pertinents.
14259async function notifierJoueursNouveauxTournois(tournoisImportes) {
14260  if (!Array.isArray(tournoisImportes) || tournoisImportes.length === 0) return;
14261  // Filtre complet forcé (les 3 critères), indépendamment des préférences d'affichage du coach
14262  const criteres = { sexe: true, age: true, classement: true };
14263  const joueurs = (listeSure(cache.joueurs)).filter(j =>
14264    (!j.statut || j.statut === 'actif') && j.user_id
14265  );
14266
14267  const notifs = [];
14268  joueurs.forEach(j => {
14269    const pertinents = tournoisImportes.filter(t => tournoiCorrespondAuJoueur(t, j, criteres));
14270    if (pertinents.length === 0) return;
14271    const titre = pertinents.length === 1
14272      ? '🎾 Nouveau tournoi pour toi'
14273      : `🎾 ${pertinents.length} nouveaux tournois pour toi`;
14274    const apercu = pertinents.slice(0, 3).map(t => t.nom).join(', ');
14275    const reste = pertinents.length > 3 ? `… (+${pertinents.length - 3})` : '';
14276    const message = pertinents.length === 1
14277      ? `${pertinents[0].nom} correspond à ton profil. Va voir dans l'onglet Tournois si tu veux t'y inscrire.`
14278      : `${apercu}${reste} correspondent à ton profil. Va voir dans l'onglet Tournois.`;
14279    notifs.push({
14280      user_id: j.user_id,
14281      type: 'nouveau_tournoi',
14282      titre,
14283      message,
14284      // Lien direct si un seul tournoi, sinon vue Tournois générale
14285      lien_type: pertinents.length === 1 ? 'tournoi' : null,
14286      lien_id: pertinents.length === 1 ? pertinents[0].id : null
14287    });
14288  });
14289
14290  if (notifs.length === 0) return;
14291  const { error } = await db.from('notifications').insert(notifs);
14292  if (error) { journal.warn('[notif tournois] insert:', error); return; }
14293}
14294
14295// ============================================================
14296// IMPORT DE L'HISTORIQUE DE CLASSEMENT (copier-coller depuis Ten'Up / FFT)
14297// ============================================================
14298// Parse le texte collé en points { date 'YYYY-MM-01', classement, horsSaison }, triés du plus ancien au plus récent.
14299// Seuls les points de la saison FFT en cours (sept. → août) sont retenus pour l'import,
14300// comme pour l'import de matchs (ne pas fausser les stats de l'année).
14301function parseClassementHisto(raw) {
14302  const [saisonDebut, saisonFin] = getPeriodeDates('saison');
14303  return TennisDomainRules.parseClassementHistorique(raw, saisonDebut, saisonFin);
14304}
14305
14306// Modal d'import (coach uniquement).
14307function openModalImportClassement(joueurId) {
14308  if (!isCoach()) { toast('Action réservée au coach', true); return; }
14309  const j = joueurById(joueurId);
14310  if (!j) { toast('Joueur introuvable', true); return; }
14311  const html = `
14312    <div class="modal-header">
14313      <h3 class="modal-title">Importer l'historique de classement</h3>
14314      <button class="modal-close" data-ui-click="closeModal()">×</button>
14315    </div>
14316    <div class="auth-info mb-2" style="font-size:13px">
14317      Sur Ten'Up, ouvre l'historique de classement de <strong>${escapeHtml(j.prenom)} ${escapeHtml(j.nom)}</strong>, sélectionne le tableau (avec ou sans l'en-tête), copie-le, puis colle-le ci-dessous. Un point par mois sera enregistré.
14318    </div>
14319    <textarea id="classementHistoRaw" rows="8" style="width:100%; font-family:monospace; font-size:12px" placeholder="Colle ici le tableau copié (Saison, Clt., Calcul de classement, …)"></textarea>
14320    <input type="hidden" id="classementHistoJoueurId" value="${joueurId}">
14321    <div class="flex gap-1 mt-2">
14322      <button class="btn flex-1" data-ui-click="closeModal()">Annuler</button>
14323      <button class="btn btn-primary flex-1" data-ui-click="previewImportClassement()">Analyser</button>
14324    </div>
14325    <div id="classementHistoPreview" style="margin-top:14px"></div>
14326  `;
14327  showModal(html);
14328}
14329
14330// Analyse + aperçu avant validation.
14331function previewImportClassement() {
14332  const raw = document.getElementById('classementHistoRaw').value;
14333  const joueurId = document.getElementById('classementHistoJoueurId').value;
14334  const preview = document.getElementById('classementHistoPreview');
14335  const tous = parseClassementHisto(raw);
14336  const points = tous.filter(p => !p.horsSaison);
14337  const nbHorsSaison = tous.length - points.length;
14338  const saison = getSaisonCourante();
14339
14340  if (tous.length === 0) {
14341    preview.innerHTML = `<div class="text-faint" style="font-size:13px; padding:10px; text-align:center">Aucune ligne reconnue. Vérifie que tu as bien collé le tableau (colonnes séparées par des tabulations).</div>`;
14342    return;
14343  }
14344  if (points.length === 0) {
14345    preview.innerHTML = `<div class="text-faint" style="font-size:13px; padding:10px; text-align:center">Aucun point dans la saison ${saison} (sept. → août). ${nbHorsSaison} point${nbHorsSaison > 1 ? 's' : ''} hors saison ignoré${nbHorsSaison > 1 ? 's' : ''}.</div>`;
14346    return;
14347  }
14348
14349  // Classements non reconnus dans l'échelle FFT (seraient mal positionné
14349s sur la courbe)
14350  const inconnus = [...new Set(points.map(p => p.classement).filter(c => CLASSEMENTS.indexOf(c) < 0))];
14351
14352  let lignes = points.map(p => {
14353    const ok = CLASSEMENTS.indexOf(p.classement) >= 0;
14354    return `<div style="display:flex; justify-content:space-between; padding:3px 0; font-size:12px; ${ok ? '' : 'color:var(--danger)'}">
14355      <span>${escapeHtml(p.source)}</span>
14356      <strong>${escapeHtml(p.classement)}${ok ? '' : ' ⚠️'}</strong>
14357    </div>`;
14358  }).join('');
14359
14360  preview.innerHTML = `
14361    <div class="card" style="padding:10px 14px">
14362      <div style="font-weight:600; font-size:13px; margin-bottom:6px">${points.length} point${points.length > 1 ? 's' : ''} dans la saison ${saison}${nbHorsSaison > 0 ? ` · <span class="text-faint">${nbHorsSaison} hors saison ignoré${nbHorsSaison > 1 ? 's' : ''}</span>` : ''}</div>
14363      <div style="max-height:200px; overflow-y:auto">${lignes}</div>
14364    </div>
14365    ${inconnus.length > 0 ? `<div class="text-faint" style="font-size:12px; margin-top:8px; color:var(--danger)">⚠️ Classement(s) non reconnu(s) dans l'échelle FFT : ${escapeHtml(inconnus.join(', '))}. Vérifie l'orthographe (ex. 30/2, 15/1).</div>` : ''}
14366    <div class="auth-info mt-2" style="font-size:12px">Cet import <strong>remplacera</strong> tout l'historique de classement existant de ce joueur.</div>
14367    <button class="btn btn-primary btn-full mt-2" data-ui-click="confirmImportClassement()">Importer ${points.length} point${points.length > 1 ? 's' : ''}</button>
14368  `;
14369}
14370
14371// Écrit en base : remplace l'historique du joueur par les points importés.
14372async function confirmImportClassement() {
14373  if (!isCoach()) { toast('Action réservée au coach', true); return; }
14374  const raw = document.getElementById('classementHistoRaw').value;
14375  const joueurId = document.getElementById('classementHistoJoueurId').value;
14376  const points = parseClassementHisto(raw).filter(p => !p.horsSaison);
14377  if (points.length === 0) { toast('Rien à importer (saison en cours)', true); return; }
14378
14379  // Construire les entrées : un point par mois. ancien = classement du mois précédent
14380  // (ou null pour le tout premier point). source = 'import' pour ne pas polluer l'onglet Perfs.
14381  const userId = session && session.user && session.user.id;
14382  const rows = points.map((p, i) => ({
14383    joueur_id: joueurId,
14384    ancien_classement: i > 0 ? points[i - 1].classement : null,
14385    nouveau_classement: p.classement,
14386    date_changement: p.date + 'T00:00:00',
14387    valide_par: userId || null,
14388    source: 'import'
14389  }));
14390
14391  const verrou = verrouillerSoumission($('modalBody'), 'Import…');
14392  if (!verrou) return;
14393
14394  // Remplacement atomique côté PostgreSQL : si validation, suppression ou
14395  // insertion échoue, l'historique précédent reste intégralement en place.
14396  let resultat;
14397  try {
14398    resultat = await db.rpc('coach_remplacer_historique_classement', {
14399      p_joueur_id: joueurId,
14400      p_points: rows.map(({ ancien_classement, nouveau_classement, date_changement }) => ({
14401        ancien_classement,
14402        nouveau_classement,
14403        date_changement
14404      }))
14405    });
14406  } finally {
14407    deverrouillerSoumission(verrou);
14408  }
14409  if (resultat.error) { toastErreur(resultat.error); return; }
14410
14411  closeModal();
14412  toast(`${rows.length} point${rows.length > 1 ? 's' : ''} de classement importé${rows.length > 1 ? 's' : ''} ✅`);
14413  await reloadTables(['joueurs', 'classement_historique', 'classement_demandes']);
14414  render();
14415}
14416
14417// ============================================================
14418// MODAL: INVITATION (coach)
14419// ============================================================
14420async function genererInvitationDepuisModal() {
14421  const id = $('inviteJoueurId').value;
14422  await genererInvitation(id);
14423  closeModal();
14424}
14425
14426let realtimeChannel = null;
14427let _realtimeTimer = null;
14428let _realtimeReconnectTimer = null;
14429const _realtimeTablesEnAttente = new Set();
14430
14431function annulerRechargeRealtime() {
14432  if (_realtimeTimer) clearTimeout(_realtimeTimer);
14433  if (_realtimeReconnectTimer) clearTimeout(_realtimeReconnectTimer);
14434  _realtimeTimer = null;
14435  _realtimeReconnectTimer = null;
14436  _realtimeTablesEnAttente.clear();
14437}
14438
14439function programmerRechargeRealtime(table) {
14440  _realtimeTablesEnAttente.add(table);
14441  if (_realtimeTimer) return;
14442  _realtimeTimer = setTimeout(async () => {
14443    _realtimeTimer = null;
14444    const tables = [..._realtimeTablesEnAttente];
14445    _realtimeTablesEnAttente.clear();
14446    try {
14447      await reloadTables(tables);
14448    } catch (e) { journal.warn('[realtime] reload groupé', tables, e); }
14449    demanderRender();
14450  }, 120);
14451}
14452
14453function rattraperDonneesRealtime() {
14454  if (document.visibilityState === 'hidden') return;
14455  const tables = ['joueurs','matches','entrainements','tournois','inscriptions_tournois','bilans','presences','seances_cours','creneaux_cours','creneaux_cours_joueurs','classement_historique','classement_demandes','creneaux_palmyre','axes_travail_palmyre','bilans_evenement','evenements','badges_joueurs','preferences_notifications','config','notifications'];
14456  if (isCoach()) tables.push('notes_joueur','observations_palmyre','debriefs_palmyre','invitations','imports_tournois');
14457  reloadTables(tables).then(demanderRender).catch(e => journal.warn('[realtime] rattrapage global', e));
14458}
14459
14460function viderDonneesSession() {
14461  annulerRechargeRealtime();
14462  gestionnaireBrouillons.purger();
14463  if (realtimeChannel) {
14464    db.removeChannel(realtimeChannel);
14465    realtimeChannel = null;
14466  }
14467  Object.keys(chartInstances).forEach(destroyChart);
14468  avatarService.vider?.();
14469  cache = creerCacheVide();
14470  exportsPdf = null;
14471  invaliderIndexMatchs();
14472  etatApp.definirSaisonAnalyse(getSaisonCourante());
14473  profile = null;
14474  document.documentElement.removeAttribute('data-startup-ms');
14475  document.documentElement.removeAttribute('data-startup-role');
14476}
14477
14478function subscribeRealtime() {
14479  annulerRechargeRealtime();
14480  if (realtimeChannel) {
14481    db.removeChannel(realtimeChannel);
14482    realtimeChannel = null;
14483  }
14484
14485  const onChange = (table) => () => programmerRechargeRealtime(table);
14486
14487  realtimeChannel = db.channel('pole-tennis-changes')
14488    .on('postgres_changes', { event: '*', schema: 'public', table: 'joueurs' }, onChange('joueurs'))
14489    .on('postgres_changes', { event: '*', schema: 'public', table: 'matches' }, onChange('matches'))
14490    .on('postgres_changes', { event: '*', schema: 'public', table: 'entrainements' }, onChange('entrainements'))
14491    .on('postgres_changes', { event: '*', schema: 'public', table: 'tournois' }, onChange('tournois'))
14492    .on('postgres_changes', { event: '*', schema: 'public', table: 'tournament_import_runs' }, onChange('imports_tournois'))
14493    .on('postgres_changes', { event: '*', schema: 'public', table: 'player_tournament_registrations' }, onChange('inscriptions_tournois'))
14494    .on('postgres_changes', { event: '*', schema: 'public', table: 'bilans' }, onChange('bilans'))
14495    .on('postgres_changes', { event: '*', schema: 'public', table: 'notes_joueur' }, onChange('notes_joueur'))
14496    .on('postgres_changes', { event: '*', schema: 'public', table: 'presences' }, onChange('presences'))
14497    .on('postgres_changes', { event: '*', schema: 'public', table: 'seances_cours' }, onChange('seances_cours'))
14498    .on('postgres_changes', { event: '*', schema: 'public', table: 'creneaux_cours' }, onChange('creneaux_cours'))
14499    .on('postgres_changes', { event: '*', schema: 'public', table: 'creneaux_cours_joueurs' }, onChange('creneaux_cours_joueurs'))
14500    .on('postgres_changes', { event: '*', schema: 'public', table: 'classement_historique' }, onChange('classement_historique'))
14501    .on('postgres_changes', { event: '*', schema: 'public', table: 'creneaux_palmyre' }, onChange('creneaux_palmyre'))
14502    .on('postgres_changes', { event: '*', schema: 'public', table: 'observations_palmyre' }, onChange('observations_palmyre'))
14503    .on('postgres_changes', { event: '*', schema: 'public', table: 'axes_travail_palmyre' }, onChange('axes_travail_palmyre'))
14504    .on('postgres_changes', { event: '*', schema: 'public', table: 'debriefs_palmyre' }, onChange('debriefs_palmyre'))
14505    .on('postgres_changes', { event: '*', schema: 'public', table: 'bilans_evenement' }, onChange('bilans_evenement'))
14506    .on('postgres_changes', { event: '*', schema: 'public', table: 'evenements' }, onChange('evenements'))
14507    .on('postgres_changes', { event: '*', schema: 'public', table: 'classement_demande' }, onChange('classement_demandes'))
14508    .on('postgres_changes', { event: '*', schema: 'public', table: 'badges_joueurs' }, onChange('badges_joueurs'))
14509    .on('postgres_changes', { event: '*', schema: 'public', table: 'config' }, onChange('config'))
14510    .on('postgres_changes', { event: '*', schema: 'public', table: 'invitations' }, onChange('invitations'))
14511    .on('postgres_changes', { event: '*', schema: 'public', table: 'preferences_notifications' }, onChange('preferences_notifications'))
14512    .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'notifications' }, async (payload) => {
14513      await loadNotifications();
14514      if (payload.new && payload.new.user_id === session?.user?.id) {
14515        toast('🔔 ' + (payload.new.titre || 'Nouvelle notification'));
14516      }
14517      demanderRender();
14518    })
14519    .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'notifications' }, async () => {
14520      await loadNotifications();
14521      demanderRender();
14522    })
14523    .on('postgres_changes', { event: 'DELETE', schema: 'public', table: 'notifications' }, async () => {
14524      await loadNotifications();
14525      demanderRender();
14526    })
14527    .subscribe(status => {
14528      if (status === 'SUBSCRIBED') {
14529        if (_realtimeReconnectTimer) clearTimeout(_realtimeReconnectTimer);
14530        _realtimeReconnectTimer = null;
14531        loadNotifications().catch(e => journal.warn('[realtime] recharge notifications', e));
14532        rattraperDonneesRealtime();
14533      }
14534      if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
14535        journal.warn('[realtime] canal indisponible, nouvelle tentative automatique', status);
14536        if (!_realtimeReconnectTimer) {
14537          _realtimeReconnectTimer = setTimeout(() => {
14538            _realtimeReconnectTimer = null;
14539            if (session?.user?.id) subscribeRealtime();
14540          }, 1500);
14541        }
14542      }
14543    });
14544}
14545
14546document.addEventListener('visibilitychange', () => {
14547  if (document.visibilityState === 'visible' && session?.user?.id) {
14548    subscribeRealtime();
14549  }
14550});
14551
14552let _renderEnAttente = false;
14553function demanderRender() {
14554  const modalEl = document.getElementById('modal');
14555  const modaleOuverte = modalEl && modalEl.classList.contains('show');
14556  if (modaleOuverte) {
14557    _renderEnAttente = true; // sera traité à la fermeture (voir closeModal)
14558    return;
14559  }
14560  render();
14561}
14562
14563let _appInitialized = false;
14564let _appStarting = false;
14565
14566async function onAuthChange(eventType) {
14567  if (_appStarting) return;
14568  _appStarting = true;
14569  const demarrageAt = performance.now();
14570  try {
14571  const { data: { session: sess } } = await db.auth.getSession();
14572  session = sess;
14573
14574  if (!session) {
14575    // Pas connecté → écran d'auth
14576    _appInitialized = false;
14577    viderDonneesSession();
14578    $('loading-screen').style.display = 'none';
14579    $('auth-screen').style.display = '';
14580    $('app-wrapper').style.display = 'none';
14581    associerLabelsFormulaires($('auth-screen'));
14582    actualiserAuditActionsUI();
14583    return;
14584  }
14585
14586  if (_appInitialized && (eventType === 'TOKEN_REFRESHED' || eventType === 'SIGNED_IN' || eventType === 'INITIAL_SESSION')) {
14587    return;
14588  }
14589
14590  // Connecté → charger profile + data
14591  profile = await avecDelai(loadProfile(), 15000, 'PROFIL_DELAI_DEPASSE');
14592  if (!profile) {
14593    toast('Profil introuvable, déconnexion', true);
14594    setTimeout(() => db.auth.signOut(), 1500);
14595    return;
14596  }
14597
14598  // Update header : on affiche uniquement le prénom (avec fallback sur l'email si absent)
14599  $('user-name').textContent = profile.prenom || profile.email;
14600  $('user-role').textContent = profile.role;
14601  updateHeaderAvatar(); // 1ère mise à jour (initiales seulement à ce stade, pas encore de cache.joueurs)
14602
14603  // Première navigation selon rôle — UNIQUEMENT au premier chargement.
14604  // Aux passages suivants, on conserve l'écran courant.
14605  if (!_appInitialized) {
14606    etatApp.definirEcran(isCoach() ? 'dashboard' : 'my');
14607  }
14608
14609  await avecDelai(loadAllData(), 30000, 'DONNEES_DELAI_DEPASSE');
14610  updateHeaderAvatar(); // 2ème mise à jour : maintenant on a accès à cache.joueurs[].photo_url
14611  updateHeaderPalmyreBadge(); // badge "Palmyre" si le joueur est en stage cette semaine
14612  await avecDelai(loadNotifications(), 15000, 'NOTIFICATIONS_DELAI_DEPASSE');
14613
14614  Promise.all([
14615    checkAndCreateTournoiReminders().catch(e => journal.warn('[rappel tournoi]', e)),
14616    checkAndCreateTrimestreReminders().catch(e => journal.warn('[rappel trimestre]', e)),
14617    checkAndCreateSaisieMatchReminders().catch(e => journal.warn('[rappel saisie]', e)),
14618    (!isCoach() ? genererRappelsPresenceJoueur().catch(e => journal.warn('[rappel présence]', e)) : Promise.resolve())
14619  ]).then(() => loadNotifications()).catch(e => journal.warn('[rappels arrière-plan]', e));
14620
14621  subscribeRealtime();
14622
14623  // Rattrapage automatique des badges (coach uniquement, une fois par session, en
14624  // arrière-plan) : enregistre les badges déjà mérités par tous les joueurs pour que
14625  // le fil d'actualité de l'académie soit complet. Ne bloque pas l'affichage.
14626  setTimeout(() => { rattrapageBadgesAuto().catch(e => journal.warn('[rattrapage auto]', e)); }, 2000);
14627
14628  $('loading-screen').style.display = 'none';
14629  $('auth-screen').style.display = 'none';
14630  $('app-wrapper').style.display = '';
14631
14632  _appInitialized = true;
14633
14634  buildNavMenu();
14635  render();
14636
14637  // Mesure locale uniquement : aucun envoi réseau et aucune donnée nominative.
14638  // Sert à décider sur des chiffres si les historiques doivent être chargés à la demande.
14639  const demarrageMs = Math.round(performance.now() - demarrageAt);
14640  document.documentElement.dataset.startupMs = String(demarrageMs);
14641  document.documentElement.dataset.startupRole = isCoach() ? 'coach' : 'joueur';
14642  journal.info('[performance démarrage]', {
14643    duree_ms: demarrageMs,
14644    role: document.documentElement.dataset.startupRole,
14645    joueurs: (listeSure(cache.joueurs)).length,
14646    matches: (listeSure(cache.matchesSaison)).length,
14647    entrainements: (listeSure(cache.entrainements)).length,
14648    presences: (listeSure(cache.presences)).length
14649  });
14650
14651  // Affiche les nouveautés si l'app vient d'être mise à jour
14652  checkNouveautes();
14653  } catch (error) {
14654    const idErreur = creerIdErreur();
14655    journal.error(`[démarrage application ${idErreur}]`, error);
14656    _appInitialized = false;
14657    const loading = $('loading-screen');
14658    if (loading) {
14659      loading.style.display = '';
14660      loading.innerHTML = `<div class="card" style="max-width:430px;margin:24px;padding:22px;text-align:center;border-top:4px solid var(--danger)">
14661        <div style="font-size:20px;font-weight:800;margin-bottom:8px">Impossible de charger l'application</div>
14662        <div class="text-dim" style="font-size:13px;margin-bottom:8px">${navigator.onLine === false ? 'Tu es actuellement hors ligne. Reconnecte-toi puis réessaie.' : "Vérifie ta connexion puis réessaie. Tes données n'ont pas été modifiées."}</div>
14663        <div class="text-faint" style="font-size:11px;margin-bottom:16px">Référence : ${idErreur}</div>
14664        <button type="button" class="btn btn-primary btn-full" data-ui-click="retryAppStart()">Réessayer</button>
14665      </div>`;
14666    }
14667    $('auth-screen').style.display = 'none';
14668    $('app-wrapper').style.display = 'none';
14669  } finally {
14670    _appStarting = false;
14671  }
14672}
14673
14674function retryAppStart() {
14675  const loading = $('loading-screen');
14676  if (loading) loading.innerHTML = '<div class="spinner"></div><span class="sr-only">Nouvelle tentative de chargement…</span>';
14677  onAuthChange('RETRY');
14678}
14679
14680db.auth.onAuthStateChange((_event, _session) => {
14681  onAuthChange(_event).catch(error => journal.error('[auth non gérée]', error));
14682});
14683
14684window.addEventListener('offline', mettreAJourEtatReseau);
14685window.addEventListener('online', () => {
14686  mettreAJourEtatReseau();
14687  toast('Connexion rétablie');
14688  if (!_appInitialized && session) retryAppStart();
14689});
14690mettreAJourEtatReseau();
14691
14692$('modal').addEventListener('click', (e) => {
14693  if (e.target.id === 'modal') closeModal();
14694});
14695
14696document.addEventListener('click', (e) => {
14697  // Fermer le panneau notifications si clic en dehors
14698  const panel = $('notifsPanel');
14699  const btn = $('btnNotifs');
14700  if (panel && panel.style.display !== 'none') {
14701    if (!panel.contains(e.target) && btn && !btn.contains(e.target)) {
14702      panel.style.display = 'none';
14703    }
14704  }
14705  // Fermer le menu utilisateur si clic en dehors
14706  const userMenu = $('userMenu');
14707  const userBtn = $('userMenuBtn');
14708  if (userMenu && userMenu.style.display !== 'none') {
14709    if (!userMenu.contains(e.target) && userBtn && !userBtn.contains(e.target)) {
14710      userMenu.style.display = 'none';
14711    }
14712  }
14713});
14714
14715let pendingUpdateWorker = null;
14716const SW_UPDATE_INTERVAL_MS = 30 * 1000;
14717const SW_UPDATE_THROTTLE_MS = 20 * 1000;
14718let derniereVerificationSw = 0;
14719
14720function verifierServiceWorker(reg, { force = false } = {}) {
14721  if (!reg) return Promise.resolve(false);
14722  const maintenant = Date.now();
14723  if (!force && maintenant - derniereVerificationSw < SW_UPDATE_THROTTLE_MS) {
14724    return Promise.resolve(false);
14725  }
14726  derniereVerificationSw = maintenant;
14727  return reg.update().then(() => true).catch(() => false);
14728}
14729
14730if ('serviceWorker' in navigator && location.protocol !== 'file:') {
14731  navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' }).then((reg) => {
14732    window._swRegistration = reg;
14733
14734    setInterval(() => verifierServiceWorker(reg), SW_UPDATE_INTERVAL_MS);
14735
14736    document.addEventListener('visibilitychange', () => {
14737      if (document.visibilityState === 'visible') {
14738        verifierServiceWorker(reg);
14739        rafraichirAvatarsAuPremierPlan().catch(error => journal.warn('[avatars] renouvellement:', error));
14740      }
14741    });
14742    window.addEventListener('focus', () => {
14743      verifierServiceWorker(reg);
14744      rafraichirAvatarsAuPremierPlan().catch(error => journal.warn('[avatars] renouvellement:', error));
14745    });
14746
14747    if (reg.waiting && navigator.serviceWorker.controller) {
14748      pendingUpdateWorker = reg.waiting;
14749      showUpdateBanner();
14750    }
14751
14752    reg.addEventListener('updatefound', () => {
14753      const newWorker = reg.installing;
14754      if (newWorker) {
14755        newWorker.addEventListener('statechange', () => {
14756          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
14757            pendingUpdateWorker = newWorker;
14758            showUpdateBanner();
14759          }
14760        });
14761      }
14762    });
14763
14764    // Quand le SW prend le contrôle (après que l'utilisateur a cliqué "Recharger"), on reload
14765    let refreshing = false;
14766    navigator.serviceWorker.addEventListener('controllerchange', () => {
14767      if (refreshing) return;
14768      refreshing = true;
14769      window.location.reload();
14770    });
14771
14772    verifierServiceWorker(reg, { force: true });
14773  }).catch((e) => journal.warn('[SW] register failed:', e));
14774}
14775
14776function attendreWorkerEnAttente(reg, delaiMs = 8000) {
14777  return new Promise((resolve) => {
14778    const debut = Date.now();
14779    const verifier = () => {
14780      const worker = reg?.waiting || pendingUpdateWorker;
14781      if (worker) {
14782        pendingUpdateWorker = worker;
14783        resolve(worker);
14784        return;
14785      }
14786      if (Date.now() - debut >= delaiMs) {
14787        resolve(null);
14788        return;
14789      }
14790      setTimeout(verifier, 150);
14791    };
14792    verifier();
14793  });
14794}
14795
14796// Vérification manuelle des mises à jour (bouton dans le menu utilisateur).
14797async function verifierMajManuelle() {
14798  const menu = $('userMenu'); if (menu) menu.style.display = 'none';
14799  const reg = window._swRegistration;
14800  if (!reg) { toast('Vérification indisponible', true); return; }
14801  toast('Recherche de mise à jour…');
14802  try {
14803    await verifierServiceWorker(reg, { force: true });
14804    // L'installation peut prendre plusieurs secondes sur un réseau mobile.
14805    // Attendre réellement le worker évite d'annoncer à tort que l'app est à jour.
14806    const worker = await attendreWorkerEnAttente(reg);
14807    if (worker && navigator.serviceWorker.controller) showUpdateBanner();
14808    else toast('Tu es déjà à la dernière version ✅');
14809  } catch (e) {
14810    toast('Vérification impossible pour le moment', true);
14811  }
14812}
14813
14814function showUpdateBanner() {
14815  // Évite les doublons
14816  if (document.getElementById('updateBanner')) return;
14817
14818  const banner = document.createElement('div');
14819  banner.id = 'updateBanner';
14820  banner.style.cssText = 'position:fixed; top:0; left:0; right:0; z-index:9999; background:#071d1f; color:#fffdf8; padding:calc(10px + env(safe-area-inset-top, 0px)) max(16px, env(safe-area-inset-right, 0px)) 10px max(16px, env(safe-area-inset-left, 0px)); border-bottom:3px solid #ff7900; display:flex; align-items:center; justify-content:space-between; gap:12px; box-shadow:0 12px 30px rgba(0,0,0,0.32); font-size:14px; font-weight:700';
14821  banner.innerHTML = `
14822    <div style="display:flex; align-items:center; gap:10px; flex:1; min-width:0">
14823      <img src="assets/v4/menu/update.webp" alt="" style="width:34px;height:34px;object-fit:contain;filter:drop-shadow(0 3px 5px rgba(0,0,0,.3))">
14824      <span style="overflow:hidden; text-overflow:ellipsis">Nouvelle version disponible</span>
14825    </div>
14826    <div style="display:flex; gap:8px; align-items:center; flex-shrink:0">
14827      <button data-ui-click="applyUpdate()" style="background:#dfff35; color:#071d1f; border:0; padding:8px 16px; clip-path:polygon(6px 0,100% 0,calc(100% - 6px) 100%,0 100%); font-weight:800; cursor:pointer; font-size:13px">Recharger</button>
14828      <button data-ui-click="dismissUpdateBanner()" title="Plus tard" aria-label="Fermer l'annonce de mise à jour" style="background:transparent; color:#fffdf8; border:1px solid rgba(255,255,255,0.45); border-radius:0; width:30px; height:30px; cursor:pointer; font-size:18px; padding:0; line-height:1">×</button>
14829    </div>
14830  `;
14831  document.body.insertBefore(banner, document.body.firstChild);
14832
14833  // Décale le contenu pour ne pas être caché par le bandeau
14834  document.body.style.paddingTop = banner.offsetHeight + 'px';
14835}
14836
14837function applyUpdate() {
14838  // On relit reg.waiting AU MOMENT DU CLIC (la référence mémorisée peut être périmée :
14839  // worker déjà activé par un autre onglet, devenu redondant, etc.).
14840  const reg = window._swRegistration;
14841  const worker = (reg && reg.waiting) || pendingUpdateWorker;
14842  if (worker) {
14843    worker.postMessage({ type: 'SKIP_WAITING' });
14844    // Filet de sécurité : 'controllerchange' ne se déclenche QUE si skipWaiting produit
14845    // réellement un changement de contrôleur. En cas de course (worker déjà actif,
14846    // redondant, autre onglet), l'event ne vient jamais → sans ce fallback, le bouton
14847    // « ne fait rien ». On force donc le rechargement si rien ne s'est produit sous 2 s.
14848    // (Si controllerchange se déclenche d'abord, la page a déjà navigué → ce timer ne
14849    //  s'exécute pas.)
14850    setTimeout(() => { window.location.reload(); }, 2000);
14851  } else {
14852    // Aucun worker en attente : on recharge directement.
14853    window.location.reload();
14854  }
14855}
14856
14857function dismissUpdateBanner() {
14858  const banner = document.getElementById('updateBanner');
14859  if (banner) {
14860    banner.remove();
14861    document.body.style.paddingTop = '';
14862  }
14863  clearTimeout(window._updateBannerReminder);
14864  window._updateBannerReminder = setTimeout(() => {
14865    const reg = window._swRegistration;
14866    if (reg?.waiting) showUpdateBanner();
14867  }, 30 * 60 * 1000);
14868}
14869
14870// Démarrage
14871handlePasswordRecovery();
14872onAuthChange();
14873loadAppVersion();
14873 // Affiche la version sur l'écran de login et le menu

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.