1// ============================================================================ 2// Page publique : calendrier + formulaire de proposition 3// ============================================================================ 4let toutesLesCourses = []; 5let listeFiltree = []; 6let vueActive = "liste"; 7let moisCalendrier = null; // 1er jour du mois affiché 8let calInitialise = false; 9 10document.addEventListener("DOMContentLoaded", async () => { 11 brancherEvenements(); 12 13 if (configIncomplete()) { 14 peuplerReferentiels(); 15 document.getElementById("bandeau-config").classList.remove("masque"); 16 document.getElementById("spinner").classList.add("masque"); 17 document.getElementById("compte-resultats").textContent = ""; 18 return; 19 } 20 await chargerParametres(); 21 peuplerReferentiels(); 22 chargerCourses(); 23}); 24 25// --- Chargement ------------------------------------------------------------ 26async function chargerCourses() { 27 const codesVisibles = STATUTS_LISTE.filter((s) => s.visible_public).map((s) => s.code); 28 const { data, error } = await sb 29 .from("courses") 30 .select("*") 31 .in("statut", codesVisibles.length ? codesVisibles : ["VALIDEE", "A CONFIRMER", "PASSEE", "ANNULEE"]) 32 .order("date_debut", { ascending: true }); 33 34 document.getElementById("spinner").classList.add("masque"); 35 36 if (error) { 37 document.getElementById("zone-liste").innerHTML = 38 `<div class="message-vide"><span class="emoji">â ï¸</span> 39 Impossible de charger le calendrier.<br /><small>${echapper(error.message)}</small></div>`; 40 return; 41 } 42 43 toutesLesCourses = data || []; 44 remplirSaisons(); 45 remplirLigues(); 46 appliquerFiltres(); 47} 48 49function remplirLigues() { 50 const ligues = [...new Set(toutesLesCourses.map((c) => c.ligue).filter(Boolean))].sort(); 51 const sel = document.getElementById("f-ligue"); 52 sel.length = 1; // conserve « Toutes » 53 ligues.forEach((l) => sel.add(new Option(l, l))); 54 const dl = document.getElementById("prop-liste-ligues"); 55 if (dl) dl.innerHTML = ligues.map((l) => `<option value="${echapper(l)}"></option>`).join(""); 56} 57 58function remplirSaisons() { 59 const saisons = [...new Set(toutesLesCourses.map((c) => c.saison).filter(Boolean))].sort().reverse(); 60 const sel = document.getElementById("f-saison"); 61 saisons.forEach((s) => { 62 const o = document.createElement("option"); 63 o.value = s; o.textContent = s; 64 sel.appendChild(o); 65 }); 66} 67 68// --- Filtrage -------------------------------------------------------------- 69function appliquerFiltres() { 70 const q = document.getElementById("f-recherche").value.trim().toLowerCase(); 71 const saison = document.getElementById("f-saison").value; 72 const classe = document.getElementById("f-classe").value; 73 const ligue = document.getElementById("f-ligue").value; 74 const type = document.getElementById("f-type").value; 75 const periode = document.getElementById("f-periode").value; 76 const auj = new Date(); auj.setHours(0, 0, 0, 0); 77 78 let liste = toutesLesCourses.filter((c) => { 79 if (saison && c.saison !== saison) return false; 80 if (classe && !(c.classes || []).includes(classe)) return false; 81 if (ligue && c.ligue !== ligue) return false; 82 if (type && c.type_evenement !== type) return false; 83 84 const fin = parseDate(c.date_fin || c.date_debut); 85 if (periode === "avenir" && fin && fin < auj) return false; 86 if (periode === "passees" && (!fin || fin >= auj)) return false; 87 88 if (q) { 89 const foin = `${c.nom || ""} ${c.lieu || ""} ${c.club || ""} ${c.ligue || ""} ${c.type_evenement || ""} ${(c.classes || []).join(" ")}`.toLowerCase(); 90 if (!foin.includes(q)) return false; 91 } 92 return true; 93 }); 94 95 // Tri : les passées en ordre décroissant, sinon croissant. 96 if (periode === "passees") { 97 liste.sort((a, b) => (b.date_debut || "").localeCompare(a.date_debut || "")); 98 } else { 99 liste.sort((a, b) => (a.date_debut || "").localeCompare(b.date_debut || "")); 100 } 101 102 listeFiltree = liste; 103 rendreVue(); 104} 105 106// --- Aiguillage entre les deux vues --------------------------------------- 107function rendreVue() { 108 const compte = document.getElementById("compte-resultats"); 109 compte.textContent = listeFiltree.length 110 ? `${listeFiltree.length} course${listeFiltree.length > 1 ? "s" : ""}` 111 : ""; 112 document.getElementById("btn-ical").disabled = listeFiltree.length === 0; 113 114 if (vueActive === "calendrier") { 115 document.getElementById("zone-liste").classList.add("masque"); 116 document.getElementById("zone-calendrier").classList.remove("masque"); 117 if (!calInitialise) { 118 moisCalendrier = moisParDefaut(); 119 calInitialise = true; 120 } 121 afficherCalendrier(); 122 } else { 123 document.getElementById("zone-calendrier").classList.add("masque"); 124 document.getElementById("zone-liste").classList.remove("masque"); 125 afficher(listeFiltree); 126 } 127} 128 129// Mois initial : celui de la 1re course à venir, sinon la 1re de la liste. 130function moisParDefaut() { 131 const auj = new Date(); auj.setHours(0, 0, 0, 0); 132 const avenir = listeFiltree.find((c) => { 133 const d = parseDate(c.date_debut); 134 return d && d >= auj; 135 }); 136 const ref = avenir || listeFiltree[0]; 137 const d = ref ? parseDate(ref.date_debut) : auj; 138 return new Date(d.getFullYear(), d.getMonth(), 1); 139} 140 141// --- Affichage ------------------------------------------------------------- 142function afficher(liste) { 143 const zone = document.getElementById("zone-liste"); 144 const compte = document.getElementById("compte-resultats"); 145 compte.textContent = liste.length 146 ? `${liste.length} course${liste.length > 1 ? "s" : ""}` 147 : ""; 148 149 if (!liste.length) { 150 zone.innerHTML = `<div class="message-vide"><span class="emoji">ð</span> 151 Aucune course ne correspond à votre recherche.</div>`; 152 return; 153 } 154 155 // Regroupement par mois. 156 const groupes = new Map(); 157 liste.forEach((c) => { 158 const cle = (c.date_debut || "0000-00").slice(0, 7); 159 if (!groupes.has(cle)) groupes.set(cle, []); 160 groupes.get(cle).push(c); 161 }); 162 163 let html = ""; 164 for (const [, courses] of groupes) { 165 html += `<h2 class="mois-titre">${echapper(moisAnnee(courses[0].date_debut))}</h2>`; 166 html += `<div class="grille">${courses.map(carte).join("")}</div>`; 167 } 168 zone.innerHTML = html; 169} 170 171function carte(c) { 172 const eff = statutEffectif(c); 173 const couleur = couleurType(c.type_evenement); 174 const classes = (c.classes || []) 175 .map((cl) => `<span class="classe-tag">${echapper(cl)}</span>`) 176 .join(""); 177 const liens = [ 178 lienSurOuVide(c.inscriptions_url, "S'inscrire"), 179 lienSurOuVide(c.ers_url, "Avis de course (ERS)"), 180 lienSurOuVide(c.resultat_url, "Résultats"),
181 ].filter(Boolean).join(""); 182 const modif = eff === "ANNULEE" ? " est-annulee" : eff === "PASSEE" ? " est-passee" : ""; 183 184 return ` 185 <article class="course${modif}" style="border-left-color:${echapper(couleur)}"> 186 <div class="haut"> 187 <span class="date">${echapper(formatPlage(c.date_debut, c.date_fin))}</span> 188 ${pastilleHTML(c)} 189 </div> 190 <h3>${echapper(c.nom || "Ãvénement")}</h3> 191 <div class="lieu">ð ${echapper(c.lieu || "Lieu à définir")}</div> 192 ${c.club ? `<div class="lieu club-ligne">ð¥ ${echapper(c.club)}</div>` : ""} 193 ${c.ligue ? `<div class="lieu club-ligne">ðï¸ ${echapper(c.ligue)}</div>` : ""} 194 ${c.type_evenement ? `<div><span class="type" style="--c:${echapper(couleur)}">${echapper(c.type_evenement)}</span></div>` : ""} 195 ${classes ? `<div class="classes">${classes}</div>` : ""} 196 ${c.directeur_course ? `<div class="arbitre">ð Directeur de course : ${echapper(c.directeur_course)}</div>` : ""} 197 ${c.directeur_jury ? `<div class="arbitre">âï¸ Président de jury : ${echapper(c.directeur_jury)}</div>` : ""} 198 ${liens ? `<div class="liens">${liens}</div>` : ""} 199 <div class="course-actions"> 200 <button class="lien-ical" type="button" data-ical="${echapper(c.id)}" title="Télécharger cet évènement (.ics)">ð Ajouter à mon agenda</button> 201 </div> 202 </article>`; 203} 204 205// --- Vue calendrier -------------------------------------------------------- 206const JOURS_COURTS = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]; 207 208function afficherCalendrier() { 209 const zone = document.getElementById("zone-calendrier"); 210 const annee = moisCalendrier.getFullYear(); 211 const mois = moisCalendrier.getMonth(); 212 213 // Index des courses par jour (clé AAAA-MM-JJ) sur le mois affiché. 214 const parJour = new Map(); 215 listeFiltree.forEach((c) => { 216 const d = parseDate(c.date_debut); 217 const f = parseDate(c.date_fin || c.date_debut) || d; 218 if (!d) return; 219 for (let cur = new Date(d); cur <= f; cur.setDate(cur.getDate() + 1)) { 220 if (cur.getFullYear() === annee && cur.getMonth() === mois) { 221 const cle = isoLocal(cur); 222 if (!parJour.has(cle)) parJour.set(cle, []); 223 parJour.get(cle).push(c); 224 } 225 } 226 }); 227 228 const premier = new Date(annee, mois, 1); 229 const offset = (premier.getDay() + 6) % 7; // lundi = 0 230 const nbJours = new Date(annee, mois + 1, 0).getDate(); 231 const auj = new Date(); auj.setHours(0, 0, 0, 0); 232 233 let html = ` 234 <div class="cal-tete"> 235 <button class="cal-nav" id="cal-prec" aria-label="Mois précédent">â¹</button> 236 <h2>${MOIS[mois]} ${annee}</h2> 237 <button class="cal-nav" id="cal-suiv" aria-label="Mois suivant">âº</button> 238 </div> 239 <div class="cal-grille">`; 240 html += JOURS_COURTS.map((j) => `<div class="cal-jour-nom">${j}</div>`).join(""); 241 242 // Cases vides avant le 1er du mois. 243 for (let i = 0; i < offset; i++) html += `<div class="cal-case hors-mois"></div>`; 244 245 for (let jour = 1; jour <= nbJours; jour++) { 246 const dateCase = new Date(annee, mois, jour); 247 const cle = isoLocal(dateCase); 248 const evts = parJour.get(cle) || []; 249 const estAuj = dateCase.getTime() === auj.getTime(); 250 html += `<div class="cal-case${estAuj ? " aujourdhui" : ""}"> 251 <span class="cal-num">${jour}</span>`; 252 evts.slice(0, 3).forEach((c) => { 253 const eff = statutEffectif(c); 254 const cls = eff === "ANNULEE" ? " est-annulee" : ""; 255 html += `<span class="cal-evt${cls}" style="border-left-color:${echapper(couleurType(c.type_evenement))}" data-id="${c.id}" title="${echapper((c.nom || "") + " â " + (c.lieu || "") + " â " + statutInfo(eff).libelle)}">${echapper(c.nom || c.lieu || "Course")}</span>`; 256 }); 257 if (evts.length > 3) { 258 html += `<span class="cal-plus" data-id="${evts[3].id}">+${evts.length - 3} autre(s)</span>`; 259 } 260 html += `</div>`; 261 } 262 html += `</div>`; 263 zone.innerHTML = html; 264 265 document.getElementById("cal-prec").addEventListener("click", () => decalerMois(-1)); 266 document.getElementById("cal-suiv").addEventListener("click", () => decalerMois(1)); 267 zone.querySelectorAll("[data-id]").forEach((el) => 268 el.addEventListener("click", () => ouvrirDetail(el.dataset.id)) 269 ); 270} 271 272function decalerMois(delta) { 273 moisCalendrier = new Date(moisCalendrier.getFullYear(), moisCalendrier.getMonth() + delta, 1); 274 afficherCalendrier(); 275} 276 277function isoLocal(d) {
278 const m = String(d.getMonth() + 1).padStart(2, "0"); 279 const j = String(d.getDate()).padStart(2, "0"); 280 return `${d.getFullYear()}-${m}-${j}`; 281} 282 283function ouvrirDetail(id) { 284 const c = listeFiltree.find((x) => x.id === id) || toutesLesCourses.find((x) => x.id === id); 285 if (!c) return; 286 document.getElementById("detail-corps").innerHTML = carte(c); 287 document.getElementById("modale-detail").classList.add("ouverte"); 288} 289 290// --- Export iCalendar (.ics) ---------------------------------------------- 291const STATUT_ICAL = { VALIDEE: "CONFIRMED", "A CONFIRMER": "TENTATIVE", ANNULEE: "CANCELLED" }; 292 293// Construit un bloc VEVENT pour une course (ou renvoie [] si sans date). 294function veventPourCourse(c, stamp) { 295 const debut = parseDate(c.date_debut); 296 if (!debut) return []; 297 const fin = parseDate(c.date_fin || c.date_debut) || debut; 298 const finExclusive = new Date(fin); 299 finExclusive.setDate(finExclusive.getDate() + 1); // DTEND non inclus (évènement « journée ») 300 301 const desc = [ 302 c.type_evenement ? `Type : ${c.type_evenement}` : "", 303 c.club ? `Club : ${c.club}` : "", 304 c.ligue ? `Ligue : ${c.ligue}` : "", 305 (c.classes || []).length ? `Classes : ${(c.classes || []).join(", ")}` : "", 306 c.directeur_course ? `Directeur de course : ${c.directeur_course}` : "", 307 c.directeur_jury ? `Président de jury : ${c.directeur_jury}` : "", 308 c.inscriptions_url ? `Inscriptions : ${c.inscriptions_url}` : "", 309 c.resultat_url ? `Résultats : ${c.resultat_url}` : "", 310 ].filter(Boolean).join("\n"); 311 312 const lignes = ["BEGIN:VEVENT", `UID:${c.id}@calendrier-ffcv`, `DTSTAMP:${stamp}`, 313 `DTSTART;VALUE=DATE:${icalDate(debut)}`, `DTEND;VALUE=DATE:${icalDate(finExclusive)}`, 314 icalPlier(`SUMMARY:${icalEsc(c.nom || "Course")}${c.lieu ? " â " + icalEsc(c.lieu) : ""}`)]; 315 if (c.lieu) lignes.push(icalPlier(`LOCATION:${icalEsc(c.lieu)}`)); 316 if (desc) lignes.push(icalPlier(`DESCRIPTION:${icalEsc(desc)}`)); 317 if (STATUT_ICAL[c.statut]) lignes.push(`STATUS:${STATUT_ICAL[c.statut]}`); 318 lignes.push("END:VEVENT"); 319 return lignes; 320} 321 322// Construit le contenu .ics complet à partir d'une liste de courses. 323function construireICS(courses) { 324 const now = new Date(); 325 const stamp = 326 now.getUTCFullYear() + pad2(now.getUTCMonth() + 1) + pad2(now.getUTCDate()) + "T" + 327 pad2(now.getUTCHours()) + pad2(now.getUTCMinutes()) + pad2(now.getUTCSeconds()) + "Z"; 328 const lignes = [ 329 "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//FFCV//Calendrier des courses//FR", 330 "CALSCALE:GREGORIAN", "METHOD:PUBLISH", "X-WR-CALNAME:Courses de char à voile", 331 ]; 332 courses.forEach((c) => lignes.push(...veventPourCourse(c, stamp))); 333 lignes.push("END:VCALENDAR"); 334 return lignes.join("\r\n"); 335} 336 337// Déclenche le téléchargement d'un fichier .ics. 338function telechargerICS(contenu, nomFichier) { 339 const blob = new Blob([contenu], { type: "text/calendar;charset=utf-8" }); 340 const url = URL.createObjectURL(blob); 341 const a = document.createElement("a"); 342 a.href = url; 343 a.download = nomFichier; 344 document.body.appendChild(a); 345 a.click(); 346 a.remove(); 347 URL.revokeObjectURL(url); 348} 349 350// Nom de fichier « propre » pour une course. 351function nomFichierCourse(c) { 352 const base = `${c.nom || "course"}-${c.lieu || ""}` 353 .toLowerCase().normalize("NFD").replace(/[Ì-ͯ]/g, "") 354 .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50); 355 return `${base || "course"}.ics`; 356} 357 358// Export de toutes les courses affichées (filtres appliqués). 359function exporterICal() { 360 if (!listeFiltree.length) return; 361 telechargerICS(construireICS(listeFiltree), "calendrier-courses-charavoile.ics"); 362} 363 364// Export d'une seule course (bouton « Ajouter à mon agenda »). 365function exporterCourseICal(id) { 366 const c = listeFiltree.find((x) => x.id === id) || toutesLesCourses.find((x) => x.id === id); 367 if (!c) return; 368 telechargerICS(construireICS([c]), nomFichierCourse(c)); 369} 370 371function pad2(n) { return String(n).padStart(2, "0"); } 372function icalDate(d) { return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}`; } 373function icalEsc(s) { 374 return String(s).replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); 375} 376// Pliage des lignes à 75 octets (recommandation RFC 5545). 377function icalPlier(ligne) { 378 if (ligne.length <= 74) return ligne; 379 let out = "", reste = ligne; 380 while (reste.length > 74) { 381 out += reste.slice(0, 74) + "\r\n "; 382 reste = reste.slice(74); 383 } 384 return out + reste; 385} 386 387// --- Référentiels & évènements -------------------------------------------- 388function peuplerReferentiels() { 389 const selClasse = document.getElementById("f-classe"); 390 selClasse.length = 1; // conserve « Toutes » 391 CLASSES.forEach((cl) => selClasse.add(new Option(cl, cl))); 392 393 const selType = document.getElementById("f-type"); 394 selType.length = 1; // conserve « Tous »
395 nomsTypes().forEach((t) => selType.add(new Option(t, t))); 396 397 const propType = document.getElementById("prop-type"); 398 propType.length = 0; 399 propType.add(new Option("â", "")); 400 nomsTypes().forEach((t) => propType.add(new Option(t, t))); 401 402 const box = document.getElementById("prop-classes"); 403 box.innerHTML = CLASSES.map( 404 (cl) => `<label class="case-classe"><input type="checkbox" name="classe" value="${echapper(cl)}" /> ${echapper(cl)}</label>` 405 ).join(""); 406} 407 408function brancherEvenements() { 409 ["f-recherche", "f-saison", "f-classe", "f-ligue", "f-type", "f-periode"].forEach((id) => { 410 const el = document.getElementById(id); 411 el.addEventListener("input", appliquerFiltres); 412 el.addEventListener("change", appliquerFiltres); 413 }); 414 415 document.getElementById("btn-reset").addEventListener("click", () => { 416 document.getElementById("f-recherche").value = ""; 417 document.getElementById("f-saison").value = ""; 418 document.getElementById("f-classe").value = ""; 419 document.getElementById("f-ligue").value = ""; 420 document.getElementById("f-type").value = ""; 421 document.getElementById("f-periode").value = "avenir"; 422 appliquerFiltres(); 423 }); 424 425 // Modale 426 const modale = document.getElementById("modale-proposer"); 427 document.getElementById("btn-proposer").addEventListener("click", () => { 428 if (configIncomplete()) { 429 alert("Le formulaire nécessite la configuration Supabase (js/config.js)."); 430 return; 431 } 432 modale.classList.add("ouverte"); 433 }); 434 modale.querySelectorAll("[data-fermer]").forEach((b) => 435 b.addEventListener("click", () => modale.classList.remove("ouverte")) 436 ); 437 modale.addEventListener("click", (e) => { 438 if (e.target === modale) modale.classList.remove("ouverte"); 439 }); 440 441 document.getElementById("form-proposer").addEventListener("submit", envoyerProposition); 442 443 // Bascule liste / calendrier 444 document.getElementById("vue-liste").addEventListener("click", () => basculerVue("liste")); 445 document.getElementById("vue-calendrier").addEventListener("click", () => basculerVue("calendrier")); 446 447 // Export iCal (global) 448 document.getElementById("btn-ical").addEventListener("click", exporterICal); 449 450 // Export iCal d'une seule course (délégation : cartes + modale de détail) 451 document.addEventListener("click", (e) => { 452 const b = e.target.closest("[data-ical]"); 453 if (b) { e.preventDefault(); exporterCourseICal(b.dataset.ical); } 454 }); 455 456 // Modale détail 457 const md = document.getElementById("modale-detail"); 458 md.querySelectorAll("[data-fermer]").forEach((b) => 459 b.addEventListener("click", () => md.classList.remove("ouverte")) 460 ); 461 md.addEventListener("click", (e) => { if (e.target === md) md.classList.remove("ouverte"); }); 462} 463 464function basculerVue(vue) { 465 vueActive = vue; 466 document.getElementById("vue-liste").classList.toggle("actif", vue === "liste"); 467 document.getElementById("vue-calendrier").classList.toggle("actif", vue === "calendrier"); 468 rendreVue(); 469} 470 471// --- Envoi d'une proposition ---------------------------------------------- 472async function envoyerProposition(e) { 473 e.preventDefault(); 474 const form = e.target; 475 const alerte = document.getElementById("prop-alerte"); 476 const btn = document.getElementById("btn-envoyer-prop"); 477 alerte.innerHTML = ""; 478 479 const fd = new FormData(form); 480 const classes = [...form.querySelectorAll('input[name="classe"]:checked')].map((c) => c.value); 481 482 const debut = fd.get("date_debut"); 483 let fin = fd.get("date_fin") || debut; 484 if (fin < debut) fin = debut; 485 486 const course = { 487 statut: "PROPOSEE", 488 nom: fd.get("nom").trim(), 489 lieu: fd.get("lieu").trim(), 490 ligue: (fd.get("ligue") || "").trim() || null, 491 type_evenement: fd.get("type_evenement") || null, 492 date_debut: debut, 493 date_fin: fin, 494 classes, 495 inscriptions_url: (fd.get("inscriptions_url") || "").trim() || null, 496 proposeur_nom: fd.get("proposeur_nom").trim(), 497 proposeur_email: fd.get("proposeur_email").trim(), 498 proposeur_message: (fd.get("proposeur_message") || "").trim() || null, 499 saison: saisonDepuisDate(debut), 500 }; 501 502 btn.disabled = true; 503 btn.textContent = "Envoiâ¦"; 504 const { error } = await sb.from("courses").insert(course); 505 btn.disabled = false;
506 btn.textContent = "Envoyer la proposition"; 507 508 if (error) { 509 alerte.innerHTML = `<div class="alerte alerte-err">Ãchec de l'envoi : ${echapper(error.message)}</div>`; 510 return; 511 } 512 513 // Notifie le webhook Make avec les informations saisies (fire-and-forget). 514 notifierWebhookProposition(course); 515 516 form.reset(); 517 alerte.innerHTML = `<div class="alerte alerte-ok"> 518 â Merci ! Votre proposition a bien été transmise. Elle sera publiée après 519 validation par un administrateur.</div>`; 520 setTimeout(() => { 521 document.getElementById("modale-proposer").classList.remove("ouverte"); 522 alerte.innerHTML = ""; 523 }, 3500); 524} 525 526// Envoie les informations d'une proposition à un webhook externe (facultatif). 527// Ne bloque pas l'utilisateur et n'affecte pas le message de confirmation. 528function notifierWebhookProposition(course) { 529 const webhook = (window.APP_CONFIG && window.APP_CONFIG.MAKE_WEBHOOK) || ""; 530 if (!webhook) return; 531 const params = new URLSearchParams({ 532 type: "proposition", 533 nom: course.nom || "", 534 lieu: course.lieu || "", 535 ligue: course.ligue || "", 536 type_evenement: course.type_evenement || "", 537 date_debut: course.date_debut || "", 538 date_fin: course.date_fin || "", 539 saison: course.saison || "", 540 classes: (course.classes || []).join(", "), 541 inscriptions_url: course.inscriptions_url || "", 542 proposeur_nom: course.proposeur_nom || "", 543 proposeur_email: course.proposeur_email || "", 544 proposeur_message: course.proposeur_message || "", 545 submitted_at: new Date().toISOString(), 546 }); 547 try { 548 fetch(webhook, { method: "POST", mode: "no-cors", body: params }).catch(() => {}); 549 } catch (err) { 550 /* sans incidence sur la proposition (déjà enregistrée) */ 551 } 552} 553 554// Déduit la saison "AAAA/AAAA" à partir d'une date (saison ~ août â juillet). 555function saisonDepuisDate(iso) { 556 const d = parseDate(iso); 557 if (!d) return null; 558 const y = d.getFullYear(); 559 return d.getMonth() >= 7 ? `${y}/${y + 1}` : `${y - 1}/${y}`; 560}
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.