split timetable parsing and display

This commit is contained in:
imperosol committed 2026-10-09 15:31:44 +02:00
1 parent a4964836e4
commit c80f4ee2b8
4 files changed
+151 -181

No files matched your search

@@ -78,6 +78,9 @@ function parseSlots(s: string): TimetableSlot[] {
} }
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
/**
* Alpine data that can parse a timetable, save it and export it to png.
*/
Alpine.data("timetableGenerator", () => ({ Alpine.data("timetableGenerator", () => ({
content: DEFAULT_TIMETABLE, content: DEFAULT_TIMETABLE,
error: null as { incorrectRow?: string } | null, error: null as { incorrectRow?: string } | null,
@@ -108,151 +111,6 @@ document.addEventListener("alpine:init", () => {
downloadLink.remove(); 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<string, string>,
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() { async saveSith() {
this.saving = true; this.saving = true;
const res = await timetableSaveTimetable({ const res = await timetableSaveTimetable({
@@ -284,4 +142,99 @@ document.addEventListener("alpine:init", () => {
this.saving = 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[],
startSlot: 0,
endSlot: 0,
table: { height: 0, width: 0 },
slots: slots,
colors: {} as Record<string, string>,
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;
},
}));
}); });
@@ -1,6 +1,6 @@
@import "core/static/core/colors"; @import "core/static/core/colors";
#timetable { .timetable {
--hour-side-width: 60px; --hour-side-width: 60px;
display: block; display: block;
+6 -35
View File
@@ -1,11 +1,13 @@
{% extends 'core/base.jinja' %} {% extends "core/base.jinja" %}
{% from "timetable/macros.jinja" import display_timetable %}
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}"> <link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
<script type="module" src="{{ static('bundled/timetable/generator-index.ts') }}"></script> <script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
{%- endblock -%} {%- endblock -%}
{% block title %} {% block title %}
@@ -60,39 +62,8 @@
</div> </div>
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}"> <input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
</form> </form>
<div {{ display_timetable("courses") }}
id="timetable" <template x-if="courses.length > 0">
x-show="table.height > 0 && table.width > 0"
:style="{width: `${table.width+80}px`, height: `${table.height+40}px`}"
>
<div class="header">
<template x-for="weekday in displayedWeekdays">
<span x-text="weekday"></span>
</template>
</div>
<div class="content">
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)">
<template x-for="[hour, style] in getHours()">
<div class="hour" :style="style">
<div x-text="hour"></div>
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div>
</div>
</template>
</div>
<div class="courses">
<template x-for="course in courses">
<div class="slot" :style="getStyle(course)">
<span class="course-type" x-text="course.courseType"></span>
<span x-text="course.ueCode"></span>
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
<span x-text="course.room"></span>
</div>
</template>
</div>
</div>
</div>
<template x-if="table.height > 0 && table.width > 0">
<section> <section>
{% if user.has_perm("timetable.add_self_timetable") %} {% if user.has_perm("timetable.add_self_timetable") %}
<div id="timetable-save" class="margin-bottom"> <div id="timetable-save" class="margin-bottom">
@@ -0,0 +1,46 @@
{# HTML+AlpineJS to display a timetable
Args:
ues: either a JS variable name or an array of object
satisfying the TimetableSlot interface
reactive: if True and `ues` is a variable used in an Alpine context,
then this timetable will react to modifications and
change its own display
#}
{% macro display_timetable(ues) %}
<div
x-data="timetableDisplay({{ ues }})"
x-model="{{ ues }}"
x-modelable="slots"
class="timetable"
x-show="slots.length > 0"
:style="{width: `${table.width+80}px`, height: `${table.height+40}px`}"
>
<div class="header">
<template x-for="weekday in displayedWeekdays">
<span x-text="weekday"></span>
</template>
</div>
<div class="content">
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)">
<template x-for="[hour, style] in getHours()">
<div class="hour" :style="style">
<div x-text="hour"></div>
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div>
</div>
</template>
</div>
<div class="courses">
<template x-for="course in slots">
<div class="slot" :style="getStyle(course)">
<span class="course-type" x-text="course.courseType"></span>
<span x-text="course.ueCode"></span>
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
<span x-text="course.room"></span>
</div>
</template>
</div>
</div>
</div>
{% endmacro %}