diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ac58628a8d6618ee244eb8aba0eed3aacfd36840..9872db38359a951199911ab881e4aa3305ffe4c7 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,8 @@ and this project adheres to `Semantic Versioning`_.
 Unreleased
 ----------
 
+Changes
+=======
 The "managed models" feature is mandatory for all models derived from `ExtensibleModel`
 and requires creating a migration for all downstream models to add the respective
 field.
@@ -17,6 +19,9 @@ Added
 ~~~~~
 
 * Frontend for managing rooms.
+* Global calendar system
+* Calendar for birthdays of persons
+* Holiday model to track information about holidays.
 * [Dev] Components for implementing standard CRUD operations in new frontend.
 * [Dev] Options for filtering and sorting of GraphQL queries at the server.
 * [Dev] Managed models for instances handled by other apps.
diff --git a/aleksis/core/frontend/app/dateTimeFormats.js b/aleksis/core/frontend/app/dateTimeFormats.js
index 567f9a196f61052ff0481c1f975a6c0d2433964e..2cea6431fa0714ebd4fd303adab620bcda94da92 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/calendar/BaseCalendarFeedDetails.vue b/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6b50ee5ba208b225709e6963ec679e826ceead82
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue
@@ -0,0 +1,94 @@
+<template>
+  <v-menu
+    v-model="model"
+    :close-on-content-click="false"
+    :activator="selectedElement"
+    :offset-x="calendarType !== 'day'"
+    min-width="350px"
+    :offset-y="calendarType === 'day'"
+  >
+    <v-card min-width="350px" flat>
+      <v-toolbar :color="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">
+          <cancelled-calendar-status-chip
+            v-if="selectedEvent.status === 'CANCELLED' && !withoutBadge"
+          />
+        </slot>
+      </v-toolbar>
+      <slot name="time" :selected-event="selectedEvent">
+        <v-list-item v-if="!withoutTime">
+          <v-list-item-icon>
+            <v-icon color="primary">mdi-calendar-today-outline</v-icon>
+          </v-list-item-icon>
+          <v-list-item-content>
+            <v-list-item-title>
+              <span
+                v-if="
+                  selectedEvent.allDay &&
+                  selectedEvent.start.getTime() === selectedEvent.end.getTime()
+                "
+              >
+                {{ $d(selectedEvent.start, "short") }}
+              </span>
+              <span v-else-if="selectedEvent.allDay">
+                {{ $d(selectedEvent.start, "short") }} –
+                {{ $d(selectedEvent.end, "short") }}
+              </span>
+              <span
+                v-else-if="
+                  dateWithoutTime(selectedEvent.start).getTime() ===
+                  dateWithoutTime(selectedEvent.end).getTime()
+                "
+              >
+                {{ $d(selectedEvent.start, "shortDateTime") }} –
+                {{ $d(selectedEvent.end, "shortTime") }}
+              </span>
+              <span v-else>
+                {{ $d(selectedEvent.start, "shortDateTime") }} –
+                {{ $d(selectedEvent.end, "shortDateTime") }}
+              </span>
+            </v-list-item-title>
+          </v-list-item-content>
+        </v-list-item>
+      </slot>
+      <slot name="description" :selected-event="selectedEvent">
+        <v-divider
+          inset
+          v-if="selectedEvent.description && !withoutDescription"
+        />
+        <v-list-item v-if="selectedEvent.description && !withoutDescription">
+          <v-list-item-icon>
+            <v-icon color="primary">mdi-card-text-outline</v-icon>
+          </v-list-item-icon>
+          <v-list-item-content style="white-space: pre-line">
+            {{ selectedEvent.description }}
+          </v-list-item-content>
+        </v-list-item>
+      </slot>
+    </v-card>
+  </v-menu>
+</template>
+
+<script>
+import calendarFeedDetailsMixin from "../../mixins/calendarFeedDetails.js";
+import CancelledCalendarStatusChip from "./CancelledCalendarStatusChip.vue";
+
+export default {
+  name: "BaseCalendarFeedDetails",
+  components: { CancelledCalendarStatusChip },
+  mixins: [calendarFeedDetailsMixin],
+  methods: {
+    dateWithoutTime(d) {
+      d = new Date(d);
+      d.setHours(0, 0, 0, 0);
+      return d;
+    },
+  },
+};
+</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..1e1d1436897ad17a1875f0bf8a9cf20d20033310
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue
@@ -0,0 +1,56 @@
+<template>
+  <div
+    class="text-truncate"
+    :class="{
+      'text-decoration-line-through': event.status === 'CANCELLED',
+      'mx-1': withPadding,
+    }"
+    :style="{ height: '100%' }"
+  >
+    <slot name="time" v-bind="$props">
+      <span
+        v-if="
+          calendarType === 'month' && eventParsed.start.hasTime && !withoutTime
+        "
+        class="mr-1 font-weight-bold ml-1"
+      >
+        {{ 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],
+  props: {
+    withPadding: {
+      required: false,
+      type: Boolean,
+      default: true,
+    },
+    icon: {
+      required: false,
+      type: String,
+      default: "",
+    },
+    withoutTime: {
+      required: false,
+      type: Boolean,
+      default: false,
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/calendar/CalendarControlBar.vue b/aleksis/core/frontend/components/calendar/CalendarControlBar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c3f9c45c424aa73c0178ea15882e4e6783a73e43
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/CalendarControlBar.vue
@@ -0,0 +1,21 @@
+<script>
+export default {
+  name: "CalendarControlBar",
+  emits: ["prev", "next", "today"],
+};
+</script>
+
+<template>
+  <div class="d-flex justify-center">
+    <v-btn icon class="mx-2" @click="$emit('prev')">
+      <v-icon>mdi-chevron-left</v-icon>
+    </v-btn>
+    <v-btn outlined text class="mx-2" @click="$emit('today')">
+      <v-icon left>mdi-calendar-today-outline</v-icon>
+      {{ $t("calendar.today") }}
+    </v-btn>
+    <v-btn icon class="mx-2" @click="$emit('next')">
+      <v-icon>mdi-chevron-right</v-icon>
+    </v-btn>
+  </div>
+</template>
diff --git a/aleksis/core/frontend/components/calendar/CalendarOverview.vue b/aleksis/core/frontend/components/calendar/CalendarOverview.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7153b8e1e8dce599fc00527d28bc327a713e4ed4
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/CalendarOverview.vue
@@ -0,0 +1,329 @@
+<template>
+  <div class="mt-4 mb-4">
+    <v-skeleton-loader
+      v-if="
+        $apollo.queries.calendar.loading && calendar.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">
+        <!-- Control bar with prev, next and today buttons -->
+        <v-col cols="12" sm="4" lg="3" xl="2" align-self="center">
+          <calendar-control-bar
+            @prev="$refs.calendar.prev()"
+            @next="$refs.calendar.next()"
+            @today="calendarFocus = new Date()"
+          />
+        </v-col>
+
+        <!-- Calendar title with current calendar time range -->
+        <v-col v-if="$vuetify.breakpoint.lgAndUp" align-self="center">
+          <h1 class="mx-2" v-if="$refs.calendar">{{ $refs.calendar.title }}</h1>
+        </v-col>
+
+        <!-- Button menu for selecting currently active calendars (only tablets/mobile) -->
+        <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="calendar.calendarFeeds"
+              @input="storeActivatedCalendars"
+            />
+          </button-menu>
+        </v-col>
+
+        <v-spacer v-if="$vuetify.breakpoint.lgAndUp" />
+
+        <!-- Calendar type select (month, week, day) -->
+        <v-col
+          cols="12"
+          sm="4"
+          lg="3"
+          align-self="center"
+          :align="$vuetify.breakpoint.smAndUp ? 'right' : 'center'"
+        >
+          <calendar-type-select v-model="currentCalendarType" />
+        </v-col>
+      </v-row>
+      <v-row>
+        <v-col v-if="$vuetify.breakpoint.lgAndUp" lg="3" xl="2">
+          <!-- Mini date picker -->
+          <v-date-picker
+            no-title
+            v-model="calendarFocus"
+            :style="{ margin: '0px -8px' }"
+            :first-day-of-week="1"
+          ></v-date-picker>
+
+          <!-- Calendar select (only desktop) -->
+          <v-list flat subheader>
+            <v-subheader>
+              {{ $t("calendar.my_calendars") }}
+            </v-subheader>
+            <calendar-select
+              class="mb-4"
+              v-model="selectedCalendarFeedNames"
+              :calendar-feeds="calendar.calendarFeeds"
+              @input="storeActivatedCalendars"
+            />
+            <v-btn depressed block v-if="calendar" :href="calendar.allFeedsUrl">
+              <v-icon left>mdi-download-outline</v-icon>
+              {{ $t("calendar.download_all") }}
+            </v-btn>
+          </v-list>
+        </v-col>
+
+        <!-- Actual calendar -->
+        <v-col lg="9" xl="10">
+          <v-sheet height="600">
+            <v-expand-transition>
+              <v-progress-linear
+                v-if="$apollo.queries.calendar.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="calendar && calendar.calendarFeeds && selectedEvent"
+              :is="detailComponentForFeed(selectedEvent.calendarFeedName)"
+              v-model="selectedOpen"
+              :selected-element="selectedElement"
+              :selected-event="selectedEvent"
+              :calendar-type="currentCalendarType"
+            />
+          </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";
+import gqlSetCalendarStatus from "./setCalendarStatus.graphql";
+import CalendarControlBar from "./CalendarControlBar.vue";
+import CalendarTypeSelect from "./CalendarTypeSelect.vue";
+
+export default {
+  name: "CalendarOverview",
+  components: {
+    CalendarTypeSelect,
+    CalendarControlBar,
+    ButtonMenu,
+    CalendarSelect,
+  },
+  data() {
+    return {
+      calendarFocus: "",
+      calendar: {
+        calendarFeeds: [],
+      },
+      selectedCalendarFeedNames: [],
+      currentCalendarType: "week",
+      selectedEvent: {},
+      selectedElement: null,
+      selectedOpen: false,
+      fetchedDateRange: { start: null, end: null },
+    };
+  },
+  apollo: {
+    calendar: {
+      query: gqlCalendarOverview,
+      skip: true,
+      result({ data }) {
+        this.selectedCalendarFeedNames = data.calendar.calendarFeeds
+          .filter((c) => c.activated)
+          .map((c) => c.name);
+      },
+    },
+  },
+  computed: {
+    events() {
+      return this.calendar.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,
+            meta: JSON.parse(event.meta),
+          }))
+        );
+    },
+  },
+  methods: {
+    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.calendar.calendarFeeds &&
+        feedName &&
+        Object.keys(calendarFeedDetailComponents).includes(feedName + "details")
+      ) {
+        return calendarFeedDetailComponents[feedName + "details"];
+      }
+      return GenericCalendarFeedDetails;
+    },
+    eventBarComponentForFeed(feedName) {
+      if (
+        this.calendar.calendarFeeds &&
+        feedName &&
+        Object.keys(calendarFeedEventBarComponents).includes(
+          feedName + "eventbar"
+        )
+      ) {
+        return calendarFeedEventBarComponents[feedName + "eventbar"];
+      }
+      return GenericCalendarFeedEventBar;
+    },
+    getColorForEvent(event) {
+      return event.color;
+    },
+    storeActivatedCalendars() {
+      // Store currently activated calendars in the backend
+      this.$apollo.mutate({
+        mutation: gqlSetCalendarStatus,
+        variables: {
+          calendars: this.selectedCalendarFeedNames,
+        },
+      });
+    },
+    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.calendar.calendarFeeds.length === 0) {
+        // No calendar feeds have been fetched yet,
+        // so fetch all events in the current date range
+
+        this.$apollo.queries.calendar.setVariables({
+          start: extendedStart,
+          end: extendedEnd,
+        });
+        this.$apollo.queries.calendar.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.calendar.fetchMore({
+          variables: {
+            start: fetchStart,
+            end: fetchEnd,
+          },
+          updateQuery: (previousResult, { fetchMoreResult }) => {
+            let previousCalendarFeeds = previousResult.calendar.calendarFeeds;
+            let newCalendarFeeds = fetchMoreResult.calendar.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 {
+              calendar: {
+                ...previousResult.calendar,
+                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..5748b1d5755b961ca901f075b4aac152f3874634
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/CalendarSelect.vue
@@ -0,0 +1,85 @@
+<template>
+  <v-list-item-group multiple v-model="model">
+    <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"
+            :color="calendarFeed.color"
+          ></v-checkbox>
+        </v-list-item-action>
+
+        <v-list-item-content>
+          <v-list-item-title>
+            {{ calendarFeed.verboseName }}
+          </v-list-item-title>
+        </v-list-item-content>
+
+        <v-list-item-action>
+          <v-menu bottom>
+            <template v-slot:activator="{ on, attrs }">
+              <v-btn fab x-small icon v-bind="attrs" v-on="on">
+                <v-icon>mdi-dots-vertical</v-icon>
+              </v-btn>
+            </template>
+            <v-list dense>
+              <v-list-item :href="calendarFeed.url">
+                <v-list-item-icon>
+                  <v-icon>mdi-calendar-export</v-icon>
+                </v-list-item-icon>
+                <v-list-item-title>
+                  {{ $t("calendar.download_ics") }}
+                </v-list-item-title>
+              </v-list-item>
+            </v-list>
+          </v-menu>
+        </v-list-item-action>
+      </template>
+    </v-list-item>
+  </v-list-item-group>
+</template>
+
+<script>
+export default {
+  name: "CalendarSelect",
+  props: {
+    calendarFeeds: {
+      type: Array,
+      required: true,
+    },
+    value: {
+      type: Array,
+      required: true,
+    },
+  },
+  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/CalendarStatusChip.vue b/aleksis/core/frontend/components/calendar/CalendarStatusChip.vue
new file mode 100644
index 0000000000000000000000000000000000000000..679749135dd12e302f18a797af4d8fb169a44470
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/CalendarStatusChip.vue
@@ -0,0 +1,24 @@
+<script>
+export default {
+  name: "CalendarStatusChip",
+  props: {
+    color: {
+      type: String,
+      required: true,
+    },
+    icon: {
+      type: String,
+      required: true,
+    },
+  },
+};
+</script>
+
+<template>
+  <v-chip :color="color" label>
+    <v-avatar left>
+      <v-icon>{{ icon }}</v-icon>
+    </v-avatar>
+    <slot></slot>
+  </v-chip>
+</template>
diff --git a/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue b/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1868b314a1af1106fe30b54571f629f2a577995f
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue
@@ -0,0 +1,61 @@
+<script>
+export default {
+  name: "CalendarTypeSelect",
+  props: {
+    value: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      innerValue: this.value,
+      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",
+        },
+      ],
+    };
+  },
+  watch: {
+    value(val) {
+      this.innerValue = val;
+    },
+    innerValue(val) {
+      this.$emit("input", val);
+    },
+  },
+  methods: {
+    nameForMenu(item) {
+      return this.$t(item.translationKey);
+    },
+  },
+};
+</script>
+
+<template>
+  <v-btn-toggle dense v-model="innerValue" class="mx-2">
+    <v-btn
+      v-for="calendarType in availableCalendarTypes"
+      :value="calendarType.type"
+      :key="calendarType.type"
+    >
+      <v-icon v-if="$vuetify.breakpoint.smAndDown">{{
+        "mdi-" + calendarType.icon
+      }}</v-icon>
+      <span class="hidden-sm-and-down">{{ nameForMenu(calendarType) }}</span>
+    </v-btn>
+  </v-btn-toggle>
+</template>
diff --git a/aleksis/core/frontend/components/calendar/CancelledCalendarStatusChip.vue b/aleksis/core/frontend/components/calendar/CancelledCalendarStatusChip.vue
new file mode 100644
index 0000000000000000000000000000000000000000..00b816943654453690f32da1db95e14f57b7dce7
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/CancelledCalendarStatusChip.vue
@@ -0,0 +1,14 @@
+<script>
+import CalendarStatusChip from "./CalendarStatusChip.vue";
+
+export default {
+  name: "CancelledCalendarStatusChip",
+  components: { CalendarStatusChip },
+};
+</script>
+
+<template>
+  <calendar-status-chip icon="mdi-cancel" color="error">
+    {{ $t("calendar.cancelled") }}
+  </calendar-status-chip>
+</template>
diff --git a/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue b/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2b6f8fa1e5833260196f97a4e939187f87c80c3f
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue
@@ -0,0 +1,25 @@
+<template>
+  <div>
+    <base-calendar-feed-details v-bind="$props" />
+    <v-divider inset v-if="selectedEvent.location" />
+    <v-list-item v-if="selectedEvent.location">
+      <v-list-item-icon>
+        <v-icon color="primary">mdi-map-marker-outline</v-icon>
+      </v-list-item-icon>
+      <v-list-item-content>
+        {{ selectedEvent.location }}
+      </v-list-item-content>
+    </v-list-item>
+  </div>
+</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..f277ef4bbffcf9cf68532a5e1ba9ce3ecd2c813b
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/calendarOverview.graphql
@@ -0,0 +1,27 @@
+query ($start: Date, $end: Date) {
+  calendar {
+    allFeedsUrl
+    calendarFeeds {
+      name
+      verboseName
+      description
+      url
+      color
+      activated
+      feed {
+        events(start: $start, end: $end) {
+          name
+          start
+          end
+          color
+          description
+          location
+          uid
+          allDay
+          status
+          meta
+        }
+      }
+    }
+  }
+}
diff --git a/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql b/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..633f0791c3e00f0c82886779366704086871c484
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql
@@ -0,0 +1,5 @@
+mutation ($calendars: [String]!) {
+  setCalendarStatus(calendars: $calendars) {
+    ok
+  }
+}
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/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/messages/en.json b/aleksis/core/frontend/messages/en.json
index ceda00c3994bccf4fc2c7b5e9cb83fe5ec2d00d9..a477736639405a1005f8799ce09b6c88f45a95de 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",
@@ -252,6 +256,19 @@
     "new_version_available": "A new version of the app is available",
     "update": "Update"
   },
+  "calendar": {
+    "menu_title": "Calendar",
+    "month": "Month",
+    "week": "Week",
+    "day": "Day",
+    "today": "Today",
+    "select": "Select calendars",
+    "ics_to_clipboard": "Copy link to calendar ICS to clipboard",
+    "cancelled": "Cancelled",
+    "download_ics": "Download ICS",
+    "my_calendars": "My Calendars",
+    "download_all": "Download all"
+  },
   "graphql": {
     "snackbar_error_message": "There was an error retrieving the page data. Please try again.",
     "snackbar_success_message": "The operation has been finished successfully."
diff --git a/aleksis/core/frontend/mixins/calendarFeedDetails.js b/aleksis/core/frontend/mixins/calendarFeedDetails.js
new file mode 100644
index 0000000000000000000000000000000000000000..0f729ba18355f7742d9080ea0188e8f3ba73e193
--- /dev/null
+++ b/aleksis/core/frontend/mixins/calendarFeedDetails.js
@@ -0,0 +1,52 @@
+/**
+ * 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: false,
+      type: Boolean,
+      default: false,
+    },
+    withoutDescription: {
+      required: false,
+      type: Boolean,
+      default: false,
+    },
+    withoutBadge: {
+      required: false,
+      type: Boolean,
+      default: false,
+    },
+    color: {
+      required: false,
+      type: String,
+      default: null,
+    },
+    calendarType: {
+      required: true,
+      type: String,
+    },
+  },
+  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..3b6590b0f68e75090b200bd0aa0fcc747cfdef51
--- /dev/null
+++ b/aleksis/core/frontend/mixins/calendarFeedEventBar.js
@@ -0,0 +1,21 @@
+/**
+ * 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,
+    },
+  },
+};
+
+export default calendarFeedEventBarMixin;
diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js
index 84b3612f33510940b97dd57fec5370e49c768d4c..b14cb40e77c84230a1555b296eda94d245df9cfb 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 = [
   {
@@ -1049,6 +1049,18 @@ const routes = [
     },
     name: "invitations.accept_invite",
   },
+  {
+    path: "/calendar/",
+    component: () => import("./components/calendar/CalendarOverview.vue"),
+    name: "core.calendar_overview",
+    meta: {
+      inMenu: true,
+      icon: "mdi-calendar",
+      titleKey: "calendar.menu_title",
+      toolbarTitle: "calendar.menu_title",
+      permission: "core.view_calendar_feed_rule",
+    },
+  },
 ];
 
 // This imports all known AlekSIS app entrypoints
diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py
index c79e756f509ea428359c95617e3e84d573985cf2..07237fdac02af7750a2000f89167cb9bbc217a5c 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 AlekSISBaseManager(_CurrentSiteManager):
@@ -141,3 +141,14 @@ class InstalledWidgetsDashboardWidgetOrderManager(Manager):
 
 class PolymorphicCurrentSiteManager(AlekSISBaseManager, 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/0051_calendarevent_and_holiday.py b/aleksis/core/migrations/0051_calendarevent_and_holiday.py
new file mode 100644
index 0000000000000000000000000000000000000000..28e5294027d766f64d6cb644696e84f7a9c2a6ac
--- /dev/null
+++ b/aleksis/core/migrations/0051_calendarevent_and_holiday.py
@@ -0,0 +1,164 @@
+# Generated by Django 4.1.10 on 2023-07-11 19:01
+
+import aleksis.core.managers
+import aleksis.core.mixins
+from django.db import migrations, models
+import django.db.models.deletion
+import recurrence.fields
+import timezone_field.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("contenttypes", "0002_remove_content_type_name"),
+        ("sites", "0002_alter_domain_unique"),
+        ("core", "0050_managed_by_app_label"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="CalendarEvent",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+                    ),
+                ),
+                (
+                    "managed_by_app_label",
+                    models.CharField(
+                        blank=True,
+                        editable=False,
+                        max_length=255,
+                        verbose_name="App label of app responsible for managing this instance",
+                    ),
+                ),
+                ("extended_data", models.JSONField(default=dict, editable=False)),
+                (
+                    "datetime_start",
+                    models.DateTimeField(blank=True, null=True, verbose_name="Start date and time"),
+                ),
+                (
+                    "datetime_end",
+                    models.DateTimeField(blank=True, null=True, verbose_name="End date and time"),
+                ),
+                (
+                    "timezone",
+                    timezone_field.fields.TimeZoneField(
+                        blank=True, null=True, verbose_name="Timezone"
+                    ),
+                ),
+                ("date_start", models.DateField(blank=True, null=True, verbose_name="Start date")),
+                ("date_end", models.DateField(blank=True, null=True, verbose_name="End date")),
+                (
+                    "recurrences",
+                    recurrence.fields.RecurrenceField(
+                        blank=True, null=True, verbose_name="Recurrences"
+                    ),
+                ),
+                (
+                    "amends",
+                    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",
+                    ),
+                ),
+                (
+                    "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,
+                        related_name="+",
+                        to="sites.site",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Calendar Event",
+                "verbose_name_plural": "Calendar Events",
+                "ordering": ["datetime_start", "date_start", "datetime_end", "date_end"],
+            },
+            bases=(aleksis.core.mixins.CalendarEventMixin, models.Model),
+            managers=[
+                ("objects", aleksis.core.managers.PolymorphicCurrentSiteManager()),
+            ],
+        ),
+        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",),
+            managers=[
+                ("objects", aleksis.core.managers.PolymorphicCurrentSiteManager()),
+            ],
+        ),
+        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",
+            ),
+        ),
+        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 dd43fbb0f5ddc1e324f56bf1271f023f630b690d..c6c19af0a5f58a105377bb2a36502b562056739d 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, fields_for_model
-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 Fieldset, Layout, LayoutNode
 from polymorphic.base import PolymorphicModelBase
@@ -40,6 +43,8 @@ from aleksis.core.managers import (
     SchoolTermRelatedQuerySet,
 )
 
+from .util.core_helpers import ExtendedICal20Feed
+
 
 class _ExtensibleModelBase(models.base.ModelBase):
     """Ensure predefined behaviour on model creation.
@@ -571,7 +576,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):
@@ -583,23 +588,178 @@ 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):
+    def get_object_by_name(cls, name: str) -> Optional[type["RegistryObject"]]:
+        """Get registered object by name."""
         return cls.registered_objects_dict.get(name)
 
 
 class ObjectAuthenticator(RegistryObject):
     def authenticate(self, request, obj):
         raise NotImplementedError()
+
+
+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_feed(cls):
+        """Return if the feed is valid."""
+        return cls.name != cls.__name__
+
+    @classproperty
+    def valid_feeds(cls):
+        """Return a list of valid feeds."""
+        return [feed for feed in cls.registered_objects_list if feed.valid_feed]
+
+    @classproperty
+    def valid_feed_names(cls):
+        """Return a list of valid feed names."""
+        return [feed.name for feed in cls.valid_feeds]
+
+    def get_object_by_name(cls, name):
+        return cls.registered_objects_dict.get(name)
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index d90790427fb3c22531ec0cda50e9c48eb615e196..4151d51b39d4cafac8ba91250a5ed64b8d4af50e 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,270 @@ 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, None]:
+        """Return the end datetime of the calendar event."""
+        if reference_object.datetime_end:
+            return reference_object.datetime_end.astimezone(reference_object.timezone)
+        if reference_object.date_end == reference_object.date_start:
+            # Rule for all day events: If the event is only one day long,
+            # the end date has to be empty
+            return None
+        return reference_object.date_end
+
+    @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..a3be1ac89770a89c2d3ea82b64aee7adc3913c9f 100644
--- a/aleksis/core/preferences.py
+++ b/aleksis/core/preferences.py
@@ -16,7 +16,7 @@ from dynamic_preferences.types import (
 )
 from oauth2_provider.models import AbstractApplication
 
-from .mixins import PublicFilePreferenceMixin
+from .mixins import CalendarEventMixin, PublicFilePreferenceMixin
 from .models import Group, Person
 from .registries import person_preferences_registry, site_preferences_registry
 from .util.notifications import get_notification_choices_lazy
@@ -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,42 @@ 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
+
+
+@person_preferences_registry.register
+class ActivatedCalendars(MultipleChoicePreference):
+    """Calendars that are activated for a person."""
+
+    section = calendar
+    name = "activated_calendars"
+    default = []
+    widget = SelectMultiple
+    verbose_name = _("Activated calendars")
+    required = False
+
+    field_attribute = {"initial": []}
+    choices = [(feed.name, feed.verbose_name) for feed in CalendarEventMixin.valid_feeds]
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 ae137be9da07fb643e17e00fd2111892802cd24b..604e0d6cbb4b1f5c08c244c42ddc42b3e4084bbb 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -23,6 +23,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 .base import FilterOrderList
+from .calendar import CalendarBaseType, SetCalendarStatusMutation
 from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType
 from .custom_menu import CustomMenuType
 from .dynamic_routes import DynamicRouteType
@@ -93,6 +94,8 @@ class Query(graphene.ObjectType):
 
     school_terms = FilterOrderList(SchoolTermType)
 
+    calendar = graphene.Field(CalendarBaseType)
+
     def resolve_ping(root, info, payload) -> str:
         return payload
 
@@ -209,6 +212,10 @@ class Query(graphene.ObjectType):
 
         return room_object
 
+    @staticmethod
+    def resolve_calendar(root, info, **kwargs):
+        return True
+
 
 class Mutation(graphene.ObjectType):
     update_person = PersonMutation.Field()
@@ -229,6 +236,8 @@ class Mutation(graphene.ObjectType):
     delete_school_terms = SchoolTermBatchDeleteMutation.Field()
     update_school_terms = SchoolTermBatchPatchMutation.Field()
 
+    set_calendar_status = SetCalendarStatusMutation.Field()
+
 
 def build_global_schema():
     """Build global GraphQL schema from all apps."""
diff --git a/aleksis/core/schema/calendar.py b/aleksis/core/schema/calendar.py
new file mode 100644
index 0000000000000000000000000000000000000000..110e2cf826f26314b278bf293724712473a61ba8
--- /dev/null
+++ b/aleksis/core/schema/calendar.py
@@ -0,0 +1,127 @@
+from datetime import datetime
+
+from django.core.exceptions import PermissionDenied
+from django.urls import reverse
+
+import graphene
+from graphene import ObjectType
+
+from aleksis.core.mixins import CalendarEventMixin
+from aleksis.core.util.core_helpers import has_person
+
+
+class CalendarEventType(ObjectType):
+    name = graphene.String()
+    description = graphene.String()
+    location = graphene.String(required=False)
+    start = graphene.String()
+    end = graphene.String()
+    color = graphene.String()
+    uid = graphene.String()
+    all_day = graphene.Boolean()
+    status = graphene.String()
+    meta = graphene.String()
+
+    def resolve_name(root, info, **kwargs):
+        return root["SUMMARY"]
+
+    def resolve_description(root, info, **kwargs):
+        return root["DESCRIPTION"]
+
+    def resolve_location(root, info, **kwargs):
+        return root.get("LOCATION", "")
+
+    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", "")
+
+    def resolve_meta(root, info, **kwargs):
+        return root.get("X-META", "{}")
+
+
+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()
+
+    activated = graphene.Boolean()
+
+    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)
+
+    def resolve_activated(root, info, **kwargs):
+        return root.name in info.context.user.person.preferences["calendar__activated_calendars"]
+
+
+class SetCalendarStatusMutation(graphene.Mutation):
+    """Mutation to change the status of a calendar."""
+
+    class Arguments:
+        calendars = graphene.List(graphene.String)
+
+    ok = graphene.Boolean()
+
+    def mutate(root, info, calendars, **kwargs):
+        if not has_person(info.context):
+            raise PermissionDenied
+        calendar_feeds = [cal for cal in calendars if cal in CalendarEventMixin.valid_feed_names]
+        info.context.user.person.preferences["calendar__activated_calendars"] = calendar_feeds
+        return SetCalendarStatusMutation(ok=True)
+
+
+class CalendarBaseType(ObjectType):
+    calendar_feeds = graphene.List(CalendarType)
+
+    calendar_feeds_by_names = graphene.List(CalendarType, names=graphene.List(graphene.String))
+
+    all_feeds_url = graphene.String()
+
+    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]
+
+    def resolve_all_feeds_url(root, info, **kwargs):
+        return info.context.build_absolute_uri(reverse("all_calendar_feeds"))
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 567fada118b295e763f1d202f23009626216a880..8307ae9c16e65073b2fcc890d7aeeac4cb9bf408 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)
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 5f1ce87cc3b51e462b61f0f109ce03c913c9c851..339a4bc2fee1c0546e62ca452793dfc7682e3aea 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -401,6 +401,8 @@ urlpatterns = [
                     views.AssignPermissionView.as_view(),
                     name="assign_permission",
                 ),
+                path("feeds/<str:name>.ics", views.ICalFeedView.as_view(), name="calendar_feed"),
+                path("feeds.ics", views.ICalAllFeedsView.as_view(), name="all_calendar_feeds"),
             ]
         ),
     ),
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index c7f89fb5badfbca1e5014b78a150129afd990669..9329dda6192b554efa5e546a0160b5225e09da17 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -1,3 +1,4 @@
+import json
 import os
 from datetime import datetime, timedelta
 from importlib import import_module, metadata
@@ -19,9 +20,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 +493,69 @@ 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"),
+    ("meta", "x-meta"),
+)
+
+
+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, with_meta=True):
+        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, with_meta=with_meta)
+
+        return cal
+
+    def write(self, outfile, encoding):
+        cal = self.get_calendar_object(with_meta=False)
+
+        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, with_meta=True):
+        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)
+                    elif ifield == "meta":
+                        if with_meta:
+                            element.add(efield, json.dumps(val))
+                    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 abe9ebbb9e98071c28069308e87f401b99d80574..f9e21e5b57472664e96bded0ebb471a11aff014b 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -100,6 +100,7 @@ from .mixins import (
     AdvancedCreateView,
     AdvancedDeleteView,
     AdvancedEditView,
+    CalendarEventMixin,
     ObjectAuthenticator,
     SuccessNextMixin,
 )
@@ -1626,3 +1627,31 @@ class ObjectRepresentationView(View):
                 return True
 
         raise PermissionDenied()
+
+
+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
+
+
+class ICalAllFeedsView(PermissionRequiredMixin, View):
+    """View to generate an iCal feed for all calendars."""
+
+    permission_required = "core.view_calendar_feed_rule"
+
+    def get(self, request, *args, **kwargs):
+        response = HttpResponse(content_type="text/calendar")
+        for calendar in CalendarEventMixin.valid_feeds:
+            feed = calendar.create_feed(request)
+            feed.write(response, "utf-8")
+        return response
diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js
index ec24281c7b1b8576bfae100c1128cea8a0d6e9b4..772fa5f27eccf8f93f1051cd51f8aaeb6e60535c 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_bar/",
+      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/pyproject.toml b/pyproject.toml
index a591d8b60936316f005f14b1db47f185322e4cb9..73fd5c29b41fcb4d64940a4c4f43a296e807e4e0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -129,6 +129,10 @@ selenium = "^4.4.3"
 django-vite = "^2.0.2"
 graphene-django-cud = "^0.11.0"
 uwsgi = "^2.0.21"
+django-ical = "^1.9.2"
+django-recurrence = "^1.11.1"
+recurring-ical-events = "^2.0.2"
+django-timezone-field = "^5.0"
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]