Files
htwkalender-pwa/frontend/src/model/roomOccupancyList.ts
2024-07-24 11:25:59 +02:00

387 lines
12 KiB
TypeScript

//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, Document} from "bson";
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.
* occupancy is a binary string, where each bit represents a block of time.
*/
class RoomOccupancy {
constructor(
public name: string,
public occupancy: Binary,
) {}
}
/**
* 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[] {
const roomOccupancy = this.rooms.find((r) => r.name === room);
// Get start and end of decoded time range (within encoded list and requested range)
const decodeInterval = interval(
clamp(from, this.getOccupancyInterval()),
clamp(to, this.getOccupancyInterval()),
);
// if the room is not in the list or the time range is empty, return stub events
if (
roomOccupancy === undefined ||
isEqual(decodeInterval.start, decodeInterval.end)
) {
return RoomOccupancyList.generateStubEvents(room, from, to);
}
const occupancyList = [];
const { decodeSliceStart, decodeSlice } = this.sliceOccupancy(
decodeInterval,
roomOccupancy.occupancy.buffer,
);
// 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;
}
/**
* Slice the important parts of the occupancy list for a given 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.
* @param decodeInterval
* @param occupancy
*/
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)
const minutesFromStart = differenceInMinutes(
decodeInterval.start,
this.start,
);
const minutesToEnd = differenceInMinutes(decodeInterval.end, this.start);
const firstByte = Math.floor(minutesFromStart / this.granularity / 8);
const 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.");
}
const decodeSliceStart = addMinutes(
this.start,
firstByte * 8 * this.granularity,
);
const 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.
*/
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[] {
const 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++) {
const byte = occupancy[byte_i];
// Iterate over all bits in the current byte
for (let bit_i = 0; bit_i < 8; bit_i++) {
const isOccupied = (byte & (1 << (7 - bit_i))) !== 0;
const calculateOccupancyBitIndex = (byte_i: number, bit_i: number) => byte_i * 8 + bit_i;
if(firstOccupancyBit === null){
if (isOccupied) {
firstOccupancyBit = calculateOccupancyBitIndex(byte_i, bit_i);
}
} else {
if (!isOccupied) {
const startTime = addMinutes(start, firstOccupancyBit * granularity);
const endTime = addMinutes(start, calculateOccupancyBitIndex(byte_i, 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,
false,
false,
),
);
firstOccupancyBit = null;
}
}
}
}
// add last event if it is still ongoing
if (firstOccupancyBit !== null) {
const startTime = addMinutes(start, firstOccupancyBit * granularity);
const endTime = addMinutes(start, occupancy.length * 8 * granularity);
occupancyList.push(
new AnonymizedOccupancy(
startTime.toISOString(),
endTime.toISOString(),
room,
false,
false,
),
);
}
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 rooms
* @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) => {
const startTime = max([
from,
RoomOccupancyList.setTimeOfDay(day, START_OF_WORKDAY),
]);
const endTime = min([
to,
RoomOccupancyList.setTimeOfDay(day, END_OF_WORKDAY),
]);
return new AnonymizedOccupancy(
startTime.toISOString(),
endTime.toISOString(),
rooms,
true,
true,
);
});
}
/**
* 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: Document): RoomOccupancyList {
return new RoomOccupancyList(
json.start,
json.granularity,
json.blocks,
json.rooms.map(
(room: { name: string, occupancy: Binary }) => 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.
*
* @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 RoomOccupancyList.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 RoomOccupancyList.endOfDay(subDays(date, 1));
} else {
return date;
}
}
/**
* 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(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);
}
}