/* 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 ? `
| LV | LektorIn | Gruppe | Orte |
Termine | Std. | Zeitraum |
${rows.map((g) => `
| ${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()} |
`).join("")}
` : `Keine Termine in diesem Semester.
`}`;
const names = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
$("#sem-events").innerHTML = `
| Datum | KW | Zeit | LV | LektorIn | Gruppe | Ort |
${visibleEvents().map((e) => `
| ${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)} |
`).join("")}
`;
}
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 detailKind = plan.kind === "student"
? (plan.ptype === "lektor" ? "lektor" : "student")
: plan.kind;
const params = new URLSearchParams({
kind: detailKind, datum: ev.datum, stunde, ort: ev.ort,
});
if (plan.kind === "stg") {
params.set("kz", plan.kz);
params.set("sem", plan.sem);
params.set("ver", plan.ver);
params.set("grp", plan.grp);
} else {
params.set("uid", plan.uid);
}
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 = `
| 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))} |
${rows["LektorIn"] ? `| LektorIn | ${esc(rows["LektorIn"])} |
` : ""}
${rows["Ort"] ? `| Ort | ${esc(rows["Ort"])} |
` : ""}
${rows["Lehrfach"] ? `| Lehrfach | ${esc(rows["Lehrfach"])} |
` : ""}
${rows["Bezeichnung"] ? `| Bezeichnung | ${esc(rows["Bezeichnung"])} |
` : ""}
${rows["Verband"] ? `| Gruppe | ${esc(rows["Verband"])} |
` : ""}
${rows["Info"] ? `| 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)}`}
`;
box.classList.add("show");
}
/* ================= Seminar- + Verband-Selects dynamisch ================= */
async function reloadSemesters() {
const sel = $("#sem-select");
const stgSel = $("#stg-select");
if (!sel || !stgSel) return;
const kz = stgSel.value;
const planSem = (($('select[name="semester"]') || {}).value || "").toUpperCase();
try {
const r = await fetch(`/api/semesters_for_stg?kz=${encodeURIComponent(kz)}`);
if (!r.ok) throw new Error("bad status");
let data = await r.json();
let arr = (Array.isArray(data) ? data : Object.values(data))
.map(Number).filter(n => Number.isInteger(n) && n > 0);
if (planSem.startsWith("WS")) arr = arr.filter(n => n % 2 === 1);
else if (planSem.startsWith("SS")) arr = arr.filter(n => n % 2 === 0);
arr = [...new Set(arr)].sort((a, b) => a - b);
if (arr.length) {
const current = sel.value;
sel.innerHTML = arr.map(n =>
``).join("");
if (current && !arr.includes(Number(current))) sel.selectedIndex = 0;
}
} catch (e) { /* Keep static options */ }
reloadVerbands();
}
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 */ }
}
/* ================= Gesichtssuche: Job-Polling ================= */
function initSearchJob() {
const job = window.__SEARCH_JOB__;
if (!job) return;
const state = $("#search-state");
const count = $("#search-count");
const progress = $("#search-progress");
const timeBadge = $("#search-time");
const bar = $("#search-bar");
const grid = $("#match-grid");
const hint = $("#search-hint");
const t0 = Date.now();
function tile(r) {
const name = `${esc(r.nachname)}${r.vorname ? ", " + esc(r.vorname) : ""}`;
const pct = Math.max(0, Math.round(r.sim * 100));
const cls = r.sim >= 0.5 ? "match-strong" : (r.sim >= 0.35 ? "match-mid" : "");
const badge = r.kind === "lektor" ? ` Lektor` : "";
return ``;
}
function poll() {
fetch(`/api/suche/status/${job.id}`)
.then(r => { if (!r.ok) throw new Error("status"); return r.json(); })
.then(d => {
if (d.total) bar.style.width = (d.done / d.total * 100).toFixed(1) + "%";
progress.textContent = `${d.done}/${d.total}`;
grid.innerHTML = (d.top || []).map(tile).join("");
count.textContent = `${(d.top || []).filter(r => r.sim >= 0.35).length} Treffer`;
if (d.status === "done") {
clearInterval(timer);
state.textContent = d.error ? "Suche fehlgeschlagen" : "Fertig";
hint.style.display = "block";
if (d.error) hint.innerHTML = `${esc(d.error)}`;
const secs = d.took ? `${d.took} s` : `${((Date.now() - t0) / 1000).toFixed(0)} s`;
timeBadge.textContent = secs;
timeBadge.style.display = "";
}
})
.catch(() => { /* retry on next tick */ });
}
const timer = setInterval(poll, 2000);
poll();
}
/* ================= Matrikel/Lektor Vorschläge ================= */
function initUidSuggest() {
const inp = $("#uid-input");
if (!inp) return;
const box = $("#uid-suggest");
let timer = null;
inp.addEventListener("input", () => {
clearTimeout(timer);
const q = inp.value.trim();
if (q.length < 2) { box.classList.remove("show"); return; }
timer = setTimeout(async () => {
try {
const r = await fetch("/api/benutzer?q=" + encodeURIComponent(q));
const arr = await r.json();
if (!arr.length) { box.classList.remove("show"); return; }
box.innerHTML = arr.map(p =>
`${esc(p.name)}
${esc(p.uid)}
${p.kind === "lektor" ? 'Lektor' : ""}`
).join("");
box.classList.add("show");
} catch (e) { box.classList.remove("show"); }
}, 250);
});
box.addEventListener("click", (e) => {
const a = e.target.closest("a[data-uid]");
if (!a) return;
e.preventDefault();
inp.value = a.dataset.uid;
box.classList.remove("show");
});
document.addEventListener("click", (e) => {
if (!e.target.closest(".uid-field")) box.classList.remove("show");
});
}
/* ================= boot ================= */
document.addEventListener("DOMContentLoaded", () => {
if (window.__PLAN_DATA__) initPlan();
if (window.__STGS__) decodeMat();
if (window.__SEARCH_JOB__) initSearchJob();
initUidSuggest();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeDetail();
});