/** * dateFormatter.ts * * Utilities pour parser, formatter et grouper les dates de travail * Format input/storage: "12/10, 13/10, 14/10, 24/10" * Format affichage: "le 12/10 ; du 14/10 au 17/10." */ // Types export interface DateGroup { type: "single" | "range"; displayFr: string; // "le 12/10" ou "du 14/10 au 17/10" startIso: string; // "2025-10-14" endIso?: string; // "2025-10-17" } export interface ParseResult { groups: DateGroup[]; hasMultiMonth: boolean; pdfFormatted: string; allDatesFr: string[]; // Pour affichage inline: ["12/10", "13/10", ...] } /** * Formate une date ISO (YYYY-MM-DD) en format français (DD/MM) */ export function formatDateFr(isoStr: string): string { if (!isoStr || isoStr.length < 10) return ""; const [year, month, day] = isoStr.split("-"); return `${day}/${month}`; } /** * Formate une date ISO (YYYY-MM-DD) en format français avec année (DD/MM/YYYY) */ export function formatDateFrWithYear(isoStr: string): string { if (!isoStr || isoStr.length < 10) return ""; const [year, month, day] = isoStr.split("-"); return `${day}/${month}/${year}`; } /** * Convertit une date française (DD/MM ou DD/MM/YYYY) en ISO (YYYY-MM-DD) * Si l'année est fournie dans frStr, elle est utilisée * Sinon, nécessite l'année de contexte */ export function parseFrenchedDate(frStr: string, yearContext: string): string { const cleaned = frStr.trim(); if (!cleaned || !cleaned.includes("/")) return ""; const parts = cleaned.split("/"); // Format DD/MM/YYYY if (parts.length === 3) { const [day, month, year] = parts; if (!day || !month || !year) return ""; return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; } // Format DD/MM - utiliser le contexte const [day, month] = parts; if (!day || !month) return ""; const year = yearContext.split("-")[0]; return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`; } /** * Vérifie si deux dates ISO consécutives sont à exactement 1 jour d'intervalle */ function isConsecutiveDay(isoStr1: string, isoStr2: string): boolean { const date1 = new Date(isoStr1 + "T00:00:00Z"); const date2 = new Date(isoStr2 + "T00:00:00Z"); const diffMs = date2.getTime() - date1.getTime(); const diffDays = diffMs / (1000 * 60 * 60 * 24); return Math.abs(diffDays - 1) < 0.01; // tolérance pour fuseaux horaires } /** * Génère toutes les dates ISO entre startIso et endIso (inclus) */ function generateDateRange(startIso: string, endIso: string): string[] { const dates: string[] = []; const current = new Date(startIso + "T00:00:00Z"); const end = new Date(endIso + "T00:00:00Z"); while (current <= end) { dates.push(current.toISOString().slice(0, 10)); current.setUTCDate(current.getUTCDate() + 1); } return dates; } // Exporter generateDateRange pour usage externe export { generateDateRange }; /** * Format des dates quantifiées. * Prend une liste de dates ISO (YYYY-MM-DD) et une map { iso: quantity } * Retourne une chaîne formatée pour PDFMonkey en regroupant les dates * consécutives qui ont la même quantité. Exemple: * "1 représentation le 12/10 ; 1 représentation par jour du 13/10 au 16/10 ; 3 heures le 13/10." */ export function formatQuantifiedDates( isos: string[], quantities: Record, dateType: "representations" | "repetitions" | "jours_travail" | "heures_repetitions", repetitionDuration?: "3" | "4" ): string { if (!isos || isos.length === 0) return ""; // Trier les dates const sorted = Array.from(new Set(isos)).sort(); // Construire tableau { iso, qty } const items = sorted.map((iso) => ({ iso, qty: quantities[iso] ?? quantities[formatDateFr(iso)] ?? 0 })); // Grouper consécutifs avec même qty const groups: Array<{ startIso: string; endIso?: string; qty: number | string } > = []; for (let i = 0; i < items.length; i++) { const cur = items[i]; const prev = groups[groups.length - 1]; if (!prev) { groups.push({ startIso: cur.iso, endIso: cur.iso, qty: cur.qty }); continue; } const isConsec = isConsecutiveDay(prev.endIso!, cur.iso); const sameQty = String(prev.qty) === String(cur.qty); if (isConsec && sameQty) { prev.endIso = cur.iso; } else { groups.push({ startIso: cur.iso, endIso: cur.iso, qty: cur.qty }); } } // Construire le texte const parts: string[] = groups.map((g) => { const qty = g.qty; const qtyNum = typeof qty === 'number' ? qty : (parseInt(String(qty)) || 0); // déterminer le libellé de base let base = ''; if (dateType === 'representations') { base = qtyNum > 1 ? 'représentations' : 'représentation'; } else if (dateType === 'repetitions') { // Pour les répétitions, ajouter la durée si disponible if (repetitionDuration) { if (qtyNum > 1) { base = `services de répétition de ${repetitionDuration} heures chacun`; } else { base = `service de répétition de ${repetitionDuration} heures`; } } else { base = qtyNum > 1 ? 'services de répétition' : 'service de répétition'; } } else if (dateType === 'jours_travail' || dateType === 'heures_repetitions') { base = qtyNum > 1 ? 'heures' : 'heure'; } if (g.startIso === g.endIso) { // date isolée return `${qty} ${base} le ${formatDateFr(g.startIso)}`; } // plage: utiliser "par jour" pour indiquer par-journée if (dateType === 'jours_travail' || dateType === 'heures_repetitions') { return `${qty} ${base} par jour du ${formatDateFr(g.startIso)} au ${formatDateFr(g.endIso!)}`; } // representations / repetitions -> "X base par jour du A au B" return `${qty} ${base} par jour du ${formatDateFr(g.startIso)} au ${formatDateFr(g.endIso!)}`; }); return parts.join(' ; ') + '.'; } /** * Parse une chaîne de dates en français (DD/MM) séparées par des virgules * Retourne des groupes (dates isolées ou plages consécutives) * * @param dateStr ex: "12/10, 13/10, 14/10, 24/10" * @param yearContext ex: "2025-10-12" (pour extraire l'année) * @returns ParseResult avec groupes, détection multi-mois et formatages */ export function parseDateString( dateStr: string, yearContext: string ): ParseResult { if (!dateStr || !yearContext) { return { groups: [], hasMultiMonth: false, pdfFormatted: "", allDatesFr: [], }; } // 1. Parser chaque date en ISO const isoArray = dateStr .split(",") .map(d => d.trim()) .filter(Boolean) .map(d => parseFrenchedDate(d, yearContext)) .filter(iso => iso.length === 10) // Valider format YYYY-MM-DD .sort(); // Trier chronologiquement // Dédupliquer const uniqueIsos = [...new Set(isoArray)]; if (uniqueIsos.length === 0) { return { groups: [], hasMultiMonth: false, pdfFormatted: "", allDatesFr: [], }; } // 2. Grouper les dates consécutives const groups: DateGroup[] = []; let i = 0; while (i < uniqueIsos.length) { const startIso = uniqueIsos[i]; let endIso = startIso; // Trouver la fin de la plage consécutive while (i + 1 < uniqueIsos.length && isConsecutiveDay(uniqueIsos[i], uniqueIsos[i + 1])) { endIso = uniqueIsos[i + 1]; i++; } // Créer le groupe if (startIso === endIso) { groups.push({ type: "single", displayFr: `le ${formatDateFr(startIso)}`, startIso, }); } else { groups.push({ type: "range", displayFr: `du ${formatDateFr(startIso)} au ${formatDateFr(endIso)}`, startIso, endIso, }); } i++; } // 3. Formater pour PDFMonkey const pdfFormatted = groups.map(g => g.displayFr).join(" ; ") + "."; // 4. Déterminer multi-mois const months = new Set(uniqueIsos.map(iso => iso.slice(0, 7))); // ["2025-10", "2025-11", ...] const hasMultiMonth = months.size > 1; // 5. Générer array de toutes les dates en français const allDatesFr = uniqueIsos.map(iso => formatDateFr(iso)); return { groups, hasMultiMonth, pdfFormatted, allDatesFr, }; } /** * Convertit les groupes en format input/storage (pour Supabase) * Ex: ["12/10", "13/10", "14/10", "24/10"] */ export function formatGroupsToInput(groups: DateGroup[]): string { const allDates: string[] = []; for (const group of groups) { if (group.type === "single") { allDates.push(formatDateFr(group.startIso)); } else if (group.type === "range" && group.endIso) { // Générer toutes les dates dans la plage const rangeDates = generateDateRange(group.startIso, group.endIso); allDates.push(...rangeDates.map(iso => formatDateFr(iso))); } } return allDates.join(", "); } /** * Convertit les groupes en format affichage lisible pour PDFMonkey * Ex: "le 12/10 ; du 13/10 au 14/10 ; le 24/10." */ export function formatGroupsToPDFMonkey(groups: DateGroup[]): string { if (groups.length === 0) return ""; return groups.map(g => g.displayFr).join(" ; ") + "."; } /** * Convertit un array de dates ISO en DateGroup[] * Utile pour le calendrier qui sélectionne les dates en ISO */ export function convertIsoDatesToGroups(isoDates: string[]): DateGroup[] { if (!isoDates || isoDates.length === 0) return []; const sorted = [...isoDates].sort(); const groups: DateGroup[] = []; let i = 0; while (i < sorted.length) { const startIso = sorted[i]; let endIso = startIso; // Trouver la fin de la plage consécutive while (i + 1 < sorted.length && isConsecutiveDay(sorted[i], sorted[i + 1])) { endIso = sorted[i + 1]; i++; } // Créer le groupe if (startIso === endIso) { groups.push({ type: "single", displayFr: `le ${formatDateFr(startIso)}`, startIso, }); } else { groups.push({ type: "range", displayFr: `du ${formatDateFr(startIso)} au ${formatDateFr(endIso)}`, startIso, endIso, }); } i++; } return groups; } /** * Détecte si les dates couvrent plusieurs mois */ export function hasMultipleMoons(isoDates: string[]): boolean { if (!isoDates || isoDates.length === 0) return false; const months = new Set(isoDates.map(iso => iso.slice(0, 7))); return months.size > 1; } /** * Récupère le mois/année d'une date ISO * Ex: "2025-10-12" → "2025-10" */ export function getMonthYear(isoStr: string): string { return isoStr.slice(0, 7); } /** * Récupère toutes les dates d'une plage consécutive (pour le calendrier) */ export function expandDateRange(startIso: string, endIso: string): string[] { return generateDateRange(startIso, endIso); }