Merge branch '5-room-finder-for-each-week' into 'main'

Resolve "room finder for each week"

Closes #5

See merge request ekresse/htwkalender!12
This commit is contained in:
ekresse
2024-01-31 15:46:21 +00:00
30 changed files with 9653 additions and 103 deletions

View File

@ -46,6 +46,27 @@ paths:
responses:
'200':
description: Successful response
/api/rooms/free:
get:
summary: Get Free Rooms
parameters:
- name: from
in: query
description: start date
example: "2006-01-02T15:04:05Z"
required: true
schema:
type: string
- name: to
in: query
description: end date
example: "2006-01-02T15:04:05Z"
required: true
schema:
type: string
responses:
'200':
description: Successful response
/api/schedule/day:
get:
summary: Get Day Schedule

View File

@ -5,6 +5,7 @@ import (
"htwkalender/service/fetch/sport"
v1 "htwkalender/service/fetch/v1"
v2 "htwkalender/service/fetch/v2"
"htwkalender/service/functions/time"
"htwkalender/service/ical"
"htwkalender/service/room"
"log/slog"
@ -181,6 +182,39 @@ func AddRoutes(app *pocketbase.PocketBase) {
return nil
})
// API Endpoint to get all rooms that have no events in a specific time frame
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
_, err := e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/api/rooms/free",
Handler: func(c echo.Context) error {
from, err := time.ParseTime(c.QueryParam("from"))
if err != nil {
slog.Error("Failed to parse time: %v", err)
return c.JSON(http.StatusBadRequest, "Failed to parse time")
}
to, err := time.ParseTime(c.QueryParam("to"))
if err != nil {
slog.Error("Failed to parse time: %v", err)
return c.JSON(http.StatusBadRequest, "Failed to parse time")
}
rooms, err := room.GetFreeRooms(app, from, to)
if err != nil {
slog.Error("Failed to get free rooms: %v", err)
return c.JSON(http.StatusBadRequest, "Failed to get free rooms")
}
return c.JSON(http.StatusOK, rooms)
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(app),
},
})
if err != nil {
return err
}
return nil
})
addFeedRoutes(app)
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {

View File

@ -2,6 +2,7 @@ package db
import (
"fmt"
"github.com/pocketbase/pocketbase/tools/types"
"htwkalender/model"
"log/slog"
"time"
@ -10,12 +11,12 @@ import (
"github.com/pocketbase/pocketbase"
)
func SaveSeminarGroupEvents(seminarGroup []model.SeminarGroup, app *pocketbase.PocketBase) ([]model.Event, error) {
func SaveSeminarGroupEvents(seminarGroups []model.SeminarGroup, app *pocketbase.PocketBase) ([]model.Event, error) {
var toBeSavedEvents model.Events
var savedRecords model.Events
// check if event is already in database and add to toBeSavedEvents if not
for _, seminarGroup := range seminarGroup {
for _, seminarGroup := range seminarGroups {
for _, event := range seminarGroup.Events {
event = event.SetCourse(seminarGroup.Course)
existsInDatabase, err := findEventByDayWeekStartEndNameCourse(event, seminarGroup.Course, app)
@ -254,3 +255,89 @@ func GetAllModulesByNameAndDateRange(app *pocketbase.PocketBase, name string, st
return events, nil
}
// GetEventsThatCollideWithTimeRange returns all events that collide with the given time range
// we have events with start and end in the database, we want to get all events that collide with the given time range
// we have 4 cases:
// 1. event starts before the given time range and ends after the given time range
// 2. event starts after the given time range and ends before the given time range
// 3. event starts before the given time range and ends before the given time range
// 4. event starts after the given time range and ends after the given time range
func GetEventsThatCollideWithTimeRange(app *pocketbase.PocketBase, from time.Time, to time.Time) (model.Events, error) {
var fromTypeTime, _ = types.ParseDateTime(from)
var toTypeTime, _ = types.ParseDateTime(to)
events1, err := GetEventsThatStartBeforeAndEndAfter(app, fromTypeTime, toTypeTime)
if err != nil {
return nil, err
}
events2, err := GetEventsThatStartAfterAndEndBefore(app, fromTypeTime, toTypeTime)
if err != nil {
return nil, err
}
events3, err := GetEventsThatStartBeforeAndEndBefore(app, fromTypeTime, toTypeTime)
if err != nil {
return nil, err
}
events4, err := GetEventsThatStartAfterAndEndAfter(app, fromTypeTime, toTypeTime)
if err != nil {
return nil, err
}
var events model.Events
events = append(events, events1...)
events = append(events, events2...)
events = append(events, events3...)
events = append(events, events4...)
return events, nil
}
func GetEventsThatStartBeforeAndEndAfter(app *pocketbase.PocketBase, from types.DateTime, to types.DateTime) (model.Events, error) {
var events model.Events
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("Start <= {:startDate} AND End >= {:endDate} AND Start <= {:endDate} AND End >= {:startDate}", dbx.Params{"startDate": from, "endDate": to})).Distinct(true).All(&events)
if err != nil {
return nil, err
}
return events, nil
}
func GetEventsThatStartAfterAndEndBefore(app *pocketbase.PocketBase, from types.DateTime, to types.DateTime) (model.Events, error) {
var events model.Events
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("Start >= {:startDate} AND End <= {:endDate} AND Start <= {:endDate} AND End >= {:startDate}", dbx.Params{"startDate": from, "endDate": to})).All(&events)
if err != nil {
return nil, err
}
return events, nil
}
func GetEventsThatStartBeforeAndEndBefore(app *pocketbase.PocketBase, from types.DateTime, to types.DateTime) (model.Events, error) {
var events model.Events
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("Start <= {:startDate} AND End <= {:endDate} AND Start <= {:endDate} AND End >= {:startDate}", dbx.Params{"startDate": from, "endDate": to})).All(&events)
if err != nil {
return nil, err
}
return events, nil
}
func GetEventsThatStartAfterAndEndAfter(app *pocketbase.PocketBase, from types.DateTime, to types.DateTime) (model.Events, error) {
var events model.Events
err := app.Dao().DB().Select("*").From("events").Where(dbx.NewExp("Start >= {:startDate} AND End >= {:endDate} AND Start <= {:endDate} AND End >= {:startDate}", dbx.Params{"startDate": from, "endDate": to})).All(&events)
if err != nil {
return nil, err
}
return events, nil
}

View File

@ -47,9 +47,7 @@ func clearAndSeparateRooms(events []struct {
// sport rooms don't have to be separated
if event.Course != "Sport" {
//split rooms by comma, tab, newline, carriage return, semicolon, space and non-breaking space
room = strings.FieldsFunc(event.Rooms, functions.IsSeparator(
[]rune{',', '\t', '\n', '\r', ';', ' ', '\u00A0'}),
)
room = functions.SeperateRoomString(event.Rooms)
} else {
room = append(room, event.Rooms)
}

View File

@ -45,3 +45,9 @@ func HashString(s string) string {
hashInBytes := hash.Sum(nil)
return hex.EncodeToString(hashInBytes)
}
func SeperateRoomString(rooms string) []string {
return strings.FieldsFunc(rooms, IsSeparator(
[]rune{',', '\t', '\n', '\r', ';', ' ', '\u00A0'}),
)
}

View File

@ -0,0 +1,7 @@
package time
import "time"
func ParseTime(timeString string) (time.Time, error) {
return time.Parse("2006-01-02T15:04:05Z", timeString)
}

View File

@ -4,6 +4,8 @@ import (
"github.com/pocketbase/pocketbase"
"htwkalender/model"
"htwkalender/service/db"
"htwkalender/service/functions"
"time"
)
func GetRooms(app *pocketbase.PocketBase) ([]string, error) {
@ -41,3 +43,46 @@ func anonymizeRooms(events []model.Event) []model.AnonymizedEventDTO {
}
return anonymizedEvents
}
func GetFreeRooms(app *pocketbase.PocketBase, from time.Time, to time.Time) ([]string, error) {
rooms, err := db.GetRooms(app)
if err != nil {
return nil, err
}
var events model.Events
events, err = db.GetEventsThatCollideWithTimeRange(app, from, to)
if err != nil {
return nil, err
}
freeRooms := removeRoomsThatHaveEvents(rooms, events)
return freeRooms, nil
}
func removeRoomsThatHaveEvents(rooms []string, schedule []model.Event) []string {
var freeRooms []string
for _, room := range rooms {
if !isRoomInSchedule(room, schedule) {
freeRooms = append(freeRooms, room)
}
}
return freeRooms
}
func isRoomInSchedule(room string, schedule []model.Event) bool {
for _, event := range schedule {
if event.Course != "Sport" {
rooms := functions.SeperateRoomString(event.Rooms)
// check if room is in rooms
for _, r := range rooms {
if r == room {
return true
}
}
} else {
if event.Rooms == room {
return true
}
}
}
return false
}

View File

@ -1,11 +1,10 @@
package room
import (
"github.com/pocketbase/pocketbase/tools/types"
"htwkalender/model"
"reflect"
"testing"
"github.com/pocketbase/pocketbase/tools/types"
)
func Test_anonymizeRooms(t *testing.T) {
@ -123,3 +122,170 @@ func Test_anonymizeRooms(t *testing.T) {
})
}
}
func Test_isRoomInSchedule(t *testing.T) {
type args struct {
room string
schedule []model.Event
}
tests := []struct {
name string
args args
want bool
}{
{
name: "room is in schedule",
args: args{
room: "Room",
schedule: []model.Event{
{
UUID: "testUUID",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Secret",
EventType: "V",
Prof: "Prof. Dr. Secret",
Rooms: "Room",
Notes: "Secret",
},
},
},
want: true,
},
{
name: "room is not in schedule",
args: args{
room: "Z324",
schedule: []model.Event{
{
UUID: "testUUID",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Secret",
EventType: "V",
Prof: "Prof. Dr. Bond",
Rooms: "LI007",
Notes: "Keine Zeit für die Uni",
},
},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRoomInSchedule(tt.args.room, tt.args.schedule); got != tt.want {
t.Errorf("isRoomInSchedule() = %v, want %v", got, tt.want)
}
})
}
}
func Test_getFreeRooms(t *testing.T) {
type args struct {
rooms []string
schedule []model.Event
}
tests := []struct {
name string
args args
want []string
}{
{
name: "remove room1 from list",
args: args{
rooms: []string{
"Room1",
"Room2",
"Room3",
},
schedule: []model.Event{
{
UUID: "testUUID",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Secret",
EventType: "V",
Prof: "Prof. Dr. Secret",
Rooms: "Room1",
Notes: "Secret",
},
},
},
want: []string{
"Room2",
"Room3",
},
},
{
name: "remove room2 from list",
args: args{
rooms: []string{
"Room1",
"Room2",
"Room3",
},
schedule: []model.Event{
{
UUID: "testUUID",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Secret",
EventType: "V",
Prof: "Prof. Dr. Secret",
Rooms: "Room3",
Notes: "Secret",
},
},
},
want: []string{
"Room1",
"Room2",
},
},
{
name: "remove no room from list",
args: args{
rooms: []string{
"Room1",
"Room2",
"Room3",
},
schedule: []model.Event{
{
UUID: "testUUID",
Day: "Montag",
Week: "52",
Start: types.DateTime{},
End: types.DateTime{},
Name: "Secret",
EventType: "V",
Prof: "Prof. Dr. Secret",
Rooms: "Room4",
Notes: "Secret",
},
},
},
want: []string{
"Room1",
"Room2",
"Room3",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := removeRoomsThatHaveEvents(tt.args.rooms, tt.args.schedule); !reflect.DeepEqual(got, tt.want) {
t.Errorf("removeRoomsThatHaveEvents() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -18,7 +18,7 @@
"primeflex": "^3.3.1",
"primeicons": "^6.0.1",
"primevue": "^3.46.0",
"source-sans-pro": "^3.6.0",
"source-sans": "^3.46.0",
"vue": "^3.4.11",
"vue-i18n": "^9.9.0",
"vue-router": "^4.2.5"
@ -3967,11 +3967,10 @@
"source-map": "^0.6.0"
}
},
"node_modules/source-sans-pro": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/source-sans-pro/-/source-sans-pro-3.6.0.tgz",
"integrity": "sha512-C1RFUGu+YASuqpgDRInTM7Y6OwqeWNOuKn7v0P/4Kh66epTI4PYWwPWP5kdA4l/VqzBAWiqoz5dk0trof73R7w==",
"deprecated": "WARNING: This project has been renamed to source-sans. Install using source-sans instead."
"node_modules/source-sans": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/source-sans/-/source-sans-3.46.0.tgz",
"integrity": "sha512-bVC2YX4VNiv5vMcy77dL0XKsNp794ThfynNsr+FqSAwk8TGG0pZsg7eUQi6yHwaRBMVmZ3Aaf4FY46dxIIGgsg=="
},
"node_modules/stackback": {
"version": "0.0.2",

View File

@ -23,7 +23,7 @@
"primeflex": "^3.3.1",
"primeicons": "^6.0.1",
"primevue": "^3.46.0",
"source-sans-pro": "^3.6.0",
"source-sans": "^3.46.0",
"vue": "^3.4.11",
"vue-i18n": "^9.9.0",
"vue-router": "^4.2.5"

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,8 @@ export async function fetchRoom(): Promise<string[]> {
.then((response) => {
return response.json();
})
.then((roomsResponse) => {
roomsResponse.forEach((room: string) => rooms.push(room));
.then((roomsResponse: [] | null) => {
roomsResponse?.forEach((room: string) => rooms.push(room));
});
return rooms;
}
@ -22,17 +22,15 @@ export async function fetchEventsByRoomAndDuration(
"/api/schedule?room=" + room + "&from=" + from_date + "&to=" + to_date,
)
.then((response) => {
console.log(response);
return response.json();
})
.then((eventsResponse) => {
console.log("Response:", eventsResponse);
eventsResponse.forEach((event: AnonymizedEventDTO) => events.push(event));
.then((eventsResponse: [] | null) => {
eventsResponse?.forEach((event: AnonymizedEventDTO) =>
events.push(event),
);
})
.catch((error) => {
console.log("Error fetching events: ", error);
return Promise.reject(error);
});
console.log("occupations: ", events);
return events;
}

View File

@ -0,0 +1,17 @@
// load free rooms as a list of strings form the backend
export async function requestFreeRooms(
from: string,
to: string,
): Promise<string[]> {
console.debug("requestFreeRooms: from=" + from + ", to=" + to);
const rooms: string[] = [];
await fetch("/api/rooms/free?from=" + from + "&to=" + to)
.then((response) => {
return response.json();
})
.then((roomsResponse: [] | null) => {
roomsResponse?.forEach((room: string) => rooms.push(room));
});
return rooms;
}

View File

@ -208,6 +208,16 @@
<div class="col">{{ $t("faqView.eighthQuestion") }}</div>
<div class="col">{{ $t("faqView.eighthAnswer") }}</div>
</div>
<div class="grid my-2">
<div class="col">{{ $t("faqView.ninthQuestion") }}</div>
<div class="col">
{{ $t("faqView.ninthAnswer") }}
<a href="https://git.imn.htwk-leipzig.de/ekresse/htwkalender"
>Gitlab</a
>
.
</div>
</div>
<p>
{{ $t("faqView.notFound") }}<br />
<a href="/imprint">{{ $t("faqView.contact") }}</a>

View File

@ -2,7 +2,9 @@
import { computed } from "vue";
import localeStore from "../store/localeStore.ts";
import { useI18n } from "vue-i18n";
import { DropdownChangeEvent } from "primevue/dropdown";
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";
const { t } = useI18n({ useScope: "global" });
const countries = computed(() => [
@ -18,9 +20,19 @@ function displayCountry(code: string) {
return countries.value.find((country) => country.code === code)?.name;
}
function updateLocale(dropdownChangeEvent: DropdownChangeEvent) {
localeStore().setLocale(dropdownChangeEvent.value);
const primeVueConfig = usePrimeVue();
function updateLocale(locale: string) {
localeStore().setLocale(locale);
if (locale === "de") {
primeVueConfig.config.locale = primeVue_de;
} else {
primeVueConfig.config.locale = primeVue_en;
}
}
updateLocale(localeStore().locale);
</script>
<template>
<Dropdown
@ -29,7 +41,7 @@ function updateLocale(dropdownChangeEvent: DropdownChangeEvent) {
option-label="name"
placeholder="Select a Language"
class="w-full md:w-14rem"
@change="updateLocale($event)"
@change="updateLocale($event.value)"
>
<template #value="slotProps">
<div v-if="slotProps.value" class="flex align-items-center">

View File

@ -17,9 +17,21 @@ const items = computed(() => [
route: "/edit",
},
{
label: t("roomFinder"),
icon: "pi pi-fw pi-calendar",
route: "/rooms",
label: t("rooms"),
icon: "pi pi-fw pi-angle-down",
info: "rooms",
items: [
{
label: t("roomFinderPage.roomSchedule"),
icon: "pi pi-fw pi-hourglass",
route: "/rooms/occupancy",
},
{
label: t("freeRooms.freeRooms"),
icon: "pi pi-fw pi-calendar",
route: "/rooms/free",
},
],
},
{
label: t("faq"),
@ -44,26 +56,42 @@ const items = computed(() => [
<template #start>
<router-link v-slot="{ navigate }" :to="`/`" custom>
<Button severity="secondary" text class="p-0 mx-2" @click="navigate">
<img
width="50"
height="50"
src="../../public/htwk.svg"
alt="Logo"
/>
<img width="50" height="50" src="../../public/htwk.svg" alt="Logo" />
</Button>
</router-link>
</template>
<template #item="{ item }">
<router-link v-slot="{ navigate }" :to="item.route" custom>
<Button
:label="String(item.label)"
:icon="item.icon"
text
severity="secondary"
:class="item.route === $route.path ? 'active' : ''"
<template #item="{ item, props }">
<router-link
v-if="item.route"
v-slot="{ navigate }"
:to="item.route"
custom
>
<a
:class="
$route.path == item.route
? 'flex align-items-center active'
: 'flex align-items-center'
"
v-bind="props.action"
@click="navigate"
/>
>
<span :class="item.icon" />
<span class="ml-2 p-menuitem-label">{{ item.label }}</span>
</a>
</router-link>
<a
v-else
:class="
$route.path.includes(item.info)
? 'flex align-items-center active'
: 'flex align-items-center'
"
v-bind="props.action"
>
<span :class="item.icon" />
<span class="ml-2 p-menuitem-label">{{ item.label }}</span>
</a>
</template>
<template #end>
<div class="flex align-items-stretch justify-content-center">
@ -80,7 +108,11 @@ const items = computed(() => [
border: none;
}
:deep(.p-button .p-button-label::after) {
:deep(.p-submenu-list) {
border-radius: 6px;
}
:deep(.p-menuitem-link .p-menuitem-label::after) {
content: "";
display: block;
width: 0;
@ -89,7 +121,7 @@ const items = computed(() => [
transition: width 0.3s;
}
:deep(.p-button.active .p-button-label::after) {
:deep(.p-menuitem-link.active .p-menuitem-label::after) {
width: 100%;
}
</style>

View File

@ -47,7 +47,7 @@ async function getOccupation() {
currentDateTo.value,
)
.then((events) => {
occupations.value = events.map((event, index) => {
occupations.value = events?.map((event, index) => {
return {
id: index,
start: event.start.replace(/\s\+\d{4}\s\w+$/, "").replace(" ", "T"),

View File

@ -3,11 +3,8 @@ import en from "./translations/en.json";
import de from "./translations/de.json";
import localeStore from "../store/localeStore.ts";
export const supportedLocales = {
en: { name: "English" },
de: { name: "Deutsch" },
};
// Private instance of VueI18n object
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let _i18n: any;
// Initializer
function setup() {

View File

@ -1,18 +0,0 @@
export default {
en: {
createCalendar: "Create Calendar",
editCalendar: "Edit Calendar",
roomFinder: "Room Finder",
faq: "FAQ",
imprint: "Imprint",
privacy: "Privacy Policy",
},
de: {
createCalendar: "Kalender erstellen",
editCalendar: "Kalender bearbeiten",
roomFinder: "Raumfinder",
faq: "FAQ",
imprint: "Impressum",
privacy: "Datenschutz",
},
};

View File

@ -2,7 +2,7 @@
"languageCode": "de",
"createCalendar": "Kalender erstellen",
"editCalendar": "Kalender bearbeiten",
"roomFinder": "Raumfinder",
"rooms": "Räume",
"faq": "FAQ",
"imprint": "Impressum",
"privacy": "Datenschutz",
@ -19,13 +19,22 @@
"semesterDropDown": "Semester"
},
"roomFinderPage": {
"headline": "Raumfinder",
"roomSchedule": "Raumbelegung",
"headline": "Raumbelegung",
"detail": "Bitte wähle einen Raum aus, um die Belegung einzusehen",
"dropDownSelect": "Bitte wähle einen Raum aus",
"noRoomsAvailable": "Keine Räume verfügbar",
"available": "verfügbar",
"occupied": "belegt"
},
"freeRooms": {
"freeRooms": "Freie Räume",
"detail": "Bitte wähle einen Zeitraum aus, um alle Räume ohne Belegung anzuzeigen.",
"searchByRoom": "Suche nach Räumen",
"pleaseSelectDate": "Bitte wähle ein Datum aus",
"room": "Raum",
"search": "Suchen"
},
"moduleSelection": {
"selectAll": "Alle anwählen",
"deselectAll": "Alle abwählen",
@ -221,6 +230,8 @@
"seventhAnswer": "Studenpläne sind erstmal nur für die ausgewählten Semester gültig. Da durch Wahlpflichtmodule oder deine Planung sich Veränderungen ergeben können.",
"eighthQuestion": "Preis und Entwicklung?",
"eighthAnswer": "Die Kosten können durch das selbständiges Hosting vollständig ausgelagert werden. Die Entwicklung soll als aktives Git Projekt auch durch die Community verwaltet werden.",
"ninthQuestion": "Wo kann ich den Quellcode einsehen und mitwirken?",
"ninthAnswer": "Wenn du dich für die Entwicklung und den Quelltext interessierst, kannst du jederzeit als HTWK-Student daran mitarbeiten. Quelltext und weitere Informationen findest du im ",
"notFound": "Nicht gefunden, wonach du suchst?",
"contact": "Kontakt aufnehmen"
}

View File

@ -2,7 +2,7 @@
"languageCode": "en",
"createCalendar": "create calendar",
"editCalendar": "edit calendar",
"roomFinder": "room finder",
"rooms": "rooms",
"faq": "faq",
"imprint": "imprint",
"privacy": "privacy",
@ -19,13 +19,22 @@
"semesterDropDown": "please select a semester"
},
"roomFinderPage": {
"headline": "room finder",
"detail": "please select a room to view the occupancy",
"roomSchedule": "room occupancy",
"headline": "room occupancy plan",
"detail": "Please select a room to view the occupancy.",
"dropDownSelect": "please select a room",
"noRoomsAvailable": "no rooms listed",
"available": "available",
"occupied": "occupied"
},
"freeRooms": {
"freeRooms": "free rooms",
"detail": "Please select a time period to display rooms that have no occupancy.",
"searchByRoom": "search by room",
"pleaseSelectDate": "please select a date",
"room": "room",
"search": "search"
},
"moduleSelection": {
"selectAll": "select all",
"deselectAll": "deselect all",
@ -221,6 +230,8 @@
"seventhAnswer": "Timetables are initially only valid for the selected semesters, as changes can occur due to elective modules or your planning.",
"eighthQuestion": "Cost and development?",
"eighthAnswer": "Costs can be completely outsourced through self-hosting. Development is intended to be managed by the community as an active Git project.",
"ninthQuestion": "Where could i find the source code?",
"ninthAnswer": "If you want to contribute, you can do so at any time if you are a HTWK student. The source code is available on ",
"notFound": "Not finding what you're looking for?",
"contact": "Get in touch"
}

View File

@ -0,0 +1,155 @@
{
"startsWith": "Beginnt mit",
"contains": "Enthält",
"notContains": "Enthält nicht",
"endsWith": "Endet mit",
"equals": "Ist gleich",
"notEquals": "Ist ungleich",
"noFilter": "Kein Filter",
"filter": "Filtern",
"lt": "Kleiner als",
"lte": "Kleiner oder gleich",
"gt": "Größer als",
"gte": "Größer oder gleich",
"dateIs": "Datum ist",
"dateIsNot": "Datum ist nicht",
"dateBefore": "Datum ist vor",
"dateAfter": "Datum ist nach",
"custom": "Benutzerdefiniert",
"clear": "Löschen",
"apply": "Übernehmen",
"matchAll": "Passt auf alle",
"matchAny": "Passt auf einige",
"addRule": "Regel hinzufügen",
"removeRule": "Regel entfernen",
"accept": "Ja",
"reject": "Nein",
"choose": "Auswählen",
"upload": "Hochladen",
"cancel": "Abbrechen",
"completed": "Abgeschlossen",
"pending": "Ausstehend",
"fileSizeTypes": ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"],
"dayNames": [
"Sonntag",
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag"
],
"dayNamesShort": ["Son", "Mon", "Die", "Mit", "Don", "Fre", "Sam"],
"dayNamesMin": ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
"monthNames": [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember"
],
"monthNamesShort": [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez"
],
"chooseYear": "Jahr wählen",
"chooseMonth": "Monat wählen",
"chooseDate": "Datum wählen",
"prevDecade": "Vorheriges Jahrzehnt",
"nextDecade": "Nächstes Jahrzehnt",
"prevYear": "Vorheriges Jahr",
"nextYear": "Nächstes Jahr",
"prevMonth": "Vorheriger Monat",
"nextMonth": "Nächster Monat",
"prevHour": "Vorherige Stunde",
"nextHour": "Nächste Stunde",
"prevMinute": "Vorherige Minute",
"nextMinute": "Nächste Minute",
"prevSecond": "Vorherige Sekunde",
"nextSecond": "Nächste Sekunde",
"am": "am",
"pm": "pm",
"today": "Heute",
"now": "Jetzt",
"weekHeader": "KW",
"firstDayOfWeek": 1,
"showMonthAfterYear": false,
"dateFormat": "dd.mm.yy",
"weak": "Schwach",
"medium": "Mittel",
"strong": "Stark",
"passwordPrompt": "Passwort eingeben",
"emptyFilterMessage": "Keine Ergebnisse gefunden",
"searchMessage": "{0} Ergebnisse verfügbar",
"selectionMessage": "{0} Elemente ausgewählt",
"emptySelectionMessage": "Kein ausgewähltes Element",
"emptySearchMessage": "Keine Ergebnisse gefunden",
"emptyMessage": "Keine Einträge gefunden",
"aria": {
"trueLabel": "Wahr",
"falseLabel": "Falsch",
"nullLabel": "Nicht ausgewählt",
"star": "1 Stern",
"stars": "{star} Sterne",
"selectAll": "Alle Elemente ausgewählt",
"unselectAll": "Alle Elemente abgewählt",
"close": "Schließen",
"previous": "Vorherige",
"next": "Nächste",
"navigation": "Navigation",
"scrollTop": "Nach oben scrollen",
"moveTop": "Zum Anfang bewegen",
"moveUp": "Nach oben bewegen",
"moveDown": "Nach unten bewegen",
"moveBottom": "Zum Ende bewegen",
"moveToTarget": "Zum Ziel bewegen",
"moveToSource": "Zur Quelle bewegen",
"moveAllToTarget": "Alle zum Ziel bewegen",
"moveAllToSource": "Alle zur Quelle bewegen",
"pageLabel": "Seite {page}",
"firstPageLabel": "Erste Seite",
"lastPageLabel": "Letzte Seite",
"nextPageLabel": "Nächste Seite",
"previousPageLabel": "Vorherige Seite",
"rowsPerPageLabel": "Zeilen pro Seite",
"jumpToPageDropdownLabel": "Zum Dropdown-Menü springen",
"jumpToPageInputLabel": "Zum Eingabefeld springen",
"selectRow": "Zeile ausgewählt",
"unselectRow": "Zeile abgewählt",
"expandRow": "Zeile erweitert",
"collapseRow": "Zeile reduziert",
"showFilterMenu": "Filtermenü anzeigen",
"hideFilterMenu": "Filtermenü ausblenden",
"filterOperator": "Filteroperator",
"filterConstraint": "Filterbeschränkung",
"editRow": "Zeile bearbeiten",
"saveEdit": "Änderungen speichern",
"cancelEdit": "Änderungen abbrechen",
"listView": "Listenansicht",
"gridView": "Rasteransicht",
"slide": "Folie",
"slideNumber": "{slideNumber}",
"zoomImage": "Bild vergrößern",
"zoomIn": "Vergrößern",
"zoomOut": "Verkleinern",
"rotateRight": "Nach rechts drehen",
"rotateLeft": "Nach links drehen"
}
}

View File

@ -0,0 +1,155 @@
{
"startsWith": "Starts with",
"contains": "Contains",
"notContains": "Not contains",
"endsWith": "Ends with",
"equals": "Equals",
"notEquals": "Not equals",
"noFilter": "No Filter",
"filter": "Filter",
"lt": "Less than",
"lte": "Less than or equal to",
"gt": "Greater than",
"gte": "Greater than or equal to",
"dateIs": "Date is",
"dateIsNot": "Date is not",
"dateBefore": "Date is before",
"dateAfter": "Date is after",
"custom": "Custom",
"clear": "Clear",
"apply": "Apply",
"matchAll": "Match All",
"matchAny": "Match Any",
"addRule": "Add Rule",
"removeRule": "Remove Rule",
"accept": "Yes",
"reject": "No",
"choose": "Choose",
"upload": "Upload",
"cancel": "Cancel",
"completed": "Completed",
"pending": "Pending",
"fileSizeTypes": ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"],
"dayNames": [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday"
],
"dayNamesShort": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
"dayNamesMin": ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"],
"monthNames": [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
],
"monthNamesShort": [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
],
"chooseYear": "Choose Year",
"chooseMonth": "Choose Month",
"chooseDate": "Choose Date",
"prevDecade": "Previous Decade",
"nextDecade": "Next Decade",
"prevYear": "Previous Year",
"nextYear": "Next Year",
"prevMonth": "Previous Month",
"nextMonth": "Next Month",
"prevHour": "Previous Hour",
"nextHour": "Next Hour",
"prevMinute": "Previous Minute",
"nextMinute": "Next Minute",
"prevSecond": "Previous Second",
"nextSecond": "Next Second",
"am": "AM",
"pm": "PM",
"today": "Today",
"now": "Now",
"weekHeader": "Wk",
"firstDayOfWeek": 0,
"showMonthAfterYear": false,
"dateFormat": "mm/dd/yy",
"weak": "Weak",
"medium": "Medium",
"strong": "Strong",
"passwordPrompt": "Enter a password",
"emptyFilterMessage": "No results found",
"searchMessage": "{0} results are available",
"selectionMessage": "{0} items selected",
"emptySelectionMessage": "No selected item",
"emptySearchMessage": "No results found",
"emptyMessage": "No available options",
"aria": {
"trueLabel": "True",
"falseLabel": "False",
"nullLabel": "Not Selected",
"star": "1 star",
"stars": "{star} stars",
"selectAll": "All items selected",
"unselectAll": "All items unselected",
"close": "Close",
"previous": "Previous",
"next": "Next",
"navigation": "Navigation",
"scrollTop": "Scroll Top",
"moveTop": "Move Top",
"moveUp": "Move Up",
"moveDown": "Move Down",
"moveBottom": "Move Bottom",
"moveToTarget": "Move to Target",
"moveToSource": "Move to Source",
"moveAllToTarget": "Move All to Target",
"moveAllToSource": "Move All to Source",
"pageLabel": "Page {page}",
"firstPageLabel": "First Page",
"lastPageLabel": "Last Page",
"nextPageLabel": "Next Page",
"previousPageLabel": "Previous Page",
"rowsPerPageLabel": "Rows per page",
"jumpToPageDropdownLabel": "Jump to Page Dropdown",
"jumpToPageInputLabel": "Jump to Page Input",
"selectRow": "Row Selected",
"unselectRow": "Row Unselected",
"expandRow": "Row Expanded",
"collapseRow": "Row Collapsed",
"showFilterMenu": "Show Filter Menu",
"hideFilterMenu": "Hide Filter Menu",
"filterOperator": "Filter Operator",
"filterConstraint": "Filter Constraint",
"editRow": "Edit Row",
"saveEdit": "Save Edit",
"cancelEdit": "Cancel Edit",
"listView": "List View",
"gridView": "Grid View",
"slide": "Slide",
"slideNumber": "{slideNumber}",
"zoomImage": "Zoom Image",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"rotateRight": "Rotate Right",
"rotateLeft": "Rotate Left"
}
}

View File

@ -1,4 +1,4 @@
import "source-sans-pro/source-sans-pro.css";
import "source-sans/source-sans-3.css";
import { createApp } from "vue";
import "./style.css";
@ -14,6 +14,7 @@ import InputSwitch from "primevue/inputswitch";
import Card from "primevue/card";
import DataView from "primevue/dataview";
import Dialog from "primevue/dialog";
import Slider from "primevue/slider";
import ToggleButton from "primevue/togglebutton";
import "primeicons/primeicons.css";
import "primeflex/primeflex.css";
@ -35,8 +36,8 @@ import DialogService from "primevue/dialogservice";
import ProgressSpinner from "primevue/progressspinner";
import Checkbox from "primevue/checkbox";
import Skeleton from "primevue/skeleton";
import Calendar from "primevue/calendar";
import i18n from "./i18n";
const app = createApp(App);
const pinia = createPinia();
@ -57,6 +58,7 @@ app.component("InputText", InputText);
app.component("InputSwitch", InputSwitch);
app.component("Card", Card);
app.component("DataView", DataView);
app.component("Slider", Slider);
app.component("ToggleButton", ToggleButton);
app.component("SpeedDial", SpeedDial);
app.component("TabView", TabView);
@ -72,5 +74,6 @@ app.component("DynamicDialog", DynamicDialog);
app.component("ProgressSpinner", ProgressSpinner);
app.component("Checkbox", Checkbox);
app.component("Skeleton", Skeleton);
app.component("Calendar", Calendar);
app.mount("#app");

View File

@ -12,6 +12,7 @@ const EditAdditionalModules = () =>
import("../view/editCalendar/EditAdditionalModules.vue");
const EditModules = () => import("../view/editCalendar/EditModules.vue");
const CourseSelection = () => import("../view/CourseSelection.vue");
const FreeRooms = () => import("../view/FreeRooms.vue");
import i18n from "../i18n";
@ -24,10 +25,15 @@ const router = createRouter({
component: CourseSelection,
},
{
path: "/rooms",
name: "room-finder",
path: "/rooms/occupancy",
name: "room-schedule",
component: RoomFinder,
},
{
path: "/rooms/free",
name: "free-rooms",
component: FreeRooms,
},
{
path: "/faq",
name: "faq",

View File

@ -0,0 +1,251 @@
<template>
<DynamicPage
:hide-content="availableRooms.length === 0"
:headline="$t('freeRooms.freeRooms')"
:sub-title="$t('freeRooms.detail')"
icon="pi pi-search"
:button="{
label: $t('freeRooms.search'),
icon: 'pi pi-search',
disabled: isLater,
onClick: loadFreeRooms,
}"
>
<template #selection="{ flexSpecs }">
<Calendar
v-model="date"
:class="flexSpecs"
:placeholder="$t('freeRooms.pleaseSelectDate')"
:empty-message="$t('roomFinderPage.noRoomsAvailable')"
date-format="dd.mm.yy"
panel-class="min-w-min"
touch-u-i
/>
<div class="break" />
<Calendar
v-if="mobilePage"
v-model="start"
type="time"
placeholder="start"
time-only
hour-format="24"
date-format="HH:mm"
:class="[{ 'p-invalid': isLater }, flexSpecs]"
panel-class="min-w-min"
touch-u-i
@update:model-value="
() => {
timeRange[0] = start.getHours() * 60 + start.getMinutes();
}
"
/>
<Calendar
v-if="mobilePage"
v-model="end"
type="time"
time-only
hour-format="24"
placeholder="end"
date-format="HH:mm"
:class="[{ 'p-invalid': isLater }, flexSpecs]"
panel-class="min-w-min"
touch-u-i
@update:model-value="
() => {
timeRange[1] = end.getHours() * 60 + end.getMinutes();
}
"
/>
<div
v-if="!mobilePage"
class="flex-grow-1 relative mb-2"
:class="flexSpecs"
>
<Tag
:value="formatTime(start)"
class="opacity-0 pointer-events-none text-xl lg:text-base"
/>
<Tag
:value="formatTime(start)"
class="absolute time-tag text-xl lg:text-base"
:style="{ left: timeToPercentString(timeRange[0]) }"
:class="startMoved.value ? 'moved' : ''"
/>
<Tag
:value="formatTime(end)"
class="absolute time-tag text-xl lg:text-base"
:style="{ left: timeToPercentString(timeRange[1]) }"
:class="endMoved.value ? 'moved' : ''"
/>
</div>
<div class="break" />
<Slider
v-if="!mobilePage"
v-model="timeRange"
:class="flexSpecs"
range
:min="0"
:max="24 * 60"
:step="5"
@change="updateTimeRange"
/>
</template>
<template #content>
<DataTable
v-model:filters="filters"
:value="availableRooms"
data-key="id"
filter-display="row"
paginator
:rows="10"
:global-filter-fields="['room']"
>
<Column field="room" sortable :header="$t('freeRooms.room')">
<template #filter="{ filterModel, filterCallback }">
<InputText
v-model="filterModel.value"
type="text"
class="p-column-filter"
:placeholder="$t('freeRooms.searchByRoom')"
@input="filterCallback()"
/>
</template>
</Column>
</DataTable>
</template>
</DynamicPage>
</template>
<script setup lang="ts">
import { computed, inject, Ref, ref } from "vue";
import DynamicPage from "@/view/DynamicPage.vue";
import { requestFreeRooms } from "@/api/requestFreeRooms.ts";
import { FilterMatchMode } from "primevue/api";
import { padStart } from "@fullcalendar/core/internal";
const mobilePage = inject("mobilePage") as Ref<boolean>;
const filters = ref({
room: { value: null, matchMode: FilterMatchMode.CONTAINS, label: "Room" },
});
const date: Ref<Date> = ref(new Date(Date.now()));
const start: Ref<Date> = ref(new Date(0));
const end: Ref<Date> = ref(new Date(0));
class Moved {
value: boolean = false;
timeout: NodeJS.Timeout | null = null;
}
const startMoved: Ref<Moved> = ref(new Moved());
const endMoved: Ref<Moved> = ref(new Moved());
start.value.setHours(new Date().getHours());
end.value.setHours(Math.min(new Date().getHours() + 3, 23));
if (end.value.getHours() === 23) {
start.value.setHours(22);
end.value.setMinutes(55);
}
const timeRange: Ref<number[]> = ref([
start.value.getHours() * 60 + start.value.getMinutes(),
end.value.getHours() * 60 + end.value.getMinutes(),
]);
function buttonMovedTimeout(
time: number,
timeDate: Date,
moved: Ref<Moved>,
): void {
if (time !== timeDate.getHours() * 60 + timeDate.getMinutes()) {
if (moved.value.timeout !== null) {
clearTimeout(moved.value.timeout);
}
moved.value.value = true;
moved.value.timeout = setTimeout(() => {
moved.value.value = false;
}, 1000);
}
}
function updateTimeRange(): void {
buttonMovedTimeout(timeRange.value[0], start.value, startMoved);
buttonMovedTimeout(timeRange.value[1], end.value, endMoved);
if (timeRange.value[0] > timeRange.value[1]) {
timeRange.value[1] = timeRange.value[0];
}
start.value.setHours(Math.floor(timeRange.value[0] / 60));
start.value.setMinutes(timeRange.value[0] % 60);
end.value.setHours(Math.floor(timeRange.value[1] / 60));
end.value.setMinutes(timeRange.value[1] % 60);
}
function formatTime(time: Date): string {
return (
padStart(time.getHours().toString(), 2) +
":" +
padStart(time.getMinutes().toString(), 2)
);
}
function timeToPercentString(time: number): string {
return `${(Number(time) * 100) / (24 * 60)}%`;
}
async function loadFreeRooms(): Promise<void> {
availableRooms.value = [];
const startDate = new Date(date.value.getTime());
startDate.setHours(start.value.getHours());
startDate.setMinutes(start.value.getMinutes());
const endDate = new Date(date.value.getTime());
endDate.setHours(end.value.getHours());
endDate.setMinutes(end.value.getMinutes());
await requestFreeRooms(startDate.toISOString(), endDate.toISOString()).then(
(data) => {
availableRooms.value = data.map((room, index) => {
return { id: index, room: room };
});
},
);
}
const isLater = computed(() => {
return start.value > end.value;
});
const availableRooms: Ref<{ id: number; room: string }[]> = ref([]);
</script>
<style scoped>
.break {
flex-basis: 100%;
height: 0;
}
.time-tag {
transform: translateX(-50%);
border: 2px solid var(--primary-color);
background-color: var(--primary-color);
opacity: 0.6;
transition: opacity 0.2s ease-in-out;
}
.time-tag.moved {
opacity: 1;
z-index: 2;
}
.time-tag::before {
content: "";
position: absolute;
bottom: -0.8rem;
left: 50%;
width: 0;
height: 0;
border-style: solid;
border-width: 0.8rem 0.5rem 0 0.5rem;
border-color: var(--primary-color) transparent transparent transparent;
transform: translateX(-50%);
}
</style>

View File

@ -3,20 +3,16 @@ import { Ref, ref } from "vue";
import { fetchRoom } from "../api/fetchRoom.ts";
import DynamicPage from "./DynamicPage.vue";
import RoomOccupation from "../components/RoomOccupation.vue";
import { computedAsync } from "@vueuse/core";
const rooms = async () => {
return await fetchRoom();
};
const roomsList: Ref<{ name: string }[]> = ref([]);
const selectedRoom: Ref<{ name: string }> = ref({ name: "" });
rooms().then(
(data) =>
(roomsList.value = data.map((room) => {
const roomsList = computedAsync(async () => {
return await fetchRoom().then((data) =>
data.map((room) => {
return { name: room };
})),
);
}),
);
});
const selectedRoom: Ref<{ name: string }> = ref({ name: "" });
</script>
<template>

View File

@ -16,5 +16,12 @@ export default defineConfig({
watch: {
usePolling: true,
},
proxy: {
"/api": {
target: "http://localhost:8090/api",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});