mirror of
https://gitlab.dit.htwk-leipzig.de/htwk-software/htwkalender-pwa.git
synced 2025-07-16 09:38:51 +02:00
387 lines
12 KiB
TypeScript
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);
|
|
}
|
|
}
|