mirror of
https://github.com/ae-utbm/sith.git
synced 2026-10-24 11:19:33 +00:00
247 lines
7.9 KiB
TypeScript
247 lines
7.9 KiB
TypeScript
import html2canvas from "html2canvas";
|
|
import { AlertMessage } from "#core:utils/alert-message";
|
|
import { type WeekDay as ApiWeekDay, timetableSaveTimetable } from "#openapi";
|
|
|
|
// see https://regex101.com/r/QHSaPM/3
|
|
const TIMETABLE_ROW_RE: RegExp =
|
|
/^(?<ueCode>\w.+\w)\s+(?<courseType>[A-Z]{2}\d)\s+((?<weekGroup>[AB])\s+)?(?<weekday>(lundi)|(mardi)|(mercredi)|(jeudi)|(vendredi)|(samedi)|(dimanche))\s+(?<startHour>\d{2}:\d{2})\s+(?<endHour>\d{2}:\d{2})\s+[\dA-B]\s+((?<attendance>[\wé]*)\s+)?(?<room>\w+(?:, \w+)*)$/;
|
|
|
|
const DEFAULT_TIMETABLE: string = `DS52\t\tCM1\t\tlundi\t08:00\t10:00\t1\tPrésentiel\tA113
|
|
DS53\t\tCM1\t\tlundi\t10:15\t12:15\t1\tPrésentiel\tA101
|
|
DS53\t\tTP1\t\tlundi\t13:00\t16:00\t1\tPrésentiel\tH010
|
|
SO03\t\tCM1\t\tlundi\t16:15\t17:45\t1\tPrésentiel\tA103
|
|
SO03\t\tTD1\t\tlundi\t17:45\t19:45\t1\tPrésentiel\tA103
|
|
DS50\t\tTP1\t\tmardi\t08:00\t10:00\t1\tPrésentiel\tA216
|
|
DS51\t\tCM1\t\tmardi\t10:15\t12:15\t1\tPrésentiel\tA216
|
|
DS51\t\tTP1\t\tmardi\t14:00\t18:00\t1\tPrésentiel\tH010
|
|
DS52\t\tTP2\tA\tjeudi\t08:00\t10:00\tA\tPrésentiel\tA110a, A110b
|
|
DS52\t\tTD1\t\tjeudi\t10:15\t12:15\t1\tPrésentiel\tA110a, A110b
|
|
LC02\t\tTP1\t\tjeudi\t15:00\t16:00\t1\tPrésentiel\tA209
|
|
LC02\t\tTD1\t\tjeudi\t16:15\t18:15\t1\tPrésentiel\tA206`;
|
|
|
|
type WeekDay =
|
|
| "lundi"
|
|
| "mardi"
|
|
| "mercredi"
|
|
| "jeudi"
|
|
| "vendredi"
|
|
| "samedi"
|
|
| "dimanche";
|
|
|
|
const WEEKDAYS = [
|
|
"lundi",
|
|
"mardi",
|
|
"mercredi",
|
|
"jeudi",
|
|
"vendredi",
|
|
"samedi",
|
|
"dimanche",
|
|
] as const;
|
|
|
|
const SLOT_HEIGHT = 20 as const; // Each 15min has a height of 20px in the timetable
|
|
const SLOT_WIDTH = 250 as const; // Each weekday ha a width of 400px in the timetable
|
|
const MINUTES_PER_SLOT = 15 as const;
|
|
|
|
interface TimetableSlot {
|
|
courseType: string;
|
|
room: string;
|
|
startHour: string;
|
|
endHour: string;
|
|
startSlot: number;
|
|
endSlot: number;
|
|
ueCode: string;
|
|
weekGroup?: string;
|
|
weekday: WeekDay;
|
|
}
|
|
|
|
function parseSlots(s: string): TimetableSlot[] {
|
|
return s
|
|
.split("\n")
|
|
.filter((s: string) => s.length > 0)
|
|
.map((row: string) => {
|
|
const parsed = TIMETABLE_ROW_RE.exec(row);
|
|
if (!parsed?.groups) {
|
|
throw new Error(`Couldn't parse row ${row}`, { cause: { row: row } });
|
|
}
|
|
const [startHour, startMin] = parsed.groups.startHour
|
|
.split(":")
|
|
.map((i) => Number.parseInt(i, 10));
|
|
const [endHour, endMin] = parsed.groups.endHour
|
|
.split(":")
|
|
.map((i) => Number.parseInt(i, 10));
|
|
return {
|
|
...parsed.groups,
|
|
startSlot: Math.floor((startHour * 60 + startMin) / MINUTES_PER_SLOT),
|
|
endSlot: Math.floor((endHour * 60 + endMin) / MINUTES_PER_SLOT),
|
|
} as unknown as TimetableSlot;
|
|
});
|
|
}
|
|
|
|
document.addEventListener("alpine:init", () => {
|
|
/**
|
|
* Alpine data that can parse a timetable, save it and export it to png.
|
|
*/
|
|
Alpine.data("timetableGenerator", (userId: number) => ({
|
|
content: DEFAULT_TIMETABLE,
|
|
error: null as { incorrectRow?: string } | null,
|
|
courses: [] as TimetableSlot[],
|
|
edtPublic: false,
|
|
saving: false,
|
|
timetableSavedMessage: new AlertMessage(),
|
|
|
|
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;
|
|
}
|
|
},
|
|
|
|
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({
|
|
// biome-ignore lint/style/useNamingConvention: api is snake case
|
|
path: { user_id: userId },
|
|
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[],
|
|
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;
|
|
},
|
|
}));
|
|
});
|