`; } }); /* FIX del error "Cannot access 'pad2' before initialization": pad2 como function (hoisting) para que nunca falle en Elementor. */ function pad2(n){ return String(n).padStart(2,'0'); } function getTZ(root){ return (root.getAttribute('data-tz') || 'America/Bogota').trim() || 'America/Bogota'; } function nowPartsInTZ(tz){ const dtf = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false }); const parts = dtf.formatToParts(new Date()); const m = {}; for (const p of parts) if (p.type !== 'literal') m[p.type] = p.value; return { year:+m.year, month:+m.month, day:+m.day, hour:+m.hour, minute:+m.minute, second:+m.second }; } function ymdFromParts(p){ return `${p.year}-${pad2(p.month)}-${pad2(p.day)}`; } function minutesOfDayFromParts(p){ return p.hour*60 + p.minute; } function dateUTCNoonFromYMD(ymd){ const [y,m,d] = ymd.split('-').map(Number); return new Date(Date.UTC(y, m-1, d, 12, 0, 0)); } function ymdFromUTCNoon(dt){ return `${dt.getUTCFullYear()}-${pad2(dt.getUTCMonth()+1)}-${pad2(dt.getUTCDate())}`; } function addDaysUTCNoon(dt, delta){ const x = new Date(dt.getTime()); x.setUTCDate(x.getUTCDate() + delta); return x; } function addDaysYMD(ymd, delta){ const dt = dateUTCNoonFromYMD(ymd); dt.setUTCDate(dt.getUTCDate() + delta); return ymdFromUTCNoon(dt); } function fmtTimeNoSeconds(tz){ return new Intl.DateTimeFormat('es-CO', { timeZone: tz, hour:'numeric', minute:'2-digit', hour12:true }).format(new Date()); } function weekdayUpperES(tz, ymd){ const dt = dateUTCNoonFromYMD(ymd); const w = new Intl.DateTimeFormat('es-CO', { timeZone: tz, weekday:'long' }).format(dt); return String(w).toUpperCase(); } function prettyDateES(ymd){ const [y,m,d] = ymd.split('-').map(Number); const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; return `${d} de ${meses[m-1]} ${y}`; } function isHHMM(s){ return /^([01]\d|2[0-3]):([0-5]\d)$/.test(String(s||'').trim()); } function toMinutes(hhmm){ if (!isHHMM(hhmm)) return null; const [h,m] = hhmm.split(':').map(Number); return h*60 + m; } function time24to12ES(hhmm){ if (!isHHMM(hhmm)) return String(hhmm||''); const [h,m] = hhmm.split(':').map(Number); const h12 = ((h + 11) % 12) + 1; const suf = h >= 12 ? 'p. m.' : 'a. m.'; return `${h12}:${pad2(m)} ${suf}`; } function scheduleLabel(schedule){ const slots = Array.isArray(schedule) ? schedule : []; const parts = slots .filter(s => s && s.start && s.end) .map(s => `${time24to12ES(String(s.start))} a ${time24to12ES(String(s.end))}`); return parts.length ? parts.join(' · ') : 'Sin restricción'; } function getHorarioEstado(schedule, nowMin){ const slots = (Array.isArray(schedule) ? schedule : []) .map(s => ({ s: toMinutes(String(s.start||'')), e: toMinutes(String(s.end||'')) })) .filter(x => x.s !== null && x.e !== null) .sort((a,b) => a.s - b.s); if (!slots.length) return { kind:'none', endMin:null }; for (const slot of slots){ if (nowMin >= slot.s && nowMin < slot.e) return { kind:'active', endMin: slot.e }; } return { kind:'inactive', endMin:null }; } function countdownHM(endMin, nowMin){ if (endMin === null) return null; const rem = Math.max(0, endMin - nowMin); return { h: Math.floor(rem/60), m: rem % 60 }; } function safeUrl(u){ const s = String(u ?? '').trim(); return /^https?:\/\//i.test(s) ? s : ''; } function dayKeyFromYMD(ymd){ const dt = dateUTCNoonFromYMD(ymd); return ['sun','mon','tue','wed','thu','fri','sat'][dt.getUTCDay()]; } function iconSvg(key){ const common = `width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"`; const car = `
`;
const truck = `
`;
const moto = `
`;
const k = String(key||'').toLowerCase();
if (k.includes('carga')) return truck;
if (k.includes('moto')) return moto;
return car;
}
/* ===== Festivos Colombia (para aplicar “NO APLICA” en festivos) ===== */ const _holCache = new Map();
function easterUTCNoon(year){
// Algoritmo gregoriano (Meeus/Jones/Butcher)
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19*a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2*e + 2*i - h - k) % 7;
const m = Math.floor((a + 11*h + 22*l) / 451);
const month = Math.floor((h + l - 7*m + 114) / 31); // 3=Mar, 4=Abr
const day = ((h + l - 7*m + 114) % 31) + 1;
return new Date(Date.UTC(year, month-1, day, 12, 0, 0));
}
function nextMondayUTCNoon(dt){
const x = new Date(dt.getTime());
const dow = x.getUTCDay(); // 0 dom ... 1 lun ... 6 sáb
const add = (dow === 1) ? 0 : ((8 - dow) % 7);
x.setUTCDate(x.getUTCDate() + add);
return x;
}
function colombiaHolidaysYMD(year){
if (_holCache.has(year)) return _holCache.get(year);
const out = new Set();
const mk = (m,d) => `${year}-${pad2(m)}-${pad2(d)}`;
// Fijos
[ [1,1],[5,1],[7,20],[8,7],[12,8],[12,25] ].forEach(([m,d]) => out.add(mk(m,d)));
// Emiliani (se pasan al lunes)
const emil = [
[1,6], // Reyes
[3,19], // San José
[6,29], // San Pedro y San Pablo
[8,15], // Asunción
[10,12], // Raza
[11,1], // Todos los Santos
[11,11] // Independencia de Cartagena
];
emil.forEach(([m,d]) => {
const moved = nextMondayUTCNoon(new Date(Date.UTC(year, m-1, d, 12, 0, 0)));
out.add(ymdFromUTCNoon(moved));
});
// Pascua y derivados
const easter = easterUTCNoon(year);
// Jueves y Viernes Santo (no se mueven)
out.add(ymdFromUTCNoon(addDaysUTCNoon(easter, -3)));
out.add(ymdFromUTCNoon(addDaysUTCNoon(easter, -2)));
// Ascensión (easter+39) -> lunes
out.add(ymdFromUTCNoon(nextMondayUTCNoon(addDaysUTCNoon(easter, 39))));
// Corpus Christi (easter+60) -> lunes
out.add(ymdFromUTCNoon(nextMondayUTCNoon(addDaysUTCNoon(easter, 60))));
// Sagrado Corazón (easter+68) -> lunes
out.add(ymdFromUTCNoon(nextMondayUTCNoon(addDaysUTCNoon(easter, 68))));
const arr = Array.from(out);
_holCache.set(year, arr);
return arr;
}
function isHolidayCO(ymd){
const y = Number(String(ymd).slice(0,4));
if (!y || y < 1900) return false;
return colombiaHolidaysYMD(y).includes(ymd);
}
function inRangeYMD(ymd, start, end){
return String(ymd) >= String(start) && String(ymd) <= String(end);
}
function resolveRestriction(city, cat, ymd){
const rule = cat.rule || {};
const dk = dayKeyFromYMD(ymd);
if (rule.type === "no_aplica"){
return { value:"NO APLICA", kind:"na", note: String(rule.note||"").trim(), schedule: [] };
}
if (rule.type === "amva_weekday_pairs"){
// No aplica fines de semana
if (dk === "sat" || dk === "sun"){
return { value:"NO APLICA", kind:"na", note:"Fin de semana: no aplica.", schedule: [] };
}
// No aplica festivos (Colombia)
if (isHolidayCO(ymd)){
return { value:"NO APLICA", kind:"na", note:"Festivo: no aplica.", schedule: [] };
}
// Rango(s) de suspensión
const ranges = Array.isArray(rule.no_aplica_ranges) ? rule.no_aplica_ranges : [];
for (const r of ranges){
if (r && r.start && r.end && inRangeYMD(ymd, r.start, r.end)){
return { value:"NO APLICA", kind:"na", note: String(r.note||"Medida suspendida.").trim(), schedule: [] };
}
}
// Rotación normal (lunes a viernes)
const v = rule.weekdays && rule.weekdays[dk] ? String(rule.weekdays[dk]) : "";
if (!v) return { value:"SIN INFORMACIÓN", kind:"unknown", note:"No hay dato cargado para esta fecha.", schedule: [] };
return { value:v, kind:"digits", note:"", schedule: cat.schedule || [] };
}
return { value:"SIN INFORMACIÓN", kind:"unknown", note:"", schedule: [] };
}
function mount(root){
const tz = getTZ(root);
const key = String(root.getAttribute("data-key") || "").trim();
const cfg = window.PZ_PYP_DATA && window.PZ_PYP_DATA[key] ? window.PZ_PYP_DATA[key] : null;
if (!cfg) throw new Error(`No existe window.PZ_PYP_DATA["${key}"]. Revisa data-key.`);
const city = cfg.city;
const options = (city.switch && Array.isArray(city.switch.options)) ? city.switch.options : [];
root.innerHTML = `
Hora: --:--
Si vas a viajar, revisa el pico y placa para la fecha de tu viaje.
`;
const $ = (sel) => root.querySelector(sel);
const elTitle = $('.pz-title');
const elTime = $('.pz-time');
const elCity = $('.pz-cityselect');
const elWeek = $('.pz-weekday');
const elDate = $('.pz-date');
const elDateInput = $('.pz-dateinput');
const elDateCenter = $('.pz-datecenter');
const elPrevDay = $('.pz-daybtn--left');
const elNextDay = $('.pz-daybtn--right');
const elGrid = $('.pz-grid');
const elFoot = $('.pz-foot');
const elPrevPage = $('.pz-page--prev');
const elNextPage = $('.pz-page--next');
elCity.innerHTML = options
.slice()
.sort((a,b) => String(a.name||'').localeCompare(String(b.name||''), 'es'))
.map(o => ``)
.join('');
elCity.value = city.id;
let todayTZ = ymdFromParts(nowPartsInTZ(tz));
let selectedYMD = todayTZ;
let pageIndex = 0;
let pageSize = window.matchMedia('(min-width: 820px)').matches ? 4 : 2;
let visibleNodes = new Map();
function getSortedCats(){
return (Array.isArray(city.categories) ? city.categories : [])
.slice()
.sort((a,b) => (Number(a.priority||99) - Number(b.priority||99)) || String(a.name||'').localeCompare(String(b.name||''), 'es'));
}
function updateHeader(){
elTitle.textContent = `PICO Y PLACA ${String(city.name || city.id).toUpperCase()}`.trim();
elTime.textContent = `Hora: ${fmtTimeNoSeconds(tz)}`;
elWeek.textContent = weekdayUpperES(tz, selectedYMD);
elDate.textContent = prettyDateES(selectedYMD);
elDateInput.value = selectedYMD;
}
// Footer: SOLO fuente (como pediste)
function updateFooter(){
const srcName = String(city.source && city.source.name ? city.source.name : '').trim();
const srcUrl = safeUrl(city.source && city.source.url ? city.source.url : '');
if (srcUrl) elFoot.innerHTML = `Fuente: ${srcName || 'Fuente oficial'}`;
else elFoot.textContent = srcName ? `Fuente: ${srcName}` : '';
}
function calcPageSize(){ return window.matchMedia('(min-width: 820px)').matches ? 4 : 2; }
function renderPage(){
const cats = getSortedCats();
const newSize = calcPageSize();
if (newSize !== pageSize) { pageSize = newSize; pageIndex = 0; }
const totalPages = Math.max(1, Math.ceil(cats.length / pageSize));
pageIndex = Math.min(pageIndex, totalPages - 1);
const start = pageIndex * pageSize;
const pageCats = cats.slice(start, start + pageSize);
const showPager = cats.length > pageSize;
elPrevPage.style.display = showPager ? 'flex' : 'none';
elNextPage.style.display = showPager ? 'flex' : 'none';
elGrid.innerHTML = pageCats.map(cat => `
${iconSvg(cat.icon || cat.id)}
${String(cat.name || cat.id).toUpperCase()}
⏱
Horario: --
`).join('');
visibleNodes = new Map();
elGrid.querySelectorAll('.pz-card').forEach(card => {
const catId = card.getAttribute('data-cat');
visibleNodes.set(catId, {
label: card.querySelector('.pz-label'),
value: card.querySelector('.pz-value'),
cd: card.querySelector('.pz-countdown'),
cdText: card.querySelector('.pz-counttext'),
band: card.querySelector('.pz-band'),
bandText: card.querySelector('.pz-bandtext'),
note: card.querySelector('.pz-note')
});
});
updateCardsDynamic();
}
function updateCardsDynamic(){
const nowP = nowPartsInTZ(tz);
todayTZ = ymdFromParts(nowP);
const nowMin = minutesOfDayFromParts(nowP);
const isTodaySelected = (selectedYMD === todayTZ);
const cats = getSortedCats();
for (const cat of cats){
const refs = visibleNodes.get(String(cat.id));
if (!refs) continue;
const r = resolveRestriction(city, cat, selectedYMD);
const sched = Array.isArray(r.schedule) && r.schedule.length ? r.schedule : (Array.isArray(cat.schedule) ? cat.schedule : []);
const schedLabel = scheduleLabel(sched);
refs.value.classList.remove('pz-value--na');
const baseLabel =
(r.kind === 'digits' && String(cat.plate_label||'').trim())
? String(cat.plate_label).trim()
: 'Placas terminadas en:';
if (r.kind === 'na'){
refs.label.textContent = 'Estado:';
refs.value.textContent = 'NO APLICA';
refs.value.classList.add('pz-value--na');
} else if (r.kind === 'unknown'){
refs.label.textContent = 'Estado:';
refs.value.textContent = 'SIN INFORMACIÓN';
} else {
refs.label.textContent = baseLabel;
refs.value.textContent = String(r.value);
}
if (r.kind === 'na' || r.kind === 'unknown'){
refs.band.classList.remove('pz-band--yellow');
refs.band.classList.add('pz-band--gray');
refs.bandText.textContent = 'Sin restricción';
} else {
refs.band.classList.remove('pz-band--gray');
refs.band.classList.add('pz-band--yellow');
refs.bandText.textContent = schedLabel;
}
const note = String(r.note || '').trim();
if (note){ refs.note.style.display = 'block'; refs.note.textContent = note; }
else { refs.note.style.display = 'none'; refs.note.textContent = ''; }
const canCount = isTodaySelected && r.kind === 'digits' && Array.isArray(sched) && sched.length > 0;
if (!canCount){ refs.cd.style.display = 'none'; refs.cdText.textContent = ''; continue; }
const st = getHorarioEstado(sched, nowMin);
if (st.kind !== 'active'){ refs.cd.style.display = 'none'; refs.cdText.textContent = ''; continue; }
const hm = countdownHM(st.endMin, nowMin);
refs.cd.style.display = 'flex';
refs.cdText.textContent = `Termina en: ${hm.h}h ${pad2(hm.m)}m`;
}
elTime.textContent = `Hora: ${fmtTimeNoSeconds(tz)}`;
}
// Redirección por ciudad
elCity.addEventListener('change', () => {
const nextId = elCity.value;
const target = options.find(o => String(o.id) === String(nextId));
const url = target && target.url ? String(target.url).trim() : '';
if (safeUrl(url)) window.location.href = url;
});
elPrevDay.addEventListener('click', () => {
selectedYMD = addDaysYMD(selectedYMD, -1);
pageIndex = 0;
updateHeader();
renderPage();
});
elNextDay.addEventListener('click', () => {
selectedYMD = addDaysYMD(selectedYMD, +1);
pageIndex = 0;
updateHeader();
renderPage();
});
function openPicker(){
if (typeof elDateInput.showPicker === 'function') elDateInput.showPicker();
else elDateInput.focus();
}
elDateCenter.addEventListener('click', openPicker);
elDateCenter.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); openPicker(); }
});
elDateInput.addEventListener('change', () => {
if (elDateInput.value){
selectedYMD = elDateInput.value;
pageIndex = 0;
updateHeader();
renderPage();
}
});
elPrevPage.addEventListener('click', () => {
const cats = getSortedCats();
const totalPages = Math.max(1, Math.ceil(cats.length / pageSize));
pageIndex = (pageIndex - 1 + totalPages) % totalPages;
renderPage();
});
elNextPage.addEventListener('click', () => {
const cats = getSortedCats();
const totalPages = Math.max(1, Math.ceil(cats.length / pageSize));
pageIndex = (pageIndex + 1) % totalPages;
renderPage();
});
window.addEventListener('resize', renderPage);
updateHeader();
updateFooter();
renderPage();
root._pzTimer && clearInterval(root._pzTimer);
root._pzTimer = setInterval(() => { updateHeader(); updateCardsDynamic(); }, 30 * 1000);
}
})();