PageSourceSearch

https://sancarlos-antioquia.gov.co/SiteAssets/SanCarlos/components/home/calendario/eventosCalendario.js

js sancarlos-antioquia.gov.co collected 2026-10-02 23:07:12 UTC 24,959 bytes, 621 lines download raw bytes

1import { fetchWithCache } from '/SiteAssets/SanCarlos/assets/js/cacheManager.js';
2
3// eventosCalendario.js - SharePoint API + Calendario + Eventos Periódicos Corregidos + Carrusel SWR
4
5// DATOS DE RESPALDO (Ajustados con la estructura real de SharePoint)
6const FALLBACK_EVENTOS = [
7  {
8    Title: "Jornada de Vacunación Veterinaria y Bienestar Animal",
9    Description: "Atención gratuita para caninos y felinos del sector urbano. Vacunación antirrábica, desparasitación y registro de mascotas.",
10    EventDate: "2026-08-13T15:00:00Z",
11    EndDate: "2026-09-28T16:00:00Z", // Fecha fin de la serie completa
12    Location: "Municipio de Simijaca",
13    Url: "",
14    Imagen: "/SiteAssets/V3/assets/img/turismo-parque.jpg",
15    Categoria: "Salud Pública",
16    fRecurrent: true,
17    TipoRecorrencia: "semanal",
18    DiasSemana: [4], // 4 = Jueves
19    RecurrenceEndDate: "2026-09-28T23:59:59Z"
20  },
21  {
22    Title: "Festival Turístico y Cultural de la Tradición",
23    Description: "Grandioso encuentro municipal con muestras artesanales, gastronomía típica, alborada musical y comparsas culturales.",
24    EventDate: "2026-08-13T08:00:00Z",
25    EndDate: "2026-08-15T22:00:00Z",
26    Location: "Parque Principal de Simijaca",
27    Url: "/MiMunicipio/Paginas/Sitios-de-Interes.aspx",
28    Imagen: "/SiteAssets/V3/assets/img/turismo-festival.jpg",
29    Categoria: "Cultura y Turismo",
30    fRecurrent: false
31  },
32  {
33    Title: "Caminata Ecológica Cerro del Santuario",
34    Description: "Recorrido guiado de senderismo para el avistamiento de aves y educación ambiental.",
35    EventDate: "2026-08-20T07:00:00Z",
36    EndDate: "2026-08-20T12:00:00Z",
37    Location: "Sendero Vereda Chuntaquira",
38    Url: "/MiMunicipio/Paginas/Sitios-de-Interes.aspx",
39    Imagen: "/SiteAssets/V3/assets/img/turismo-cerro.jpg",
40    Categoria: "Ecoturismo",
41    fRecurrent: false
42  }
43];
44
45// ESTADO GLOBAL
46let state = {
47  currentDate: new Date(),
48  viewYear: new Date().getFullYear(),
49  viewMonth: new Date().getMonth(), // 0-11
50  selectedDateStr: formatDateKey(new Date()), // "YYYY-MM-DD"
51  rawEventsList: [],
52  eventsMap: {},
53  carouselIndex: 0
54};
55
56// HELPER: Formatea Date a "YYYY-MM-DD"
57function formatDateKey(dateObj) {
58  const y = dateObj.getFullYear();
59  const m = String(dateObj.getMonth() + 1).padStart(2, '0');
60  const d = String(dateObj.getDate()).padStart(2, '0');
61  return `${y}-${m}-${d}`;
62}
63
64// HELPER: Parsea string de fecha a objeto Date
65function parseDate(dateStr) {
66  if (!dateStr) return new Date();
67  const d = new Date(dateStr);
68  return isNaN(d.getTime()) ? new Date() : d;
69}
70
71// OBTENCIÓN DE DATOS DESDE SHAREPOINT (Biblioteca 'CalendarioEventos')
72export async function getEventosSharePoint() {
73  const url = "/sp_api.aspx?ref=get-list-items";
74  const body = {
75    fields: [
76      "Title",
77      "Description",
78      "EventDate",
79      "EndDate",
80      "Location",
81      "Url",
82      "Imagen",
83      "Categoria",
84      "fRecurrent",
85      "RecurrenceData",
86      "RecurrenceType",
87      "RecurrenceEndDate",
88      "TipoRecorrencia",
89      "DiasSemana"
90    ],
91    site: "/",
92    list: "CalendarioEventos"
93  };
94
95  try {
96    const response = await fetch(url, {
97      method: "POST",
98      headers: {
99        "Content-Type": "application/json",
100        Accept: "application/json"
101      },
102      body: JSON.stringify(body)
103    });
104
105    if (!response.ok) throw new Error(`HTTP ${response.status}`);
106    const data = await response.json();
107
108    if (data.status !== "ok") throw new Error(data.message);
109
110    const items = Array.isArray(data.data) ? data.data : [];
111    return items.length > 0 ? items : FALLBACK_EVENTOS;
112  } catch (error) {
113    console.warn("Fallo al obtener eventos de SharePoint, cargando respaldo:", error);
114    return FALLBACK_EVENTOS;
115  }
116}
117
118// MOTOR CORREGIDO DE EXPANSIÓN DE RECURRENCIA
119function generateEventOccurrences(event, windowStart, windowEnd) {
120  const isRecurrent = Boolean(
121    event.fRecurrent === true || 
122    event.fRecurrent === "true" || 
123    event.fRecurrent === 1 || 
124    event.fRecurrent === "1" ||
125    event.TipoRecorrencia ||
126    event.RecurrenceData
127  );
128
129  const start = parseDate(event.EventDate);
130  const rawEnd = parseDate(event.EndDate || event.EventDate);
131
132  // --- CASO 1: EVENTO ÚNICO / NO RECURRENTE (Puede ser de un solo día o varios días) ---
133  if (!isRecurrent) {
134    const dates = [];
135    let cur = new Date(start.getFullYear(), start.getMonth(), start.getDate());
136    const last = new Date(rawEnd.getFullYear(), rawEnd.getMonth(), rawEnd.getDate());
137
138    while (cur <= last) {
139      dates.push({
140        dateKey: formatDateKey(cur),
141        eventInstance: event
142      });
143      cur.setDate(cur.getDate() + 1);
144    }
145    return dates;
146  }
147
148  // --- CASO 2: EVENTO PERIÓDICO / RECURRENTE ---
149  // A. Extraer horas de inicio y fin para CADA sesión individual
150  let startHour = start.getHours();
151  let startMin = start.getMinutes();
152  let endHour = rawEnd.getHours();
153  let endMin = rawEnd.getMinutes();
154
155  // Si no hay diferencia de horas, por defecto dura 1 hora la sesión
156  if (startHour === endHour && startMin === endMin) {
157    endHour = (startHour + 1) % 24;
158  }
159
160  // B. Determinar la fecha límite de finalización de la SERIE de repeticiones
161  let seriesEndDate = windowEnd;
162
163  if (event.RecurrenceEndDate) {
164    seriesEndDate = parseDate(event.RecurrenceEndDate);
165  } else if (rawEnd > start && (rawEnd.getTime() - start.getTime() > 24 * 3600 * 1000)) {
166    // Si la EndDate traída de SP es más de 24h después de la EventDate en un evento recurrente,
167    // significa que EndDate almacena el fin de la SERIE COMPLETA.
168    seriesEndDate = rawEnd;
169  }
170
171  const maxLimit = seriesEndDate < windowEnd ? seriesEndDate : windowEnd;
172
173  // C. Determinar días de la semana en que se repite
174  const recType = (event.TipoRecorrencia || event.RecurrenceType || 'semanal').toLowerCase();
175
176  let daysOfWeek = Array.isArray(event.DiasSemana) ? event.DiasSemana : [];
177  if (daysOfWeek.length === 0 && typeof event.DiasSemana === 'string') {
178    daysOfWeek = event.DiasSemana.split(',').map(n => parseInt(n.trim(), 10)).filter(n => !isNaN(n));
179  }
180
181  // Si viene del XML de SharePoint (RecurrenceData)
182  if (daysOfWeek.length === 0 && event.RecurrenceData) {
183    const rd = String(event.RecurrenceData).toLowerCase();
184    if (rd.includes('mo="true"')) daysOfWeek.push(1);
185    if (rd.includes('tu="true"')) daysOfWeek.push(2);
186    if (rd.includes('we="true"')) daysOfWeek.push(3);
187    if (rd.includes('th="true"') || rd.includes('thur="true"')) daysOfWeek.push(4);
188    if (rd.includes('fr="true"')) daysOfWeek.push(5);
189    if (rd.includes('sa="true"')) daysOfWeek.push(6);
190    if (rd.includes('su="true"')) daysOfWeek.push(0);
191  }
192
193  // Si no se especificaron días, usar el día de la semana de la fecha de inicio
194  if (daysOfWeek.length === 0) {
195    daysOfWeek = [start.getDay()];
196  }
197
198  const occurrences = [];
199  let currentCursor = new Date(start.getFullYear(), start.getMonth(), start.getDate());
200
201  // D. Generar instancias ÚNICAMENTE en las fechas que cumplen la regla
202  while (currentCursor <= maxLimit) {
203    if (currentCursor >= windowStart) {
204      let match = false;
205
206      if (recType.includes('diaria') || recType.includes('daily')) {
207        match = true;
208      } else if (recType.includes('semanal') || recType.includes('weekly')) {
209        const dayNum = currentCursor.getDay(); // 0=Dom, 1=Lun, ..., 6=Sáb
210        if (daysOfWeek.includes(dayNum)) {
211          match = true;
212        }
213      } else if (recType.includes('mensual') || recType.includes('monthly')) {
214        if (currentCursor.getDate() === start.getDate()) {
215          match = true;
216        }
217      } else if (recType.includes('anual') || recType.includes('yearly')) {
218        if (currentCursor.getDate() === start.getDate() && currentCursor.getMonth() === start.getMonth()) {
219          match = true;
220        }
221      }
222
223      if (match) {
224        // Crear las fechas específicas para la sesión de ESTE día
225        const occStart = new Date(currentCursor);
226        occStart.setHours(startHour, startMin, 0, 0);
227
228        const occEnd = new Date(currentCursor);
229        occEnd.setHours(endHour, endMin, 0, 0);
230
231        if (occEnd <= occStart) {
232          occEnd.setDate(occEnd.getDate() + 1);
233        }
234
235        const occEvent = {
236          ...event,
237          EventDate: occStart.toISOString(),
238          EndDate: occEnd.toISOString(),
239          isRecurrentInstance: true
240        };
241
242        occurrences.push({
243          dateKey: formatDateKey(currentCursor),
244          eventInstance: occEvent
245        });
246      }
247    }
248
249    // Avanzar un día
250    currentCursor.setDate(currentCursor.getDate() + 1);
251  }
252
253  return occurrences;
254}
255
256// CONSTRUCCIÓN DEL MAPA INDEXADO
257function buildEventsMap(eventsList) {
258  const map = {};
259
260  const now = new Date();
261  const windowStart = new Date(now.getFullYear() - 1, 0, 1);
262  const windowEnd = new Date(now.getFullYear() + 2, 11, 31);
263
264  eventsList.forEach(event => {
265    const occurrences = generateEventOccurrences(event, windowStart, windowEnd);
266
267    occurrences.forEach(({ dateKey, eventInstance }) => {
268      if (!map[dateKey]) map[dateKey] = [];
269      
270      // Evitar duplicados exactos en la misma fecha
271      const exists = map[dateKey].some(e => e.Title === eventInstance.Title && e.EventDate === eventInstance.EventDate);
272      if (!exists) {
273        map[dateKey].push(eventInstance);
274      }
275    });
276  });
277
278  return map;
279}
280
281// RENDERIZADO DE LA REJILLA DEL CALENDARIO
282function renderCalendarGrid() {
283  const gridContainer = document.getElementById('cal-days-grid');
284  const titleContainer = document.getElementById('cal-month-year-title');
285  if (!gridContainer || !titleContainer) return;
286
287  const monthNames = [
288    "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
289    "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"
290  ];
291
292  titleContainer.textContent = `${monthNames[state.viewMonth]} ${state.viewYear}`;
293
294  const firstDayOfMonth = new Date(state.viewYear, state.viewMonth, 1);
295  const daysInMonth = new Date(state.viewYear, state.viewMonth + 1, 0).getDate();
296
297  let startingDay = firstDayOfMonth.getDay() - 1;
298  if (startingDay === -1) startingDay = 6;
299
300  const todayStr = formatDateKey(state.currentDate);
301
302  let html = '';
303
304  for (let i = 0; i < startingDay; i++) {
305    html += `<div class="h-10 sm:h-11 rounded-xl bg-transparent" aria-hidden="true"></div>`;
306  }
307
308  for (let day = 1; day <= daysInMonth; day++) {
309    const dayDateObj = new Date(state.viewYear, state.viewMonth, day);
310    const dayStr = formatDateKey(dayDateObj);
311
312    const hasEvents = Boolean(state.eventsMap[dayStr] && state.eventsMap[dayStr].length > 0);
313    const isToday = (dayStr === todayStr);
314    const isSelected = (dayStr === state.selectedDateStr);
315
316    let btnClasses = "relative h-10 sm:h-11 rounded-2xl font-montserrat font-bold text-xs sm:text-sm flex flex-col items-center justify-center transition-all cursor-pointer border-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary ";
317
318    if (isSelected) {
319      btnClasses += "bg-text-dark text-white shadow-md scale-105 z-10 ";
320    } else if (isToday) {
321      btnClasses += "bg-surface-base text-primary ring-2 ring-primary font-black shadow-xs ";
322    } else if (hasEvents) {
323      btnClasses += "bg-primary/15 text-primary hover:bg-primary/25 font-extrabold ";
324    } else {
325      btnClasses += "bg-surface-base text-text-dark hover:bg-border-subtle/50 font-medium ";
326    }
327
328    const eventsCount = hasEvents ? state.eventsMap[dayStr].length : 0;
329
330    html += `
331      <button 
332        type="button" 
333        class="${btnClasses}"
334        data-date="${dayStr}"
335        aria-label="${day} de ${monthNames[state.viewMonth]} ${state.viewYear}${hasEvents ? `, ${eventsCount} eventos` : ''}"
336        ${isSelected ? 'aria-selected="true"' : ''}
337      >
338        <span>${day}</span>
339        ${hasEvents ? `
340          <span class="absolute bottom-1 w-1.5 h-1.5 rounded-full ${isSelected ? 'bg-white' : 'bg-primary'}"></span>
341        ` : ''}
342      </button>
343    `;
344  }
345
346  gridContainer.innerHTML = html;
347
348  gridContainer.querySelectorAll('button[data-date]').forEach(btn => {
349    btn.addEventListener('click', () => {
350      const selected = btn.getAttribute('data-date');
351      state.selectedDateStr = selected;
352      state.carouselIndex = 0;
353      renderCalendarGrid();
354      renderEventDetails();
355    });
356  });
357}
358
359// RENDERIZADO DEL PANEL DE DETALLES Y CARRUSEL
360function renderEventDetails() {
361  const container = document.getElementById('event-details-container');
362  if (!container) return;
363
364  const eventsForSelectedDate = state.eventsMap[state.selectedDateStr] || [];
365
366  const [y, m, d] = state.selectedDateStr.split('-').map(Number);
367  const dateObj = new Date(y, m - 1, d);
368  const formattedHeaderDate = dateObj.toLocaleDateString('es-CO', {
369    weekday: 'long',
370    day: 'numeric',
371    month: 'long',
372    year: 'numeric'
373  });
374
375  // ESTADO VACÍO
376  if (eventsForSelectedDate.length === 0) {
377    container.innerHTML = `
378      <div class="flex flex-col items-center justify-center text-center py-12 my-auto">
379        <div class="w-16 h-16 rounded-2xl bg-surface-alt text-text-muted flex items-center justify-center mb-4 border border-solid border-border-subtle">
380          <svg class="w-8 h-8 stroke-current fill-none" stroke-width="1.8" viewBox="0 0 24 24" aria-hidden="true">
381            <path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
382          </svg>
383        </div>
384        <span class="font-montserrat font-bold text-xs uppercase tracking-wider text-text-muted">Fecha Sin Programación</span>
385        <h3 class="font-montserrat font-extrabold text-lg text-text-dark capitalize mt-1 mb-2">
386          ${formattedHeaderDate}
387        </h3>
388        <p class="font-work-sans text-xs sm:text-sm text-text-muted max-w-sm m-0 leading-relaxed">
389          No hay eventos agendados para este día. Seleccione los días destacados con punto verde en el calendario.
390        </p>
391      </div>
392    `;
393    return;
394  }
395
396  const totalEvents = eventsForSelectedDate.length;
397  if (state.carouselIndex >= totalEvents) state.carouselIndex = 0;
398  if (state.carouselIndex < 0) state.carouselIndex = totalEvents - 1;
399
400  const currentEvent = eventsForSelectedDate[state.carouselIndex];
401
402  const title = currentEvent.Title || "Evento Institucional";
403  const description = currentEvent.Description || "Sin descripción disponible.";
404  const location = currentEvent.Location || "Municipio de Simijaca";
405  const category = currentEvent.Categoria || "Evento General";
406  const imgSrc = currentEvent.Imagen || "/SiteAssets/V3/assets/img/turismo-placeholder.jpg";
407  const hasUrl = Boolean(currentEvent.Url && typeof currentEvent.Url === 'string' && currentEvent.Url.trim() !== '');
408  const isRecurrent = Boolean(currentEvent.isRecurrentInstance || currentEvent.fRecurrent);
409
410  const startDate = parseDate(currentEvent.EventDate);
411  const endDate = parseDate(currentEvent.EndDate);
412
413  const timeFormatOptions = { hour: '2-digit', minute: '2-digit', hour12: true };
414  const startTimeStr = startDate.toLocaleTimeString('es-CO', timeFormatOptions);
415  const endTimeStr = endDate.toLocaleTimeString('es-CO', timeFormatOptions);
416
417  const timeDisplay = (formatDateKey(startDate) === formatDateKey(endDate))
418    ? `${startTimeStr} - ${endTimeStr}`
419    : `${startDate.getDate()} de ${startDate.toLocaleDateString('es-CO', {month:'short'})} - ${endDate.getDate()} de ${endDate.toLocaleDateString('es-CO', {month:'short'})}`;
420
421  container.innerHTML = `
422    <!-- Cabecera de la Ficha -->
423    <div class="flex items-center justify-between pb-4 mb-4 border-b border-solid border-border-subtle/60 gap-3">
424      <div class="flex items-center gap-2 flex-wrap">
425        <span class="inline-flex items-center gap-1.5 text-primary font-montserrat font-extrabold text-[11px] uppercase tracking-wider">
426          <svg class="w-3.5 h-3.5 fill-current shrink-0" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
427          <span>${category}</span>
428        </span>
429
430        ${isRecurrent ? `
431          <span class="inline-flex items-center gap-1 bg-primary/10 text-primary font-montserrat font-bold text-[10px] uppercase px-2 py-0.5 rounded-full" title="Este evento se repite periódicamente">
432            <svg class="w-3 h-3 stroke-[2.5] fill-none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
433            <span>Periódico</span>
434          </span>
435        ` : ''}
436
437        <h3 class="w-full font-montserrat font-black text-sm sm:text-base text-text-dark capitalize my-0 leading-tight">
438          ${formattedHeaderDate}
439        </h3>
440      </div>
441
442      ${totalEvents > 1 ? `
443        <!-- Controles del Carrusel -->
444        <div class="flex items-center gap-2 shrink-0">
445          <span class="font-montserrat font-bold text-xs text-text-muted bg-surface-alt px-2.5 py-1 rounded-full border border-solid border-border-subtle">
446            ${state.carouselIndex + 1} de ${totalEvents}
447          </span>
448          <button 
449            type="button" 
450            id="event-carousel-prev"
451            class="w-8 h-8 rounded-full bg-surface-alt hover:bg-primary hover:text-white text-text-dark border border-solid border-border-subtle flex items-center justify-center transition-colors cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary"
452            aria-label="Evento anterior"
453            title="Ver evento anterior de este día"
454          >
455            <svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
456          </button>
457          <button 
458            type="button" 
459            id="event-carousel-next"
460            class="w-8 h-8 rounded-full bg-surface-alt hover:bg-primary hover:text-white text-text-dark border border-solid border-border-subtle flex items-center justify-center transition-colors cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary"
461            aria-label="Evento siguiente"
462            title="Ver siguiente evento de este día"
463          >
464            <svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
465          </button>
466        </div>
467      ` : ''}
468    </div>
469
470    <!-- Cuerpo del Evento (Imagen + Información) -->
471    <div class="grid grid-cols-1 sm:grid-cols-12 gap-5 items-center my-auto">
472      <div class="sm:col-span-5 relative w-full h-44 sm:h-52 rounded-2xl overflow-hidden bg-surface-muted shadow-xs border border-solid border-border-subtle/60 shrink-0">
473        <img 
474          src="${imgSrc}" 
475          alt="Imagen alusiva a ${title}" 
476          class="w-full h-full object-cover block"
477          loading="lazy"
478        />
479      </div>
480
481      <div class="sm:col-span-7 flex flex-col justify-between">
482        <div>
483          <h4 class="font-montserrat font-extrabold text-lg sm:text-xl text-text-dark my-0 leading-snug line-clamp-2">
484            ${title}
485          </h4>
486
487          <div class="flex flex-col gap-1.5 my-3 font-montserrat font-bold text-xs text-text-muted">
488            <div class="flex items-center gap-2">
489              <svg class="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
490                <path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
491              </svg>
492              <span>${timeDisplay}</span>
493            </div>
494            <div class="flex items-center gap-2">
495              <svg class="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
496                <path stroke-linecap="round" stroke-linejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
497                <path stroke-linecap="round" stroke-linejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
498              </svg>
499              <span class="truncate">${location}</span>
500            </div>
501          </div>
502
503          <p class="font-work-sans text-xs sm:text-sm text-text-muted leading-relaxed m-0 line-clamp-3">
504            ${description}
505          </p>
506        </div>
507      </div>
508    </div>
509
510    <!-- Pie de Tarjeta: Enlace / Brochure -->
511    <div class="pt-5 mt-5 border-t border-solid border-border-subtle/60 flex items-center justify-between">
512      ${hasUrl ? `
513        <a 
514          href="${currentEvent.Url}" 
515          target="_blank"
516          rel="noopener noreferrer"
517          class="inline-flex items-center gap-2 bg-primary hover:bg-primary-hover text-white font-montserrat font-bold text-xs py-2.5 px-5 rounded-full transition-all duration-300 no-underline shadow-xs hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary"
518          title="Ver más información sobre ${title}"
519        >
520          <span>Más información / Brochure</span>
521          <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" aria-hidden="true">
522            <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/>
523          </svg>
524        </a>
525      ` : `
526        <span class="text-xs font-montserrat font-semibold text-text-muted/70 italic">
527          Sin brochure adjunto
528        </span>
529      `}
530
531      ${totalEvents > 1 ? `
532        <div class="flex items-center gap-1" aria-hidden="true">
533          ${eventsForSelectedDate.map((_, i) => `
534            <span class="w-2 h-2 rounded-full transition-all ${i === state.carouselIndex ? 'bg-primary w-4' : 'bg-border-subtle'}"></span>
535          `).join('')}
536        </div>
537      ` : ''}
538    </div>
539  `;
540
541  if (totalEvents > 1) {
542    const prevBtn = document.getElementById('event-carousel-prev');
543    const nextBtn = document.getElementById('event-carousel-next');
544
545    if (prevBtn) {
546      prevBtn.addEventListener('click', () => {
547        state.carouselIndex = (state.carouselIndex - 1 + totalEvents) % totalEvents;
548        renderEventDetails();
549      });
550    }
551
552    if (nextBtn) {
553      nextBtn.addEventListener('click', () => {
554        state.carouselIndex = (state.carouselIndex + 1) % totalEvents;
555        renderEventDetails();
556      });
557    }
558  }
559}
560
561// LISTENERS DEL CALENDARIO
562function setupCalendarControls() {
563  const prevBtn = document.getElementById('cal-prev-month');
564  const nextBtn = document.getElementById('cal-next-month');
565  const todayBtn = document.getElementById('cal-btn-today');
566
567  if (prevBtn) {
568    prevBtn.addEventListener('click', () => {
569      state.viewMonth--;
570      if (state.viewMonth < 0) {
571        state.viewMonth = 11;
572        state.viewYear--;
573      }
574      renderCalendarGrid();
575    });
576  }
577
578  if (nextBtn) {
579    nextBtn.addEventListener('click', () => {
580      state.viewMonth++;
581      if (state.viewMonth > 11) {
582        state.viewMonth = 0;
583        state.viewYear++;
584      }
585      renderCalendarGrid();
586    });
587  }
588
589  if (todayBtn) {
590    todayBtn.addEventListener('click', () => {
591      state.currentDate = new Date();
592      state.viewYear = state.currentDate.getFullYear();
593      state.viewMonth = state.currentDate.getMonth();
594      state.selectedDateStr = formatDateKey(state.currentDate);
595      state.carouselIndex = 0;
596
597      renderCalendarGrid();
598      renderEventDetails();
599    });
600  }
601}
602
603// INICIALIZACIÓN
604export async function initEventosCalendario() {
605  const handleRender = (data) => {
606    const rawEvents = (data && data.length > 0) ? data : FALLBACK_EVENTOS;
607
608    state.rawEventsList = rawEvents;
609    state.eventsMap = buildEventsMap(rawEvents);
610
611    renderCalendarGrid();
612    renderEventDetails();
613    setupCalendarControls();
614  };
615
616  await fetchWithCache('eventos_municipales', getEventosSharePoint, handleRender);
617}
618
619document.addEventListener('DOMContentLoaded', async () => {
620  await initEventosCalendario();
621});

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.