/* CIS Enhanced - client logic */ "use strict"; /* ================= helpers ================= */ const $ = (sel) => document.querySelector(sel); const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[c])); function lvColor(lv) { let h = 0; for (const c of lv) h = (h * 31 + c.charCodeAt(0)) % 997; const hue = (h * 1.375) % 360; return `hsl(${hue}, 52%, 42%)`; } function fmtTime(d) { return d.toTimeString().slice(0, 5); } function fmtDate(d) { const months = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]; return `${String(d.getDate()).padStart(2, "0")}.${months[d.getMonth()]}`; } function parseISO(s) { const [date, time] = s.split("T"); const [y, m, d] = date.split("-").map(Number); const [hh, mm, ss] = time.split(":").map(Number); return new Date(y, m - 1, d, hh, mm, ss || 0); } function addDays(d, n) { const r = new Date(d); r.setDate(r.getDate() + n); return r; } function sameDay(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } /* ================= plan view ================= */ const PX_PER_HOUR = 48; const DAY_START_H = 8; let plan = null; let weekStart = null; let gruppeFilter = ""; function visibleEvents() { if (!gruppeFilter) return plan.events; return plan.events.filter((e) => e.gruppe === gruppeFilter); } function setGruppeFilter(v) { gruppeFilter = v; renderWeek(); renderSemester(); } function initPlan() { plan = window.__PLAN_DATA__; plan.events = plan.events.map((e) => ({ ...e, s: parseISO(e.start), e2: parseISO(e.end) })); weekStart = parseISO(plan.initWeek + "T00:00:00"); if (plan.kind === "stg") { const sel = $("#gruppe-filter"); const groups = [...new Set(plan.events.map((e) => e.gruppe).filter(Boolean))].sort(); if (groups.length > 1) { for (const g of groups) { sel.insertAdjacentHTML("beforeend", ``); } sel.style.display = ""; } } renderWeek(); renderSemester(); if (plan.tab === "sem") switchTab("sem"); } function stundeOf(ev) { const mins = ev.s.getHours() * 60 + ev.s.getMinutes(); let best = 1; plan.slots.forEach((slot, i) => { const [hh, mm] = slot.split(":").map(Number); if (hh * 60 + mm <= mins) best = i + 1; }); return best; } function weekEnd() { let endH = 18; for (const e of visibleEvents()) { const h = e.e2.getHours() + (e.e2.getMinutes() / 60); if (h > endH) endH = Math.ceil(h + 0.25); } return endH; } function layoutDay(evs) { evs.sort((a, b) => (a.s - b.s) || (b.e2 - a.e2)); const clusters = []; let cur = null, curEnd = null; for (const e of evs) { if (!cur || e.s >= curEnd) { cur = []; clusters.push(cur); curEnd = e.e2; } else { curEnd = Math.max(curEnd, e.e2); } cur.push(e); } for (const cluster of clusters) { const cols = []; for (const e of cluster) { let placed = false; for (let i = 0; i < cols.length; i++) { if (cols[i][cols[i].length - 1].e2 <= e.s) { cols[i].push(e); e._col = i; placed = true; break; } } if (!placed) { e._col = cols.length; cols.push([e]); } } for (const e of cluster) e._cols = cols.length; } } function renderWeek() { const grid = $("#week-grid"); if (!grid) return; const endH = weekEnd(); const height = (endH - DAY_START_H) * PX_PER_HOUR; const today = new Date(); const monday = weekStart; const sunday = addDays(monday, 6); const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]; const names = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; let html = `
`; for (let i = 0; i < 7; i++) { const d = addDays(monday, i); const isToday = sameDay(d, today) ? "today" : ""; const we = i >= 5 ? "weekend" : ""; const monthLabel = (i === 0 || d.getDate() <= 7) ? ` ${MONTHS[d.getMonth()]}` : ""; html += `
${names[i]}${d.getDate()}${monthLabel}
`; } html += `
`; for (let h = DAY_START_H; h <= endH; h++) { const top = (h - DAY_START_H) * PX_PER_HOUR; html += `${String(h).padStart(2, "0")}:00`; } html += `
`; for (let i = 0; i < 7; i++) { const d = addDays(monday, i); const we = i >= 5 ? "weekend" : ""; html += `
`; for (let m = 0; m < (endH - DAY_START_H) * 60; m += 30) { const top = (m / 60) * PX_PER_HOUR; if (m % 60 === 0 && m > 0) continue; html += `
`; } const dayEvents = visibleEvents().filter((ev) => ev.s >= d && ev.s < addDays(d, 1) && ev.s.getHours() >= DAY_START_H); layoutDay(dayEvents); for (const ev of dayEvents) { const top = ((ev.s.getHours() + ev.s.getMinutes() / 60) - DAY_START_H) * PX_PER_HOUR; const dur = (ev.e2 - ev.s) / 3600000; const hgt = Math.max(dur * PX_PER_HOUR, 14); const w = 100 / (ev._cols || 1); const l = (ev._col || 0) * w; const pay = { lv: ev.lv, lektor: ev.lektor, gruppe: ev.gruppe, ort: ev.ort, start: ev.start, end: ev.end, datum: ev.start.slice(0, 10) }; html += ``; } html += `
`; } grid.style.gridTemplateRows = `auto ${height}px`; grid.innerHTML = html; $("#week-label").textContent = `KW ${isoWeek(monday)}`; $("#week-range").textContent = `${fmtDate(monday)} - ${fmtDate(sunday)} ${sunday.getFullYear()}`; renderLegend(); } function isoWeek(d) { const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); const dayNum = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); return Math.ceil((((date - yearStart) / 86400000) + 1) / 7); } function renderLegend() { const lvs = [...new Set(visibleEvents().map((e) => e.lv))]; $("#legend").innerHTML = lvs.map((lv) => `${esc(lv)}`).join(""); } function renderSemester() { const byLv = {}; for (const e of visibleEvents()) { if (!byLv[e.lv]) byLv[e.lv] = { lv: e.lv, lektors: new Set(), gruppens: new Set(), orte: new Set(), n: 0, hours: 0, first: e.s, last: e.s }; const g = byLv[e.lv]; g.lektors.add(e.lektor); if (e.gruppe) g.gruppens.add(e.gruppe); if (e.ort) g.orte.add(e.ort); g.n++; g.hours += (e.e2 - e.s) / 3600000; if (e.s < g.first) g.first = e.s; if (e.s > g.last) g.last = e.s; } const rows = Object.values(byLv).sort((a, b) => a.lv.localeCompare(b.lv)); $("#sem-lv").innerHTML = `

Lehrveranstaltungen

${rows.length ? ` ${rows.map((g) => ``).join("")}
LVLektorInGruppeOrte TermineStd.Zeitraum
${esc(g.lv)} ${esc([...g.lektors].join(", "))} ${esc([...g.gruppens].join(", ")) || "-"} ${esc([...g.orte].join(", ")) || "-"} ${g.n} ${g.hours.toFixed(1)} ${fmtDate(g.first)} - ${fmtDate(g.last)} ${g.last.getFullYear()}
` : `
Keine Termine in diesem Semester.
`}`; const names = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]; $("#sem-events").innerHTML = ` ${visibleEvents().map((e) => ``).join("")}
DatumKWZeitLVLektorInGruppeOrt
${fmtDate(e.s)} ${e.s.getFullYear()} ${names[e.s.getDay()]} / ${isoWeek(e.s)} ${fmtTime(e.s)} - ${fmtTime(e.e2)} ${esc(e.lv)} ${esc(e.lektor)} ${esc(e.gruppe) || "-"} ${esc(e.ort)}
`; } function shiftWeek(n) { weekStart = addDays(weekStart, n * 7); renderWeek(); } function goToday() { const t = new Date(); weekStart = addDays(t, -((t.getDay() + 6) % 7)); renderWeek(); } function switchTab(which) { $("#tab-week").classList.toggle("active", which === "week"); $("#tab-sem").classList.toggle("active", which === "sem"); $("#pane-week").classList.toggle("active", which === "week"); $("#pane-sem").classList.toggle("active", which === "sem"); } /* ---------- detail modal ---------- */ async function openEvent(ev) { const modal = $("#detail-modal"); modal.classList.add("open"); const body = $("#detail-body"); body.innerHTML = `

Lade Details vom CIS...

`; const stunde = stundeOf({ s: parseISO(ev.start) }); const params = new URLSearchParams({ kind: plan.kind, datum: ev.datum, stunde, ort: ev.ort, }); if (plan.kind === "student") { params.set("uid", plan.uid); } else { params.set("kz", plan.kz); params.set("sem", plan.sem); params.set("ver", plan.ver); params.set("grp", plan.grp); } try { const r = await fetch("/api/detail?" + params.toString()); const j = await r.json(); if (j.error) throw new Error(j.error); const rows = j.rows || {}; body.innerHTML = ` ${rows["LektorIn"] ? `` : ""} ${rows["Ort"] ? `` : ""} ${rows["Lehrfach"] ? `` : ""} ${rows["Bezeichnung"] ? `` : ""} ${rows["Verband"] ? `` : ""} ${rows["Info"] ? `` : ""}
Datum / Stunde${esc(ev.datum)} / Stunde ${esc(stunde)}
LV-Code${esc(ev.lv)}
Zeit${esc(ev.start.slice(11, 16))} - ${esc(ev.end.slice(11, 16))}
LektorIn${esc(rows["LektorIn"])}
Ort${esc(rows["Ort"])}
Lehrfach${esc(rows["Lehrfach"])}
Bezeichnung${esc(rows["Bezeichnung"])}
Gruppe${esc(rows["Verband"])}
Info${esc(rows["Info"])}
`; } catch (e) { body.innerHTML = `

Details konnten nicht geladen werden: ${esc(e.message)}

Datum${esc(ev.datum)}
Zeit${esc(ev.start.slice(11, 16))} - ${esc(ev.end.slice(11, 16))}
LV${esc(ev.lv)}
LektorIn${esc(ev.lektor)}
Gruppe${esc(ev.gruppe)}
Ort${esc(ev.ort)}
`; } } function closeDetail() { $("#detail-modal").classList.remove("open"); } /* ---------- CSV ---------- */ function downloadCSV() { const names = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]; let csv; if (window.__PLAN_DATA__) { csv = "Datum;Wochentag;Beginn;Ende;LV;Lektor;Gruppe;Ort\n" + plan.events.map((e) => [e.start.slice(0, 10), names[e.s.getDay()], fmtTime(e.s), fmtTime(e.e2), e.lv, e.lektor, e.gruppe, e.ort].map((x) => `"${String(x).replace(/"/g, '""')}"`).join(";") ).join("\n"); } else if (window.__LIST_DATA__) { csv = "Nr;Nachname;Vorname;Geschlecht;Matrikelnummer;Mail\n" + window.__LIST_DATA__.map((s, i) => [i + 1, s.nachname, s.vorname, s.geschlecht, s.matrikel, s.mail].map((x) => `"${String(x).replace(/"/g, '""')}"`).join(";") ).join("\n"); } else return; const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = (window.__PLAN_DATA__ ? `lvplan_${window.__PLAN_DATA__.semester}` : `namensliste`) + ".csv"; a.click(); URL.revokeObjectURL(a.href); } /* ================= Namensliste ================= */ function filterList() { const q = ($("#list-search").value || "").toLowerCase().trim(); let n = 0; for (const tr of $("#list-table").querySelectorAll("tbody tr")) { const hay = (tr.dataset.search || "").toLowerCase(); const show = !q || hay.includes(q); tr.style.display = show ? "" : "none"; if (show) n++; } const c = $("#student-count"); if (c) c.textContent = q ? `${n}` : window.__LIST_COUNT__ || n; } /* ================= Matrikel decoder (home) ================= */ function decodeMat() { const m = $("#decode-input").value.trim(); const box = $("#decode-result"); if (!/^\d{9,10}$/.test(m)) { box.classList.remove("show"); return; } const stgKz = m.slice(-6, -3); const nr = m.slice(-3); const year = m.slice(0, 2); const code = m.slice(2, 4); let start = `20${year}`; if (code === "10") start = `WS 20${year}`; else if (code === "20") start = `SS 20${year}`; const stg = (window.__STGS__ || []).find((s) => s.kz === stgKz); box.innerHTML = `
Startsemester
${esc(start)}
Studiengang
${stg ? `${esc(stg.code)} - ${esc(stg.name)}` : `KZ ${esc(stgKz)}`}
Fortlaufnummer
${esc(nr)}
Plan ansehen
`; box.classList.add("show"); } /* ================= Verband-Selects dynamisch ================= */ async function reloadVerbands() { const sel = $("#ver-select"); if (!sel) return; const kz = $("#stg-select").value; const sem = $("#sem-select").value; const current = sel.value; try { const r = await fetch(`/api/verbands?kz=${encodeURIComponent(kz)}&sem=${encodeURIComponent(sem)}`); if (!r.ok) return; const verbs = await r.json(); if (!Array.isArray(verbs) || !verbs.length) return; const labels = { V: " (Vollzeit)", B: " (berufsbegl.)" }; sel.innerHTML = verbs.map((v) => ``).join(""); if (current && !verbs.includes(current)) sel.selectedIndex = 0; } catch (e) { /* Keep static options */ } } /* ================= boot ================= */ document.addEventListener("DOMContentLoaded", () => { if (window.__PLAN_DATA__) initPlan(); if (window.__STGS__) decodeMat(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDetail(); });