2 Commits

Author SHA1 Message Date
theis.gaedigk 4f6b33f924 simplified code and added documentation 2026-04-17 22:46:47 +02:00
theis.gaedigk 6889cf15bf finished backend 2026-04-17 21:30:46 +02:00
5 changed files with 1004 additions and 49 deletions
+392
View File
@@ -0,0 +1,392 @@
[
{
"room": "027",
"teacher": "DavA",
"subject": "BI 2",
"startTime": "08:00",
"endTime": "09:40",
"weekday": "Freitag",
"date": "17.04.2026",
"cancelled": false
},
{
"room": "-",
"teacher": "-",
"subject": "SZ",
"startTime": "09:40",
"endTime": "10:25",
"weekday": "Freitag",
"date": "17.04.2026",
"cancelled": false
},
{
"room": "U56",
"teacher": "BürD",
"subject": "TC G1",
"startTime": "10:55",
"endTime": "12:25",
"weekday": "Freitag",
"date": "17.04.2026",
"cancelled": false
},
{
"room": "-",
"teacher": "-",
"subject": "SZ",
"startTime": "12:30",
"endTime": "13:15",
"weekday": "Freitag",
"date": "17.04.2026",
"cancelled": false
},
{
"room": "254",
"teacher": "RadF",
"subject": "E G4",
"startTime": "14:00",
"endTime": "14:45",
"weekday": "Freitag",
"date": "17.04.2026",
"cancelled": false
},
{
"room": "230",
"teacher": "HeeA",
"subject": "Ku G3",
"startTime": "14:45",
"endTime": "16:20",
"weekday": "Freitag",
"date": "17.04.2026",
"cancelled": false
},
{
"room": "-",
"teacher": "-",
"subject": "SZ",
"startTime": "09:40",
"endTime": "10:25",
"weekday": "Montag",
"date": "20.04.2026",
"cancelled": false
},
{
"room": "255",
"teacher": "ScLa",
"subject": "M G2",
"startTime": "10:55",
"endTime": "12:25",
"weekday": "Montag",
"date": "20.04.2026",
"cancelled": false
},
{
"room": "-",
"teacher": "-",
"subject": "SZ",
"startTime": "12:30",
"endTime": "13:15",
"weekday": "Montag",
"date": "20.04.2026",
"cancelled": false
},
{
"room": "U53",
"teacher": "WenS",
"subject": "Ph G2",
"startTime": "14:00",
"endTime": "14:45",
"weekday": "Montag",
"date": "20.04.2026",
"cancelled": false
},
{
"room": "NGB",
"teacher": "NGB",
"subject": "H0",
"startTime": "14:45",
"endTime": "16:20",
"weekday": "Montag",
"date": "20.04.2026",
"cancelled": false
},
{
"room": "255",
"teacher": "VanC",
"subject": "D G2",
"startTime": "08:55",
"endTime": "09:40",
"weekday": "Dienstag",
"date": "21.04.2026",
"cancelled": false
},
{
"room": "-",
"teacher": "-",
"subject": "SZ",
"startTime": "09:40",
"endTime": "10:25",
"weekday": "Dienstag",
"date": "21.04.2026",
"cancelled": false
},
{
"room": "U53",
"teacher": "WenS",
"subject": "Ph G2",
"startTime": "10:55",
"endTime": "11:40",
"weekday": "Dienstag",
"date": "21.04.2026",
"cancelled": false
},
{
"room": "255",
"teacher": "RadF",
"subject": "E G4",
"startTime": "11:40",
"endTime": "12:25",
"weekday": "Dienstag",
"date": "21.04.2026",
"cancelled": false
},
{
"room": "-",
"teacher": "-",
"subject": "SZ",
"startTime": "12:30",
"endTime": "13:15",
"weekday": "Dienstag",
"date": "21.04.2026",
"cancelled": false
},
{
"room": "TKS-3",
"teacher": "TKS",
"subject": "F7",
"startTime": "14:45",
"endTime": "16:20",
"weekday": "Dienstag",
"date": "21.04.2026",
"cancelled": false
},
{
"room": "261",
"teacher": "PerJ",
"subject": "S G1",
"startTime": "14:45",
"endTime": "16:20",
"weekday": "Dienstag",
"date": "21.04.2026",
"cancelled": false
},
{
"room": "253",
"teacher": "BecN",
"subject": "SW G2",
"startTime": "08:00",
"endTime": "09:40",
"weekday": "Mittwoch",
"date": "22.04.2026",
"cancelled": false
},
{
"room": "-",
"teacher": "-",
"subject": "SZ",
"startTime": "09:40",
"endTime": "10:25",
"weekday": "Mittwoch",
"date": "22.04.2026",
"cancelled": false
},
{
"room": "255",
"teacher": "VanC",
"subject": "D G2",
"startTime": "10:55",
"endTime": "11:40",
"weekday": "Mittwoch",
"date": "22.04.2026",
"cancelled": false
},
{
"room": "040-2",
"teacher": "ScLa",
"subject": "Sp G2",
"startTime": "11:40",
"endTime": "12:25",
"weekday": "Mittwoch",
"date": "22.04.2026",
"cancelled": false
},
{
"room": "040-3",
"teacher": "HofS",
"subject": "Sp G4",
"startTime": "11:40",
"endTime": "12:25",
"weekday": "Mittwoch",
"date": "22.04.2026",
"cancelled": true
},
{
"room": "-",
"teacher": "-",
"subject": "SZ",
"startTime": "12:30",
"endTime": "13:15",
"weekday": "Mittwoch",
"date": "22.04.2026",
"cancelled": false
},
{
"room": "261",
"teacher": "MasL",
"subject": "ER G1",
"startTime": "14:00",
"endTime": "15:30",
"weekday": "Mittwoch",
"date": "22.04.2026",
"cancelled": false
},
{
"room": "",
"teacher": "",
"subject": "ER G4",
"startTime": "14:00",
"endTime": "15:30",
"weekday": "Mittwoch",
"date": "22.04.2026",
"cancelled": false
},
{
"room": "TKS-3",
"teacher": "TKS-1",
"subject": "F7",
"startTime": "08:00",
"endTime": "09:40",
"weekday": "Donnerstag",
"date": "23.04.2026",
"cancelled": false
},
{
"room": "255",
"teacher": "PerJ",
"subject": "S G1",
"startTime": "08:00",
"endTime": "09:40",
"weekday": "Donnerstag",
"date": "23.04.2026",
"cancelled": false
},
{
"room": "-",
"teacher": "-",
"subject": "SZ",
"startTime": "09:40",
"endTime": "10:25",
"weekday": "Donnerstag",
"date": "23.04.2026",
"cancelled": false
},
{
"room": "254",
"teacher": "WedL",
"subject": "EK G2",
"startTime": "10:55",
"endTime": "12:25",
"weekday": "Donnerstag",
"date": "23.04.2026",
"cancelled": false
},
{
"room": "-",
"teacher": "-",
"subject": "SZ",
"startTime": "12:30",
"endTime": "13:15",
"weekday": "Donnerstag",
"date": "23.04.2026",
"cancelled": false
},
{
"room": "040-2",
"teacher": "ScLa",
"subject": "Sp G2",
"startTime": "14:45",
"endTime": "16:20",
"weekday": "Donnerstag",
"date": "23.04.2026",
"cancelled": false
},
{
"room": "040-3",
"teacher": "HofS",
"subject": "Sp G4",
"startTime": "14:45",
"endTime": "16:20",
"weekday": "Donnerstag",
"date": "23.04.2026",
"cancelled": true
},
{
"room": "027",
"teacher": "DavA",
"subject": "BI 2",
"startTime": "08:00",
"endTime": "09:40",
"weekday": "Freitag",
"date": "24.04.2026",
"cancelled": false
},
{
"room": "-",
"teacher": "-",
"subject": "SZ",
"startTime": "09:40",
"endTime": "10:25",
"weekday": "Freitag",
"date": "24.04.2026",
"cancelled": false
},
{
"room": "U56",
"teacher": "BürD",
"subject": "TC G1",
"startTime": "10:55",
"endTime": "12:25",
"weekday": "Freitag",
"date": "24.04.2026",
"cancelled": false
},
{
"room": "-",
"teacher": "-",
"subject": "SZ",
"startTime": "12:30",
"endTime": "13:15",
"weekday": "Freitag",
"date": "24.04.2026",
"cancelled": false
},
{
"room": "254",
"teacher": "RadF",
"subject": "E G4",
"startTime": "14:00",
"endTime": "14:45",
"weekday": "Freitag",
"date": "24.04.2026",
"cancelled": false
},
{
"room": "230",
"teacher": "HeeA",
"subject": "Ku G3",
"startTime": "14:45",
"endTime": "16:20",
"weekday": "Freitag",
"date": "24.04.2026",
"cancelled": false
}
]
+272
View File
@@ -0,0 +1,272 @@
/**
* 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;
};
+151 -3
View File
@@ -9,6 +9,8 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"axios": "^1.15.0",
"dotenv": "^17.4.2",
"express": "^5.2.1" "express": "^5.2.1"
} }
}, },
@@ -25,6 +27,23 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -87,6 +106,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -144,6 +175,15 @@
} }
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -153,6 +193,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -212,6 +264,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -291,6 +358,63 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -379,6 +503,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -561,9 +700,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "8.3.0", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -583,6 +722,15 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
+3
View File
@@ -2,6 +2,7 @@
"name": "backend", "name": "backend",
"version": "1.0.0", "version": "1.0.0",
"main": "server.js", "main": "server.js",
"type": "module",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js" "start": "node server.js"
@@ -11,6 +12,8 @@
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"axios": "^1.15.0",
"dotenv": "^17.4.2",
"express": "^5.2.1" "express": "^5.2.1"
} }
} }
+185 -45
View File
@@ -1,54 +1,194 @@
import express from "express"; import express from "express";
import dotenv from "dotenv";
import axios from "axios";
import { formatData } from "./formatter.js";
dotenv.config();
const app = express(); const app = express();
app.get("/api/today", (req, res) => { app.use(express.json());
const userLink = req.query.userLink;
// Simulate fetching today's schedule app.get("/api/get-timetable/", async (req, res) => {
const todaySchedule = { const username = req.query.username; // required
subjects: [ const password = req.query.password; // required
{ const school = req.query.school; // required
lesson_nr: "1.",
name: "D G2", if (!username || !password || !school) {
room: "255", res.status(400).json({
teacher: "VanC", error: "Missing required query params: username, password, school",
clock: "08:00-08:55", });
cancelled: false, return;
}, }
{
lesson_nr: "2. & 3.", let startDate = req.query.startDate; // optional - expected format: YYYYMMDD
name: "M G2", let endDate = req.query.endDate; // optional - expected format: YYYYMMDD
room: "255",
teacher: "ScLa", let sessionId = ""; // will be set after authentication by server response
clock: "08:55-10:25", let personId = ""; // will be set after authentication by server response
cancelled: false, let personType = ""; // will be set after authentication by server response
},
{ // static function for checking or/and generating start and end date for the current week
lesson_nr: "4.", function setDates() {
name: "E G4", if (startDate && endDate) {
room: "254", return;
teacher: "RadF", }
clock: "10:55-11:40",
cancelled: false, const now = new Date();
}, const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
{ const dayOfWeek = today.getDay(); // 0 (Sunday) to 6 (Saturday)
lesson_nr: "5.",
name: "CH G2", const addDays = (date, days) =>
room: "133", new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
teacher: "LenT",
clock: "11:40-12:25", // format dates to YYYYMMDD
cancelled: true, const formatDate = (date) => {
}, const year = date.getFullYear();
], const month = String(date.getMonth() + 1).padStart(2, "0");
date: "15.04.2026", const day = String(date.getDate()).padStart(2, "0");
day: "Mittwoch", return `${year}${month}${day}`;
week: "16",
student: "Max Mustermann",
}; };
console.log(`Fetching schedule for user: ${userLink}`); // Desired behavior:
res.json(todaySchedule); // - Weekday (Mon-Thu): startDate = today, endDate = coming Friday (same week)
// - Friday: startDate = today, endDate = coming Friday (next week)
// - Weekend (Sat/Sun): startDate = coming Monday, endDate = coming Friday (of that week)
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
let start;
let end;
if (isWeekend) {
const daysUntilMonday = dayOfWeek === 0 ? 1 : 2; // Sun->Mon:1, Sat->Mon:2
start = addDays(today, daysUntilMonday);
end = addDays(start, 4); // Monday + 4 days = Friday
} else {
start = today;
const daysUntilFriday = 5 - dayOfWeek; // Mon(1)->4 ... Fri(5)->0
end = addDays(today, daysUntilFriday === 0 ? 7 : daysUntilFriday);
}
startDate = formatDate(start);
endDate = formatDate(end);
}
setDates();
const baseUrl = `https://${school}.webuntis.com/WebUntis/jsonrpc.do?school=${school}`;
const toJsonRpcError = (data) => {
const err = data?.error;
if (!err) return null;
const code = err.code;
const message = err.message || "Unknown WebUntis error";
return { code, message };
};
try {
// 1) authenticate
const authResponse = await axios.post(baseUrl, {
id: "1",
method: "authenticate",
params: {
user: username,
password: password,
client: "watch-untis",
},
jsonrpc: "2.0",
});
const authErr = toJsonRpcError(authResponse.data);
if (authErr) {
if (authErr.message === "bad credentials") {
res.status(401).json({ error: "Invalid username or password" });
return;
}
if (authErr.code === -8504) {
res.status(403).json({ error: "WebUntis Error: -8504" });
return;
}
res.status(502).json({ error: authErr.message, code: authErr.code });
return;
}
sessionId = authResponse.data?.result?.sessionId;
personId = authResponse.data?.result?.personId;
personType = authResponse.data?.result?.personType;
if (!sessionId || !personId) {
res.status(502).json({
error: "WebUntis auth succeeded but returned no sessionId/personId",
});
return;
}
// 2) get timetable (after we have sessionId, personId & personType from auth response)
const timetableResponse = await axios.post(
baseUrl,
{
id: "3",
method: "getTimetable",
params: {
options: {
element: {
id: personId,
type: personType,
},
startDate,
endDate,
teacherFields: ["id", "name", "longname"],
subjectFields: ["id", "name", "longname"],
roomFields: ["id", "name", "longname"],
},
},
jsonrpc: "2.0",
},
{
headers: {
Cookie: `JSESSIONID=${sessionId}`,
},
},
);
const timetableErr = toJsonRpcError(timetableResponse.data);
if (timetableErr) {
res
.status(502)
.json({ error: timetableErr.message, code: timetableErr.code });
return;
}
// 3) format the data once it successfully returned
try {
const formattedData = formatData(timetableResponse.data);
res.json(formattedData);
} catch (formatError) {
console.error("formatData failed", formatError);
res.status(500).json({ error: "Failed to format timetable data" });
}
} catch (error) {
const status = error?.response?.status;
const data = error?.response?.data;
const jsonRpcErr = toJsonRpcError(data);
if (jsonRpcErr?.message === "bad credentials") {
res.status(401).json({ error: "Invalid username or password" });
return;
}
if (jsonRpcErr?.code === -8504) {
res.status(403).json({ error: "WebUntis Error: -8504" });
return;
}
console.error(error);
res.status(502).json({
error: "Failed to fetch timetable",
upstreamStatus: status,
});
}
});
// error handling code
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send("Something broke!");
}); });
app.listen(8001, () => { app.listen(8001, () => {