aboutsummaryrefslogtreecommitdiff
path: root/cockpit/js
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2026-05-28 15:20:46 +0200
committerFlorian Fischer <florian.fischer@muhq.space>2026-05-28 15:20:46 +0200
commitf9a9fa15d6d61eb9730183193ce8af29bdfc17e7 (patch)
treec3b96a36623e52f22d281b518d7136795cf82b8a /cockpit/js
downloadbycs-sol-f9a9fa15d6d61eb9730183193ce8af29bdfc17e7.tar.gz
bycs-sol-f9a9fa15d6d61eb9730183193ce8af29bdfc17e7.zip
cockpit: initial commit
Diffstat (limited to 'cockpit/js')
-rw-r--r--cockpit/js/cockpit.js289
-rw-r--r--cockpit/js/config.js38
-rw-r--r--cockpit/js/database.js41
-rw-r--r--cockpit/js/graduation-common.js134
-rw-r--r--cockpit/js/graduation-mock.js6
-rw-r--r--cockpit/js/graduation-prod.js2
-rw-r--r--cockpit/js/mock.js77
-rw-r--r--cockpit/js/student-common.js56
-rw-r--r--cockpit/js/student-mock.js3
-rw-r--r--cockpit/js/student-prod.js2
10 files changed, 648 insertions, 0 deletions
diff --git a/cockpit/js/cockpit.js b/cockpit/js/cockpit.js
new file mode 100644
index 0000000..3b54f67
--- /dev/null
+++ b/cockpit/js/cockpit.js
@@ -0,0 +1,289 @@
+import {
+ fetchSubjectsMock as fetchSubjects,
+ fetchProgressMock as fetchProgress,
+} from "./mock.js";
+
+import {
+ Graduation,
+ GraduationChange,
+ fetchGraduation,
+ submitGraduationChange
+} from "./graduation-ENV.js";
+
+import { Student, fetchStudents } from "./student-ENV.js";
+
+let isTeacher;
+let firstname;
+let lastname;
+let student;
+
+function getIsTeacher() {
+ return Boolean(document.querySelector('a[href*="templates.php"]')) || document.body.classList.contains('editing');
+}
+
+function initCurrentName() {
+ const autoNameDiv = document.getElementById("auto-name");
+ firstname = autoNameDiv.children[0].innerHTML;
+ lastname = autoNameDiv.children[1].innerHTML;
+ autoNameDiv.remove();
+}
+
+async function loadNewStudent(name, group) {
+ student = new Student(0, name, group);
+ await loadStudent(student);
+}
+
+async function loadStudent(studentToLoad) {
+ student = studentToLoad;
+ fetchAndUpdateStudentInfo();
+ updateStudentCalendar();
+ updateStudentProgress();
+ updateStudentGraduation();
+}
+
+async function initStudentSelection() {
+ const sel = document.getElementById("student-selection");
+ const list = document.getElementById('student-selection-list');
+ const noResults = document.getElementById('student-selection-noresults');
+
+ const students = await fetchStudents();
+
+ let items = [];
+
+ let currentFocus = -1;
+ let visibleItems = [];
+ function setActive() {
+ visibleItems.forEach(item => item.classList.remove("active"));
+ if (currentFocus >= 0 && visibleItems[currentFocus]) {
+ visibleItems[currentFocus].classList.add("active");
+ }
+ }
+
+ for (const student of students) {
+ let item = document.createElement("li");
+ item.student = student;
+ item.innerHTML = `${student.name} ${student.group}`;
+ item.style.display = 'none';
+
+ item.addEventListener('click', function() {
+ sel.value = "";
+ sel.dispatchEvent(new Event('keyup'));
+ loadStudent(student);
+ });
+
+ list.appendChild(item);
+ items.push(item);
+ }
+
+ sel.addEventListener('keyup', function(e) {
+ if ((["ArrowDown", "ArrowUp", "Enter"]).includes(e.key))
+ return;
+
+ const filter = sel.value.toLowerCase();
+ visibleItems = [];
+ for (const item of items) {
+ const itemText = item.textContent.trim().toLowerCase();
+
+ if (itemText.indexOf(filter) > -1 && filter !== "") {
+ item.style.display = "";
+ visibleItems.push(item);
+
+ // Highlight matched text
+ const regex = new RegExp(`(${filter})`, 'gi');
+ item.innerHTML = item.textContent.replace(
+ regex,
+ "<span class='highlight'>$1</span>"
+ );
+ } else {
+ item.style.display = "none";
+ }
+ }
+
+ // Select the first or none if there are no items visible
+ currentFocus = visibleItems.length ? 0 : -1;
+ setActive(visibleItems);
+ noResults.style.display = visibleItems.length || filter === "" ? "none" : "block";
+ });
+
+ const clearBtn = document.getElementById('student-selection-clear');
+
+ sel.addEventListener('input', function() {
+ clearBtn.style.display = sel.value ? "block" : "none";
+ });
+
+ clearBtn.addEventListener('click', function() {
+ sel.value = "";
+ clearBtn.style.display = "none";
+ sel.dispatchEvent(new Event('keyup')); // reset list
+ sel.focus();
+ });
+
+ sel.addEventListener('keydown', function(e) {
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+ if(e.key === "ArrowDown")
+ currentFocus = (currentFocus + 1) % visibleItems.length;
+ else
+ currentFocus = (currentFocus - 1 + visibleItems.length) % visibleItems.length;
+ setActive();
+ e.preventDefault();
+ } else if (e.key === "Enter") {
+ if (currentFocus > -1 && visibleItems[currentFocus]) {
+
+ const visibleItem = visibleItems[currentFocus];
+ sel.value = "";
+ sel.dispatchEvent(new Event('keyup'));
+ loadStudent(visibleItem.student);
+ }
+ e.preventDefault();
+ }
+ });
+}
+
+function pointClickHandler(color) {
+ const textareaDiv = document.getElementById("student-info-point-reason-container");
+ const textarea = document.getElementById("student-info-point-reason");
+ const submitBtn = document.getElementById("student-info-point-reason-submit");
+
+ // TODO(muhq): Prefill the textarea with common reasons
+ switch (color) {
+ case "green":
+ textarea.style.background = "rgb(from green r g b / 20%)"
+ textarea.value = "";
+ break;
+ case "orange":
+ textarea.style.background = "rgb(from orange r g b / 20%)"
+ textarea.value = "";
+ break;
+ case "red":
+ textarea.style.background = "rgb(from red r g b / 20%)"
+ textarea.value = "";
+ break;
+ }
+ textareaDiv.style.display = "block";
+ textarea.focus();
+
+ let submit = function() {
+ let change;
+ switch (color) {
+ case "green":
+ change = GraduationChange.newGreenPointChange(student, textarea.value, `${firstname} ${lastname}`);
+ break;
+ case "orange":
+ change = GraduationChange.newOrangePointChange(student, textarea.value, `${firstname} ${lastname}`);
+ break;
+ case "red":
+ change = GraduationChange.newRedPointChange(student, textarea.value, `${firstname} ${lastname}`);
+ break;
+ default:
+ alert("Invalid color");
+ break;
+ }
+ submitGraduationChange(student, change);
+ textareaDiv.style.display = "none";
+ updateStudentInfo();
+ }
+ // Use onEVENT to override any previous handler
+ textarea.onkeydown = function(e) {
+ if (e.shiftKey && e.key === "Enter") {
+ submit();
+ }
+ if (e.key === "Escape") {
+ textareaDiv.style.display = "none";
+ }
+ };
+ submitBtn.onclick = submit;
+}
+
+async function fetchAndUpdateStudentInfo() {
+ student.graduation = await fetchGraduation(student);
+ updateStudentInfo();
+}
+
+function updateStudentInfo() {
+ document.getElementById("student-info-icon").src = student.getIconUrl();
+ document.getElementById("student-info-name").innerHTML = `${student.name}`;
+
+ if (student.graduation) {
+ const tag = document.getElementById("student-info-tag");
+ tag.innerHTML = student.graduation.level;
+ tag.style.display = "block";
+
+ document.getElementById("student-info-green").addEventListener('click', () => pointClickHandler("green"));
+ document.getElementById("student-info-points-green").innerHTML = student.graduation.green;
+
+ document.getElementById("student-info-orange").addEventListener('click', () => pointClickHandler("orange"));
+ document.getElementById("student-info-points-orange").innerHTML = student.graduation.orange;
+
+ document.getElementById("student-info-red").addEventListener('click', () => pointClickHandler("red"));
+ document.getElementById("student-info-points-red").innerHTML = student.graduation.red;
+
+ renderGraduationLevel(student.graduation, document.getElementById("student-info-level"));
+
+ renderGraduationStickers(student.graduation, document.getElementById("student-info-stickers"));
+ renderGraduationBonus(student.graduation, document.getElementById("student-info-stickers"));
+ }
+}
+
+function renderGraduationLevel(graduation, node) {
+ node.innerHTML = graduation.level;
+}
+
+function renderGraduationStickers(graduation, node) {
+}
+
+function renderGraduationBonus(graduation, node) {
+}
+
+function newProgressBar(subject) {
+ let bar = document.createElement("div");
+ fetchProgress(subject).then((progress) => {
+ bar.progress = progress;
+ });
+
+ let s1 = document.createElement("div");
+ s1.onhover
+
+ bar.appendChild(s1);
+ let s2 = document.createElement("div");
+ bar.appendChild(s2);
+ let s3 = document.createElement("div");
+ bar.appendChild(s3);
+ return bar;
+}
+
+let selectedSubjects;
+async function updateStudentProgress() {
+ selectedSubjects = await fetchSubjects(student);
+
+ let prog = document.getElementById("progress");
+ for(const subj of selectedSubjects) {
+ let subjDiv = document.createElement("div");
+ subjDiv.innerHTML = subj;
+ subjDiv.appendChild(newProgressBar(subj))
+ prog.appendChild(subjDiv);
+ }
+}
+
+async function updateStudentCalendar() {
+ let cal = document.getElementById("calendar");
+}
+
+async function updateStudentGraduation() {
+ let graduation = document.getElementById("graduation");
+}
+
+async function initCockpit() {
+ if (isTeacher) {
+ await initStudentSelection();
+ } else {
+ loadNewStudent(`${firstname} ${lastname}`);
+ }
+ return;
+}
+
+window.onload = function() {
+ // isTeacher = getIsTeacher();
+ isTeacher = true;
+ initCurrentName();
+ initCockpit(isTeacher);
+}
diff --git a/cockpit/js/config.js b/cockpit/js/config.js
new file mode 100644
index 0000000..6ec5831
--- /dev/null
+++ b/cockpit/js/config.js
@@ -0,0 +1,38 @@
+const GRADUATION_DB = "https://lernplattform.bycs.de/mod/data/view.php?d=262744";
+const GRADUATION_DB_PARAMS_MAP = {
+ id: "f_1133275",
+ // name: "f_1130848",
+ // group: "f_1130869",
+ change: "f_1133254",
+ reason: "f_1130860",
+ date: (date) => {
+ return {
+ f_1130863_d: date.getDate(),
+ f_1130863_m: date.getMonth(),
+ f_1130863_y: date.getFullYear(),
+ f_1130863_z: 1,
+ };
+ },
+ time: "f_1133248",
+ coach: "f_1130866",
+}
+
+const courseId = 2466361;
+function getCourseId() {
+ return courseId;
+}
+
+function getUserIconUrl(userId) {
+ return `https://lernplattform.bycs.de/pluginfile.php/${userId}/user/icon`;
+}
+
+function getCourseParticipantsUrl() {
+ return `https://lernplattform.bycs.de/user/index.php?id=${courseId}`;
+}
+
+export {
+ GRADUATION_DB, GRADUATION_DB_PARAMS_MAP,
+ getCourseId,
+ getUserIconUrl,
+ getCourseParticipantsUrl
+};
diff --git a/cockpit/js/database.js b/cockpit/js/database.js
new file mode 100644
index 0000000..9c71025
--- /dev/null
+++ b/cockpit/js/database.js
@@ -0,0 +1,41 @@
+async function loadFromDatabase(url, params) {
+ let dbUrl = url;
+ dbUrl += "&perpage=1000";
+ // TODO(muhq): Why does this not include entries?
+ if (params) {
+ dbUrl += "&filter=1&advanced=1";
+ for (const param in params) {
+ dbUrl += `&${param}=${encodeURIComponent(params[param])}`
+ }
+ }
+
+ const dbRes = await fetch(encodeURI(dbUrl));
+ const dbDoc = new DOMParser().parseFromString(await dbRes.text(), 'text/html');
+ let data = [];
+ dbDoc.querySelectorAll('.db-row').forEach(row => {
+ let entry = {};
+ row.querySelectorAll('.db-cell').forEach(field => {
+ const key = field.dataset.key;
+ const value = field.innerText.trim();
+ entry[key] = value;
+ });
+ data.push(entry);
+ });
+ return data;
+}
+
+async function writeToDatabase(url, params) {
+ let dbUrl = url;
+ dbUrl += "&perpage=1000";
+ // TODO(muhq): Why does this not include entries?
+ if (params) {
+ dbUrl += "&filter=1&advanced=1";
+ for (const param in params) {
+ dbUrl += `&${param}=${encodeURIComponent(params[param])}`
+ }
+ }
+
+ // return fetch(encodeURI(dbUrl));
+}
+
+export { loadFromDatabase, writeToDatabase };
diff --git a/cockpit/js/graduation-common.js b/cockpit/js/graduation-common.js
new file mode 100644
index 0000000..8d0e626
--- /dev/null
+++ b/cockpit/js/graduation-common.js
@@ -0,0 +1,134 @@
+import { GRADUATION_DB, GRADUATION_DB_PARAMS_MAP } from "./config.js";
+import { loadFromDatabase, writeToDatabase } from "./database.js"
+
+class GraduationChange {
+ constructor(date, time, id, name, group, change, reason, coach, json) {
+ this.date = date;
+ this.time = time;
+ this.id = id;
+ this.name = name;
+ this.group = group;
+ this.change = change;
+ this.reason = reason;
+ this.coach = coach;
+ this.json = json;
+ }
+
+ static newGreenPointChange(student, reason, coach) {
+ const now = new Date();
+ return new GraduationChange(
+ now, now.toTimeString(),
+ student.id, student.name, student.group,
+ "Grün", reason, coach, ""
+ );
+ }
+
+ static newOrangePointChange(student, reason, coach) {
+ const now = new Date();
+ return new GraduationChange(
+ now, now.toTimeString(),
+ student.id, student.name, student.group,
+ "Orange", reason, coach, ""
+ );
+ }
+
+ static newRedPointChange(student, reason, coach) {
+ const now = new Date();
+ return new GraduationChange(
+ now, now.toTimeString(),
+ student.id, student.name, student.group,
+ "Rot", reason, coach, ""
+ );
+ }
+}
+
+class Graduation {
+ constructor(student, green, orange, red, level, stickers, changes) {
+ this.student = student;
+ this.green = green;
+ this.orange = orange;
+ this.red = red;
+ this.level = level;
+ this.stickers = stickers;
+ this.changes = changes;
+
+ student.graduation = this;
+ }
+
+ static from(student, changes) {
+ let green = 0;
+ let orange = 0;
+ let red = 0;
+ let level = "Beginner";
+ let stickers = [];
+ for (const change of changes) {
+ switch (change.change) {
+ case "Grün":
+ green++;
+ break;
+ case "Orange":
+ orange++;
+ break;
+ case "Rot":
+ red++;
+ break;
+ case "Gespräch":
+ orange = 0;
+ break;
+ case "Änderung":
+ // TODO:
+ break;
+ default:
+ alert(`Unexpected change: ${change}`);
+ }
+ }
+ return new Graduation(student, green, orange, red, level, stickers, changes);
+ }
+
+ addChange(change) {
+ // TODO(muhq): Implement incremental addition
+ let changes = this.changes;
+ changes.push(change);
+ // This updates the student.graduation reference as well
+ Graduation.from(this.student, changes);
+ }
+}
+
+async function fetchGraduationChanges(student) {
+ let params = {};
+
+ for (const key in student) {
+ if (GRADUATION_DB_PARAMS_MAP[key] instanceof Function) {
+ const res = GRADUATION_DB_PARAMS_MAP[key](student[key]);
+ Object.assign(res, params);
+ } else {
+ params[GRADUATION_DB_PARAMS_MAP[key]] = student[key];
+ }
+ }
+ const data = await loadFromDatabase(GRADUATION_DB, params);
+
+ let changes = [];
+ for (const e of data) {
+ const gradChange = new GraduationChange(e.date, e.time, e.id, e.name, e.group, e.change, e.reason, e.coach, e.json);
+ changes.push(gradChange);
+ }
+ return changes;
+}
+
+async function fetchGraduation(student) {
+ const changes = await fetchGraduationChanges(student);
+ return Graduation.from(student, changes);
+}
+
+async function submitGraduationChange(student, change) {
+ student.graduation.addChange(change);
+ writeToDatabase(change);
+}
+
+export {
+ GraduationChange,
+ Graduation,
+ fetchGraduation,
+ fetchGraduationChanges,
+ submitGraduationChange
+}; \ No newline at end of file
diff --git a/cockpit/js/graduation-mock.js b/cockpit/js/graduation-mock.js
new file mode 100644
index 0000000..9f67185
--- /dev/null
+++ b/cockpit/js/graduation-mock.js
@@ -0,0 +1,6 @@
+import { Graduation, GraduationChange, } from "./graduation-common.js";
+import {
+ fetchGraduationMock as fetchGraduation,
+ submitGraduationChangeMock as submitGraduationChange
+} from "./mock.js";
+export { GraduationChange, Graduation, fetchGraduation, submitGraduationChange}; \ No newline at end of file
diff --git a/cockpit/js/graduation-prod.js b/cockpit/js/graduation-prod.js
new file mode 100644
index 0000000..de7cbf4
--- /dev/null
+++ b/cockpit/js/graduation-prod.js
@@ -0,0 +1,2 @@
+import { GraduationChange, Graduation, fetchGraduation, submitGraduationChange } from "./graduation-common.js";
+export { GraduationChange, Graduation, fetchGraduation, submitGraduationChange }; \ No newline at end of file
diff --git a/cockpit/js/mock.js b/cockpit/js/mock.js
new file mode 100644
index 0000000..a189a11
--- /dev/null
+++ b/cockpit/js/mock.js
@@ -0,0 +1,77 @@
+import { Graduation, GraduationChange } from "./graduation-common.js";
+import { Student } from "./student-common.js";
+
+const students = [
+ new Student(1, 'foo bar', 'test'),
+ new Student(2, 'maxi mustermensch', 'test'),
+ new Student(3, 'b b b', '5a')
+];
+
+function fetchStudentsMock() {
+ return new Promise((resolve) => { resolve(students); });
+}
+
+function fetchSubjectsMock() {
+ return new Promise((resolve) => {
+ resolve(['Mathe', 'Deutsch', 'Englisch']);
+ });
+}
+
+function fetchProgressMock(subject) {
+ return new Promise((resolve) => {
+ if (subject === "Mathe") {
+ resolve([40, 10, 3]);
+ } else if (subject === "Deutsch") {
+ resolve([10, 2, 0]);
+ } else if (subject === "Englisch") {
+ resolve([50, 25, 10]);
+ } else {
+ resolve([0, 0, 0]);
+ }
+ });
+}
+
+const graduationChanges = {};
+for (const s of students) {
+ switch (s.name) {
+ case "foo bar":
+ graduationChanges[s.name] = [
+ GraduationChange.newGreenPointChange(s, "", "Florian Fischer"),
+ GraduationChange.newOrangePointChange(s, "", "Florian Fischer"),
+ ];
+ break;
+ case "maxi mustermensch":
+ graduationChanges[s.name] = [
+ GraduationChange.newGreenPointChange(s, "", "Florian Fischer"),
+ GraduationChange.newGreenPointChange(s, "", "Florian Fischer"),
+ ];
+ break;
+ case "b b b":
+ graduationChanges[s.name] = [
+ GraduationChange.newRedPointChange(s, "", "Florian Fischer"),
+ ];
+ }
+}
+
+function fetchGraduationMock(s) {
+ return new Promise((resolve) => {
+ const changes = graduationChanges[s.name];
+ if (!changes) {
+ resolve(null);
+ } else {
+ resolve(Graduation.from(s, graduationChanges[s.name]));
+ }
+ });
+}
+
+function submitGraduationChangeMock(s, c) {
+ s.graduation.addChange(c);
+}
+
+export {
+ fetchStudentsMock,
+ fetchSubjectsMock,
+ fetchProgressMock,
+ fetchGraduationMock,
+ submitGraduationChangeMock,
+};
diff --git a/cockpit/js/student-common.js b/cockpit/js/student-common.js
new file mode 100644
index 0000000..74cc845
--- /dev/null
+++ b/cockpit/js/student-common.js
@@ -0,0 +1,56 @@
+import { getUserIconUrl, getCourseParticipantsUrl } from "./config.js";
+/**
+ * Class to represent Students.
+ */
+class Student {
+ constructor(id, name, group) {
+ this.id = id;
+ this.name = name;
+ this.group = group;
+ }
+
+ getIconUrl() {
+ return getUserIconUrl(this.id);
+ }
+}
+
+async function fetchStudent(nameFilter, groupFilter) {
+ return await fetchStudents(nameFilter, groupFilter)[0];
+}
+
+const idRegex = /id=(\d+)/;
+async function fetchStudents(nameFilter, groupFilter) {
+ const res = await fetch(getCourseParticipantsUrl());
+ const doc = new DOMParser().parseFromString(await res.text(), "text/html");
+ const table = doc.querySelector("tbody");
+ let students = [];
+ table.querySelectorAll("tr").forEach(row => {
+ if (row.classList.contains("emptyrow"))
+ return;
+
+ const roleCell = row.querySelector(".c4");
+ // TODO(muhq): Is this locale dependent?
+ if (roleCell.innerHTML.includes("Lehrer")) {
+ return;
+ }
+
+ const nameCell = row.querySelector(".c1");
+ const name = nameCell.firstChild.firstChild.title;
+ const profileUrl = nameCell.querySelector("a.aabtn").href;
+ const match = idRegex.exec(profileUrl);
+ const id = match[1];
+
+ const groupCell = row.querySelector(".c5");
+ const groupsText = groupCell.querySelector("a.quickeditlink").textContent;
+ const group = groupsText.trim();
+
+ if ((!nameFilter || name.includes(nameFilter)) &&
+ (!groupFilter || groupFilter === group))
+ {
+ students.push(new Student(id, name, group));
+ }
+ });
+ return students;
+}
+
+export { Student, fetchStudents, fetchStudent };
diff --git a/cockpit/js/student-mock.js b/cockpit/js/student-mock.js
new file mode 100644
index 0000000..0ea12ea
--- /dev/null
+++ b/cockpit/js/student-mock.js
@@ -0,0 +1,3 @@
+import { Student, fetchStudent } from "./student-common.js";
+import { fetchStudentsMock as fetchStudents } from "./mock.js";
+export { Student, fetchStudents, fetchStudent }; \ No newline at end of file
diff --git a/cockpit/js/student-prod.js b/cockpit/js/student-prod.js
new file mode 100644
index 0000000..f9c430c
--- /dev/null
+++ b/cockpit/js/student-prod.js
@@ -0,0 +1,2 @@
+import { Student, fetchStudent, fetchStudents } from "./student-common.js";
+export { Student, fetchStudents, fetchStudent }; \ No newline at end of file