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

fix:#3 fetch room occupancy once

Closes #3

See merge request htwk-software/htwkalender-pwa!17
This commit is contained in:
Elmar Kresse
2024-08-13 14:57:19 +00:00
22 changed files with 4298 additions and 1194 deletions

View File

@ -478,7 +478,8 @@ $highlightFocusBg: rgba($primaryColor, 0.24) !default;
}
}
.fc-event-selected::after, .fc-event:focus::after {
.fc-event-selected::after,
.fc-event:focus::after {
background: var(--fc-event-selected-overlay-color);
inset: -1px;
content: "";

View File

@ -488,7 +488,8 @@ $highlightFocusBg: rgba($primaryColor, 0.24) !default;
}
}
.fc-event-selected::after, .fc-event:focus::after {
.fc-event-selected::after,
.fc-event:focus::after {
background: var(--fc-event-selected-overlay-color);
inset: -1px;
content: "";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -37,7 +37,7 @@ const disabledPages = [
"edit-calendar",
"rooms",
"free-rooms",
"room-schedule"
"room-schedule",
];
const store = moduleStore();
@ -60,10 +60,15 @@ const emit = defineEmits(["dark-mode-toggled"]);
onMounted(() => {
// set theme matching browser preference
settings().setDarkMode(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches)
settings().setDarkMode(
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches,
);
setTheme(settings, primeVue, emit);
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
settings().setDarkMode(e.matches)
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
settings().setDarkMode(e.matches);
setTheme(settings, primeVue, emit);
});
});

View File

@ -16,12 +16,68 @@
import { BSON } from "bson";
import { RoomOccupancyList } from "@/model/roomOccupancyList.ts";
import { addMonths } from "date-fns";
import { formatYearMonthDay } from "@/helpers/dates";
const END_OF_SUMMER_SEMESTER = "0930";
const END_OF_WINTER_SEMESTER = "0331";
/**
* check if date is in winter semester before summer semester
* @param date - The date to check
* @returns boolean - true if date is in winter semester
*/
export function isBeforeSummer(date: Date): boolean {
const formattedDate = formatYearMonthDay(date).slice(4);
return formattedDate <= END_OF_WINTER_SEMESTER;
}
/**
* check if date is in winter semester after summer semester
* @param date - The date to check
* @returns boolean - true if date is in winter semester
*/
export function isAfterSummer(date: Date): boolean {
const formattedDate = formatYearMonthDay(date).slice(4);
return formattedDate > END_OF_SUMMER_SEMESTER;
}
/**
* Gets the start date of the current semester
* @param date - The date to check
* @returns Date - The start date of the current semester
*/
export function getSemesterStart(date: Date): Date {
if (isBeforeSummer(date)) {
return new Date(date.getFullYear() - 1, 9, 1);
} else if (isAfterSummer(date)) {
return new Date(date.getFullYear(), 9, 1);
} else {
return new Date(date.getFullYear(), 3, 1);
}
}
/**
* Fetches the room occupancy for a given date range.
* @param from_date the start date of the date range
* @param to_date the end date of the date range
* @returns RoomOccupancyList - the room occupancy list
*/
export async function fetchRoomOccupancy(
from_date: string,
to_date: string,
from_date?: string,
to_date?: string,
): Promise<RoomOccupancyList> {
var roomOccupancyList: RoomOccupancyList = new RoomOccupancyList(
if (from_date == undefined) {
const new_from_date = getSemesterStart(new Date());
from_date = new_from_date.toISOString();
}
if (to_date == undefined) {
const new_to_date = getSemesterStart(addMonths(new Date(), 6));
to_date = new_to_date.toISOString();
}
let roomOccupancyList: RoomOccupancyList = new RoomOccupancyList(
new Date(),
0,
0,

View File

@ -71,7 +71,6 @@ const toggle = (info: EventClickArg) => {
}
};
const selectedToken = computed(() => {
return props.token;
});
@ -89,7 +88,7 @@ const { data: calendar} = useQuery({
refetchOnWindowFocus: "always",
refetchOnReconnect: "always",
networkMode: "offlineFirst",
enabled: () => tokenStore().token !== ""
enabled: () => tokenStore().token !== "",
});
const queryClient = useQueryClient();
@ -103,7 +102,7 @@ const invalidateAndRefetchCalendar = () => {
};
defineExpose({
invalidateAndRefetchCalendar
invalidateAndRefetchCalendar,
});
const events = computed(() => {

View File

@ -29,7 +29,6 @@ const emit = defineEmits(["dark-mode-toggled"]);
const store = settingsStore;
const isDark = computed(() => store().isDark);
</script>
<template>

View File

@ -20,10 +20,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import { computed, ComputedRef, ref } from "vue";
import settingsStore from "../store/settingsStore.ts";
const pageOptions: ComputedRef<(string | {
const pageOptions: ComputedRef<
(
| string
| {
label: string;
value: string;
})[]> = computed(() => [...settingsStore().getDefaultPageOptions()]);
}
)[]
> = computed(() => [...settingsStore().getDefaultPageOptions()]);
const selectedPage = ref(settingsStore().defaultPage);

View File

@ -21,8 +21,6 @@ import { computed } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n({ useScope: "global" });
const items = computed(() => [
{
label: t("calendar"),
@ -133,7 +131,15 @@ const items = computed(() => [
<div class="flex align-items-stretch justify-content-center">
<!-- Settings Button with Gear Icon -->
<router-link v-slot="{ navigate }" :to="`/settings`" custom>
<Button icon="pi pi-cog" severity="secondary" rounded text size="large" aria-label="Settings" @click="navigate" />
<Button
icon="pi pi-cog"
severity="secondary"
rounded
text
size="large"
aria-label="Settings"
@click="navigate"
/>
</router-link>
</div>
</template>

View File

@ -86,7 +86,7 @@ const { data: occupations } = useQuery({
showFree: event.free,
})),
enabled: () => selectedRoom.value !== "" && currentDateFrom.value !== "",
staleTime: 5000000, // 500 seconds
staleTime: 5000000, // 5000 seconds
});
watch(occupations, () => fullCalendar.value?.getApi().refetchEvents());

View File

@ -21,14 +21,13 @@ 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 {computed, ComputedRef, inject, ref, Ref, watch} 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";
@ -78,7 +77,7 @@ const selectedRoom = computed(() => props.room);
* @returns Anonymized occupancy events
*/
function transformData(data: RoomOccupancyList) {
const events = data
return data
.decodeOccupancy(
selectedRoom.value,
new Date(currentDateFrom.value),
@ -88,19 +87,17 @@ function transformData(data: RoomOccupancyList) {
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
const { data: occupancy } = useQuery({
queryKey: ["roomOccupancy"], //, selectedRoom, currentDateFrom, currentDateTo],
queryFn: () => fetchRoomOccupancy(),
staleTime: 12 * 3600000, // 12 hours
});
const occupations = computed(() => {
if (!occupancy.value) return;
return transformData(occupancy.value);
});
watch(occupations, () => fullCalendar.value?.getApi().refetchEvents());

View File

@ -41,10 +41,13 @@ export function parseICalData(
const jCalData = ICAL.parse(icalData);
const comp = new ICAL.Component(jCalData);
const vEvents = comp.getAllSubcomponents("vevent");
const events: CalendarComponent[] = vEvents.map((vevent: CalendarComponent) => {
const events: CalendarComponent[] = vEvents.map(
(vevent: CalendarComponent) => {
return new ICAL.Event(vevent);
});
const colorDistinctionEvents: ColorDistinctionEvent[] = extractedColorizedEvents(events);
},
);
const colorDistinctionEvents: ColorDistinctionEvent[] =
extractedColorizedEvents(events);
return vEvents.map((vevent: CalendarComponent) => {
const event = new ICAL.Event(vevent);

View File

@ -36,7 +36,7 @@ export function toggleTheme(
export function setTheme(
store: SettingsStore,
{ changeTheme }: { changeTheme: PrimeVueChangeTheme },
emit: EmitFn<"dark-mode-toggled"[]>
emit: EmitFn<"dark-mode-toggled"[]>,
) {
const isDark = ref(store().isDark);
const newTheme = isDark.value ? darkTheme.value : lightTheme.value,

View File

@ -70,10 +70,10 @@ app.use(VueQueryPlugin, {
queryClientConfig: {
defaultOptions: {
queries: {
refetchOnWindowFocus: false
}
}
}
refetchOnWindowFocus: false,
},
},
},
});
app.use(PrimeVue);

View File

@ -14,7 +14,7 @@
//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 {Binary, Document} from "bson";
import { AnonymizedOccupancy } from "./event";
import {
Duration,
@ -92,19 +92,23 @@ export class RoomOccupancyList {
): AnonymizedOccupancy[] {
const roomOccupancy = this.rooms.find((r) => r.name === room);
if (roomOccupancy === undefined) {
// Get start and end of decoded time range (within encoded list and requested range)
const decodeInterval = interval(
clamp(from, this.getOccupancyInterval()),
clamp(to, this.getOccupancyInterval()),
);
// if the room is not in the list or the time range is empty, return stub events
if (
roomOccupancy === undefined ||
isEqual(decodeInterval.start, decodeInterval.end)
) {
return RoomOccupancyList.generateStubEvents(room, from, to);
}
const occupancyList = [];
// 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(
const { decodeSliceStart, decodeSlice } = this.sliceOccupancy(
decodeInterval,
roomOccupancy.occupancy.buffer,
);
@ -141,10 +145,10 @@ export class RoomOccupancyList {
/**
* 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.
* @param decodeInterval
* @param occupancy
*/
private sliceOccupancy(
decodeInterval: NormalizedInterval,
@ -152,14 +156,14 @@ export class RoomOccupancyList {
): { 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(
const minutesFromStart = differenceInMinutes(
decodeInterval.start,
this.start,
);
let minutesToEnd = differenceInMinutes(decodeInterval.end, this.start);
const minutesToEnd = differenceInMinutes(decodeInterval.end, this.start);
let firstByte = Math.floor(minutesFromStart / this.granularity / 8);
let lastByte = Math.ceil(minutesToEnd / this.granularity / 8);
const firstByte = Math.floor(minutesFromStart / this.granularity / 8);
const lastByte = Math.ceil(minutesToEnd / this.granularity / 8);
// check if firstByte and lastByte are within the bounds of the occupancy array and throw an error if not
if (
@ -171,11 +175,11 @@ export class RoomOccupancyList {
throw new Error("Requested time range is outside of the occupancy list.");
}
let decodeSliceStart = addMinutes(
const decodeSliceStart = addMinutes(
this.start,
firstByte * 8 * this.granularity,
);
let decodeSlice = occupancy.buffer.slice(firstByte, lastByte);
const decodeSlice = occupancy.buffer.slice(firstByte, lastByte);
return { decodeSliceStart, decodeSlice: new Uint8Array(decodeSlice) };
}
@ -205,23 +209,25 @@ export class RoomOccupancyList {
granularity: number,
room: string,
): AnonymizedOccupancy[] {
let occupancyList = [];
const occupancyList = [];
let firstOccupancyBit: number | null = null;
// Iterate over all bytes that are in the array
for (let byte_i = 0; byte_i < occupancy.length; byte_i++) {
let byte = occupancy[byte_i];
const byte = occupancy[byte_i];
// Iterate over all bits in the current byte
for (let bit_i = 0; bit_i < 8; bit_i++) {
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);
const isOccupied = (byte & (1 << (7 - bit_i))) !== 0;
const calculateOccupancyBitIndex = (byte_i: number, bit_i: number) => byte_i * 8 + bit_i;
if(firstOccupancyBit === null){
if (isOccupied) {
firstOccupancyBit = calculateOccupancyBitIndex(byte_i, bit_i);
}
} else {
if (!isOccupied) {
const startTime = addMinutes(start, firstOccupancyBit * granularity);
const endTime = addMinutes(start, calculateOccupancyBitIndex(byte_i, bit_i) * granularity);
// add event between start and end of a block of boolean true values
occupancyList.push(
new AnonymizedOccupancy(
@ -232,16 +238,15 @@ export class RoomOccupancyList {
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);
const startTime = addMinutes(start, firstOccupancyBit * granularity);
const endTime = addMinutes(start, occupancy.length * 8 * granularity);
occupancyList.push(
new AnonymizedOccupancy(
@ -261,6 +266,7 @@ export class RoomOccupancyList {
* Generate a list of AnonymizedOccupancy objects for a given time range.
* The generated events are always lying within the time range [START_OF_DAY, END_OF_DAY].
*
* @param rooms
* @param from The start time within the specified start day.
* @param to The end time within the specified end day.
* @returns a list of AnonymizedEventDTO objects, from start to end.
@ -278,11 +284,11 @@ export class RoomOccupancyList {
}
return eachDayOfInterval({ start: from, end: to }).map((day) => {
let startTime = max([
const startTime = max([
from,
RoomOccupancyList.setTimeOfDay(day, START_OF_WORKDAY),
]);
let endTime = min([
const endTime = min([
to,
RoomOccupancyList.setTimeOfDay(day, END_OF_WORKDAY),
]);
@ -303,13 +309,13 @@ export class RoomOccupancyList {
* @param json the JS object to read from.
* @returns a RoomOccupancyList object.
*/
public static fromJSON(json: any): RoomOccupancyList {
public static fromJSON(json: Document): RoomOccupancyList {
return new RoomOccupancyList(
json.start,
json.granularity,
json.blocks,
json.rooms.map(
(room: any) => new RoomOccupancy(room.name, room.occupancy),
(room: { name: string, occupancy: Binary }) => new RoomOccupancy(room.name, room.occupancy),
),
);
}

View File

@ -35,7 +35,6 @@ const NotFound = () => import("../view/NotFound.vue");
import i18n from "../i18n";
import settingsStore from "@/store/settingsStore.ts";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [

View File

@ -23,7 +23,10 @@ const settingsStore = defineStore("settingsStore", {
return {
locale: useLocalStorage("locale", "en"), //useLocalStorage takes in a key of 'count' and default value of 0
isDark: true,
defaultPage: useLocalStorage("defaultPage", {label: "Home", value: "/home"}),
defaultPage: useLocalStorage("defaultPage", {
label: "Home",
value: "/home",
}),
};
},
actions: {
@ -36,10 +39,7 @@ const settingsStore = defineStore("settingsStore", {
getDarkMode(): boolean {
return this.isDark;
},
setDefaultPage(page: {
label: string;
value: string;
}) {
setDefaultPage(page: { label: string; value: string }) {
this.defaultPage = page;
},
getDefaultPageOptions(): {

View File

@ -11,6 +11,4 @@ import DynamicPage from "@/view/DynamicPage.vue";
</DynamicPage>
</template>
<style scoped>
</style>
<style scoped></style>

View File

@ -1,5 +1,4 @@
<script lang="ts" setup>
import LocaleSwitcher from "@/components/LocaleSwitcher.vue";
import { ref } from "vue";
import DarkModeSwitcher from "@/components/DarkModeSwitcher.vue";
@ -14,7 +13,6 @@ function handleDarkModeToggled(isDarkVar: boolean) {
// Assuming the root component has an isDark ref
isDark.value = isDarkVar;
}
</script>
<template>
@ -68,11 +66,9 @@ class="opacity-100 transition-all transition-duration-500 transition-ease-in-out
</div>
</template>
<style scoped>
.col {
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@ -77,7 +77,7 @@ onMounted(() => {
></Button>
</template>
<template #content>
<CalendarViewer :token="tokenStore().token" ref="calendarViewerRef" />
<CalendarViewer ref="calendarViewerRef" :token="tokenStore().token" />
</template>
</DynamicPage>
</template>

View File

@ -86,11 +86,11 @@ export default defineConfig({
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: ({ url }) => url.pathname.startsWith('/api/feed'),
method: 'GET',
handler: 'NetworkFirst',
urlPattern: ({ url }) => url.pathname.startsWith("/api/feed"),
method: "GET",
handler: "NetworkFirst",
options: {
cacheName: 'calendar-feed-cache',
cacheName: "calendar-feed-cache",
expiration: {
maxAgeSeconds: 12 * 60 * 60, // 12 hours
},