aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2026-08-03 14:31:25 +0200
committerFlorian Fischer <florian.fischer@muhq.space>2026-08-03 14:31:25 +0200
commit6839831af54cd9302119bcc3bffafe10475958d4 (patch)
tree12a1c1fbd864e9579cb9c3a09d4bf4686a280b73 /src
parentfccb70cbd876785f6833dfe6efc10ec9e445556d (diff)
downloadbycs-sol-6839831af54cd9302119bcc3bffafe10475958d4.tar.gz
bycs-sol-6839831af54cd9302119bcc3bffafe10475958d4.zip
refactor project structure
* merge sites into single project root * fix name of mass-graduation site * enable typescript support
Diffstat (limited to 'src')
-rw-r--r--src/admin-tools.js57
-rw-r--r--src/cockpit.js366
-rw-r--r--src/config.js0
-rw-r--r--src/database.js160
-rw-r--r--src/graduation-common.js396
-rw-r--r--src/graduation-mock.js30
-rw-r--r--src/graduation-prod.js15
-rw-r--r--src/group.js57
-rw-r--r--src/mass-graduation.js357
-rw-r--r--src/mock.js104
-rw-r--r--src/selection.js170
-rw-r--r--src/student-common.js115
-rw-r--r--src/student-mock.js9
-rw-r--r--src/student-prod.js2
-rw-r--r--src/utils-common.ts25
-rw-r--r--src/utils-mock.js41
-rw-r--r--src/utils-prod.js36
17 files changed, 1940 insertions, 0 deletions
diff --git a/src/admin-tools.js b/src/admin-tools.js
new file mode 100644
index 0000000..b0cf80d
--- /dev/null
+++ b/src/admin-tools.js
@@ -0,0 +1,57 @@
+import { DB } from "./database.js";
+import { fetchStudents } from "./student-ENV.js";
+
+let outputNode;
+function output(msg) {
+ if (outputNode.style.display === "none") {
+ outputNode.style.display = "block";
+ }
+ const line = document.createElement("div");
+ line.innerHTML = msg;
+ line.classList.add("admin-tools-output-line");
+ outputNode.appendChild(line);
+}
+
+const studentsPromise = fetchStudents();
+
+async function createGraduationDbs() {
+ const students = await studentsPromise;
+
+ for (const student of students) {
+ if (!DB.getStudentDbs(student)) {
+ }
+ }
+}
+
+async function init() {
+ outputNode = document.getElementById("admin-tools-output");
+ const students = await studentsPromise;
+
+ output("Suche nach Datenbanken ...");
+ let missing = 0;
+ for (const student of students) {
+ const dbs = await DB.getStudentDbs(student);
+ if (!dbs) {
+ output(`\t${student.name}: Datenbanken fehlen`);
+ missing += 3;
+ continue;
+ }
+ for (const type of ["shadow", "public", "submit"]) {
+ if (!dbs[type]) {
+ output(`\t${student.name} ${student.group}: ${type} Datenbank fehlt`);
+ missing++;
+ }
+ }
+ }
+ if (missing) {
+ output(`${missing} von ${students.length * 3} Datenbanken fehlen!`);
+ } else {
+ output("Alle Datenbanken vorhanden!");
+ }
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init);
+} else {
+ init();
+}
diff --git a/src/cockpit.js b/src/cockpit.js
new file mode 100644
index 0000000..bd4c860
--- /dev/null
+++ b/src/cockpit.js
@@ -0,0 +1,366 @@
+// import {
+ // fetchSubjectsMock as fetchSubjects,
+ // fetchProgressMock as fetchProgress,
+// } from "../../js/mock.js";
+
+import {
+ OBSERVATION_TYPES,
+ LEVEL_NAMES,
+ GraduationChange,
+ fetchGraduation,
+ fetchPublicGraduation,
+ submitGraduationChange
+} from "./graduation-ENV.js";
+
+import { fetchStudent, fetchStudents, Student } from "./student-ENV.js";
+import {
+ isTeacher as getIsTeacher,
+ getName,
+ getUserId,
+ DATE_FMT,
+ DATETIME_FMT,
+} from "./utils-ENV.js";
+import { initSelection } from "./selection.js";
+
+let isTeacher;
+let name;
+let student;
+
+async function loadStudent(studentToLoad) {
+ student = studentToLoad;
+ await fetchAndUpdateStudentInfo();
+ // updateStudentCalendar();
+ // updateStudentProgress();
+ updateStudentGraduation();
+}
+
+function initStudentSelection() {
+ initSelection(
+ "student-selection",
+ fetchStudents,
+ (student) => `${student.name} ${student.getGroup()}`,
+ loadStudent
+ );
+}
+
+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();
+
+ const coach = name;
+
+ let submit = function() {
+ let change;
+ switch (color) {
+ case "green":
+ change = GraduationChange.newGreenPointChange(textarea.value, coach);
+ break;
+ case "orange":
+ change = GraduationChange.newOrangePointChange(textarea.value, coach);
+ break;
+ case "red":
+ change = GraduationChange.newRedPointChange(textarea.value, coach);
+ break;
+ default:
+ alert("Invalid color");
+ break;
+ }
+ submitGraduationChange(student, change);
+ textareaDiv.style.display = "none";
+ updateStudentInfo();
+ student.fetchGraduation().then(() => updateStudentGraduation() );
+ }
+ // 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() {
+ if (isTeacher)
+ student.graduation = await fetchGraduation(student);
+ else
+ student.graduation = await fetchPublicGraduation(student);
+
+ updateStudentInfo();
+}
+
+let DEFAULT_STUDENT_ICON;
+
+function updateStudentInfo() {
+ // Reset the student icon
+ document.getElementById("student-info-icon").src = DEFAULT_STUDENT_ICON;
+
+ // Try to replace the generic icon with the user's icon
+ var img = document.createElement('img');
+ img.onload = function() {
+ document.getElementById("student-info-icon").src = img.src;
+ img = img.onload = null;
+ };
+ student.getIconUrl().then( (url) => img.src = url );
+
+ document.getElementById("student-info-name").innerHTML = `${student.name}`;
+
+ if (student.graduation) {
+ const tag = document.getElementById("student-info-tag");
+ tag.innerHTML = student.graduation.getLevelName();
+ tag.style.display = "block";
+
+ document.getElementById("student-info-points-green").innerHTML = student.graduation.green;
+ document.getElementById("student-info-points-orange").innerHTML = student.graduation.orange;
+ document.getElementById("student-info-points-red").innerHTML = student.graduation.red;
+
+ if (isTeacher) {
+ document.getElementById("student-info-green").addEventListener('click',
+ () => pointClickHandler("green"));
+ document.getElementById("student-info-orange").addEventListener('click',
+ () => pointClickHandler("orange"));
+ document.getElementById("student-info-red").addEventListener('click',
+ () => pointClickHandler("red"));
+ }
+
+ renderGraduationLevel(student.graduation);
+
+ // renderGraduationStickers(student.graduation, document.getElementById("student-info-stickers"));
+ // renderGraduationBonus(student.graduation, document.getElementById("student-info-stickers"));
+
+ renderObservationStatus(student.graduation, document.getElementById("student-observation"));
+ }
+
+ document.getElementById("student-content").style.display = "block";
+}
+
+function renderGraduationLevel(graduation) {
+ const label = document.getElementById("student-info-level");
+ label.innerHTML = graduation.getLevelName();
+ const icon = document.getElementById("student-level-icon");
+ icon.src = graduation.getIcon();
+}
+
+// function renderGraduationStickers(graduation, node) {
+ // node.innerHTML = "";
+// }
+
+// function renderGraduationBonus(graduation, node) {
+ // node.innerHTML = "";
+// }
+
+function renderObservationStatus(graduation, node) {
+ node.innerHTML = "";
+
+ let status;
+ if (isTeacher || graduation.isObserved() || graduation.observationRequested) {
+ status = document.createElement("span");
+ status.classList.add("student-observation-status");
+ if (graduation.isObserved()) {
+ status.innerHTML = '<span style="font-size: larger;">&#128065</span> Ich befinde mich in der Beobachtungsphase zum Levelaufstieg.';
+ } else if (graduation.observationRequested) {
+ status.innerHTML = 'Beobachtungsphase noch nicht begonnen.';
+ } else {
+ status.innerHTML = "Nicht in Beobachtung.";
+ }
+ } else {
+ status = document.createElement("button");
+ status.classList.add("student-request-observation-button");
+ // Level is 1-based
+ status.innerHTML = `Ich will ${LEVEL_NAMES[graduation.level]} werden`;
+ status.addEventListener("click", async function() {
+ await graduation.requestObservation();
+ // Rerender the observation status
+ renderObservationStatus(graduation, node);
+ });
+ }
+ node.appendChild(status);
+}
+
+// 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() {
+ if (!isTeacher) return;
+ console.assert(student.graduation, "Student has no graduation");
+ const graduation = student.graduation;
+
+ const changesTbl = document.getElementById("graduation-changes");
+ changesTbl.innerHTML = "";
+ for (const change of graduation.changes.filter(
+ (ch) => ["Grün", "Rot", "Orange"].includes(ch.change) ))
+ {
+ const changeTr = document.createElement("tr");
+ for (const key in change) {
+ if (key == "json" || key == "id" || key == "name") continue;
+
+ if (key == "change") {
+ switch(change.change) {
+ case "Grün":
+ changeTr.style.background = "green";
+ break;
+ case "Rot":
+ changeTr.style.background = "red";
+ break;
+ case "Orange":
+ changeTr.style.background = "orange";
+ break;
+ default:
+ alert(`unexpected change: ${change.change}`);
+ }
+ continue;
+ }
+
+ const value = change[key];
+ const fmt = key == "date" ? DATETIME_FMT.format(value) : value;
+
+ const changeTd = document.createElement("td");
+ changeTd.innerHTML = fmt;
+ changeTd.dataset.key = key;
+ changeTr.appendChild(changeTd);
+ }
+ changesTbl.appendChild(changeTr);
+ }
+
+ const observtionsTbl = document.getElementById("graduation-current-observation-period");
+ observtionsTbl.innerHTML = "";
+ if (graduation.observation) {
+ const sums = graduation.observation.getObservationsSums();
+ for (let i = 0; i < OBSERVATION_TYPES.length; i++) {
+ const tr = document.createElement("tr");
+ const typeTd = document.createElement("td");
+ typeTd.innerHTML = OBSERVATION_TYPES[i];
+ tr.appendChild(typeTd);
+
+ const yesTd = document.createElement("td");
+ yesTd.innerHTML = sums[i].yes;
+ tr.appendChild(yesTd);
+
+ const noTd = document.createElement("td");
+ noTd.innerHTML = sums[i].no;
+ tr.appendChild(noTd);
+
+ observtionsTbl.appendChild(tr);
+ }
+
+ const endDiv = document.getElementById("graduation-current-observation-period-end");
+ if (!graduation.observation.ready()) {
+ endDiv.style.display = "none";
+ return;
+ }
+
+ endDiv.style.display = "block";
+
+ const reason = document.getElementById("graduation-current-observation-period-end-reason").value;
+ const successBtn = document.getElementById("graduation-current-observation-period-end-success");
+ successBtn.addEventListener("click", () => {
+ submitGraduationChange(graduation.student, GraduationChange.newObservationEnd(reason, true));
+ });
+ const failureBtn = document.getElementById("graduation-current-observation-period-end-failure");
+ failureBtn.addEventListener("click", () => {
+ submitGraduationChange(graduation.student, GraduationChange.newObservationEnd(reason, false));
+ });
+ }
+
+ const pastObservtionsTbl = document.getElementById("graduation-observation-periods");
+ pastObservtionsTbl.innerHTML = "";
+ if (graduation.pastObservations.length > 0) {
+ for (const period of graduation.pastObservations) {
+ const tr = document.createElement("tr");
+ const periodTd = document.createElement("td");
+ const startDate = DATE_FMT.format(period.start);
+ const endDate = DATE_FMT.format(period.end);
+ periodTd.innerHTML = `${startDate} - ${endDate}`;
+ tr.appendChild(periodTd);
+
+ const successTd = document.createElement("td");
+ if (period.success) {
+ successTd.innerHTML = "Graduierung erfolgreich"
+ } else {
+ successTd.innerHTML = "Graduierung nicht erfolgreich"
+ }
+ tr.appendChild(successTd);
+
+ pastObservtionsTbl.appendChild(tr);
+ }
+ }
+}
+
+async function initCockpit() {
+ if (isTeacher) {
+ initStudentSelection();
+ const params = new URLSearchParams(location.search);
+ const userId = params.get("userToLoad");
+ if (userId) {
+ student = await fetchStudent(userId);
+ await loadStudent(student);
+ }
+ } else {
+ // Remove all elements only suitable for teachers
+ for(const elem of document.querySelectorAll("[data-availability=if-lehrer]")) {
+ elem.remove();
+ }
+
+ const student = await Student.fetchById(getUserId());
+ await loadStudent(student);
+ }
+}
+
+window.onload = function() {
+ isTeacher = getIsTeacher();
+ DEFAULT_STUDENT_ICON = document.getElementById("student-info-icon").src;
+ name = getName();
+ initCockpit();
+}
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/config.js
diff --git a/src/database.js b/src/database.js
new file mode 100644
index 0000000..e929896
--- /dev/null
+++ b/src/database.js
@@ -0,0 +1,160 @@
+import { getCourseId } from "./utils-ENV.js";
+
+const DB_TEMPLATE_LIST_HEADER = `
+<table class="table table-striped">
+ <thead>
+ <tr>
+ <th>Json</th>
+ </tr>
+ </thead>
+ <tbody>
+`;
+const DB_TEMPLATE_LIST_ENTRY = `
+<tr class="db-row">
+ <td class="db-cell" data-key="date">##timeadded##</td>
+ <td class="db-cell" data-key="coach">##user##</td>
+ <td class="db-cell" data-key="json">[[Json]]</td>
+</tr>
+`;
+const DB_TEMPLATE_LIST_FOOTER = `
+ </tbody>
+</table>
+`;
+
+let studentDbs = populateStudentDbs();
+
+const ID_QUERY_PARAM_REGEX = /[?&]id=(\d+)/
+
+async function populateStudentDbs() {
+ const courseUrl = `https://lernplattform.bycs.de/course/view.php?id=${getCourseId()}`;
+ const courseRes = await fetch(courseUrl);
+ const courseDoc = new DOMParser().parseFromString(await courseRes.text(), 'text/html');
+
+ let dbs = new Map();
+ for (const link of courseDoc.querySelectorAll("div.activityname a.aalink")) {
+ if (link.textContent.trim() === "Submit Datenbank") {
+ const dbId = link.href.match(ID_QUERY_PARAM_REGEX)[1]
+ dbs.set("submit", new DB(dbId));
+ continue;
+ }
+
+ const match = link.textContent.match(/(\d+)(Shadow|Public|Submit)/);
+ if (!match) continue;
+ const studentId = match[1];
+ const dbType = match[2];
+ const dbId = link.href.match(ID_QUERY_PARAM_REGEX)[1]
+
+ if (!dbs.has(studentId)) {
+ dbs.set(studentId, {});
+ }
+
+ dbs.get(studentId)[dbType.toLowerCase()] = new DB(dbId);
+ }
+
+ return dbs;
+}
+
+async function getSubmitDb() {
+ const dbs = await studentDbs;
+ return dbs.get("submit");
+}
+
+class DB {
+ constructor(id) {
+ this.id = id;
+ }
+
+ async fetchEntries(params) {
+ return loadFromDatabaseActivity(this.id, params)
+ }
+
+ async saveEntry(json) {
+ return addEntryToDatabaseActivity(this.id, json);
+ }
+
+ static async getStudentDbs(student) {
+ const dbs = await studentDbs;
+ return dbs.get(student.id);
+ }
+}
+
+async function loadFromDatabaseActivity(id, params) {
+ let dbUrl = `${window.location.origin}/mod/data/view.php?id=${id}&mode=list&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;
+ let value;
+ // Moodle includes the full date in the title attribute of the included span
+ if (key === "date") {
+ value = field.querySelector("span").title;
+ } else {
+ value = field.innerText.trim();
+ }
+ entry[key] = value;
+ });
+ data.push(entry);
+ });
+ return data;
+}
+
+async function loadFromStudentDatabase(student, type) {
+ const dbs = await DB.getStudentDbs(student);
+ const db = dbs[type];
+ return await db.fetchEntries()
+}
+
+async function addEntryToDatabaseActivity(id, json) {
+ const url = `/mod/data/edit.php?id=${id}`;
+ const editRes = await fetch(url);
+ const editDoc = new DOMParser().parseFromString(await editRes.text(), 'text/html');
+ const form = editDoc.querySelector('div[role="main"] > form');
+
+ const formData = new FormData(form);
+
+ for (const key of formData.keys()) {
+ if (key.startsWith("field_")) {
+ formData.set(key, json);
+ // We found our json key
+ break;
+ }
+ }
+ formData.set("saveandview", "Speichern");
+
+ return await fetch("/mod/data/edit.php", {
+ method: "POST",
+ // Set the FormData instance as the request body
+ body: formData,
+ });
+}
+
+async function addEntryToStudentDatabase(student, type, json) {
+ let db;
+ // global submit database
+ if (type === "submit") {
+ db = await getSubmitDb();
+ // individuell databases
+ } else {
+ const dbs = await DB.getStudentDbs(student);
+ db = dbs[type];
+ }
+ await db.saveEntry(json);
+}
+
+export {
+ DB,
+ getSubmitDb,
+ loadFromDatabaseActivity, loadFromStudentDatabase,
+ addEntryToDatabaseActivity, addEntryToStudentDatabase,
+};
diff --git a/src/graduation-common.js b/src/graduation-common.js
new file mode 100644
index 0000000..0c28465
--- /dev/null
+++ b/src/graduation-common.js
@@ -0,0 +1,396 @@
+import { getSubmitDb, loadFromStudentDatabase, addEntryToStudentDatabase } from "./database.js"
+
+import NeulingPng from "../assets/Neuling.png";
+import EntdeckerPng from "../assets/Entdecker.png";
+import NavigatorPng from "../assets/Navigator.png";
+import PioneerPng from "../assets/Pioneer.png";
+
+const LEVEL_NAMES = ["Neuling", "Entdecker", "Navigator", "Pionieer"];
+
+const LEVEL_IMAGES = [
+ new URL(NeulingPng),
+ new URL(EntdeckerPng),
+ new URL(NavigatorPng),
+ new URL(PioneerPng),
+];
+
+class GraduationChange {
+ constructor(date, change, reason, coach, context) {
+ this.date = date;
+ this.change = change;
+ this.reason = reason;
+ this.coach = coach;
+ this.context = context;
+ }
+
+ isPublic() {
+ return [
+ "Grün", "Orange", "Rot",
+ "ObservationStart", "ObservationEnd",
+ "LevelChange"
+ ].includes(this.change);
+ }
+
+ publicDBFields () {
+ return {
+ "change": this.change,
+ "date": this.date,
+ };
+ }
+
+ dbFields () {
+ return {
+ // We include the date in the json because Moodle reports the timeadded
+ // locale dependent.
+ "date": this.date,
+ "change": this.change,
+ "reason": this.reason,
+ "context": this.context,
+ };
+ }
+
+ static fromDBEntry(e) {
+ const {coach, json} = e;
+ const change = JSON.parse(json);
+ return new GraduationChange(new Date(change.date), change.change, change.reason, coach, change.context);
+ }
+
+ static newObservationRequest() {
+ return new GraduationChange(new Date(), "ObservationRequest", "", "", "");
+ }
+
+ static newObservationStart() {
+ return new GraduationChange(new Date(), "ObservationStart", "", "", "");
+ }
+
+ static newObservationEnd(reason, success) {
+ return new GraduationChange(new Date(), "ObservationEnd", reason, "", JSON.stringify(success));
+ }
+
+ static newObservationChange(observation) {
+ return new GraduationChange(observation.date, "Observation", "", "", JSON.stringify(observation.status));
+ }
+
+ static newGreenPointChange(reason, coach) {
+ return new GraduationChange(new Date(), "Grün", reason, coach, "");
+ }
+
+ static newOrangePointChange(reason, coach) {
+ return new GraduationChange(new Date(), "Orange", reason, coach, "");
+ }
+
+ static newRedPointChange(reason, coach) {
+ return new GraduationChange(new Date(), "Rot", reason, coach, "");
+ }
+}
+
+class Graduation {
+ constructor(student, green, orange, red, level, stickers, observationRequested, observation, pastObservations, changes) {
+ this.student = student;
+ this.green = green;
+ this.orange = orange;
+ this.red = red;
+ this.level = level;
+ this.stickers = stickers;
+ this.observationRequested = observationRequested;
+ this.observation = observation;
+ this.pastObservations = pastObservations;
+ this.changes = changes;
+
+ student.graduation = this;
+ }
+
+ static from(student, changes) {
+ let green = 0;
+ let orange = 0;
+ let red = 0;
+ let level = 1;
+ let openObservationRequest = null;
+ let observationPeriod = null;
+ let pastObservations = [];
+ let stickers = [];
+
+ // The parsing logic assumes that changes are ordered chronologically
+ changes.sort((a, b) => a.date - b.date);
+
+ for (const change of changes) {
+ switch (change.change) {
+ case "Grün":
+ green++;
+ break;
+
+ case "Orange":
+ orange++;
+ break;
+
+ case "Rot":
+ red++;
+ break;
+
+ case "ObservationRequest":
+ openObservationRequest = change.date;
+ break;
+
+ case "ObservationStart":
+ console.assert(openObservationRequest, "No request for observation start");
+ observationPeriod = new ObservationPeriod(change.date);
+ openObservationRequest = null;
+ break;
+
+ case "ObservationEnd":
+ console.assert(observationPeriod, "No active observation period");
+ observationPeriod.finishPeriod(change.context.success);
+ if (change.context.success) {
+ level++;
+ }
+ pastObservations.push(observationPeriod);
+ observationPeriod = null;
+ break;
+
+ case "Observation":
+ console.assert(observationPeriod, "No active observation period");
+ const observation = Observation.fromChange(change);
+ observationPeriod.addObservation(observation);
+ break;
+
+ case "LevelChange":
+ level = change.context.level;
+ break;
+
+ default:
+ alert(`Unexpected change: ${change}`);
+ }
+ }
+ return new Graduation(student, green, orange, red, level,
+ stickers,
+ openObservationRequest, observationPeriod, pastObservations,
+ 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);
+ }
+
+ /**
+ * Request observation
+ */
+ async requestObservation() {
+ const request = GraduationChange.newObservationRequest();
+ this.observationRequested = request.date;
+ await addEntryToStudentDatabase(this.student, "submit", JSON.stringify(request.publicDBFields()));
+ }
+
+ /**
+ * Start an observation
+ */
+ async startObservation() {
+ if (!this.observationRequested) {
+ console.assert(this.observationRequested, "No observation requested");
+ return;
+ }
+ this.observationRequested = null;
+ this.observation = new ObservationPeriod(new Date());
+ return submitGraduationChange(this.student, GraduationChange.newObservationStart());
+ }
+
+ /**
+ * End an observation
+ */
+ async endObservation(reason, success) {
+ if (!this.observation) {
+ console.assert(this.observation, "No active observation");
+ return;
+ }
+ this.observation.end = new Date();
+ this.pastObservations.push(this.observation);
+ this.observation = null;
+ return submitGraduationChange(this.student, GraduationChange.newObservationEnd(reason, success));
+ }
+
+ /**
+ * Return if the student is in observation.
+ */
+ isObserved() {
+ return Boolean(this.observation);
+ }
+
+ /**
+ * Return the URI of the icon representing the graduation level.
+ */
+ getIcon() {
+ return LEVEL_IMAGES[this.level - 1];
+ }
+
+ /**
+ * Return the name of the current graduation level.
+ * @return {String} The name of the current level.
+ */
+ getLevelName() {
+ return LEVEL_NAMES[this.level - 1];
+ }
+}
+
+class ObservationPeriod {
+ constructor(start) {
+ this.start = start;
+ this.observations = [];
+ }
+
+ finishPeriod(success) {
+ this.end = new Date();
+ this.success = success;
+ }
+
+ ended() {
+ return Boolean(this.end);
+ }
+
+ ready() {
+ if (this.observations.length < 10)
+ return false;
+
+ // at least 10 observation on different days
+ let days = new Map();
+ for (const observation of this.observations) {
+ const d = observation.date;
+ days.set(`${d.getFullYear()}${d.getMonth()}${d.getDate()}`, true);
+ }
+ return days.size >= 10;
+ }
+
+ getObservationsSums() {
+ let sums = Array(OBSERVATION_TYPES.length).fill().map(() => ({"yes":0, "no":0}));
+ for (const observation of this.observations) {
+ for (let i = 0; i < sums.length; i++) {
+ if (observation.status[i]) {
+ if (observation.status[i] === "yes")
+ sums[i].yes++;
+ else
+ sums[i].no++;
+ }
+ }
+ }
+ return sums;
+ }
+
+ addObservation(observation) {
+ this.observations.push(observation);
+ }
+}
+
+const OBSERVATION_TYPES = [
+ "Arbeitet konzentriert an Aufgaben",
+ "Arbeitet zunehmend selbstständig",
+ "Führt das Logbuch zuverlässig",
+ "Bringt Material mit",
+ "Hält Regeln ein",
+ "Geht verantwortungsvoll mit Materialien und LernRaum um",
+ "Nimmt Rückmeldungen an und setzt sie um",
+];
+
+class Observation {
+ constructor(date, status) {
+ console.assert(status.length == OBSERVATION_TYPES.length, "invalid observation status");
+ this.date = date;
+ this.status = status;
+ }
+
+ static fromChange(change) {
+ return new Observation(change.date, JSON.parse(change.context))
+ }
+}
+
+function changesFromDbEntries(entries) {
+ let changes = [];
+ for (const e of entries) {
+ const gradChange = GraduationChange.fromDBEntry(e);
+ changes.push(gradChange);
+ }
+ return changes;
+}
+
+async function fetchGraduationChangesFromDb(student, type) {
+ const data = await loadFromStudentDatabase(student, type);
+ return changesFromDbEntries(data);
+}
+
+const submitEntriesPromise = async function() {
+ const db = await getSubmitDb();
+ const entries = await db.fetchEntries();
+ return changesFromDbEntries(entries);
+}();
+
+async function addSubmittedChanges(student, changes) {
+ let submitEntries = await submitEntriesPromise;
+ for (const e of submitEntries.filter( (c) => c.coach === student.name )) {
+ changes.push(e);
+ }
+ return changes;
+}
+
+async function fetchGraduationChanges(student) {
+ let changes = await fetchGraduationChangesFromDb(student, "shadow");
+ return addSubmittedChanges(student, changes);
+}
+
+async function fetchPublicGraduationChanges(student) {
+ let changes = await fetchGraduationChangesFromDb(student, "public");
+ return addSubmittedChanges(student, changes);
+}
+
+async function fetchGraduation(student) {
+ const changes = await fetchGraduationChanges(student);
+ return Graduation.from(student, changes);
+}
+
+async function fetchPublicGraduation(student) {
+ const changes = await fetchPublicGraduationChanges(student);
+ return Graduation.from(student, changes);
+}
+
+async function submitGraduationChange(student, change) {
+ student.graduation.addChange(change);
+
+ // Submit changes concurrently
+ let promises = [];
+
+ if (change.isPublic()) {
+ promises.push(
+ // TODO(muhq): strip non public fields from changes
+ addEntryToStudentDatabase(student, "public", JSON.stringify(change.publicDBFields()))
+ );
+ }
+
+ promises.push(
+ addEntryToStudentDatabase(student, "shadow", JSON.stringify(change.dbFields()))
+ );
+
+ await Promise.all(promises);
+}
+
+async function submitObservation(student, observation) {
+ console.assert(student.graduation.observation, "no active observation");
+ student.graduation.observation.addObservation(observation);
+ await submitGraduationChange(student, GraduationChange.newObservationChange(observation));
+}
+
+export {
+ LEVEL_NAMES,
+ LEVEL_IMAGES,
+ GraduationChange,
+ Graduation,
+ fetchGraduation,
+ fetchPublicGraduation,
+ fetchGraduationChanges,
+ fetchPublicGraduationChanges,
+ submitGraduationChange,
+ submitObservation,
+
+ OBSERVATION_TYPES,
+ Observation,
+ ObservationPeriod,
+};
diff --git a/src/graduation-mock.js b/src/graduation-mock.js
new file mode 100644
index 0000000..1c84433
--- /dev/null
+++ b/src/graduation-mock.js
@@ -0,0 +1,30 @@
+import {
+ LEVEL_NAMES,
+ GraduationChange,
+ Graduation,
+
+ OBSERVATION_TYPES,
+ Observation,
+ ObservationPeriod,
+} from "./graduation-common.js";
+
+import {
+ fetchGraduationMock as fetchGraduation,
+ fetchPublicGraduationMock as fetchPublicGraduation,
+ submitGraduationChangeMock as submitGraduationChange,
+ submitObservationMock as submitObservation
+} from "./mock.js";
+
+export {
+ LEVEL_NAMES,
+ GraduationChange,
+ Graduation,
+ fetchGraduation,
+ fetchPublicGraduation,
+ submitGraduationChange,
+ submitObservation,
+
+ OBSERVATION_TYPES,
+ Observation,
+ ObservationPeriod,
+};
diff --git a/src/graduation-prod.js b/src/graduation-prod.js
new file mode 100644
index 0000000..0e86956
--- /dev/null
+++ b/src/graduation-prod.js
@@ -0,0 +1,15 @@
+export {
+ LEVEL_NAMES,
+ GraduationChange,
+ Graduation,
+ fetchGraduation,
+ fetchPublicGraduation,
+ fetchGraduationChanges,
+ fetchPublicGraduationChanges,
+ submitGraduationChange,
+ submitObservation,
+
+ OBSERVATION_TYPES,
+ Observation,
+ ObservationPeriod,
+} from "./graduation-common.js";
diff --git a/src/group.js b/src/group.js
new file mode 100644
index 0000000..ba5e1a0
--- /dev/null
+++ b/src/group.js
@@ -0,0 +1,57 @@
+import { getCourseParticipantsUrl } from "./utils-ENV.js";
+import { fetchStudents } from "./student-ENV.js";
+
+/**
+ * Class to represent a Group of Students.
+ */
+class Group {
+ constructor(name, students) {
+ this.name = name;
+ if (!students) {
+ this.students = [];
+ }
+ }
+
+ setStudents(students) {
+ this.students = students;
+ }
+
+ addStudent(student) {
+ this.students.push(student);
+ }
+
+ async getStudents() {
+ return this.students;
+ }
+}
+
+let groups = new Map();
+
+function buildGroups(students) {
+ for (const student of students) {
+ let group = groups.get(student.group);
+
+ if (!group) {
+ group = new Group(student.group);
+ groups.set(student.group, group);
+ }
+
+ group.addStudent(student);
+ }
+}
+
+/**
+ * Fetch all Groups and return them as a map.
+ */
+async function fetchGroups() {
+ if (groups.size) {
+ return groups;
+ }
+
+ // Fetch all students and build the groups from them
+ const students = await fetchStudents();
+ buildGroups(students);
+ return groups;
+}
+
+export { Group, buildGroups, fetchGroups };
diff --git a/src/mass-graduation.js b/src/mass-graduation.js
new file mode 100644
index 0000000..3cbf32a
--- /dev/null
+++ b/src/mass-graduation.js
@@ -0,0 +1,357 @@
+import {
+ OBSERVATION_TYPES,
+ GraduationChange,
+ submitGraduationChange,
+ Observation,
+ submitObservation
+} from "./graduation-ENV.js";
+
+import { fetchGroups } from "./group.js";
+import { initSelection } from "./selection.js";
+import { getName, DATETIME_FMT, datetimeStringNow } from "./utils-ENV.js";
+
+let group;
+let name;
+
+function toggle(btn, others) {
+ if (btn.innerHTML === "X") {
+ btn.innerHTML = "o";
+ } else {
+ btn.innerHTML = "X";
+ if (others) {
+ for (const other of others)
+ other.innerHTML = "o";
+ }
+ }
+
+ document.getElementById("submit-mass-graduation").style.display = "inline";
+ document.getElementById("submit-mass-graduation-state").innerHTML = "";
+}
+
+function observationClickHandler(li) {
+ let observationDiv = li.observationDiv;
+ // Build the observation container
+ if(!observationDiv) {
+ li.style.background = "lightgray";
+
+ observationDiv = document.createElement("div");
+ // Remember the observation div for this student
+ li.observationDiv = observationDiv;
+ observationDiv.id = `${li.student.name}-observation-container`;
+ observationDiv.classList.add("group-member-observation-container");
+ li.appendChild(observationDiv);
+
+ const obsTbl = document.createElement("table");
+ obsTbl.classList.add("group-member-observation-table");
+ observationDiv.appendChild(obsTbl);
+
+ const obsTh = document.createElement("thead");
+ obsTh.innerHTML = `
+<tr>
+ <th></th>
+ <th class="group-member-observation-table-yes">Ja</th>
+ <th class="group-member-observation-table-no">Nein</th>
+ <th class="group-member-observation-table-maybe">Kann ich nicht beurteilen</th>
+</tr>`;
+
+ obsTbl.appendChild(obsTh);
+
+ const obsTbdy = document.createElement("tbody");
+ obsTbl.appendChild(obsTbdy);
+
+ for (const obsType of OBSERVATION_TYPES) {
+ const obsTr = document.createElement("tr");
+
+ const obsDesc = document.createElement("td");
+ obsDesc.innerHTML = obsType;
+ obsDesc.classList.add("group-member-observation-table-description");
+ obsTr.appendChild(obsDesc);
+
+ const obsYes = document.createElement("td");
+ obsYes.innerHTML = "o";
+ obsYes.dataset.yes = "true";
+ obsYes.classList.add("group-member-observation-table-yes");
+ obsTr.appendChild(obsYes);
+
+ const obsNo = document.createElement("td");
+ obsNo.innerHTML = "o";
+ obsNo.dataset.no = "true";
+ obsNo.classList.add("group-member-observation-table-no");
+ obsTr.appendChild(obsNo);
+
+ const obsMaybe = document.createElement("td");
+ obsMaybe.innerHTML = "X";
+ obsMaybe.dataset.maybe = "true";
+ obsMaybe.classList.add("group-member-observation-table-maybe");
+ obsTr.appendChild(obsMaybe);
+
+ obsYes.addEventListener("click", () => {
+ toggle(obsYes, [obsNo, obsMaybe]);
+ });
+ obsNo.addEventListener("click", () => {
+ toggle(obsNo, [obsYes, obsMaybe]);
+ });
+ obsMaybe.addEventListener("click", () => {
+ toggle(obsMaybe, [obsYes, obsNo]);
+ });
+
+ obsTbdy.appendChild(obsTr);
+ }
+ // Toggle the observation container visibility
+ } else {
+ if (observationDiv.style.display === "none") {
+ observationDiv.style.display = "block";
+ li.style.background = "lightgray";
+
+ } else {
+ observationDiv.style.display = "none";
+ li.style.background = "unset";
+ }
+ }
+}
+
+async function loadGroup(selected) {
+ group = selected;
+ const students = await group.getStudents();
+ let graduationPromises = [];
+ document.getElementById("group-content").style.display = "block";
+
+ const ul = document.getElementById("group-members");
+ // Clear group list
+ ul.innerHTML = "";
+
+ for (const student of students) {
+ const graduationPromise = student.getGraduation();
+
+ const li = document.createElement("li");
+ li.classList.add("group-member")
+ // Remember the student of the entry
+ li.student = student;
+
+ const i = document.createElement("img");
+ i.classList.add("group-member-icon")
+ graduationPromise.then((graduation) => {
+ i.src = graduation.getIcon();
+ });
+ li.appendChild(i);
+
+ const n = document.createElement("span");
+ n.classList.add("group-member-name")
+ n.innerHTML = `${student.name}`;
+ li.appendChild(n);
+
+ const ob = document.createElement("button");
+ ob.classList.add("group-member-observation-icon")
+
+ graduationPromise.then((graduation) => {
+ if (!graduation.isObserved()) {
+ return;
+ }
+ ob.innerHTML = "&#128065"
+ ob.addEventListener("click", () => observationClickHandler(li) );
+ });
+ graduationPromises.push(graduationPromise);
+
+ li.appendChild(ob);
+
+ for (const col of ["green", "orange", "red"]) {
+ const b = document.createElement("button");
+ b.classList.add(`group-member-${col}`)
+ b.innerHTML = "o";
+ b.addEventListener("click", () => toggle(b) );
+ li.appendChild(b);
+ }
+
+ const textarea = document.createElement("textarea");
+ textarea.classList.add("group-member-point-reason");
+ li.appendChild(textarea);
+
+ const p = document.createElement("button");
+ p.classList.add("group-member-print")
+ p.innerHTML = "🖶";
+ p.addEventListener("click", () => printNote(li) );
+ li.appendChild(p);
+
+ ul.appendChild(li);
+ }
+
+ const graduations = await Promise.all(graduationPromises);
+ let observations = graduations.filter(
+ (g) => g.observationRequested || (g.observation && !g.observation.end)
+ );
+
+ const observationReuqestsContainer = document.getElementById("observation-requests-container");
+
+ if (observations.length == 0) {
+ observationReuqestsContainer.style.display = "none";
+ return;
+ } else {
+ observationReuqestsContainer.style.display = "unset";
+ }
+
+ const observationReuqestsNode = document.getElementById("observation-requests");
+ observationReuqestsNode.innerHTML = "";
+ observations.sort((g1, g2) => {
+ let g1D, g2D;
+ g1D = g1.observationRequested ? g1.observationRequested : g1.observation.start;
+ g2D = g2.observationRequested ? g2.observationRequested : g2.observation.start;
+ return g1D - g2D;
+ });
+
+ for (const graduation of observations) {
+ const row = document.createElement("tr");
+ const iconCell = document.createElement("td");
+ const icon = document.createElement("img");
+ icon.src = graduation.getIcon();
+ row.appendChild(iconCell);
+
+ const nameCell = document.createElement("td");
+ nameCell.innerHTML = `${graduation.student.name}`;
+ row.appendChild(nameCell);
+
+ const dateCell = document.createElement("td");
+ let date;
+ if (graduation.observationRequested) {
+ date = graduation.observationRequested;
+ } else {
+ date = graduation.observation.start;
+ }
+ dateCell.innerHTML = DATETIME_FMT.format(date);
+ row.appendChild(dateCell);
+
+ const btnCell = document.createElement("td");
+ if (graduation.observation && !graduation.observation.ready()) {
+ btnCell.innerHTML = "In Beobachtungsphase";
+ } else {
+ const startBtn = document.createElement("button");
+ if (graduation.observationRequested) {
+ startBtn.addEventListener("click", () => {
+ graduation.startObservation();
+ //TODO(muhq): Add observation icon-button.
+ startBtn.remove();
+ btnCell.innerHTML = "In Beobachtungsphase";
+ });
+ startBtn.innerHTML = `Beobachtungsphase beginnen`;
+ startBtn.classList.add("start-observation");
+ } else {
+ startBtn.addEventListener("click", () => {
+ window.open(`/mod/page/view.php?id=93017047&userToLoad=${graduation.student.id}`, '_blank');
+ });
+ startBtn.innerHTML = `Graduierung abschließen`;
+ startBtn.classList.add("end-observation");
+ }
+ btnCell.appendChild(startBtn);
+ }
+ row.appendChild(btnCell);
+
+ observationReuqestsNode.appendChild(row);
+ }
+}
+
+function submitMassGraduation() {
+ const state = document.getElementById("submit-mass-graduation-state");
+ state.innerHTML = "";
+ state.classList.add("submit-mass-graduation-inprogress");
+ state.style.display = "unset";
+
+ // TODO(muhq): await graduation promises
+
+ let promises = [];
+ for(const li of document.querySelectorAll("li.group-member")) {
+ const reason = li.querySelector(".group-member-point-reason").value;
+ const student = li.student;
+ if (li.querySelector(".group-member-green").innerHTML === "X") {
+ promises.push(submitGraduationChange(student, GraduationChange.newGreenPointChange(reason, name)));
+ }
+ if (li.querySelector(".group-member-orange").innerHTML === "X") {
+ promises.push(submitGraduationChange(student, GraduationChange.newOrangePointChange(reason, name)));
+ }
+ if (li.querySelector(".group-member-red").innerHTML === "X") {
+ promises.push(submitGraduationChange(student, GraduationChange.newRedPointChange(reason, name)));
+ }
+ if (li.observationDiv) {
+ let status = [];
+ for (const row of li.observationDiv.querySelectorAll("tbody > tr")) {
+ if (row.querySelector('td[data-yes="true"]').innerHTML === "X")
+ status.push("yes");
+ else if (row.querySelector('td[data-no="true"]').innerHTML === "X")
+ status.push("no");
+ else
+ status.push("");
+ }
+ promises.push(submitObservation(student, new Observation(new Date(), status)));
+ }
+ }
+
+ Promise.all(promises).then(() => {
+ state.style.display = "inline";
+ state.innerHTML = "✓";
+ state.style.color = "green";
+ for (const li of document.querySelectorAll("li.group-member")) {
+ if (!li.observationDiv)
+ continue;
+ li.observationDiv.remove();
+ li.observationDiv = null;
+ }
+ }).catch((error) => {
+ state.style.display = "inline";
+ state.innerHTML = "X";
+ state.style.color = "red";
+ console.log(error);
+ }).finally(() => {
+ state.classList.remove("submit-mass-graduation-inprogress");
+ });
+}
+
+let prePrintBody = null;
+function printNote(li) {
+ // save the current DOM
+ prePrintBody = document.body;
+
+ // prepare the printable red point notice
+ const printBdy = document.createElement("body");
+ const reason = li.querySelector(".group-member-point-reason").value;
+ const dateStr = datetimeStringNow();
+ let pointType = "";
+ if (li.querySelector(".group-member-orange").innerHTML === "X") {
+ pointType = "Oranger Punkt";
+ }
+ if (li.querySelector(".group-member-red").innerHTML === "X") {
+ pointType = "Roter Punkt";
+ }
+
+ printBdy.innerHTML = `
+<h2>Dokuzettel</h2>
+<h3>${pointType}</h3>
+<p>Schüler*in: ${li.student.name}</p>
+<p>Datum: ${dateStr}</p>
+<p>Lehrkraft: ${name}</p>
+<p>Klassenleitung: TODO</p>
+<h3>Begründung</h3>
+<p>${reason}</p>
+`;
+
+ document.body = printBdy;
+ print();
+ // Restore the DOM after printing the red point note
+ document.body = prePrintBody;
+}
+
+async function init() {
+ document.getElementById("submit-mass-graduation").addEventListener("click", submitMassGraduation);
+ name = getName();
+
+ initSelection(
+ "group-selection",
+ async function() { return (await fetchGroups()).values(); },
+ (group) => `${group.name}`,
+ loadGroup,
+ );
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init);
+} else {
+ init();
+}
+
diff --git a/src/mock.js b/src/mock.js
new file mode 100644
index 0000000..91b1525
--- /dev/null
+++ b/src/mock.js
@@ -0,0 +1,104 @@
+import { Graduation, GraduationChange } from "./graduation-common.js";
+import { Student } from "./student-common.js";
+import { buildGroups } from "./group.js";
+
+const students = [
+ new Student(1, 'foo bar', 'test'),
+ new Student(2, 'maxi mustermensch', 'test'),
+ new Student(3, 'b b b', '5a')
+];
+
+// Build the groups from our mock students
+buildGroups(students);
+
+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("Gute Arbeit", "Florian Fischer"),
+ GraduationChange.newOrangePointChange("", "Florian Fischer"),
+ ];
+ break;
+ case "maxi mustermensch":
+ graduationChanges[s.name] = [
+ GraduationChange.newGreenPointChange("", "Florian Fischer"),
+ GraduationChange.newGreenPointChange("", "Florian Fischer"),
+ new GraduationChange(new Date(), "ObservationStart", "", "Florian Fischer", ""),
+ ];
+ break;
+ case "b b b":
+ graduationChanges[s.name] = [
+ GraduationChange.newRedPointChange("", "Florian Fischer"),
+ ];
+ }
+ // Build the student's graduation
+ Graduation.from(s, graduationChanges[s.name]);
+}
+
+function fetchGraduationMock(s) {
+ return new Promise((resolve) => {
+ const changes = graduationChanges[s.name];
+ if (!changes) {
+ resolve(null);
+ } else {
+ resolve(Graduation.from(s, changes));
+ }
+ });
+}
+
+function fetchPublicGraduationMock(s) {
+ return new Promise((resolve) => {
+ const changes = graduationChanges[s.name];
+ if (!changes) {
+ resolve(null);
+ } else {
+ resolve(Graduation.from(s, changes.filter((change) => change.isPublic())));
+ }
+ });
+}
+
+function submitGraduationChangeMock(s, c) {
+ console.assert(s.graduation, "no graduation")
+ s.graduation.addChange(c);
+}
+
+function submitObservationMock(s, o) {
+ console.assert(s.graduation, "no graduation")
+ console.assert(s.graduation.observation, "no observation period")
+ s.graduation.observation.addObservation(o);
+}
+
+export {
+ fetchStudentsMock,
+ fetchSubjectsMock,
+ fetchProgressMock,
+ fetchGraduationMock,
+ fetchPublicGraduationMock,
+ submitGraduationChangeMock,
+ submitObservationMock,
+};
diff --git a/src/selection.js b/src/selection.js
new file mode 100644
index 0000000..eda20ae
--- /dev/null
+++ b/src/selection.js
@@ -0,0 +1,170 @@
+/**
+ * Initialises a selection based on existing HTML elements.
+ * For HTML elements are required:
+ * - A text input with id $idPrefix
+ * - A list with id ${idPrefix}-list
+ * - An element shown if no results are available with id ${idPrefix}-noresults
+ * - A button to clear the input with id ${idPrefix}-clear
+ *
+ * @param {string} idPrefix - The id prefix of the four HTML elements.
+ * @param {async function} fetchCandidates - The async function to retrieve the selectable candidates.
+ * @param {function} fmtCandidate - The function to format the displayed HTML for each candidate.
+ * @param {function} onSelection - The function to call when a candidate is selected.
+ */
+async function initSelection(idPrefix, fetchCandidates, fmtCandidate, onSelection) {
+ const sel = document.getElementById(idPrefix);
+ const list = document.getElementById(`${idPrefix}-list`);
+ const noResults = document.getElementById(`${idPrefix}-noresults`);
+ const clearBtn = document.getElementById(`${idPrefix}-clear`);
+ const loader = document.getElementById(`${idPrefix}-loader`);
+
+ loader.style.display = "unset";
+ const candidates = await fetchCandidates();
+ loader.style.display = "none";
+
+ 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 candidate of candidates) {
+ let item = document.createElement("li");
+ item.candidate = candidate;
+ item.innerHTML = fmtCandidate(candidate);
+ item.style.display = 'none';
+
+ item.addEventListener('click', function() {
+ sel.value = "";
+ sel.dispatchEvent(new Event('keyup'));
+ onSelection(candidate);
+ });
+
+ 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,
+ "<u class='student-selection-list-highlight'>$1</u>"
+ );
+ } else {
+ item.style.display = "none";
+ }
+ }
+
+ // Show the clear button if there is an input
+ clearBtn.style.display = sel.value ? "block" : "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";
+ });
+
+ clearBtn.addEventListener('click', function() {
+ sel.value = "";
+ 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'));
+ onSelection(visibleItem.candidate);
+ }
+ 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();
+
+ // Can not use string interpolation because of Moodle's firstname filter.
+ const coach = firstname + " " + lastname;
+
+ let submit = function() {
+ let change;
+ switch (color) {
+ case "green":
+ change = GraduationChange.newGreenPointChange(student, textarea.value, coach);
+ break;
+ case "orange":
+ change = GraduationChange.newOrangePointChange(student, textarea.value, coach);
+ break;
+ case "red":
+ change = GraduationChange.newRedPointChange(student, textarea.value, coach);
+ 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;
+}
+
+export { initSelection };
diff --git a/src/student-common.js b/src/student-common.js
new file mode 100644
index 0000000..2dfa35d
--- /dev/null
+++ b/src/student-common.js
@@ -0,0 +1,115 @@
+import { getCourseId, getUserIconUrl, getCourseParticipantsUrl } from "./utils-ENV.js";
+import { fetchGraduation } from "./graduation-common.js";
+/**
+ * Class to represent Students.
+ */
+class Student {
+ graduation = null;
+
+ constructor(id, name, group) {
+ this.id = id;
+ this.name = name;
+ this.group = group;
+ }
+
+ async getIconUrl() {
+ return await getUserIconUrl(this.id);
+ }
+
+ getGroup() {
+ return this.group;
+ }
+
+ async getGraduation() {
+ if(!this.graduation) {
+ // fetchGraduation(this) sets this.graduation
+ await fetchGraduation(this);
+ }
+ return this.graduation;
+ }
+
+ static async fetchById(id) {
+ let name = "";
+ let group = "";
+
+ const res = await fetch(`/user/view.php?id=${id}&course=${getCourseId()}`);
+ const doc = new DOMParser().parseFromString(await res.text(), "text/html");
+
+ name = doc.querySelector("div.page-header-headings > h2").textContent;
+
+ const cards = doc.querySelectorAll("div.card-body dl");
+ for (const card of cards) {
+ if (card.querySelector("dt").textContent !== "Gruppe")
+ continue;
+ group = card.querySelector("dd > a").textContent;
+ break;
+ }
+
+ return new Student(id, name, group);
+ }
+}
+
+// Cache the fetched students.
+let students = [];
+
+const idRegex = /id=(\d+)/;
+async function _fetchStudents() {
+ const res = await fetch(getCourseParticipantsUrl());
+ const doc = new DOMParser().parseFromString(await res.text(), "text/html");
+ const table = doc.querySelector("tbody");
+ let fetchedStudents = [];
+ 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");
+ let name;
+ if (nameCell.firstChild.firstChild.tagName == "SPAN")
+ name = nameCell.firstChild.firstChild.title;
+ else
+ name = nameCell.innerText;
+
+ const profileUrl = nameCell.querySelector("a.aabtn").href;
+ const match = idRegex.exec(profileUrl);
+ const id = match[1];
+
+ const groupCell = row.querySelector(".c5");
+ const group = groupCell.querySelector("a.quickeditlink").textContent;
+
+ fetchedStudents.push(new Student(id, name, group));
+ });
+ return fetchedStudents;
+}
+
+async function fetchStudents(idFilter, nameFilter, groupFilter) {
+ if (!students.size) {
+ const studentsPromise = _fetchStudents()
+ const storedStudents = localStorage.getItem("students");
+ if (storedStudents) {
+ studentsPromise.then((fetched) => localStorage.setItem("students", JSON.stringify(fetched)));
+ for (const o of JSON.parse(storedStudents)) {
+ students.push(new Student(o.id, o.name, o.group));
+ }
+ } else {
+ students = await studentsPromise;
+ localStorage.setItem("students", JSON.stringify(students));
+ }
+ }
+
+ return students.filter((s) => (!idFilter || s.id === idFilter) &&
+ (!nameFilter || s.name.includes(nameFilter)) &&
+ (!groupFilter || s.group.includes(groupFilter)));
+}
+
+async function fetchStudent(idFilter, nameFilter, groupFilter) {
+ const students = await fetchStudents(idFilter, nameFilter, groupFilter);
+ return students[0];
+}
+
+export { Student, fetchStudents, fetchStudent };
diff --git a/src/student-mock.js b/src/student-mock.js
new file mode 100644
index 0000000..7c79e12
--- /dev/null
+++ b/src/student-mock.js
@@ -0,0 +1,9 @@
+import { Student } from "./student-common.js";
+import { fetchStudentsMock as fetchStudents } from "./mock.js";
+
+async function fetchStudent(idFilter, nameFilter, groupFilter) {
+ const students = await fetchStudents(idFilter, nameFilter, groupFilter);
+ return students[0];
+}
+
+export { Student, fetchStudents, fetchStudent };
diff --git a/src/student-prod.js b/src/student-prod.js
new file mode 100644
index 0000000..9c8d7cb
--- /dev/null
+++ b/src/student-prod.js
@@ -0,0 +1,2 @@
+import { Student, fetchStudent, fetchStudents } from "./student-common.js";
+export { Student, fetchStudents, fetchStudent };
diff --git a/src/utils-common.ts b/src/utils-common.ts
new file mode 100644
index 0000000..89a8ddd
--- /dev/null
+++ b/src/utils-common.ts
@@ -0,0 +1,25 @@
+"use strict";
+
+export const DATE_FMT = new Intl.DateTimeFormat("de-DE", { month: "numeric", day: "numeric", year: "numeric"});
+
+export const DATETIME_FMT = new Intl.DateTimeFormat("de-DE", { month: "numeric", day: "numeric", year: "numeric", hour: "numeric", minute: "numeric" });
+
+export function dateStringNow() {
+ return DATE_FMT.format(new Date());
+}
+
+export function datetimeStringNow() {
+ return DATETIME_FMT.format(new Date());
+}
+
+// TODO(muhq): Cache the retrieved URL
+export async function getUserIconUrl(userId: number) {
+ const userRes = await fetch(`https://lernplattform.bycs.de/user/profile.php?id=${userId}`)
+ const userDoc = new DOMParser().parseFromString(await userRes.text(), 'text/html')
+ const userpicture = userDoc.querySelector(".userpicture");
+ if (userpicture === null) {
+ return "";
+ }
+
+ return (userpicture as HTMLImageElement).src;
+}
diff --git a/src/utils-mock.js b/src/utils-mock.js
new file mode 100644
index 0000000..e64b59d
--- /dev/null
+++ b/src/utils-mock.js
@@ -0,0 +1,41 @@
+import { fetchStudent } from "./student-ENV.js";
+export { DATE_FMT, DATETIME_FMT, dateStringNow, datetimeStringNow } from "../dist/src/utils-common.js";
+
+export function isTeacher() {
+ return !(new URLSearchParams(document.location.search)).has("student");
+}
+
+export function getFirstAndLastname() {
+ let firstname = "";
+ let lastname = "";
+
+ const params = new URLSearchParams(document.location.search);
+ if (params.has("firstname"))
+ firstname = params.get("firstname")
+ if (params.has("lastname"))
+ lastname = params.get("lastname")
+
+ return [firstname, lastname];
+}
+
+export function getName() {
+ let name = "";
+
+ const params = new URLSearchParams(document.location.search);
+ if (params.has("firstname"))
+ name = params.get("firstname")
+ if (params.has("lastname"))
+ name += params.get("lastname")
+
+ return name;
+}
+
+export function getUserId() {
+ const name = getName();
+
+ return fetchStudent(null, name).id;
+}
+
+export function getCourseId() {
+ return "1337";
+}
diff --git a/src/utils-prod.js b/src/utils-prod.js
new file mode 100644
index 0000000..efcd8e3
--- /dev/null
+++ b/src/utils-prod.js
@@ -0,0 +1,36 @@
+export { DATE_FMT, DATETIME_FMT, dateStringNow, datetimeStringNow, getUserIconUrl } from "./utils-common.ts";
+
+export function isTeacher() {
+ return Boolean(document.querySelector('.editmode-switch-form'))
+ || document.body.classList.contains('editing');
+}
+
+export function getFirstAndLastname() {
+ let firstname = "";
+ let lastname = "";
+
+ const autoNameDiv = document.getElementById("auto-name");
+ firstname = autoNameDiv.children[0].innerHTML;
+ lastname = autoNameDiv.children[1].innerHTML;
+ autoNameDiv.remove();
+
+ return [firstname, lastname];
+}
+
+export function getName() {
+ return document.querySelector("bycs-topbar").shadowRoot.querySelector("div.user-name").textContent;
+}
+
+export function getUserId() {
+ // M is exported by Moodle
+ return String(M.cfg.userId);
+}
+
+export function getCourseId() {
+ // M is exported by Moodle
+ return String(M.cfg.courseId);
+}
+
+export function getCourseParticipantsUrl() {
+ return `https://lernplattform.bycs.de/user/index.php?id=${getCourseId()}&perpage=5000`;
+}