// ScreenMain.jsx — home / dashboard
// WX_CODES and useWeather are now defined in Data.jsx and exported via window.
// ── Screen ───────────────────────────────────────────────────────────
function ScreenMain({ go, t, authed }) {
const [tick, setTick] = React.useState(0);
const [isFullscreen, setIsFullscreen] = React.useState(false);
React.useEffect(() => {
const i = setInterval(() => setTick(x => x + 1), 90);
return () => clearInterval(i);
}, []);
React.useEffect(() => {
const fn = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', fn);
return () => document.removeEventListener('fullscreenchange', fn);
}, []);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
(document.documentElement.requestFullscreen || document.documentElement.webkitRequestFullscreen || (() => {})).call(document.documentElement);
} else {
(document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document);
}
};
const lineStatus = useLiveStatus();
const wx = useWeather();
// Exclude lines that are simply out-of-service-hours — those aren't "alerts"
const alerts = Object.entries(lineStatus).filter(([id, s]) =>
s.state !== 'normal' && getServiceState(id).state !== 'closed'
);
const mainLines = LINES.filter(l => ['A','B','C','D','E','H'].includes(l.id));
const extraLines = LINES.filter(l => ['U','P'].includes(l.id));
// ¿Toda la red cerrada? → próximo inicio
const allClosedInfo = getAllClosedInfo();
// Anuncios desestimables (Emova X). Persistido en localStorage.
const [dismissed, setDismissed] = React.useState(() => loadDismissed());
const activeAnns = React.useMemo(() => {
const list = getActiveAnnouncements(dismissed);
// Agregar anuncios del CMS (guardados en window.CMS_ANNOUNCEMENTS)
if (window.CMS_ANNOUNCEMENTS && Array.isArray(window.CMS_ANNOUNCEMENTS)) {
window.CMS_ANNOUNCEMENTS.forEach(a => {
if (!dismissed.has(a.id)) list.push(a);
});
}
// Inyección automática: si el clima actual sugiere lluvia/tormenta y el
// anuncio sintético no fue descartado, lo agregamos al tope.
const wxId = 'auto-weather-' + (wx?.code ?? '?');
if (wx && wx.code >= 51 && wx.code <= 99 && !dismissed.has(wxId)) {
list.unshift({
id: wxId,
kind: 'weather',
lineId: null,
text: `${wx.icon} ${wx.desc} en CABA. Pueden producirse demoras y filtraciones en estaciones a cielo abierto.`,
images: [],
url: 'https://www.smn.gob.ar/',
source: SOURCES.smn,
createdAt: new Date().toISOString(),
});
}
return list;
}, [dismissed, tick, wx]);
const dismissAnn = (id) => {
const next = new Set(dismissed); next.add(id); saveDismissed(next); setDismissed(next);
};
// Status events — loaded once on mount, independent of push prefs
const [statusEvents, setStatusEvents] = React.useState([]);
React.useEffect(() => {
loadStatusEvents(5).then(evs => setStatusEvents(evs || []));
}, []);
return (
{/* weather icon + temp */}
{wx ? (
<>
{wx.icon}
{wx.temp}°
ST {wx.feels}°
{wx.desc}
>
) : (
Buenos Aires
)}
{/* live update time — same horizontal row, pushed right */}
{new Date().toLocaleTimeString('es-AR',{hour:'2-digit',minute:'2-digit'})}
}
subtitle={null}
left={ }
right={
go('settings')}
style={{ width: 38, height: 38, borderRadius: 19, background: 'var(--ink-100)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
}
/>
{/* Summary banner */}
{allClosedInfo.allClosed ? (
Servicio finalizado en toda la red
Inicio del servicio nuevamente {allClosedInfo.opensDay} a las {allClosedInfo.opensAt}
) : alerts.length === 0 && CLOSED_TODAY.length === 0 ? (
) : alerts.length === 0 && CLOSED_TODAY.length > 0 ? (
{CLOSED_TODAY.length} estación{CLOSED_TODAY.length > 1 ? 'es' : ''} cerrada{CLOSED_TODAY.length > 1 ? 's' : ''}
{CLOSED_TODAY.join(' · ')}
) : (
{alerts.length} {t('linesWithIssues')}
{alerts.map(([id]) => id).join(' · ')}
)}
{/* Anuncios trascendentales (Emova X) — desestimables */}
{activeAnns.length > 0 && (
{activeAnns.map(a =>
dismissAnn(a.id)} />)}
)}
{/* Main line cards (A-H) — 2 cols mobile, 3 cols ≥540px */}
{mainLines.map(line => {
const st = lineStatus[line.id];
const isY = line.id === 'H';
const txt = isY ? '#0B1220' : '#fff';
const sub = isY ? 'rgba(11,18,32,.7)' : 'rgba(255,255,255,.85)';
const hasClosed = line.stations.some(s => CLOSED_TODAY.includes(s));
const svc = getServiceState(line.id);
const closed = svc.state === 'closed';
const closingSoon = svc.state === 'closing-soon';
const minsLeft = closingSoon ? Math.max(1, Math.round(svc.minsUntilClose)) : null;
return (
go('lineDetail',{line:line.id})}
style={{ borderRadius: 18, padding: 14, color: txt, textAlign: 'left', minHeight: 134, display: 'flex', flexDirection: 'column', border: 'none', boxShadow: 'var(--shadow-sm)', position: 'relative', overflow: 'hidden' }}>
{line.id}
{/* Single status dot: hasClosed overrides to orange, else follows service state */}
{closed ? `1° tren ${svc.opensDay} ${svc.opensAt}` : st.freq}
{closed ? 'Fin del servicio'
: closingSoon ? `⚠ Último tren en ${minsLeft} min`
: st.state === 'normal' ? t('normalService')
: st.state === 'delay' ? t('delays')
: t('alert')}
);
})}
{/* Secondary lines: Urquiza + Premetro — wrap to column on narrow screens */}
{extraLines.map(line => {
const st = lineStatus[line.id];
const svc = getServiceState(line.id);
const closed = svc.state === 'closed';
const closingSoon = svc.state === 'closing-soon';
const minsLeft = closingSoon ? Math.max(1, Math.round(svc.minsUntilClose)) : null;
return (
go('lineDetail',{line:line.id})}
style={{ padding: '10px 14px', borderRadius: 14, background: `var(--linea-${line.id.toLowerCase()}-2,var(--linea-p-2))`, color: line.id === 'P' ? '#0B1220' : 'white', textAlign: 'left', display: 'flex', alignItems: 'center', gap: 10, border: 'none', boxShadow: 'var(--shadow-sm)', minWidth: 0 }}>
{line.id}
{line.name}
{closed ? `1° tren ${svc.opensDay} ${svc.opensAt}` : st.freq}
{closed ? 'cerrado' : closingSoon ? `⚠ último tren ${minsLeft}m` : st.state === 'normal' ? t('normalService') : t('delays')}
);
})}
{/* Mini map preview — Leaflet real map, non-interactive */}
{/* {t('liveTrains')} */}
go('map')} style={{ fontSize: 12, color: 'var(--info)', fontFamily: 'var(--font-display)', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 4 }}>{t('openMap')}
{/* Click overlay routes to full map */}
go('map')}
style={{ position: 'absolute', inset: 0, zIndex: 500, cursor: 'pointer' }}
/>
{/* Quick actions */}
go('incidentReport')} className="card" style={{ padding: 14, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12, width: '100%' }}>
{t('reportIncident')}
vandalismo · accesibilidad · seguridad
{/* News teaser — últimos 5 días de cambios de estado (infosubte) */}
{t('news')}
go('news')} style={{ fontSize: 12, color: 'var(--info)', fontFamily: 'var(--font-display)', fontWeight: 700 }}>ver todas →
{statusEvents.length === 0 ? (
Sin novedades en los últimos 5 días.
) : (
statusEvents.slice(0, 5).map((ev, i) => (
))
)}
);
}
// ── StatusEventCard ──────────────────────────────────────────────────
// Muestra un evento de cambio de estado del infosubte (origen: ops.infosubte).
function StatusEventCard({ event: ev }) {
const isAlert = !!ev.isCurrent;
return (
{isAlert && (
En curso
)}
{timeAgoLabel(ev.startedAt)}
{ev.endedAt && !ev.isCurrent && ` · duró ${timeAgoLabel(ev.startedAt).replace('hace ','')}`}
{ev.text}
{ev.endedAt && !ev.isCurrent && (
Resuelto {timeAgoLabel(ev.endedAt)}
)}
);
}
// ── AnnouncementCard ─────────────────────────────────────────────────
// Tarjeta destacada para anuncios trascendentales (Emova X). Muestra texto
// + imágenes + link a la fuente. Se cierra con la X y se persiste en
// localStorage; reaparece en "Novedades" abajo.
function AnnouncementCard({ ann, onDismiss }) {
const meta = ANNOUNCEMENT_KIND[ann.kind] || ANNOUNCEMENT_KIND.advisory;
const tone = meta.tone;
const bg = tone === 'error' ? 'oklch(0.96 0.06 25)'
: tone === 'warn' ? 'oklch(0.96 0.05 75)'
: tone === 'info' ? 'oklch(0.96 0.04 240)'
: 'oklch(0.94 0.04 155)';
const bd = tone === 'error' ? 'oklch(0.78 0.18 25)'
: tone === 'warn' ? 'oklch(0.85 0.12 75)'
: tone === 'info' ? 'oklch(0.82 0.10 240)'
: 'oklch(0.82 0.10 155)';
const fg = tone === 'error' ? 'oklch(0.40 0.18 25)'
: tone === 'warn' ? 'oklch(0.35 0.15 75)'
: tone === 'info' ? 'oklch(0.35 0.14 240)'
: 'oklch(0.30 0.10 155)';
return (
{meta.label}
{ann.lineId &&
}
{ann.source && (
{ann.source.label}{ann.source.handle ? ` · ${ann.source.handle}` : ''}
)}
· {timeAgoLabel(ann.createdAt)}
{ann.text}
{ann.images && ann.images.length > 0 && (
{ann.images.map((src, i) => (
))}
)}
{ann.url && (
{(ann.source && ann.source.linkLabel) || 'Ver fuente'}
)}
);
}
Object.assign(window, { ScreenMain, AnnouncementCard });