/** * Formats the raw WebUntis JSON-RPC timetable response into a compact list of lessons. * * Output schema (per entry): * - room: string * - teacher: string * - subject: string * - startTime: "HH:MM" | "–" * - endTime: "HH:MM" | "–" * - weekday: German weekday name | "–" * - date: "DD.MM.YYYY" | "–" * - cancelled: boolean * * Business rules implemented here: * 1) SZ normalization: * If multiple lessons share the same day + same start time and at least one subject starts * with "SZ" (e.g. "SZ1", "SZ10"), the whole slot becomes exactly ONE entry with: * subject="SZ", room="-", teacher="-". * 2) Cancelled flag: * If the raw item contains code="cancelled", cancelled=true, otherwise cancelled=false. * 3) Double-lesson merging: * Consecutive lessons with same (date, weekday, subject, teacher, room) are merged into one * block by extending endTime. A small gap (break) up to MERGE_GAP_MINUTES is allowed. * * @param {any} rawData WebUntis JSON-RPC response (expects rawData.result to be an array) * @returns {Array<{room:string,teacher:string,subject:string,startTime:string,endTime:string,weekday:string,date:string,cancelled:boolean}>} */ export const formatData = (rawData) => { const weekdays = [ "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", ]; /** * Allowed gap (in minutes) between two blocks to still merge them into a double lesson. * Example: 15:30 -> 15:35 (5 min break) will still merge. */ const MERGE_GAP_MINUTES = 15; /** * WebUntis times are typically numbers like 800, 855, 1435. * Converts to "HH:MM". Returns "–" for missing/invalid values. */ function formatTime(time) { if (time === null || time === undefined) return "–"; const n = Number(time); if (!Number.isFinite(n)) return "–"; const str = String(time).padStart(4, "0"); return str.slice(0, 2) + ":" + str.slice(2); } /** * WebUntis dates are typically numbers like 20260417. * Converts to "DD.MM.YYYY". Returns "–" for missing/invalid values. */ function formatDate(date) { if (date === null || date === undefined) return "–"; const str = String(date); if (str.length < 8) return "–"; return str.slice(6, 8) + "." + str.slice(4, 6) + "." + str.slice(0, 4); } /** * Returns German weekday name for a WebUntis date (YYYYMMDD). * Returns "–" for missing/invalid values. */ function getWeekday(date) { if (date === null || date === undefined) return "–"; const str = String(date); if (str.length < 8) return "–"; const d = new Date(str.slice(0, 4), str.slice(4, 6) - 1, str.slice(6, 8)); if (Number.isNaN(d.getTime())) return "–"; return weekdays[d.getDay()]; } /** * Parses "HH:MM" into minutes since midnight. * Returns null for "–" or invalid values. */ function parseTimeToMinutes(timeStr) { if (!timeStr || timeStr === "–") return null; const [h, m] = String(timeStr).split(":"); const hh = Number(h); const mm = Number(m); if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null; if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return null; return hh * 60 + mm; } /** * Converts a formatted date "DD.MM.YYYY" to a sortable numeric key YYYYMMDD. * Returns null for "–" or invalid values. */ function dateKeyFromFormatted(dateStr) { if (!dateStr || dateStr === "–") return null; const [dd, mm, yyyy] = String(dateStr).split("."); const d = Number(dd); const m = Number(mm); const y = Number(yyyy); if (!Number.isFinite(d) || !Number.isFinite(m) || !Number.isFinite(y)) { return null; } return y * 10000 + m * 100 + d; } /** @type {any[]} */ const items = rawData?.result ?? []; const lessons = []; for (const item of items) { if (item?.activityType !== "Unterricht") continue; // Subject name is typically in item.su[0].name. const subject = item?.su?.[0]?.name ?? "–"; const normalizedSubject = String(subject).trimStart(); const isSz = normalizedSubject.startsWith("SZ"); // WebUntis uses item.code === "cancelled" for cancelled lessons. const cancelled = String(item?.code ?? "") .trim() .toLowerCase() === "cancelled"; lessons.push({ room: item?.ro?.[0]?.name ?? "–", teacher: item?.te?.[0]?.name ?? "–", subject, startTime: formatTime(item?.startTime), endTime: formatTime(item?.endTime), weekday: getWeekday(item?.date), date: formatDate(item?.date), cancelled, // internal fields for grouping/sorting _dateRaw: Number(item?.date) || 0, _startTimeRaw: Number(item?.startTime) || 0, _endTimeRaw: Number(item?.endTime) || 0, _isSz: isSz, }); } lessons.sort((a, b) => { if (a._dateRaw !== b._dateRaw) return a._dateRaw - b._dateRaw; if (a._startTimeRaw !== b._startTimeRaw) return a._startTimeRaw - b._startTimeRaw; return a._endTimeRaw - b._endTimeRaw; }); // 1) Group by same day + same start time. // If any subject in a group starts with "SZ", collapse the whole group into ONE SZ entry. const groups = new Map(); for (const lesson of lessons) { const key = `${lesson._dateRaw}-${lesson._startTimeRaw}`; const group = groups.get(key); if (group) group.push(lesson); else groups.set(key, [lesson]); } const timetable = []; for (const group of groups.values()) { const hasSz = group.some((l) => l._isSz); if (!hasSz) { for (const lesson of group) { const { _dateRaw, _startTimeRaw, _endTimeRaw, _isSz, ...publicLesson } = lesson; timetable.push(publicLesson); } continue; } // Normalize: if any SZ* exists, show exactly one SZ block for that slot. const base = group.find((l) => l._isSz) ?? group[0]; const maxEndRaw = group.reduce( (max, l) => (l._endTimeRaw > max ? l._endTimeRaw : max), 0, ); // SZ cancelled only if ALL underlying entries are cancelled. // If at least one is not cancelled, the SZ block is considered not cancelled. const cancelled = group.every((l) => l.cancelled); timetable.push({ room: "-", teacher: "-", subject: "SZ", startTime: base.startTime, endTime: maxEndRaw ? formatTime(maxEndRaw) : base.endTime, weekday: base.weekday, date: base.date, cancelled, }); } // 2) Merge double lessons: group by (date, weekday, subject, teacher, room) // and merge consecutive time blocks within each group. const bucketKey = (e) => `${e.date}|${e.weekday}|${e.subject}|${e.teacher}|${e.room}`; const buckets = new Map(); for (const entry of timetable) { const key = bucketKey(entry); const arr = buckets.get(key); if (arr) arr.push(entry); else buckets.set(key, [entry]); } const merged = []; for (const arr of buckets.values()) { arr.sort((a, b) => { const as = parseTimeToMinutes(a.startTime) ?? 0; const bs = parseTimeToMinutes(b.startTime) ?? 0; if (as !== bs) return as - bs; const ae = parseTimeToMinutes(a.endTime) ?? 0; const be = parseTimeToMinutes(b.endTime) ?? 0; return ae - be; }); const mergedArr = []; for (const entry of arr) { const prev = mergedArr[mergedArr.length - 1]; if (!prev) { mergedArr.push(entry); continue; } const prevEnd = parseTimeToMinutes(prev.endTime); const currStart = parseTimeToMinutes(entry.startTime); const currEnd = parseTimeToMinutes(entry.endTime); const canMergeTimes = prevEnd !== null && currStart !== null && currEnd !== null && currStart >= prevEnd && currStart - prevEnd <= MERGE_GAP_MINUTES; if (canMergeTimes) { prev.endTime = entry.endTime; prev.cancelled = Boolean(prev.cancelled) || Boolean(entry.cancelled); continue; } mergedArr.push(entry); } merged.push(...mergedArr); } merged.sort((a, b) => { const ad = dateKeyFromFormatted(a.date) ?? 0; const bd = dateKeyFromFormatted(b.date) ?? 0; if (ad !== bd) return ad - bd; const as = parseTimeToMinutes(a.startTime) ?? 0; const bs = parseTimeToMinutes(b.startTime) ?? 0; if (as !== bs) return as - bs; // stable-ish: keep similar subjects grouped const subj = String(a.subject).localeCompare(String(b.subject)); if (subj !== 0) return subj; const room = String(a.room).localeCompare(String(b.room)); if (room !== 0) return room; return String(a.teacher).localeCompare(String(b.teacher)); }); return merged; };