diff --git a/backend/service/addRoute.go b/backend/service/addRoute.go index 143bc86..d28070e 100644 --- a/backend/service/addRoute.go +++ b/backend/service/addRoute.go @@ -34,7 +34,7 @@ import ( "go.mongodb.org/mongo-driver/bson" ) -const RoomOccupancyGranularity = 5 +const RoomOccupancyGranularity = 15 func AddRoutes(app *pocketbase.PocketBase) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 293b93c..ccc930d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index afe4231..bd35f2f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/fetchRoomOccupancy.ts b/frontend/src/api/fetchRoomOccupancy.ts new file mode 100644 index 0000000..bc9f6e1 --- /dev/null +++ b/frontend/src/api/fetchRoomOccupancy.ts @@ -0,0 +1,44 @@ +//Calendar implementation for the HTWK Leipzig timetable. Evaluation and display of the individual dates in iCal format. +//Copyright (C) 2024 HTWKalender support@htwkalender.de + +//This program is free software: you can redistribute it and/or modify +//it under the terms of the GNU Affero General Public License as published by +//the Free Software Foundation, either version 3 of the License, or +//(at your option) any later version. + +//This program is distributed in the hope that it will be useful, +//but WITHOUT ANY WARRANTY; without even the implied warranty of +//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +//GNU Affero General Public License for more details. + +//You should have received a copy of the GNU Affero General Public License +//along with this program. If not, see . + +import { BSON } from "bson"; +import { RoomOccupancyList } from "@/model/roomOccupancyList.ts"; + +export async function fetchRoomOccupancy( + from_date: string, + to_date: string, +): Promise { + var roomOccupancyList: RoomOccupancyList = new RoomOccupancyList( + new Date(), 0, 0, [] + ); + + await fetch( + "/api/schedule/rooms?from=" + from_date + "&to=" + to_date, + ) + .then((response) => { + return response.arrayBuffer(); + }) + .then((roomsResponse: ArrayBuffer | null) => { + if (roomsResponse == null) { + return null; + } + const data = new Uint8Array(roomsResponse); + roomOccupancyList = RoomOccupancyList.fromJSON(BSON.deserialize(data)); + return roomOccupancyList; + }); + + return roomOccupancyList; +} diff --git a/frontend/src/components/LocaleSwitcher.vue b/frontend/src/components/LocaleSwitcher.vue index 4762e6e..cd5d587 100644 --- a/frontend/src/components/LocaleSwitcher.vue +++ b/frontend/src/components/LocaleSwitcher.vue @@ -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; } diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index 995d208..fd2ce36 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -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", + } ], }, { diff --git a/frontend/src/components/RoomOccupation.vue b/frontend/src/components/RoomOccupation.vue index a3b8807..9bf4e2e 100644 --- a/frontend/src/components/RoomOccupation.vue +++ b/frontend/src/components/RoomOccupation.vue @@ -30,6 +30,7 @@ import router from "@/router"; import { formatYearMonthDay } from "@/helpers/dates"; import { useQuery } from "@tanstack/vue-query"; import { watch } from "vue"; +import { isValid } from "date-fns"; const { t } = useI18n({ useScope: "global" }); @@ -50,11 +51,13 @@ function setDateFromQuery() { return; } // date is in format like YYYYMMDD - // TODO check if date is valid const year = queryDate.substring(0, 4); const month = queryDate.substring(4, 6); const day = queryDate.substring(6, 8); date.value = new Date(`${year}-${month}-${day}`); + if (!isValid(date.value)) { + date.value = new Date(); + } } } diff --git a/frontend/src/components/RoomOccupationOffline.vue b/frontend/src/components/RoomOccupationOffline.vue new file mode 100644 index 0000000..1e8b817 --- /dev/null +++ b/frontend/src/components/RoomOccupationOffline.vue @@ -0,0 +1,215 @@ + + + + + + diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 4ec0601..75ee450 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -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; diff --git a/frontend/src/i18n/translations/de.json b/frontend/src/i18n/translations/de.json index 3ef62ac..6e62d2a 100644 --- a/frontend/src/i18n/translations/de.json +++ b/frontend/src/i18n/translations/de.json @@ -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", diff --git a/frontend/src/i18n/translations/en.json b/frontend/src/i18n/translations/en.json index 0292fac..dd108ec 100644 --- a/frontend/src/i18n/translations/en.json +++ b/frontend/src/i18n/translations/en.json @@ -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": { diff --git a/frontend/src/i18n/translations/ja.json b/frontend/src/i18n/translations/ja.json new file mode 100644 index 0000000..455bb34 --- /dev/null +++ b/frontend/src/i18n/translations/ja.json @@ -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": "ロードカレンダー" + } +} diff --git a/frontend/src/i18n/translations/primevue/prime_vue_local_ja.json b/frontend/src/i18n/translations/primevue/prime_vue_local_ja.json new file mode 100644 index 0000000..70924fb --- /dev/null +++ b/frontend/src/i18n/translations/primevue/prime_vue_local_ja.json @@ -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": "縮小" + } +} diff --git a/frontend/src/model/event.ts b/frontend/src/model/event.ts index 81800fe..5bb6213 100644 --- a/frontend/src/model/event.ts +++ b/frontend/src/model/event.ts @@ -31,7 +31,17 @@ export class Event { ) {} } -export class AnonymizedEventDTO { +export class AnonymizedOccupancy { + constructor( + public start: string, + public end: string, + public rooms: string, + public free: boolean, + public stub: boolean = false, + ) {} +} + +export class AnonymizedEventDTO extends AnonymizedOccupancy { constructor( public day: string, public week: string, @@ -39,5 +49,7 @@ export class AnonymizedEventDTO { public end: string, public rooms: string, public free: boolean, - ) {} + ) { + super(start, end, rooms, free); + } } diff --git a/frontend/src/model/roomOccupancyList.test.ts b/frontend/src/model/roomOccupancyList.test.ts new file mode 100644 index 0000000..aa0a3a9 --- /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 { 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")); + }); + }); + +}); diff --git a/frontend/src/model/roomOccupancyList.ts b/frontend/src/model/roomOccupancyList.ts new file mode 100644 index 0000000..c18f629 --- /dev/null +++ b/frontend/src/model/roomOccupancyList.ts @@ -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 . + +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 { + 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); + } + +} + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8e107bd..7aff183 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -21,6 +21,7 @@ const AdditionalModules = () => import("../view/AdditionalModules.vue"); const CalendarLink = () => import("../components/CalendarLink.vue"); const RenameModules = () => import("../components/RenameModules.vue"); const RoomFinder = () => import("../view/RoomFinder.vue"); +const RoomFinderOffline = () => import("../view/RoomFinderOffline.vue"); const EditCalendarView = () => import("../view/EditCalendarView.vue"); const EditAdditionalModules = () => import("../view/editCalendar/EditAdditionalModules.vue"); @@ -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", diff --git a/frontend/src/view/RoomFinderOffline.vue b/frontend/src/view/RoomFinderOffline.vue new file mode 100644 index 0000000..dc698c6 --- /dev/null +++ b/frontend/src/view/RoomFinderOffline.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 51d8f95..b8f3de3 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -21,7 +21,7 @@ "allowSyntheticDefaultImports": true, "paths": { "@/*": ["./src/*"] - } + }, }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "references": [ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 93f1906..ec8f975 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -110,4 +110,16 @@ export default defineConfig({ }, }, }, + esbuild: { + supported: { + 'top-level-await': true + }, + }, + optimizeDeps: { + esbuildOptions: { + supported: { + 'top-level-await': true + } + }, + }, }); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..5f06fdd --- /dev/null +++ b/frontend/vitest.config.ts @@ -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', + }, +})) \ No newline at end of file diff --git a/frontend/vitest.global-setup.ts b/frontend/vitest.global-setup.ts new file mode 100644 index 0000000..f7ffa41 --- /dev/null +++ b/frontend/vitest.global-setup.ts @@ -0,0 +1,3 @@ +export const setup = () => { + process.env.TZ = 'UTC' +} \ No newline at end of file