diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e5e059a..b034702 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "bson": "^5.5.1", "country-flag-emoji-polyfill": "^0.1.8", "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", "pinia": "^2.1.7", "primeflex": "^3.3.1", "primeicons": "^6.0.1", @@ -4581,6 +4582,14 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/date-fns-tz": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.1.3.tgz", + "integrity": "sha512-ZfbMu+nbzW0mEzC8VZrLiSWvUIaI3aRHeq33mTe7Y38UctKukgqPR4nTDwcwS4d64Gf8GghnVsroBuMY3eiTeA==", + "peerDependencies": { + "date-fns": "^3.0.0" + } + }, "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 f576ee1..5e6c954 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "bson": "^5.5.1", "country-flag-emoji-polyfill": "^0.1.8", "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", "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 index 71b2605..bc9f6e1 100644 --- a/frontend/src/api/fetchRoomOccupancy.ts +++ b/frontend/src/api/fetchRoomOccupancy.ts @@ -36,7 +36,7 @@ export async function fetchRoomOccupancy( return null; } const data = new Uint8Array(roomsResponse); - roomOccupancyList = BSON.deserialize(data) as RoomOccupancyList; + roomOccupancyList = RoomOccupancyList.fromJSON(BSON.deserialize(data)); return roomOccupancyList; }); diff --git a/frontend/src/components/RoomOccupationOffline.vue b/frontend/src/components/RoomOccupationOffline.vue index d9732f3..1e8b817 100644 --- a/frontend/src/components/RoomOccupationOffline.vue +++ b/frontend/src/components/RoomOccupationOffline.vue @@ -31,6 +31,7 @@ import { useQuery } from "@tanstack/vue-query"; import { watch } from "vue"; import { fetchRoomOccupancy } from "@/api/fetchRoomOccupancy"; import { isValid } from "date-fns"; +import { RoomOccupancyList } from "@/model/roomOccupancyList"; const { t } = useI18n({ useScope: "global" }); @@ -70,6 +71,22 @@ const mobilePage = inject("mobilePage") as Ref; const selectedRoom = computed(() => props.room); +/** + * Transform decoded JSON object with binary data + * to anonymized occupancy events + * @param data RoomOccupancyList with binary data + * @returns Anonymized occupancy events + */ +function transformData(data: RoomOccupancyList) { + const events = data + .decodeOccupancy(selectedRoom.value, new Date(currentDateFrom.value), new Date(currentDateTo.value)) + .map((event, index) => ({ + id: index, + event: event, + })); + return events; +} + const { data: occupations } = useQuery({ queryKey: ["roomOccupation", selectedRoom, currentDateFrom, currentDateTo], queryFn: () => @@ -77,12 +94,7 @@ const { data: occupations } = useQuery({ 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, - })), + select: (data) => transformData(data), enabled: () => selectedRoom.value !== "" && currentDateFrom.value !== "", staleTime: 5000000, // 500 seconds }); diff --git a/frontend/src/model/roomOccupancyList.test.ts b/frontend/src/model/roomOccupancyList.test.ts new file mode 100644 index 0000000..e2a4b8f --- /dev/null +++ b/frontend/src/model/roomOccupancyList.test.ts @@ -0,0 +1,651 @@ +//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 { beforeEach, describe, expect, test } from "vitest"; +import { RoomOccupancyList } from "./roomOccupancyList"; +import { Binary } from "bson"; +import { add, addHours, addMinutes, interval, subHours } from "date-fns"; +import { fromZonedTime, toZonedTime } from "date-fns-tz"; + +const testListStart = new Date("2022-01-01T00:00:00Z"); +var testList : RoomOccupancyList; //= RoomOccupancyList.fromJSON({}); +var alternating : Uint8Array = new Uint8Array(Array(4).fill(0xF0)); +var booked : Uint8Array = new Uint8Array(Array(4).fill(0xFF)); +var empty : Uint8Array = new Uint8Array(Array(4).fill(0x00)); +var counting : Uint8Array = new Uint8Array([0x00, 0x01, 0x02, 0x03]); + +const localTimezone = "Europe/Berlin"; + +describe("RoomOccupancyList", () => { + beforeEach(() => { + alternating = new Uint8Array(Array(4).fill(0xF0)); + booked = new Uint8Array(Array(4).fill(0xFF)); + empty = new Uint8Array(Array(4).fill(0x00)); + counting = new Uint8Array([0x00, 0x01, 0x02, 0x03]); + testList = RoomOccupancyList.fromJSON({ + start: testListStart, + granularity: 60, + blocks: 32, + rooms: [ + { + name: "BOOKED", + occupancy: new Binary(booked, 0), + }, + { + name: "EMPTY", + occupancy: new Binary(empty, 0), + }, + { + name: "ALTERNATING", + occupancy: new Binary(alternating, 0), + }, + { + name: "COUNTING", + occupancy: new Binary(counting, 0), + } + ] + }); + }); + + describe("getRooms", () => { + test("get rooms", () => { + // act + const rooms = testList["getRooms"](); + + // assert + expect(rooms).toEqual([ + "BOOKED", + "EMPTY", + "ALTERNATING", + "COUNTING" + ]); + }); + + test("get empty rooms", () => { + // arrange + const emptyRoomOccupancy = RoomOccupancyList.fromJSON({ + start: testListStart, + granularity: 60, + blocks: 32, + rooms: [] + }); + + // act + const rooms = emptyRoomOccupancy["getRooms"](); + + // assert + expect(rooms).toEqual([]); + }); + }) + + describe("decodeOccupancy", () => { + test("generate stubs for missing room", () => { + // arrange + + // act + const decoded = testList["decodeOccupancy"]( + "MISSING", + testListStart, + addHours(testListStart, 8), + ); + + // assert + expect(decoded).toEqual([ + { + start: "2022-01-01T06:00:00.000Z", + end: "2022-01-01T08:00:00.000Z", + rooms: "MISSING", + free: true, + stub: true + } + ]); + }); + + test("generate stubs out of range", () => { + // arrange + + // act + const decoded = testList["decodeOccupancy"]( + "BOOKED", + subHours(testListStart, 10), + testListStart, + ); + + // assert + expect(decoded).toEqual([ + { + start: "2021-12-31T14:00:00.000Z", + end: "2021-12-31T19:00:00.000Z", + rooms: "BOOKED", + free: true, + stub: true + } + ]); + }); + + test("decode occupancy in range", () => { + // arrange + // act + const decoded = testList["decodeOccupancy"]( + "BOOKED", + subHours(testListStart, 2), + addHours(testListStart, 8), + ); + + expect(decoded).toEqual([ + { + start: "2022-01-01T00:00:00.000Z", + end: "2022-01-01T08:00:00.000Z", + rooms: "BOOKED", + free: false, + stub: false + } + ]); + }); + }); + + describe("sliceOccupancy", () => { + test.each([ + booked, + empty, + alternating + ])("getCompleteOccupancy of %j", (occupancy) => { + // arrange + const startTime = new Date(testList.start); + const endTime = new Date(addHours(startTime, 32)); + const sliceInterval = interval(startTime, endTime); + + // act + const sliced = testList["sliceOccupancy"]( + sliceInterval, + occupancy + ) + + // assert + expect(sliced).toEqual({ + decodeSliceStart: startTime, + decodeSlice: new Uint8Array(occupancy), + }); + }); + + test("throws start out of bounds", () => { + // arrange + const startTime = new Date(subHours(testList.start,1)); + const endTime = new Date(addHours(startTime, 32)); + const sliceInterval = interval(startTime, endTime); + + // act and assert + expect(() => { + testList["sliceOccupancy"]( + sliceInterval, + alternating + ) + }).toThrowError(); + }); + + test("throws end out of bounds", () => { + // arrange + const startTime = new Date(testList.start); + const endTime = new Date(addHours(startTime, 33)); + const sliceInterval = interval(startTime, endTime); + + // act and assert + expect(() => { + testList["sliceOccupancy"]( + sliceInterval, + alternating + ) + }).toThrowError(); + }); + + test("range at byte boundaries", () => { + // arrange + const startTime = new Date(addHours(testList.start, 8)); + const endTime = new Date(addHours(testList.start, 24)); + const sliceInterval = interval(startTime, endTime); + + // act + const sliced = testList["sliceOccupancy"]( + sliceInterval, + counting + ) + + // assert + expect(sliced).toEqual({ + decodeSliceStart: startTime, + decodeSlice: new Uint8Array([0x01, 0x02]), + }); + }); + + test("range within byte boundaries", () => { + // arrange + const startTime = new Date(addMinutes(testListStart, 500)); + const endTime = new Date(addHours(testListStart, 15)); + const sliceInterval = interval(startTime, endTime); + + // act + const sliced = testList["sliceOccupancy"]( + sliceInterval, + counting + ) + + // assert + expect(sliced).toEqual({ + decodeSliceStart: addHours(testListStart,8), + decodeSlice: new Uint8Array([0x01]), + }); + }); + }); + + describe("getOccupancyInterval", () => { + test("get empty interval", () => { + // arrange + const emptyRoomOccupancy = RoomOccupancyList.fromJSON({ + start: testListStart, + granularity: 60, + blocks: 0, + rooms: [] + }); + + // act + const emptyInterval = emptyRoomOccupancy["getOccupancyInterval"](); + + // assert + expect(emptyInterval).toEqual(interval(testListStart, testListStart)); + }); + + test("get interval of valid room occupancy list", () => { + // act + const testInterval = testList["getOccupancyInterval"](); + + // assert + expect(testInterval).toEqual(interval(testListStart, addHours(testListStart, 32))); + }); + }); + + describe("decodeOccupancyData", () => { + test("decode occupancy without length", () => { + // arrange + // act + const decoded = RoomOccupancyList["decodeOccupancyData"]( + new Uint8Array([]), + testListStart, + 15, + "Raum" + ); + + // assert + expect(decoded).toEqual([]); + }); + + test("decode blocked occupancy", () => { + // arrange + // act + const decoded = RoomOccupancyList["decodeOccupancyData"]( + booked, + testListStart, + 15, + "BOOKED" + ); + + // assert + expect(decoded).toEqual([ + { + start: testListStart.toISOString(), + end: addHours(testListStart, 8).toISOString(), + rooms: "BOOKED", + free: false, + stub: false + } + ]); + }); + + test("decode empty occupancy", () => { + // arrange + // act + const decoded = RoomOccupancyList["decodeOccupancyData"]( + empty, + testListStart, + 15, + "BOOKED" + ); + + // assert + expect(decoded).toEqual([]); + }); + + test("decode alternating occupancy", () => { + // arrange + // act + const decoded = RoomOccupancyList["decodeOccupancyData"]( + alternating, + new Date("2024-01-01T00:00:00Z"), + 15, + "ALTERNATING" + ); + + // assert + expect(decoded).toEqual([ + { + start: "2024-01-01T00:00:00.000Z", + end: "2024-01-01T01:00:00.000Z", + rooms: "ALTERNATING", + free: false, + stub: false + }, + { + start: "2024-01-01T02:00:00.000Z", + end: "2024-01-01T03:00:00.000Z", + rooms: "ALTERNATING", + free: false, + stub: false + }, + { + start: "2024-01-01T04:00:00.000Z", + end: "2024-01-01T05:00:00.000Z", + rooms: "ALTERNATING", + free: false, + stub: false + }, + { + start: "2024-01-01T06:00:00.000Z", + end: "2024-01-01T07:00:00.000Z", + rooms: "ALTERNATING", + free: false, + stub: false + } + ]); + }); + }); + + describe("generateStubEvents", () => { + test("no events if negative range", () => { + // arrange + const startTime = new Date("2022-01-01T00:00:00Z"); + const endTime = new Date("2021-01-01T00:00:00Z"); + + // act + const stubEvents = RoomOccupancyList["generateStubEvents"]( + "ROOM", + startTime, + endTime + ); + + // assert + expect(stubEvents).toEqual([]); + }); + + test("no events if start and end the same", () => { + // arrange + const startTime = new Date("2022-01-01T00:00:00Z"); + const endTime = new Date("2022-01-01T00:00:00Z"); + + // act + const stubEvents = RoomOccupancyList["generateStubEvents"]( + "ROOM", + startTime, + endTime + ); + + // assert + expect(stubEvents).toEqual([]); + }); + + test("generate week", () => { + // arrange + const startTime = new Date("2022-01-01T12:00:00Z"); + const endTime = new Date("2022-01-07T12:30:00Z"); + + // act + const stubEvents = RoomOccupancyList["generateStubEvents"]( + "ROOM", + startTime, + endTime + ); + + // assert + expect(stubEvents).toEqual([ + { + start: "2022-01-01T12:00:00.000Z", + end: "2022-01-01T19:00:00.000Z", + rooms: "ROOM", + free: true, + stub: true, + }, + { + start: "2022-01-02T06:00:00.000Z", + end: "2022-01-02T19:00:00.000Z", + rooms: "ROOM", + free: true, + stub: true, + }, + { + start: "2022-01-03T06:00:00.000Z", + end: "2022-01-03T19:00:00.000Z", + rooms: "ROOM", + free: true, + stub: true, + }, + { + start: "2022-01-04T06:00:00.000Z", + end: "2022-01-04T19:00:00.000Z", + rooms: "ROOM", + free: true, + stub: true, + }, + { + start: "2022-01-05T06:00:00.000Z", + end: "2022-01-05T19:00:00.000Z", + rooms: "ROOM", + free: true, + stub: true, + }, + { + start: "2022-01-06T06:00:00.000Z", + end: "2022-01-06T19:00:00.000Z", + rooms: "ROOM", + free: true, + stub: true, + }, + { + start: "2022-01-07T06:00:00.000Z", + end: "2022-01-07T12:30:00.000Z", + rooms: "ROOM", + free: true, + stub: true, + } + ]); + }); + + test("generate day", () => { + // arrange + const startTime = new Date("2022-01-01T16:00:00Z"); + const endTime = new Date("2022-01-01T19:00:00Z"); + + // act + const stubEvents = RoomOccupancyList["generateStubEvents"]( + "ROOM", + startTime, + endTime + ); + + // assert + expect(stubEvents).toEqual([ + { + start: "2022-01-01T16:00:00.000Z", + end: "2022-01-01T19:00:00.000Z", + rooms: "ROOM", + free: true, + stub: true, + } + ]); + }); + }); + + describe("shiftTimeForwardInsideWorkday", () => { + test("shift time to next day", () => { + // arrange + const startTime = new Date("2022-01-01T20:00:00Z"); + + // act + const shiftedTime = RoomOccupancyList["shiftTimeForwardInsideWorkday"](startTime); + + // assert + expect(toZonedTime(shiftedTime, localTimezone)).toEqual(new Date("2022-01-01T23:00:00Z")); + }); + + test("don't shift time on the same day", () => { + // arrange + const startTime = new Date("2022-01-02T01:00:00Z"); + + // act + const shiftedTime = RoomOccupancyList["shiftTimeForwardInsideWorkday"](startTime); + + // assert + expect(toZonedTime(shiftedTime, localTimezone)).toEqual(new Date("2022-01-02T01:00:00Z")); + }); + + test("don't shift if already inside workday", () => { + // arrange + const startTime = new Date("2022-01-02T12:30:00Z"); + + // act + const shiftedTime = RoomOccupancyList["shiftTimeForwardInsideWorkday"](startTime); + + // assert + expect(toZonedTime(shiftedTime, localTimezone)).toEqual(new Date("2022-01-02T12:30:00Z")); + }); + }); + + describe("shiftTimeBackwardInsideWorkday", () => { + test("shift time to last day", () => { + // arrange + const startTime = new Date("2022-01-02T05:30:00Z"); + + // act + const shiftedTime = RoomOccupancyList["shiftTimeBackwardInsideWorkday"](startTime); + + // assert + expect(toZonedTime(shiftedTime, localTimezone)).toEqual(new Date("2022-01-01T22:59:59.999Z")); + }); + + test("don't shift time on the same day", () => { + // arrange + const startTime = new Date("2022-01-02T22:00:00Z"); + + // act + const shiftedTime = RoomOccupancyList["shiftTimeBackwardInsideWorkday"](startTime); + + // assert + expect(toZonedTime(shiftedTime, localTimezone)).toEqual(new Date("2022-01-02T22:00:00Z")); + }); + + test("don't shift if already inside workday", () => { + // arrange + const startTime = new Date("2022-01-02T12:30:00Z"); + + // act + const shiftedTime = RoomOccupancyList["shiftTimeBackwardInsideWorkday"](startTime); + + // assert + expect(toZonedTime(shiftedTime, localTimezone)).toEqual(new Date("2022-01-02T12:30:00Z")); + }); + }); + + describe("setTimeOfDay", () => { + test("set time to 00:00:00", () => { + // arrange + const startTime = new Date("2022-01-02T12:30:00Z"); + + // act + const shiftedTime = RoomOccupancyList["setTimeOfDay"](startTime, {}); + + // assert + expect(shiftedTime).toEqual(new Date("2022-01-01T23:00:00Z")); + }); + + test("set time to 23:59:59", () => { + // arrange + const startTime = new Date("2022-06-02T12:30:00Z"); + + // act + const shiftedTime = RoomOccupancyList["setTimeOfDay"](startTime, {hours: 23, minutes: 59, seconds: 59}); + + // assert + expect(shiftedTime).toEqual(new Date("2022-06-02T21:59:59Z")); + }); + + test("set same time", () => { + // arrange + const startTime = new Date("2022-01-02T12:30:00Z"); + + // act + const shiftedTime = RoomOccupancyList["setTimeOfDay"](startTime, {hours: 13, minutes: 30, seconds: 0}); + + // assert + expect(shiftedTime).toEqual(new Date("2022-01-02T12:30:00Z")); + }); + }); + + describe("startOfDay", () => { + test("in the winter", () => { + // arrange + const startTime = new Date("2022-01-02T12:30:00Z"); + + // act + const shiftedTime = RoomOccupancyList["startOfDay"](startTime); + + // assert + expect(shiftedTime).toEqual(new Date("2022-01-01T23:00:00Z")); + }); + + test("in the summer", () => { + // arrange + const startTime = new Date("2022-06-02T12:30:00Z"); + + // act + const shiftedTime = RoomOccupancyList["startOfDay"](startTime); + + // assert + expect(shiftedTime).toEqual(new Date("2022-06-01:22:00Z")); + }); + }); + + describe("endOfDay", () => { + test("in the winter", () => { + // arrange + const startTime = new Date("2022-01-02T12:30:00Z"); + + // act + const shiftedTime = RoomOccupancyList["startOfDay"](startTime); + + // assert + expect(shiftedTime).toEqual(new Date("2022-01-01T23:00:00Z")); + }); + + test("in the summer", () => { + // arrange + const startTime = new Date("2022-06-02T12:30:00Z"); + + // act + const shiftedTime = RoomOccupancyList["startOfDay"](startTime); + + // assert + expect(shiftedTime).toEqual(new Date("2022-06-01:22:00Z")); + }); + }); + +}); diff --git a/frontend/src/model/roomOccupancyList.ts b/frontend/src/model/roomOccupancyList.ts index 12fc00b..c18f629 100644 --- a/frontend/src/model/roomOccupancyList.ts +++ b/frontend/src/model/roomOccupancyList.ts @@ -15,8 +15,16 @@ //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"; +import { AnonymizedOccupancy } from "./event"; +import { Duration, NormalizedInterval, add, addDays, addMinutes, clamp, differenceInMinutes, eachDayOfInterval, endOfDay, interval, isAfter, isBefore, isEqual, max, min, startOfDay, subDays } from "date-fns"; +import { fromZonedTime, toZonedTime } from "date-fns-tz"; + +/// The start time of the day. 07:00 +const START_OF_WORKDAY : Duration = {hours: 7}; +/// The end time of the day. 20:00 +const END_OF_WORKDAY : Duration = {hours: 20}; +/// The timezone of the data (Leipzig) +const TIMEZONE = "Europe/Berlin"; /** * Represents the occupancy of a single room. @@ -29,11 +37,6 @@ class RoomOccupancy { ) {} } -/// 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. @@ -65,7 +68,6 @@ export class RoomOccupancyList { * @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) { @@ -77,15 +79,10 @@ export class RoomOccupancyList { // 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); + let {decodeSliceStart, decodeSlice} = this.sliceOccupancy( + decodeInterval, + roomOccupancy.occupancy.buffer + ); // Decode the occupancy data occupancyList.push(...RoomOccupancyList.decodeOccupancyData(new Uint8Array(decodeSlice), decodeSliceStart, this.granularity, room)); @@ -102,6 +99,36 @@ export class RoomOccupancyList { return occupancyList; } + /** + * Slice the important parts of the occupancy list for a given time range. + * @param from the start of the time range. + * @param to the end of the time range. + * @returns a new occupancy byte array with the starting time of the first byte + * @throws an error, if the selected time range is outside of the occupancy list. + */ + private sliceOccupancy(decodeInterval : NormalizedInterval, occupancy : Uint8Array) : {decodeSliceStart: Date, decodeSlice: Uint8Array} { + // Calculate the slice of bytes, that are needed to decode the requested time range + // Note: differenceInMinutes calculates (left - right) + let minutesFromStart = differenceInMinutes(decodeInterval.start, this.start); + let minutesToEnd = differenceInMinutes(decodeInterval.end, this.start); + + let firstByte = Math.floor(minutesFromStart / this.granularity / 8); + let lastByte = Math.ceil(minutesToEnd / this.granularity / 8); + + // check if firstByte and lastByte are within the bounds of the occupancy array and throw an error if not + if ( + firstByte < 0 || firstByte >= occupancy.length || + lastByte < 0 || lastByte > occupancy.length + ) { + throw new Error("Requested time range is outside of the occupancy list."); + } + + let decodeSliceStart = addMinutes(this.start, firstByte * 8 * this.granularity); + let decodeSlice = occupancy.buffer.slice(firstByte, lastByte); + + return { decodeSliceStart, decodeSlice: new Uint8Array(decodeSlice) }; + } + /** * Get the decoded time interval within the current occupancy list. * @returns the interval of the occupancy list. @@ -128,7 +155,7 @@ export class RoomOccupancyList { // 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; + let isOccupied = (byte & (1 << (7-bit_i))) !== 0; if (firstOccupancyBit === null && isOccupied) { firstOccupancyBit = byte_i * 8 + bit_i; @@ -141,7 +168,8 @@ export class RoomOccupancyList { startTime.toISOString(), endTime.toISOString(), room, - true + false, + false, )); firstOccupancyBit = null; @@ -158,7 +186,8 @@ export class RoomOccupancyList { startTime.toISOString(), endTime.toISOString(), room, - true + false, + false, )); } @@ -195,6 +224,21 @@ export class RoomOccupancyList { }); } + /** + * Generate RoomOccupancyList from plain JSON object. + * For performance no deep copy is made, reference attributes will be shared. + * @param json the JS object to read from. + * @returns a RoomOccupancyList object. + */ + public static fromJSON(json : any) : RoomOccupancyList { + return new RoomOccupancyList( + json.start, + json.granularity, + json.blocks, + json.rooms.map((room : any) => new RoomOccupancy(room.name, room.occupancy) + )); + } + /** * Shift the time forward to the start of the next day if it is after the end of the current day. * @@ -205,7 +249,7 @@ export class RoomOccupancyList { // 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)); + return RoomOccupancyList.startOfDay(addDays(date,1)); } else { return date; } @@ -221,20 +265,41 @@ export class RoomOccupancyList { // 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)); + return RoomOccupancyList.endOfDay(subDays(date,1)); } else { return date; } } /** - * Sets the date to the specified time after 00:00. + * Sets the date to the specified time after 00:00 in the current local timezone. * @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); + return add(RoomOccupancyList.startOfDay(date), time); } + + /** + * Start of day in server timezone defined as const TIMEZONE. + * @param date + * @returns the start of the day. + */ + private static startOfDay(date : Date) : Date { + const dateInLocalTimezone = toZonedTime(date, TIMEZONE); + return fromZonedTime(startOfDay(dateInLocalTimezone), TIMEZONE); + } + + /** + * End of day in server timezone defined as const TIMEZONE. + * @param date + * @returns the end of the day. + */ + private static endOfDay(date : Date) : Date { + const dateInLocalTimezone = toZonedTime(date, TIMEZONE); + return fromZonedTime(endOfDay(dateInLocalTimezone), TIMEZONE); + } + }