From c80f4ee2b8d830e499bf1b1853c76a53bd07b19b Mon Sep 17 00:00:00 2001 From: imperosol Date: Sat, 3 Oct 2026 23:21:32 +0200 Subject: [PATCH] split timetable parsing and display --- ...{generator-index.ts => timetable-index.ts} | 243 +++++++----------- timetable/static/timetable/css/generator.scss | 2 +- timetable/templates/timetable/generator.jinja | 41 +-- timetable/templates/timetable/macros.jinja | 46 ++++ 4 files changed, 151 insertions(+), 181 deletions(-) rename timetable/static/bundled/timetable/{generator-index.ts => timetable-index.ts} (74%) create mode 100644 timetable/templates/timetable/macros.jinja diff --git a/timetable/static/bundled/timetable/generator-index.ts b/timetable/static/bundled/timetable/timetable-index.ts similarity index 74% rename from timetable/static/bundled/timetable/generator-index.ts rename to timetable/static/bundled/timetable/timetable-index.ts index 3ead147a..f8e0287f 100644 --- a/timetable/static/bundled/timetable/generator-index.ts +++ b/timetable/static/bundled/timetable/timetable-index.ts @@ -78,6 +78,9 @@ function parseSlots(s: string): TimetableSlot[] { } document.addEventListener("alpine:init", () => { + /** + * Alpine data that can parse a timetable, save it and export it to png. + */ Alpine.data("timetableGenerator", () => ({ content: DEFAULT_TIMETABLE, error: null as { incorrectRow?: string } | null, @@ -108,151 +111,6 @@ document.addEventListener("alpine:init", () => { downloadLink.remove(); }, - async saveSith() { - this.saving = true; - const res = await timetableSaveTimetable({ - body: { - // biome-ignore lint/style/useNamingConvention: api is snake case - is_viewable: this.edtPublic, - slots: this.courses.map((c) => ({ - // biome-ignore lint/style/useNamingConvention: api is snake case - start_at: c.startSlot, - // biome-ignore lint/style/useNamingConvention: api is snake case - end_at: c.endSlot, - weekday: (WEEKDAYS.indexOf(c.weekday) + 1) as ApiWeekDay, - // biome-ignore lint/style/useNamingConvention: api is snake case - week_group: c.weekGroup, - // biome-ignore lint/style/useNamingConvention: api is snake case - course_type: c.courseType, - room: c.room, - ue: c.ueCode, - })), - }, - }); - if (res.response?.ok) { - this.timetableSavedMessage.display( - gettext("This timetable has been saved in your profile"), - { success: true }, - ); - } else { - this.timetableSavedMessage.display( - interpolate(gettext("Error %d: timetable save failed"), res.response?.status), - { success: false }, - ); - } - this.saving = false; - }, - })); - - /** - * Alpine data dedicated to timetable display only. - * It can interact with the `timetableGenerator` data with x-model and x-modelable - * (for example of this, see timetable/generator.jinja) - */ - Alpine.data("timetableDisplay", (slots: TimetableSlot[]) => ({ - displayedWeekdays: [] as WeekDay[], - courses: [] as TimetableSlot[], - startSlot: 0, - endSlot: 0, - table: { height: 0, width: 0 }, - edtPublic: false, - saving: false, - timetableSavedMessage: new AlertMessage(), - - colors: {} as Record, - colorPalette: [ - "#27ae60", - "#2980b9", - "#c0392b", - "#7f8c8d", - "#f1c40f", - "#1abc9c", - "#95a5a6", - "#26C6DA", - "#c2185b", - "#e64a19", - "#1b5e20", - ], - - generate() { - try { - this.courses = parseSlots(this.content); - this.error = null; - } catch (err) { - type ParseRowError = { cause: { row: string } }; - this.error = { incorrectRow: (err as ParseRowError)?.cause?.row }; - return; - } - - // color each UE - let colorIndex = 0; - for (const slot of this.courses) { - if (!this.colors[slot.ueCode]) { - this.colors[slot.ueCode] = - this.colorPalette[colorIndex % this.colorPalette.length]; - colorIndex++; - } - } - - this.displayedWeekdays = WEEKDAYS.filter((day) => - this.courses.some((slot: TimetableSlot) => slot.weekday === day), - ); - this.startSlot = this.courses.reduce( - (acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot), - 25 * 4, - ); - this.endSlot = this.courses.reduce( - (acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot), - 1, - ); - this.table.height = SLOT_HEIGHT * (this.endSlot - this.startSlot); - this.table.width = SLOT_WIDTH * this.displayedWeekdays.length; - }, - - getStyle(slot: TimetableSlot) { - const hasWeekGroup = !!slot.weekGroup; - const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH; - const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0; - return { - height: `${(slot.endSlot - slot.startSlot) * SLOT_HEIGHT}px`, - width: `${width}px`, - top: `${(slot.startSlot - this.startSlot) * SLOT_HEIGHT}px`, - left: `${this.displayedWeekdays.indexOf(slot.weekday) * SLOT_WIDTH + leftOffset}px`, - backgroundColor: this.colors[slot.ueCode], - }; - }, - - getHours(): [string, object][] { - let hour: number = Number.parseInt( - this.courses - .map((c: TimetableSlot) => c.startHour) - .reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00") - .split(":")[0], - 10, - ); - const res: [string, object][] = []; - for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) { - res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]); - hour += 1; - } - return res; - }, - - getWidth() { - return this.displayedWeekdays.length * SLOT_WIDTH + 20; - }, - - async savePng() { - const elem = document.getElementById("timetable"); - const img = (await html2canvas(elem as HTMLElement)).toDataURL(); - const downloadLink = document.createElement("a"); - downloadLink.href = img; - downloadLink.download = "edt.png"; - document.body.appendChild(downloadLink); - downloadLink.click(); - downloadLink.remove(); - }, - async saveSith() { this.saving = true; const res = await timetableSaveTimetable({ @@ -284,4 +142,99 @@ document.addEventListener("alpine:init", () => { this.saving = false; }, })); + + /** + * Alpine data dedicated to timetable display only. + * It can interact with the `timetableGenerator` data with x-model and x-modelable + * (for example of this, see timetable/generator.jinja) + */ + Alpine.data("timetableDisplay", (slots: TimetableSlot[]) => ({ + displayedWeekdays: [] as WeekDay[], + startSlot: 0, + endSlot: 0, + table: { height: 0, width: 0 }, + slots: slots, + + colors: {} as Record, + colorPalette: [ + "#27ae60", + "#2980b9", + "#c0392b", + "#7f8c8d", + "#f1c40f", + "#1abc9c", + "#95a5a6", + "#26C6DA", + "#c2185b", + "#e64a19", + "#1b5e20", + ], + + init() { + this.$watch("slots", () => this.display()); + }, + + display() { + // color each UE + let colorIndex = 0; + for (const slot of this.slots) { + if (!this.colors[slot.ueCode]) { + this.colors[slot.ueCode] = + this.colorPalette[colorIndex % this.colorPalette.length]; + colorIndex++; + } + } + + this.displayedWeekdays = WEEKDAYS.filter((day) => + this.slots.some((slot: TimetableSlot) => slot.weekday === day), + ); + this.startSlot = this.slots.reduce( + (acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot), + 25 * 4, + ); + this.endSlot = this.slots.reduce( + (acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot), + 1, + ); + this.table.height = SLOT_HEIGHT * (this.endSlot - this.startSlot); + this.table.width = SLOT_WIDTH * this.displayedWeekdays.length; + }, + + getStyle(slot: TimetableSlot) { + const hasWeekGroup = !!slot.weekGroup; + const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH; + const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0; + return { + height: `${(slot.endSlot - slot.startSlot) * SLOT_HEIGHT}px`, + width: `${width}px`, + top: `${(slot.startSlot - this.startSlot) * SLOT_HEIGHT}px`, + left: `${this.displayedWeekdays.indexOf(slot.weekday) * SLOT_WIDTH + leftOffset}px`, + backgroundColor: this.colors[slot.ueCode], + }; + }, + + /** + * Return the hours that should be displayed on this timetable, + * with their `top` placing + */ + getHours(): [string, { top: `${number}px` }][] { + let hour: number = Number.parseInt( + this.slots + .map((c: TimetableSlot) => c.startHour) + .reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00") + .split(":")[0], + 10, + ); + const res: [string, { top: `${number}px` }][] = []; + for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) { + res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]); + hour += 1; + } + return res; + }, + + getWidth() { + return this.displayedWeekdays.length * SLOT_WIDTH + 20; + }, + })); }); diff --git a/timetable/static/timetable/css/generator.scss b/timetable/static/timetable/css/generator.scss index 758892de..095ed30e 100644 --- a/timetable/static/timetable/css/generator.scss +++ b/timetable/static/timetable/css/generator.scss @@ -1,6 +1,6 @@ @import "core/static/core/colors"; -#timetable { +.timetable { --hour-side-width: 60px; display: block; diff --git a/timetable/templates/timetable/generator.jinja b/timetable/templates/timetable/generator.jinja index 0769181f..3606a8fa 100644 --- a/timetable/templates/timetable/generator.jinja +++ b/timetable/templates/timetable/generator.jinja @@ -1,11 +1,13 @@ -{% extends 'core/base.jinja' %} +{% extends "core/base.jinja" %} + +{% from "timetable/macros.jinja" import display_timetable %} {%- block additional_css -%} {%- endblock -%} {%- block additional_js -%} - + {%- endblock -%} {% block title %} @@ -60,39 +62,8 @@ -
-
- -
-
-
- -
-
- -
-
-
-