diff --git a/public/app.js b/public/app.js index 0679c86..70db9c1 100644 --- a/public/app.js +++ b/public/app.js @@ -287,6 +287,78 @@ async function loadGeo() { } } +// ── Calendar ────────────────────────────────────────────────────────────────── + +const calList = document.getElementById('cal-list'); +const calStatus = document.getElementById('cal-status'); +let calEvents = []; +const notifiedCourses = new Set(); + +const DAY_SHORT = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']; + +function renderCalendar() { + calList.innerHTML = ''; + const now = new Date(); + const todayStart = new Date(now); todayStart.setHours(0, 0, 0, 0); + const todayEnd = new Date(now); todayEnd.setHours(23, 59, 59, 999); + + const todayEvents = calEvents.filter(e => { + const s = new Date(e.start); + return s >= todayStart && s <= todayEnd; + }); + + if (!todayEvents.length) { + calList.innerHTML = '
  • Aucun cours aujourd\'hui
  • '; + return; + } + + todayEvents.forEach(event => { + const start = new Date(event.start); + const end = new Date(event.end); + const li = document.createElement('li'); + li.className = 'cal-item' + + (now >= start && now < end ? ' active' : '') + + (now >= end ? ' past' : ''); + const timeStr = `${pad(start.getHours())}:${pad(start.getMinutes())} – ${pad(end.getHours())}:${pad(end.getMinutes())}`; + li.innerHTML = ` + ${escapeHtml(timeStr)} + ${escapeHtml(event.title)} + ${event.location ? `${escapeHtml(event.location)}` : ''} + `; + calList.appendChild(li); + }); +} + +async function loadCalendar() { + calStatus.textContent = '...'; + calStatus.className = 'widget-status'; + try { + const resp = await fetch('/api/calendar'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + calEvents = await resp.json(); + renderCalendar(); + calStatus.textContent = `${calEvents.length} cours`; + calStatus.className = 'widget-status ok'; + } catch (err) { + calStatus.textContent = 'ERREUR'; + calStatus.className = 'widget-status err'; + calList.innerHTML = `
  • Erreur : ${escapeHtml(err.message)}
  • `; + } +} + +function checkUpcomingCourses() { + const now = new Date(); + calEvents.forEach(event => { + const start = new Date(event.start); + const diffMin = (start - now) / 60000; + if (diffMin > 0 && diffMin <= 5 && !notifiedCourses.has(event.start)) { + notifiedCourses.add(event.start); + const loc = event.location ? ` — ${event.location}` : ''; + showNotif(`COURS DANS ${Math.ceil(diffMin)} MIN : ${event.title}${loc}`, null); + } + }); +} + // ── Root-me ranking ─────────────────────────────────────────────────────────── const rootmeList = document.getElementById('rootme-list'); @@ -343,8 +415,8 @@ async function loadRootme() { // ── Carousel ────────────────────────────────────────────────────────────────── const CAROUSEL_PAGES = [ - ['widget-kaspersky', 'widget-anssi', 'widget-geo', 'widget-placeholder'], - ['widget-kaspersky', 'widget-anssi', 'widget-geo', 'widget-clock'], + ['widget-kaspersky', 'widget-calendar', 'widget-geo', 'widget-placeholder'], + ['widget-kaspersky', 'widget-anssi', 'widget-geo', 'widget-clock'], ]; const ALL_CAROUSEL_IDS = [...new Set(CAROUSEL_PAGES.flat())]; const CAROUSEL_FADE_MS = 450; @@ -403,8 +475,13 @@ function init() { loadAnssi(); loadGeo(); loadRootme(); - setInterval(loadAnssi, 5 * 60 * 1000); - setInterval(loadGeo, 5 * 60 * 1000); + loadCalendar(); + setInterval(loadAnssi, 5 * 60 * 1000); + setInterval(loadGeo, 5 * 60 * 1000); + setInterval(loadCalendar, 30 * 60 * 1000); + setInterval(renderCalendar, 60 * 1000); // re-render active/past state chaque minute + setInterval(checkUpcomingCourses, 30 * 1000); + checkUpcomingCourses(); // Root-me est mis à jour via WebSocket (rootme_update / rootme_flag) } diff --git a/public/index.html b/public/index.html index 83fccc8..697b704 100644 --- a/public/index.html +++ b/public/index.html @@ -81,6 +81,19 @@ + +
    +
    + EMPLOI DU TEMPS + ... +
    +
    + +
    +
    +
    diff --git a/public/style.css b/public/style.css index b6f227b..2e3064c 100644 --- a/public/style.css +++ b/public/style.css @@ -2,9 +2,9 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { - --bg: #0a0a0a; - --bg-widget: #0f0f0f; - --bg-header: #111111; + --bg: #000000; + --bg-widget: #050505; + --bg-header: #080808; --border: #1a2a1a; --green: #00ff41; --green-dim: #00aa2a; @@ -65,6 +65,7 @@ main.grid { /* ── Carousel grid positions ─────────────────────────────────────────────── */ #widget-kaspersky { grid-column: 1; grid-row: 1; } #widget-anssi { grid-column: 2; grid-row: 1; } +#widget-calendar { grid-column: 2; grid-row: 1; } #widget-geo { grid-column: 1; grid-row: 2; } #widget-clock { grid-column: 2; grid-row: 2; } #widget-placeholder { grid-column: 2; grid-row: 2; } @@ -80,6 +81,51 @@ main.grid { transition: opacity 0.45s ease; } +/* ── Calendar ────────────────────────────────────────────────────────────── */ +#cal-list { + overflow: hidden; + justify-content: space-evenly; +} + +.cal-item { + border-bottom: 1px solid var(--border); + padding: 0 6px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 3px; + flex: 1; +} + +.cal-item.active { + border-left: 2px solid var(--green); + padding-left: 10px; + background: rgba(0, 255, 65, 0.04); +} + +.cal-item.past .cal-title, +.cal-item.past .cal-time, +.cal-item.past .cal-location { opacity: 0.35; } + +.cal-time { + color: var(--text-dim); + font-size: 13px; + letter-spacing: 1px; +} + +.cal-title { + color: var(--green); + font-size: 17px; + font-weight: bold; + line-height: 1.3; +} + +.cal-location { + color: var(--green-dim); + font-size: 13px; + letter-spacing: 1px; +} + /* ── Root-me ranking ─────────────────────────────────────────────────────── */ .rootme-item { display: grid; diff --git a/server.js b/server.js index 0436cfb..ae680bd 100644 --- a/server.js +++ b/server.js @@ -145,6 +145,80 @@ app.get('/api/feeds/geo', async (req, res) => { } }); +// ── ICS / Calendar ────────────────────────────────────────────────────────── + +const CALENDAR_URL = 'https://zeus.ionis-it.com/api/group/22/ics/TwBFCoxyY3?startDate=2026-01-01'; +const CALENDAR_CACHE_TTL = 30 * 60 * 1000; +let calendarCache = null; +let calendarCacheTime = 0; + +function parseICS(text) { + const unfolded = text.replace(/\r\n[ \t]/g, '').replace(/\n[ \t]/g, ''); + const lines = unfolded.split(/\r?\n/); + const events = []; + let cur = null; + for (const line of lines) { + if (line === 'BEGIN:VEVENT') { cur = {}; } + else if (line === 'END:VEVENT' && cur) { events.push(cur); cur = null; } + else if (cur) { + const ci = line.indexOf(':'); + if (ci === -1) continue; + cur[line.slice(0, ci).split(';')[0]] = line.slice(ci + 1); + } + } + return events; +} + +function parseICSDate(str) { + if (!str) return null; + const utc = str.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/); + if (utc) return new Date(`${utc[1]}-${utc[2]}-${utc[3]}T${utc[4]}:${utc[5]}:${utc[6]}Z`); + const loc = str.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/); + if (loc) return new Date(`${loc[1]}-${loc[2]}-${loc[3]}T${loc[4]}:${loc[5]}:${loc[6]}`); + const day = str.match(/^(\d{4})(\d{2})(\d{2})$/); + if (day) return new Date(`${day[1]}-${day[2]}-${day[3]}`); + return null; +} + +app.get('/api/calendar', async (req, res) => { + if (calendarCache && Date.now() - calendarCacheTime < CALENDAR_CACHE_TTL) { + return res.json(calendarCache); + } + try { + const response = await fetch(CALENDAR_URL, { + headers: { 'User-Agent': 'CyberDashboard/1.0' }, + timeout: 10000 + }); + const raw = parseICS(await response.text()); + + const now = new Date(); + const weekStart = new Date(now); + weekStart.setHours(0, 0, 0, 0); + const d = weekStart.getDay(); + weekStart.setDate(weekStart.getDate() - (d === 0 ? 6 : d - 1)); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 7); + + const unescape = s => (s || '').replace(/\\,/g, ',').replace(/\\n/g, ' ').replace(/\\;/g, ';').trim(); + + const events = raw + .map(e => { + const start = parseICSDate(e['DTSTART']); + const end = parseICSDate(e['DTEND']); + if (!start || !end) return null; + return { title: unescape(e['SUMMARY']), location: unescape(e['LOCATION']), start: start.toISOString(), end: end.toISOString() }; + }) + .filter(e => e && new Date(e.start) >= weekStart && new Date(e.start) < weekEnd) + .sort((a, b) => new Date(a.start) - new Date(b.start)); + + calendarCache = events; + calendarCacheTime = Date.now(); + res.json(events); + } catch (err) { + res.status(502).json({ error: 'Calendar fetch failed', detail: err.message }); + } +}); + // Root-me ranking const ROOTME_POLL_MS = 10 * 60 * 1000; let rootmeCache = null;