`; } }); const pad2 = (n) => 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 addDaysYMD(ymd, delta){ const dt = dateUTCNoonFromYMD(ymd); dt.setUTCDate(dt.getUTCDate() + delta); return `${dt.getUTCFullYear()}-${pad2(dt.getUTCMonth()+1)}-${pad2(dt.getUTCDate())}`; } 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 taxi = `
`;
const shield = `
`;
const truck = `
`;
const k = String(key||'').toLowerCase();
if (k.includes('taxi')) return taxi;
if (k.includes('especial')) return shield;
if (k.includes('carga')) return truck;
return car;
}
function resolveRestriction(city, cat, ymd){
const rule = cat.rule || {};
const dk = dayKeyFromYMD(ymd);
if (rule.type === "bogota_particulares_odd_even"){
const ex = rule.exceptions && rule.exceptions[ymd] ? rule.exceptions[ymd] : null;
if (ex && ex.kind === "no_aplica") return { value:"NO APLICA", kind:"na", note: ex.note || "", schedule: [] };
if (ex && ex.kind === "regional") return { value: ex.label || "REGIONAL", kind:"info", note: ex.note || "", schedule: ex.schedule || [] };
if (dk === "sat" || dk === "sun") return { value:"NO APLICA", kind:"na", note:"Sin restricción.", schedule: [] };
const day = Number(ymd.slice(-2));
const restricted = (day % 2 === 1) ? "6-7-8-9-0" : "1-2-3-4-5";
return { value: restricted, kind:"digits", note:"", schedule: cat.schedule || [] };
}
if (rule.type === "month_map"){
const ym = ymd.slice(0,7);
const bucket = rule.months && rule.months[ym] ? rule.months[ym] : null;
if (!bucket) return { value:"SIN INFORMACIÓN", kind:"unknown", note:"No hay calendario cargado para esta fecha.", schedule: [] };
const noA = Array.isArray(bucket.no_aplica) ? bucket.no_aplica : [];
if (noA.includes(ymd)) return { value:"NO APLICA", kind:"na", note:"No aplica (domingo o festivo).", schedule: [] };
const v = bucket.restricted && bucket.restricted[ymd] ? String(bucket.restricted[ymd]) : "";
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 || [] };
}
if (rule.type === "bogota_carga_20"){
const noA = Array.isArray(rule.no_aplica) ? rule.no_aplica : [];
if (noA.includes(ymd)) return { value:"NO APLICA", kind:"na", note:"Festivo: no aplica.", schedule: [] };
if (dk === "sun") return { value:"NO APLICA", kind:"na", note:"Domingo: no aplica.", schedule: [] };
if (dk === "sat"){
const allowed = rule.saturday_allowed && rule.saturday_allowed[ymd] ? String(rule.saturday_allowed[ymd]) : "";
if (!allowed) return { value:"SIN INFORMACIÓN", kind:"unknown", note:"No hay dato cargado para este sábado.", schedule: [] };
const restricted = (allowed === "1-3-5-7-9") ? "0-2-4-6-8" : "1-3-5-7-9";
return { value: restricted, kind:"digits", note:"Sábado (5:00 a. m. a 9:00 p. m.).", schedule: rule.saturday_schedule || [] };
}
if (dk === "mon" || dk === "tue" || dk === "wed" || dk === "thu" || dk === "fri"){
return { value:"TODOS", kind:"all", note:"Entre semana aplica sin importar el dígito.", schedule: cat.schedule || [] };
}
return { value:"SIN INFORMACIÓN", kind:"unknown", note:"", 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;
}
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 : '');
const verified = String(city.last_verified || '').trim();
const parts = [];
if (srcUrl) parts.push(`Fuente: ${srcName || 'Fuente oficial'}`);
else if (srcName) parts.push(`Fuente: ${srcName}`);
if (verified) parts.push(`Verificado: ${verified}`);
elFoot.innerHTML = parts.join(' · ');
}
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()}
Placas terminadas en:
--
⏱
Termina en: --
⏱
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');
if (r.kind === 'na'){
refs.label.textContent = 'Estado hoy:';
refs.value.textContent = 'NO APLICA';
refs.value.classList.add('pz-value--na');
} else if (r.kind === 'all'){
refs.label.textContent = 'Aplica a:';
refs.value.textContent = 'TODOS';
} else if (r.kind === 'unknown'){
refs.label.textContent = 'Estado hoy:';
refs.value.textContent = 'SIN INFORMACIÓN';
} else {
refs.label.textContent = 'Placas terminadas en:';
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 !== 'na' && r.kind !== 'unknown' && r.kind !== 'info' && 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);
}
})();