PageSourceSearch

https://ffcvcalendar.netlify.app/js/app.js

js ffcvcalendar.netlify.app collected 2026-10-03 10:43:54 UTC 22,179 bytes, 560 lines download raw bytes

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.