273 lines
8.6 KiB
JavaScript
273 lines
8.6 KiB
JavaScript
/**
|
||
* 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;
|
||
};
|