From 98f4081ee9ed0346a574a34bdf7b5b8b90533afc Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Fri, 4 Aug 2023 18:32:13 +0200
Subject: [PATCH] Refactor calendar overview in more different components

---
 .../frontend/components/calendar/Calendar.vue | 291 +++++++++++++
 .../calendar/CalendarDownloadAllButton.vue    |  17 +
 .../components/calendar/CalendarOverview.vue  | 402 +++++-------------
 .../calendar/CalendarWithControls.vue         |  48 +++
 .../components/calendar/calendar.graphql      |  24 ++
 .../components/calendar/calendarFeeds.graphql |  13 +
 .../components/calendar/calendarMixin.js      |  30 ++
 .../calendar/calendarOverview.graphql         |  27 --
 .../calendar/calendarSelectedFeedsMixin.js    |  47 ++
 aleksis/core/mixins.py                        |  31 +-
 aleksis/core/models.py                        |   4 +-
 aleksis/core/schema/calendar.py               |  37 +-
 12 files changed, 606 insertions(+), 365 deletions(-)
 create mode 100644 aleksis/core/frontend/components/calendar/Calendar.vue
 create mode 100644 aleksis/core/frontend/components/calendar/CalendarDownloadAllButton.vue
 create mode 100644 aleksis/core/frontend/components/calendar/CalendarWithControls.vue
 create mode 100644 aleksis/core/frontend/components/calendar/calendar.graphql
 create mode 100644 aleksis/core/frontend/components/calendar/calendarFeeds.graphql
 create mode 100644 aleksis/core/frontend/components/calendar/calendarMixin.js
 delete mode 100644 aleksis/core/frontend/components/calendar/calendarOverview.graphql
 create mode 100644 aleksis/core/frontend/components/calendar/calendarSelectedFeedsMixin.js

diff --git a/aleksis/core/frontend/components/calendar/Calendar.vue b/aleksis/core/frontend/components/calendar/Calendar.vue
new file mode 100644
index 000000000..d2f5875c4
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/Calendar.vue
@@ -0,0 +1,291 @@
+<template>
+  <div>
+    <!-- Calendar title with current calendar time range -->
+    <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="internalCalendarFocus"
+        show-week
+        :events="events"
+        :type="internalCalendarType"
+        :event_color="getColorForEvent"
+        @click:date="viewDay"
+        @click:day="viewDay"
+        @click:more="viewDay"
+        @click:event="viewEvent"
+        @change="setCalendarRange"
+      >
+        <template #event="{ event, eventParsed, timed }">
+          <component
+            :is="eventBarComponentForFeed(event.calendarFeedName)"
+            :event="event"
+            :event-parsed="eventParsed"
+            :calendar-type="internalCalendarType"
+          />
+        </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="internalCalendarType"
+      />
+    </v-sheet>
+  </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 gqlCalendar from "./calendar.graphql";
+import CalendarControlBar from "./CalendarControlBar.vue";
+import CalendarTypeSelect from "./CalendarTypeSelect.vue";
+
+export default {
+  name: "Calendar",
+  components: {
+    CalendarTypeSelect,
+    CalendarControlBar,
+    ButtonMenu,
+    CalendarSelect,
+  },
+  props: {
+    calendarFeeds: {
+      type: Array,
+      required: false,
+      default: () => [],
+    },
+    params: {
+      type: Object,
+      required: false,
+      default: null,
+    },
+  },
+  data() {
+    return {
+      internalCalendarFocus: "",
+      internalCalendarType: "week",
+
+      calendar: {
+        calendarFeeds: [],
+      },
+
+      selectedEvent: {},
+      selectedElement: null,
+      selectedOpen: false,
+
+      fetchedDateRange: { start: null, end: null },
+
+      title: "",
+
+      range: {
+        start: null,
+        end: null,
+      },
+    };
+  },
+  emits: ["changeCalendarType", "changeCalendarFocus", "selectEvent"],
+  apollo: {
+    calendar: {
+      query: gqlCalendar,
+      skip: true,
+    },
+  },
+  computed: {
+    events() {
+      return this.calendar.calendarFeeds.flatMap((cf) =>
+        cf.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),
+        }))
+      );
+    },
+    paramsForSend() {
+      if (this.params !== null) {
+        return JSON.stringify(this.params);
+      }
+      return null;
+    },
+    queryVariables() {
+      let extendedStart = this.$refs.calendar.getStartOfWeek(
+        this.range.start
+      ).date;
+      let extendedEnd = this.$refs.calendar.getEndOfWeek(this.range.end).date;
+      return {
+        start: extendedStart,
+        end: extendedEnd,
+        names: this.calendarFeeds.map((f) => f.name),
+        params: this.paramsForSend,
+      };
+    },
+  },
+  watch: {
+    params(newParams) {
+      if (this.range.start && this.range.end) {
+        this.$apollo.queries.calendar.refetch(this.queryVariables);
+      }
+    },
+    range: {
+      handler() {
+        this.fetchMoreCalendarEvents();
+      },
+      deep: true,
+    },
+    internalCalendarType(val) {
+      this.$emit("changeCalendarType", val);
+    },
+    internalCalendarFocus(val) {
+      this.$emit("changeCalendarFocus", val);
+    },
+    selectedEvent(val) {
+      this.$emit("selectEvent", val);
+    },
+  },
+  methods: {
+    prev() {
+      this.$refs.calendar.prev();
+    },
+    next() {
+      this.$refs.calendar.next();
+    },
+    setCalendarFocus(val) {
+      this.internalCalendarFocus = val;
+    },
+    setCalendarType(val) {
+      this.internalCalendarType = val;
+    },
+    viewDay({ date }) {
+      this.internalCalendarFocus = date;
+      this.internalCalendarType = "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;
+    },
+    setCalendarRange({ start, end }) {
+      this.range.start = start;
+      this.range.end = end;
+    },
+    fetchMoreCalendarEvents() {
+      this.title = this.$refs.calendar.title;
+
+      // Get the start and end dates of the current date range shown in the calendar
+      let extendedStart,
+        extendedEnd = (this.queryVariables.start, this.queryVariables.end);
+
+      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(this.queryVariables);
+        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: this.queryVariables,
+          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.events.filter(
+                (event) => event.end < fetchStart || event.start > fetchEnd
+              );
+
+              /// Update the events of the calendar feed
+              calendarFeeds[i].events = [
+                ...keepEvents,
+                ...newCalendarFeeds[i].events,
+              ];
+            });
+            return {
+              calendar: {
+                ...previousResult.calendar,
+                calendarFeeds: previousCalendarFeeds,
+              },
+            };
+          },
+        });
+
+        this.fetchedDateRange = { start: newStart, end: newEnd };
+      }
+    },
+  },
+  mounted() {
+    this.$refs.calendar.move(0);
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/calendar/CalendarDownloadAllButton.vue b/aleksis/core/frontend/components/calendar/CalendarDownloadAllButton.vue
new file mode 100644
index 000000000..08b47421a
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/CalendarDownloadAllButton.vue
@@ -0,0 +1,17 @@
+<script>
+export default {
+  name: "CalendarDownloadAllButton",
+  props: {
+    url: {
+      type: String,
+      required: true,
+    },
+  },
+};
+</script>
+<template>
+  <v-btn depressed block :href="url">
+    <v-icon left>mdi-download-outline</v-icon>
+    {{ $t("calendar.download_all") }}
+  </v-btn>
+</template>
diff --git a/aleksis/core/frontend/components/calendar/CalendarOverview.vue b/aleksis/core/frontend/components/calendar/CalendarOverview.vue
index 7153b8e1e..7d602c01b 100644
--- a/aleksis/core/frontend/components/calendar/CalendarOverview.vue
+++ b/aleksis/core/frontend/components/calendar/CalendarOverview.vue
@@ -1,329 +1,121 @@
 <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"
+    <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 = ''"
+        />
+      </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"
       >
-        {{ $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'"
+        <button-menu
+          icon="mdi-calendar-check-outline"
+          text-translation-key="calendar.select"
         >
-          <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>
+          <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="calendarType" />
+      </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"
+          />
+          <calendar-download-all-button
+            v-if="calendar?.allFeedsUrl"
+            :url="calendar.allFeedsUrl"
+          />
+        </v-list>
+      </v-col>
+
+      <!-- Actual calendar -->
+      <v-col lg="9" xl="10">
+        <calendar
+          :calendar-feeds="selectedFeedsForCalendar"
+          @changeCalendarFocus="setCalendarFocus"
+          @changeCalendarType="setCalendarType"
+          ref="calendar"
+        />
+      </v-col>
+    </v-row>
   </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";
+import Calendar from "./Calendar.vue";
+import CalendarDownloadAllButton from "./CalendarDownloadAllButton.vue";
+import calendarMixin from "./calendarMixin";
+import calendarSelectedFeedsMixin from "./calendarSelectedFeedsMixin";
 
 export default {
   name: "CalendarOverview",
+  mixins: [calendarMixin, calendarSelectedFeedsMixin],
   components: {
+    Calendar,
     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);
+    CalendarDownloadAllButton,
   },
 };
 </script>
-
-<style scoped></style>
diff --git a/aleksis/core/frontend/components/calendar/CalendarWithControls.vue b/aleksis/core/frontend/components/calendar/CalendarWithControls.vue
new file mode 100644
index 000000000..304904f3d
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/CalendarWithControls.vue
@@ -0,0 +1,48 @@
+<template>
+  <div>
+    <div class="d-flex mb-3 justify-space-between">
+      <!-- Control bar with prev, next and today buttons -->
+      <calendar-control-bar
+        @prev="$refs.calendar.prev()"
+        @next="$refs.calendar.next()"
+        @today="calendarFocus = ''"
+      />
+
+      <!-- Calendar title with current calendar time range -->
+      <div class="mx-2" v-if="$refs.calendar">{{ $refs.calendar.title }}</div>
+
+      <calendar-type-select v-model="calendarType" />
+    </div>
+
+    <!-- Actual calendar -->
+    <calendar
+      :calendar-feeds="calendarFeeds"
+      @changeCalendarFocus="setCalendarFocus"
+      @changeCalendarType="setCalendarType"
+      v-bind="$attrs"
+      ref="calendar"
+    />
+  </div>
+</template>
+
+<script>
+import CalendarControlBar from "./CalendarControlBar.vue";
+import CalendarTypeSelect from "./CalendarTypeSelect.vue";
+import Calendar from "./Calendar.vue";
+import calendarMixin from "./calendarMixin";
+export default {
+  name: "CalendarWithControls",
+  components: {
+    Calendar,
+    CalendarTypeSelect,
+    CalendarControlBar,
+  },
+  mixins: [calendarMixin],
+  props: {
+    calendarFeeds: {
+      type: Array,
+      required: true,
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/calendar/calendar.graphql b/aleksis/core/frontend/components/calendar/calendar.graphql
new file mode 100644
index 000000000..5f2efd518
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/calendar.graphql
@@ -0,0 +1,24 @@
+query ($names: [String], $start: Date, $end: Date, $params: String) {
+  calendar {
+    calendarFeeds(names: $names) {
+      name
+      verboseName
+      description
+      url
+      color
+      activated
+      events(start: $start, end: $end, params: $params) {
+        name
+        start
+        end
+        color
+        description
+        location
+        uid
+        allDay
+        status
+        meta
+      }
+    }
+  }
+}
diff --git a/aleksis/core/frontend/components/calendar/calendarFeeds.graphql b/aleksis/core/frontend/components/calendar/calendarFeeds.graphql
new file mode 100644
index 000000000..917cf3869
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/calendarFeeds.graphql
@@ -0,0 +1,13 @@
+query {
+  calendar {
+    allFeedsUrl
+    calendarFeeds {
+      name
+      verboseName
+      description
+      url
+      color
+      activated
+    }
+  }
+}
diff --git a/aleksis/core/frontend/components/calendar/calendarMixin.js b/aleksis/core/frontend/components/calendar/calendarMixin.js
new file mode 100644
index 000000000..902525615
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/calendarMixin.js
@@ -0,0 +1,30 @@
+/**
+ * Mixin for use with adaptable components showing details for calendar feeds.
+ */
+
+const calendarMixin = {
+  data() {
+    return {
+      calendarFocus: "",
+      calendarType: "week",
+    };
+  },
+  methods: {
+    setCalendarFocus(val) {
+      this.calendarFocus = val;
+    },
+    setCalendarType(val) {
+      this.calendarType = val;
+    },
+  },
+  watch: {
+    calendarFocus(val) {
+      this.$refs.calendar.setCalendarFocus(val);
+    },
+    calendarType(val) {
+      this.$refs.calendar.setCalendarType(val);
+    },
+  },
+};
+
+export default calendarMixin;
diff --git a/aleksis/core/frontend/components/calendar/calendarOverview.graphql b/aleksis/core/frontend/components/calendar/calendarOverview.graphql
deleted file mode 100644
index f277ef4bb..000000000
--- a/aleksis/core/frontend/components/calendar/calendarOverview.graphql
+++ /dev/null
@@ -1,27 +0,0 @@
-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/calendarSelectedFeedsMixin.js b/aleksis/core/frontend/components/calendar/calendarSelectedFeedsMixin.js
new file mode 100644
index 000000000..b8d93e624
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/calendarSelectedFeedsMixin.js
@@ -0,0 +1,47 @@
+/**
+ * Mixin for use with adaptable components showing details for calendar feeds.
+ */
+import gqlCalendarFeeds from "./calendarFeeds.graphql";
+import gqlSetCalendarStatus from "./setCalendarStatus.graphql";
+
+const calendarSelectedFeedsMixin = {
+  data() {
+    return {
+      calendar: {
+        calendarFeeds: [],
+      },
+      selectedCalendarFeedNames: [],
+    };
+  },
+  apollo: {
+    calendar: {
+      query: gqlCalendarFeeds,
+      result({ data }) {
+        console.log(data);
+        this.selectedCalendarFeedNames = data.calendar.calendarFeeds
+          .filter((c) => c.activated)
+          .map((c) => c.name);
+      },
+    },
+  },
+  computed: {
+    selectedFeedsForCalendar() {
+      return this.selectedCalendarFeedNames.map((name) => {
+        return { name };
+      });
+    },
+  },
+  methods: {
+    storeActivatedCalendars() {
+      // Store currently activated calendars in the backend
+      this.$apollo.mutate({
+        mutation: gqlSetCalendarStatus,
+        variables: {
+          calendars: this.selectedCalendarFeedNames,
+        },
+      });
+    },
+  },
+};
+
+export default calendarSelectedFeedsMixin;
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index c6c19af0a..a2e133cb4 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -676,18 +676,20 @@ class CalendarEventMixin(RegistryObject):
         return cls.color
 
     @classmethod
-    def create_event(cls, reference_object, feed: ExtendedICal20Feed, request) -> dict[str, Any]:
+    def create_event(
+        cls, reference_object, feed: ExtendedICal20Feed, request, params=None
+    ) -> 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)
+            field_value = cls.get_event_field_value(reference_object, field, request, params)
             if field_value is not None:
                 values[field] = field_value
         feed.add_item(**values)
         return values
 
     @classmethod
-    def start_feed(cls, request) -> ExtendedICal20Feed:
+    def start_feed(cls, request, params=None) -> ExtendedICal20Feed:
         """Start the feed and return it."""
         feed = ExtendedICal20Feed(
             title=cls.get_verbose_name(request),
@@ -704,25 +706,25 @@ class CalendarEventMixin(RegistryObject):
         raise NotImplementedError
 
     @classmethod
-    def create_feed(cls, request: HttpRequest) -> ExtendedICal20Feed:
+    def create_feed(cls, request: HttpRequest, params: Optional[dict] = None) -> ExtendedICal20Feed:
         """Create the calendar feed with all events."""
-        feed = cls.start_feed(request)
+        feed = cls.start_feed(request, params)
 
-        for reference_object in cls.get_objects(request):
-            cls.create_event(reference_object, feed, request)
+        for reference_object in cls.get_objects(request, params):
+            cls.create_event(reference_object, feed, request, params)
 
         return feed
 
     @classmethod
-    def get_calendar_object(cls, request: HttpRequest) -> Calendar:
+    def get_calendar_object(cls, request: HttpRequest, params=None) -> Calendar:
         """Return the calendar object."""
-        feed = cls.create_feed(request)
+        feed = cls.create_feed(request, params)
         return feed.get_calendar_object()
 
     @classmethod
-    def get_single_events(cls, request: HttpRequest, start=None, end=None):
+    def get_single_events(cls, request: HttpRequest, start=None, end=None, params=None):
         """Get single events for this calendar feed."""
-        feed = cls.create_feed(request)
+        feed = cls.create_feed(request, params)
         return feed.get_single_events(start, end)
 
     @classmethod
@@ -731,7 +733,7 @@ class CalendarEventMixin(RegistryObject):
         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):
+    def get_event_field_value(cls, reference_object, field_name: str, request, params=None):
         """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)):
@@ -761,5 +763,10 @@ class CalendarEventMixin(RegistryObject):
         """Return a list of valid feed names."""
         return [feed.name for feed in cls.valid_feeds]
 
+    @classmethod
     def get_object_by_name(cls, name):
         return cls.registered_objects_dict.get(name)
+
+    @classmethod
+    def get_activated(cls, person):
+        return cls.name in person.preferences["calendar__activated_calendars"]
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 4151d51b3..0d404c1b1 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -1624,7 +1624,7 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel):
         return cls.get_color(request)
 
     @classmethod
-    def get_objects(cls, request) -> Iterable:
+    def get_objects(cls, request, params=None) -> Iterable:
         """Return all objects that should be included in the calendar."""
         return cls.objects.instance_of(cls)
 
@@ -1697,7 +1697,7 @@ class BirthdayEvent(CalendarEventMixin):
         return get_site_preferences()["calendar__birthday_color"]
 
     @classmethod
-    def get_objects(cls, request) -> QuerySet:
+    def get_objects(cls, request, params=None) -> QuerySet:
         qs = Person.objects.filter(date_of_birth__isnull=False)
         qs = qs.filter(
             Q(pk=request.user.person.pk)
diff --git a/aleksis/core/schema/calendar.py b/aleksis/core/schema/calendar.py
index 110e2cf82..f3fcdf98d 100644
--- a/aleksis/core/schema/calendar.py
+++ b/aleksis/core/schema/calendar.py
@@ -1,3 +1,4 @@
+import json
 from datetime import datetime
 
 from django.core.exceptions import PermissionDenied
@@ -53,22 +54,24 @@ class CalendarEventType(ObjectType):
         return root.get("X-META", "{}")
 
 
-class CalendarFeedType(ObjectType):
+class CalendarType(ObjectType):
+    name = graphene.String(required=True)
+    verbose_name = graphene.String(required=True)
+    description = graphene.String()
     events = graphene.List(
         CalendarEventType,
         start=graphene.Date(required=False),
         end=graphene.Date(required=False),
+        params=graphene.String(required=False),
     )
 
-    def resolve_events(root, info, start=None, end=None, **kwargs):
-        return root.get_single_events(start, end)
+    def resolve_events(root, info, start=None, end=None, params=None, **kwargs):
+        if params:
+            params = json.loads(params)
+        feed = root.create_feed(info.context, params)
 
+        return feed.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()
@@ -81,9 +84,6 @@ class CalendarType(ObjectType):
     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]))
 
@@ -91,7 +91,7 @@ class CalendarType(ObjectType):
         return root.get_color(info.context)
 
     def resolve_activated(root, info, **kwargs):
-        return root.name in info.context.user.person.preferences["calendar__activated_calendars"]
+        return root.get_activated(info.context.user.person)
 
 
 class SetCalendarStatusMutation(graphene.Mutation):
@@ -111,17 +111,16 @@ class SetCalendarStatusMutation(graphene.Mutation):
 
 
 class CalendarBaseType(ObjectType):
-    calendar_feeds = graphene.List(CalendarType)
-
-    calendar_feeds_by_names = graphene.List(CalendarType, names=graphene.List(graphene.String))
+    calendar_feeds = graphene.List(
+        CalendarType, names=graphene.List(graphene.String, required=False)
+    )
 
     all_feeds_url = graphene.String()
 
-    def resolve_calendar_feeds(root, info, **kwargs):
+    def resolve_calendar_feeds(root, info, names=None, **kwargs):
+        if names is not None:
+            return [CalendarEventMixin.get_object_by_name(name) for name in names]
         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"))
-- 
GitLab