mirror of
https://github.com/ae-utbm/sith.git
synced 2026-10-10 12:50:29 +00:00
split timetable parsing and display
This commit is contained in:
1 parent
a4964836e4
commit
c80f4ee2b8
4 files changed
+151
-181
No files matched your search
+98
-145
@@ -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;
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in new issue
Block a user