diff --git a/backend/service/addRoute.go b/backend/service/addRoute.go
index 143bc86..d28070e 100644
--- a/backend/service/addRoute.go
+++ b/backend/service/addRoute.go
@@ -34,7 +34,7 @@ import (
"go.mongodb.org/mongo-driver/bson"
)
-const RoomOccupancyGranularity = 5
+const RoomOccupancyGranularity = 15
func AddRoutes(app *pocketbase.PocketBase) {
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 26d126a..e5e059a 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -16,7 +16,9 @@
"@tanstack/vue-query": "^5.28.9",
"@tanstack/vue-query-devtools": "^5.28.10",
"@vueuse/core": "^10.9.0",
+ "bson": "^5.5.1",
"country-flag-emoji-polyfill": "^0.1.8",
+ "date-fns": "^3.6.0",
"pinia": "^2.1.7",
"primeflex": "^3.3.1",
"primeicons": "^6.0.1",
@@ -4220,6 +4222,14 @@
"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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -4562,6 +4572,15 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index c001738..f576ee1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -21,7 +21,9 @@
"@tanstack/vue-query": "^5.28.9",
"@tanstack/vue-query-devtools": "^5.28.10",
"@vueuse/core": "^10.9.0",
+ "bson": "^5.5.1",
"country-flag-emoji-polyfill": "^0.1.8",
+ "date-fns": "^3.6.0",
"pinia": "^2.1.7",
"primeflex": "^3.3.1",
"primeicons": "^6.0.1",
diff --git a/frontend/src/api/fetchRoomOccupancy.ts b/frontend/src/api/fetchRoomOccupancy.ts
new file mode 100644
index 0000000..71b2605
--- /dev/null
+++ b/frontend/src/api/fetchRoomOccupancy.ts
@@ -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 .
+
+import { BSON } from "bson";
+import { RoomOccupancyList } from "@/model/roomOccupancyList.ts";
+
+export async function fetchRoomOccupancy(
+ from_date: string,
+ to_date: string,
+): Promise {
+ 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;
+}
diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue
index 9b44a7d..9ff8c58 100644
--- a/frontend/src/components/MenuBar.vue
+++ b/frontend/src/components/MenuBar.vue
@@ -51,6 +51,11 @@ const items = computed(() => [
icon: "pi pi-fw pi-calendar",
route: "/rooms/free",
},
+ {
+ label: t("roomFinderPage.roomSchedule") + " (offline)",
+ icon: "pi pi-fw pi-ban",
+ route: "/rooms/occupancy/offline",
+ }
],
},
{
diff --git a/frontend/src/components/RoomOccupation.vue b/frontend/src/components/RoomOccupation.vue
index 1f33015..a844748 100644
--- a/frontend/src/components/RoomOccupation.vue
+++ b/frontend/src/components/RoomOccupation.vue
@@ -30,6 +30,7 @@ import router from "@/router";
import { formatYearMonthDay } from "@/helpers/dates";
import { useQuery } from "@tanstack/vue-query";
import { watch } from "vue";
+import { isValid } from "date-fns";
const { t } = useI18n({ useScope: "global" });
@@ -50,11 +51,13 @@ function setDateFromQuery() {
return;
}
// date is in format like YYYYMMDD
- // TODO check if date is valid
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();
+ }
}
}
diff --git a/frontend/src/components/RoomOccupationOffline.vue b/frontend/src/components/RoomOccupationOffline.vue
new file mode 100644
index 0000000..d9732f3
--- /dev/null
+++ b/frontend/src/components/RoomOccupationOffline.vue
@@ -0,0 +1,203 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/src/i18n/translations/de.json b/frontend/src/i18n/translations/de.json
index e870db6..2396b5d 100644
--- a/frontend/src/i18n/translations/de.json
+++ b/frontend/src/i18n/translations/de.json
@@ -26,7 +26,8 @@
"dropDownSelect": "Bitte wähle einen Raum aus",
"noRoomsAvailable": "Keine Räume verfügbar",
"available": "verfügbar",
- "occupied": "belegt"
+ "occupied": "belegt",
+ "stub": "bitte online prüfen"
},
"freeRooms": {
"freeRooms": "Freie Räume",
diff --git a/frontend/src/i18n/translations/en.json b/frontend/src/i18n/translations/en.json
index 99766e0..fc7b792 100644
--- a/frontend/src/i18n/translations/en.json
+++ b/frontend/src/i18n/translations/en.json
@@ -26,7 +26,8 @@
"dropDownSelect": "please select a room",
"noRoomsAvailable": "no rooms listed",
"available": "available",
- "occupied": "occupied"
+ "occupied": "occupied",
+ "stub": "please check online"
},
"freeRooms": {
"freeRooms": "free rooms",
diff --git a/frontend/src/i18n/translations/ja.json b/frontend/src/i18n/translations/ja.json
index 1b3c917..bd5ffe0 100644
--- a/frontend/src/i18n/translations/ja.json
+++ b/frontend/src/i18n/translations/ja.json
@@ -26,7 +26,8 @@
"dropDownSelect": "部屋を選択してください",
"noRoomsAvailable": "利用可能な部屋がありません",
"available": "利用可能",
- "occupied": "占有中"
+ "occupied": "占有中",
+ "stub": "オンラインでご確認ください。"
},
"freeRooms": {
"freeRooms": "空いている部屋",
diff --git a/frontend/src/model/event.ts b/frontend/src/model/event.ts
index 81800fe..5bb6213 100644
--- a/frontend/src/model/event.ts
+++ b/frontend/src/model/event.ts
@@ -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(
public day: string,
public week: string,
@@ -39,5 +49,7 @@ export class AnonymizedEventDTO {
public end: string,
public rooms: string,
public free: boolean,
- ) {}
+ ) {
+ super(start, end, rooms, free);
+ }
}
diff --git a/frontend/src/model/roomOccupancyList.ts b/frontend/src/model/roomOccupancyList.ts
new file mode 100644
index 0000000..12fc00b
--- /dev/null
+++ b/frontend/src/model/roomOccupancyList.ts
@@ -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 .
+
+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 {
+ 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);
+ }
+}
+
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 1de0165..8aef74b 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -21,6 +21,7 @@ const AdditionalModules = () => import("../view/AdditionalModules.vue");
const CalendarLink = () => import("../components/CalendarLink.vue");
const RenameModules = () => import("../components/RenameModules.vue");
const RoomFinder = () => import("../view/RoomFinder.vue");
+const RoomFinderOffline = () => import("../view/RoomFinderOffline.vue");
const EditCalendarView = () => import("../view/EditCalendarView.vue");
const EditAdditionalModules = () =>
import("../view/editCalendar/EditAdditionalModules.vue");
@@ -43,6 +44,11 @@ const router = createRouter({
name: "room-schedule",
component: RoomFinder,
},
+ {
+ path: "/rooms/occupancy/offline",
+ name: "room-schedule-offline",
+ component: RoomFinderOffline,
+ },
{
path: "/rooms/free",
name: "free-rooms",
diff --git a/frontend/src/view/RoomFinderOffline.vue b/frontend/src/view/RoomFinderOffline.vue
new file mode 100644
index 0000000..dc698c6
--- /dev/null
+++ b/frontend/src/view/RoomFinderOffline.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 51d8f95..b8f3de3 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -21,7 +21,7 @@
"allowSyntheticDefaultImports": true,
"paths": {
"@/*": ["./src/*"]
- }
+ },
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 0e9baec..0feaef1 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -96,4 +96,16 @@ export default defineConfig({
},
},
},
+ esbuild: {
+ supported: {
+ 'top-level-await': true
+ },
+ },
+ optimizeDeps: {
+ esbuildOptions: {
+ supported: {
+ 'top-level-await': true
+ }
+ },
+ },
});