diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 29a72a1804db5ee013a10bf92ea69f91de399ace..8612ce0d2f0be6b31d03a76fe45e6f92822dcaab 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,28 @@ and this project adheres to `Semantic Versioning`_.
 Unreleased
 ----------
 
+Added
+~~~~~
+
+* Introduce Holiday model to track information about holidays.
+
+Changes
+~~~~~~~
+
+* The frontend is now able to display headings in the main toolbar.
+
+Fixed
+~~~~~
+
+* Default translations from vuetify were not loaded.
+* Browser locale was not the default locale in the entire frontend.
+* In some cases, some items in the sidenav menu were not shown due to its height being higher than the visible page area.
+* The search bar in the sidenav menu is shown even though the user has no permission to see it.
+* Add permission check to accept invitation menu point in order to hide it when this feature is disabled.
+* Metrics endpoint for Prometheus was at the wrong URL.
+* Polling behavior of the whoAmI and permission queries was fixed.
+* Confirmation e-mail contained a wrong link.
+
 `3.0`_ - 2022-05-11
 -------------------
 
@@ -19,6 +41,7 @@ Added
 * Provide API endpoint for system status.
 * [Dev] UpdateIndicator Vue Component to display the status of interactive pages
 * [Dev] DeleteDialog Vue Component to unify item deletion in the new frontend
+* Use build-in mechanism in Apollo for GraphQL batch querying.
 
 
 Changed
@@ -51,7 +74,7 @@ Fixed
 * Backend cleanup task for Celery wasn't working.
 * URLs in invitation email were broken.
 * Invitation view didn't work.
-* Invitation emails were using wrong styling. 
+* Invitation emails were using wrong styling.
 * GraphQL queries and mutations did not log exceptions.
 
 `3.0b3`_ - 2023-03-19
diff --git a/aleksis/core/frontend/app/apollo.js b/aleksis/core/frontend/app/apollo.js
index 267997b9f93ba8d992a90093a93e650f32995811..519bc24aa5d7920d6433d4eebef1b38673753907 100644
--- a/aleksis/core/frontend/app/apollo.js
+++ b/aleksis/core/frontend/app/apollo.js
@@ -2,11 +2,12 @@
  * Configuration for Apollo provider, client, and caches.
  */
 
-import { ApolloClient, HttpLink, from } from "@/apollo-boost";
+import { ApolloClient, from } from "@/apollo-boost";
 
 import { RetryLink } from "@/apollo-link-retry";
 import { persistCache, LocalStorageWrapper } from "@/apollo3-cache-persist";
 import { InMemoryCache } from "@/apollo-cache-inmemory";
+import { BatchHttpLink } from "@/apollo-link-batch-http";
 
 // Cache for GraphQL query results in memory and persistent across sessions
 const cache = new InMemoryCache();
@@ -33,14 +34,17 @@ const links = [
   // Automatically retry failed queries
   new RetryLink(),
   // Finally, the HTTP link to the real backend (Django)
-  new HttpLink({
+  new BatchHttpLink({
     uri: getGraphqlURL(),
+    batchInterval: 200,
+    batchDebounce: true,
   }),
 ];
 
 /** Upstream Apollo GraphQL client */
 const apolloClient = new ApolloClient({
   cache,
+  shouldBatch: true,
   link: from(links),
 });
 
diff --git a/aleksis/core/frontend/app/dateTimeFormats.js b/aleksis/core/frontend/app/dateTimeFormats.js
index b5d498f29c1c1a7bcfd2e59f64c695cadfcd9027..76c00c47bc47b17ed2f37d075a3b3edd259f21dd 100644
--- a/aleksis/core/frontend/app/dateTimeFormats.js
+++ b/aleksis/core/frontend/app/dateTimeFormats.js
@@ -6,6 +6,13 @@ const dateTimeFormats = {
       month: "short",
       day: "numeric",
     },
+    shortDateTime: {
+      year: "numeric",
+      month: "short",
+      day: "numeric",
+      hour: "numeric",
+      minute: "numeric",
+    },
     long: {
       year: "numeric",
       month: "long",
@@ -37,6 +44,13 @@ const dateTimeFormats = {
       month: "short",
       day: "numeric",
     },
+    shortDateTime: {
+      year: "numeric",
+      month: "short",
+      day: "numeric",
+      hour: "numeric",
+      minute: "numeric",
+    },
     long: {
       year: "numeric",
       month: "long",
diff --git a/aleksis/core/frontend/components/app/App.vue b/aleksis/core/frontend/components/app/App.vue
index 0d8e5b215535324d5765bcaca2ad83df94021aa8..9c3a9ced9b3e950a8c4955b96bc6afa5f79355b9 100644
--- a/aleksis/core/frontend/components/app/App.vue
+++ b/aleksis/core/frontend/components/app/App.vue
@@ -246,12 +246,17 @@ export default {
     systemProperties: gqlSystemProperties,
     whoAmI: {
       query: gqlWhoAmI,
+      pollInterval: 30000,
+      result({ data }) {
+        if (data && data.whoAmI) {
+          this.$root.permissions = data.whoAmI.permissions;
+        }
+      },
       variables() {
         return {
-          permissions: this.permissionNames,
+          permissions: this.$root.permissionNames,
         };
       },
-      pollInterval: 10000,
     },
     messages: {
       query: gqlMessages,
diff --git a/aleksis/core/frontend/components/app/LanguageForm.vue b/aleksis/core/frontend/components/app/LanguageForm.vue
index 12acbe613e94a23e25f5d1195b0badf420d92aa6..cdef5932231e5b0401296b6918d009eb544588ec 100644
--- a/aleksis/core/frontend/components/app/LanguageForm.vue
+++ b/aleksis/core/frontend/components/app/LanguageForm.vue
@@ -33,17 +33,33 @@ export default {
       type: Array,
       required: true,
     },
+    defaultLanguage: {
+      type: Object,
+      required: true,
+    },
   },
   methods: {
     setLanguage: function (languageOption) {
       document.cookie = languageOption.cookie;
       this.$i18n.locale = languageOption.code;
       this.$vuetify.lang.current = languageOption.code;
+      this.language = languageOption;
     },
     nameForMenu: function (item) {
       return `${item.nameLocal} (${item.code})`;
     },
   },
+  mounted() {
+    if (
+      this.availableLanguages.filter((lang) => lang.code === this.$i18n.locale)
+        .length === 0
+    ) {
+      console.warn(
+        `Unsupported language ${this.$i18n.locale} selected, defaulting to ${this.defaultLanguage.code}`
+      );
+      this.setLanguage(this.defaultLanguage);
+    }
+  },
   name: "LanguageForm",
 };
 </script>
diff --git a/aleksis/core/frontend/components/app/SideNav.vue b/aleksis/core/frontend/components/app/SideNav.vue
index 0975c580cda88d03fd8a5d34b2fb16425ece69ee..e2594aae9eb9d561a710530a3d87e345e2791c74 100644
--- a/aleksis/core/frontend/components/app/SideNav.vue
+++ b/aleksis/core/frontend/components/app/SideNav.vue
@@ -1,5 +1,10 @@
 <template>
-  <v-navigation-drawer app :value="value" @input="$emit('input', $event)">
+  <v-navigation-drawer
+    app
+    :value="value"
+    height="100dvh"
+    @input="$emit('input', $event)"
+  >
     <v-list nav dense shaped>
       <v-list-item class="logo">
         <a
@@ -10,7 +15,7 @@
           <brand-logo :site-preferences="systemProperties.sitePreferences" />
         </a>
       </v-list-item>
-      <v-list-item class="search">
+      <v-list-item v-if="checkPermission('core.search_rule')" class="search">
         <sidenav-search />
       </v-list-item>
       <v-list-item-group :value="$route.name" v-if="sideNavMenu">
@@ -82,6 +87,7 @@
         <v-spacer />
         <language-form
           :available-languages="systemProperties.availableLanguages"
+          :default-language="systemProperties.defaultLanguage"
         />
         <v-spacer />
       </div>
@@ -94,6 +100,8 @@ import BrandLogo from "./BrandLogo.vue";
 import LanguageForm from "./LanguageForm.vue";
 import SidenavSearch from "./SidenavSearch.vue";
 
+import permissionsMixin from "../../mixins/permissions.js";
+
 export default {
   name: "SideNav",
   components: {
@@ -106,6 +114,10 @@ export default {
     systemProperties: { type: Object, required: true },
     value: { type: Boolean, required: true },
   },
+  mixins: [permissionsMixin],
+  mounted() {
+    this.addPermissions(["core.search_rule"]);
+  },
 };
 </script>
 
diff --git a/aleksis/core/frontend/components/app/systemProperties.graphql b/aleksis/core/frontend/components/app/systemProperties.graphql
index 99533650b65369f4a07c330399a2f452a815b763..b8ec991bda2b1b689c97f2fb339dafce9d0e1175 100644
--- a/aleksis/core/frontend/components/app/systemProperties.graphql
+++ b/aleksis/core/frontend/components/app/systemProperties.graphql
@@ -6,6 +6,12 @@
       nameLocal
       cookie
     }
+    defaultLanguage {
+      code
+      nameTranslated
+      nameLocal
+      cookie
+    }
     sitePreferences {
       themePrimary
       themeSecondary
diff --git a/aleksis/core/frontend/components/app/whoAmI.graphql b/aleksis/core/frontend/components/app/whoAmI.graphql
index 0b2877bd2cf15b5134c8e70978fb6b2218414e3a..fff7344d06817d73a37ede3f8698afaaa06e1ea6 100644
--- a/aleksis/core/frontend/components/app/whoAmI.graphql
+++ b/aleksis/core/frontend/components/app/whoAmI.graphql
@@ -1,4 +1,4 @@
-query ($permissions: [String]!) {
+query whoAmI($permissions: [String]!) {
   whoAmI {
     username
     isAuthenticated
diff --git a/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue b/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2db52ae5ef86ecf4534aee70ffd7d9939dffe453
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue
@@ -0,0 +1,64 @@
+<template>
+  <v-menu
+    v-model="model"
+    :close-on-content-click="false"
+    :activator="selectedElement"
+    offset-x
+  >
+    <v-card min-width="350px" flat>
+      <v-toolbar :color="selectedEvent.color" dark dense>
+        <v-toolbar-title>
+          <slot name="title" :selected-event="selectedEvent">{{
+            selectedEvent.name
+          }}</slot>
+        </v-toolbar-title>
+        <v-spacer></v-spacer>
+        <slot name="badge" :selected-event="selectedEvent">
+          <v-chip
+            v-if="selectedEvent.status === 'CANCELLED' && !withoutBadge"
+            color="error"
+            label
+          >
+            <v-avatar left>
+              <v-icon>mdi-cancel</v-icon>
+            </v-avatar>
+            {{ $t("calendar.cancelled") }}
+          </v-chip>
+        </slot>
+      </v-toolbar>
+      <slot name="time" :selected-event="selectedEvent">
+        <v-card-text v-if="!withoutTime">
+          <v-icon left>mdi-calendar-today-outline</v-icon>
+          <span v-if="selectedEvent.start !== selectedEvent.end">
+            {{ $d(selectedEvent.start, "shortDateTime") }} –
+            {{ $d(selectedEvent.end, "shortDateTime") }}
+          </span>
+          <span v-else> {{ $d(selectedEvent.start, "shortDateTime") }}</span>
+        </v-card-text>
+      </slot>
+      <slot name="description" :selected-event="selectedEvent">
+        <v-divider v-if="selectedEvent.description && !withoutDescription" />
+        <v-card-text
+          class="d-flex"
+          v-if="selectedEvent.description && !withoutDescription"
+        >
+          <div>
+            <v-icon left>mdi-card-text-outline</v-icon>
+          </div>
+          <div style="white-space: pre-line">
+            {{ selectedEvent.description }}
+          </div>
+        </v-card-text>
+      </slot>
+    </v-card>
+  </v-menu>
+</template>
+
+<script>
+import calendarFeedDetailsMixin from "../../mixins/calendarFeedDetails.js";
+
+export default {
+  name: "BaseCalendarFeedDetails",
+  mixins: [calendarFeedDetailsMixin],
+};
+</script>
diff --git a/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue b/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..510e2af17160f6d2cbcacbf3d640cb18d1f9df5c
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue
@@ -0,0 +1,36 @@
+<template>
+  <div
+    class="mx-1 text-truncate"
+    :style="
+      event.status === 'CANCELLED' ? 'text-decoration: line-through;' : ''
+    "
+  >
+    <slot name="time" v-bind="$props">
+      <span
+        v-if="
+          calendarType === 'month' && eventParsed.start.hasTime && !withoutTime
+        "
+        class="mr-1 font-weight-bold"
+        >{{ eventParsed.start.time }}</span
+      >
+    </slot>
+    <slot name="icon" v-bind="$props">
+      <v-icon v-if="icon" x-small color="white" class="mx-1 left">
+        {{ icon }}
+      </v-icon>
+    </slot>
+
+    <slot name="title" v-bind="$props">
+      {{ event.name }}
+    </slot>
+  </div>
+</template>
+
+<script>
+import calendarFeedEventBarMixin from "../../mixins/calendarFeedEventBar.js";
+
+export default {
+  name: "BaseCalendarFeedEventBar",
+  mixins: [calendarFeedEventBarMixin],
+};
+</script>
diff --git a/aleksis/core/frontend/components/calendar/CalendarOverview.vue b/aleksis/core/frontend/components/calendar/CalendarOverview.vue
new file mode 100644
index 0000000000000000000000000000000000000000..575475db559c39a24d8ffebf645b17d666594e91
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/CalendarOverview.vue
@@ -0,0 +1,317 @@
+<template>
+  <div class="mt-4 mb-4">
+    <v-skeleton-loader
+      v-if="$apollo.queries.calendarFeeds.loading && calendarFeeds.length === 0"
+      type="date-picker-options, actions"
+    />
+    <div v-else>
+      <h1
+        class="mb-4 mx-4"
+        v-if="$vuetify.breakpoint.mdAndDown && $refs.calendar"
+      >
+        {{ $refs.calendar.title }}
+      </h1>
+      <v-row align="stretch">
+        <v-col
+          cols="12"
+          sm="4"
+          lg="3"
+          xl="2"
+          align-self="center"
+          class="d-flex justify-center"
+        >
+          <v-btn icon class="mx-2" @click="$refs.calendar.prev()">
+            <v-icon>mdi-chevron-left</v-icon>
+          </v-btn>
+          <v-btn outlined text class="mx-2" @click="calendarFocus = ''">
+            <v-icon left>mdi-calendar-today-outline</v-icon>
+            {{ $t("calendar.today") }}
+          </v-btn>
+          <v-btn icon class="mx-2" @click="$refs.calendar.next()">
+            <v-icon>mdi-chevron-right</v-icon>
+          </v-btn>
+        </v-col>
+        <v-col v-if="$vuetify.breakpoint.lgAndUp" align-self="center">
+          <h1 class="mx-2" v-if="$refs.calendar">{{ $refs.calendar.title }}</h1>
+        </v-col>
+        <v-col
+          v-if="$vuetify.breakpoint.mdAndDown"
+          cols="12"
+          sm="4"
+          align-self="center"
+          class="d-flex justify-center"
+        >
+          <button-menu
+            icon="mdi-calendar-check-outline"
+            text-translation-key="calendar.select"
+          >
+            <calendar-select
+              v-model="selectedCalendarFeedNames"
+              :calendar-feeds="calendarFeeds"
+            />
+          </button-menu>
+        </v-col>
+        <v-spacer v-if="$vuetify.breakpoint.lgAndUp" />
+        <v-col
+          cols="12"
+          sm="4"
+          lg="3"
+          align-self="center"
+          :align="$vuetify.breakpoint.smAndUp ? 'right' : 'center'"
+        >
+          <v-btn-toggle dense v-model="currentCalendarType" class="mx-2">
+            <v-btn
+              v-for="calendarType in availableCalendarTypes"
+              :value="calendarType.type"
+              :key="calendarType.type"
+            >
+              <span class="hidden-sm-and-down">{{
+                nameForMenu(calendarType)
+              }}</span>
+              <v-icon :right="$vuetify.breakpoint.mdAndUp">{{
+                "mdi-" + calendarType.icon
+              }}</v-icon>
+            </v-btn>
+          </v-btn-toggle>
+        </v-col>
+      </v-row>
+      <v-row>
+        <v-col v-if="$vuetify.breakpoint.lgAndUp" lg="3" xl="2">
+          <v-list flat>
+            <calendar-select
+              v-model="selectedCalendarFeedNames"
+              :calendar-feeds="calendarFeeds"
+            />
+          </v-list>
+        </v-col>
+        <v-col lg="9" xl="10">
+          <v-sheet height="600">
+            <v-expand-transition>
+              <v-progress-linear
+                v-if="$apollo.queries.calendarFeeds.loading"
+                indeterminate
+              />
+            </v-expand-transition>
+            <v-calendar
+              ref="calendar"
+              v-model="calendarFocus"
+              show-week
+              :events="events"
+              :type="currentCalendarType"
+              :event_color="getColorForEvent"
+              @click:date="viewDay"
+              @click:day="viewDay"
+              @click:more="viewDay"
+              @click:event="viewEvent"
+              @change="fetchMoreCalendarEvents"
+            >
+              <template #event="{ event, eventParsed, timed }">
+                <component
+                  :is="eventBarComponentForFeed(event.calendarFeedName)"
+                  :event="event"
+                  :event-parsed="eventParsed"
+                  :calendar-type="currentCalendarType"
+                />
+              </template>
+            </v-calendar>
+            <component
+              v-if="calendarFeeds && selectedEvent"
+              :is="detailComponentForFeed(selectedEvent.calendarFeedName)"
+              v-model="selectedOpen"
+              :selected-element="selectedElement"
+              :selected-event="selectedEvent"
+            />
+          </v-sheet>
+        </v-col>
+      </v-row>
+    </div>
+  </div>
+</template>
+
+<script>
+import ButtonMenu from "../generic/ButtonMenu.vue";
+import CalendarSelect from "./CalendarSelect.vue";
+import GenericCalendarFeedDetails from "./GenericCalendarFeedDetails.vue";
+import GenericCalendarFeedEventBar from "./GenericCalendarFeedEventBar.vue";
+
+import {
+  calendarFeedDetailComponents,
+  calendarFeedEventBarComponents,
+} from "aleksisAppImporter";
+
+import gqlCalendarOverview from "./calendarOverview.graphql";
+
+export default {
+  name: "CalendarOverview",
+  components: {
+    ButtonMenu,
+    CalendarSelect,
+  },
+  data() {
+    return {
+      calendarFocus: "",
+      calendarFeeds: [],
+      selectedCalendarFeedNames: [],
+      currentCalendarType: "week",
+      selectedEvent: {},
+      selectedElement: null,
+      selectedOpen: false,
+      availableCalendarTypes: [
+        {
+          type: "month",
+          translationKey: "calendar.month",
+          icon: "calendar-month-outline",
+        },
+        {
+          type: "week",
+          translationKey: "calendar.week",
+          icon: "calendar-week-outline",
+        },
+        {
+          type: "day",
+          translationKey: "calendar.day",
+          icon: "calendar-today-outline",
+        },
+      ],
+      fetchedDateRange: { start: null, end: null },
+    };
+  },
+  apollo: {
+    calendarFeeds: {
+      query: gqlCalendarOverview,
+      skip: true,
+    },
+  },
+  computed: {
+    events() {
+      return this.calendarFeeds
+        .filter((c) => this.selectedCalendarFeedNames.includes(c.name))
+        .flatMap((cf) =>
+          cf.feed.events.map((event) => ({
+            ...event,
+            category: cf.verboseName,
+            calendarFeedName: cf.name,
+            start: new Date(event.start),
+            end: new Date(event.end),
+            color: event.color ? event.color : cf.color,
+            timed: !event.allDay,
+          }))
+        );
+    },
+  },
+  methods: {
+    nameForMenu: function (item) {
+      return this.$t(item.translationKey);
+    },
+    viewDay({ date }) {
+      this.calendarFocus = date;
+      this.currentCalendarType = "day";
+    },
+    viewEvent({ nativeEvent, event }) {
+      const open = () => {
+        this.selectedEvent = event;
+        this.selectedElement = nativeEvent.target;
+        requestAnimationFrame(() =>
+          requestAnimationFrame(() => (this.selectedOpen = true))
+        );
+      };
+
+      if (this.selectedOpen) {
+        this.selectedOpen = false;
+        requestAnimationFrame(() => requestAnimationFrame(() => open()));
+      } else {
+        open();
+      }
+
+      nativeEvent.stopPropagation();
+    },
+    detailComponentForFeed(feedName) {
+      if (
+        this.calendarFeeds &&
+        feedName &&
+        Object.keys(calendarFeedDetailComponents).includes(feedName + "details")
+      ) {
+        return calendarFeedDetailComponents[feedName + "details"];
+      }
+      return GenericCalendarFeedDetails;
+    },
+    eventBarComponentForFeed(feedName) {
+      if (
+        this.calendarFeeds &&
+        feedName &&
+        Object.keys(calendarFeedEventBarComponents).includes(
+          feedName + "eventbar"
+        )
+      ) {
+        return calendarFeedEventBarComponents[feedName + "eventbar"];
+      }
+      return GenericCalendarFeedEventBar;
+    },
+    getColorForEvent(event) {
+      return event.color;
+    },
+    fetchMoreCalendarEvents({ start, end }) {
+      // Get the start and end dates of the current date range shown in the calendar
+      let extendedStart = this.$refs.calendar.getStartOfWeek(start).date;
+      let extendedEnd = this.$refs.calendar.getEndOfWeek(end).date;
+
+      let olderStart = extendedStart < this.fetchedDateRange.start;
+      let youngerEnd = extendedEnd > this.fetchedDateRange.end;
+
+      if (this.calendarFeeds.length === 0) {
+        // No calendar feeds have been fetched yet,
+        // so fetch all events in the current date range
+
+        this.$apollo.queries.calendarFeeds.setVariables({
+          start: extendedStart,
+          end: extendedEnd,
+        });
+        this.$apollo.queries.calendarFeeds.skip = false;
+        this.fetchedDateRange = { start: extendedStart, end: extendedEnd };
+      } else if (olderStart || youngerEnd) {
+        // Define newly fetched date range
+        let newStart = olderStart ? extendedStart : this.fetchedDateRange.start;
+        let newEnd = youngerEnd ? extendedEnd : this.fetchedDateRange.end;
+
+        // Define date range to fetch
+        let fetchStart = olderStart ? extendedStart : this.fetchedDateRange.end;
+        let fetchEnd = youngerEnd ? extendedEnd : this.fetchedDateRange.start;
+
+        this.$apollo.queries.calendarFeeds.fetchMore({
+          variables: {
+            start: fetchStart,
+            end: fetchEnd,
+          },
+          updateQuery: (previousResult, { fetchMoreResult }) => {
+            let previousCalendarFeeds = previousResult.calendarFeeds;
+            let newCalendarFeeds = fetchMoreResult.calendarFeeds;
+
+            previousCalendarFeeds.forEach((calendarFeed, i, calendarFeeds) => {
+              // Get all events except those that are updated
+              let keepEvents = calendarFeed.feed.events.filter(
+                (event) => event.end < fetchStart || event.start > fetchEnd
+              );
+
+              /// Update the events of the calendar feed
+              calendarFeeds[i].feed.events = [
+                ...keepEvents,
+                ...newCalendarFeeds[i].feed.events,
+              ];
+            });
+            return {
+              calendarFeeds: previousCalendarFeeds,
+            };
+          },
+        });
+
+        this.fetchedDateRange = { start: newStart, end: newEnd };
+      }
+    },
+  },
+  mounted() {
+    this.$refs.calendar.move(0);
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/calendar/CalendarSelect.vue b/aleksis/core/frontend/components/calendar/CalendarSelect.vue
new file mode 100644
index 0000000000000000000000000000000000000000..04421cb09ee6c9e6a23bd59a5ad4eb5e7c37d938
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/CalendarSelect.vue
@@ -0,0 +1,86 @@
+<template>
+  <v-list-item-group multiple v-model="model">
+    <v-subheader>
+      <v-checkbox
+        :label="$t('actions.select_all')"
+        :indeterminate="someSelected"
+        :input-value="allSelected"
+        @change="toggleAll"
+      />
+    </v-subheader>
+    <v-list-item
+      v-for="calendarFeed in calendarFeeds"
+      :key="calendarFeed.name"
+      :value="calendarFeed.name"
+    >
+      <template #default="{ active }">
+        <v-list-item-action>
+          <v-checkbox :input-value="active"></v-checkbox>
+        </v-list-item-action>
+
+        <v-list-item-icon>
+          <v-icon class="mr-2" :color="calendarFeed.color"> mdi-circle </v-icon>
+        </v-list-item-icon>
+
+        <v-list-item-content>
+          <v-list-item-title>
+            {{ calendarFeed.verboseName }}
+          </v-list-item-title>
+        </v-list-item-content>
+
+        <v-list-item-action>
+          <copy-to-clipboard-button
+            :text="calendarFeed.url"
+            :tooltip-help-text="$t('calendar.ics_to_clipboard')"
+          />
+        </v-list-item-action>
+      </template>
+    </v-list-item>
+  </v-list-item-group>
+</template>
+
+<script>
+import CopyToClipboardButton from "../generic/CopyToClipboardButton.vue";
+
+export default {
+  name: "CalendarSelect",
+  props: {
+    calendarFeeds: {
+      type: Array,
+      required: true,
+    },
+    value: {
+      type: Array,
+      required: true,
+    },
+  },
+  components: {
+    CopyToClipboardButton,
+  },
+  computed: {
+    model: {
+      get() {
+        return this.value;
+      },
+      set(value) {
+        this.$emit("input", value);
+      },
+    },
+    someSelected() {
+      return this.model.length > 0 && !this.allSelected;
+    },
+    allSelected() {
+      return this.model.length === this.calendarFeeds.length;
+    },
+  },
+  methods: {
+    toggleAll(newValue) {
+      if (newValue) {
+        this.model = this.calendarFeeds.map((feed) => feed.name);
+      } else {
+        this.model = [];
+      }
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue b/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue
new file mode 100644
index 0000000000000000000000000000000000000000..4c890629fb1662ccfa21102194d6dbbf4f2c40df
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue
@@ -0,0 +1,14 @@
+<template>
+  <base-calendar-feed-details v-bind="$props" />
+</template>
+
+<script>
+import calendarFeedDetailsMixin from "../../mixins/calendarFeedDetails.js";
+import BaseCalendarFeedDetails from "./BaseCalendarFeedDetails.vue";
+
+export default {
+  name: "GenericCalendarFeedDetails",
+  components: { BaseCalendarFeedDetails },
+  mixins: [calendarFeedDetailsMixin],
+};
+</script>
diff --git a/aleksis/core/frontend/components/calendar/GenericCalendarFeedEventBar.vue b/aleksis/core/frontend/components/calendar/GenericCalendarFeedEventBar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..251b7a8b929f3b67ada0e4a207aefef16d500149
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/GenericCalendarFeedEventBar.vue
@@ -0,0 +1,14 @@
+<template>
+  <base-calendar-feed-event-bar v-bind="$props" />
+</template>
+
+<script>
+import calendarFeedEventBarMixin from "../../mixins/calendarFeedEventBar.js";
+import BaseCalendarFeedEventBar from "./BaseCalendarFeedEventBar.vue";
+
+export default {
+  name: "GenericCalendarFeedEventBar",
+  components: { BaseCalendarFeedEventBar },
+  mixins: [calendarFeedEventBarMixin],
+};
+</script>
diff --git a/aleksis/core/frontend/components/calendar/calendarOverview.graphql b/aleksis/core/frontend/components/calendar/calendarOverview.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..061ab3872572d3c8e98ac1e75bc236f664f62000
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/calendarOverview.graphql
@@ -0,0 +1,21 @@
+query ($start: Date, $end: Date) {
+  calendarFeeds {
+    name
+    verboseName
+    description
+    url
+    color
+    feed {
+      events(start: $start, end: $end) {
+        name
+        start
+        end
+        color
+        description
+        uid
+        allDay
+        status
+      }
+    }
+  }
+}
diff --git a/aleksis/core/frontend/components/calendar_feeds/details/BirthdaysDetails.vue b/aleksis/core/frontend/components/calendar_feeds/details/BirthdaysDetails.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f651ff2d11fa189b057b1adc6a613b288ae84604
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar_feeds/details/BirthdaysDetails.vue
@@ -0,0 +1,24 @@
+<template>
+  <base-calendar-feed-details v-bind="$props" without-time>
+    <template #description="{ selectedEvent }">
+      <v-divider />
+      <v-card-text>
+        <span>
+          <v-icon class="mr-2">mdi-cake-variant-outline</v-icon>
+          {{ $d(selectedEvent.start) }}
+        </span>
+      </v-card-text>
+    </template>
+  </base-calendar-feed-details>
+</template>
+
+<script>
+import calendarFeedDetailsMixin from "../../../mixins/calendarFeedDetails.js";
+import BaseCalendarFeedDetails from "../../calendar/BaseCalendarFeedDetails.vue";
+
+export default {
+  name: "BirthdaysDetails",
+  components: { BaseCalendarFeedDetails },
+  mixins: [calendarFeedDetailsMixin],
+};
+</script>
diff --git a/aleksis/core/frontend/components/calendar_feeds/event_bar/BirthdaysEventBar.vue b/aleksis/core/frontend/components/calendar_feeds/event_bar/BirthdaysEventBar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d0f89cfae0695cba0033722dda8d66d5686205a6
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar_feeds/event_bar/BirthdaysEventBar.vue
@@ -0,0 +1,17 @@
+<template>
+  <base-calendar-feed-event-bar
+    v-bind="$props"
+    icon="mdi-cake-variant-outline"
+  />
+</template>
+
+<script>
+import calendarFeedEventBarMixin from "../../../mixins/calendarFeedEventBar.js";
+import BaseCalendarFeedEventBar from "../../calendar/BaseCalendarFeedEventBar.vue";
+
+export default {
+  name: "BirthdaysEventBar",
+  components: { BaseCalendarFeedEventBar },
+  mixins: [calendarFeedEventBarMixin],
+};
+</script>
diff --git a/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue b/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue
index 410e57fda0eca36d3acaca7fc0318174fd71d318..912fb779241ef577c6f900abc78a26008df008f6 100644
--- a/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue
+++ b/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue
@@ -47,7 +47,7 @@ export default {
   apollo: {
     celeryProgressByUser: {
       query: gqlCeleryProgressButton,
-      pollInterval: 1000,
+      pollInterval: 30000,
     },
   },
 };
diff --git a/aleksis/core/frontend/components/generic/ButtonMenu.vue b/aleksis/core/frontend/components/generic/ButtonMenu.vue
index b105f84a8e1254f90bb1d78a2903c42f60cec29e..acc0990d050815c8bef8967aed93729c212f6f24 100644
--- a/aleksis/core/frontend/components/generic/ButtonMenu.vue
+++ b/aleksis/core/frontend/components/generic/ButtonMenu.vue
@@ -6,6 +6,7 @@
           <v-icon center>
             {{ icon }}
           </v-icon>
+          <span v-if="textTranslationKey">{{ $t(textTranslationKey) }}</span>
         </v-btn>
       </slot>
     </template>
@@ -25,6 +26,11 @@ export default {
       required: false,
       default: "mdi-dots-horizontal",
     },
+    textTranslationKey: {
+      type: String,
+      required: false,
+      default: "",
+    },
   },
 };
 </script>
diff --git a/aleksis/core/frontend/components/generic/CopyToClipboardButton.vue b/aleksis/core/frontend/components/generic/CopyToClipboardButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f4c167229bb53e444696522e2643e73540536e99
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/CopyToClipboardButton.vue
@@ -0,0 +1,67 @@
+<template>
+  <v-tooltip bottom :open-on-hover="hover" v-model="tooltipModel">
+    <template #activator="{ on, attrs }">
+      <v-layout wrap v-on="on" v-bind="attrs">
+        <v-btn fab x-small icon @click.stop="copyToClipboard(text)">
+          <v-scroll-x-transition mode="out-in">
+            <v-icon :key="clipboardIcon">
+              {{ clipboardIcon }}
+            </v-icon>
+          </v-scroll-x-transition>
+        </v-btn>
+      </v-layout>
+    </template>
+    <span>{{ tooltipText }}</span>
+  </v-tooltip>
+</template>
+
+<script>
+export default {
+  name: "CopyToClipboardButton",
+  data() {
+    return {
+      copied: false,
+      tooltipModel: false,
+      hover: true,
+    };
+  },
+  props: {
+    text: {
+      type: String,
+      required: true,
+    },
+    tooltipHelpText: {
+      type: String,
+      default: "",
+    },
+  },
+  computed: {
+    tooltipText() {
+      return this.copied
+        ? this.$t("actions.copied")
+        : this.tooltipHelpText
+        ? this.tooltipHelpText
+        : this.$t("actions.copy");
+    },
+    clipboardIcon() {
+      return this.copied
+        ? "mdi-clipboard-check-outline"
+        : "mdi-clipboard-outline";
+    },
+  },
+  methods: {
+    copyToClipboard(text) {
+      navigator.clipboard.writeText(text);
+      this.tooltipModel = false;
+      setTimeout(() => {
+        this.tooltipModel = this.copied = true;
+        this.hover = false;
+        setTimeout(() => {
+          this.tooltipModel = this.copied = false;
+          this.hover = true;
+        }, 1000);
+      }, 100);
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/notifications/NotificationList.vue b/aleksis/core/frontend/components/notifications/NotificationList.vue
index b64769cc6d3dacb130197a3efcea516ef3d89c83..1c51c00661816fe3e244e6d511a0e70c747dbca6 100644
--- a/aleksis/core/frontend/components/notifications/NotificationList.vue
+++ b/aleksis/core/frontend/components/notifications/NotificationList.vue
@@ -83,7 +83,7 @@ export default {
   apollo: {
     myNotifications: {
       query: gqlMyNotifications,
-      pollInterval: 1000,
+      pollInterval: 30000,
     },
   },
 };
diff --git a/aleksis/core/frontend/index.js b/aleksis/core/frontend/index.js
index 739c99eb1628165cac893789ac4ff17b2533cefc..e54375745f0941ca8799a0537ac9fdcda6da9122 100644
--- a/aleksis/core/frontend/index.js
+++ b/aleksis/core/frontend/index.js
@@ -36,9 +36,7 @@ import routerOpts from "./app/router.js";
 import apolloOpts from "./app/apollo.js";
 
 const i18n = new VueI18n({
-  locale: Vue.$cookies.get("django_language")
-    ? Vue.$cookies.get("django_language")
-    : "en",
+  locale: Vue.$cookies.get("django_language") || navigator.language || "en",
   ...i18nOpts,
 });
 const vuetify = new Vuetify({
@@ -72,6 +70,8 @@ const app = new Vue({
     invalidation: false,
     snackbarItems: [],
     toolbarTitle: "AlekSIS®",
+    permissions: [],
+    permissionNames: [],
   }),
   computed: {
     matchedComponents() {
@@ -91,6 +91,6 @@ const app = new Vue({
 });
 
 // Late setup for some plugins handed off to out ALeksisVue plugin
-app.$loadAppMessages();
 app.$loadVuetifyMessages();
+app.$loadAppMessages();
 app.$setupNavigationGuards();
diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json
index 28541da25604e98308d400d3c9bada9d921d467e..23ffc376f590f14b3aec8c17237926a34cb99d2c 100644
--- a/aleksis/core/frontend/messages/en.json
+++ b/aleksis/core/frontend/messages/en.json
@@ -81,6 +81,10 @@
     "confirm_deletion_multiple": "Are you sure you want to delete these items?",
     "delete": "Delete",
     "edit": "Edit",
+    "close": "Close",
+    "select_all": "Select all",
+    "copy": "Copy",
+    "copied": "Copied",
     "save": "Save",
     "search": "Search",
     "stop_editing": "Stop editing",
@@ -256,6 +260,16 @@
     "snackbar_error_message": "There was an error retrieving the page data. Please try again.",
     "snackbar_success_message": "The operation has been finished successfully."
   },
+  "calendar": {
+    "menu_title_overview": "Calendar",
+    "month": "Month",
+    "week": "Week",
+    "day": "Day",
+    "today": "Today",
+    "select": "Select calendars",
+    "ics_to_clipboard": "Copy link to calendar ICS to clipboard",
+    "cancelled": "Cancelled"
+  },
   "status": {
     "changes": "You have unsaved changes.",
     "error": "There has been an error while saving the latest changes.",
diff --git a/aleksis/core/frontend/mixins/calendarFeedDetails.js b/aleksis/core/frontend/mixins/calendarFeedDetails.js
new file mode 100644
index 0000000000000000000000000000000000000000..ef874b5c5600d487472ca73b957b4ed3fad801bc
--- /dev/null
+++ b/aleksis/core/frontend/mixins/calendarFeedDetails.js
@@ -0,0 +1,43 @@
+/**
+ * Mixin for use with adaptable components showing details for calendar feeds.
+ */
+const calendarFeedDetailsMixin = {
+  props: {
+    selectedElement: {
+      required: false,
+      default: null,
+    },
+    selectedEvent: {
+      required: true,
+      type: Object,
+    },
+    value: { type: Boolean, required: true },
+    withoutTime: {
+      required: true,
+      type: Boolean,
+      default: false,
+    },
+    withoutDescription: {
+      required: true,
+      type: Boolean,
+      default: false,
+    },
+    withoutBadge: {
+      required: true,
+      type: Boolean,
+      default: false,
+    },
+  },
+  computed: {
+    model: {
+      get() {
+        return this.value;
+      },
+      set(value) {
+        this.$emit("input", value);
+      },
+    },
+  },
+};
+
+export default calendarFeedDetailsMixin;
diff --git a/aleksis/core/frontend/mixins/calendarFeedEventBar.js b/aleksis/core/frontend/mixins/calendarFeedEventBar.js
new file mode 100644
index 0000000000000000000000000000000000000000..5a0367c2b7590dce12f6eb5d85097e23bd1b1ced
--- /dev/null
+++ b/aleksis/core/frontend/mixins/calendarFeedEventBar.js
@@ -0,0 +1,31 @@
+/**
+ * Mixin for use with adaptable components showing event bar for calendar feeds.
+ */
+const calendarFeedEventBarMixin = {
+  props: {
+    event: {
+      required: true,
+      type: Object,
+    },
+    eventParsed: {
+      required: true,
+      type: Object,
+    },
+    calendarType: {
+      required: true,
+      type: String,
+    },
+    icon: {
+      required: false,
+      type: String,
+      default: "",
+    },
+    withoutTime: {
+      required: true,
+      type: Boolean,
+      default: false,
+    },
+  },
+};
+
+export default calendarFeedEventBarMixin;
diff --git a/aleksis/core/frontend/mixins/menus.js b/aleksis/core/frontend/mixins/menus.js
index 9ba58bcaa84e72917e3057614fca6058df39af3a..60ce66b5b0d36efa77cc2b8944ce21e928a1f107 100644
--- a/aleksis/core/frontend/mixins/menus.js
+++ b/aleksis/core/frontend/mixins/menus.js
@@ -1,15 +1,17 @@
 import gqlCustomMenu from "../components/app/customMenu.graphql";
 
+import permissionsMixin from "./permissions.js";
+
 /**
  * Vue mixin containing menu generation code.
  *
  * Only used by main App component, but factored out for readability.
  */
 const menusMixin = {
+  mixins: [permissionsMixin],
   data() {
     return {
       footerMenu: null,
-      permissionNames: [],
       sideNavMenu: null,
       accountMenu: null,
     };
@@ -35,8 +37,7 @@ const menusMixin = {
         }
       }
 
-      this.permissionNames = permArray;
-      this.$apollo.queries.whoAmI.refetch();
+      this.addPermissions(permArray);
     },
     buildMenu(routes, menuKey) {
       let menu = {};
@@ -99,14 +100,6 @@ const menusMixin = {
 
       return Object.values(menu);
     },
-    checkPermission(permissionName) {
-      return (
-        this.whoAmI &&
-        this.whoAmI.permissions &&
-        this.whoAmI.permissions.find((p) => p.name === permissionName) &&
-        this.whoAmI.permissions.find((p) => p.name === permissionName).result
-      );
-    },
     checkValidators(validators) {
       for (const validator of validators) {
         if (!validator(this.whoAmI)) {
@@ -118,14 +111,9 @@ const menusMixin = {
     buildMenus() {
       this.accountMenu = this.buildMenu(
         this.$router.getRoutes(),
-        "inAccountMenu",
-        this.whoAmI ? this.whoAmI.permissions : []
-      );
-      this.sideNavMenu = this.buildMenu(
-        this.$router.getRoutes(),
-        "inMenu",
-        this.whoAmI ? this.whoAmI.permissions : []
+        "inAccountMenu"
       );
+      this.sideNavMenu = this.buildMenu(this.$router.getRoutes(), "inMenu");
     },
   },
   apollo: {
diff --git a/aleksis/core/frontend/mixins/permissions.js b/aleksis/core/frontend/mixins/permissions.js
new file mode 100644
index 0000000000000000000000000000000000000000..9d5a00f51a747e06d0d38461351d9bc63a1dc8ac
--- /dev/null
+++ b/aleksis/core/frontend/mixins/permissions.js
@@ -0,0 +1,28 @@
+/**
+ * Vue mixin containing permission checking code.
+ */
+
+const permissionsMixin = {
+  methods: {
+    checkPermission(permissionName) {
+      return (
+        this.$root.permissions &&
+        this.$root.permissions.find((p) => p.name === permissionName) &&
+        this.$root.permissions.find((p) => p.name === permissionName).result
+      );
+    },
+    addPermissions(newPermissionNames) {
+      const keepPermissionNames = this.$root.permissionNames.filter(
+        (oldPermName) =>
+          !newPermissionNames.find((newPermName) => newPermName === oldPermName)
+      );
+
+      this.$root.permissionNames = [
+        ...keepPermissionNames,
+        ...newPermissionNames,
+      ];
+    },
+  },
+};
+
+export default permissionsMixin;
diff --git a/aleksis/core/frontend/mixins/routes.js b/aleksis/core/frontend/mixins/routes.js
index 9adbdafc7426fcc19174f91266b978471f3a38e9..e458b23a6f65d22f9682178980ab8e15fe1e74d5 100644
--- a/aleksis/core/frontend/mixins/routes.js
+++ b/aleksis/core/frontend/mixins/routes.js
@@ -14,7 +14,7 @@ const routesMixin = {
   apollo: {
     dynamicRoutes: {
       query: gqlDynamicRoutes,
-      pollInterval: 10000,
+      pollInterval: 30000,
     },
   },
   watch: {
diff --git a/aleksis/core/frontend/plugins/aleksis.js b/aleksis/core/frontend/plugins/aleksis.js
index b69eb2ebe36560c7f18bcd7c150d94fd7539a3b3..09c04330a58adede55fa2198bc2c637e0707a829 100644
--- a/aleksis/core/frontend/plugins/aleksis.js
+++ b/aleksis/core/frontend/plugins/aleksis.js
@@ -147,10 +147,10 @@ AleksisVue.install = function (Vue) {
    * Load vuetifys built-in translations
    */
   Vue.prototype.$loadVuetifyMessages = function () {
-    for(const [locale, messages] of Object.entries(langs)) {
-      this.$i18n.mergeLocaleMessage(locale, {$vuetify: messages})
+    for (const [locale, messages] of Object.entries(langs)) {
+      this.$i18n.mergeLocaleMessage(locale, { $vuetify: messages });
     }
-  }
+  };
 
   /**
    * Invalidate state and force reload from server.
diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js
index 5024ff93520b586f4dc198cd9b47004fac0e2cf9..a1d81600660398926d3ba843be0a5000870f4359 100644
--- a/aleksis/core/frontend/routes.js
+++ b/aleksis/core/frontend/routes.js
@@ -8,7 +8,7 @@
 // aleksisAppImporter is a virtual module defined in Vite config
 import { appObjects } from "aleksisAppImporter";
 
-import { notLoggedInValidator } from "./routeValidators";
+import { hasPersonValidator, notLoggedInValidator } from "./routeValidators";
 
 const routes = [
   {
@@ -54,6 +54,7 @@ const routes = [
       icon: "mdi-key-outline",
       titleKey: "accounts.invitation.accept_invitation.menu_title",
       validators: [notLoggedInValidator],
+      permission: "core.invite_enabled",
     },
   },
   {
@@ -949,14 +950,6 @@ const routes = [
       invalidate: "leave",
     },
   },
-  {
-    path: "/invitations/code/enter",
-    component: () => import("./components/LegacyBaseTemplate.vue"),
-    props: {
-      byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-    },
-    name: "core.enter_invitation_code",
-  },
   {
     path: "/invitations/code/generate",
     component: () => import("./components/LegacyBaseTemplate.vue"),
@@ -1056,6 +1049,17 @@ const routes = [
     },
     name: "invitations.accept_invite",
   },
+  {
+    path: "/calendar/overview",
+    component: () => import("./components/calendar/CalendarOverview.vue"),
+    name: "core.calendar_overview",
+    meta: {
+      inMenu: true,
+      icon: "mdi-calendar",
+      titleKey: "calendar.menu_title_overview",
+      validators: [hasPersonValidator],
+    },
+  },
 ];
 
 // This imports all known AlekSIS app entrypoints
diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py
index 7a549775f1730d57f83e6c3486644fd2c3bdda65..66cac5f59cb9a328aa36be23f36f5dc481b8e1f1 100644
--- a/aleksis/core/managers.py
+++ b/aleksis/core/managers.py
@@ -8,7 +8,7 @@ from django.db.models.manager import Manager
 
 from calendarweek import CalendarWeek
 from django_cte import CTEManager, CTEQuerySet
-from polymorphic.managers import PolymorphicManager
+from polymorphic.managers import PolymorphicManager, PolymorphicQuerySet
 
 
 class CurrentSiteManagerWithoutMigrations(_CurrentSiteManager):
@@ -123,5 +123,16 @@ class InstalledWidgetsDashboardWidgetOrderManager(Manager):
         return super().get_queryset().filter(widget_id__in=dashboard_widget_pks)
 
 
-class PolymorphicCurrentSiteManager(_CurrentSiteManager, PolymorphicManager):
+class PolymorphicCurrentSiteManager(CurrentSiteManagerWithoutMigrations, PolymorphicManager):
     """Default manager for extensible, polymorphic models."""
+
+
+class HolidayQuerySet(DateRangeQuerySetMixin, PolymorphicQuerySet):
+    """QuerySet with custom query methods for holidays."""
+
+    def get_all_days(self) -> list[date]:
+        """Get all days included in the selected holidays."""
+        holiday_days = []
+        for holiday in self:
+            holiday_days += list(holiday.get_days())
+        return holiday_days
diff --git a/aleksis/core/migrations/0049_calendarevent.py b/aleksis/core/migrations/0049_calendarevent.py
new file mode 100644
index 0000000000000000000000000000000000000000..d2f54fc4a353c982e5e5da6b0a879cf484d4dc71
--- /dev/null
+++ b/aleksis/core/migrations/0049_calendarevent.py
@@ -0,0 +1,76 @@
+# Generated by Django 4.1.5 on 2023-01-29 13:46
+
+import aleksis.core.managers
+import aleksis.core.mixins
+from django.db import migrations, models
+import django.db.models.deletion
+import recurrence.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("sites", "0002_alter_domain_unique"),
+        ("contenttypes", "0002_remove_content_type_name"),
+        ("core", "0048_delete_personalicalurl"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="CalendarEvent",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+                    ),
+                ),
+                ("extended_data", models.JSONField(default=dict, editable=False)),
+                ("start", models.DateTimeField(verbose_name="Start date and time")),
+                ("end", models.DateTimeField(verbose_name="End date and time")),
+                (
+                    "recurrences",
+                    recurrence.fields.RecurrenceField(
+                        blank=True, null=True, verbose_name="Recurrences"
+                    ),
+                ),
+                (
+                    "ammends",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="core.calendarevent",
+                        verbose_name="Ammended base event",
+                    ),
+                ),
+                (
+                    "polymorphic_ctype",
+                    models.ForeignKey(
+                        editable=False,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="polymorphic_%(app_label)s.%(class)s_set+",
+                        to="contenttypes.contenttype",
+                    ),
+                ),
+                (
+                    "site",
+                    models.ForeignKey(
+                        default=1,
+                        editable=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="sites.site",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Calendar Event",
+                "verbose_name_plural": "Calendar Events",
+            },
+            bases=(aleksis.core.mixins.CalendarEventMixin, models.Model),
+            managers=[
+                ("objects", aleksis.core.managers.PolymorphicCurrentSiteManager()),
+            ],
+        ),
+    ]
diff --git a/aleksis/core/migrations/0050_fix_amends.py b/aleksis/core/migrations/0050_fix_amends.py
new file mode 100644
index 0000000000000000000000000000000000000000..09ea72fb0f44577aec4284343420dc7253e478cb
--- /dev/null
+++ b/aleksis/core/migrations/0050_fix_amends.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.1.7 on 2023-03-26 10:26
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("sites", "0002_alter_domain_unique"),
+        ("core", "0049_calendarevent"),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name="calendarevent",
+            old_name="ammends",
+            new_name="amends",
+        ),
+    ]
diff --git a/aleksis/core/migrations/0051_calendarevent_dates.py b/aleksis/core/migrations/0051_calendarevent_dates.py
new file mode 100644
index 0000000000000000000000000000000000000000..e4b0bc3e763a0b00447f733d0c26f8c65373119f
--- /dev/null
+++ b/aleksis/core/migrations/0051_calendarevent_dates.py
@@ -0,0 +1,92 @@
+# Generated by Django 4.1.8 on 2023-04-08 15:25
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("sites", "0002_alter_domain_unique"),
+        ("core", "0050_fix_amends"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="calendarevent",
+            options={
+                "ordering": ["datetime_start", "date_start", "datetime_end", "date_end"],
+                "verbose_name": "Calendar Event",
+                "verbose_name_plural": "Calendar Events",
+            },
+        ),
+        migrations.RenameField(
+            model_name="calendarevent",
+            old_name="end",
+            new_name="datetime_end",
+        ),
+        migrations.RenameField(
+            model_name="calendarevent",
+            old_name="start",
+            new_name="datetime_start",
+        ),
+        migrations.AddField(
+            model_name="calendarevent",
+            name="date_end",
+            field=models.DateField(blank=True, null=True, verbose_name="End date"),
+        ),
+        migrations.AddField(
+            model_name="calendarevent",
+            name="date_start",
+            field=models.DateField(blank=True, null=True, verbose_name="Start date"),
+        ),
+        migrations.AlterField(
+            model_name="calendarevent",
+            name="amends",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to="core.calendarevent",
+                verbose_name="Amended base event",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="calendarevent",
+            name="datetime_end",
+            field=models.DateTimeField(blank=True, null=True, verbose_name="End date and time"),
+        ),
+        migrations.AlterField(
+            model_name="calendarevent",
+            name="datetime_start",
+            field=models.DateTimeField(blank=True, null=True, verbose_name="Start date and time"),
+        ),
+        migrations.AlterField(
+            model_name="calendarevent",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="calendarevent",
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    ("date_start__isnull", True), ("datetime_start__isnull", True), _negated=True
+                ),
+                name="datetime_start_or_date_start",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="calendarevent",
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    ("date_end__isnull", True), ("datetime_end__isnull", True), _negated=True
+                ),
+                name="datetime_end_or_date_end",
+            ),
+        ),
+    ]
diff --git a/aleksis/core/migrations/0052_holiday.py b/aleksis/core/migrations/0052_holiday.py
new file mode 100644
index 0000000000000000000000000000000000000000..ed2e75ba011261884c26e9e695f85595d0b63e91
--- /dev/null
+++ b/aleksis/core/migrations/0052_holiday.py
@@ -0,0 +1,36 @@
+# Generated by Django 4.1.8 on 2023-04-09 14:09
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("sites", "0002_alter_domain_unique"),
+        ("core", "0051_calendarevent_dates"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Holiday",
+            fields=[
+                (
+                    "calendarevent_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="core.calendarevent",
+                    ),
+                ),
+                ("holiday_name", models.CharField(max_length=255, verbose_name="Name")),
+            ],
+            options={
+                "verbose_name": "Holiday",
+                "verbose_name_plural": "Holidays",
+            },
+            bases=("core.calendarevent",),
+        ),
+    ]
diff --git a/aleksis/core/migrations/0053_alter_calendarevent_managers_alter_activity_site_and_more.py b/aleksis/core/migrations/0053_alter_calendarevent_managers_alter_activity_site_and_more.py
new file mode 100644
index 0000000000000000000000000000000000000000..452e18e2a9bed5be3c42f19eb313b9a54df5a27d
--- /dev/null
+++ b/aleksis/core/migrations/0053_alter_calendarevent_managers_alter_activity_site_and_more.py
@@ -0,0 +1,223 @@
+# Generated by Django 4.1.8 on 2023-05-01 17:55
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("sites", "0002_alter_domain_unique"),
+        ("core", "0052_holiday"),
+    ]
+
+    operations = [
+        migrations.AlterModelManagers(
+            name="calendarevent",
+            managers=[],
+        ),
+        migrations.AlterField(
+            model_name="activity",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="additionalfield",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="announcement",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="announcementrecipient",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="custommenu",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="custommenuitem",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="dashboardwidgetorder",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="datacheckresult",
+            name="data_check",
+            field=models.CharField(
+                choices=[
+                    (
+                        "broken_dashboard_widgets",
+                        "Ensure that there are no broken DashboardWidgets.",
+                    ),
+                    (
+                        "field_validation_custommenuitem_icon",
+                        "Validate field icon of model core.CustomMenuItem.",
+                    ),
+                ],
+                max_length=255,
+                verbose_name="Related data check task",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="datacheckresult",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="group",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="grouptype",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="notification",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="pdffile",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="person",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="persongroupthrough",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="room",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="schoolterm",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="taskuserassignment",
+            name="site",
+            field=models.ForeignKey(
+                default=1,
+                editable=False,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="+",
+                to="sites.site",
+            ),
+        ),
+    ]
diff --git a/aleksis/core/migrations/0054_calendarevent_timezone.py b/aleksis/core/migrations/0054_calendarevent_timezone.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2a98b3d712c9e3dcc2410ff398902370d8e8b30
--- /dev/null
+++ b/aleksis/core/migrations/0054_calendarevent_timezone.py
@@ -0,0 +1,51 @@
+# Generated by Django 4.1.9 on 2023-05-29 11:00
+
+from django.db import migrations, models
+import django.db.models.deletion
+import timezone_field.fields
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("core", "0053_alter_calendarevent_managers_alter_activity_site_and_more"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="calendarevent",
+            name="timezone",
+            field=timezone_field.fields.TimeZoneField(
+                blank=True, null=True, verbose_name="Timezone"
+            ),
+        ),
+        migrations.AlterField(
+            model_name="calendarevent",
+            name="amends",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="amended_by",
+                to="core.calendarevent",
+                verbose_name="Amended base event",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="calendarevent",
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    ("datetime_start__isnull", False), ("timezone__isnull", True), _negated=True
+                ),
+                name="timezone_if_datetime_start",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="calendarevent",
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    ("datetime_end__isnull", False), ("timezone__isnull", True), _negated=True
+                ),
+                name="timezone_if_datetime_end",
+            ),
+        ),
+    ]
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index e734b40a8f7cf55b86e1f496cfb8e8cfe0099cfb..43bed808ee3f36b98f2745a5bab329f6ece97508 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -2,7 +2,7 @@
 
 import os
 from datetime import datetime
-from typing import Any, Callable, ClassVar, List, Optional, Union
+from typing import Any, Callable, ClassVar, Iterable, List, Optional, Union
 
 from django.conf import settings
 from django.contrib import messages
@@ -15,17 +15,20 @@ from django.db.models import JSONField, QuerySet
 from django.db.models.fields import CharField, TextField
 from django.forms.forms import BaseForm
 from django.forms.models import ModelForm, ModelFormMetaclass
-from django.http import HttpResponse
+from django.http import HttpRequest, HttpResponse
 from django.utils.functional import classproperty, lazy
 from django.utils.translation import gettext as _
 from django.views.generic import CreateView, UpdateView
 from django.views.generic.edit import DeleteView, ModelFormMixin
 
+import recurring_ical_events
 import reversion
+from django_ical.feedgenerator import ITEM_ELEMENT_FIELD_MAP
 from dynamic_preferences.settings import preferences_settings
 from dynamic_preferences.types import FilePreference
 from guardian.admin import GuardedModelAdmin
 from guardian.core import ObjectPermissionChecker
+from icalendar import Calendar
 from jsonstore.fields import IntegerField, JSONFieldMixin
 from material.base import Layout, LayoutNode
 from polymorphic.base import PolymorphicModelBase
@@ -39,6 +42,8 @@ from aleksis.core.managers import (
     SchoolTermRelatedQuerySet,
 )
 
+from .util.core_helpers import ExtendedICal20Feed
+
 
 class _ExtensibleModelBase(models.base.ModelBase):
     """Ensure predefined behaviour on model creation.
@@ -548,7 +553,7 @@ class PublicFilePreferenceMixin(FilePreference):
 class RegistryObject:
     """Generic registry to allow registration of subclasses over all apps."""
 
-    _registry: ClassVar[Optional[dict[str, "RegistryObject"]]] = None
+    _registry: ClassVar[Optional[dict[str, type["RegistryObject"]]]] = None
     name: ClassVar[str] = ""
 
     def __init_subclass__(cls):
@@ -560,18 +565,160 @@ class RegistryObject:
             cls._register()
 
     @classmethod
-    def _register(cls):
+    def _register(cls: type["RegistryObject"]):
         if cls.name and cls.name not in cls._registry:
             cls._registry[cls.name] = cls
 
     @classproperty
-    def registered_objects_dict(cls):
+    def registered_objects_dict(cls) -> dict[str, type["RegistryObject"]]:
+        """Get dict of registered objects."""
         return cls._registry
 
     @classproperty
-    def registered_objects_list(cls):
+    def registered_objects_list(cls) -> list[type["RegistryObject"]]:
+        """Get list of registered objects."""
         return list(cls._registry.values())
 
     @classmethod
-    def get_object_by_name(cls, name):
-        cls.registered_objects_dict.get(name)
+    def get_object_by_name(cls, name: str) -> Optional[type["RegistryObject"]]:
+        """Get registered object by name."""
+        return cls.registered_objects_dict.get(name)
+
+
+class CalendarEventMixin(RegistryObject):
+    """Mixin for calendar feeds.
+
+    This mixin can be used to create calendar feeds for objects. It can be used
+    by adding it to a model or another object. The basic attributes of the calendar
+    can be set by either setting the attributes of the class or by implementing
+    the corresponding class methods. Please notice that the class methods are
+    overriding the attributes. The following attributes are mandatory:
+
+    - name: Unique name for the calendar feed
+    - verbose_name: Shown name of the feed
+
+    The respective class methods have a `get_` prefix and are called without any arguments.
+    There are also some more attributes. Please refer to the class signature for more
+    information.
+
+    The list of objects used to create the calendar feed have to be provided by
+    the method `get_objects` class method. It's mandatory to implement this method.
+
+    To provide the data for the events, a certain set of class methods can be implemented.
+    The following iCal attributes are supported:
+
+    guid, title, description, link, class, created, updateddate, start_datetime, end_datetime,
+    location, geolocation, transparency, organizer, attendee, rrule, rdate, exdate, valarm, status
+
+    To implement a method for a certain attribute, the name of the method has to be
+    `value_<your_attribute>`. For example, to implement the `title` attribute, the
+    method `value_title` has to be implemented. The method has to return the value
+    for the attribute. The method is called with the reference object as argument.
+    """
+
+    name: str = ""  # Unique name for the calendar feed
+    verbose_name: str = ""  # Shown name of the feed
+    link: str = ""  # Link for the feed, optional
+    description: str = ""  # Description of the feed, optional
+    color: str = "#222222"  # Color of the feed, optional
+
+    @classmethod
+    def get_verbose_name(cls, request) -> str:
+        """Return the verbose name of the calendar feed."""
+        return cls.verbose_name
+
+    @classmethod
+    def get_link(cls, request) -> str:
+        """Return the link of the calendar feed."""
+        return cls.link
+
+    @classmethod
+    def get_description(cls, request) -> str:
+        """Return the description of the calendar feed."""
+        return cls.description
+
+    @classmethod
+    def get_language(cls, request) -> str:
+        """Return the language of the calendar feed."""
+        return "en"  # FIXME
+
+    @classmethod
+    def get_color(cls, request) -> str:
+        """Return the color of the calendar feed."""
+        return cls.color
+
+    @classmethod
+    def create_event(cls, reference_object, feed: ExtendedICal20Feed, request) -> dict[str, Any]:
+        """Create an event for the given reference object and add it to the feed."""
+        values = {}
+        for field in cls.get_event_field_names():
+            field_value = cls.get_event_field_value(reference_object, field, request)
+            if field_value is not None:
+                values[field] = field_value
+        feed.add_item(**values)
+        return values
+
+    @classmethod
+    def start_feed(cls, request) -> ExtendedICal20Feed:
+        """Start the feed and return it."""
+        feed = ExtendedICal20Feed(
+            title=cls.get_verbose_name(request),
+            link=cls.get_link(request),
+            description=cls.get_description(request),
+            language=cls.get_language(request),
+            color=cls.get_color(request),
+        )
+        return feed
+
+    @classmethod
+    def get_objects(cls, request: HttpRequest) -> Iterable:
+        """Return the objects to create the calendar feed for."""
+        raise NotImplementedError
+
+    @classmethod
+    def create_feed(cls, request: HttpRequest) -> ExtendedICal20Feed:
+        """Create the calendar feed with all events."""
+        feed = cls.start_feed(request)
+
+        for reference_object in cls.get_objects(request):
+            cls.create_event(reference_object, feed, request)
+
+        return feed
+
+    @classmethod
+    def get_calendar_object(cls, request: HttpRequest) -> Calendar:
+        """Return the calendar object."""
+        feed = cls.create_feed(request)
+        return feed.get_calendar_object()
+
+    @classmethod
+    def get_single_events(cls, request: HttpRequest, start=None, end=None):
+        """Get single events for this calendar feed."""
+        feed = cls.create_feed(request)
+        return feed.get_single_events(start, end)
+
+    @classmethod
+    def get_event_field_names(cls) -> list[str]:
+        """Return the names of the fields to be used for the feed."""
+        return [field_map[0] for field_map in ITEM_ELEMENT_FIELD_MAP]
+
+    @classmethod
+    def get_event_field_value(cls, reference_object, field_name: str, request):
+        """Return the value for the given field name."""
+        method_name = f"value_{field_name}"
+        if hasattr(cls, method_name) and callable(getattr(cls, method_name)):
+            return getattr(cls, method_name)(reference_object, request)
+        return None
+
+    @classmethod
+    def value_link(cls, reference_object, request) -> str:
+        return ""
+
+    @classmethod
+    def value_color(cls, reference_object, request) -> str:
+        return cls.get_color(request)
+
+    @classproperty
+    def valid_feeds(cls):
+        """Return a list of valid feeds."""
+        return [feed for feed in cls.registered_objects_list if feed.name != feed.__name__]
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index d90790427fb3c22531ec0cda50e9c48eb615e196..3f6f6d915a4570855317de09939c980b25069949 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -3,7 +3,7 @@ import base64
 import hmac
 import uuid
 from datetime import date, datetime, timedelta
-from typing import Any, Iterable, List, Optional, Sequence, Union
+from typing import Any, Iterable, Iterator, List, Optional, Sequence, Union
 from urllib.parse import urljoin, urlparse
 
 from django.conf import settings
@@ -23,6 +23,7 @@ from django.dispatch import receiver
 from django.forms.widgets import Media
 from django.urls import reverse
 from django.utils import timezone
+from django.utils.formats import date_format
 from django.utils.functional import classproperty
 from django.utils.text import slugify
 from django.utils.translation import gettext_lazy as _
@@ -31,12 +32,17 @@ import customidenticon
 import jsonstore
 from cachalot.api import cachalot_disabled
 from cache_memoize import cache_memoize
+from calendarweek import CalendarWeek
 from celery.result import AsyncResult
 from celery_progress.backend import Progress
 from ckeditor.fields import RichTextField
 from django_celery_results.models import TaskResult
 from django_cte import CTEQuerySet, With
+from django_ical.utils import build_rrule_from_recurrences_rrule, build_rrule_from_text
 from dynamic_preferences.models import PerInstancePreferenceModel
+from guardian.shortcuts import get_objects_for_user
+from icalendar import vCalAddress, vText
+from icalendar.prop import vRecur
 from invitations import signals
 from invitations.adapters import get_invitations_adapter
 from invitations.base_invitation import AbstractBaseInvitation
@@ -52,6 +58,8 @@ from oauth2_provider.models import (
 )
 from phonenumber_field.modelfields import PhoneNumberField
 from polymorphic.models import PolymorphicModel
+from recurrence.fields import RecurrenceField
+from timezone_field import TimeZoneField
 
 from aleksis.core.data_checks import (
     BrokenDashboardWidgetDataCheck,
@@ -63,12 +71,16 @@ from .managers import (
     CurrentSiteManagerWithoutMigrations,
     GroupManager,
     GroupQuerySet,
+    HolidayQuerySet,
     InstalledWidgetsDashboardWidgetOrderManager,
+    PolymorphicCurrentSiteManager,
     SchoolTermQuerySet,
     UninstallRenitentPolymorphicManager,
 )
 from .mixins import (
+    CalendarEventMixin,
     ExtensibleModel,
+    ExtensiblePolymorphicModel,
     GlobalPermissionModel,
     PureDjangoModel,
     RegistryObject,
@@ -365,6 +377,16 @@ class Person(ExtensibleModel):
         else:
             return self.identicon_url
 
+    def get_vcal_address(self, role: str = "REQ-PARTICIPANT") -> Optional[vCalAddress]:
+        """Return a vCalAddress object for this person."""
+        if not self.email:
+            # vCalAddress requires an email address
+            return None
+        vcal = vCalAddress(f"MAILTO:{self.email}")
+        vcal.params["cn"] = vText(self.full_name)
+        vcal.params["ROLE"] = vText(role)
+        return vcal
+
     def save(self, *args, **kwargs):
         # Determine all fields that were changed since last load
         changed = self.user_info_tracker.changed()
@@ -1484,3 +1506,266 @@ class Room(ExtensibleModel):
                 fields=["site_id", "short_name"], name="unique_room_short_name_per_site"
             ),
         ]
+
+
+class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel):
+    """A planned event in a calendar.
+
+    To make use of this model, you need to inherit from this model.
+    Every subclass of this model represents a certain calendar (feed).
+    It therefore needs to set the basic attributes of the calendar like
+    described in the documentation of `CalendarEventMixin`.
+
+    Furthermore, every `value_*` method from `CalendarEventMixin`
+    can be implemented to provide additional data (either static or dynamic).
+    Some like start and end date are pre-implemented in this model. Others, like
+    `value_title` need to be implemented in the subclass. Some methods are
+    also optional, like `value_location` or `value_description`.
+    Please refer to the documentation of `CalendarEventMixin` for more information.
+    """
+
+    datetime_start = models.DateTimeField(
+        verbose_name=_("Start date and time"), null=True, blank=True
+    )
+    datetime_end = models.DateTimeField(verbose_name=_("End date and time"), null=True, blank=True)
+    timezone = TimeZoneField(verbose_name=_("Timezone"), null=True, blank=True)
+    date_start = models.DateField(verbose_name=_("Start date"), null=True, blank=True)
+    date_end = models.DateField(verbose_name=_("End date"), null=True, blank=True)
+    recurrences = RecurrenceField(verbose_name=_("Recurrences"), null=True, blank=True)
+    amends = models.ForeignKey(
+        "self",
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+        verbose_name=_("Amended base event"),
+        related_name="amended_by",
+    )
+
+    def provide_list_in_timezone(self, seq):
+        """Provide a list of datetimes in the saved timezone."""
+        return [dt.astimezone(self.timezone) if isinstance(dt, datetime) else dt for dt in seq]
+
+    @classmethod
+    def value_title(cls, reference_object: "CalendarEvent", request) -> str:
+        """Return the title of the calendar event."""
+        raise NotImplementedError()
+
+    @classmethod
+    def value_start_datetime(
+        cls, reference_object: "CalendarEvent", request
+    ) -> Union[datetime, date]:
+        """Return the start datetime of the calendar event."""
+        if reference_object.datetime_start:
+            return reference_object.datetime_start.astimezone(reference_object.timezone)
+        return reference_object.date_start
+
+    @classmethod
+    def value_end_datetime(
+        cls, reference_object: "CalendarEvent", request
+    ) -> Union[datetime, date]:
+        """Return the end datetime of the calendar event."""
+        if reference_object.datetime_end:
+            return reference_object.datetime_end.astimezone(reference_object.timezone)
+        return reference_object.date_end + timedelta(days=1)
+
+    @classmethod
+    def value_rrule(cls, reference_object: "CalendarEvent", request) -> Optional[vRecur]:
+        """Return the rrule of the calendar event."""
+        if not reference_object.recurrences or not reference_object.recurrences.rrules:
+            return None
+        # iCal only supports one RRULE per event as per RFC 5545 (change to older RFC 2445)
+        return build_rrule_from_recurrences_rrule(reference_object.recurrences.rrules[0])
+
+    @classmethod
+    def value_rdate(cls, reference_object: "CalendarEvent", request) -> Optional[list[datetime]]:
+        """Return the rdate of the calendar event."""
+        if not reference_object.recurrences:
+            return None
+        return reference_object.provide_list_in_timezone(reference_object.recurrences.rdates)
+
+    @classmethod
+    def value_exrule(cls, reference_object: "CalendarEvent", request) -> Optional[vRecur]:
+        """Return the exrule of the calendar event."""
+        if not reference_object.recurrences or not reference_object.recurrences.exrules:
+            return None
+        return [build_rrule_from_recurrences_rrule(r) for r in reference_object.recurrences.exrules]
+
+    @classmethod
+    def value_exdate(cls, reference_object: "CalendarEvent", request) -> Optional[list[datetime]]:
+        """Return the exdate of the calendar event."""
+        if not reference_object.recurrences:
+            return None
+        # return reference_object.recurrences.exdates
+        return reference_object.provide_list_in_timezone(reference_object.recurrences.exdates)
+
+    @classmethod
+    def value_unique_id(cls, reference_object: "CalendarEvent", request) -> str:
+        """Return an unique identifier for an event."""
+        if reference_object.amends:
+            return cls.value_unique_id(reference_object.amends, request)
+        return f"{cls.name}-{reference_object.id}"
+
+    @classmethod
+    def value_recurrence_id(
+        cls, reference_object: "CalendarEvent", request
+    ) -> Optional[Union[datetime, date]]:
+        """Return the recurrence id of the calendar event."""
+        if reference_object.amends:
+            return reference_object.amends.value_start_datetime(reference_object, request)
+        return None
+
+    @classmethod
+    def value_color(cls, reference_object: "CalendarEvent", request) -> str:
+        """Return the color of the calendar."""
+        return cls.get_color(request)
+
+    @classmethod
+    def get_objects(cls, request) -> Iterable:
+        """Return all objects that should be included in the calendar."""
+        return cls.objects.instance_of(cls)
+
+    def save(self, *args, **kwargs):
+        if (
+            self.datetime_start
+            and self.datetime_end
+            and self.datetime_start.tzinfo != self.datetime_end.tzinfo
+        ):
+            self.datetime_end = self.datetime_end.astimezone(self.datetime_start.tzinfo)
+        if self.datetime_start and self.datetime_end:
+            self.timezone = self.datetime_start.tzinfo
+        super().save(*args, **kwargs)
+
+    class Meta:
+        verbose_name = _("Calendar Event")
+        verbose_name_plural = _("Calendar Events")
+        constraints = [
+            models.CheckConstraint(
+                check=~Q(datetime_start__isnull=True, date_start__isnull=True),
+                name="datetime_start_or_date_start",
+            ),
+            models.CheckConstraint(
+                check=~Q(datetime_end__isnull=True, date_end__isnull=True),
+                name="datetime_end_or_date_end",
+            ),
+            models.CheckConstraint(
+                check=~Q(datetime_start__isnull=False, timezone__isnull=True),
+                name="timezone_if_datetime_start",
+            ),
+            models.CheckConstraint(
+                check=~Q(datetime_end__isnull=False, timezone__isnull=True),
+                name="timezone_if_datetime_end",
+            ),
+        ]
+        ordering = ["datetime_start", "date_start", "datetime_end", "date_end"]
+
+
+class BirthdayEvent(CalendarEventMixin):
+    """A calendar feed with all birthdays."""
+
+    name = "birthdays"
+    verbose_name = _("Birthdays")
+
+    @classmethod
+    def value_title(cls, reference_object: Person, request) -> str:
+        return _("{}'s birthday").format(reference_object.addressing_name)
+
+    @classmethod
+    def value_description(cls, reference_object: Person, request) -> str:
+        return ("{name} was born on {birthday}.").format(
+            name=reference_object.addressing_name,
+            birthday=date_format(reference_object.date_of_birth),
+        )
+
+    @classmethod
+    def value_start_datetime(cls, reference_object: Person, request) -> date:
+        return reference_object.date_of_birth
+
+    @classmethod
+    def value_rrule(cls, reference_object: Person, request) -> vRecur:
+        return build_rrule_from_text("FREQ=YEARLY")
+
+    @classmethod
+    def value_unique_id(cls, reference_object: Person, request) -> str:
+        return f"birthday-{reference_object.id}"
+
+    @classmethod
+    def get_color(cls, request) -> str:
+        return get_site_preferences()["calendar__birthday_color"]
+
+    @classmethod
+    def get_objects(cls, request) -> QuerySet:
+        qs = Person.objects.filter(date_of_birth__isnull=False)
+        qs = qs.filter(
+            Q(pk=request.user.person.pk)
+            | Q(pk__in=get_objects_for_user(request.user, "core.view_personal_details", qs))
+        )
+        return qs
+
+
+class Holiday(CalendarEvent):
+    """Holiday model for keeping track of school holidays."""
+
+    name = "holidays"
+    verbose_name = _("Holidays")
+
+    @classmethod
+    def value_title(cls, reference_object: "Holiday", request) -> str:
+        return reference_object.holiday_name
+
+    @classmethod
+    def value_description(cls, reference_object: "Holiday", request) -> str:
+        return ""
+
+    @classmethod
+    def get_color(cls, request) -> str:
+        return get_site_preferences()["calendar__holiday_color"]
+
+    objects = PolymorphicCurrentSiteManager.from_queryset(HolidayQuerySet)()
+
+    holiday_name = models.CharField(verbose_name=_("Name"), max_length=255)
+
+    def get_days(self) -> Iterator[date]:
+        """Get all days included in the holiday."""
+        delta = self.date_end - self.date_start
+        for i in range(delta.days + 1):
+            yield self.date_start + timedelta(days=i)
+
+    @classmethod
+    def in_week(cls, week: CalendarWeek) -> dict[int, Optional["Holiday"]]:
+        """Get the holidays that are active in a given week."""
+        per_weekday = {}
+        holidays = Holiday.objects.in_week(week)
+
+        for weekday in range(0, 7):
+            holiday_date = week[weekday]
+            filtered_holidays = list(
+                filter(
+                    lambda h: holiday_date >= h.date_start and holiday_date <= h.date_end,
+                    holidays,
+                )
+            )
+            if filtered_holidays:
+                per_weekday[weekday] = filtered_holidays[0]
+
+        return per_weekday
+
+    @classmethod
+    def get_ex_dates(cls, datetime_start, datetime_end, recurrence):
+        """Get the dates to exclude for holidays."""
+        recurrence.dtstart = recurrence.dtstart.astimezone(timezone.get_current_timezone())
+        holiday_dates = [
+            h["DTSTART"].dt for h in Holiday.get_single_events(datetime_start, datetime_end)
+        ]
+        exdates = [
+            h.astimezone(timezone.utc)
+            for h in recurrence.occurrences()
+            if h.date() in holiday_dates
+        ]
+        return exdates
+
+    def __str__(self) -> str:
+        return self.holiday_name
+
+    class Meta:
+        verbose_name = _("Holiday")
+        verbose_name_plural = _("Holidays")
diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py
index 7d10c2ae5c4a7b2538ad907c227033ea6a0ef0f2..4c9758e16e3722c8775e50a11004c533366b3f05 100644
--- a/aleksis/core/preferences.py
+++ b/aleksis/core/preferences.py
@@ -30,6 +30,7 @@ footer = Section("footer", verbose_name=_("Footer"))
 account = Section("account", verbose_name=_("Accounts"))
 auth = Section("auth", verbose_name=_("Authentication"))
 internationalisation = Section("internationalisation", verbose_name=_("Internationalisation"))
+calendar = Section("calendar", verbose_name=_("Calendar"))
 
 
 @site_preferences_registry.register
@@ -477,3 +478,27 @@ class AutoUpdatingDashboardSite(BooleanPreference):
     name = "automatically_update_dashboard_site"
     default = True
     verbose_name = _("Automatically update the dashboard and its widgets sitewide")
+
+
+@site_preferences_registry.register
+class BirthdayFeedColor(StringPreference):
+    """Color for the birthdays calendar feed."""
+
+    section = calendar
+    name = "birthday_color"
+    default = "#0d5eaf"
+    verbose_name = _("Birthday calendar feed color")
+    widget = ColorWidget
+    required = True
+
+
+@site_preferences_registry.register
+class HolidayFeedColor(StringPreference):
+    """Color for the holidays calendar feed."""
+
+    section = calendar
+    name = "holiday_color"
+    default = "#0d5eaf"
+    verbose_name = _("Holiday calendar feed color")
+    widget = ColorWidget
+    required = True
diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py
index 6d77fcd70fb32712c4358a9bcec5a03e306140d5..ae2f12129cd8193cb0f5fba9887a6a45c2c92785 100644
--- a/aleksis/core/rules.py
+++ b/aleksis/core/rules.py
@@ -409,3 +409,6 @@ rules.add_perm("core.test_pdf_rule", test_pdf_generation_predicate)
 
 view_progress_predicate = has_person & is_own_celery_task
 rules.add_perm("core.view_progress_rule", view_progress_predicate)
+
+view_calendar_feed_predicate = has_person
+rules.add_perm("core.view_calendar_feed_rule", view_calendar_feed_predicate)
diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py
index c5d5ddd502a2e8ebeb39c48288b94b05989c8d09..ecbd78eb930b83bd7fa6ba493e37ee242fb37fc9 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -10,6 +10,7 @@ from haystack.query import SearchQuerySet
 from haystack.utils.loading import UnifiedIndex
 
 from .base import FilterOrderList
+from ..mixins import CalendarEventMixin
 from ..models import (
     CustomMenu,
     DynamicRoute,
@@ -24,6 +25,7 @@ from ..models import (
 )
 from ..util.apps import AppConfig
 from ..util.core_helpers import get_allowed_object_ids, get_app_module, get_app_packages, has_person
+from .calendar import CalendarType
 from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType
 from .custom_menu import CustomMenuType
 from .dynamic_routes import DynamicRouteType
@@ -83,6 +85,10 @@ class Query(graphene.ObjectType):
 
     oauth_access_tokens = graphene.List(OAuthAccessTokenType)
 
+    calendar_feeds = graphene.List(CalendarType)
+
+    calendar_feeds_by_names = graphene.List(CalendarType, names=graphene.List(graphene.String))
+
     rooms = FilterOrderList(RoomType)
     room_by_id = graphene.Field(RoomType, id=graphene.ID())
 
@@ -194,6 +200,12 @@ class Query(graphene.ObjectType):
     def resolve_oauth_access_tokens(root, info, **kwargs):
         return OAuthAccessToken.objects.filter(user=info.context.user)
 
+    def resolve_calendar_feeds(root, info, **kwargs):
+        return CalendarEventMixin.valid_feeds
+
+    def resolve_calendar_feeds_by_names(root, info, names, **kwargs):
+        return [CalendarEventMixin.get_object_by_name(name) for name in names]
+
     @staticmethod
     def resolve_room_by_id(root, info, **kwargs):
         pk = kwargs.get("id")
diff --git a/aleksis/core/schema/calendar.py b/aleksis/core/schema/calendar.py
new file mode 100644
index 0000000000000000000000000000000000000000..4414249bc16259468c67256ee356732028cdaf7e
--- /dev/null
+++ b/aleksis/core/schema/calendar.py
@@ -0,0 +1,77 @@
+from datetime import datetime
+
+from django.urls import reverse
+
+import graphene
+from graphene import ObjectType
+
+
+class CalendarEventType(ObjectType):
+    name = graphene.String()
+    description = graphene.String()
+    start = graphene.String()
+    end = graphene.String()
+    color = graphene.String()
+    uid = graphene.String()
+    all_day = graphene.Boolean()
+    status = graphene.String()
+
+    def resolve_name(root, info, **kwargs):
+        return root["SUMMARY"]
+
+    def resolve_description(root, info, **kwargs):
+        return root["DESCRIPTION"]
+
+    def resolve_start(root, info, **kwargs):
+        return root["DTSTART"].dt
+
+    def resolve_end(root, info, **kwargs):
+        return root["DTEND"].dt
+
+    def resolve_color(root, info, **kwargs):
+        return root["COLOR"]
+
+    def resolve_uid(root, info, **kwargs):
+        return root["UID"]
+
+    def resolve_all_day(root, info, **kwargs):
+        return not isinstance(root["DTSTART"].dt, datetime)
+
+    def resolve_status(root, info, **kwargs):
+        return root.get("STATUS", "")
+
+
+class CalendarFeedType(ObjectType):
+    events = graphene.List(
+        CalendarEventType,
+        start=graphene.Date(required=False),
+        end=graphene.Date(required=False),
+    )
+
+    def resolve_events(root, info, start=None, end=None, **kwargs):
+        return root.get_single_events(start, end)
+
+
+class CalendarType(ObjectType):
+    name = graphene.String(required=True)
+    verbose_name = graphene.String(required=True)
+    description = graphene.String()
+    feed = graphene.Field(CalendarFeedType)
+    color = graphene.String()
+
+    url = graphene.String()
+
+    def resolve_verbose_name(root, info, **kwargs):
+        return root.get_verbose_name(info.context)
+
+    def resolve_description(root, info, **kwargs):
+        return root.get_description(info.context)
+
+    def resolve_feed(root, info, **kwargs):
+        return root.create_feed(info.context)
+
+    def resolve_url(root, info, **kwargs):
+        return info.context.build_absolute_uri(reverse("calendar_feed", args=[root.name]))
+
+    def resolve_color(root, info, **kwargs):
+        return root.get_color(info.context)
diff --git a/aleksis/core/schema/system_properties.py b/aleksis/core/schema/system_properties.py
index 1546512fc4e488faa01a93d87510249577c63388..6d6e50d5958601db24eca6e61d959482611cf5af 100644
--- a/aleksis/core/schema/system_properties.py
+++ b/aleksis/core/schema/system_properties.py
@@ -20,6 +20,7 @@ class LanguageType(graphene.ObjectType):
 
 class SystemPropertiesType(graphene.ObjectType):
     current_language = graphene.String(required=True)
+    default_language = graphene.Field(LanguageType)
     available_languages = graphene.List(LanguageType)
     site_preferences = graphene.Field(SitePreferencesType)
     custom_menu_by_name = graphene.Field(CustomMenuType)
@@ -27,6 +28,11 @@ class SystemPropertiesType(graphene.ObjectType):
     def resolve_current_language(parent, info, **kwargs):
         return info.context.LANGUAGE_CODE
 
+    @staticmethod
+    def resolve_default_language(root, info, **kwargs):
+        code = settings.LANGUAGE_CODE
+        return translation.get_language_info(code) | {"cookie": get_language_cookie(code)}
+
     def resolve_available_languages(parent, info, **kwargs):
         return [
             translation.get_language_info(code) | {"cookie": get_language_cookie(code)}
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 2923ba290c1364b20c5f2dda05f77aa2805fdc40..79ea1305cd2141509115da09db8de30a44d18914 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -156,6 +156,7 @@ INSTALLED_APPS = [
     "rest_framework",
     "graphene_django",
     "dj_iconify.apps.DjIconifyConfig",
+    "recurrence",
 ]
 
 merge_app_settings("INSTALLED_APPS", INSTALLED_APPS, True)
@@ -577,7 +578,7 @@ YARN_INSTALLED_APPS = [
     "@fontsource/roboto@^4.5.5",
     "jquery@^3.6.0",
     "@materializecss/materialize@~1.0.0",
-    "material-design-icons-iconfont@^6.6.0",
+    "material-design-icons-iconfont@^6.7.0",
     "select2-materialize@^0.1.8",
     "paper-css@^0.4.1",
     "jquery-sortablejs@^1.0.1",
@@ -588,6 +589,7 @@ YARN_INSTALLED_APPS = [
     "@iconify/json@^2.1.30",
     "@mdi/font@^7.2.96",
     "apollo-boost@^0.4.9",
+    "apollo-link-batch-http@^1.2.14",
     "apollo-link-retry@^2.2.16",
     "apollo3-cache-persist@^0.14.1",
     "deepmerge@^4.2.2",
diff --git a/aleksis/core/templates/account/email/email_confirmation_message.txt b/aleksis/core/templates/account/email/email_confirmation_message.txt
new file mode 100644
index 0000000000000000000000000000000000000000..f795c60f7b66c75b37217caed8c38c3d7f9295e0
--- /dev/null
+++ b/aleksis/core/templates/account/email/email_confirmation_message.txt
@@ -0,0 +1,7 @@
+{% extends "account/email/base_message.txt" %}
+{% load account %}
+{% load html_helpers %}
+{% load i18n %}
+
+{% block content %}{% user_display user as user_display %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Someone tried to register an account with the username {{ user_display }} and your e-mail address on {{ site_domain }}.
+If it was you, please confirm the registration by clicking on the following link:{% endblocktrans %} {{ activate_url|remove_prefix:"/django/" }}{% endblock %}
diff --git a/aleksis/core/tests/mixins/test_registry_object.py b/aleksis/core/tests/mixins/test_registry_object.py
new file mode 100644
index 0000000000000000000000000000000000000000..d872e4a7078d27fd176a4b3f557bef3b55467fbb
--- /dev/null
+++ b/aleksis/core/tests/mixins/test_registry_object.py
@@ -0,0 +1,57 @@
+import pytest
+
+from aleksis.core.mixins import RegistryObject
+
+
+def test_registry_object_name():
+    class ExampleRegistry(RegistryObject):
+        pass
+
+    class ExampleWithManualName(ExampleRegistry):
+        name = "example_a"
+
+    class ExampleWithAutomaticName(ExampleRegistry):
+        pass
+
+    class ExampleWithOverridenAutomaticName(ExampleWithManualName):
+        name = "example_b"
+
+    class ExampleWithOverridenManualName(ExampleWithAutomaticName):
+        name = "example_bb"
+
+    assert ExampleRegistry.name == ""
+    assert ExampleWithManualName.name == "example_a"
+    assert ExampleWithAutomaticName.name == "ExampleWithAutomaticName"
+    assert ExampleWithOverridenAutomaticName.name == "example_b"
+    assert ExampleWithOverridenManualName.name == "example_bb"
+
+
+def test_registry_object_registry():
+    class ExampleRegistry(RegistryObject):
+        pass
+
+    class ExampleA(ExampleRegistry):
+        pass
+
+    class ExampleB(ExampleRegistry):
+        pass
+
+    class ExampleAA(ExampleA):
+        name = "example_aa"
+
+    assert ExampleRegistry.registered_objects_dict == {
+        "ExampleA": ExampleA,
+        "ExampleB": ExampleB,
+        "example_aa": ExampleAA,
+    }
+    assert ExampleRegistry.registered_objects_dict == ExampleRegistry._registry
+
+    assert ExampleRegistry.registered_objects_list == [
+        ExampleA,
+        ExampleB,
+        ExampleAA,
+    ]
+
+    assert ExampleRegistry.get_object_by_name("ExampleA") == ExampleA
+    assert ExampleRegistry.get_object_by_name("ExampleB") == ExampleB
+    assert ExampleRegistry.get_object_by_name("example_aa") == ExampleAA
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 9792fcb75cd1e7197cda1f1e6707c3eb42ed1d3e..887a384247207c391f46b9e3eabfec870f863e9c 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -27,7 +27,7 @@ urlpatterns = [
     path("__icons__/", include("dj_iconify.urls")),
     path(
         "graphql/",
-        csrf_exempt(views.LoggingGraphQLView.as_view(graphiql=True)),
+        csrf_exempt(views.LoggingGraphQLView.as_view(batch=True)),
         name="graphql",
     ),
     path("logo", force_maintenance_mode_off(views.LogoView.as_view()), name="logo"),
@@ -42,11 +42,11 @@ urlpatterns = [
     ),
     path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")),
     path("system_status/", views.SystemStatusAPIView.as_view(), name="system_status_api"),
+    path("", include("django_prometheus.urls")),
     path(
         "django/",
         include(
             [
-                path("", include("django_prometheus.urls")),
                 path("account/login/", views.LoginView.as_view(), name="login"),
                 path(
                     "accounts/signup/", views.AccountRegisterView.as_view(), name="account_signup"
@@ -397,6 +397,7 @@ urlpatterns = [
                     views.AssignPermissionView.as_view(),
                     name="assign_permission",
                 ),
+                path("feeds/<str:name>.ics", views.ICalFeedView.as_view(), name="calendar_feed"),
             ]
         ),
     ),
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index c7f89fb5badfbca1e5014b78a150129afd990669..65576a76ce333b870e8eaeba2656cd92ae1c60ea 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -19,9 +19,12 @@ from django.utils.crypto import get_random_string
 from django.utils.functional import lazy
 from django.utils.module_loading import import_string
 
+import django_ical.feedgenerator as feedgenerator
+import recurring_ical_events
 from cachalot.api import invalidate
 from cachalot.signals import post_invalidation
 from cache_memoize import cache_memoize
+from icalendar import Calendar, Event, Todo
 
 
 def copyright_years(years: Sequence[int], separator: str = ", ", joiner: str = "–") -> str:
@@ -489,3 +492,75 @@ def get_ip(*args, **kwargs):
     from ipware.ip import get_client_ip  # noqa
 
     return get_client_ip(*args, **kwargs)[0]
+
+
+feedgenerator.FEED_FIELD_MAP = feedgenerator.FEED_FIELD_MAP + (("color", "color"),)
+feedgenerator.ITEM_ELEMENT_FIELD_MAP = feedgenerator.ITEM_ELEMENT_FIELD_MAP + (
+    ("color", "color"),
+    ("recurrence_id", "recurrence-id"),
+)
+
+
+class ExtendedICal20Feed(feedgenerator.ICal20Feed):
+    """Extends the ICal20Feed class from django-ical.
+
+    Adds a method to return the actual calendar object.
+    """
+
+    def get_calendar_object(self):
+        cal = Calendar()
+        cal.add("version", "2.0")
+        cal.add("calscale", "GREGORIAN")
+
+        for ifield, efield in feedgenerator.FEED_FIELD_MAP:
+            val = self.feed.get(ifield)
+            if val is not None:
+                cal.add(efield, val)
+
+        self.write_items(cal)
+
+        return cal
+
+    def write(self, outfile, encoding):
+        cal = Calendar()
+        cal.add("version", "2.0")
+        cal.add("calscale", "GREGORIAN")
+
+        for ifield, efield in feedgenerator.ITEM_ELEMENT_FIELD_MAP:
+            val = self.feed.get(ifield)
+            if val is not None:
+                cal.add(efield, val)
+
+        self.write_items(cal)
+
+        to_ical = getattr(cal, "as_string", None)
+        if not to_ical:
+            to_ical = cal.to_ical
+        outfile.write(to_ical())
+
+    def write_items(self, calendar):
+        for item in self.items:
+            component_type = item.get("component_type")
+            if component_type == "todo":
+                element = Todo()
+            else:
+                element = Event()
+            for ifield, efield in feedgenerator.ITEM_ELEMENT_FIELD_MAP:
+                val = item.get(ifield)
+                if val is not None:
+                    if ifield == "attendee":
+                        for list_item in val:
+                            element.add(efield, list_item)
+                    elif ifield == "valarm":
+                        for list_item in val:
+                            element.add_component(list_item)
+                    else:
+                        element.add(efield, val)
+            calendar.add_component(element)
+
+    def get_single_events(self, start=None, end=None):
+        """Get single event objects for this feed."""
+        events = recurring_ical_events.of(self.get_calendar_object())
+        if start and end:
+            return events.between(start, end)
+        return events.all()
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index c1de17adafc9bc33205a364b619b4c23c5b1e897..62f095450ea01e9295b97cfba2427683b7bb466a 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -97,7 +97,13 @@ from .forms import (
     SelectPermissionForm,
     SitePreferenceForm,
 )
-from .mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView, SuccessNextMixin
+from .mixins import (
+    AdvancedCreateView,
+    AdvancedDeleteView,
+    AdvancedEditView,
+    CalendarEventMixin,
+    SuccessNextMixin,
+)
 from .models import (
     AdditionalField,
     Announcement,
@@ -1585,3 +1591,18 @@ class LoggingGraphQLView(GraphQLView):
             if not isinstance(error.original_error, GraphQLError):
                 raise error
         return result
+
+
+class ICalFeedView(PermissionRequiredMixin, View):
+    """View to generate an iCal feed for a calendar."""
+
+    permission_required = "core.view_calendar_feed_rule"
+
+    def get(self, request, name, *args, **kwargs):
+        if name in CalendarEventMixin.registered_objects_dict:
+            calendar = CalendarEventMixin.registered_objects_dict[name]
+            feed = calendar.create_feed(request)
+            response = HttpResponse(content_type="text/calendar")
+            feed.write(response, "utf-8")
+            return response
+        raise Http404
diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js
index 287534f096c186bc428de742c4e5ff56b71e8eab..f1402e7702877f33967158820fda5ecf4f4141db 100644
--- a/aleksis/core/vite.config.js
+++ b/aleksis/core/vite.config.js
@@ -60,6 +60,28 @@ function generateMessageImportCode(assetDir, name, importAppName) {
   return code;
 }
 
+/**
+ * Generate code to import all components from a specified directory of a single AlekSIS app.
+ */
+function generateComponentsImportCode(
+  assetDir,
+  componentsDir,
+  name,
+  exportName
+) {
+  let code = "";
+  let componentsPath = assetDir + "/components" + componentsDir;
+  if (fs.existsSync(componentsPath)) {
+    const files = fs.readdirSync(componentsPath);
+    for (file of files) {
+      let componentName = file.split(".")[0];
+      code += `import ${componentName} from '${componentsPath + file}';\n`;
+      code += `${exportName}["${componentName.toLowerCase()}"] = ${componentName};\n`;
+    }
+  }
+  return code;
+}
+
 /**
  * Generate a virtual module that helps the AlekSIS-Core frontend code import other apps.
  *
@@ -69,6 +91,8 @@ function generateMessageImportCode(assetDir, name, importAppName) {
 function generateAppImporter(appDetails) {
   let code = "let appObjects = {};\n";
   code += "let appMessages = {};\n";
+  code += "let calendarFeedDetailComponents = {};\n";
+  code += "let calendarFeedEventBarComponents = {};\n";
 
   for (const [appPackage, appMeta] of Object.entries(appDetails)) {
     let indexPath = appMeta.assetDir + "/index.js";
@@ -86,13 +110,46 @@ function generateAppImporter(appDetails) {
         importAppName
       );
     }
+
+    // Include calendar feed detail components from all apps
+    code += generateComponentsImportCode(
+      appMeta.assetDir,
+      "/calendar_feeds/details/",
+      appMeta.name,
+      "calendarFeedDetailComponents"
+    );
+
+    // Include calendar feed event bar components from all apps
+    code += generateComponentsImportCode(
+      appMeta.assetDir,
+      "/calendar_feeds/event_bas/",
+      appMeta.name,
+      "calendarFeedEventBarComponents"
+    );
   }
 
   // Include core messages
   code += generateMessageImportCode(django_values.coreAssetDir, "core", "Core");
 
+  // Include core calendar feed detail components
+  code += generateComponentsImportCode(
+    django_values.coreAssetDir,
+    "/calendar_feeds/details/",
+    "core",
+    "calendarFeedDetailComponents"
+  );
+
+  // Include core calendar feed event bar components
+  code += generateComponentsImportCode(
+    django_values.coreAssetDir,
+    "/calendar_feeds/event_bar/",
+    "core",
+    "calendarFeedEventBarComponents"
+  );
+
   code += "export default appObjects;\n";
-  code += "export { appObjects, appMessages };\n";
+  code +=
+    "export { appObjects, appMessages, calendarFeedDetailComponents, calendarFeedEventBarComponents };\n";
 
   return code;
 }
diff --git a/docs/dev/01_setup.rst b/docs/dev/01_setup.rst
index 1b82cf5e35c78585ee01b101120ceaff8fa19305..f7f56f811bcd7b0bfca48fbf9793a026ff2a44bf 100644
--- a/docs/dev/01_setup.rst
+++ b/docs/dev/01_setup.rst
@@ -90,7 +90,7 @@ All three steps can be done with the ``poetry shell`` command and
 ``aleksis-admin``::
 
   ALEKSIS_maintenance__debug=true ALEKSIS_database__password=aleksis poetry shell
-   poetry run aleksis-admin yarn install
+   poetry run aleksis-admin vite build
    poetry run aleksis-admin collectstatic
    poetry run aleksis-admin compilemessages
    poetry run aleksis-admin migrate
diff --git a/pyproject.toml b/pyproject.toml
index eab2e8f450fd15f3312ac0305b658a813a65c549..1d4147cc2049f81bd4c83fbe8369ffac2002488d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -128,6 +128,10 @@ graphene-django = "^3.0.0"
 selenium = "^4.4.3"
 django-vite = "^2.0.2"
 graphene-django-cud = "^0.10.0"
+django-ical = "^1.8.3"
+django-recurrence = "^1.11.1"
+recurring-ical-events = "^2.0.2"
+django-timezone-field = "^5.0"
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]
@@ -146,5 +150,5 @@ line-length = 100
 exclude = "/migrations/"
 
 [build-system]
-requires = ["poetry>=1.0"]
-build-backend = "poetry.masonry.api"
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"