Merge branch '3-semi-offline-room-finder' into 'main'

Resolve "semi offline room finder"

See merge request htwk-software/htwkalender-pwa!4
This commit is contained in:
Elmar Kresse
2024-06-03 08:34:31 +00:00
22 changed files with 1867 additions and 10 deletions

View File

@ -34,7 +34,7 @@ import (
"go.mongodb.org/mongo-driver/bson"
)
const RoomOccupancyGranularity = 5
const RoomOccupancyGranularity = 15
func AddRoutes(app *pocketbase.PocketBase) {

View File

@ -18,7 +18,10 @@
"@tanstack/vue-query-devtools": "^5.37.1",
"@types/ical": "^0.8.3",
"@vueuse/core": "^10.9.0",
"bson": "^5.5.1",
"country-flag-emoji-polyfill": "^0.1.8",
"date-fns": "^3.6.0",
"date-fns-tz": "^3.1.3",
"ical.js": "^1.5.0",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
@ -4129,6 +4132,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",
@ -4462,6 +4473,23 @@
"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/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",

View File

@ -10,7 +10,7 @@
"lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src",
"lint-no-fix": "eslint --ext .js,.vue --ignore-path .gitignore src",
"format": "prettier . --write",
"test": "vitest"
"test": "vitest --config vitest.config.ts"
},
"dependencies": {
"@fullcalendar/core": "^6.1.13",
@ -23,7 +23,10 @@
"@tanstack/vue-query-devtools": "^5.37.1",
"@types/ical": "^0.8.3",
"@vueuse/core": "^10.9.0",
"bson": "^5.5.1",
"country-flag-emoji-polyfill": "^0.1.8",
"date-fns": "^3.6.0",
"date-fns-tz": "^3.1.3",
"ical.js": "^1.5.0",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",

View File

@ -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 <https://www.gnu.org/licenses/>.
import { BSON } from "bson";
import { RoomOccupancyList } from "@/model/roomOccupancyList.ts";
export async function fetchRoomOccupancy(
from_date: string,
to_date: string,
): Promise<RoomOccupancyList> {
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 = RoomOccupancyList.fromJSON(BSON.deserialize(data));
return roomOccupancyList;
});
return roomOccupancyList;
}

View File

@ -23,11 +23,13 @@ import { useI18n } from "vue-i18n";
import { usePrimeVue } from "primevue/config";
import primeVue_de from "@/i18n/translations/primevue/prime_vue_local_de.json";
import primeVue_en from "@/i18n/translations/primevue/prime_vue_local_en.json";
import primeVue_ja from "@/i18n/translations/primevue/prime_vue_local_ja.json";
const { t } = useI18n({ useScope: "global" });
const countries = computed(() => [
{ name: t("english"), code: "en", icon: "🇬🇧" },
{ name: t("german"), code: "de", icon: "🇩🇪" },
{ name: t("japanese"), code: "ja", icon: "🇯🇵" },
]);
function displayIcon(code: string) {
@ -45,6 +47,8 @@ function updateLocale(locale: string) {
if (locale === "de") {
primeVueConfig.config.locale = primeVue_de;
} else if (locale === "ja") {
primeVueConfig.config.locale = primeVue_ja;
} else {
primeVueConfig.config.locale = primeVue_en;
}

View File

@ -63,6 +63,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",
}
],
},
{

View File

@ -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();
}
}
}

View File

@ -0,0 +1,215 @@
<!--
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/>.
-->
<script lang="ts" setup>
import FullCalendar from "@fullcalendar/vue3";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import { computed, ComputedRef, inject, ref, Ref } from "vue";
import { CalendarOptions, DatesSetArg, EventInput } from "@fullcalendar/core";
import { useI18n } from "vue-i18n";
import allLocales from "@fullcalendar/core/locales-all";
import router from "@/router";
import { formatYearMonthDay } from "@/helpers/dates";
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" });
const props = defineProps({
room: {
type: String,
required: true,
},
});
const date: Ref<Date> = ref(new Date());
// Set the selected date from the URL
function setDateFromQuery() {
const queryDate = router.currentRoute.value.query.date;
if (typeof queryDate === "string") {
if (queryDate === formatYearMonthDay(date.value)) {
return;
}
// date is in format like YYYYMMDD
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();
}
}
}
setDateFromQuery();
const currentDateFrom: Ref<string> = ref("");
const currentDateTo: Ref<string> = ref("");
const mobilePage = inject("mobilePage") as Ref<boolean>;
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: () =>
fetchRoomOccupancy(
new Date(currentDateFrom.value).toISOString(),
new Date(currentDateTo.value).toISOString()
),
select: (data) => transformData(data),
enabled: () => selectedRoom.value !== "" && currentDateFrom.value !== "",
staleTime: 5000000, // 500 seconds
});
watch(occupations, () => fullCalendar.value?.getApi().refetchEvents());
const fullCalendar = ref<InstanceType<typeof FullCalendar>>();
const calendarOptions: ComputedRef<CalendarOptions> = computed(() => ({
locales: allLocales,
locale: t("languageCode"),
plugins: [dayGridPlugin, interactionPlugin, timeGridPlugin],
// local debugging of mobilePage variable in object creation on ternary expression
initialView: mobilePage.value ? "Day" : "week",
initialDate: date.value,
dayHeaderFormat: { weekday: "short", omitCommas: true },
slotDuration: "00:15:00",
eventTimeFormat: {
hour: "2-digit",
minute: "2-digit",
hour12: false,
},
height: "auto",
views: {
week: {
type: "timeGrid",
slotLabelFormat: {
hour: "numeric",
minute: "2-digit",
omitZeroMinute: false,
meridiem: false,
hour12: false,
},
dateAlignment: "week",
titleFormat: { month: "short", day: "numeric" },
slotMinTime: "06:00:00",
slotMaxTime: "22:00:00",
duration: { days: 7 },
firstDay: 1,
allDaySlot: false,
hiddenDays: [0],
},
Day: {
type: "timeGrid",
slotLabelFormat: {
hour: "numeric",
minute: "2-digit",
omitZeroMinute: false,
meridiem: false,
hour12: false,
},
titleFormat: { month: "short", day: "numeric" },
slotMinTime: "06:00:00",
slotMaxTime: "22:00:00",
duration: { days: 1 },
allDaySlot: false,
hiddenDays: [0],
},
},
headerToolbar: {
end: "prev,next today",
center: "title",
start: "week,Day",
},
datesSet: function (dateInfo: DatesSetArg) {
const view = dateInfo.view;
const offset = new Date().getTimezoneOffset();
const startDate = new Date(view.activeStart.getTime() - offset * 60 * 1000);
const endDate = new Date(view.activeEnd.getTime() - offset * 60 * 1000);
currentDateFrom.value = startDate.toISOString().split("T")[0];
currentDateTo.value = endDate.toISOString().split("T")[0];
router.replace({
query: {
...router.currentRoute.value.query,
date: formatYearMonthDay(endDate),
},
});
},
events: function (
_info: unknown,
successCallback: (events: EventInput[]) => void,
) {
if (!occupations.value) return;
successCallback(
occupations.value.map((event) => {
return {
id: event.id.toString(),
start: event.event.start,
end: event.event.end,
color: event.event.free
? "var(--htwk-gruen-500)"
: "var(--htwk-grau-60-500)",
textColor: event.event.free
? "var(--green-50)"
: "white",
title: event.event.stub
? t("roomFinderPage.stub")
: event.event.free
? t("roomFinderPage.available")
: t("roomFinderPage.occupied"),
} as EventInput;
}),
);
},
}));
</script>
<template>
<FullCalendar ref="fullCalendar" :options="calendarOptions" />
</template>
<style scoped>
:deep(.fc-toolbar.fc-header-toolbar) {
flex-wrap: wrap;
justify-content: space-between;
gap: 0.5rem;
}
</style>

View File

@ -17,6 +17,7 @@
import { createI18n } from "vue-i18n";
import en from "./translations/en.json";
import de from "./translations/de.json";
import ja from "./translations/ja.json";
import localeStore from "../store/localeStore.ts";
// Private instance of VueI18n object
@ -31,6 +32,7 @@ function setup() {
messages: {
en,
de,
ja,
},
});
return _i18n;

View File

@ -10,6 +10,7 @@
"privacy": "Datenschutz",
"english": "Englisch",
"german": "Deutsch",
"japanese": "Japanisch",
"courseSelection": {
"headline": "Willkommen beim HTWKalender",
"winterSemester": "Wintersemester",
@ -27,7 +28,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",

View File

@ -10,6 +10,7 @@
"privacy": "privacy",
"english": "English",
"german": "German",
"japanese" : "Japanese",
"courseSelection": {
"headline": "welcome to HTWKalender",
"winterSemester": "winter semester",
@ -27,7 +28,8 @@
"dropDownSelect": "please select a room",
"noRoomsAvailable": "no rooms listed",
"available": "available",
"occupied": "occupied"
"occupied": "occupied",
"stub": "please check online"
},
"freeRooms": {
"freeRooms": "free rooms",
@ -138,8 +140,8 @@
"copyToastError": "error",
"copyToastErrorDetail": "could not copy link to clipboard",
"copyToClipboard": "copy to clipboard",
"toGoogleCalendar": "to Google Calendar",
"toMicrosoftCalendar": "to Microsoft Calendar",
"toGoogleCalendar": "Google Calendar",
"toMicrosoftCalendar": "Microsoft Calendar",
"toHTWKalendar": "HTWKalender"
},
"calendarPreview": {

View File

@ -0,0 +1,253 @@
{
"languageCode": "ja",
"createCalendar": "カレンダーを作成",
"editCalendar": "カレンダーを編集",
"userCalendar": "ユーザーカレンダー",
"calendar": "カレンダー",
"rooms": "部屋",
"faq": "よくある質問",
"imprint": "インプリント",
"privacy": "プライバシー",
"english": "英語",
"german": "ドイツ語",
"japanese": "日本語",
"courseSelection": {
"headline": "HTWカレンダーへようこそ",
"winterSemester": "冬学期",
"summerSemester": "夏学期",
"subTitle": "コースと学期を選択してください",
"nextStep": "次のステップ",
"courseDropDown": "コースを選択してください",
"noCoursesAvailable": "利用可能なコースがありません",
"semesterDropDown": "学期を選択してください"
},
"roomFinderPage": {
"roomSchedule": "部屋の占有状況",
"headline": "部屋の占有計画",
"detail": "占有状況を表示するために部屋を選択してください。",
"dropDownSelect": "部屋を選択してください",
"noRoomsAvailable": "利用可能な部屋がありません",
"available": "利用可能",
"occupied": "占有中",
"stub": "オンラインでご確認ください。"
},
"freeRooms": {
"freeRooms": "空いている部屋",
"detail": "占有されていない部屋を表示するために期間を選択してください。",
"searchByRoom": "部屋で検索",
"pleaseSelectDate": "日付を選択してください",
"room": "部屋",
"search": "検索",
"viewOccupancy": "占有状況を見る"
},
"moduleSelection": {
"selectAll": "すべて選択",
"deselectAll": "すべて選択解除",
"selected": "選択済み",
"unselected": "未選択",
"noModulesAvailable": "利用可能なモジュールがありません",
"modules": "モジュール"
},
"moduleInformation": {
"course": "コース",
"person": "講師",
"semester": "学期",
"module": "モジュール",
"notes": "メモ",
"day": "日",
"start": "開始",
"end": "終了",
"room": "部屋",
"type": "タイプ",
"week": "週",
"nthWeek": "{count}週",
"weekday": {
"Montag": "月曜日",
"Dienstag": "火曜日",
"Mittwoch": "水曜日",
"Donnerstag": "木曜日",
"Freitag": "金曜日",
"Samstag": "土曜日",
"Sonntag": "日曜日"
}
},
"editCalendarView": {
"error": "エラー",
"invalidToken": "無効なトークン",
"headline": "HTWカレンダーを編集",
"subTitle": "リンクまたはカレントークンを入力してください",
"loadCalendar": "カレンダーを読み込む",
"noCalendarFound": "カレンダーが見つかりません",
"save": "保存",
"delete": "削除",
"addModules": "モジュールを追加",
"dialog": {
"headline": "カレンダーを削除",
"subTitle": "カレンダーを削除してもよろしいですか?この操作は元に戻せません。カレンダーアプリからカレンダーを削除してください。",
"delete": "削除"
},
"toast": {
"success": "成功",
"error": "エラー",
"successDetail": "カレンダーが正常に削除されました",
"errorDetail": "カレンダーを削除できませんでした"
}
},
"additionalModules": {
"subTitle": "コースの通常の学期には記載されていない追加モジュールを選択してください",
"dropDown": "追加モジュールを選択",
"module": "モジュール",
"modules": "モジュール",
"footerModulesSelected": "{count}個のモジュールが選択されました",
"nextStep": "次のステップ",
"professor": "教授",
"course": "コース",
"info": "情報",
"info-long": "情報",
"paginator": {
"from": "",
"to": " から ",
"of": " の "
},
"eventType": "イベントタイプ"
},
"renameModules": {
"reminder": "リマインダー",
"enableAllNotifications": "すべての通知を有効にする",
"subTitle": "選択したモジュールを好みに合わせて設定",
"nextStep": "保存",
"error": "エラー",
"TooManyRequests": "短時間でのカレンダー作成リクエストが多すぎます"
},
"moduleTemplateDialog": {
"explanationOne": "ここで、モジュールを好みに合わせて名前を変更できます。これはカレンダーのイベント名になります。",
"explanationTwo": "さらに、各モジュールの通知を切り替えることができます。有効にすると、イベント開始の15分前に通知されます。",
"tableDescription": "モジュール名に次のプレースホルダーを使用できます:",
"placeholder": "プレースホルダー",
"description": "説明",
"examples": "例",
"moduleConfiguration": "モジュール設定",
"mandatory": "必修",
"optional": "選択",
"lecture": "講義",
"seminar": "セミナー",
"exam": "試験/インターンシッププロジェクト",
"eventTyp": "イベントタイプ"
},
"calendarLink": {
"copyToastNotification": "リンクをクリップボードにコピーしました",
"copyToastSummary": "情報",
"copyToastError": "エラー",
"copyToastErrorDetail": "リンクをクリップボードにコピーできませんでした",
"copyToClipboard": "クリップボードにコピー",
"toGoogleCalendar": "Googleカレンダー",
"toMicrosoftCalendar": "Microsoftカレンダー",
"toHTWKalendar": "HTWカレンダー"
},
"calendarPreview": {
"preview": "プレビュー",
"preview-long": "カレンダープレビュー",
"module": "モジュール",
"course": "コース"
},
"faqView": {
"headline": "よくある質問",
"firstQuestion": "HTWカレンダーを使用してカレンダーを作成するにはどうすればよいですか",
"firstAnswer": "このウェブサイトを使用すると、HTWKの時間割をお気に入りのカレンダープログラムOutlook、Googleカレンダーなどに統合できます。",
"secondQuestion": "具体的にはどのように機能しますか?",
"secondAnswer": "学習コースと希望する学期を選択し、それに関連するモジュールを選択できます。「祝日」や履修しない選択科目などのモジュールを個別に選択または選択解除できます。最後のステップで、作成されたカレンダーの対応するトークンを含むリンクが表示されます。このリンクを使用して、カレンダーを購読またはダウンロードできます。",
"thirdQuestion": "カレンダーを購読するにはどうすればよいですか?",
"thirdAnswer": {
"tabTitle": "Googleカレンダー",
"google": {
"first": "カレンダーを作成してリンクをコピーします。",
"second": "Googleカレンダーの左側のサイドバーに「その他のカレンダー」というセクションがあります。そこにあるテキストの右側にある小さな矢印アイコンをクリックします。表示されるメニューで「URLで追加」をクリックします。",
"third": "コピーしたカレンダーリンクを貼り付けて「カレンダーを追加」をクリックすると完了です。"
},
"microsoft_outlook": {
"title": "Microsoft Outlook",
"outlook_2010": {
"title": "Outlook 2010で:",
"first": "カレンダーを作成してリンクをコピーします。",
"second": "「ホーム」タブをクリックします。",
"third": "「カレンダーの管理」セクションに「カレンダーを開く」ボタンがあります。",
"fourth": "表示されるコンテキストメニューで「インターネットから」をクリックします。",
"fifth": "表示されるウィンドウにリンクを貼り付け、「OK」をクリックして確認します。追加の手順で購読を確認する必要があるかもしれません。これで完了です。"
},
"outlook_2007": {
"title": "Outlook 2007で:",
"first": "カレンダーを作成してリンクをコピーします。",
"second": "「ツール」メニューで「アカウント設定」を見つけます。",
"third": "「インターネットカレンダー」タブをクリックします。",
"fourth": "「新規」をクリックします。",
"fifth": "表示されるウィンドウにリンクを貼り付けます。ただし、「http://」を「webcal://」に置き換える必要があります。",
"sixth": "少なくとも、次回の起動時にカレンダーが更新され、利用可能になります。"
}
},
"apple_osx": {
"title": "カレンダーOS X",
"first": "カレンダーを作成してリンクをコピーします。",
"second": "「ファイル」メニューで「新規カレンダー購読」を見つけますSnow Leopardでは「購読」。",
"third": "表示されるウィンドウにコピーしたリンクを貼り付け、「購読」をクリックします。",
"fourth": "カレンダーに名前を付け、更新頻度を設定できます。iPhoneなどでiCloudを使用している場合、「場所」の下に「iCloud」を選択することをお勧めします。これにより、追加の操作なしで常に予定表が利用可能になります。"
},
"thunderbird": {
"title": "Thunderbird",
"one": "カレンダーを作成してリンクをコピーします。",
"two": "「イベントとタスク」メニューで「カレンダー」を選択します。",
"three": "左側にカレンダーの概要が表示されます。この領域で右クリックし、表示されるコンテキストメニューで「新しいカレンダー」をクリックします。",
"four": "「コンピューター上」または「ネットワーク上」を選択できます。後者を選択して「続行」をクリックします。",
"five": "次のウィンドウで「形式」を「iCalendar」のままにします。",
"six": "「場所」にコピーしたカレンダーリンクを貼り付けます。",
"seven": "名前を付け、必要に応じて追加の設定を行えます。"
},
"iphone": {
"title": "iPhone",
"description": "iOSで最も簡単な方法は、iCloud同期を通じて行う方法ですOS Xカレンダーの指示を参照。別の方法もあります:",
"one": "カレンダーを作成してリンクをコピーします。",
"two": "「設定」に移動します。",
"three": "「メール、連絡先、カレンダー」を選択します。",
"four": "「アカウント追加」を選択します。",
"five": "下部にある「その他」をタップします。",
"six": "「購読カレンダーを追加」が最後のオプションです。それをタップします。",
"seven": "表示されるテキストフィールドにカレンダーリンクを貼り付け、上部の「次へ」を押します。",
"eight": "「説明」を入力できます。その他の設定はそのままにします。",
"nine": "しばらくすると、購読カレンダーがカレンダーアプリに表示されます。"
},
"android": {
"description": "Androidでは、Googleカレンダーとの同期が最も簡単なオプションです。Googleカレンダーの指示を参照してカレンダーを購読する方法を確認してください。"
},
"windows_phone": {
"description": "Windows Phoneでは、Outlook.comを通じた同期が最も簡単な方法です:",
"one": "カレンダーを作成してリンクをコピーします。",
"two": "Outlook.comにログインし、カレンダーに移動します。",
"three": "「購読」をクリックします。",
"four": "「公開カレンダーを購読」を選択します。",
"five": "「カレンダーURL」にカレンダーリンクを貼り付けます。",
"six": "他の設定を希望に応じて行います。",
"seven": "「カレンダーを購読」をクリックします。",
"eight": "Windows Phoneデバイスは同じOutlook.comユーザーアカウントでログインしている必要があります。この後、カレンダーの同期が自動的に行われるはずです。"
}
},
"fourthQuestion": "カレンダーを購読する?ダウンロードしたい!",
"fourthAnswer": "もちろん可能です。個人の時間割が作成された後、ダウンロードするオプションがあります。また、生成されたリンクをブラウザで開くだけでいつでもダウンロードできます。ダウンロードしたカレンダーや時間割は更新されないことを覚えておいてください。更新はカレンダーを購読する場合にのみ可能です。",
"fifthQuestion": "他の学科の追加モジュールを履修し、それを時間割に追加したい。",
"fifthAnswer": "モジュールハンドブックからコースに提供されているモジュールを選択する機会があった後、2ページ目にリダイレクトされます。そこでは、他の学科から追加のモジュールを学習計画に追加することができます。",
"sixthQuestion": "カレンダーの更新に問題がありますか?",
"sixthAnswer": "これは、おそらくダウンロードしたためであり、購読していないためです。購読したカレンダーのみが自動的に更新されます。サーバーのカレンダー更新は毎日0時から3時間ごとに行われます。これにより、HTWKからのすべての変更が反映されます。",
"seventhQuestion": "私の時間割やそのリンクはどのくらい有効ですか?",
"seventhAnswer": "時間割は選択された学期のみに有効です。選択科目や計画の変更による影響があるためです。しかし、リンクは無期限に有効であり、いつでも時間割を更新できます。",
"eighthQuestion": "費用と開発?",
"eighthAnswer": "開発はコミュニティによるアクティブなGitプロジェクトとして管理されるべきです。HTWカレンダーの無料バージョンはFSR IMNのサーバーに内部でホストされ、すべてのHTWK学生に無料で提供されます。",
"ninthQuestion": "ソースコードはどこにありますか?",
"ninthAnswer": "貢献したい場合は、HTWKの学生であればいつでもできます。ソースコードは以下にあります",
"notFound": "探しているものが見つかりませんか?",
"contact": "連絡する"
},
"userCalender": {
"headline": "ユーザーカレンダー",
"subTitle": "ここでは、個人のフィードのカレンダー表示を見つけることができます。",
"searchPlaceholder": "カレンダートークン",
"searchButton": "ロードカレンダー"
}
}

View File

@ -0,0 +1,188 @@
{
"accept": "はい",
"addRule": "条件追加",
"am": "午前",
"apply": "適用",
"cancel": "キャンセル",
"choose": "選択",
"chooseDate": "日を選択",
"chooseMonth": "月を選択",
"chooseYear": "年を選択",
"clear": "クリア",
"completed": "完了済",
"contains": "含む",
"custom": "カスタム",
"dateAfter": "指定日より未来",
"dateBefore": "指定日より過去",
"dateFormat": "yy/mm/dd",
"dateIs": "等しい",
"dateIsNot": "等しくない",
"dayNames": [
"日曜日",
"月曜日",
"火曜日",
"水曜日",
"木曜日",
"金曜日",
"土曜日"
],
"dayNamesMin": [
"日",
"月",
"火",
"水",
"木",
"金",
"土"
],
"dayNamesShort": [
"日",
"月",
"火",
"水",
"木",
"金",
"土"
],
"emptyFilterMessage": "オプションなし",
"emptyMessage": "結果なし",
"emptySearchMessage": "該当なし",
"emptySelectionMessage": "選択なし",
"endsWith": "終わる",
"equals": "等しい",
"fileSizeTypes": [
"B",
"KB",
"MB",
"GB",
"TB",
"PB",
"EB",
"ZB",
"YB"
],
"filter": "フィルター",
"firstDayOfWeek": 0,
"gt": "超える",
"gte": "以上",
"lt": "未満",
"lte": "以下",
"matchAll": "全て一致",
"matchAny": "いずれかが一致",
"medium": "普通",
"monthNames": [
"1月",
"2月",
"3月",
"4月",
"5月",
"6月",
"7月",
"8月",
"9月",
"10月",
"11月",
"12月"
],
"monthNamesShort": [
"1月",
"2月",
"3月",
"4月",
"5月",
"6月",
"7月",
"8月",
"9月",
"10月",
"11月",
"12月"
],
"nextDecade": "後の10年",
"nextHour": "次の時間",
"nextMinute": "次の分",
"nextMonth": "翌月",
"nextSecond": "次の秒",
"nextYear": "翌年",
"noFilter": "フィルターなし",
"notContains": "含まない",
"notEquals": "等しくない",
"now": "今",
"passwordPrompt": "パスワードを入力",
"pending": "保留",
"pm": "午後",
"prevDecade": "前の10年",
"prevHour": "前の時間",
"prevMinute": "前の分",
"prevMonth": "先月",
"prevSecond": "前の秒",
"prevYear": "前年",
"reject": "いいえ",
"removeRule": "条件削除",
"searchMessage": "{0} 件の結果",
"selectionMessage": "{0} 件選択済み",
"showMonthAfterYear": true,
"startsWith": "始まる",
"strong": "強い",
"today": "今日",
"upload": "アップロード",
"weak": "弱い",
"weekHeader": "週",
"aria": {
"cancelEdit": "キャンセル",
"close": "閉じる",
"collapseLabel": "崩壊",
"collapseRow": "折りたたみ行",
"editRow": "行編集",
"expandLabel": "拡大する",
"expandRow": "展開済行",
"falseLabel": "False",
"filterConstraint": "フィルター成約",
"filterOperator": "フィルター操作",
"firstPageLabel": "最初のページ",
"gridView": "グリッドビュー",
"hideFilterMenu": "フィルターメニューを非表示",
"jumpToPageDropdownLabel": "ページドロップダウンへ",
"jumpToPageInputLabel": "ページ入力へ",
"lastPageLabel": "最後のページ",
"listView": "リストビュー",
"moveAllToSource": "ソースへ全て移動",
"moveAllToTarget": "ターゲットへ全て移動",
"moveBottom": "一番下へ",
"moveDown": "下へ",
"moveTop": "トップへ移動",
"moveToSource": "ソースへ移動",
"moveToTarget": "ターゲットへ移動",
"moveUp": "上へ",
"navigation": "ナビゲーション",
"next": "次",
"nextPageLabel": "次のページ",
"nullLabel": "未選択",
"otpLabel": "ワンタイム パスワードの文字 {0} を入力してください",
"pageLabel": "{page}ページ",
"passwordHide": "パスワードを隠す",
"passwordShow": "パスワードを表示",
"previous": "前",
"previousPageLabel": "前のページ",
"rotateLeft": "左に回転",
"rotateRight": "右に回転",
"rowsPerPageLabel": "行/ページ",
"saveEdit": "保存",
"scrollTop": "トップへスクロール",
"selectAll": "全て選択",
"selectLabel": "選択する",
"selectRow": "選択済み行",
"showFilterMenu": "フィルターメニューを表示",
"slide": "スライド",
"slideNumber": "{slideNumber}",
"star": "1件のスター",
"stars": "{star}件のスター",
"trueLabel": "True",
"unselectAll": "すべての選択を解除",
"unselectLabel": "選択を解除します",
"unselectRow": "未選択行",
"zoomImage": "画像を拡大",
"zoomIn": "拡大",
"zoomOut": "縮小"
}
}

View File

@ -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);
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
import { beforeEach, describe, expect, test } from "vitest";
import { RoomOccupancyList } from "./roomOccupancyList";
import { Binary } from "bson";
import { addHours, addMinutes, interval, subHours } from "date-fns";
import { 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-02T00: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-02T02: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-02T13: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-01T23: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-02T23: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-02T13: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"));
});
});
});

View File

@ -0,0 +1,305 @@
//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 } 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);
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()));
let {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.
* @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.
*/
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[] {
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 << (7-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,
false,
false,
));
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,
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 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,
);
});
}
/**
* 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.
*
* @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);
}
}

View File

@ -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");
@ -49,6 +50,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",

View File

@ -0,0 +1,108 @@
<!--
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/>.
-->
<script lang="ts" setup>
import { Ref, computed, ref, watch } from "vue";
import { fetchRoom } from "../api/fetchRoom.ts";
import DynamicPage from "./DynamicPage.vue";
import RoomOccupationOffline from "../components/RoomOccupationOffline.vue";
import { computedAsync } from "@vueuse/core";
import router from "@/router";
type Room = {
name: string;
};
const selectedRoom: Ref<Room> = ref({ name: "" });
// Watch for changes in URL parameter
router.afterEach(async (to) => {
const room = to.query.room;
if (room && typeof room === "string") {
setRoomFromList(room, rooms.value);
}
});
const rooms = computedAsync<Set<string>>(async () => {
let rooms: Set<string> = new Set();
return await fetchRoom()
.then((data) => {
rooms = new Set(data);
return rooms;
})
.finally(() => {
const room = router.currentRoute.value.query.room;
if (room && typeof room === "string") {
// check if room is available in roomsList
setRoomFromList(room, rooms);
}
});
}, new Set());
const roomsList = computed(() => {
return Array.from(rooms.value).map((room) => {
return { name: room } as Room;
});
});
/**
* Set the room from the list of rooms
* @param room Name of the room
* @param rooms List of available rooms
*/
function setRoomFromList(room: string, rooms: Set<string>) {
// wait for the roomsList to be available
const roomInList: boolean = rooms.has(room);
if (roomInList) {
selectedRoom.value.name = room;
}
}
watch(selectedRoom, (newRoom: Room) => {
if (newRoom.name !== "") {
router.push({
query: { ...router.currentRoute.value.query, room: newRoom.name },
});
}
});
</script>
<template>
<DynamicPage
:hide-content="selectedRoom.name === ''"
:headline="$t('roomFinderPage.headline') + ' &ndash; Offline'"
:sub-title="$t('roomFinderPage.detail')"
icon="pi pi-search"
>
<template #selection>
<Dropdown
v-model="selectedRoom"
:options="roomsList"
class="flex-1 m-0"
filter
option-label="name"
:placeholder="$t('roomFinderPage.dropDownSelect')"
:empty-message="$t('roomFinderPage.noRoomsAvailable')"
:auto-filter-focus="true"
/>
</template>
<template #content>
<RoomOccupationOffline :room="selectedRoom.name" />
</template>
</DynamicPage>
</template>

View File

@ -21,7 +21,7 @@
"allowSyntheticDefaultImports": true,
"paths": {
"@/*": ["./src/*"]
}
},
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [

View File

@ -110,4 +110,16 @@ export default defineConfig({
},
},
},
esbuild: {
supported: {
'top-level-await': true
},
},
optimizeDeps: {
esbuildOptions: {
supported: {
'top-level-await': true
}
},
},
});

11
frontend/vitest.config.ts Normal file
View File

@ -0,0 +1,11 @@
// vitest.config.ts
import {mergeConfig} from 'vite'
import {defineConfig} from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(viteConfig, defineConfig({
test: {
globals: true,
globalSetup: './vitest.global-setup.ts',
},
}))

View File

@ -0,0 +1,3 @@
export const setup = () => {
process.env.TZ = 'UTC'
}