diff --git a/backend/exampleResponse.json b/backend/exampleResponse.json new file mode 100644 index 0000000..f9bcbbf --- /dev/null +++ b/backend/exampleResponse.json @@ -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 + } +] \ No newline at end of file diff --git a/backend/formatter.js b/backend/formatter.js new file mode 100644 index 0000000..a01e9f0 --- /dev/null +++ b/backend/formatter.js @@ -0,0 +1,216 @@ +export const formatData = (rawData) => { + const weekdays = [ + "Sonntag", + "Montag", + "Dienstag", + "Mittwoch", + "Donnerstag", + "Freitag", + "Samstag", + ]; + + const MERGE_GAP_MINUTES = 15; + + 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); + } + + 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); + } + + 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()]; + } + + 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; + } + + 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; + } + + const items = rawData?.result ?? []; + const lessons = []; + + for (const item of items) { + if (item?.activityType !== "Unterricht") continue; + + const subject = item?.su?.[0]?.name ?? "–"; + const normalizedSubject = String(subject).trimStart(); + const isSz = normalizedSubject.startsWith("SZ"); + + 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; + }); + + // Group by same day + same start time. + // If any subject in a group starts with "SZ", collapse the whole group into the SZ lesson. + 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, + ); + 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, + }); + } + + // Merge double lessons (same subject/teacher/room/day) that are consecutive + // (allows a short gap, e.g. breaks). + 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; +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 8fc191b..2e1f0b8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "axios": "^1.15.0", + "dotenv": "^17.4.2", "express": "^5.2.1" } }, @@ -25,6 +27,23 @@ "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": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -87,6 +106,18 @@ "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": { "version": "1.0.1", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -153,6 +193,18 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -212,6 +264,21 @@ "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": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -291,6 +358,63 @@ "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": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -379,6 +503,21 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -561,9 +700,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -583,6 +722,15 @@ "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": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", diff --git a/backend/package.json b/backend/package.json index 64f3b3d..df4c223 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,6 +2,7 @@ "name": "backend", "version": "1.0.0", "main": "server.js", + "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "node server.js" @@ -11,6 +12,8 @@ "license": "ISC", "description": "", "dependencies": { + "axios": "^1.15.0", + "dotenv": "^17.4.2", "express": "^5.2.1" } -} +} \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 2099a4d..bd91c6d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,54 +1,198 @@ import express from "express"; - +import dotenv from "dotenv"; +import axios from "axios"; +import { formatData } from "./formatter.js"; +dotenv.config(); const app = express(); -app.get("/api/today", (req, res) => { - const userLink = req.query.userLink; +app.use(express.json()); - // Simulate fetching today's schedule - const todaySchedule = { - subjects: [ - { - lesson_nr: "1.", - name: "D G2", - room: "255", - teacher: "VanC", - clock: "08:00-08:55", - cancelled: false, - }, - { - lesson_nr: "2. & 3.", - name: "M G2", - room: "255", - teacher: "ScLa", - clock: "08:55-10:25", - cancelled: false, - }, - { - lesson_nr: "4.", - name: "E G4", - room: "254", - teacher: "RadF", - clock: "10:55-11:40", - cancelled: false, - }, - { - lesson_nr: "5.", - name: "CH G2", - room: "133", - teacher: "LenT", - clock: "11:40-12:25", - cancelled: true, - }, - ], - date: "15.04.2026", - day: "Mittwoch", - week: "16", - student: "Max Mustermann", +app.get("/api/get-timetable/", async (req, res) => { + const username = req.query.username; // required + const password = req.query.password; // required + const school = req.query.school; // required + + if (!username || !password || !school) { + res.status(400).json({ + error: "Missing required query params: username, password, school", + }); + return; + } + + let startDate = req.query.startDate; // optional - expected format: YYYYMMDD + let endDate = req.query.endDate; // optional - expected format: YYYYMMDD + + let sessionId = ""; // will be set after authentication by server response + let personId = ""; // will be set after authentication by server response + 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 + function setDates() { + if (startDate && endDate) { + return; + } + + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const dayOfWeek = today.getDay(); // 0 (Sunday) to 6 (Saturday) + + const addDays = (date, days) => + new Date(date.getFullYear(), date.getMonth(), date.getDate() + days); + + // format dates to YYYYMMDD + const formatDate = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}${month}${day}`; + }; + + // Desired behavior: + // - 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 }; }; - console.log(`Fetching schedule for user: ${userLink}`); - res.json(todaySchedule); + 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" }); + } + + // respond to client + res.json(timetableResponse.data?.result ?? null); + return; + } 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, () => {