Files
watch-untis/backend/formatter.js
T

273 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
};