Repository: StudyConnect-ZHAW/Frontend
Pull request #71: Feat/#65 implement a UI for timetable calender
Author: login: sussu01
Labels: name: feat
description: New feature or request
Created: 2025-05-15T07:04:40Z | Merged: 2025-05-21T22:27:26Z

## Resolves  #65


## Description

This pull request implements the **ZHAW Timetable calendar UI** based on FullCalendar and integrates it into the homepage and a standalone calendar page.

- Configured `FullCalendar` with support for:
  - Day view (timeGridDay) as default on the homepage
  - Month, week, list views on the calendar page
- Integrated dynamic event fetching from the internal `/api/calendar` endpoint
- Added support for Swiss public holidays using the OpenHolidays API
- Implemented internationalization (`i18next`) for holiday names and date localization (`de-CH` / `en-GB`)
- Auto-adjusts calendar `slotMinTime` and `slotMaxTime` based on lesson times
- Documentation: [05. Bausteinsicht](https://github.com/StudyConnect-ZHAW/StudyConnect/wiki/05.-Bausteinsicht#kalenderkomponente-zhaw-stundenplan--feiertagsintegration)

## Checklist

Please make sure that the following items have been completed before submitting this pull request:

- [x] All issue acceptance criteria are fulfilled
- [x] All code has been properly tested
- [x] All tests pass successfully
- [x] Meets coding standards
- [x] Code has been properly documented with comments
- [x] Manual Testing was done by Developer and Reviewer
- [x] Refactoring is done (if needed)
- [x] Documentation (README/API/UX/ARC42) updated
- [x] Related non-linked issue is updated

To test this PR, please change your email address to the zhaw email address on swagger. Will be fixed in https://github.com/StudyConnect-ZHAW/Frontend/issues/72
PUT request /api/v1/users/{id}.

Linked issue #65: Implement a UI for Timetable Calender
**Is your feature request related to a problem? Please describe.**
A Calender UI needs to be implemented. 
- See Teams For API Endpoints of the ZHAW Timetable.

**Describe the solution you'd like**
Make a basic UI of the Timetable mathing colors and design scheme of StudyConnect Theme.
- For now, only the timetable’s user interface needs to be built; calendar functionality that depends on user interaction can be added later.

**Describe alternatives you've considered**
none

**Additional context**
- See Teams for futher Information

**Story points:** 7


Diff:
diff --git a/next.config.ts b/next.config.ts
index f7164f78..2e5291ae 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,6 +1,7 @@
 import type { NextConfig } from "next";
 
 const nextConfig: NextConfig = {
+  reactStrictMode: false, // To disable certain code firing twice in DEV
   /* config options here */
   env: {
     NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, // This will be taken from .env file
diff --git a/package-lock.json b/package-lock.json
index 93eff158..7a755a8a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,10 @@
       "version": "0.1.0",
       "dependencies": {
         "@azure/msal-node": "^3.5.3",
+        "@fullcalendar/daygrid": "^6.1.17",
+        "@fullcalendar/list": "^6.1.17",
+        "@fullcalendar/react": "^6.1.17",
+        "@fullcalendar/timegrid": "^6.1.17",
         "@headlessui/react": "^2.2.2",
         "@tailwindcss/postcss": "^4.1.6",
         "i18next-http-backend": "^3.0.2",
@@ -923,6 +927,57 @@
       "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
       "license": "MIT"
     },
+    "node_modules/@fullcalendar/core": {
+      "version": "6.1.17",
+      "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.17.tgz",
+      "integrity": "sha512-0W7lnIrv18ruJ5zeWBeNZXO8qCWlzxDdp9COFEsZnyNjiEhUVnrW/dPbjRKYpL0edGG0/Lhs0ghp1z/5ekt8ZA==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "preact": "~10.12.1"
+      }
+    },
+    "node_modules/@fullcalendar/daygrid": {
+      "version": "6.1.17",
+      "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.17.tgz",
+      "integrity": "sha512-K7m+pd7oVJ9fW4h7CLDdDGJbc9szJ1xDU1DZ2ag+7oOo1aCNLv44CehzkkknM6r8EYlOOhgaelxQpKAI4glj7A==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@fullcalendar/core": "~6.1.17"
+      }
+    },
+    "node_modules/@fullcalendar/list": {
+      "version": "6.1.17",
+      "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.17.tgz",
+      "integrity": "sha512-fkyK49F9IxwlGUBVhJGsFpd/LTi/vRVERLIAe1HmBaGkjwpxnynm8TMLb9mZip97wvDk3CmZWduMe6PxscAlow==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@fullcalendar/core": "~6.1.17"
+      }
+    },
+    "node_modules/@fullcalendar/react": {
+      "version": "6.1.17",
+      "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.17.tgz",
+      "integrity": "sha512-AA8soHhlfRH5dUeqHnfAtzDiXa2vrgWocJSK/F5qzw/pOxc9MqpuoS/nQBROWtHHg6yQUg3DoGqOOhi7dmylXQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@fullcalendar/core": "~6.1.17",
+        "react": "^16.7.0 || ^17 || ^18 || ^19",
+        "react-dom": "^16.7.0 || ^17 || ^18 || ^19"
+      }
+    },
+    "node_modules/@fullcalendar/timegrid": {
+      "version": "6.1.17",
+      "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.17.tgz",
+      "integrity": "sha512-K4PlA3L3lclLOs3IX8cvddeiJI9ZVMD7RA9IqaWwbvac771971foc9tFze9YY+Pqesf6S+vhS2dWtEVlERaGlQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@fullcalendar/daygrid": "~6.1.17"
+      },
+      "peerDependencies": {
+        "@fullcalendar/core": "~6.1.17"
+      }
+    },
     "node_modules/@headlessui/react": {
       "version": "2.2.3",
       "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.3.tgz",
@@ -2467,9 +2522,9 @@
       "license": "MIT"
     },
     "node_modules/@types/node": {
-      "version": "22.15.17",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz",
-      "integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==",
+      "version": "22.15.18",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.18.tgz",
+      "integrity": "sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -4027,9 +4082,9 @@
       }
     },
     "node_modules/debug": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
-      "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+      "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -4957,9 +5012,9 @@
       }
     },
     "node_modules/eventsource-parser": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz",
-      "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==",
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.2.tgz",
+      "integrity": "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -7617,6 +7672,17 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/preact": {
+      "version": "10.12.1",
+      "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
+      "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/preact"
+      }
+    },
     "node_modules/prelude-ls": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
diff --git a/package.json b/package.json
index ed9c9f9c..907880e6 100644
--- a/package.json
+++ b/package.json
@@ -12,10 +12,14 @@
     "sonar": "sonar-scanner"
   },
   "dependencies": {
-    "i18next-http-backend": "^3.0.2",
     "@azure/msal-node": "^3.5.3",
+    "@fullcalendar/daygrid": "^6.1.17",
+    "@fullcalendar/list": "^6.1.17",
+    "@fullcalendar/react": "^6.1.17",
+    "@fullcalendar/timegrid": "^6.1.17",
     "@headlessui/react": "^2.2.2",
     "@tailwindcss/postcss": "^4.1.6",
+    "i18next-http-backend": "^3.0.2",
     "jwt-decode": "^4.0.0",
     "next": "^15.3.2",
     "postcss": "^8.5.3",
diff --git a/public/locales/de-CH/calendar.json b/public/locales/de-CH/calendar.json
index 54e19c19..570d645f 100644
--- a/public/locales/de-CH/calendar.json
+++ b/public/locales/de-CH/calendar.json
@@ -1,3 +1,4 @@
 {
-  "title": "Kalender"
+  "title": "Kalender",
+  "semesterBreak": "Semesterunterbruch"
 }
\ No newline at end of file
diff --git a/public/locales/en-GB/calendar.json b/public/locales/en-GB/calendar.json
index 0242f582..f55c89fe 100644
--- a/public/locales/en-GB/calendar.json
+++ b/public/locales/en-GB/calendar.json
@@ -1,3 +1,4 @@
 {
-  "title": "Calendar"
+  "title": "Calendar",
+  "semesterBreak": "Semester Break"
 }
\ No newline at end of file
diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts
new file mode 100644
index 00000000..2fc1d5ac
--- /dev/null
+++ b/src/app/api/calendar/route.ts
@@ -0,0 +1,87 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { jwtDecode } from 'jwt-decode';
+
+/*
+ * API Route: /api/calendar
+ * Fetches the ZHAW schedule for authenticated users with a valid ZHAW email.
+ * Requires a valid JWT in the 'access_token' cookie.
+ */
+export async function GET(req: NextRequest) {
+  try {
+    const cookieHeader = req.headers.get('cookie');
+
+    // Extract JWT from cookies
+    const token = cookieHeader
+      ?.split(';')
+      .find((c) => c.trim().startsWith('access_token='))
+      ?.split('=')[1];
+
+    if (!token) {
+      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+    }
+
+    const decoded = jwtDecode<{ oid: string }>(token);
+    const userId = decoded.oid;
+
+    const apiUrl = process.env.NEXT_PUBLIC_API_URL!;
+    const userRes = await fetch(`${apiUrl}v1/users/${userId}`);
+
+    if (!userRes.ok) {
+      return NextResponse.json({ error: 'User not found' }, { status: 404 });
+    }
+
+    const user = await userRes.json();
+    const email = user.email?.toLowerCase() ?? '';
+
+    // Extract the username (before @); must match the ZHAW shortname used in the calendar system
+    let shortName: string | undefined;
+
+    if (email.includes('@')) {
+      shortName = email.split('@')[0];
+    } else {
+      return NextResponse.json({ error: 'Invalid email format' }, { status: 400 });
+    }
+
+    // Extract params
+    const rawDateStr = req.nextUrl.searchParams.get('startingAt') ?? new Date().toISOString().split('T')[0];
+    const view = req.nextUrl.searchParams.get('view') ?? '';
+    const date = new Date(rawDateStr);
+
+    // ZHAW weeks start on Monday but API returns Sunday, shift only for non-day views
+    if (view !== 'timeGridDay') {
+      date.setDate(date.getDate() + 1);
+    }
+
+    const startingAt = date.toISOString().split('T')[0];
+
+    const scheduleRes = await fetch(
+      `https://api.apps.engineering.zhaw.ch/v1/schedules/students/${shortName}?startingAt=${startingAt}`,
+      {
+        headers: {
+          'User-Agent': 'StudyConnect (https://github.com/StudyConnect-ZHAW)',
+          Accept: 'application/json',
+        },
+      }
+    );
+
+    if (scheduleRes.status === 404) {
+
+      return NextResponse.json({ days: [] });
+    }
+
+    if (!scheduleRes.ok) {
+
+      return NextResponse.json({ error: 'Failed to fetch ZHAW calendar' }, { status: scheduleRes.status });
+    }
+
+    const schedule = await scheduleRes.json();
+
+    return NextResponse.json(schedule);
+
+  } catch (error) {
+    // eslint-disable-next-line no-console
+    console.error('API-Error:', error);
+
+    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+  }
+}
\ No newline at end of file
diff --git a/src/app/calendar/page.tsx b/src/app/calendar/page.tsx
index 668d85b8..605f220f 100644
--- a/src/app/calendar/page.tsx
+++ b/src/app/calendar/page.tsx
@@ -1,13 +1,21 @@
+'use client';
+
 import React from 'react';
 import PageHeader from '@/components/PageHeader';
-import WIPSection from "@/components/WIPSection";
+import { useTranslation } from 'react-i18next';
+import Calendar from '@/components/Calendar';
 
+// * CalendarPage: Standalone page that displays the ZHAW calendar with public holidays
 export default function CalendarPage() {
+  const { t } = useTranslation(['calendar', 'common']);
+    
   return (
     <>
-      <PageHeader title='Calendar' />
-
-      <WIPSection />
+      {/* Page title */}
+      <PageHeader title={t('title')} />
+      
+      {/* Full calendar component */}
+      <Calendar />
     </>
   );
 }
\ No newline at end of file
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 15db4fd6..fc3e2e2e 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -5,25 +5,20 @@ import React, { useEffect, useState } from 'react';
 import { useTranslation } from "react-i18next";
 import PageHeader from '@/components/PageHeader';
 import WIPSection from "@/components/WIPSection";
+import Calendar from '@/components/Calendar';
 
+// * HomePage component: displays welcome header, sections, and calendar for the current ZHAW user
 const HomePage = () => {
-  const [formattedDate, setFormattedDate] = useState<string | null>(null);
   const [userName, setUserName] = useState<string | null>(null);
 
   const { t, i18n } = useTranslation('common');
 
   useEffect(() => {
-    const date = new Intl.DateTimeFormat(i18n.language, {
-      weekday: 'long',
-      day: '2-digit',
-      month: '2-digit',
-      year: 'numeric',
-    }).format(new Date());
-    setFormattedDate(date);
-
     const fetchUser = async () => {
       try {
         const res = await fetch('/api/me');
+
+        // If not authenticated, redirect to login
         if (!res.ok) {
           redirect('/login');
         }
@@ -36,10 +31,12 @@ const HomePage = () => {
       }
     };
 
+    // Fetch user data on language change
     fetchUser();
   }, [i18n.language]);
 
-  if (!userName || !formattedDate) {
+  // Don't render the page until the user is loaded
+  if (!userName) {
     return null;
   }
 
@@ -51,11 +48,7 @@ const HomePage = () => {
         {/* Top row: left empty, right shows date */}
         <div className="flex flex-row gap-8">
           <div className="flex-grow basis-0" />
-          <div className="flex-grow basis-0">
-            <div className="text-2xl font-bold">
-              {formattedDate}
-            </div>
-          </div>
+          <div className="flex-grow basis-0" />
         </div>
 
         <div className="flex flex-row gap-8 h-full">
@@ -72,8 +65,8 @@ const HomePage = () => {
           </div>
 
           {/* Right column */}
-          <div className="flex flex-col flex-grow basis-0">
-            <WIPSection />
+          <div className="flex flex-col gap-4 flex-grow basis-0">
+            <Calendar initialView="timeGridDay" showHeader={false} />
           </div>
         </div>
       </div>
diff --git a/src/components/Calendar.tsx b/src/components/Calendar.tsx
index 292ea9be..06bd836e 100644
--- a/src/components/Calendar.tsx
+++ b/src/components/Calendar.tsx
@@ -1,12 +1,185 @@
-/**
- * Component that represents a calendar integrated in the page.
- *
- * TODO: Different components for week view and month view of the calendar?
- */
-export default function Calendar() {
-    return (
-        <>
-            {/* TODO: Implement component structure and/or logic.*/}
-        </>
-    )
+'use client';
+
+import React, { useCallback, useState } from 'react';
+import FullCalendar from '@fullcalendar/react';
+import dayGridPlugin from '@fullcalendar/daygrid';
+import timeGridPlugin from '@fullcalendar/timegrid';
+import enLocale from '@fullcalendar/core/locales/en-gb';
+import deLocale from '@fullcalendar/core/locales/de';
+import { useTranslation } from 'react-i18next';
+import { type EventInput, type EventSourceFuncArg, formatDate } from '@fullcalendar/core';
+
+import { mapZhawDaysToEvents } from '@/lib/calendar';
+import { fetchPublicHolidays } from '@/lib/api/openholidays';
+import type { ZhawSchedule } from '@/types/calendar';
+
+interface CalendarProps {
+    initialView?: 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay' | 'listWeek';
+    showHeader?: boolean;
+  }
+
+/*
+* Calendar component for displaying ZHAW schedule and Swiss public holidays
+* Integrates FullCalendar and fetches weekly schedule + holidays dynamically
+*/
+export default function Calendar({ initialView = 'dayGridMonth', showHeader = true }: CalendarProps) {
+  const { t, i18n } = useTranslation(['calendar']);
+  const calendarLocale = i18n.language === 'de-CH' ? deLocale : enLocale;
+
+  const [slotMinTime] = useState('08:00:00');
+  const [slotMaxTime] = useState('22:00:00');
+
+  const fetchEventsDynamically = useCallback(
+    async (
+      fetchInfo: EventSourceFuncArg,
+      successCallback: (events: EventInput[]) => void,
+      failureCallback: (error: Error) => void
+    ) => {
+      try {
+        const viewStart = new Date(fetchInfo.start ?? new Date());
+        const viewEnd = new Date(fetchInfo.end ?? new Date());
+
+        const viewType = (fetchInfo as { view?: { type?: string } }).view?.type ?? initialView;
+        const isDailyView = viewType === 'timeGridDay';
+
+        const allEvents: EventInput[] = [];
+        const fetchedWeeks = new Set<string>();
+        const maxDays = 7;
+
+        // Add Swiss public holidays for the current year
+        const publicHolidays = await fetchPublicHolidays(viewStart.getFullYear());
+        allEvents.push(...publicHolidays);
+
+        if (isDailyView) {
+          const adjustedDate = new Date(viewStart);
+          const startingAt = adjustedDate.toISOString().split('T')[0];
+
+          // Fetch calendar for a single day
+          const res = await fetch(`/api/calendar?startingAt=${startingAt}&view=timeGridDay`);
+          if (!res.ok) {return;}
+
+          const data: ZhawSchedule = await res.json();
+
+          const filtered = data.days.map((day) => ({
+            ...day,
+            events: (day.events ?? []).filter((e) => e.type !== 'Holiday'),
+          }));
+
+          // Convert to FullCalendar format and add to event list
+          allEvents.push(...mapZhawDaysToEvents({ days: filtered }));
+        } else {
+          // Weekly/monthly view – loop through each week
+          for (let date = new Date(viewStart); date <= viewEnd; date.setDate(date.getDate() + maxDays)) {
+            const adjustedDate = new Date(date);
+
+            // Shift by +1 day because ZHAW calendar weeks start on Monday (API returns Sunday)
+            adjustedDate.setDate(adjustedDate.getDate() + 1);
+
+            const startingAt = adjustedDate.toISOString().split('T')[0];
+
+            // Avoid duplicate API requests for the same week
+            if (fetchedWeeks.has(startingAt)) {
+              continue;
+            }
+            fetchedWeeks.add(startingAt);
+
+            // Fetch calendar for the current week
+            const res = await fetch(`/api/calendar?startingAt=${startingAt}&view=${viewType}`);
+            if (!res.ok) {continue;}
+
+            const data: ZhawSchedule = await res.json();
+
+            // Check if the week is empty (no regular events, only holidays or none)
+            const isWeekEmpty =
+              !data.days || data.days.length === 0 ||
+              data.days.every((day) => !day.events || day.events.every((e) => e.type === 'Holiday'));
+
+            if (isWeekEmpty) {
+              // Add placeholder event to indicate semester break
+              const end = new Date(adjustedDate);
+              end.setDate(end.getDate() + 7);
+              allEvents.push({
+                title: t('semesterBreak'),
+                start: startingAt,
+                end: end.toISOString().split('T')[0],
+                allDay: true,
+                color: '#F85A6D',
+              });
+            } else {
+              // Filter out holidays and map events to calendar format
+              const filtered = data.days.map((day) => ({
+                ...day,
+                events: (day.events ?? []).filter((e) => e.type !== 'Holiday'),
+              }));
+              allEvents.push(...mapZhawDaysToEvents({ days: filtered }));
+            }
+          }
+        }
+
+        successCallback(allEvents);
+      } catch (err) {
+        failureCallback(err as Error);
+      }
+    },
+    [initialView, t]
+  );
+
+  return (
+    <div className="flex-grow flex items-center justify-center h-full">
+      <div className="w-full max-w-4xl h-full">
+        <FullCalendar
+          plugins={[dayGridPlugin, timeGridPlugin]}
+          initialView={initialView}
+          headerToolbar={
+            showHeader
+              ? {
+                left: 'prev,next today',
+                center: 'title',
+                right: 'dayGridMonth,timeGridWeek,timeGridDay',
+              }
+              : false
+          }
+          events={fetchEventsDynamically}
+          locale={calendarLocale}
+          slotMinTime={slotMinTime}
+          slotMaxTime={slotMaxTime}
+          height="100%"
+          dayHeaderContent={
+            initialView === 'timeGridDay'
+              ? (arg) => ({
+                html: formatDate(arg.date, {
+                  weekday: 'long',
+                  day: '2-digit',
+                  month: '2-digit',
+                  year: 'numeric',
+                  locale: calendarLocale.code,
+                }),
+              })
+              : undefined
+          }
+          
+          /**
+           * Only override rendering if in timeGrid views.
+           * Otherwise let FullCalendar render its default styled event.
+           **/
+          eventContent={
+            initialView === 'timeGridDay' || initialView === 'timeGridWeek'
+              ? (arg) => {
+                const room = arg.event.extendedProps.room;
+
+                return (
+                  <>
+                    <div>{arg.event.title}</div>
+                    {room && (
+                      <div className="text-xs text-white-400">{room}</div>
+                    )}
+                  </>
+                );
+              }
+              : undefined
+          }
+        />
+      </div>
+    </div>
+  );
 }
\ No newline at end of file
diff --git a/src/lib/api/openholidays.ts b/src/lib/api/openholidays.ts
new file mode 100644
index 00000000..1db17fce
--- /dev/null
+++ b/src/lib/api/openholidays.ts
@@ -0,0 +1,35 @@
+import type { OpenHoliday } from '@/types/openholidays';
+import i18n from 'i18next';
+
+/**
+ * Fetches public holidays in Switzerland (CH) for a given year.
+ * Uses OpenHolidays API and translates based on current i18n language.
+ * Filters holidays for nationwide or Zurich (CH-ZH) region only.
+ */
+export async function fetchPublicHolidays(year: number) {
+  const lang = i18n.language;
+  const isoLang = lang.startsWith('de') ? 'DE' : 'EN';
+
+  const res = await fetch(
+    `https://openholidaysapi.org/PublicHolidays?countryIsoCode=CH&languageIsoCode=${isoLang}&validFrom=${year}-01-01&validTo=${year}-12-31`
+  );
+
+  if (!res.ok) {return [];}
+
+  const holidays: OpenHoliday[] = await res.json();
+
+  return holidays
+    // Only include nationwide or Zurich-specific holidays
+    .filter((holiday) =>
+      holiday.nationwide === true ||
+      holiday.subdivisions?.some((sub) => sub.code === 'CH-ZH')
+    )
+    // Map holidays to FullCalendar-compatible format
+    .map((holiday) => ({
+      title: holiday.name[0].text,
+      start: holiday.startDate,
+      end: holiday.endDate,
+      allDay: true,
+      color: '#F85A6D',
+    }));
+}
\ No newline at end of file
diff --git a/src/lib/calendar.ts b/src/lib/calendar.ts
new file mode 100644
index 00000000..1d8fa115
--- /dev/null
+++ b/src/lib/calendar.ts
@@ -0,0 +1,31 @@
+import type { ZhawSchedule } from '@/types/calendar';
+import type { EventInput } from '@fullcalendar/core';
+
+/**
+ * Converts a ZHAW schedule object into an array of FullCalendar-compatible events.
+ * Filters out invalid entries and handles holiday & all-day classification.
+ */
+export function mapZhawDaysToEvents(data: ZhawSchedule): EventInput[] {
+  return (data.days ?? []).flatMap((day) => {
+    if (!Array.isArray(day.events)) {return [];}
+
+    return day.events
+      // Only include events with a name or description
+      .filter((event) => event.name || event.description)
+      .map((event) => {
+        const isHoliday = event.type === 'Holiday';
+        const isAllDay = event.startTime?.includes('T00:00');
+
+        return {
+          title: event.name || event.description!, // fallback to description if name is missing
+          extendedProps: {
+            room: event.eventRealizations?.[0]?.room?.name ?? '',
+          },
+          start: event.startTime,
+          end: event.endTime,
+          color: isHoliday ? '#F85A6D' : '#EC3349',
+          allDay: isHoliday || isAllDay || false,
+        };
+      });
+  });
+}
\ No newline at end of file
diff --git a/src/types/calendar.ts b/src/types/calendar.ts
new file mode 100644
index 00000000..85faad6b
--- /dev/null
+++ b/src/types/calendar.ts
@@ -0,0 +1,32 @@
+// * Type definitions for ZHAW schedule and mapped calendar events
+
+export interface ZhawEvent {
+    startTime: string;
+    endTime: string;
+    name?: string;
+    description?: string;
+    type?: string;
+    eventRealizations?: {
+        room?: { name: string };
+    }[];
+  }
+  
+export interface ZhawDay {
+    date: string;
+    events: ZhawEvent[];
+  }
+  
+export interface ZhawSchedule {
+    days: ZhawDay[];
+  }
+  
+export interface CalendarEvent {
+    title: string;
+    start: string;
+    end: string;
+    color?: string;
+    allDay?: boolean;
+    extendedProps?: {
+      isHoliday?: boolean;
+    };
+  }
\ No newline at end of file
diff --git a/src/types/openholidays.ts b/src/types/openholidays.ts
new file mode 100644
index 00000000..dd2bf7f5
--- /dev/null
+++ b/src/types/openholidays.ts
@@ -0,0 +1,23 @@
+// * Type definitions for data received from the OpenHolidays API
+
+export interface HolidayName {
+    language: string;
+    text: string;
+  }
+  
+export interface HolidaySubdivision {
+    code: string;
+    shortName: string;
+  }
+  
+export interface OpenHoliday {
+    id: string;
+    startDate: string;
+    endDate: string;
+    name: HolidayName[];
+    nationwide: boolean;
+    regionalScope: string;
+    subdivisions: HolidaySubdivision[];
+    temporalScope: string;
+    type: string;
+  }
\ No newline at end of file


Reviews:
user: login: adrmrt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T16:25:48Z
user: login: adrmrt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T16:50:45Z
user: login: adrmrt
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T16:53:04Z
user: login: adrmrt
body: Overall, the structure and logic is looking good. Just a few minor suggestions:
- I would at least add the room number in the week and day view since students probably want to see where their lectures take place without having to go to stundenplan.zhaw.ch
- I would avoid using Route Handlers because we don't really have an internal API or database connected to the webserver. If possible, try to implement normal API functions instead of Next routes :)

That being said, the PR is fully functional and does its job, so it can be merged in my opinion.
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-20T17:55:28Z
user: login: AbdulBhatti2001
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T21:09:37Z
user: login: adrmrt
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-21T22:00:36Z
user: login: AbdulBhatti2001
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T22:12:20Z

Review comments:
path: src/app/page.tsx
user: login: adrmrt
body: There is now a scrollbar in the home page because the calendar overflows the screen size. Can you try to fit the calendar snugly within the screen so there is no scrollbar?
created_at: 2025-05-19T16:25:48Z
author_association: MEMBER
path: src/types/calendar.ts
user: login: adrmrt
body: I would keep the Props interface in the component itself, since you only use there to make sure you get valid values as parameters. Otherwise, looks good and reasonable.
created_at: 2025-05-19T16:50:45Z
author_association: MEMBER
path: src/lib/openholidays.ts
user: login: adrmrt
body: Please move API functions to `src/lib/api/`
created_at: 2025-05-19T16:53:03Z
author_association: MEMBER
path: src/lib/api/msal.ts
user: login: adrmrt
body: I'm kinda confused. Is MSAL an actual API or just a service instance? If it's the latter, I would keep it as it was before
created_at: 2025-05-20T16:50:40Z
author_association: MEMBER
path: src/lib/api/openholidays.ts
user: login: adrmrt
body: That's very cool. Thank you for implementing the fetching of holiday names in German or English from the OpenHolidays API :)
created_at: 2025-05-20T16:54:34Z
author_association: MEMBER

PR comments:
user: login: sussu01
created_at: 2025-05-20T11:26:05Z
body: To test this PR, please change your email address to the zhaw email address on swagger. Will be fixed in #72 
PUT request /api/v1/users/{id}.


author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-05-20T11:27:35Z
body: **SonarQube Analysis Results**

*   **Quality Gate Status:** `OK`

*   **Conditions:**
    - new_reliability_rating (OK): 1 (required 1)
- new_security_rating (OK): 1 (required 1)
- new_maintainability_rating (OK): 1 (required 1)
- new_duplicated_lines_density (OK): 0.0 (required 3)

[View Full Report on SonarQube](https://sonarqube.pm4.init-lab.ch/dashboard?id=StudyConnect-frontend)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T11:48:26Z
body: **SonarQube Analysis Results**

*   **Quality Gate Status:** `OK`

*   **Conditions:**
    - new_reliability_rating (OK): 1 (required 1)
- new_security_rating (OK): 1 (required 1)
- new_maintainability_rating (OK): 1 (required 1)
- new_duplicated_lines_density (OK): 0.0 (required 3)

[View Full Report on SonarQube](https://sonarqube.pm4.init-lab.ch/dashboard?id=StudyConnect-frontend)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-21T08:55:41Z
body: **SonarQube Analysis Results**

*   **Quality Gate Status:** `OK`

*   **Conditions:**
    - new_reliability_rating (OK): 1 (required 1)
- new_security_rating (OK): 1 (required 1)
- new_maintainability_rating (OK): 1 (required 1)
- new_duplicated_lines_density (OK): 0.0 (required 3)

[View Full Report on SonarQube](https://sonarqube.pm4.init-lab.ch/dashboard?id=StudyConnect-frontend)
author_association: NONE
user: login: sussu01
created_at: 2025-05-21T09:19:32Z
body: > Overall, the structure and logic is looking good. Just a few minor suggestions:
> 
> * I would at least add the room number in the week and day view since students probably want to see where their lectures take place without having to go to stundenplan.zhaw.ch
> * I would avoid using Route Handlers because we don't really have an internal API or database connected to the webserver. If possible, try to implement normal API functions instead of Next routes :)
> 
> That being said, the PR is fully functional and does its job, so it can be merged in my opinion.

Thank you for your feedback Adrian. 
Unfortunately, I had to implement them in this case because I can’t directly call the server-side logic (fetchZhawSchedule) from the client – due to browser limitations (e.g. cookie parsing, JWT decoding, etc.). I did not have a better solution but if you have one Im happy to discuss them with you. 

author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-05-21T09:31:49Z
body: **SonarQube Analysis Results**

*   **Quality Gate Status:** `OK`

*   **Conditions:**
    - new_reliability_rating (OK): 1 (required 1)
- new_security_rating (OK): 1 (required 1)
- new_maintainability_rating (OK): 1 (required 1)
- new_duplicated_lines_density (OK): 0.0 (required 3)

[View Full Report on SonarQube](https://sonarqube.pm4.init-lab.ch/dashboard?id=StudyConnect-frontend)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-21T10:28:22Z
body: **SonarQube Analysis Results**

*   **Quality Gate Status:** `OK`

*   **Conditions:**
    - new_reliability_rating (OK): 1 (required 1)
- new_security_rating (OK): 1 (required 1)
- new_maintainability_rating (OK): 1 (required 1)
- new_duplicated_lines_density (OK): 0.0 (required 3)

[View Full Report on SonarQube](https://sonarqube.pm4.init-lab.ch/dashboard?id=StudyConnect-frontend)
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-21T11:05:43Z
body: **SonarQube Analysis Results**

*   **Quality Gate Status:** `OK`

*   **Conditions:**
    - new_reliability_rating (OK): 1 (required 1)
- new_security_rating (OK): 1 (required 1)
- new_maintainability_rating (OK): 1 (required 1)
- new_duplicated_lines_density (OK): 0.0 (required 3)

[View Full Report on SonarQube](https://sonarqube.pm4.init-lab.ch/dashboard?id=StudyConnect-frontend)
author_association: NONE