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 + } + }, + }, });