mirror of
https://gitlab.dit.htwk-leipzig.de/htwk-software/htwkalender-pwa.git
synced 2025-07-16 17:48:51 +02:00
feat:#3 frontend room occupancy decoder
This commit is contained in:
@ -34,7 +34,7 @@ import (
|
|||||||
"go.mongodb.org/mongo-driver/bson"
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
const RoomOccupancyGranularity = 5
|
const RoomOccupancyGranularity = 15
|
||||||
|
|
||||||
func AddRoutes(app *pocketbase.PocketBase) {
|
func AddRoutes(app *pocketbase.PocketBase) {
|
||||||
|
|
||||||
|
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@ -16,7 +16,9 @@
|
|||||||
"@tanstack/vue-query": "^5.28.9",
|
"@tanstack/vue-query": "^5.28.9",
|
||||||
"@tanstack/vue-query-devtools": "^5.28.10",
|
"@tanstack/vue-query-devtools": "^5.28.10",
|
||||||
"@vueuse/core": "^10.9.0",
|
"@vueuse/core": "^10.9.0",
|
||||||
|
"bson": "^5.5.1",
|
||||||
"country-flag-emoji-polyfill": "^0.1.8",
|
"country-flag-emoji-polyfill": "^0.1.8",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"primeflex": "^3.3.1",
|
"primeflex": "^3.3.1",
|
||||||
"primeicons": "^6.0.1",
|
"primeicons": "^6.0.1",
|
||||||
@ -4220,6 +4222,14 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bson": {
|
||||||
|
"version": "5.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz",
|
||||||
|
"integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@ -4562,6 +4572,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/de-indent": {
|
"node_modules/de-indent": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||||
|
@ -21,7 +21,9 @@
|
|||||||
"@tanstack/vue-query": "^5.28.9",
|
"@tanstack/vue-query": "^5.28.9",
|
||||||
"@tanstack/vue-query-devtools": "^5.28.10",
|
"@tanstack/vue-query-devtools": "^5.28.10",
|
||||||
"@vueuse/core": "^10.9.0",
|
"@vueuse/core": "^10.9.0",
|
||||||
|
"bson": "^5.5.1",
|
||||||
"country-flag-emoji-polyfill": "^0.1.8",
|
"country-flag-emoji-polyfill": "^0.1.8",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"primeflex": "^3.3.1",
|
"primeflex": "^3.3.1",
|
||||||
"primeicons": "^6.0.1",
|
"primeicons": "^6.0.1",
|
||||||
|
44
frontend/src/api/fetchRoomOccupancy.ts
Normal file
44
frontend/src/api/fetchRoomOccupancy.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
|
||||||
|
//Copyright (C) 2024 HTWKalender support@htwkalender.de
|
||||||
|
|
||||||
|
//This program is free software: you can redistribute it and/or modify
|
||||||
|
//it under the terms of the GNU Affero General Public License as published by
|
||||||
|
//the Free Software Foundation, either version 3 of the License, or
|
||||||
|
//(at your option) any later version.
|
||||||
|
|
||||||
|
//This program is distributed in the hope that it will be useful,
|
||||||
|
//but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
//GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
//You should have received a copy of the GNU Affero General Public License
|
||||||
|
//along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import { BSON } from "bson";
|
||||||
|
import { RoomOccupancyList } from "@/model/roomOccupancyList.ts";
|
||||||
|
|
||||||
|
export async function fetchRoomOccupancy(
|
||||||
|
from_date: string,
|
||||||
|
to_date: string,
|
||||||
|
): Promise<RoomOccupancyList> {
|
||||||
|
var roomOccupancyList: RoomOccupancyList = new RoomOccupancyList(
|
||||||
|
new Date(), 0, 0, []
|
||||||
|
);
|
||||||
|
|
||||||
|
await fetch(
|
||||||
|
"/api/schedule/rooms?from=" + from_date + "&to=" + to_date,
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
return response.arrayBuffer();
|
||||||
|
})
|
||||||
|
.then((roomsResponse: ArrayBuffer | null) => {
|
||||||
|
if (roomsResponse == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = new Uint8Array(roomsResponse);
|
||||||
|
roomOccupancyList = BSON.deserialize(data) as RoomOccupancyList;
|
||||||
|
return roomOccupancyList;
|
||||||
|
});
|
||||||
|
|
||||||
|
return roomOccupancyList;
|
||||||
|
}
|
@ -51,6 +51,11 @@ const items = computed(() => [
|
|||||||
icon: "pi pi-fw pi-calendar",
|
icon: "pi pi-fw pi-calendar",
|
||||||
route: "/rooms/free",
|
route: "/rooms/free",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("roomFinderPage.roomSchedule") + " (offline)",
|
||||||
|
icon: "pi pi-fw pi-ban",
|
||||||
|
route: "/rooms/occupancy/offline",
|
||||||
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -30,6 +30,7 @@ import router from "@/router";
|
|||||||
import { formatYearMonthDay } from "@/helpers/dates";
|
import { formatYearMonthDay } from "@/helpers/dates";
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import { watch } from "vue";
|
import { watch } from "vue";
|
||||||
|
import { isValid } from "date-fns";
|
||||||
|
|
||||||
const { t } = useI18n({ useScope: "global" });
|
const { t } = useI18n({ useScope: "global" });
|
||||||
|
|
||||||
@ -50,11 +51,13 @@ function setDateFromQuery() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// date is in format like YYYYMMDD
|
// date is in format like YYYYMMDD
|
||||||
// TODO check if date is valid
|
|
||||||
const year = queryDate.substring(0, 4);
|
const year = queryDate.substring(0, 4);
|
||||||
const month = queryDate.substring(4, 6);
|
const month = queryDate.substring(4, 6);
|
||||||
const day = queryDate.substring(6, 8);
|
const day = queryDate.substring(6, 8);
|
||||||
date.value = new Date(`${year}-${month}-${day}`);
|
date.value = new Date(`${year}-${month}-${day}`);
|
||||||
|
if (!isValid(date.value)) {
|
||||||
|
date.value = new Date();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
203
frontend/src/components/RoomOccupationOffline.vue
Normal file
203
frontend/src/components/RoomOccupationOffline.vue
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
<!--
|
||||||
|
Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
|
||||||
|
Copyright (C) 2024 HTWKalender support@htwkalender.de
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import FullCalendar from "@fullcalendar/vue3";
|
||||||
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||||
|
import interactionPlugin from "@fullcalendar/interaction";
|
||||||
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||||
|
import { computed, ComputedRef, inject, ref, Ref } from "vue";
|
||||||
|
import { CalendarOptions, DatesSetArg, EventInput } from "@fullcalendar/core";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import allLocales from "@fullcalendar/core/locales-all";
|
||||||
|
import router from "@/router";
|
||||||
|
import { formatYearMonthDay } from "@/helpers/dates";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
import { watch } from "vue";
|
||||||
|
import { fetchRoomOccupancy } from "@/api/fetchRoomOccupancy";
|
||||||
|
import { isValid } from "date-fns";
|
||||||
|
|
||||||
|
const { t } = useI18n({ useScope: "global" });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
room: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const date: Ref<Date> = ref(new Date());
|
||||||
|
|
||||||
|
// Set the selected date from the URL
|
||||||
|
function setDateFromQuery() {
|
||||||
|
const queryDate = router.currentRoute.value.query.date;
|
||||||
|
if (typeof queryDate === "string") {
|
||||||
|
if (queryDate === formatYearMonthDay(date.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// date is in format like YYYYMMDD
|
||||||
|
const year = queryDate.substring(0, 4);
|
||||||
|
const month = queryDate.substring(4, 6);
|
||||||
|
const day = queryDate.substring(6, 8);
|
||||||
|
date.value = new Date(`${year}-${month}-${day}`);
|
||||||
|
if (!isValid(date.value)) {
|
||||||
|
date.value = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDateFromQuery();
|
||||||
|
|
||||||
|
const currentDateFrom: Ref<string> = ref("");
|
||||||
|
const currentDateTo: Ref<string> = ref("");
|
||||||
|
|
||||||
|
const mobilePage = inject("mobilePage") as Ref<boolean>;
|
||||||
|
|
||||||
|
const selectedRoom = computed(() => props.room);
|
||||||
|
|
||||||
|
const { data: occupations } = useQuery({
|
||||||
|
queryKey: ["roomOccupation", selectedRoom, currentDateFrom, currentDateTo],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRoomOccupancy(
|
||||||
|
new Date(currentDateFrom.value).toISOString(),
|
||||||
|
new Date(currentDateTo.value).toISOString()
|
||||||
|
),
|
||||||
|
select: (data) => data
|
||||||
|
.decodeOccupancy(selectedRoom.value, new Date(currentDateFrom.value), new Date(currentDateTo.value))
|
||||||
|
.map((event, index) => ({
|
||||||
|
id: index,
|
||||||
|
event: event,
|
||||||
|
})),
|
||||||
|
enabled: () => selectedRoom.value !== "" && currentDateFrom.value !== "",
|
||||||
|
staleTime: 5000000, // 500 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(occupations, () => fullCalendar.value?.getApi().refetchEvents());
|
||||||
|
|
||||||
|
const fullCalendar = ref<InstanceType<typeof FullCalendar>>();
|
||||||
|
|
||||||
|
const calendarOptions: ComputedRef<CalendarOptions> = computed(() => ({
|
||||||
|
locales: allLocales,
|
||||||
|
locale: t("languageCode"),
|
||||||
|
plugins: [dayGridPlugin, interactionPlugin, timeGridPlugin],
|
||||||
|
// local debugging of mobilePage variable in object creation on ternary expression
|
||||||
|
initialView: mobilePage.value ? "Day" : "week",
|
||||||
|
initialDate: date.value,
|
||||||
|
dayHeaderFormat: { weekday: "short", omitCommas: true },
|
||||||
|
slotDuration: "00:15:00",
|
||||||
|
eventTimeFormat: {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
},
|
||||||
|
height: "auto",
|
||||||
|
views: {
|
||||||
|
week: {
|
||||||
|
type: "timeGrid",
|
||||||
|
slotLabelFormat: {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
omitZeroMinute: false,
|
||||||
|
meridiem: false,
|
||||||
|
hour12: false,
|
||||||
|
},
|
||||||
|
dateAlignment: "week",
|
||||||
|
titleFormat: { month: "short", day: "numeric" },
|
||||||
|
slotMinTime: "06:00:00",
|
||||||
|
slotMaxTime: "22:00:00",
|
||||||
|
duration: { days: 7 },
|
||||||
|
firstDay: 1,
|
||||||
|
allDaySlot: false,
|
||||||
|
hiddenDays: [0],
|
||||||
|
},
|
||||||
|
Day: {
|
||||||
|
type: "timeGrid",
|
||||||
|
slotLabelFormat: {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
omitZeroMinute: false,
|
||||||
|
meridiem: false,
|
||||||
|
hour12: false,
|
||||||
|
},
|
||||||
|
titleFormat: { month: "short", day: "numeric" },
|
||||||
|
slotMinTime: "06:00:00",
|
||||||
|
slotMaxTime: "22:00:00",
|
||||||
|
duration: { days: 1 },
|
||||||
|
allDaySlot: false,
|
||||||
|
hiddenDays: [0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headerToolbar: {
|
||||||
|
end: "prev,next today",
|
||||||
|
center: "title",
|
||||||
|
start: "week,Day",
|
||||||
|
},
|
||||||
|
|
||||||
|
datesSet: function (dateInfo: DatesSetArg) {
|
||||||
|
const view = dateInfo.view;
|
||||||
|
const offset = new Date().getTimezoneOffset();
|
||||||
|
const startDate = new Date(view.activeStart.getTime() - offset * 60 * 1000);
|
||||||
|
const endDate = new Date(view.activeEnd.getTime() - offset * 60 * 1000);
|
||||||
|
currentDateFrom.value = startDate.toISOString().split("T")[0];
|
||||||
|
currentDateTo.value = endDate.toISOString().split("T")[0];
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
...router.currentRoute.value.query,
|
||||||
|
date: formatYearMonthDay(endDate),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
events: function (
|
||||||
|
_info: unknown,
|
||||||
|
successCallback: (events: EventInput[]) => void,
|
||||||
|
) {
|
||||||
|
if (!occupations.value) return;
|
||||||
|
successCallback(
|
||||||
|
occupations.value.map((event) => {
|
||||||
|
return {
|
||||||
|
id: event.id.toString(),
|
||||||
|
start: event.event.start,
|
||||||
|
end: event.event.end,
|
||||||
|
color: event.event.free
|
||||||
|
? "var(--htwk-gruen-500)"
|
||||||
|
: "var(--htwk-grau-60-500)",
|
||||||
|
textColor: event.event.free
|
||||||
|
? "var(--green-50)"
|
||||||
|
: "white",
|
||||||
|
title: event.event.stub
|
||||||
|
? t("roomFinderPage.stub")
|
||||||
|
: event.event.free
|
||||||
|
? t("roomFinderPage.available")
|
||||||
|
: t("roomFinderPage.occupied"),
|
||||||
|
} as EventInput;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<FullCalendar ref="fullCalendar" :options="calendarOptions" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.fc-toolbar.fc-header-toolbar) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
@ -26,7 +26,8 @@
|
|||||||
"dropDownSelect": "Bitte wähle einen Raum aus",
|
"dropDownSelect": "Bitte wähle einen Raum aus",
|
||||||
"noRoomsAvailable": "Keine Räume verfügbar",
|
"noRoomsAvailable": "Keine Räume verfügbar",
|
||||||
"available": "verfügbar",
|
"available": "verfügbar",
|
||||||
"occupied": "belegt"
|
"occupied": "belegt",
|
||||||
|
"stub": "bitte online prüfen"
|
||||||
},
|
},
|
||||||
"freeRooms": {
|
"freeRooms": {
|
||||||
"freeRooms": "Freie Räume",
|
"freeRooms": "Freie Räume",
|
||||||
|
@ -26,7 +26,8 @@
|
|||||||
"dropDownSelect": "please select a room",
|
"dropDownSelect": "please select a room",
|
||||||
"noRoomsAvailable": "no rooms listed",
|
"noRoomsAvailable": "no rooms listed",
|
||||||
"available": "available",
|
"available": "available",
|
||||||
"occupied": "occupied"
|
"occupied": "occupied",
|
||||||
|
"stub": "please check online"
|
||||||
},
|
},
|
||||||
"freeRooms": {
|
"freeRooms": {
|
||||||
"freeRooms": "free rooms",
|
"freeRooms": "free rooms",
|
||||||
|
@ -26,7 +26,8 @@
|
|||||||
"dropDownSelect": "部屋を選択してください",
|
"dropDownSelect": "部屋を選択してください",
|
||||||
"noRoomsAvailable": "利用可能な部屋がありません",
|
"noRoomsAvailable": "利用可能な部屋がありません",
|
||||||
"available": "利用可能",
|
"available": "利用可能",
|
||||||
"occupied": "占有中"
|
"occupied": "占有中",
|
||||||
|
"stub": "オンラインでご確認ください。"
|
||||||
},
|
},
|
||||||
"freeRooms": {
|
"freeRooms": {
|
||||||
"freeRooms": "空いている部屋",
|
"freeRooms": "空いている部屋",
|
||||||
|
@ -31,7 +31,17 @@ export class Event {
|
|||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnonymizedEventDTO {
|
export class AnonymizedOccupancy {
|
||||||
|
constructor(
|
||||||
|
public start: string,
|
||||||
|
public end: string,
|
||||||
|
public rooms: string,
|
||||||
|
public free: boolean,
|
||||||
|
public stub: boolean = false,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnonymizedEventDTO extends AnonymizedOccupancy {
|
||||||
constructor(
|
constructor(
|
||||||
public day: string,
|
public day: string,
|
||||||
public week: string,
|
public week: string,
|
||||||
@ -39,5 +49,7 @@ export class AnonymizedEventDTO {
|
|||||||
public end: string,
|
public end: string,
|
||||||
public rooms: string,
|
public rooms: string,
|
||||||
public free: boolean,
|
public free: boolean,
|
||||||
) {}
|
) {
|
||||||
|
super(start, end, rooms, free);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
240
frontend/src/model/roomOccupancyList.ts
Normal file
240
frontend/src/model/roomOccupancyList.ts
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
|
||||||
|
//Copyright (C) 2024 HTWKalender support@htwkalender.de
|
||||||
|
|
||||||
|
//This program is free software: you can redistribute it and/or modify
|
||||||
|
//it under the terms of the GNU Affero General Public License as published by
|
||||||
|
//the Free Software Foundation, either version 3 of the License, or
|
||||||
|
//(at your option) any later version.
|
||||||
|
|
||||||
|
//This program is distributed in the hope that it will be useful,
|
||||||
|
//but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
//GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
//You should have received a copy of the GNU Affero General Public License
|
||||||
|
//along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import { Binary } from "bson";
|
||||||
|
import { AnonymizedEventDTO, AnonymizedOccupancy } from "./event";
|
||||||
|
import { Duration, NormalizedInterval, add, addDays, addMinutes, clamp, differenceInMinutes, eachDayOfInterval, endOfDay, interval, isAfter, isBefore, isEqual, max, min, startOfDay, startOfTomorrow, subDays } from "date-fns";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the occupancy of a single room.
|
||||||
|
* occupancy is a binary string, where each bit represents a block of time.
|
||||||
|
*/
|
||||||
|
class RoomOccupancy {
|
||||||
|
constructor(
|
||||||
|
public name : string,
|
||||||
|
public occupancy : Binary,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The start time of the day. 08:00
|
||||||
|
const START_OF_WORKDAY : Duration = {hours: 8};
|
||||||
|
/// The end time of the day. 20:00
|
||||||
|
const END_OF_WORKDAY : Duration = {hours: 20};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the occupancy of multiple rooms.
|
||||||
|
* start is the start date of the occupancy list.
|
||||||
|
* granularity is the duration of a single block in minutes.
|
||||||
|
* blocks is the number of time slices called blocks.
|
||||||
|
* rooms is a list of RoomOccupancy objects representing the occupancy of each room.
|
||||||
|
*/
|
||||||
|
export class RoomOccupancyList {
|
||||||
|
constructor(
|
||||||
|
public start : Date,
|
||||||
|
public granularity : number,
|
||||||
|
public blocks : number,
|
||||||
|
public rooms : RoomOccupancy[],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of all rooms encoded in this occupancy list.
|
||||||
|
* @returns a list of room names.
|
||||||
|
*/
|
||||||
|
public getRooms() : string[] {
|
||||||
|
return this.rooms.map((room) => room.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the occupancy of a room for a given time range.
|
||||||
|
* @param room the room to decode.
|
||||||
|
* @param from the start of the time range.
|
||||||
|
* @param to the end of the time range.
|
||||||
|
* @returns a list of AnonymizedEventDTO objects representing the occupancy of the room.
|
||||||
|
*/
|
||||||
|
public decodeOccupancy(room : string, from : Date, to : Date) : AnonymizedOccupancy[] {
|
||||||
|
console.log("Decoding occupancy for room " + room + " from " + from.toISOString() + " to " + to.toISOString());
|
||||||
|
const roomOccupancy = this.rooms.find((r) => r.name === room);
|
||||||
|
|
||||||
|
if (roomOccupancy === undefined) {
|
||||||
|
return RoomOccupancyList.generateStubEvents(room, from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
const occupancyList = [];
|
||||||
|
|
||||||
|
// Get start and end of decoded time range (within encoded list and requested range)
|
||||||
|
let decodeInterval = interval(clamp(from, this.getOccupancyInterval()), clamp(to, this.getOccupancyInterval()));
|
||||||
|
|
||||||
|
// Calculate the slice of bytes, that are needed to decode the requested time range
|
||||||
|
let minutesFromStart = differenceInMinutes(this.start, decodeInterval.start);
|
||||||
|
let minutesToEnd = differenceInMinutes(this.start, decodeInterval.end);
|
||||||
|
|
||||||
|
let firstByte = Math.floor(minutesFromStart / this.granularity / 8);
|
||||||
|
let lastByte = Math.floor(minutesToEnd / this.granularity / 8);
|
||||||
|
|
||||||
|
let decodeSliceStart = addMinutes(this.start, firstByte * 8 * this.granularity);
|
||||||
|
let decodeSlice = roomOccupancy.occupancy.buffer.slice(firstByte, lastByte + 1);
|
||||||
|
|
||||||
|
// Decode the occupancy data
|
||||||
|
occupancyList.push(...RoomOccupancyList.decodeOccupancyData(new Uint8Array(decodeSlice), decodeSliceStart, this.granularity, room));
|
||||||
|
|
||||||
|
// add stub events for the time before and after the decoded time range
|
||||||
|
if (!isEqual(from, decodeInterval.start)) {
|
||||||
|
occupancyList.push(...RoomOccupancyList.generateStubEvents(room, from, decodeInterval.start));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEqual(to, decodeInterval.end)) {
|
||||||
|
occupancyList.push(...RoomOccupancyList.generateStubEvents(room, decodeInterval.end, to));
|
||||||
|
}
|
||||||
|
|
||||||
|
return occupancyList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the decoded time interval within the current occupancy list.
|
||||||
|
* @returns the interval of the occupancy list.
|
||||||
|
*/
|
||||||
|
private getOccupancyInterval() : NormalizedInterval<Date> {
|
||||||
|
return interval(this.start, addMinutes(this.start, this.granularity * this.blocks));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the whole array of occupancy data with the first bit representing the given start time.
|
||||||
|
* @param occupancy the occupancy data to decode.
|
||||||
|
* @param start the start time of the occupancy data.
|
||||||
|
* @param granularity the duration of a single block in minutes.
|
||||||
|
* @param room the room name.
|
||||||
|
* @returns a list of AnonymizedOccupancy objects representing the occupancy of the room.
|
||||||
|
*/
|
||||||
|
public static decodeOccupancyData(occupancy : Uint8Array, start : Date, granularity : number, room : string) : AnonymizedOccupancy[] {
|
||||||
|
let occupancyList = [];
|
||||||
|
let firstOccupancyBit : number | null = null;
|
||||||
|
|
||||||
|
// Iterate over all bytes that are in the array
|
||||||
|
for (let byte_i = 0; byte_i < occupancy.length; byte_i++) {
|
||||||
|
let byte = occupancy[byte_i];
|
||||||
|
|
||||||
|
// Iterate over all bits in the current byte
|
||||||
|
for (let bit_i = 0; bit_i < 8; bit_i++) {
|
||||||
|
let isOccupied = (byte & (1 << bit_i)) !== 0;
|
||||||
|
|
||||||
|
if (firstOccupancyBit === null && isOccupied) {
|
||||||
|
firstOccupancyBit = byte_i * 8 + bit_i;
|
||||||
|
} else if (firstOccupancyBit !== null && !isOccupied) {
|
||||||
|
let startTime = addMinutes(start, firstOccupancyBit * granularity);
|
||||||
|
let endTime = addMinutes(start, (byte_i * 8 + bit_i) * granularity);
|
||||||
|
|
||||||
|
// add event between start and end of a block of boolean true values
|
||||||
|
occupancyList.push(new AnonymizedOccupancy(
|
||||||
|
startTime.toISOString(),
|
||||||
|
endTime.toISOString(),
|
||||||
|
room,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
firstOccupancyBit = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add last event if it is still ongoing
|
||||||
|
if (firstOccupancyBit !== null) {
|
||||||
|
let startTime = addMinutes(start, firstOccupancyBit * granularity);
|
||||||
|
let endTime = addMinutes(start, occupancy.length * 8 * granularity);
|
||||||
|
|
||||||
|
occupancyList.push(new AnonymizedOccupancy(
|
||||||
|
startTime.toISOString(),
|
||||||
|
endTime.toISOString(),
|
||||||
|
room,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return occupancyList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a list of AnonymizedOccupancy objects for a given time range.
|
||||||
|
* The generated events are always lying within the time range [START_OF_DAY, END_OF_DAY].
|
||||||
|
*
|
||||||
|
* @param from The start time within the specified start day.
|
||||||
|
* @param to The end time within the specified end day.
|
||||||
|
* @returns a list of AnonymizedEventDTO objects, from start to end.
|
||||||
|
*/
|
||||||
|
public static generateStubEvents(rooms : string, from : Date, to : Date) : AnonymizedOccupancy[] {
|
||||||
|
from = RoomOccupancyList.shiftTimeForwardInsideWorkday(from);
|
||||||
|
to = RoomOccupancyList.shiftTimeBackwardInsideWorkday(to);
|
||||||
|
|
||||||
|
if (isAfter(from, to)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return eachDayOfInterval({start: from, end: to}).map((day) => {
|
||||||
|
let startTime = max([from, RoomOccupancyList.setTimeOfDay(day, START_OF_WORKDAY)]);
|
||||||
|
let endTime = min([to, RoomOccupancyList.setTimeOfDay(day, END_OF_WORKDAY)]);
|
||||||
|
|
||||||
|
return new AnonymizedOccupancy(
|
||||||
|
startTime.toISOString(),
|
||||||
|
endTime.toISOString(),
|
||||||
|
rooms,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift the time forward to the start of the next day if it is after the end of the current day.
|
||||||
|
*
|
||||||
|
* @param date the date time to check if in bounds.
|
||||||
|
* @returns the shifted time.
|
||||||
|
*/
|
||||||
|
private static shiftTimeForwardInsideWorkday(date : Date) : Date {
|
||||||
|
// if the time of date is after the end of the workday
|
||||||
|
if (isAfter(date, RoomOccupancyList.setTimeOfDay(date, END_OF_WORKDAY))) {
|
||||||
|
// shift the time to the start of the next day
|
||||||
|
return startOfDay(addDays(date,1));
|
||||||
|
} else {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift the time backward to the end of the previous day if it is before the start of the current day.
|
||||||
|
*
|
||||||
|
* @param date the date time to check if in bounds.
|
||||||
|
* @returns the shifted time.
|
||||||
|
*/
|
||||||
|
private static shiftTimeBackwardInsideWorkday(date : Date) : Date {
|
||||||
|
// if the time of date is before the start of the workday
|
||||||
|
if (isBefore(date, RoomOccupancyList.setTimeOfDay(date, START_OF_WORKDAY))) {
|
||||||
|
// shift the time to the end of the previous day
|
||||||
|
return endOfDay(subDays(date,1));
|
||||||
|
} else {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the date to the specified time after 00:00.
|
||||||
|
* @param date the date object to extract the day from.
|
||||||
|
* @param time the time as Duration after 00:00.
|
||||||
|
* @returns new date with changed time values.
|
||||||
|
*/
|
||||||
|
private static setTimeOfDay(date : Date, time : Duration) : Date {
|
||||||
|
return add(startOfDay(date), time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ const AdditionalModules = () => import("../view/AdditionalModules.vue");
|
|||||||
const CalendarLink = () => import("../components/CalendarLink.vue");
|
const CalendarLink = () => import("../components/CalendarLink.vue");
|
||||||
const RenameModules = () => import("../components/RenameModules.vue");
|
const RenameModules = () => import("../components/RenameModules.vue");
|
||||||
const RoomFinder = () => import("../view/RoomFinder.vue");
|
const RoomFinder = () => import("../view/RoomFinder.vue");
|
||||||
|
const RoomFinderOffline = () => import("../view/RoomFinderOffline.vue");
|
||||||
const EditCalendarView = () => import("../view/EditCalendarView.vue");
|
const EditCalendarView = () => import("../view/EditCalendarView.vue");
|
||||||
const EditAdditionalModules = () =>
|
const EditAdditionalModules = () =>
|
||||||
import("../view/editCalendar/EditAdditionalModules.vue");
|
import("../view/editCalendar/EditAdditionalModules.vue");
|
||||||
@ -43,6 +44,11 @@ const router = createRouter({
|
|||||||
name: "room-schedule",
|
name: "room-schedule",
|
||||||
component: RoomFinder,
|
component: RoomFinder,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/rooms/occupancy/offline",
|
||||||
|
name: "room-schedule-offline",
|
||||||
|
component: RoomFinderOffline,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/rooms/free",
|
path: "/rooms/free",
|
||||||
name: "free-rooms",
|
name: "free-rooms",
|
||||||
|
108
frontend/src/view/RoomFinderOffline.vue
Normal file
108
frontend/src/view/RoomFinderOffline.vue
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<!--
|
||||||
|
Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format.
|
||||||
|
Copyright (C) 2024 HTWKalender support@htwkalender.de
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Ref, computed, ref, watch } from "vue";
|
||||||
|
import { fetchRoom } from "../api/fetchRoom.ts";
|
||||||
|
import DynamicPage from "./DynamicPage.vue";
|
||||||
|
import RoomOccupationOffline from "../components/RoomOccupationOffline.vue";
|
||||||
|
import { computedAsync } from "@vueuse/core";
|
||||||
|
import router from "@/router";
|
||||||
|
|
||||||
|
type Room = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedRoom: Ref<Room> = ref({ name: "" });
|
||||||
|
|
||||||
|
// Watch for changes in URL parameter
|
||||||
|
router.afterEach(async (to) => {
|
||||||
|
const room = to.query.room;
|
||||||
|
if (room && typeof room === "string") {
|
||||||
|
setRoomFromList(room, rooms.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rooms = computedAsync<Set<string>>(async () => {
|
||||||
|
let rooms: Set<string> = new Set();
|
||||||
|
return await fetchRoom()
|
||||||
|
.then((data) => {
|
||||||
|
rooms = new Set(data);
|
||||||
|
return rooms;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
const room = router.currentRoute.value.query.room;
|
||||||
|
if (room && typeof room === "string") {
|
||||||
|
// check if room is available in roomsList
|
||||||
|
setRoomFromList(room, rooms);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, new Set());
|
||||||
|
|
||||||
|
const roomsList = computed(() => {
|
||||||
|
return Array.from(rooms.value).map((room) => {
|
||||||
|
return { name: room } as Room;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the room from the list of rooms
|
||||||
|
* @param room Name of the room
|
||||||
|
* @param rooms List of available rooms
|
||||||
|
*/
|
||||||
|
function setRoomFromList(room: string, rooms: Set<string>) {
|
||||||
|
// wait for the roomsList to be available
|
||||||
|
const roomInList: boolean = rooms.has(room);
|
||||||
|
if (roomInList) {
|
||||||
|
selectedRoom.value.name = room;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedRoom, (newRoom: Room) => {
|
||||||
|
if (newRoom.name !== "") {
|
||||||
|
router.push({
|
||||||
|
query: { ...router.currentRoute.value.query, room: newRoom.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DynamicPage
|
||||||
|
:hide-content="selectedRoom.name === ''"
|
||||||
|
:headline="$t('roomFinderPage.headline') + ' – Offline'"
|
||||||
|
:sub-title="$t('roomFinderPage.detail')"
|
||||||
|
icon="pi pi-search"
|
||||||
|
>
|
||||||
|
<template #selection>
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedRoom"
|
||||||
|
:options="roomsList"
|
||||||
|
class="flex-1 m-0"
|
||||||
|
filter
|
||||||
|
option-label="name"
|
||||||
|
:placeholder="$t('roomFinderPage.dropDownSelect')"
|
||||||
|
:empty-message="$t('roomFinderPage.noRoomsAvailable')"
|
||||||
|
:auto-filter-focus="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<RoomOccupationOffline :room="selectedRoom.name" />
|
||||||
|
</template>
|
||||||
|
</DynamicPage>
|
||||||
|
</template>
|
@ -21,7 +21,7 @@
|
|||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
"references": [
|
"references": [
|
||||||
|
@ -96,4 +96,16 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
esbuild: {
|
||||||
|
supported: {
|
||||||
|
'top-level-await': true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
esbuildOptions: {
|
||||||
|
supported: {
|
||||||
|
'top-level-await': true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user