diff --git a/aleksis/apps/lesrooster/apps.py b/aleksis/apps/lesrooster/apps.py
index f4dd55a8af02a5597297ad875d87251da38ec0fc..7f07c18492012e5510c41302eaa5ee84bdafb604 100644
--- a/aleksis/apps/lesrooster/apps.py
+++ b/aleksis/apps/lesrooster/apps.py
@@ -2,7 +2,13 @@ from django.db.models import signals
 
 from aleksis.core.util.apps import AppConfig
 
-from .util.signal_handlers import m2m_changed_handler, post_save_handler
+from .util.signal_handlers import (
+    create_time_grid_for_new_validity_range,
+    m2m_changed_handler,
+    post_save_handler,
+    pre_delete_handler,
+    publish_validity_range,
+)
 
 
 class DefaultConfig(AppConfig):
@@ -18,7 +24,13 @@ class DefaultConfig(AppConfig):
 
     def ready(self):
         # Configure change tracking for models to sync changes with LessonEvent in Chronos
-        from .models import Lesson, Substitution, Supervision, SupervisionSubstitution
+        from .models import (
+            Lesson,
+            Substitution,
+            Supervision,
+            SupervisionSubstitution,
+            ValidityRange,
+        )
 
         models = [Lesson, Supervision, Substitution, SupervisionSubstitution]
 
@@ -29,5 +41,23 @@ class DefaultConfig(AppConfig):
             )
             signals.m2m_changed.connect(
                 m2m_changed_handler,
-                sender=model,
+                sender=model.teachers.through,
             )
+            signals.pre_delete.connect(pre_delete_handler, sender=model)
+
+        signals.m2m_changed.connect(
+            m2m_changed_handler,
+            sender=Lesson.rooms.through,
+        )
+        signals.m2m_changed.connect(
+            m2m_changed_handler,
+            sender=Supervision.rooms.through,
+        )
+        signals.m2m_changed.connect(
+            m2m_changed_handler,
+            sender=Substitution.rooms.through,
+        )
+
+        signals.post_save.connect(create_time_grid_for_new_validity_range, sender=ValidityRange)
+
+        signals.post_save.connect(publish_validity_range, sender=ValidityRange)
diff --git a/aleksis/apps/lesrooster/form_extensions.py b/aleksis/apps/lesrooster/form_extensions.py
new file mode 100644
index 0000000000000000000000000000000000000000..d5b97204c13db4f0bf1e7545efb88de7dce49baf
--- /dev/null
+++ b/aleksis/apps/lesrooster/form_extensions.py
@@ -0,0 +1,7 @@
+from django.utils.translation import gettext as _
+
+from material import Fieldset
+
+from aleksis.core.forms import PersonForm
+
+PersonForm.add_node_to_layout(Fieldset(_("Lesson quota as a teacher"), "lesson_quota"))
diff --git a/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/Break.vue b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/Break.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b1b6156eb677aa3d0fa98fdeb792ec5cd87a746b
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/Break.vue
@@ -0,0 +1,74 @@
+<script>
+import {
+  breakSlots,
+  createBreakSlot,
+  deleteBreakSlot,
+  deleteBreakSlots,
+  updateBreakSlots,
+} from "./break.graphql";
+import LesroosterSlot from "./LesroosterSlot.vue";
+
+export default {
+  name: "Break",
+  extends: LesroosterSlot,
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t("lesrooster.slot.name"),
+          value: "name",
+        },
+        {
+          text: this.$t("lesrooster.validity_range.title"),
+          value: "timeGrid",
+          orderKey: "time_grid__validity_range__date_start",
+        },
+        {
+          text: this.$t("lesrooster.slot.weekday"),
+          value: "weekday",
+        },
+        {
+          text: this.$t("lesrooster.slot.time_start"),
+          value: "timeStart",
+        },
+        {
+          text: this.$t("lesrooster.slot.time_end"),
+          value: "timeEnd",
+        },
+      ],
+      i18nKey: "lesrooster.break",
+      createItemI18nKey: "lesrooster.break.create_item",
+      gqlQuery: breakSlots,
+      gqlCreateMutation: createBreakSlot,
+      gqlPatchMutation: updateBreakSlots,
+      gqlDeleteMutation: deleteBreakSlot,
+      gqlDeleteMultipleMutation: deleteBreakSlots,
+    };
+  },
+  methods: {
+    getCreateData(item) {
+      console.log("in getCreateData", item);
+      return {
+        ...item,
+        period: null,
+        weekday: this.weekdayAsInt(item.weekday),
+        timeGrid: item.timeGrid.id,
+      };
+    },
+    getPatchData(items) {
+      console.log("patch items", items);
+      return items.map((item) => ({
+        id: item.id,
+        name: item.name,
+        weekday: this.weekdayAsInt(item.weekday),
+        period: null,
+        timeStart: item.timeStart,
+        timeEnd: item.timeEnd,
+        timeGrid: item.timeGrid.id,
+      }));
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/LesroosterSlot.vue b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/LesroosterSlot.vue
new file mode 100644
index 0000000000000000000000000000000000000000..00b17c5eed9491cacdf0b6a9238d68d271d3fd15
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/LesroosterSlot.vue
@@ -0,0 +1,224 @@
+<script setup>
+import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue";
+import WeekDayField from "aleksis.core/components/generic/forms/WeekDayField.vue";
+import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
+import TimeField from "aleksis.core/components/generic/forms/TimeField.vue";
+import TimeGridField from "../validity_range/TimeGridField.vue";
+</script>
+
+<template>
+  <inline-c-r-u-d-list
+    :headers="headers"
+    :i18n-key="i18nKey"
+    :create-item-i18n-key="createItemI18nKey"
+    :gql-query="gqlQuery"
+    :gql-create-mutation="gqlCreateMutation"
+    :gql-patch-mutation="gqlPatchMutation"
+    :gql-delete-mutation="gqlDeleteMutation"
+    :gql-delete-multiple-mutation="gqlDeleteMultipleMutation"
+    :default-item="defaultItem"
+    :get-create-data="getCreateData"
+    :get-patch-data="getPatchData"
+    filter
+  >
+    <template #weekday="{ item }">
+      {{ $t("weekdays." + item.weekday) }}
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #weekday.field="{ attrs, on }">
+      <div aria-required="true">
+        <week-day-field v-bind="attrs" v-on="on" :rules="required" required />
+      </div>
+    </template>
+
+    <template #timeGrid="{ item }">
+      {{ formatTimeGrid(item.timeGrid) }}
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #timeGrid.field="{ attrs, on }">
+      <div aria-required="true">
+        <time-grid-field v-bind="attrs" v-on="on" :rules="required" required />
+      </div>
+    </template>
+
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #period.field="{ attrs, on }">
+      <positive-small-integer-field v-bind="attrs" v-on="on" />
+    </template>
+
+    <template #timeStart="{ item }">
+      {{ $d(new Date("1970-01-01T" + item.timeStart), "shortTime") }}
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #timeStart.field="{ attrs, on }">
+      <div aria-required="true">
+        <time-field v-bind="attrs" v-on="on" :rules="required" required />
+      </div>
+    </template>
+
+    <template #timeEnd="{ item }">
+      {{ $d(new Date("1970-01-01T" + item.timeEnd), "shortTime") }}
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #timeEnd.field="{ attrs, on }">
+      <div aria-required="true">
+        <time-field v-bind="attrs" v-on="on" :rules="required" required />
+      </div>
+    </template>
+
+    <template #filters="{ attrs, on }">
+      <week-day-field
+        v-bind="attrs('weekday')"
+        v-on="on('weekday')"
+        return-int
+        clearable
+        :label="$t('lesrooster.slot.weekday')"
+      />
+
+      <v-row>
+        <v-col>
+          <positive-small-integer-field
+            v-bind="attrs('period__gte')"
+            v-on="on('period__gte')"
+            :label="$t('lesrooster.slot.period_gte')"
+          />
+        </v-col>
+
+        <v-col>
+          <positive-small-integer-field
+            v-bind="attrs('period__lte')"
+            v-on="on('period__lte')"
+            :label="$t('lesrooster.slot.period_lte')"
+          />
+        </v-col>
+      </v-row>
+
+      <v-row>
+        <v-col>
+          <time-field
+            v-bind="attrs('time_end__gte')"
+            v-on="on('time_end__gte')"
+            :label="$t('school_term.after')"
+          />
+        </v-col>
+        <v-col>
+          <time-field
+            v-bind="attrs('time_start__lte')"
+            v-on="on('time_start__lte')"
+            :label="$t('school_term.before')"
+          />
+        </v-col>
+      </v-row>
+    </template>
+  </inline-c-r-u-d-list>
+</template>
+
+<script>
+import {
+  slots,
+  createSlot,
+  deleteSlot,
+  deleteSlots,
+  updateSlots,
+} from "./slot.graphql";
+
+export default {
+  name: "LesroosterSlot",
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t("lesrooster.slot.name"),
+          value: "name",
+        },
+        {
+          text: this.$t("lesrooster.validity_range.title"),
+          value: "timeGrid",
+          orderKey: "time_grid__validity_range__date_start",
+        },
+        {
+          text: this.$t("lesrooster.slot.weekday"),
+          value: "weekday",
+        },
+        {
+          text: this.$t("lesrooster.slot.period"),
+          value: "period",
+        },
+        {
+          text: this.$t("lesrooster.slot.time_start"),
+          value: "timeStart",
+        },
+        {
+          text: this.$t("lesrooster.slot.time_end"),
+          value: "timeEnd",
+        },
+      ],
+      i18nKey: "lesrooster.slot",
+      createItemI18nKey: "lesrooster.slot.create_slot",
+      gqlQuery: slots,
+      gqlCreateMutation: createSlot,
+      gqlPatchMutation: updateSlots,
+      gqlDeleteMutation: deleteSlot,
+      gqlDeleteMultipleMutation: deleteSlots,
+      defaultItem: {
+        name: "",
+        timeStart: "",
+        timeEnd: "",
+        weekday: "A_0",
+        timeGrid: null,
+      },
+      required: [(value) => !!value || this.$t("forms.errors.required")],
+    };
+  },
+  methods: {
+    weekdayAsInt(weekday) {
+      // Weekday is in format A_0 (monday) to A_6
+      if (
+        (weekday instanceof String || typeof weekday === "string") &&
+        weekday.length === 3 &&
+        weekday.startsWith("A_") &&
+        !isNaN(parseInt(weekday.charAt(2)))
+      ) {
+        return parseInt(weekday.charAt(2));
+      }
+      console.error("Invalid Weekday:", weekday);
+      return NaN;
+    },
+    getCreateData(item) {
+      console.log("in getCreateData", item);
+      return {
+        ...item,
+        weekday: this.weekdayAsInt(item.weekday),
+        timeGrid: item.timeGrid.id,
+      };
+    },
+    getPatchData(items) {
+      console.log("patch items", items);
+      return items.map((item) => ({
+        id: item.id,
+        name: item.name,
+        weekday: this.weekdayAsInt(item.weekday),
+        period: item.period,
+        timeStart: item.timeStart,
+        timeEnd: item.timeEnd,
+        timeGrid: item.timeGrid.id,
+      }));
+    },
+    formatTimeGrid(item) {
+      if (!item) return null;
+      if (item.group === null) {
+        return this.$t(
+          "lesrooster.validity_range.time_grid.repr.generic",
+          item.validityRange
+        );
+      }
+      return this.$t("lesrooster.validity_range.time_grid.repr.default", [
+        item.validityRange.name,
+        item.group.name,
+      ]);
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/break.graphql b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/break.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..63c2087d1f17c516c84ef8ac8e3fd84381fccd1c
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/break.graphql
@@ -0,0 +1,116 @@
+query breakSlots($orderBy: [String], $filters: JSONString) {
+  items: breakSlots(orderBy: $orderBy, filters: $filters) {
+    id
+    name
+    timeGrid {
+      id
+      group {
+        id
+        name
+      }
+      validityRange {
+        id
+        name
+        dateStart
+        dateEnd
+      }
+    }
+    weekday
+    period
+    timeStart
+    timeEnd
+    canEdit
+    canDelete
+  }
+}
+
+mutation createBreakSlot($input: CreateBreakSlotInput!) {
+  createBreakSlot(input: $input) {
+    item: breakSlot {
+      id
+      name
+      timeGrid {
+        id
+        group {
+          id
+          name
+        }
+        validityRange {
+          id
+          name
+        }
+      }
+      weekday
+      period
+      timeStart
+      timeEnd
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation createBreakSlots($input: [BatchCreateBreakSlotInput]!) {
+  createBreakSlots(input: $input) {
+    items: breakSlots {
+      id
+      model
+      name
+      timeGrid {
+        id
+        group {
+          id
+          name
+        }
+        validityRange {
+          id
+          name
+        }
+      }
+      weekday
+      period
+      timeStart
+      timeEnd
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation deleteBreakSlot($id: ID!) {
+  deleteBreakSlot(id: $id) {
+    ok
+  }
+}
+
+mutation deleteBreakSlots($ids: [ID]!) {
+  deleteBreakSlots(ids: $ids) {
+    deletionCount
+  }
+}
+
+mutation updateBreakSlots($input: [BatchPatchBreakSlotInput]!) {
+  batchMutation: updateBreakSlots(input: $input) {
+    items: breakSlots {
+      id
+      name
+      timeGrid {
+        id
+        group {
+          id
+          name
+        }
+        validityRange {
+          id
+          name
+        }
+      }
+      weekday
+      period
+      timeStart
+      timeEnd
+      canEdit
+      canDelete
+    }
+  }
+}
diff --git a/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/slot.graphql b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/slot.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..269faa53c9f3bb62ea2a5d138c778279089d433f
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/breaks_and_slots/slot.graphql
@@ -0,0 +1,181 @@
+query slots($orderBy: [String], $filters: JSONString) {
+  items: slots(orderBy: $orderBy, filters: $filters) {
+    id
+    model
+    name
+    timeGrid {
+      id
+      group {
+        id
+        name
+      }
+      validityRange {
+        id
+        name
+      }
+    }
+    weekday
+    period
+    timeStart
+    timeEnd
+    canEdit
+    canDelete
+  }
+}
+
+mutation createSlot($input: CreateSlotInput!) {
+  createSlot(input: $input) {
+    item: slot {
+      id
+      name
+      timeGrid {
+        id
+        group {
+          id
+          name
+        }
+        validityRange {
+          id
+          name
+        }
+      }
+      weekday
+      period
+      timeStart
+      timeEnd
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation createSlots($input: [BatchCreateSlotInput]!) {
+  createSlots(input: $input) {
+    items: slots {
+      id
+      model
+      name
+      timeGrid {
+        id
+        group {
+          id
+          name
+        }
+        validityRange {
+          id
+          name
+        }
+      }
+      weekday
+      period
+      timeStart
+      timeEnd
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation deleteSlot($id: ID!) {
+  deleteSlot(id: $id) {
+    ok
+  }
+}
+
+mutation deleteSlots($ids: [ID]!) {
+  deleteSlots(ids: $ids) {
+    deletionCount
+  }
+}
+
+mutation updateSlots($input: [BatchPatchSlotInput]!) {
+  batchMutation: updateSlots(input: $input) {
+    items: slots {
+      id
+      name
+      timeGrid {
+        id
+        group {
+          id
+          name
+        }
+        validityRange {
+          id
+          name
+        }
+      }
+      weekday
+      period
+      timeStart
+      timeEnd
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation carryOverSlots(
+  $timeGrid: ID!
+  $fromDay: Int!
+  $toDay: Int!
+  $only: [ID]
+) {
+  carryOverSlots(
+    timeGrid: $timeGrid
+    fromDay: $fromDay
+    toDay: $toDay
+    only: $only
+  ) {
+    deleted
+    result {
+      id
+      model
+      name
+      timeGrid {
+        id
+        group {
+          id
+          name
+        }
+        validityRange {
+          id
+          name
+        }
+      }
+      weekday
+      period
+      timeStart
+      timeEnd
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation copySlotsFromGrid($toTimeGrid: ID!, $fromTimeGrid: ID!) {
+  copySlotsFromGrid(timeGrid: $toTimeGrid, fromTimeGrid: $fromTimeGrid) {
+    result {
+      id
+      model
+      name
+      timeGrid {
+        id
+        group {
+          id
+          name
+        }
+        validityRange {
+          id
+          name
+        }
+      }
+      weekday
+      period
+      timeStart
+      timeEnd
+      canEdit
+      canDelete
+    }
+    deleted
+  }
+}
diff --git a/aleksis/apps/lesrooster/frontend/components/helper.graphql b/aleksis/apps/lesrooster/frontend/components/helper.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..417870dfca049ce5c85c082555459e379b3ed274
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/helper.graphql
@@ -0,0 +1,48 @@
+query gqlPersons {
+  persons {
+    id
+    fullName
+  }
+}
+
+query gqlTeachers {
+  persons: teachers {
+    id
+    fullName
+    shortName
+  }
+}
+
+query gqlGroups {
+  groups {
+    id
+    name
+    shortName
+  }
+}
+
+query gqlClasses {
+  groups: schoolClasses {
+    id
+    name
+    shortName
+  }
+}
+
+query gqlCourses {
+  courses {
+    id
+    name
+    subject {
+      id
+      shortName
+      name
+      colourFg
+      colourBg
+    }
+    teachers {
+      id
+      fullName
+    }
+  }
+}
diff --git a/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue b/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1258345642b7b6d2e501a445350ec457c0174962
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue
@@ -0,0 +1,521 @@
+<template>
+  <div id="slot-container">
+    <v-card class="sidebar">
+      <v-navigation-drawer floating permanent>
+        <v-list dense rounded>
+          <time-grid-field
+            solo
+            rounded
+            hide-details
+            v-model="internalTimeGrid"
+          />
+          <slot-creator
+            :query="$apollo.queries.items"
+            :time-grid="internalTimeGrid.id"
+            v-if="internalTimeGrid"
+            :breaks="createBreaks"
+          >
+            <template #activator="{ on, attrs }">
+              <v-list-item
+                link
+                v-bind="attrs"
+                v-on="on"
+                @click="createBreaks = false"
+              >
+                <v-list-item-icon>
+                  <v-icon>$plus</v-icon>
+                </v-list-item-icon>
+                <v-list-item-content>
+                  <v-list-item-title>{{
+                    $t("lesrooster.slot.create_items")
+                  }}</v-list-item-title>
+                </v-list-item-content>
+              </v-list-item>
+              <v-list-item
+                link
+                v-bind="attrs"
+                v-on="on"
+                @click="createBreaks = true"
+              >
+                <v-list-item-icon>
+                  <v-icon>$plus</v-icon>
+                </v-list-item-icon>
+                <v-list-item-content>
+                  <v-list-item-title>{{
+                    $t("lesrooster.break.create_items")
+                  }}</v-list-item-title>
+                </v-list-item-content>
+              </v-list-item>
+            </template>
+          </slot-creator>
+
+          <copy-from-time-grid-menu
+            v-if="internalTimeGrid"
+            :deny-ids="[internalTimeGrid.id]"
+            @confirm="copyFromGrid"
+          >
+            <template #activator="{ on, attrs }">
+              <v-list-item link v-bind="attrs" v-on="on">
+                <v-list-item-icon>
+                  <v-icon>mdi-content-copy</v-icon>
+                </v-list-item-icon>
+                <v-list-item-content>
+                  <v-list-item-title>
+                    {{ $t("actions.copy_last_configuration") }}
+                  </v-list-item-title>
+                </v-list-item-content>
+              </v-list-item>
+            </template>
+          </copy-from-time-grid-menu>
+        </v-list>
+      </v-navigation-drawer>
+    </v-card>
+
+    <v-hover
+      v-for="weekday in weekdays"
+      :key="'weekday-' + weekday"
+      :style="{
+        gridColumn: weekday,
+      }"
+      v-slot="{ hover }"
+    >
+      <v-card :loading="$apollo.queries.items.loading || loading.main">
+        <v-card-title
+          class="d-flex flex-wrap justify-space-between align-center fill-height"
+        >
+          <span class="min-height">{{ $t("weekdays." + weekday) }}</span>
+
+          <v-tooltip bottom>
+            <template #activator="{ on, attrs }">
+              <v-btn
+                @click="deleteSlotsOfDay(weekday)"
+                icon
+                v-bind="attrs"
+                v-on="on"
+                v-show="hover"
+              >
+                <v-icon>$deleteContent</v-icon>
+              </v-btn>
+            </template>
+            <span v-t="'actions.delete'"></span>
+          </v-tooltip>
+
+          <v-menu offset-y>
+            <template #activator="{ on: menu, attrs }">
+              <v-tooltip bottom>
+                <template #activator="{ on: tooltip }">
+                  <v-btn
+                    icon
+                    v-bind="attrs"
+                    v-on="{ ...tooltip, ...menu }"
+                    :loading="loading[weekday] || loading.main"
+                    v-show="hover"
+                  >
+                    <v-icon>mdi-application-export</v-icon>
+                  </v-btn>
+                </template>
+                <span v-t="'actions.copy_to_day'"></span>
+              </v-tooltip>
+            </template>
+            <v-list>
+              <v-list-item
+                v-for="(item, index) in weekdays.filter(
+                  (day) => day !== weekday
+                )"
+                :key="index"
+                link
+              >
+                <v-list-item-title @click="copyTo(weekday, item)">{{
+                  $t("weekdays." + item)
+                }}</v-list-item-title>
+              </v-list-item>
+            </v-list>
+          </v-menu>
+
+          <v-btn
+            v-if="canAddDay(left(weekday))"
+            v-show="hover"
+            color="secondary"
+            fab
+            dark
+            small
+            absolute
+            left
+            style="left: calc(-20px - 0.5rem)"
+            @click="add(left(weekday))"
+          >
+            <v-icon>mdi-table-column-plus-before</v-icon>
+          </v-btn>
+          <v-btn
+            v-if="canAddDay(right(weekday))"
+            v-show="hover"
+            color="secondary"
+            fab
+            dark
+            small
+            absolute
+            right
+            style="right: calc(-20px - 0.5rem)"
+            @click="add(right(weekday))"
+          >
+            <v-icon>mdi-table-column-plus-after</v-icon>
+          </v-btn>
+        </v-card-title>
+      </v-card>
+    </v-hover>
+
+    <slot-card
+      v-for="slot in slots"
+      :key="'slot-' + slot.id"
+      :item="slot"
+      :disabled="
+        $apollo.queries.items.loading || loading.main || loading[slot.weekday]
+      "
+      @click:delete="deleteSingularSlot"
+      @click:copy="copySingularSlotTodDay($event.item, $event.weekday)"
+      :weekdays="weekdays"
+      :id="'#slot-' + slot.id"
+    />
+
+    <delete-dialog
+      :gql-mutation="deleteMutation"
+      :gql-query="$apollo.queries.items"
+      v-model="deleteDialog"
+      :item="itemToDelete"
+    >
+      <template #body>
+        {{
+          $t(
+            "lesrooster." + itemToDelete.model.toLowerCase() + ".repr",
+            itemToDelete
+          )
+        }}
+      </template>
+    </delete-dialog>
+
+    <delete-multiple-dialog
+      :gql-mutation="deleteMultipleMutation"
+      :gql-query="$apollo.queries.items"
+      :items="itemsToDelete"
+      v-model="deleteMultipleDialog"
+    >
+      <template #title>
+        {{
+          $t("lesrooster.slot.confirm_delete_multiple_slots", {
+            day: $t("weekdays." + weekdayToDelete),
+          })
+        }}
+      </template>
+
+      <template #body>
+        <ul class="text-body-1">
+          <li v-for="item in itemsToDelete" :key="'delete-' + item.id">
+            {{ $t("lesrooster." + item.model.toLowerCase() + ".repr", item) }}
+          </li>
+        </ul>
+      </template>
+    </delete-multiple-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  carryOverSlots,
+  copySlotsFromGrid,
+  slots,
+  deleteSlot,
+  deleteSlots,
+} from "../breaks_and_slots/slot.graphql";
+import DeleteDialog from "aleksis.core/components/generic/dialogs/DeleteDialog.vue";
+import DeleteMultipleDialog from "aleksis.core/components/generic/dialogs/DeleteMultipleDialog.vue";
+import CopyFromTimeGridMenu from "../validity_range/CopyFromTimeGridMenu.vue";
+import SlotCard from "./SlotCard.vue";
+import SlotCreator from "./SlotCreator.vue";
+import TimeGridField from "../validity_range/TimeGridField.vue";
+
+export default {
+  name: "LessonRaster",
+  components: {
+    TimeGridField,
+    CopyFromTimeGridMenu,
+    SlotCreator,
+    DeleteDialog,
+    DeleteMultipleDialog,
+    SlotCard,
+  },
+  apollo: {
+    items: {
+      query: slots,
+      variables() {
+        return {
+          filters: JSON.stringify({
+            time_grid: this.internalTimeGrid.id,
+          }),
+        };
+      },
+      result(data) {
+        console.log(data);
+        this.weekdays = Array.from(
+          new Set(data.data.items.map((slot) => slot.weekday))
+        ).sort();
+      },
+      skip() {
+        return this.internalTimeGrid === null;
+      },
+    },
+  },
+  data() {
+    return {
+      weekdays: [],
+      internalTimeGrid: null,
+      loading: {
+        main: false,
+      },
+      gqlQuery: slots,
+      deleteMutation: deleteSlot,
+      deleteMultipleMutation: deleteSlots,
+      deleteDialog: false,
+      deleteMultipleDialog: false,
+      itemToDelete: null,
+      itemsToDelete: [],
+      weekdayToDelete: "",
+      createBreaks: false,
+    };
+  },
+  computed: {
+    slots() {
+      return (
+        [...(this.items || [])].sort(
+          (a, b) =>
+            parseInt(a.timeStart.replace(":", "")) -
+            parseInt(b.timeStart.replace(":", ""))
+        ) || []
+      );
+    },
+    columns() {
+      return (
+        "[side] 256px " + this.weekdays.map((day) => `[${day}] 1fr`).join(" ")
+      );
+    },
+  },
+  methods: {
+    intDay(weekday) {
+      return Number.isInteger(weekday) ? weekday : parseInt(weekday[2]);
+    },
+    canAddDay(weekday) {
+      if (!weekday) {
+        return false;
+      }
+
+      return !this.weekdays.includes(weekday);
+    },
+    add(weekday) {
+      if (!this.weekdays.includes(weekday)) {
+        this.weekdays.push(weekday);
+        this.weekdays.sort();
+      }
+    },
+    right(weekday) {
+      return weekday === "A_6"
+        ? null
+        : weekday.replace(/\d+$/, (match) => parseInt(match) + 1);
+    },
+    left(weekday) {
+      return weekday === "A_0"
+        ? null
+        : weekday.replace(/\d+$/, (match) => parseInt(match) - 1);
+    },
+    async copyTo(src, dest) {
+      this.loading[dest] = true;
+
+      // As there is an error when deleting breaks and normal slots in one action, we delete them separately
+      // FIXME NO ACtion
+
+      let that = this;
+
+      await this.$apollo.mutate({
+        mutation: carryOverSlots,
+        variables: {
+          timeGrid: this.internalTimeGrid.id,
+          fromDay: this.intDay(src),
+          toDay: this.intDay(dest),
+        },
+        update(
+          store,
+          {
+            data: {
+              carryOverSlots: { result },
+            },
+          }
+        ) {
+          let query = {
+            ...that.$apollo.queries.items.options,
+            variables: JSON.parse(
+              that.$apollo.queries.items.previousVariablesJson
+            ),
+          };
+          // Read the data from cache for query
+          const storedData = store.readQuery(query);
+
+          if (!storedData) {
+            // There are no data in the cache yet
+            return;
+          }
+
+          storedData.items = [
+            ...storedData.items.filter((item) => item.weekday !== dest),
+            ...result,
+          ];
+
+          // Write data back to the cache
+          store.writeQuery({ ...query, data: storedData });
+        },
+      });
+
+      this.weekdays = this.weekdays.sort((a, b) => a[2] - b[2]);
+      this.loading[dest] = false;
+    },
+    async copySingularSlotTodDay(slot, day) {
+      const that = this;
+
+      this.loading[day] = true;
+      this.$apollo
+        .mutate({
+          mutation: carryOverSlots,
+          variables: {
+            timeGrid: this.internalTimeGrid.id || slot.timeGrid.id,
+            fromDay: this.intDay(slot.weekday),
+            toDay: this.intDay(day),
+            only: [slot.id],
+          },
+          update(
+            store,
+            {
+              data: {
+                carryOverSlots: { result },
+              },
+            }
+          ) {
+            let query = {
+              ...that.$apollo.queries.items.options,
+              variables: JSON.parse(
+                that.$apollo.queries.items.previousVariablesJson
+              ),
+            };
+            // Read the data from cache for query
+            const storedData = store.readQuery(query);
+
+            if (!storedData) {
+              // There are no data in the cache yet
+              return;
+            }
+
+            storedData.items.push(result[0]);
+
+            // Write data back to the cache
+            store.writeQuery({ ...query, data: storedData });
+          },
+        })
+        .then(() => {
+          this.$toastSuccess();
+        })
+        .catch(() => {
+          this.$toastError();
+        })
+        .finally(() => {
+          this.loading[day] = false;
+        });
+    },
+    deleteSingularSlot(slot) {
+      this.itemToDelete = slot;
+      this.deleteDialog = true;
+    },
+    deleteSlotsOfDay(weekday) {
+      this.itemsToDelete = this.items.filter(
+        (slot) => slot.weekday === weekday
+      );
+      this.weekdayToDelete = weekday;
+      this.deleteMultipleDialog = true;
+    },
+    copyFromGrid(existingTimeGrid) {
+      if (!this.internalTimeGrid || !this.internalTimeGrid.id) return;
+
+      let that = this;
+      this.loading.main = true;
+
+      this.$apollo
+        .mutate({
+          mutation: copySlotsFromGrid,
+          variables: {
+            fromTimeGrid: existingTimeGrid.id,
+            toTimeGrid: this.internalTimeGrid.id,
+          },
+          update(
+            store,
+            {
+              data: {
+                copySlotsFromGrid: { result, deleted },
+              },
+            }
+          ) {
+            let query = {
+              ...that.$apollo.queries.items.options,
+              variables: JSON.parse(
+                that.$apollo.queries.items.previousVariablesJson
+              ),
+            };
+            // Read the data from cache for query
+            const storedData = store.readQuery(query);
+
+            if (!storedData) {
+              // There are no data in the cache yet
+              return;
+            }
+
+            for (const id of deleted) {
+              // Remove item from stored data
+              const index = storedData.items.findIndex((m) => m.id === id);
+              storedData.items.splice(index, 1);
+            }
+
+            storedData.items.push(...result);
+
+            // Write data back to the cache
+            store.writeQuery({ ...query, data: storedData });
+          },
+        })
+        .then(() => {
+          this.$toastSuccess();
+        })
+        .catch(() => {
+          this.$toastError();
+        })
+        .finally(() => {
+          this.loading.main = false;
+        });
+    },
+  },
+};
+</script>
+
+<style scoped>
+#slot-container {
+  display: grid;
+  grid-template-columns: v-bind(columns);
+  grid-auto-rows: 1fr;
+  gap: 0.7rem;
+  overflow-x: scroll;
+  margin: -1em;
+  padding: 1em;
+  grid-auto-flow: column;
+}
+
+.min-height {
+  min-height: 36px;
+}
+
+.sidebar {
+  position: fixed;
+  z-index: 1;
+}
+</style>
diff --git a/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..87d413663e2e551c473344fcf34f1747ca44994c
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue
@@ -0,0 +1,120 @@
+<script>
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  name: "SlotCard",
+  props: {
+    item: {
+      type: Object,
+      required: true,
+    },
+    disabled: {
+      type: Boolean,
+      default: false,
+      required: false,
+    },
+    weekdays: {
+      type: Array,
+      required: false,
+      default: () => [],
+    },
+  },
+  methods: {
+    handleDelete() {
+      this.$emit("click:delete", this.item);
+    },
+    handleCopy(weekday) {
+      this.$emit("click:copy", { item: this.item, weekday: weekday });
+    },
+  },
+});
+</script>
+
+<template>
+  <v-card
+    :style="{
+      gridColumn: item.weekday,
+    }"
+    :disabled="disabled"
+  >
+    <v-hover v-slot="{ hover }">
+      <v-card-text class="d-flex align-center">
+        <v-col cols="4" class="text-h4">
+          <span v-if="item.model === 'Slot'">{{ item.period }}</span>
+          <v-icon v-else>mdi-timer-sand-paused</v-icon>
+        </v-col>
+
+        <v-col cols="6">
+          <div class="time">
+            {{ $d(new Date("1970-01-01T" + item.timeStart), "shortTime") }}
+          </div>
+          <div class="time">
+            {{ $d(new Date("1970-01-01T" + item.timeEnd), "shortTime") }}
+          </div>
+        </v-col>
+
+        <v-col
+          cols="2"
+          class="d-flex flex-column align-center pa-0 my-n1 hover-box"
+        >
+          <v-tooltip left>
+            <template #activator="{ on, attrs }">
+              <v-btn
+                icon
+                v-bind="attrs"
+                v-on="on"
+                @click="handleDelete"
+                v-show="hover"
+              >
+                <v-icon>$deleteContent</v-icon>
+              </v-btn>
+            </template>
+            <span v-t="'actions.delete'"></span>
+          </v-tooltip>
+
+          <v-menu offset-y>
+            <template #activator="{ on: menu, attrs }">
+              <v-tooltip left>
+                <template #activator="{ on: tooltip }">
+                  <v-btn
+                    icon
+                    v-bind="attrs"
+                    v-on="{ ...tooltip, ...menu }"
+                    v-show="hover"
+                  >
+                    <v-icon>mdi-application-export</v-icon>
+                  </v-btn>
+                </template>
+                <span v-t="'actions.copy_to_day'"></span>
+              </v-tooltip>
+            </template>
+            <v-list>
+              <v-list-item
+                v-for="(weekday, index) in weekdays.filter(
+                  (day) => day !== item.weekday
+                )"
+                :key="index"
+                link
+              >
+                <v-list-item-title @click="handleCopy(weekday)"
+                  >{{ $t("weekdays." + weekday) }}
+                </v-list-item-title>
+              </v-list-item>
+            </v-list>
+          </v-menu>
+        </v-col>
+      </v-card-text>
+    </v-hover>
+  </v-card>
+</template>
+
+<style scoped>
+.time {
+  white-space: nowrap;
+}
+
+.hover-box {
+  padding-inline-end: 0.5em !important;
+  min-width: calc(36px + 0.5em);
+}
+</style>
diff --git a/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCreator.vue b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCreator.vue
new file mode 100644
index 0000000000000000000000000000000000000000..bbf5d06bea283115f809cc6658fdc701854bdd62
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCreator.vue
@@ -0,0 +1,177 @@
+<script>
+import { defineComponent } from "vue";
+import { createSlots } from "../breaks_and_slots/slot.graphql";
+import { createBreakSlots } from "../breaks_and_slots/break.graphql";
+import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue";
+import CreateButton from "aleksis.core/components/generic/buttons/CreateButton.vue";
+import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue";
+import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
+import TimeField from "aleksis.core/components/generic/forms/TimeField.vue";
+import WeekDayField from "aleksis.core/components/generic/forms/WeekDayField.vue";
+
+export default defineComponent({
+  name: "SlotCreator",
+  components: {
+    CreateButton,
+    CancelButton,
+    PositiveSmallIntegerField,
+    WeekDayField,
+    MobileFullscreenDialog,
+    TimeField,
+  },
+  data() {
+    return {
+      dialog: false,
+      slots: {
+        weekdays: [],
+        period: null,
+        timeStart: "8:00",
+        timeEnd: "9:00",
+      },
+      required: [(value) => !!value || this.$t("forms.errors.required")],
+    };
+  },
+  props: {
+    timeGrid: {
+      type: String,
+      required: true,
+    },
+    breaks: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    query: {
+      type: Object,
+      required: true,
+    },
+  },
+  methods: {
+    save() {
+      this.loading = true;
+      this.$apollo
+        .mutate({
+          mutation: this.breaks ? createBreakSlots : createSlots,
+          variables: {
+            input: this.slots.weekdays.map((weekday) => ({
+              name: "",
+              timeGrid: this.timeGrid,
+              period: this.slots.period,
+              weekday: parseInt(weekday[2]),
+              timeStart: this.slots.timeStart,
+              timeEnd: this.slots.timeEnd,
+            })),
+          },
+          update: (store, data) => {
+            let mutationName = this.breaks ? "createBreakSlots" : "createSlots";
+            this.$emit("update", store, data.data[mutationName].items);
+
+            let query = {
+              ...this.query.options,
+              variables: JSON.parse(this.query.previousVariablesJson),
+            };
+            // Read the data from cache for query
+            const storedData = store.readQuery(query);
+
+            if (!storedData) {
+              // There are no data in the cache yet
+              return;
+            }
+
+            storedData.items = [
+              ...storedData.items,
+              ...data.data[mutationName].items,
+            ];
+
+            // Write data back to the cache
+            store.writeQuery({ ...query, data: storedData });
+          },
+        })
+        .then((data) => {
+          this.$emit("save", data);
+
+          this.handleSuccess();
+        })
+        .catch((error) => {
+          console.error(error);
+          this.$emit("error", error);
+        })
+        .finally(() => {
+          this.loading = false;
+          this.dialog = false;
+        });
+    },
+    handleSuccess() {
+      this.$root.snackbarItems.push({
+        id: crypto.randomUUID(),
+        timeout: 5000,
+        messageKey: `lesrooster.${
+          this.breaks ? "break" : "slot"
+        }.create_items_success`,
+        color: "success",
+      });
+    },
+  },
+});
+</script>
+
+<template>
+  <mobile-fullscreen-dialog v-model="dialog">
+    <template #activator="{ on, attrs }">
+      <slot name="activator" v-bind="{ on, attrs }" />
+    </template>
+
+    <template #title>
+      {{ $t(`lesrooster.${breaks ? "break" : "slot"}.create_items`) }}
+    </template>
+
+    <template #content>
+      <div aria-required="true">
+        <positive-small-integer-field
+          v-model="slots.period"
+          :label="$t('lesrooster.slot.period')"
+          :rules="required"
+        />
+      </div>
+
+      <div aria-required="true">
+        <week-day-field
+          v-model="slots.weekdays"
+          multiple
+          chips
+          :label="$t('lesrooster.slot.weekdays')"
+          :rules="required"
+        />
+      </div>
+
+      <v-row>
+        <v-col>
+          <div aria-required="true">
+            <time-field
+              v-model="slots.timeStart"
+              :label="$t('lesrooster.slot.time_start')"
+              :rules="required"
+            />
+          </div>
+        </v-col>
+
+        <v-col>
+          <div aria-required="true">
+            <time-field
+              v-model="slots.timeEnd"
+              :label="$t('lesrooster.slot.time_end')"
+              :rules="required"
+            />
+          </div>
+        </v-col>
+      </v-row>
+    </template>
+
+    <template #actions>
+      <cancel-button @click="dialog = false" />
+      <create-button @click="save" />
+    </template>
+  </mobile-fullscreen-dialog>
+</template>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue b/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue
new file mode 100644
index 0000000000000000000000000000000000000000..21b51ebd2ddbc3aa039a3636c4c3e294f6ba97e4
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue
@@ -0,0 +1,315 @@
+<script setup>
+// eslint-disable-next-line no-unused-vars
+import CreateSubject from "aleksis.apps.cursus/components/CreateSubject.vue";
+import ForeignKeyField from "aleksis.core/components/generic/forms/ForeignKeyField.vue";
+import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
+import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue";
+</script>
+
+<template>
+  <inline-c-r-u-d-list
+    :headers="headers"
+    :i18n-key="i18nKey"
+    :create-item-i18n-key="createItemI18nKey"
+    :gql-query="gqlQuery"
+    :gql-create-mutation="gqlCreateMutation"
+    :gql-patch-mutation="gqlPatchMutation"
+    :gql-delete-mutation="gqlDeleteMutation"
+    :gql-delete-multiple-mutation="gqlDeleteMultipleMutation"
+    :default-item="defaultItem"
+    :get-create-data="getCreateData"
+    :get-patch-data="getPatchData"
+    filter
+  >
+    <template #breakSlot="{ item }">
+      <div class="body-1">{{ formatBreakSlotItem(item.breakSlot) }}</div>
+      <div class="caption">
+        {{ formatTimeGridItem(item.breakSlot.timeGrid) }}
+      </div>
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #breakSlot.field="{ attrs, on }">
+      <div aria-required="true">
+        <v-autocomplete
+          return-object
+          :items="internalBreakSlots"
+          :item-text="formatBreakSlotItem"
+          item-value="id"
+          :loading="$apollo.queries.internalBreakSlots.loading"
+          v-bind="attrs"
+          v-on="on"
+        >
+          <template #item="data">
+            <v-list-item-content>
+              <v-list-item-title>{{
+                formatBreakSlotItem(data.item)
+              }}</v-list-item-title>
+              <v-list-item-subtitle>{{
+                formatTimeGridItem(data.item.timeGrid)
+              }}</v-list-item-subtitle>
+            </v-list-item-content>
+          </template>
+        </v-autocomplete>
+      </div>
+    </template>
+
+    <template #rooms="{ item }">
+      <v-chip v-for="room in item.rooms" dense class="mx-1" :key="room.id">{{
+        room.shortName
+      }}</v-chip>
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #rooms.field="{ attrs, on }">
+      <div aria-required="true">
+        <v-autocomplete
+          multiple
+          return-object
+          :items="internalRooms"
+          item-text="name"
+          item-value="id"
+          :loading="$apollo.queries.internalRooms.loading"
+          v-bind="attrs"
+          v-on="on"
+        />
+      </div>
+    </template>
+
+    <template #teachers="{ item }">
+      <v-chip
+        v-for="teacher in item.teachers"
+        dense
+        class="mx-1"
+        :key="teacher.id"
+        >{{ teacher.fullName }}</v-chip
+      >
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #teachers.field="{ attrs, on }">
+      <div aria-required="true">
+        <v-autocomplete
+          multiple
+          return-object
+          :items="persons"
+          item-text="fullName"
+          item-value="id"
+          v-bind="attrs"
+          v-on="on"
+          :loading="$apollo.queries.persons.loading"
+        >
+          <template #item="data">
+            <v-list-item-action>
+              <v-checkbox v-model="data.attrs.inputValue" />
+            </v-list-item-action>
+            <v-list-item-content>
+              <v-list-item-title>{{ data.item.fullName }}</v-list-item-title>
+              <v-list-item-subtitle v-if="data.item.shortName">{{
+                data.item.shortName
+              }}</v-list-item-subtitle>
+            </v-list-item-content>
+          </template>
+        </v-autocomplete>
+      </div>
+    </template>
+
+    <template #subject="{ item }">
+      <subject-chip v-if="item.subject" :subject="item.subject" />
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #subject.field="{ attrs, on }">
+      <foreign-key-field
+        v-bind="attrs"
+        v-on="on"
+        :fields="subject.fields"
+        :default-item="subject.defaultItem"
+        :gql-query="subject.gqlQuery"
+        :gql-patch-mutation="{}"
+        :gql-create-mutation="subject.gqlCreateMutation"
+        :get-create-data="subject.transformCreateData"
+        create-item-i18n-key="cursus.subject.create"
+        return-object
+      >
+        <template #createComponent="{ attrs: attrs2, on: on2 }">
+          <create-subject v-bind="attrs2" v-on="on2"></create-subject>
+        </template>
+      </foreign-key-field>
+    </template>
+
+    <!--<template #filters="{ attrs, on }">-->
+    <!--  <time-grid-field-->
+    <!--    outlined-->
+    <!--    filled-->
+    <!--    v-bind="attrs('break_slot__time_grid__exact')"-->
+    <!--    v-on="on('break_slot__time_grid__exact')"-->
+    <!--    :label="$t('labels.select_validity_range')"-->
+    <!--    hide-details-->
+    <!--  />-->
+    <!--</template>-->
+  </inline-c-r-u-d-list>
+</template>
+
+<script>
+import {
+  supervisions,
+  createSupervision,
+  deleteSupervision,
+  deleteSupervisions,
+  updateSupervisions,
+} from "./supervision.graphql";
+
+import { gqlTeachers } from "../helper.graphql";
+import { rooms } from "aleksis.core/components/room/room.graphql";
+import { breakSlots } from "../breaks_and_slots/break.graphql";
+import {
+  subjects,
+  createSubject,
+} from "aleksis.apps.cursus/components/subject.graphql";
+
+import { RRule } from "rrule";
+
+export default {
+  name: "LesroosterSupervision",
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t("lesrooster.supervision.break_slot"),
+          value: "breakSlot",
+        },
+        {
+          text: this.$t("lesrooster.supervision.rooms"),
+          value: "rooms",
+        },
+        {
+          text: this.$t("lesrooster.supervision.teachers"),
+          value: "teachers",
+        },
+        {
+          text: this.$t("lesrooster.supervision.subject"),
+          value: "subject",
+        },
+      ],
+      i18nKey: "lesrooster.supervision",
+      createItemI18nKey: "lesrooster.supervision.create_supervision",
+      gqlQuery: supervisions,
+      gqlCreateMutation: createSupervision,
+      gqlPatchMutation: updateSupervisions,
+      gqlDeleteMutation: deleteSupervision,
+      gqlDeleteMultipleMutation: deleteSupervisions,
+      defaultItem: {
+        breakSlot: null,
+        teachers: [],
+        rooms: [],
+      },
+      subject: {
+        gqlQuery: subjects,
+        gqlCreateMutation: createSubject,
+        transformCreateData(item) {
+          return { ...item, parent: item.parent?.id };
+        },
+        defaultItem: {
+          name: "",
+          shortName: "",
+          parent: null,
+          colourFg: "",
+          colourBg: "",
+        },
+        fields: [
+          {
+            text: this.$t("cursus.subject.fields.name"),
+            value: "name",
+          },
+          {
+            text: this.$t("cursus.subject.fields.short_name"),
+            value: "shortName",
+          },
+          {
+            text: this.$t("cursus.subject.fields.parent"),
+            value: "parent",
+          },
+          {
+            text: this.$t("cursus.subject.fields.colour_fg"),
+            value: "colourFg",
+          },
+          {
+            text: this.$t("cursus.subject.fields.colour_bg"),
+            value: "colourBg",
+          },
+          {
+            text: this.$t("cursus.subject.fields.teachers"),
+            value: "teachers",
+          },
+        ],
+      },
+      rules: {
+        required: [(value) => !!value || this.$t("forms.errors.required")],
+        subject: [
+          (subject) => !!subject || this.$t("cursus.errors.subject_required"),
+        ],
+      },
+    };
+  },
+  apollo: {
+    persons: {
+      query: gqlTeachers,
+    },
+    internalRooms: {
+      query: rooms,
+      update: (data) => data.items,
+    },
+    internalBreakSlots: {
+      query: breakSlots,
+      update: (data) => data.items,
+    },
+  },
+  methods: {
+    formatTimeGridItem(item) {
+      if (item.group === null) {
+        return this.$t(
+          "lesrooster.validity_range.time_grid.repr.generic",
+          item.validityRange
+        );
+      }
+      return this.$t("lesrooster.validity_range.time_grid.repr.default", [
+        item.validityRange.name,
+        item.group.name,
+      ]);
+    },
+    formatBreakSlotItem(item) {
+      return this.$t("lesrooster.break.repr.weekday_short", {
+        weekday: this.$t("weekdays." + item.weekday),
+        timeStart: item.timeStart,
+        timeEnd: item.timeEnd,
+      });
+    },
+    getRRule(timeGrid) {
+      const rule = new RRule({
+        freq: RRule.WEEKLY, // TODO: Make this configurable
+        dtstart: new Date(timeGrid.validityRange.dateStart), // FIXME: check if this is correct with timezones etc.
+        until: new Date(timeGrid.validityRange.dateEnd), // FIXME: check if this is correct with timezones etc.
+      });
+      return rule;
+    },
+    getCreateData(item) {
+      return {
+        breakSlot: item.breakSlot.id,
+        rooms: item.rooms.map((r) => r.id),
+        teachers: item.teachers.map((t) => t.id),
+        subject: item.subject?.id,
+        recurrence: this.getRRule(item.breakSlot.timeGrid).toString(),
+      };
+    },
+    getPatchData(items) {
+      return items.map((item) => ({
+        id: item.id,
+        breakSlot: item.breakSlot.id,
+        rooms: item.rooms.map((r) => r.id),
+        teachers: item.teachers.map((t) => t.id),
+        subject: item.subject?.id,
+        recurrence: this.getRRule(item.breakSlot.timeGrid).toString(),
+      }));
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/supervision/supervision.graphql b/aleksis/apps/lesrooster/frontend/components/supervision/supervision.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..26556dfe7a3abb474e64b04f19243df70e110770
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/supervision/supervision.graphql
@@ -0,0 +1,84 @@
+fragment supervisionFields on SupervisionType {
+  id
+  rooms {
+    id
+    shortName
+    name
+  }
+  teachers {
+    id
+    fullName
+  }
+  subject {
+    id
+    name
+    colourFg
+    colourBg
+  }
+  breakSlot {
+    id
+    name
+    timeGrid {
+      id
+      group {
+        id
+        name
+      }
+      validityRange {
+        id
+        name
+        dateStart
+        dateEnd
+      }
+    }
+    weekday
+    timeStart
+    timeEnd
+    canEdit
+    canDelete
+  }
+  canEdit
+  canDelete
+}
+
+query supervisions($orderBy: [String], $filters: JSONString) {
+  items: supervisions(orderBy: $orderBy, filters: $filters) {
+    ...supervisionFields
+  }
+}
+
+mutation createSupervision($input: CreateSupervisionInput!) {
+  createSupervision(input: $input) {
+    item: supervision {
+      ...supervisionFields
+    }
+  }
+}
+
+mutation createSupervisions($input: [BatchCreateSupervisionInput]!) {
+  createSupervisions(input: $input) {
+    items: supervisions {
+      ...supervisionFields
+    }
+  }
+}
+
+mutation deleteSupervision($id: ID!) {
+  deleteSupervision(id: $id) {
+    ok
+  }
+}
+
+mutation deleteSupervisions($ids: [ID]!) {
+  deleteSupervisions(ids: $ids) {
+    deletionCount
+  }
+}
+
+mutation updateSupervisions($input: [BatchPatchSupervisionInput]!) {
+  batchMutation: updateSupervisions(input: $input) {
+    items: supervisions {
+      ...supervisionFields
+    }
+  }
+}
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigCRUDTable.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigCRUDTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..bf93ef62c6150e8610ad570b437a140f483d2b7c
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigCRUDTable.vue
@@ -0,0 +1,227 @@
+<script setup>
+import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue";
+import WeekDayField from "aleksis.core/components/generic/forms/WeekDayField.vue";
+import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
+import TimeField from "aleksis.core/components/generic/forms/TimeField.vue";
+import ValidityRangeField from "../validity_range/ValidityRangeField.vue";
+import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
+</script>
+
+<template>
+  <inline-c-r-u-d-list
+    :headers="headers"
+    :i18n-key="i18nKey"
+    :create-item-i18n-key="createItemI18nKey"
+    :gql-query="gqlQuery"
+    :gql-create-mutation="gqlCreateMutation"
+    :gql-patch-mutation="gqlPatchMutation"
+    :gql-delete-mutation="gqlDeleteMutation"
+    :default-item="defaultItem"
+    :get-create-data="getCreateData"
+    :get-patch-data="getPatchData"
+    filter
+  >
+    <template #course="{ item }">
+      {{ item.course.name }}
+      <subject-chip v-if="item.course.subject" :subject="item.course.subject" />
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #course.field="{ attrs, on }">
+      <v-autocomplete
+        :items="courses"
+        item-text="name"
+        item-value="id"
+        v-bind="attrs"
+        v-on="on"
+        return-object
+      />
+    </template>
+
+    <template #validityRange="{ item }">
+      {{ item.validityRange?.name }}
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #validityRange.field="{ attrs, on }">
+      <validity-range-field v-bind="attrs" v-on="on" :rules="required" />
+    </template>
+
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #lessonQuota.field="{ attrs, on }">
+      <positive-small-integer-field
+        v-bind="attrs"
+        v-on="on"
+        :rules="required"
+      />
+    </template>
+
+    <template #teachers="{ item }">
+      <v-chip v-for="teacher in item.teachers" :key="teacher.id">{{
+        teacher.fullName
+      }}</v-chip>
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #teachers.field="{ attrs, on }">
+      <v-autocomplete
+        multiple
+        :items="persons"
+        item-text="fullName"
+        item-value="id"
+        v-bind="attrs"
+        v-on="on"
+        chips
+        deletable-chips
+        return-object
+      >
+        <template #item="data">
+          <v-list-item-action>
+            <v-checkbox v-model="data.attrs.inputValue" />
+          </v-list-item-action>
+          <v-list-item-content>
+            <v-list-item-title>{{ data.item.fullName }}</v-list-item-title>
+            <v-list-item-subtitle v-if="data.item.shortName">{{
+              data.item.shortName
+            }}</v-list-item-subtitle>
+          </v-list-item-content>
+        </template>
+      </v-autocomplete>
+    </template>
+
+    <template #filters="{ attrs, on }">
+      <week-day-field
+        v-bind="attrs('weekday')"
+        v-on="on('weekday')"
+        return-int
+        clearable
+        :label="$t('lesrooster.slot.weekday')"
+      />
+
+      <v-row>
+        <v-col>
+          <positive-small-integer-field
+            v-bind="attrs('period__gte')"
+            v-on="on('period__gte')"
+            :label="$t('lesrooster.slot.period_gte')"
+          />
+        </v-col>
+
+        <v-col>
+          <positive-small-integer-field
+            v-bind="attrs('period__lte')"
+            v-on="on('period__lte')"
+            :label="$t('lesrooster.slot.period_lte')"
+          />
+        </v-col>
+      </v-row>
+
+      <v-row>
+        <v-col>
+          <time-field
+            v-bind="attrs('time_end__gte')"
+            v-on="on('time_end__gte')"
+            :label="$t('school_term.after')"
+          />
+        </v-col>
+        <v-col>
+          <time-field
+            v-bind="attrs('time_start__lte')"
+            v-on="on('time_start__lte')"
+            :label="$t('school_term.before')"
+          />
+        </v-col>
+      </v-row>
+    </template>
+  </inline-c-r-u-d-list>
+</template>
+
+<script>
+import {
+  timeboundCourseConfigs,
+  createTimeboundCourseConfig,
+  deleteTimeboundCourseConfig,
+  updateTimeboundCourseConfigs,
+} from "./timeboundCourseConfig.graphql";
+
+import { currentValidityRange as gqlCurrentValidityRange } from "../validity_range/validityRange.graphql";
+
+import { gqlPersons, gqlCourses } from "../helper.graphql";
+
+export default {
+  name: "TimeboungCourseConfigCRUDTable",
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t("lesrooster.timebound_course_config.course"),
+          value: "course",
+        },
+        {
+          text: this.$t("lesrooster.validity_range.title"),
+          value: "validityRange",
+          orderKey: "validity_range__date_start",
+        },
+        {
+          text: this.$t("lesrooster.timebound_course_config.teachers"),
+          value: "teachers",
+        },
+        {
+          text: this.$t("lesrooster.timebound_course_config.lesson_quota"),
+          value: "lessonQuota",
+        },
+      ],
+      i18nKey: "lesrooster.timebound_course_config",
+      createItemI18nKey:
+        "lesrooster.timebound_course_config.create_timebound_course_config",
+      gqlQuery: timeboundCourseConfigs,
+      gqlCreateMutation: createTimeboundCourseConfig,
+      gqlPatchMutation: updateTimeboundCourseConfigs,
+      gqlDeleteMutation: deleteTimeboundCourseConfig,
+      defaultItem: {
+        course: {
+          id: "",
+          name: "",
+        },
+        validityRange: {
+          id: "",
+          name: "",
+        },
+        teachers: [],
+        lessonQuota: undefined,
+      },
+      required: [(value) => !!value || this.$t("forms.errors.required")],
+    };
+  },
+  methods: {
+    getCreateData(item) {
+      console.log("in getCreateData", item);
+      return {
+        ...item,
+        course: item.course.id,
+        teachers: item.teachers.map((t) => t.id),
+        validityRange: item.validityRange.id,
+      };
+    },
+    getPatchData(items) {
+      console.log("patch items", items);
+      return items.map((item) => ({
+        id: item.id,
+        course: item.course.id,
+        teachers: item.teachers.map((t) => t.id),
+        validityRange: item.validityRange.id,
+        lessonQuota: item.lessonQuota,
+      }));
+    },
+  },
+  apollo: {
+    currentValidityRange: {
+      query: gqlCurrentValidityRange,
+      result({ data }) {
+        this.$set(this.defaultItem, "validityRange", data.currentValidityRange);
+      },
+    },
+    persons: gqlPersons,
+    courses: gqlCourses,
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2c5bdca9de543eacbeaf89900f380e3f8d353c9c
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -0,0 +1,507 @@
+<script setup>
+import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
+import SaveButton from "aleksis.core/components/generic/buttons/SaveButton.vue";
+import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue";
+import ValidityRangeField from "../validity_range/ValidityRangeField.vue";
+import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
+</script>
+
+<template>
+  <div>
+    <v-data-table
+      disable-sort
+      disable-filtering
+      disable-pagination
+      hide-default-footer
+      :headers="headers"
+      :items="tableItems"
+    >
+      <template #top>
+        <v-row>
+          <v-col
+            cols="6"
+            lg="3"
+            class="d-flex justify-space-between flex-wrap align-center"
+          >
+            <v-autocomplete
+              outlined
+              filled
+              multiple
+              hide-details
+              :items="groups"
+              item-text="shortName"
+              item-value="id"
+              return-object
+              :disabled="$apollo.queries.groups.loading"
+              :label="$t('lesrooster.timebound_course_config.groups')"
+              :loading="$apollo.queries.groups.loading"
+              v-model="selectedGroups"
+              class="mr-4"
+            />
+          </v-col>
+
+          <v-col
+            cols="6"
+            lg="3"
+            class="d-flex justify-space-between flex-wrap align-center"
+          >
+            <validity-range-field
+              outlined
+              filled
+              label="Select Validity Range"
+              hide-details
+              v-model="internalValidityRange"
+              :loading="$apollo.queries.currentValidityRange.loading"
+            />
+          </v-col>
+
+          <v-spacer />
+
+          <v-col
+            cols="8"
+            lg="3"
+            class="d-flex justify-space-between flex-wrap align-center"
+          >
+            <secondary-action-button
+              i18n-key="actions.copy_last_configuration"
+              block
+              class="mr-4"
+            />
+          </v-col>
+          <v-col
+            cols="4"
+            lg="1"
+            class="d-flex justify-space-between flex-wrap align-center"
+          >
+            <save-button
+              :disabled="
+                !editedCourseConfigs.length &&
+                !createdCourseConfigs.length &&
+                !createdCourses.length
+              "
+              @click="save"
+            />
+          </v-col>
+        </v-row>
+      </template>
+
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #item.subject="{ item, value }">
+        <subject-chip v-if="value" :subject="value" />
+      </template>
+
+      <template
+        v-for="(groupHeader, index) in groupHeaders"
+        #[tableItemSlotName(groupHeader)]="{ item, value, header }"
+      >
+        <div :key="index">
+          <div v-if="value.length">
+            <v-row
+              v-for="(course, index) in value"
+              :key="index"
+              no-gutters
+              class="mt-2"
+            >
+              <v-col cols="6">
+                <positive-small-integer-field
+                  dense
+                  filled
+                  class="mx-1"
+                  :disabled="loading"
+                  :value="
+                    getCurrentCourseConfig(course)
+                      ? getCurrentCourseConfig(course).lessonQuota
+                      : course.lessonQuota
+                  "
+                  :label="$t('lesrooster.timebound_course_config.lesson_quota')"
+                  @input="
+                    (event) =>
+                      setCourseConfigData(course, item.subject, header, {
+                        lessonQuota: event,
+                      })
+                  "
+                />
+              </v-col>
+              <v-col cols="6">
+                <v-autocomplete
+                  counter
+                  dense
+                  filled
+                  multiple
+                  :items="getTeacherList(item.subject.teachers)"
+                  item-text="fullName"
+                  item-value="id"
+                  class="mx-1"
+                  :disabled="loading"
+                  :label="$t('lesrooster.timebound_course_config.teachers')"
+                  :value="
+                    getCurrentCourseConfig(course)
+                      ? getCurrentCourseConfig(course).teachers
+                      : course.teachers
+                  "
+                  @input="
+                    (event) =>
+                      setCourseConfigData(course, item.subject, header, {
+                        teachers: event,
+                      })
+                  "
+                >
+                  <template #item="data">
+                    <template v-if="typeof data.item !== 'object'">
+                      <v-list-item-content>{{ data.item }}</v-list-item-content>
+                    </template>
+                    <template v-else>
+                      <v-list-item-action>
+                        <v-checkbox v-model="data.attrs.inputValue" />
+                      </v-list-item-action>
+                      <v-list-item-content>
+                        <v-list-item-title>{{
+                          data.item.fullName
+                        }}</v-list-item-title>
+                        <v-list-item-subtitle v-if="data.item.shortName">{{
+                          data.item.shortName
+                        }}</v-list-item-subtitle>
+                      </v-list-item-content>
+                    </template>
+                  </template>
+                </v-autocomplete>
+              </v-col>
+            </v-row>
+          </div>
+          <div v-if="!value.length">
+            <v-btn
+              block
+              icon
+              tile
+              outlined
+              @click="addCourse(item.subject.id, header.value)"
+            >
+              <v-icon>mdi-plus</v-icon>
+            </v-btn>
+          </div>
+        </div>
+      </template>
+    </v-data-table>
+  </div>
+</template>
+
+<script>
+import {
+  subjects,
+  batchCreateTimeboundCourseConfig,
+  updateTimeboundCourseConfigs,
+} from "./timeboundCourseConfig.graphql";
+
+import { currentValidityRange as gqlCurrentValidityRange } from "../validity_range/validityRange.graphql";
+
+import { gqlGroups, gqlTeachers } from "../helper.graphql";
+
+import { batchCreateCourse } from "aleksis.apps.cursus/components/course.graphql";
+
+export default {
+  name: "TimeboungCourseConfigRaster",
+  data() {
+    return {
+      i18nKey: "lesrooster.timebound_course_config",
+      createItemI18nKey:
+        "lesrooster.timebound_course_config.create_timebound_course_config",
+      defaultItem: {
+        course: {
+          id: "",
+          name: "",
+        },
+        validityRange: {
+          id: "",
+          name: "",
+        },
+        teachers: [],
+        lessonQuota: undefined,
+      },
+      required: [(value) => !!value || this.$t("forms.errors.required")],
+      internalValidityRange: null,
+      groups: [],
+      selectedGroups: [],
+      subjects: [],
+      editedCourseConfigs: [],
+      createdCourseConfigs: [],
+      newCourses: [],
+      createdCourses: [],
+      currentCourse: null,
+      currentSubject: null,
+      loading: false,
+    };
+  },
+  methods: {
+    tableItemSlotName(header) {
+      return "item." + header.value;
+    },
+    getCurrentCourseConfig(course) {
+      if (course.lrTimeboundCourseConfigs?.length) {
+        let currentCourseConfigs = course.lrTimeboundCourseConfigs.filter(
+          (timeboundConfig) =>
+            timeboundConfig.validityRange.id === this.internalValidityRange.id
+        );
+        if (currentCourseConfigs.length) {
+          return currentCourseConfigs[0];
+        } else {
+          return null;
+        }
+      } else {
+        return null;
+      }
+    },
+    setCourseConfigData(course, subject, header, newValue) {
+      if (course.newCourse) {
+        let existingCreatedCourse = this.createdCourses.find(
+          (c) =>
+            c.subject === subject.id &&
+            JSON.stringify(c.groups) === header.value
+        );
+        if (!existingCreatedCourse) {
+          this.createdCourses.push({
+            subject: subject.id,
+            groups: JSON.parse(header.value),
+            name: `${header.text}-${subject.name}`,
+            ...newValue,
+          });
+        } else {
+          Object.assign(existingCreatedCourse, newValue);
+        }
+      } else {
+        if (
+          !course.lrTimeboundCourseConfigs.filter(
+            (c) => c.validityRange.id === this.internalValidityRange?.id
+          ).length
+        ) {
+          let existingCreatedCourseConfig = this.createdCourseConfigs.find(
+            (c) =>
+              c.course === course.id &&
+              c.validityRange === this.internalValidityRange?.id
+          );
+          if (!existingCreatedCourseConfig) {
+            this.createdCourseConfigs.push({
+              course: course.id,
+              validityRange: this.internalValidityRange?.id,
+              teachers: course.teachers.map((t) => t.id),
+              lessonQuota: course.lessonQuota,
+              ...newValue,
+            });
+          } else {
+            Object.assign(existingCreatedCourseConfig, newValue);
+          }
+        } else {
+          let courseConfigID = course.lrTimeboundCourseConfigs[0].id;
+          let existingEditedCourseConfig = this.editedCourseConfigs.find(
+            (c) => c.id === courseConfigID
+          );
+          if (!existingEditedCourseConfig) {
+            this.editedCourseConfigs.push({ id: courseConfigID, ...newValue });
+          } else {
+            Object.assign(existingEditedCourseConfig, newValue);
+          }
+        }
+      }
+    },
+    save() {
+      this.loading = true;
+
+      for (let mutationCombination of [
+        {
+          data: this.editedCourseConfigs,
+          mutation: updateTimeboundCourseConfigs,
+        },
+        {
+          data: this.createdCourseConfigs,
+          mutation: batchCreateTimeboundCourseConfig,
+        },
+        {
+          data: this.createdCourses,
+          mutation: batchCreateCourse,
+        },
+      ]) {
+        if (mutationCombination.data.length) {
+          this.$apollo
+            .mutate({
+              mutation: mutationCombination.mutation,
+              variables: {
+                input: mutationCombination.data,
+              },
+            })
+            .catch(() => {}); // FIXME Error Handling
+        }
+      }
+
+      this.editedCourseConfigs = [];
+      this.createdCourseConfigs = [];
+      this.createdCourses = [];
+      this.$apollo.queries.subjects.refetch();
+      this.loading = false;
+    },
+    getTeacherList(subjectTeachers) {
+      return [
+        {
+          header: this.$t(
+            "lesrooster.timebound_course_config.subject_teachers"
+          ),
+        },
+        ...this.persons.filter((person) =>
+          subjectTeachers.find((teacher) => teacher.id === person.id)
+        ),
+        { divider: true },
+        { header: this.$t("lesrooster.timebound_course_config.all_teachers") },
+        ...this.persons.filter(
+          (person) =>
+            !subjectTeachers.find((teacher) => teacher.id === person.id)
+        ),
+      ];
+    },
+    addCourse(subject, groups) {
+      let courseSubjectGroup = this.newCourses.find(
+        (courseSubject) => courseSubject.subject === subject
+      );
+      if (courseSubjectGroup) {
+        if (courseSubjectGroup.groupCombinations) {
+          this.$set(courseSubjectGroup.groupCombinations, groups, [
+            { teachers: [], newCourse: true },
+          ]);
+        } else {
+          courseSubjectGroup.groupCombinations = {
+            [groups]: [{ teachers: [], newCourse: true }],
+          };
+        }
+      } else {
+        this.newCourses.push({
+          subject: subject,
+          groupCombinations: { [groups]: [{ teachers: [], newCourse: true }] },
+        });
+      }
+    },
+  },
+  computed: {
+    groupIDList() {
+      return this.selectedGroups.map((group) => group.id);
+    },
+    subjectGroupCombinations() {
+      return [].concat.apply(
+        [],
+        this.items.map((subject) => Object.keys(subject.groupCombinations))
+      );
+    },
+    groupHeaders() {
+      return this.selectedGroups
+        .map((group) => ({
+          text: group.shortName,
+          value: JSON.stringify([group.id]),
+        }))
+        .concat(
+          this.subjectGroupCombinations.map((combination) => {
+            let parsedCombination = JSON.parse(combination);
+            return {
+              text: parsedCombination
+                .map(
+                  (groupID) =>
+                    this.groups.find((group) => group.id === groupID).shortName
+                )
+                .join(", "),
+              value: combination,
+            };
+          })
+        )
+        .filter(
+          (obj, index, self) =>
+            index === self.findIndex((o) => o.value === obj.value)
+        );
+    },
+    headers() {
+      let groupHeadersWithWidth = this.groupHeaders.map((header) => ({
+        ...header,
+        width: `${Math.max(95 / this.groupHeaders.length, 15)}vw`,
+      }));
+      return [
+        {
+          text: this.$t("lesrooster.timebound_course_config.subject"),
+          value: "subject",
+          width: "5%",
+        },
+      ].concat(groupHeadersWithWidth);
+    },
+    items() {
+      return this.subjects.map((subject) => {
+        let groupCombinations = {};
+
+        subject.courses.forEach((course) => {
+          let groupIds = JSON.stringify(
+            course.groups.map((group) => group.id).sort()
+          );
+
+          if (!groupCombinations[groupIds]) {
+            groupCombinations[groupIds] = [];
+          }
+
+          if (!groupCombinations[groupIds].find((c) => c.id === course.id)) {
+            groupCombinations[groupIds].push({
+              ...course,
+            });
+          }
+        });
+
+        subject = {
+          ...subject,
+          groupCombinations: { ...groupCombinations },
+          newCourses: {
+            ...this.newCourses.find(
+              (courseSubject) => courseSubject.subject === subject.id
+            )?.groupCombinations,
+          },
+        };
+
+        return subject;
+      });
+    },
+    tableItems() {
+      return this.items.map((subject) => {
+        // eslint-disable-next-line no-unused-vars
+        let { courses, groupCombinations, ...reducedSubject } = subject;
+        return {
+          subject: reducedSubject,
+          ...Object.fromEntries(
+            this.groupHeaders.map((header) => [header.value, []])
+          ),
+          ...subject.groupCombinations,
+          ...subject.newCourses,
+        };
+      });
+    },
+  },
+  apollo: {
+    currentValidityRange: {
+      query: gqlCurrentValidityRange,
+      result(data) {
+        this.internalValidityRange = data.data.currentValidityRange;
+      },
+    },
+    groups: {
+      query: gqlGroups,
+      result(data) {
+        this.selectedGroups = data.data.groups;
+      },
+    },
+    subjects: {
+      query: subjects,
+      skip() {
+        return !this.groupIDList.length;
+      },
+      variables() {
+        return {
+          groups: this.groupIDList,
+        };
+      },
+    },
+    persons: {
+      query: gqlTeachers,
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..9ede745ed2fff96245478ed7871745a751cadf58
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
@@ -0,0 +1,126 @@
+fragment subjectFields on LesroosterExtendedSubjectType {
+  id
+  shortName
+  name
+  colourFg
+  colourBg
+  teachers {
+    id
+    fullName
+    shortName
+  }
+}
+
+fragment courseFields on LesroosterExtendedCourseType {
+  id
+  name
+  teachers {
+    id
+    fullName
+    shortName
+  }
+  groups {
+    id
+    name
+    shortName
+  }
+  lessonQuota
+}
+
+fragment timeboundCourseConfigFields on TimeboundCourseConfigType {
+  id
+  validityRange {
+    id
+    name
+  }
+  lessonQuota
+  teachers {
+    id
+    fullName
+    shortName
+  }
+  canEdit
+  canDelete
+}
+
+query timeboundCourseConfigs($orderBy: [String], $filters: JSONString) {
+  items: timeboundCourseConfigs(orderBy: $orderBy, filters: $filters) {
+    ...timeboundCourseConfigFields
+    course {
+      ...courseFields
+      subject {
+        ...subjectFields
+      }
+    }
+  }
+}
+
+mutation createTimeboundCourseConfig(
+  $input: CreateTimeboundCourseConfigInput!
+) {
+  createTimeboundCourseConfig(input: $input) {
+    item: timeboundCourseConfig {
+      ...timeboundCourseConfigFields
+      course {
+        ...courseFields
+        subject {
+          ...subjectFields
+        }
+      }
+    }
+  }
+}
+
+mutation batchCreateTimeboundCourseConfig(
+  $input: [BatchCreateTimeboundCourseConfigInput]!
+) {
+  batchCreateTimeboundCourseConfig(input: $input) {
+    item: timeboundCourseConfigs {
+      ...timeboundCourseConfigFields
+      course {
+        ...courseFields
+        subject {
+          ...subjectFields
+        }
+      }
+    }
+  }
+}
+
+mutation deleteTimeboundCourseConfig($id: ID!) {
+  deleteTimeboundCourseConfig(id: $id) {
+    ok
+  }
+}
+
+mutation updateTimeboundCourseConfigs(
+  $input: [BatchPatchTimeboundCourseConfigInput]!
+) {
+  batchMutation: updateTimeboundCourseConfigs(input: $input) {
+    items: timeboundCourseConfigs {
+      ...timeboundCourseConfigFields
+      course {
+        ...courseFields
+        subject {
+          ...subjectFields
+        }
+      }
+    }
+  }
+}
+
+query subjects($orderBy: [String], $filters: JSONString, $groups: [ID]) {
+  subjects: lesroosterExtendedSubjects(
+    orderBy: $orderBy
+    filters: $filters
+    groups: $groups
+  ) {
+    ...subjectFields
+    courses {
+      ...courseFields
+      lrTimeboundCourseConfigs {
+        ...timeboundCourseConfigFields
+      }
+    }
+  }
+}
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/BlockingCard.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/BlockingCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c2688cc4000d5912e231eab572393ca9d0b87566
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/BlockingCard.vue
@@ -0,0 +1,24 @@
+<script>
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  name: "BlockingCard",
+});
+</script>
+
+<template>
+  <v-card
+    height="100%"
+    class="non-important-flex align-center justify-center"
+    flat
+  >
+    <v-icon color="error" large>mdi-close</v-icon>
+    <v-overlay absolute color="error" :value="true" opacity="0.12" />
+  </v-card>
+</template>
+
+<style scoped>
+.non-important-flex {
+  display: flex;
+}
+</style>
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/LessonCard.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/LessonCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1efb27a40e0a0e0497ba73da30bce833cbaabfa9
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/LessonCard.vue
@@ -0,0 +1,161 @@
+<script>
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  name: "LessonCard",
+  extends: "v-card",
+  props: {
+    lesson: {
+      type: Object,
+      required: true,
+    },
+  },
+  computed: {
+    subject() {
+      return (
+        this.lesson.subject || {
+          name: "",
+          colourFg: "#000000",
+          colourBg: "#e6e6e6",
+        }
+      );
+    },
+    teachers() {
+      return this.lesson.teachers;
+    },
+    groups() {
+      return this.lesson.groups;
+    },
+    color() {
+      return this.subject.colourFg;
+    },
+    background() {
+      return this.subject.colourBg;
+    },
+    loading() {
+      return (
+        this.lesson.isOptimistic ||
+        this.lesson.id.toString().startsWith("temporary")
+      );
+    },
+  },
+  methods: {
+    firstNonEmpty(...arrays) {
+      return (
+        arrays.find((array) => Array.isArray(array) && array.length > 0) || []
+      );
+    },
+  },
+});
+</script>
+
+<template>
+  <v-card
+    :color="background"
+    :disabled="loading"
+    class="color no-select h-100 fill-height d-flex align-center justify-center pa-0 width-title"
+    v-bind="$attrs"
+    v-on="$listeners"
+  >
+    <div v-if="!loading" class="d-flex flex-column align-center my-1">
+      <v-card-title
+        class="color d-flex justify-center flex-wrap px-3 py-0 ma-0"
+      >
+        <span>
+          <v-tooltip bottom tag="span" class="hidden-when-large">
+            <template #activator="{ on, attrs }">
+              <span v-bind="attrs" v-on="on" class="hidden-when-large">
+                {{ subject.shortName }}
+              </span>
+            </template>
+            <span>{{
+              "course" in lesson ? lesson.course.name : lesson.name
+            }}</span>
+          </v-tooltip>
+          <span class="hidden-when-small">{{ subject.name }}</span>
+        </span>
+
+        <v-card-subtitle
+          class="caption px-3 py-0 ma-0 text-center hidden-when-small"
+        >
+          {{ "course" in lesson ? lesson.course.name : lesson.name }}
+        </v-card-subtitle>
+      </v-card-title>
+      <v-card-subtitle class="color pa-0 ma-0 d-flex flex-wrap justify-center">
+        <span v-for="(teacher, index) in teachers" :key="teacher.id">
+          <v-tooltip bottom>
+            <template #activator="{ on, attrs }">
+              <v-btn
+                text
+                :color="color"
+                rounded
+                small
+                v-bind="attrs"
+                v-on="on"
+                @click="$emit('click:teacher', teacher)"
+              >
+                {{ teacher.shortName }}
+              </v-btn>
+            </template>
+            <span>{{ teacher.fullName }}</span>
+          </v-tooltip>
+        </span>
+        <span v-for="(room, index) in lesson.rooms" :key="room.id">
+          <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
+          <span v-if="index !== 0">, </span>
+          <v-tooltip bottom>
+            <template #activator="{ on, attrs }">
+              <v-btn
+                text
+                :color="color"
+                rounded
+                small
+                v-bind="attrs"
+                v-on="on"
+                @click="$emit('click:room', room)"
+              >
+                {{ room.shortName }}
+              </v-btn>
+            </template>
+            <span>{{ room.name }}</span>
+          </v-tooltip>
+        </span>
+      </v-card-subtitle>
+      <slot />
+    </div>
+    <div v-if="loading" class="text-center">
+      <v-progress-circular :color="color" indeterminate />
+    </div>
+  </v-card>
+</template>
+
+<style scoped>
+.width-title {
+  container: title/inline-size;
+}
+
+.hidden-when-small {
+  display: none;
+}
+.hidden-when-large {
+  display: inline;
+}
+
+@container title (width > 150px) {
+  .hidden-when-small {
+    display: inline;
+  }
+
+  .hidden-when-large {
+    display: none;
+  }
+}
+
+.color {
+  color: v-bind(color);
+}
+
+.no-select {
+  user-select: none;
+}
+</style>
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/LessonRatioChip.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/LessonRatioChip.vue
new file mode 100644
index 0000000000000000000000000000000000000000..657855db1c61d28ecbea4d90fc44b90ca8498820
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/LessonRatioChip.vue
@@ -0,0 +1,43 @@
+<script>
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  name: "LessonRatioChip",
+  props: {
+    course: {
+      type: Object,
+      required: true,
+    },
+  },
+});
+</script>
+
+<template>
+  <v-chip
+    class="text-body-1 font-weight-500 px-4 mb-1"
+    small
+    color="white"
+    light
+  >
+    <v-icon
+      v-if="course.lessonsUsed === course.lessonQuota"
+      color="green"
+      left
+      size="20px"
+    >
+      mdi-check-circle
+    </v-icon>
+    <v-icon
+      v-else-if="course.lessonsUsed < course.lessonQuota"
+      color="orange"
+      left
+      size="20px"
+    >
+      mdi-timer-sand-empty
+    </v-icon>
+    <v-icon v-else color="red" left size="20px"> mdi-alert </v-icon>
+    {{ $t("lesrooster.timetable_management.lessons_used_ratio", course) }}
+  </v-chip>
+</template>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/PeriodCard.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/PeriodCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b76ef7a9c1f7e5851b8b30f7c4cdb9c0be088bce
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/PeriodCard.vue
@@ -0,0 +1,98 @@
+<template>
+  <v-card class="d-flex justify-space-between align-center">
+    <v-card-title>{{ period }}</v-card-title>
+    <div class="ma-0 py-4"><br /><br /></div>
+    <v-card-subtitle
+      class="ma-0 pa-4 subtitle text-right"
+      v-if="timeRanges.length < 2"
+    >
+      {{ getTimeRangesByWeekdaysString(timeRanges?.[0]) }}
+    </v-card-subtitle>
+    <v-menu v-if="timeRanges.length > 1" offset-x>
+      <template #activator="{ attrs, on }">
+        <v-btn icon color="info" v-bind="attrs" v-on="on">
+          <v-icon>$info</v-icon>
+        </v-btn>
+      </template>
+
+      <v-list>
+        <v-list-item v-for="(timeRange, index) in timeRanges" :key="index">
+          {{ getTimeRangesByWeekdaysString(timeRange) }}
+        </v-list-item>
+      </v-list>
+    </v-menu>
+  </v-card>
+</template>
+
+<script>
+export default {
+  name: "PeriodCard",
+  props: {
+    period: {
+      type: Number,
+      required: true,
+    },
+    weekdays: {
+      type: Array,
+      required: true,
+    },
+    timeRanges: {
+      type: Array,
+      required: true,
+    },
+  },
+  methods: {
+    getOutermostItems(arr) {
+      const result = [];
+
+      // Convert the input array into an array of numbers
+      const numbers = arr.map((item) => parseInt(item.slice(2), 10));
+
+      let startIndex = 0;
+
+      for (let i = 1; i < numbers.length; i++) {
+        if (numbers[i] !== numbers[i - 1] + 1) {
+          result.push(arr.slice(startIndex, i));
+          startIndex = i;
+        }
+      }
+
+      // Push the last subarray
+      result.push(arr.slice(startIndex));
+
+      return result.map((array) =>
+        array.length < 3 ? array : [array[0], array[array.length - 1]]
+      );
+    },
+    getTimeRangesByWeekdaysString(timeRange) {
+      return (
+        (timeRange.weekdays.length === this.weekdays.length
+          ? ""
+          : this.getOutermostItems(timeRange.weekdays)
+              .map(
+                (weekdays) =>
+                  weekdays
+                    .map((weekday) => this.$t("weekdays_short." + weekday))
+                    .join("‑") // Non-breaking hyphen (U+02011)
+              )
+              .join(", ") + ": ") +
+        this.$d(
+          new Date("1970-01-01T" + timeRange.timeStart),
+          "shortTime"
+        ).replace(" ", " ") +
+        (timeRange.weekdays.length === this.weekdays.length ? " " : "‑") + // Non-breaking hyphen (U+02011)
+        this.$d(
+          new Date("1970-01-01T" + timeRange.timeEnd),
+          "shortTime"
+        ).replace(" ", " ")
+      );
+    },
+  },
+};
+</script>
+
+<style scoped>
+.subtitle {
+  width: min-content;
+}
+</style>
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b3d4a3c635299a10bcea9722db76947f53a024b8
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue
@@ -0,0 +1,1149 @@
+<script>
+import { defineComponent } from "vue";
+import {
+  courses,
+  createLesson,
+  deleteLesson,
+  gqlGroups,
+  lessonObjects,
+  moveLesson,
+  updateLesson,
+} from "./timetableManagement.graphql";
+import { gqlTeachers } from "../helper.graphql";
+import { timeGrids } from "../validity_range/validityRange.graphql";
+import { slots } from "../breaks_and_slots/slot.graphql";
+import { rooms } from "aleksis.core/components/room/room.graphql";
+import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue";
+import DeleteDialog from "aleksis.core/components/generic/dialogs/DeleteDialog.vue";
+import DialogObjectForm from "aleksis.core/components/generic/dialogs/DialogObjectForm.vue";
+import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue";
+import SubjectField from "aleksis.apps.cursus/components/SubjectField.vue";
+import LessonCard from "./LessonCard.vue";
+
+import { RRule } from "rrule";
+import TeacherTimeTable from "./timetables/TeacherTimeTable.vue";
+import RoomTimeTable from "./timetables/RoomTimeTable.vue";
+import LessonRatioChip from "./LessonRatioChip.vue";
+import TimeGridField from "../validity_range/TimeGridField.vue";
+import BlockingCard from "./BlockingCard.vue";
+import PeriodCard from "./PeriodCard.vue";
+
+export default defineComponent({
+  name: "TimetableManagement",
+  components: {
+    PeriodCard,
+    BlockingCard,
+    TimeGridField,
+    SubjectField,
+    DialogObjectForm,
+    LessonRatioChip,
+    MobileFullscreenDialog,
+    RoomTimeTable,
+    TeacherTimeTable,
+    DeleteDialog,
+    LessonCard,
+    SecondaryActionButton,
+  },
+  data() {
+    return {
+      weekdays: [],
+      periods: [],
+      slotsByPeriods: [],
+      internalTimeGrid: null,
+      courseSearch: null,
+      lessonsUsed: {},
+      lessonQuotaTotal: 0,
+      deleteMutation: deleteLesson,
+      deleteDialog: false,
+      itemToDelete: null,
+      selectedObject: null,
+      selectedObjectType: null,
+      selectedObjectTitle: "",
+      selectedObjectDialogOpen: false,
+      selectedObjectDialogTab: null,
+      groups: [],
+      selectedGroup: null,
+      lessonEdit: {
+        open: false,
+        id: null,
+        object: {},
+        fields: [
+          {
+            text: this.$t(
+              "lesrooster.timetable_management.lesson_fields.subject"
+            ),
+            value: "subject",
+          },
+          {
+            text: this.$t(
+              "lesrooster.timetable_management.lesson_fields.teachers"
+            ),
+            value: "teachers",
+          },
+          {
+            text: this.$t(
+              "lesrooster.timetable_management.lesson_fields.rooms"
+            ),
+            value: "rooms",
+          },
+        ],
+        mutation: updateLesson,
+      },
+    };
+  },
+  apollo: {
+    groups: {
+      query: gqlGroups,
+      variables() {
+        return {
+          timeGrid: this.internalTimeGrid.id,
+        };
+      },
+      skip() {
+        return this.internalTimeGrid === null;
+      },
+      result() {
+        if (!this.selectedGroup && this.$route.params.id && this.groups) {
+          this.selectedGroup = this.groups.find(
+            (group) => group.id === this.$route.params.id
+          );
+        }
+      },
+    },
+    slots: {
+      query: slots,
+      variables() {
+        return {
+          filters: JSON.stringify({
+            time_grid: this.internalTimeGrid.id,
+          }),
+        };
+      },
+      skip() {
+        return !this.readyForQueries;
+      },
+      update: (data) => data.items,
+      result({ data: { items } }) {
+        this.weekdays = Array.from(
+          new Set(
+            items
+              .filter((slot) => slot.model === "Slot")
+              .map((slot) => slot.weekday)
+          )
+        );
+        this.periods = Array.from(
+          new Set(
+            items
+              .filter((slot) => slot.model === "Slot")
+              .map((slot) => slot.period)
+          )
+        );
+        this.slotsByPeriods = this.periods.map((period) => ({
+          period: period,
+          slots: items.filter(
+            (slot) => slot.model === "Slot" && slot.period === period
+          ),
+        }));
+      },
+    },
+    timeGrids: {
+      query: timeGrids,
+      update: (data) => data.items,
+      variables() {
+        return {
+          filters: JSON.stringify({
+            validity_range: this.internalTimeGrid.validityRange.id,
+          }),
+        };
+      },
+      skip() {
+        return !this.internalTimeGrid;
+      },
+    },
+    courses: {
+      query: courses,
+      variables() {
+        return {
+          timeGrid: this.internalTimeGrid.id,
+          group: this.selectedGroup.id,
+        };
+      },
+      skip() {
+        return !this.readyForQueries;
+      },
+      result({ data }) {
+        this.lessonQuotaTotal =
+          data && data.courses
+            ? data.courses.reduce(
+                (accumulator, course) => accumulator + course.lessonQuota,
+                0
+              )
+            : 0;
+      },
+    },
+    lessonObjects: {
+      query: lessonObjects,
+      variables() {
+        return {
+          timeGrid: this.internalTimeGrid.id,
+          group: this.selectedGroup.id,
+        };
+      },
+      skip() {
+        return !this.readyForQueries;
+      },
+      result({ data: { lessonObjects } }) {
+        this.lessonsUsed = {};
+        lessonObjects.forEach((lesson) => {
+          let increment =
+            this.periods.indexOf(lesson.slotEnd.period) -
+            this.periods.indexOf(lesson.slotStart.period) +
+            1;
+          this.lessonsUsed[lesson.course.id] =
+            this.lessonsUsed[lesson.course.id] + increment || increment;
+        });
+      },
+    },
+    persons: {
+      query: gqlTeachers,
+    },
+    rooms: {
+      query: rooms,
+      update: (data) => data.items,
+    },
+  },
+  computed: {
+    readyForQueries() {
+      return this.internalTimeGrid !== null && this.selectedGroup !== null;
+    },
+    lessons() {
+      return this.lessonObjects
+        ? this.lessonObjects.map((lesson) => ({
+            x: this.weekdays.indexOf(lesson.slotStart.weekday) + 1,
+            y: this.periods.indexOf(lesson.slotStart.period) + 1,
+            w:
+              this.weekdays.indexOf(lesson.slotEnd.weekday) -
+              this.weekdays.indexOf(lesson.slotStart.weekday) +
+              1,
+            h:
+              this.periods.indexOf(lesson.slotEnd.period) -
+              this.periods.indexOf(lesson.slotStart.period) +
+              1,
+            key: "lesson-" + lesson.id,
+            disabled: !lesson.canEdit,
+            data: lesson,
+          }))
+        : [];
+    },
+    gridItems() {
+      // As we may want to display more in the future
+      return this.lessons;
+    },
+    gridLoading() {
+      return (
+        this.$apollo.queries.slots.loading ||
+        this.$apollo.queries.lessonObjects.loading ||
+        this.$apollo.queries.groups.loading
+      );
+    },
+    selectableCourses() {
+      return this.courses
+        ? this.courses.map((course) => ({
+            x: "0",
+            y: "0",
+            w: 1,
+            h: 1,
+            key: "course-" + course.courseId,
+            data: {
+              ...course,
+              lessonsUsed: this.lessonsUsed[course.courseId] || 0,
+              lessonRatio:
+                (this.lessonsUsed[course.courseId] || 0) / course.lessonQuota,
+            },
+          }))
+        : [];
+    },
+    disabledSlots() {
+      // Disable all fields in the grid where no slot exists
+      return this.periods
+        .map((period, indexY) =>
+          this.weekdays.map((weekday, indexX) =>
+            this.slots.filter(
+              (slot) =>
+                slot.model === "Slot" &&
+                slot.weekday === weekday &&
+                slot.period === period
+            ).length === 0
+              ? {
+                  x: indexX + 1,
+                  y: indexY + 1,
+                }
+              : undefined
+          )
+        )
+        .flat()
+        .filter((val) => val !== undefined);
+    },
+    totalLessonRatio() {
+      return this.$t(
+        "lesrooster.timetable_management.lessons_used_ratio_total",
+        {
+          lessonsUsed: Object.values(this.lessonsUsed).reduce(
+            (a, b) => a + b,
+            0
+          ),
+          lessonQuota: this.lessonQuotaTotal,
+        }
+      );
+    },
+  },
+  watch: {
+    selectedGroup() {
+      if (!this.selectedGroup) return;
+      if (this.selectedGroup.id != this.$route.params.id) {
+        this.$router.push({
+          name: "lesrooster.timetable_management",
+          params: { id: this.selectedGroup.id },
+        });
+      }
+      this.$setToolBarTitle(
+        this.$t("lesrooster.timetable_management.for_group", {
+          group: this.selectedGroup.name,
+        })
+      );
+      this.$apollo.queries.courses.refetch();
+      this.$apollo.queries.lessonObjects.refetch();
+    },
+  },
+  methods: {
+    itemMovedToLessons(eventData) {
+      let newStartSlotId = this.slots.filter(
+        (slot) =>
+          slot.period === this.periods[eventData.y - 1] &&
+          slot.weekday === this.weekdays[eventData.x - 1]
+      );
+      let newEndSlotId = this.slots.filter(
+        (slot) =>
+          slot.period === this.periods[eventData.y + eventData.h - 2] &&
+          slot.weekday === this.weekdays[eventData.x + eventData.w - 2]
+      );
+
+      let newStartSlot, newEndSlot;
+
+      if (newStartSlotId.length === 1 && newStartSlotId.length === 1) {
+        newStartSlot = newStartSlotId[0];
+        newStartSlotId = newStartSlot.id;
+
+        newEndSlot = newEndSlotId[0];
+        newEndSlotId = newEndSlot.id;
+      } else {
+        throw new Error("Multiple slots matched");
+      }
+
+      if (eventData.originGridId === "lessons") {
+        let that = this;
+        this.$apollo
+          .mutate({
+            mutation: moveLesson,
+            variables: {
+              id: eventData.data.id,
+              input: {
+                slotStart: newStartSlotId,
+                slotEnd: newEndSlotId,
+              },
+            },
+            optimisticResponse: {
+              updateLesson: {
+                lesson: {
+                  ...eventData.data,
+                  slotStart: newStartSlot,
+                  slotEnd: newEndSlot,
+                  isOptimistic: true,
+                },
+                __typename: "LessonPatchMutation",
+              },
+            },
+            update(
+              store,
+              {
+                data: {
+                  updateLesson: { lesson },
+                },
+              }
+            ) {
+              let query = {
+                ...that.$apollo.queries.lessonObjects.options,
+                variables: JSON.parse(
+                  that.$apollo.queries.lessonObjects.previousVariablesJson
+                ),
+              };
+              // Read the data from cache for query
+              const storedData = store.readQuery(query);
+
+              if (!storedData) {
+                // There are no data in the cache yet
+                return;
+              }
+
+              const index = storedData.lessonObjects.findIndex(
+                (lessonObject) => lessonObject.id === lesson.id
+              );
+              storedData.lessonObjects[index].slotStart = lesson.slotStart;
+              storedData.lessonObjects[index].slotEnd = lesson.slotEnd;
+
+              // Write data back to the cache
+              store.writeQuery({ ...query, data: storedData });
+            },
+          })
+          .then(() => {
+            this.$toastSuccess(
+              "lesrooster.timetable_management.snacks.lesson_move.success"
+            );
+          })
+          .catch(() => {
+            this.$toastError(
+              "lesrooster.timetable_management.snacks.lesson_move.error"
+            );
+          });
+      } else if (eventData.originGridId === "courses") {
+        let that = this;
+        const rule = new RRule({
+          freq: RRule.WEEKLY, // TODO: Make this configurable
+          dtstart: new Date(this.internalTimeGrid.validityRange.dateStart), // FIXME: check if this is correct with timezones etc.
+          until: new Date(this.internalTimeGrid.validityRange.dateEnd), // FIXME: check if this is correct with timezones etc.
+        });
+        const recurrenceString = rule.toString();
+        this.$apollo
+          .mutate({
+            mutation: createLesson,
+            variables: {
+              input: {
+                slotStart: newStartSlotId,
+                slotEnd: newEndSlotId,
+                course: eventData.data.courseId,
+                subject: eventData.data.subject?.id,
+                teachers: eventData.data.teachers.map((t) => t.id),
+                rooms: [],
+                // rooms: eventData.data.rooms.map((r) => r.id),
+                recurrence: recurrenceString,
+              },
+            },
+            optimisticResponse: {
+              createLesson: {
+                lesson: {
+                  id: "temporary-lesson-id-" + crypto.randomUUID(),
+                  slotStart: newStartSlot,
+                  slotEnd: newEndSlot,
+                  subject: eventData.data.subject,
+                  teachers: eventData.data.teachers,
+                  // rooms: eventData.data.rooms,
+                  rooms: [],
+                  course: eventData.data,
+                  isOptimistic: true,
+                  canEdit: true,
+                  canDelete: true,
+                  recurrence: recurrenceString,
+                  __typename: "LessonType",
+                },
+                __typename: "LessonCreateMutation",
+              },
+            },
+            update(
+              store,
+              {
+                data: {
+                  createLesson: { lesson },
+                },
+              }
+            ) {
+              let query = {
+                ...that.$apollo.queries.lessonObjects.options,
+                variables: JSON.parse(
+                  that.$apollo.queries.lessonObjects.previousVariablesJson
+                ),
+              };
+              // Read the data from cache for query
+              const storedData = store.readQuery(query);
+
+              if (!storedData) {
+                // There are no data in the cache yet
+                return;
+              }
+
+              storedData.lessonObjects.push(lesson);
+
+              // Write data back to the cache
+              store.writeQuery({ ...query, data: storedData });
+            },
+          })
+          .then(() => {
+            this.$toastSuccess(
+              "lesrooster.timetable_management.snacks.lesson_create.success"
+            );
+          })
+          .catch(() => {
+            this.$toastError(
+              "lesrooster.timetable_management.snacks.lesson_create.error"
+            );
+          });
+      }
+    },
+    itemMovedToCourses(eventData) {
+      if (eventData.originGridId === "lessons") {
+        // TODO: remove lessons from plan?
+        // Maybe not needed, due to delete button in menu
+      }
+    },
+    canShortenLesson(lesson) {
+      // Only allow shortening a lesson if it is longer than 1 slot
+      return lesson.slotEnd.id !== lesson.slotStart.id;
+    },
+    canProlongLesson(lesson) {
+      const nextSlot = this.slots
+        .filter(
+          (slot) =>
+            slot.weekday === lesson.slotEnd.weekday &&
+            slot.period > lesson.slotEnd.period
+        )
+        .reduce(
+          (prev, current) =>
+            prev && prev.period > current.period ? current : prev || current,
+          null
+        );
+
+      return !!nextSlot;
+    },
+    changeLessonSlots(lesson, slotStart, slotEnd) {
+      let that = this;
+      this.$apollo
+        .mutate({
+          mutation: moveLesson,
+          variables: {
+            id: lesson.id,
+            input: {
+              slotStart: slotStart.id,
+              slotEnd: slotEnd.id,
+            },
+          },
+          optimisticResponse: {
+            updateLesson: {
+              lesson: {
+                ...lesson,
+                slotStart: slotStart,
+                slotEnd: slotEnd,
+                isOptimistic: true,
+              },
+              __typename: "LessonPatchMutation",
+            },
+          },
+          update(
+            store,
+            {
+              data: {
+                updateLesson: { lesson },
+              },
+            }
+          ) {
+            let query = {
+              ...that.$apollo.queries.lessonObjects.options,
+              variables: JSON.parse(
+                that.$apollo.queries.lessonObjects.previousVariablesJson
+              ),
+            };
+            // Read the data from cache for query
+            const storedData = store.readQuery(query);
+
+            if (!storedData) {
+              // There are no data in the cache yet
+              return;
+            }
+
+            const index = storedData.lessonObjects.findIndex(
+              (lessonObject) => lessonObject.id === lesson.id
+            );
+            storedData.lessonObjects[index].slotStart = lesson.slotStart;
+            storedData.lessonObjects[index].slotEnd = lesson.slotEnd;
+
+            // Write data back to the cache
+            store.writeQuery({ ...query, data: storedData });
+          },
+        })
+        .then(() => {
+          this.$toastSuccess(
+            "lesrooster.timetable_management.snacks.lesson_change_length.success"
+          );
+        })
+        .catch(() => {
+          this.$toastError(
+            "lesrooster.timetable_management.snacks.lesson_change_length.error"
+          );
+        });
+    },
+    prolongLesson(lesson) {
+      // Find next slot on the same day
+      const slotEnd = this.slots
+        .filter(
+          (slot) =>
+            slot.weekday === lesson.slotEnd.weekday &&
+            slot.period > lesson.slotEnd.period
+        )
+        .reduce((prev, current) =>
+          prev.period < current.period ? prev : current
+        );
+
+      this.changeLessonSlots(lesson, lesson.slotStart, slotEnd);
+    },
+    shortenLesson(lesson) {
+      // Find previous slot on the same day
+      const slotEnd = this.slots
+        .filter(
+          (slot) =>
+            slot.weekday === lesson.slotEnd.weekday &&
+            slot.period < lesson.slotEnd.period
+        )
+        .reduce((prev, current) =>
+          prev.period > current.period ? prev : current
+        );
+
+      this.changeLessonSlots(lesson, lesson.slotStart, slotEnd);
+    },
+    deleteLesson(lesson) {
+      this.itemToDelete = lesson;
+      this.deleteDialog = true;
+    },
+    teacherClick(teacher) {
+      // A teacher was selected for miniplan
+      this.selectedObjectType = "teacher";
+      this.selectedObject = teacher.id;
+      this.selectedObjectTitle = teacher.fullName;
+      this.selectedObjectDialogOpen = true;
+    },
+    roomClick(room) {
+      // A room was selected for miniplan
+      this.selectedObjectType = "room";
+      this.selectedObject = room.id;
+      this.selectedObjectTitle = room.name;
+      this.selectedObjectDialogOpen = true;
+    },
+    editLessonClick(lesson) {
+      this.lessonEdit.id = lesson.id;
+      this.lessonEdit.object = lesson;
+      this.lessonEdit.open = true;
+    },
+    getTeacherList(subjectTeachers) {
+      return [
+        {
+          header: this.$t(
+            "lesrooster.timebound_course_config.subject_teachers"
+          ),
+        },
+        ...this.persons.filter((person) =>
+          subjectTeachers.find((teacher) => teacher.id === person.id)
+        ),
+        { divider: true },
+        { header: this.$t("lesrooster.timebound_course_config.all_teachers") },
+        ...this.persons.filter(
+          (person) =>
+            !subjectTeachers.find((teacher) => teacher.id === person.id)
+        ),
+      ];
+    },
+    handleLessonEditUpdate(store, lesson) {
+      const query = {
+        ...this.$apollo.queries.lessonObjects.options,
+        variables: JSON.parse(
+          this.$apollo.queries.lessonObjects.previousVariablesJson
+        ),
+      };
+      // Read the data from cache for query
+      const storedData = store.readQuery(query);
+
+      if (!storedData) {
+        // There are no data in the cache yet
+        return;
+      }
+
+      const index = storedData.lessonObjects.findIndex(
+        (lessonObject) => lessonObject.id === lesson.id
+      );
+      storedData.lessonObjects[index].subject = lesson.subject;
+      storedData.lessonObjects[index].teachers = lesson.teachers;
+      storedData.lessonObjects[index].rooms = lesson.rooms;
+
+      // Write data back to the cache
+      store.writeQuery({ ...query, data: storedData });
+    },
+    handleLessonEditSave() {
+      this.$toastSuccess(
+        "lesrooster.timetable_management.snacks.lesson_edit.success"
+      );
+    },
+    handleLessonEditError() {
+      this.$toastError(
+        "lesrooster.timetable_management.snacks.lesson_edit.error"
+      );
+    },
+    lessonEditGetPatchData(lesson) {
+      return {
+        subject: lesson.subject.id,
+        teachers: lesson.teachers.map((teacher) => teacher.id),
+        rooms: lesson.rooms.map((room) => room.id),
+      };
+    },
+    courseSearchFilter(items, search) {
+      if (!search || !items.length) return items;
+      search = (search || "").trim().toLowerCase();
+      if (!search) return items;
+
+      return items.filter((item) => {
+        return (
+          item.data.name?.toLowerCase().includes(search) ||
+          item.data.subject?.name?.toLowerCase().includes(search) ||
+          item.data.subject?.teachers?.some(
+            (teacher) =>
+              teacher.fullName?.toLowerCase().includes(search) ||
+              teacher.shortName?.toLowerCase().includes(search)
+          ) ||
+          item.data.teachers?.some(
+            (teacher) =>
+              teacher.fullName?.toLowerCase().includes(search) ||
+              teacher.shortName?.toLowerCase().includes(search)
+          ) ||
+          item.data.groups?.some(
+            (group) =>
+              group.name?.toLowerCase().includes(search) ||
+              group.shortName?.toLowerCase().includes(search)
+          )
+        );
+      });
+    },
+    formatTimeGrid(item) {
+      if (!item) return null;
+      if (item.group === null) {
+        return this.$t(
+          "lesrooster.validity_range.time_grid.repr.generic",
+          item.validityRange
+        );
+      }
+      return this.$t("lesrooster.validity_range.time_grid.repr.default", [
+        item.validityRange.name,
+        item.group.name,
+      ]);
+    },
+    timeRangesByWeekdays(period) {
+      return period.slots
+        .map((slot) => ({ timeStart: slot.timeStart, timeEnd: slot.timeEnd }))
+        .filter(
+          (value, index, self) =>
+            index ===
+            self.findIndex(
+              (timeRange) =>
+                timeRange.timeStart === value.timeStart &&
+                timeRange.timeEnd === value.timeEnd
+            )
+        )
+        .map((timeRange) => ({
+          ...timeRange,
+          weekdays: period.slots
+            .filter(
+              (slot) =>
+                slot.timeStart === timeRange.timeStart &&
+                slot.timeEnd === timeRange.timeEnd
+            )
+            .map((slot) => slot.weekday),
+        }));
+    },
+  },
+});
+</script>
+
+<template>
+  <div>
+    <v-row>
+      <v-col cols="12" lg="8" xl="9">
+        <div class="d-flex justify-space-between flex-wrap align-center">
+          <secondary-action-button
+            i18n-key="lesrooster.timetable_management.back"
+            :to="{ name: 'cursus.school_structure' }"
+          />
+
+          <v-spacer />
+
+          <v-autocomplete
+            outlined
+            filled
+            hide-details
+            label="Select Group"
+            :items="groups"
+            item-text="name"
+            item-value="id"
+            return-object
+            v-model="selectedGroup"
+            :loading="$apollo.queries.gqlGroups"
+            class="mr-4"
+          />
+
+          <time-grid-field
+            outlined
+            filled
+            label="Select Validity Range"
+            hide-details
+            v-model="internalTimeGrid"
+          />
+        </div>
+      </v-col>
+
+      <v-col
+        cols="12"
+        lg="4"
+        xl="3"
+        class="d-flex justify-space-between flex-wrap align-center"
+      >
+        <secondary-action-button
+          i18n-key="actions.copy_last_configuration"
+          block
+          disabled
+        />
+      </v-col>
+
+      <v-col cols="12" lg="8" xl="9" class="align-self-start" id="grid">
+        <div id="weekdays">
+          <v-card
+            v-for="weekday in weekdays"
+            :key="weekday"
+            class="d-flex justify-center align-center"
+          >
+            <v-card-title class="text-body-1">{{
+              $t("weekdays." + weekday)
+            }}</v-card-title>
+          </v-card>
+        </div>
+        <div id="periods">
+          <period-card
+            v-for="(period, index) in periods"
+            :key="period"
+            :period="period"
+            :weekdays="weekdays"
+            :time-ranges="
+              timeRangesByWeekdays(
+                slotsByPeriods.find(
+                  (periodWithSlots) => periodWithSlots.period === period
+                )
+              )
+            "
+          />
+        </div>
+        <drag-grid
+          :cols="weekdays.length"
+          :rows="periods.length"
+          :value="gridItems"
+          :loading="gridLoading"
+          context="timetable"
+          :disabled-fields="disabledSlots"
+          @itemChanged="itemMovedToLessons"
+          grid-id="lessons"
+          id="timetable"
+          multiple-items-y
+        >
+          <template #item="item">
+            <v-menu
+              open-on-hover
+              offset-y
+              :open-on-click="false"
+              rounded="pill"
+              bottom
+              min-width="max-content"
+              nudge-right="40%"
+            >
+              <template #activator="{ attrs, on }">
+                <lesson-card
+                  :lesson="item.data"
+                  rounded="lg"
+                  class="d-flex"
+                  v-bind="attrs"
+                  v-on="on"
+                  @click:teacher="teacherClick"
+                  @click:room="roomClick"
+                />
+              </template>
+
+              <v-card rounded="pill" style="width: max-content">
+                <v-btn
+                  icon
+                  :disabled="!item.data.canDelete"
+                  @click="deleteLesson(item.data)"
+                >
+                  <v-icon>$deleteContent</v-icon>
+                </v-btn>
+                <v-btn
+                  icon
+                  :disabled="!canShortenLesson(item.data)"
+                  @click="shortenLesson(item.data)"
+                >
+                  <v-icon>mdi-minus</v-icon>
+                </v-btn>
+                <v-btn
+                  icon
+                  :disabled="!canProlongLesson(item.data)"
+                  @click="prolongLesson(item.data)"
+                >
+                  <v-icon>mdi-plus</v-icon>
+                </v-btn>
+                <v-btn icon @click="editLessonClick(item.data)">
+                  <v-icon>$edit</v-icon>
+                </v-btn>
+              </v-card>
+            </v-menu>
+          </template>
+          <template #loader>
+            <v-skeleton-loader type="sentences" />
+          </template>
+          <template #highlight>
+            <v-skeleton-loader
+              type="image"
+              boilerplate
+              height="100%"
+              id="highlight"
+            />
+          </template>
+          <template #disabledField="{ isDraggedOver }">
+            <v-fade-transition>
+              <blocking-card v-show="isDraggedOver" />
+            </v-fade-transition>
+          </template>
+        </drag-grid>
+      </v-col>
+
+      <v-col cols="12" lg="4" xl="3">
+        <v-card>
+          <v-card-text>
+            <v-text-field
+              search
+              filled
+              rounded
+              v-model="courseSearch"
+              clearable
+              :label="$t('actions.search_courses')"
+              :hint="totalLessonRatio"
+              persistent-hint
+            />
+            <v-data-iterator
+              :items="selectableCourses"
+              item-key="key"
+              :items-per-page="12"
+              single-expand
+              :search="courseSearch"
+              sort-by="data.lessonRatio"
+              :custom-filter="courseSearchFilter"
+            >
+              <template #default="{ items }">
+                <drag-grid
+                  :cols="3"
+                  :rows="4"
+                  :value="items"
+                  :loading="$apollo.queries.courses.loading"
+                  no-highlight
+                  context="timetable"
+                  @itemChanged="itemMovedToCourses"
+                  grid-id="courses"
+                >
+                  <template #item="item">
+                    <lesson-card
+                      :lesson="item.data"
+                      rounded="lg"
+                      @click:teacher="teacherClick"
+                      @click:room="roomClick"
+                    >
+                      <lesson-ratio-chip :course="item.data" />
+                    </lesson-card>
+                  </template>
+                  <template #loader>
+                    <v-skeleton-loader type="image" />
+                  </template>
+                </drag-grid>
+              </template>
+            </v-data-iterator>
+          </v-card-text>
+        </v-card>
+      </v-col>
+    </v-row>
+
+    <mobile-fullscreen-dialog
+      v-model="selectedObjectDialogOpen"
+      max-width="75vw"
+    >
+      <v-card>
+        <v-card-title class="justify-space-between">
+          <span>
+            {{
+              $t("lesrooster.timetable_management.timetable_for", {
+                name: selectedObjectTitle,
+              })
+            }}
+          </span>
+
+          <v-spacer />
+
+          <v-tabs
+            v-model="selectedObjectDialogTab"
+            color="secondary"
+            right
+            v-if="timeGrids && timeGrids.length > 1"
+            class="width-max-content"
+          >
+            <v-tab v-for="timeGrid in timeGrids" :key="timeGrid.id">
+              {{ formatTimeGrid(timeGrid) }}
+            </v-tab>
+          </v-tabs>
+        </v-card-title>
+        <v-card-text>
+          <v-tabs-items v-model="selectedObjectDialogTab">
+            <v-tab-item v-for="timeGrid in timeGrids" :key="timeGrid.id">
+              <teacher-time-table
+                v-if="internalTimeGrid && selectedObjectType === 'teacher'"
+                :teacher-id="selectedObject"
+                :time-grid="timeGrid"
+                class="fill-height"
+              />
+              <room-time-table
+                v-if="internalTimeGrid && selectedObjectType === 'room'"
+                :room-id="selectedObject"
+                :time-grid="timeGrid"
+                class="fill-height"
+              />
+            </v-tab-item>
+          </v-tabs-items>
+        </v-card-text>
+      </v-card>
+    </mobile-fullscreen-dialog>
+
+    <delete-dialog
+      :gql-mutation="deleteMutation"
+      :gql-query="$apollo.queries.lessonObjects"
+      v-model="deleteDialog"
+      :item="itemToDelete"
+    >
+      <template #body>
+        {{ itemToDelete.subject?.name || itemToDelete.course.subject.name }}
+      </template>
+    </delete-dialog>
+
+    <dialog-object-form
+      :is-create="false"
+      :default-item="lessonEdit.object"
+      :edit-item="lessonEdit.object"
+      :fields="lessonEdit.fields"
+      v-model="lessonEdit.open"
+      item-title-attribute="course.name"
+      :gql-patch-mutation="lessonEdit.mutation"
+      :get-patch-data="lessonEditGetPatchData"
+      @cancel="lessonEdit.open = false"
+      @save="handleLessonEditSave"
+      @error="handleLessonEditError"
+      @update="handleLessonEditUpdate"
+      force-model-item-update
+    >
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #subject.field="{ attrs, on }">
+        <subject-field v-bind="attrs" v-on="on" />
+      </template>
+
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #teachers.field="{ attrs, on, item }">
+        <v-autocomplete
+          multiple
+          return-object
+          :items="getTeacherList(item.subject?.teachers || [])"
+          item-text="fullName"
+          item-value="id"
+          v-bind="attrs"
+          v-on="on"
+          :loading="$apollo.queries.persons.loading"
+        >
+          <template #item="data">
+            <v-list-item-action>
+              <v-checkbox v-model="data.attrs.inputValue" />
+            </v-list-item-action>
+            <v-list-item-content>
+              <v-list-item-title>{{ data.item.fullName }}</v-list-item-title>
+              <v-list-item-subtitle v-if="data.item.shortName">{{
+                data.item.shortName
+              }}</v-list-item-subtitle>
+            </v-list-item-content>
+          </template>
+        </v-autocomplete>
+      </template>
+
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #rooms.field="{ attrs, on }">
+        <v-autocomplete
+          multiple
+          return-object
+          :items="rooms"
+          item-text="name"
+          item-value="id"
+          :loading="$apollo.queries.rooms.loading"
+          v-bind="attrs"
+          v-on="on"
+        />
+      </template>
+    </dialog-object-form>
+  </div>
+</template>
+
+<style>
+#highlight > .v-skeleton-loader__image {
+  height: 100%;
+}
+</style>
+
+<style scoped lang="scss">
+.big {
+  width: 36px;
+}
+
+.spacer {
+  width: 36px;
+}
+
+.width-max-content {
+  width: max-content;
+}
+
+#grid {
+  display: grid;
+  grid-template: ". weekdays" auto "periods timetable" auto / min-content auto;
+  gap: 0.5rem;
+}
+
+#weekdays {
+  grid-area: weekdays;
+  display: flex;
+  flex-direction: row;
+  width: 100%;
+  justify-content: space-between;
+  gap: 0.5rem;
+
+  & > * {
+    width: 100%;
+  }
+}
+
+#periods {
+  grid-area: periods;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  justify-content: space-between;
+  gap: 0.5rem;
+
+  & > * {
+    height: 100%;
+  }
+}
+
+#timetable {
+  grid-area: timetable;
+  gap: 0.5rem;
+}
+</style>
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetableManagement.graphql b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetableManagement.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..0a7dc27fd475f58778de605dfa72ac292ba8cf22
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetableManagement.graphql
@@ -0,0 +1,224 @@
+query group($id: ID!) {
+  groupById(id: $id) {
+    id
+    name
+  }
+}
+
+query gqlGroups($timeGrid: ID!) {
+  groups: groupsByTimeGrid(timeGrid: $timeGrid) {
+    id
+    name
+  }
+}
+
+query courses($group: ID!, $timeGrid: ID!) {
+  courses: courseObjectsForGroup(group: $group, timeGrid: $timeGrid) {
+    id
+    courseId
+    name
+    subject {
+      id
+      name
+      shortName
+      colourFg
+      colourBg
+      teachers {
+        id
+        fullName
+        shortName
+      }
+    }
+    teachers {
+      id
+      fullName
+      shortName
+    }
+    groups {
+      id
+      name
+      shortName
+    }
+    lessonQuota
+  }
+}
+
+query lessonObjects($group: ID!, $timeGrid: ID!) {
+  lessonObjects: lessonObjectsForGroup(group: $group, timeGrid: $timeGrid) {
+    id
+    slotStart {
+      id
+      period
+      weekday
+    }
+    slotEnd {
+      id
+      period
+      weekday
+    }
+    subject {
+      id
+      name
+      shortName
+      colourFg
+      colourBg
+      teachers {
+        id
+        fullName
+        shortName
+      }
+    }
+    teachers {
+      id
+      fullName
+      shortName
+    }
+    rooms {
+      id
+      name
+      shortName
+    }
+    course {
+      id
+      name
+      subject {
+        id
+        name
+        colourFg
+        colourBg
+        teachers {
+          id
+          fullName
+          shortName
+        }
+      }
+      teachers {
+        id
+        fullName
+        shortName
+      }
+      groups {
+        id
+        name
+        shortName
+      }
+    }
+    isOptimistic
+    recurrence
+    canEdit
+    canDelete
+  }
+}
+
+mutation createLesson($input: CreateLessonInput!) {
+  createLesson(input: $input) {
+    lesson {
+      id
+      slotStart {
+        id
+        period
+        weekday
+      }
+      slotEnd {
+        id
+        period
+        weekday
+      }
+      subject {
+        id
+        name
+        shortName
+        colourFg
+        colourBg
+      }
+      teachers {
+        id
+        fullName
+        shortName
+      }
+      rooms {
+        id
+        name
+        shortName
+      }
+      course {
+        id
+        name
+        subject {
+          id
+          name
+          shortName
+          colourFg
+          colourBg
+        }
+        teachers {
+          id
+          fullName
+          shortName
+        }
+        groups {
+          id
+          name
+          shortName
+        }
+      }
+      isOptimistic
+      recurrence
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation moveLesson($id: ID!, $input: PatchLessonInput!) {
+  updateLesson(id: $id, input: $input) {
+    lesson {
+      id
+      slotStart {
+        id
+        period
+        weekday
+      }
+      slotEnd {
+        id
+        period
+        weekday
+      }
+      isOptimistic
+    }
+  }
+}
+
+mutation updateLesson($input: PatchLessonInput!, $id: ID!) {
+  updateLesson(id: $id, input: $input) {
+    item: lesson {
+      id
+      subject {
+        id
+        name
+        shortName
+        colourFg
+        colourBg
+      }
+      teachers {
+        id
+        fullName
+        shortName
+      }
+      rooms {
+        id
+        name
+        shortName
+      }
+      isOptimistic
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation deleteLesson($id: ID!) {
+  deleteLesson(id: $id) {
+    ok
+  }
+}
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a097e292ca8b6172903a26400862833068980a71
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue
@@ -0,0 +1,129 @@
+<script>
+import { defineComponent } from "vue";
+import { slots } from "../../breaks_and_slots/slot.graphql";
+import LessonCard from "../LessonCard.vue";
+import MessageBox from "aleksis.core/components/generic/MessageBox.vue";
+
+export default defineComponent({
+  name: "MiniTimeTable",
+  components: { LessonCard, MessageBox },
+  props: {
+    timeGrid: {
+      type: Object,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      periods: [],
+      weekdays: [],
+    };
+  },
+  apollo: {
+    slots: {
+      query: slots,
+      variables() {
+        return {
+          filters: JSON.stringify({
+            time_grid: this.timeGrid.id,
+          }),
+        };
+      },
+      skip() {
+        return this.timeGrid === null;
+      },
+      update: (data) => data.items,
+      result({ data: { items } }) {
+        this.weekdays = Array.from(
+          new Set(
+            items
+              .filter((slot) => slot.model === "Slot")
+              .map((slot) => slot.weekday)
+          )
+        );
+        this.periods = Array.from(
+          new Set(
+            items
+              .filter((slot) => slot.model === "Slot")
+              .map((slot) => slot.period)
+          )
+        );
+      },
+    },
+  },
+  computed: {
+    gridTemplate() {
+      return (
+        "[legend-row] auto " +
+        this.periods.map((period) => `[period-${period}] auto `).join("") +
+        "/ [legend-day] auto" +
+        this.weekdays.map((weekday) => ` [${weekday}] 1fr`).join("")
+      );
+    },
+    lessons() {
+      return [];
+    },
+  },
+  methods: {
+    styleForLesson(lesson) {
+      return {
+        gridArea:
+          `period-${lesson.slotStart.period} / ${lesson.slotStart.weekday} / ` +
+          `span ${lesson.slotEnd.period - lesson.slotStart.period + 1} / ${
+            lesson.slotEnd.weekday
+          }`,
+      };
+    },
+  },
+});
+</script>
+
+<template>
+  <div class="timetable">
+    <!-- Empty div to fill top-left corner -->
+    <div></div>
+    <v-card
+      v-for="period in periods"
+      :style="{
+        gridColumn: 'legend-day',
+        gridRow: `period-${period} / span 1`,
+      }"
+      :key="'period' + period"
+    >
+      <v-card-text>{{ period }}</v-card-text>
+    </v-card>
+    <v-card
+      v-for="weekday in weekdays"
+      :style="{ gridRow: 'legend-row', gridColumn: `${weekday} / span 1` }"
+      :key="weekday"
+    >
+      <v-card-text>{{ $t("weekdays." + weekday) }}</v-card-text>
+    </v-card>
+    <lesson-card
+      v-for="lesson in lessons"
+      :lesson="lesson"
+      :style="styleForLesson(lesson)"
+      :key="lesson.id"
+    />
+
+    <message-box type="info" v-if="!lessons || lessons.length === 0">
+      {{ $t("lesrooster.timetable_management.no_lessons") }}
+    </message-box>
+    <message-box type="warning" v-if="!slots || slots.length === 0">
+      {{ $t("lesrooster.timetable_management.no_slots") }}
+    </message-box>
+  </div>
+</template>
+
+<style scoped>
+.timetable {
+  display: grid;
+  grid-template: v-bind(gridTemplate);
+  gap: 1em;
+}
+
+.timetable > * {
+  width: 100%;
+  height: 100%;
+}
+</style>
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/RoomTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/RoomTimeTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a50154bbbf49f681223283176652128fa08f4c8e
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/RoomTimeTable.vue
@@ -0,0 +1,35 @@
+<script>
+import { defineComponent } from "vue";
+import { lessonsRoom } from "./timetables.graphql";
+import MiniTimeTable from "./MiniTimeTable.vue";
+
+export default defineComponent({
+  name: "RoomTimeTable",
+  extends: MiniTimeTable,
+  props: {
+    roomId: {
+      type: String,
+      required: true,
+    },
+  },
+  computed: {
+    lessons() {
+      return this.lessonsRoom;
+    },
+  },
+  apollo: {
+    lessonsRoom: {
+      query: lessonsRoom,
+      variables() {
+        return {
+          timeGrid: this.timeGrid.id,
+          room: this.roomId,
+        };
+      },
+      skip() {
+        return this.timeGrid === null;
+      },
+    },
+  },
+});
+</script>
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/TeacherTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/TeacherTimeTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5ed3211176f9bb0ad66c654a5fe1c36747ff13b0
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/TeacherTimeTable.vue
@@ -0,0 +1,35 @@
+<script>
+import { defineComponent } from "vue";
+import { lessonsTeacher } from "./timetables.graphql";
+import MiniTimeTable from "./MiniTimeTable.vue";
+
+export default defineComponent({
+  name: "TeacherTimeTable",
+  extends: MiniTimeTable,
+  props: {
+    teacherId: {
+      type: String,
+      required: true,
+    },
+  },
+  computed: {
+    lessons() {
+      return this.lessonsTeacher;
+    },
+  },
+  apollo: {
+    lessonsTeacher: {
+      query: lessonsTeacher,
+      variables() {
+        return {
+          timeGrid: this.timeGrid.id,
+          teacher: this.teacherId,
+        };
+      },
+      skip() {
+        return this.timeGrid === null;
+      },
+    },
+  },
+});
+</script>
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..3bd1897b5674ad1baf196e8e820a57e5c4afacf2
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql
@@ -0,0 +1,112 @@
+query lessonsTeacher($teacher: ID!, $timeGrid: ID!) {
+  lessonsTeacher: lessonObjectsForTeacher(
+    teacher: $teacher
+    timeGrid: $timeGrid
+  ) {
+    id
+    slotStart {
+      id
+      period
+      weekday
+    }
+    slotEnd {
+      id
+      period
+      weekday
+    }
+    subject {
+      id
+      name
+      colourFg
+      colourBg
+    }
+    teachers {
+      id
+      fullName
+      shortName
+    }
+    rooms {
+      id
+      name
+      shortName
+    }
+    course {
+      id
+      name
+      subject {
+        id
+        name
+        colourFg
+        colourBg
+      }
+      teachers {
+        id
+        fullName
+        shortName
+      }
+      groups {
+        id
+        name
+        shortName
+      }
+    }
+    recurrence
+    canEdit
+    canDelete
+  }
+}
+
+query lessonsRoom($room: ID!, $timeGrid: ID!) {
+  lessonsRoom: lessonObjectsForRoom(room: $room, timeGrid: $timeGrid) {
+    id
+    slotStart {
+      id
+      period
+      weekday
+    }
+    slotEnd {
+      id
+      period
+      weekday
+    }
+    subject {
+      id
+      name
+      colourFg
+      colourBg
+    }
+    teachers {
+      id
+      fullName
+      shortName
+    }
+    rooms {
+      id
+      name
+      shortName
+    }
+    course {
+      id
+      name
+      subject {
+        id
+        name
+        colourFg
+        colourBg
+      }
+      teachers {
+        id
+        fullName
+        shortName
+      }
+      groups {
+        id
+        name
+        shortName
+      }
+    }
+    recurrence
+    canEdit
+    canDelete
+  }
+}
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8558207a7503e7609b3247ff8bbec2edf2ceb6a3
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue
@@ -0,0 +1,112 @@
+<script>
+import { defineComponent } from "vue";
+import { timeGrids } from "./validityRange.graphql";
+import ConfirmDialog from "aleksis.core/components/generic/dialogs/ConfirmDialog.vue";
+import PrimaryActionButton from "aleksis.core/components/generic/buttons/PrimaryActionButton.vue";
+
+export default defineComponent({
+  name: "CopyFromTimeGridMenu",
+  components: { ConfirmDialog, PrimaryActionButton },
+  apollo: {
+    timeGrids: {
+      query: timeGrids,
+      variables() {
+        return {
+          filters: JSON.stringify({
+            group: this.groupMatch,
+          }),
+          orderBy: ["validity_range__date_start", "validity_range__date_end"],
+        };
+      },
+      update: (data) => data.items,
+    },
+  },
+  computed: {
+    grids() {
+      return this.timeGrids.filter((grid) => !this.denyIds.includes(grid.id));
+    },
+  },
+  props: {
+    groupMatch: {
+      required: false,
+      type: Object,
+      default: undefined,
+    },
+    denyIds: {
+      required: false,
+      default: () => [],
+      type: Array,
+    },
+  },
+  data() {
+    return {
+      dialog: false,
+      gridToCopyFrom: null,
+      timeGrids: [],
+    };
+  },
+  methods: {
+    openConfirmationDialog(grid) {
+      this.gridToCopyFrom = grid;
+      this.dialog = true;
+    },
+    confirm() {
+      console.log("Confirmed");
+      this.$emit("confirm", this.gridToCopyFrom);
+      this.dialog = false;
+    },
+    cancel() {
+      console.log("Cancelled");
+      this.dialog = false;
+      this.gridToCopyFrom = null;
+    },
+    formatTimeGrid(item) {
+      if (item.group === null) {
+        return this.$t(
+          "lesrooster.validity_range.time_grid.repr.generic",
+          item.validityRange
+        );
+      }
+      return this.$t("lesrooster.validity_range.time_grid.repr.default", [
+        item.validityRange.name,
+        item.group.name,
+      ]);
+    },
+  },
+});
+</script>
+
+<template>
+  <div>
+    <v-menu offset-y>
+      <template #activator="{ attrs, on }">
+        <slot name="activator" :attrs="attrs" :on="on">
+          <primary-action-button
+            i18n-key="actions.copy_last_configuration"
+            icon="mdi-content-copy"
+          />
+        </slot>
+      </template>
+      <v-list dense>
+        <v-list-item
+          v-for="(grid, index) in grids"
+          @click="openConfirmationDialog(grid)"
+          :key="index"
+        >
+          <v-list-item-title>{{ formatTimeGrid(grid) }}</v-list-item-title>
+        </v-list-item>
+      </v-list>
+    </v-menu>
+
+    <confirm-dialog v-model="dialog" @confirm="confirm" @cancel="cancel">
+      <template #title>
+        {{ $t("actions.confirm_copy_last_configuration") }}
+      </template>
+      <template #text>
+        {{ $t("actions.confirm_copy_last_configuration_message") }}
+      </template>
+    </confirm-dialog>
+  </div>
+</template>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridChip.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridChip.vue
new file mode 100644
index 0000000000000000000000000000000000000000..32d2e065217f010fc7ce75526098e3cdbf8a035c
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridChip.vue
@@ -0,0 +1,34 @@
+<script>
+import { defineComponent } from "vue";
+
+export default defineComponent({
+  name: "TimeGridChip",
+  props: {
+    value: {
+      type: Object,
+      required: true,
+    },
+    shortName: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+  computed: {
+    groupName() {
+      return this.shortName ? "shortName" : "name";
+    },
+  },
+});
+</script>
+
+<template>
+  <v-chip v-bind="$attrs" v-on="$listeners" close>
+    {{
+      value.group?.[groupName] ||
+      $t("lesrooster.validity_range.time_grid.generic")
+    }}
+  </v-chip>
+</template>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..212b6104f316fccac4ce80c2130e0d36172098f0
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue
@@ -0,0 +1,154 @@
+<script setup>
+import ForeignKeyField from "aleksis.core/components/generic/forms/ForeignKeyField.vue";
+import ValidityRangeField from "./ValidityRangeField.vue";
+</script>
+
+<script>
+import { defineComponent } from "vue";
+import { timeGrids, createTimeGrid } from "./validityRange.graphql";
+import { gqlGroups } from "../helper.graphql";
+
+export default defineComponent({
+  name: "TimeGridField",
+  apollo: {
+    groups: {
+      query: gqlGroups,
+    },
+  },
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t(
+            "lesrooster.validity_range.time_grid.fields.validity_range"
+          ),
+          value: "validityRange",
+          cols: 12,
+        },
+        {
+          text: this.$t(
+            "lesrooster.validity_range.time_grid.fields.is_generic"
+          ),
+          value: "isGeneric",
+        },
+        {
+          text: this.$t("lesrooster.validity_range.time_grid.fields.group"),
+          value: "group",
+        },
+      ],
+      i18nKey: "lesrooster.validity_range.time_grid",
+      gqlQuery: timeGrids,
+      gqlCreateMutation: createTimeGrid,
+      defaultItem: {
+        isGeneric: false,
+        group: null,
+        validityRange: null,
+      },
+      required: [(value) => !!value || this.$t("forms.errors.required")],
+    };
+  },
+  methods: {
+    getCreateData(item) {
+      return {
+        group: item.group,
+        validityRange: item.validityRange?.id,
+      };
+    },
+    getPatchData(items) {},
+    selectableGroups(itemModel) {
+      if (itemModel.validityRange === null) return [];
+
+      // Filter all groups, so we only take the ones that are not already used in this validityRange
+      return this.groups?.filter(
+        (group) =>
+          !this.$refs.field.items.some(
+            (timeGrid) =>
+              timeGrid.validityRange.id === itemModel.validityRange.id &&
+              timeGrid.group !== null &&
+              timeGrid.group.id === group.id
+          )
+      );
+    },
+    genericDisabled(itemModel) {
+      if (itemModel.validityRange === null) return true;
+
+      // Is there a timeGrid that has the same validityRange as we and no group?
+      return this.$refs.field.items.some(
+        (timeGrid) =>
+          timeGrid.validityRange.id === itemModel.validityRange.id &&
+          timeGrid.group === null
+      );
+    },
+    formatItem(item) {
+      if (item.group === null) {
+        return this.$t(
+          "lesrooster.validity_range.time_grid.repr.generic",
+          item.validityRange
+        );
+      }
+      return this.$t("lesrooster.validity_range.time_grid.repr.default", [
+        item.validityRange.name,
+        item.group.name,
+      ]);
+    },
+  },
+});
+</script>
+
+<template>
+  <foreign-key-field
+    v-bind="$attrs"
+    v-on="$listeners"
+    :fields="headers"
+    create-item-i18n-key="lesrooster.validity_range.time_grid.create_long"
+    :gql-query="gqlQuery"
+    :gql-create-mutation="gqlCreateMutation"
+    :gql-patch-mutation="{}"
+    :default-item="defaultItem"
+    :get-create-data="getCreateData"
+    :get-patch-data="getPatchData"
+    :item-name="formatItem"
+    return-object
+    ref="field"
+  >
+    <template #item="{ item }">
+      {{ formatItem(item) }}
+    </template>
+
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #validityRange.field="{ attrs, on }">
+      <div aria-required="true">
+        <validity-range-field
+          v-bind="attrs"
+          v-on="on"
+          :rules="required"
+          required
+        />
+      </div>
+    </template>
+
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #isGeneric.field="{ attrs, on, item }">
+      <v-switch
+        v-bind="attrs"
+        v-on="on"
+        :disabled="genericDisabled(item)"
+      ></v-switch>
+    </template>
+
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #group.field="{ attrs, on, item }">
+      <v-autocomplete
+        :items="selectableGroups(item)"
+        item-text="name"
+        item-value="id"
+        v-bind="attrs"
+        v-on="on"
+        :disabled="item.isGeneric"
+        :loading="$apollo.queries.groups.loading"
+      />
+    </template>
+  </foreign-key-field>
+</template>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7b59458e6a470af1ecfd435bc982aa535a832dd5
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRange.vue
@@ -0,0 +1,403 @@
+<script setup>
+import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue";
+import DateField from "aleksis.core/components/generic/forms/DateField.vue";
+import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField.vue";
+import TimeGridChip from "./TimeGridChip.vue";
+import MessageBox from "aleksis.core/components/generic/MessageBox.vue";
+import CreateButton from "aleksis.core/components/generic/buttons/CreateButton.vue";
+import DialogObjectForm from "aleksis.core/components/generic/dialogs/DialogObjectForm.vue";
+import DeleteDialog from "aleksis.core/components/generic/dialogs/DeleteDialog.vue";
+import ValidityRangeStatusField from "./ValidityRangeStatusField.vue";
+import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue";
+</script>
+
+<template>
+  <div>
+    <inline-c-r-u-d-list
+      :headers="headers"
+      :i18n-key="i18nKey"
+      create-item-i18n-key="lesrooster.validity_range.create_validity_range"
+      :gql-query="gqlQuery"
+      :gql-create-mutation="gqlCreateMutation"
+      :gql-patch-mutation="gqlPatchMutation"
+      :gql-delete-mutation="gqlDeleteMutation"
+      :gql-delete-multiple-mutation="gqlDeleteMultipleMutation"
+      :default-item="defaultItem"
+      :get-create-data="getCreateData"
+      :get-patch-data="getPatchData"
+      filter
+      show-expand
+      ref="crudList"
+    >
+      <template #status="{ item }">
+        <validity-range-status-chip :value="item.status" />
+      </template>
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #status.field="{ attrs, on }">
+        <div aria-required="true">
+          <validity-range-status-field
+            v-bind="attrs"
+            v-on="on"
+            required
+            :rules="required"
+          />
+        </div>
+      </template>
+
+      <template #schoolTerm="{ item }">
+        {{ item.schoolTerm.name }}
+      </template>
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #schoolTerm.field="{ attrs, on }">
+        <div aria-required="true">
+          <school-term-field
+            v-bind="attrs"
+            v-on="on"
+            return-object
+            required
+            :rules="required"
+          />
+        </div>
+      </template>
+
+      <template #dateStart="{ item }">
+        {{ $d(new Date(item.dateStart), "short") }}
+      </template>
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #dateStart.field="{ attrs, on, item }">
+        <div aria-required="true">
+          <date-field
+            v-bind="attrs"
+            v-on="on"
+            :rules="required"
+            :max="item ? item.dateEnd : undefined"
+            required
+          ></date-field>
+        </div>
+      </template>
+
+      <template #dateEnd="{ item }">
+        {{ $d(new Date(item.dateEnd), "short") }}
+      </template>
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #dateEnd.field="{ attrs, on, item }">
+        <div aria-required="true">
+          <date-field
+            v-bind="attrs"
+            v-on="on"
+            required
+            :rules="required"
+            :min="item ? item.dateStart : undefined"
+          ></date-field>
+        </div>
+      </template>
+
+      <template #filters="{ attrs, on }">
+        <date-field
+          v-bind="attrs('date_end__gte')"
+          v-on="on('date_end__gte')"
+          :label="$t('school_term.after')"
+        />
+
+        <date-field
+          v-bind="attrs('date_start__lte')"
+          v-on="on('date_start__lte')"
+          :label="$t('school_term.before')"
+        />
+      </template>
+
+      <template #expanded-item="{ item }">
+        <v-sheet class="my-4">
+          <message-box type="error" v-if="item.timeGrids.length === 0">
+            {{
+              $t(
+                "lesrooster.validity_range.time_grid.explanations.none_created"
+              )
+            }}
+          </message-box>
+          <message-box
+            type="info"
+            v-else-if="item.timeGrids.length === 1 && !item.timeGrids[0].group"
+          >
+            {{
+              $t(
+                "lesrooster.validity_range.time_grid.explanations.only_generic"
+              )
+            }}
+          </message-box>
+          <message-box type="info" v-else-if="item.timeGrids.length === 1">
+            {{
+              $t(
+                "lesrooster.validity_range.time_grid.explanations.only_one_group"
+              )
+            }}
+          </message-box>
+          <message-box type="info" v-else>
+            {{
+              $t(
+                "lesrooster.validity_range.time_grid.explanations.multiple_set"
+              )
+            }}
+          </message-box>
+
+          <v-slide-x-transition group>
+            <time-grid-chip
+              :value="timeGrid"
+              v-for="timeGrid in item.timeGrids"
+              :key="timeGrid.id"
+              @click:close="handleDeleteTimeGridClick(timeGrid, item)"
+              class="me-2"
+            />
+          </v-slide-x-transition>
+
+          <create-button
+            i18n-key="lesrooster.validity_range.time_grid.create"
+            @click="createTimeGridFor(item)"
+          />
+        </v-sheet>
+      </template>
+    </inline-c-r-u-d-list>
+
+    <dialog-object-form
+      is-create
+      :default-item="timeGrids.object"
+      :fields="timeGrids.fields"
+      v-model="timeGrids.open"
+      item-title-attribute="course.name"
+      :get-create-data="timeGrids.getCreateDataBuilder(timeGrids.range)"
+      :gql-create-mutation="timeGrids.mutation"
+      @cancel="timeGrids.open = false"
+      @save="handleTimeGridSave"
+      @error="handleTimeGridError"
+      @update="handleTimeGridUpdate"
+    >
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #isGeneric.field="{ attrs, on }">
+        <v-switch
+          v-bind="attrs"
+          v-on="on"
+          :disabled="!genericPossible"
+        ></v-switch>
+      </template>
+
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #group.field="{ attrs, on, item }">
+        <v-autocomplete
+          :items="selectableGroups"
+          item-text="name"
+          item-value="id"
+          v-bind="attrs"
+          v-on="on"
+          :disabled="item.isGeneric"
+          :loading="$apollo.queries.groups.loading"
+        />
+      </template>
+    </dialog-object-form>
+
+    <delete-dialog
+      v-model="timeGrids.deleteOpen"
+      :item="timeGrids.deleteItem"
+      :gql-mutation="timeGrids.deleteMutation"
+      @update="updateTimeGridDelete"
+    >
+      <template #body>
+        {{ $t("lesrooster.validity_range.time_grid.confirm_delete_body") }}
+      </template>
+    </delete-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  validityRanges,
+  createValidityRange,
+  deleteValidityRange,
+  deleteValidityRanges,
+  updateValidityRanges,
+  createTimeGrid,
+  deleteTimeGrid,
+} from "./validityRange.graphql";
+import { gqlGroups } from "../helper.graphql";
+
+export default {
+  name: "ValidityRange",
+  apollo: {
+    groups: {
+      query: gqlGroups,
+    },
+  },
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t("lesrooster.validity_range.name"),
+          value: "name",
+        },
+        {
+          text: this.$t("lesrooster.validity_range.status_label"),
+          value: "status",
+        },
+        {
+          text: this.$t("school_term.title"),
+          value: "schoolTerm",
+          orderKey: "school_term__date_start",
+        },
+        {
+          text: this.$t("lesrooster.validity_range.date_start"),
+          value: "dateStart",
+        },
+        {
+          text: this.$t("lesrooster.validity_range.date_end"),
+          value: "dateEnd",
+        },
+      ],
+      i18nKey: "lesrooster.validity_range",
+      gqlQuery: validityRanges,
+      gqlCreateMutation: createValidityRange,
+      gqlPatchMutation: updateValidityRanges,
+      gqlDeleteMutation: deleteValidityRange,
+      gqlDeleteMultipleMutation: deleteValidityRanges,
+      defaultItem: {
+        name: "",
+        dateStart: "",
+        dateEnd: "",
+        schoolTerm: "",
+      },
+      required: [(value) => !!value || this.$t("forms.errors.required")],
+      timeGrids: {
+        open: false,
+        deleteOpen: false,
+        deleteItem: null,
+        deleteMutation: deleteTimeGrid,
+        range: null,
+        object: {
+          isGeneric: false,
+          group: null,
+        },
+        mutation: createTimeGrid,
+        fields: [
+          {
+            text: this.$t(
+              "lesrooster.validity_range.time_grid.fields.is_generic"
+            ),
+            value: "isGeneric",
+          },
+          {
+            text: this.$t("lesrooster.validity_range.time_grid.fields.group"),
+            value: "group",
+          },
+        ],
+        getCreateDataBuilder(validityRange) {
+          return (model) => ({
+            group: model.isGeneric ? null : model.group,
+            validityRange: validityRange.id,
+          });
+        },
+      },
+    };
+  },
+  computed: {
+    selectableGroups() {
+      return this.groups?.filter(
+        (group) =>
+          !this.timeGrids.range?.timeGrids
+            .map((timeGrid) => timeGrid.group?.id)
+            .includes(group.id)
+      );
+    },
+    genericPossible() {
+      return !this.timeGrids.range.timeGrids.some(
+        (timeGrid) => timeGrid.group === null
+      );
+    },
+  },
+  methods: {
+    getCreateData(item) {
+      console.log("in getCreateData", item);
+      return {
+        ...item,
+        schoolTerm: item.schoolTerm?.id,
+      };
+    },
+    getPatchData(items) {
+      console.log("patch items", items);
+      return items.map((item) => ({
+        id: item.id,
+        name: item.name,
+        dateStart: item.dateStart,
+        dateEnd: item.dateEnd,
+        schoolTerm: item.schoolTerm.id,
+        status: item.status.toLowerCase(),
+      }));
+    },
+    createTimeGridFor(validityRange) {
+      this.timeGrids.range = validityRange;
+      this.timeGrids.open = true;
+    },
+    handleTimeGridSave() {
+      this.$toastSuccess();
+    },
+    handleTimeGridError() {
+      this.$toastError();
+    },
+    handleTimeGridUpdate(store, timeGrid) {
+      const query = {
+        ...this.$refs.crudList.$apollo.queries.items.options,
+        variables: JSON.parse(
+          this.$refs.crudList.$apollo.queries.items.previousVariablesJson
+        ),
+      };
+      // Read the data from cache for query
+      const storedData = store.readQuery(query);
+
+      if (!storedData) {
+        // There are no data in the cache yet
+        return;
+      }
+
+      const index = storedData.items.findIndex(
+        (validityRange) => validityRange.id === timeGrid.validityRange.id
+      );
+      storedData.items[index].timeGrids.push(timeGrid);
+
+      // Write data back to the cache
+      store.writeQuery({ ...query, data: storedData });
+    },
+    handleDeleteTimeGridClick(timeGrid, validityRange) {
+      this.timeGrids.deleteItem = timeGrid;
+      this.timeGrids.range = validityRange;
+      this.timeGrids.deleteOpen = true;
+    },
+    updateTimeGridDelete(store) {
+      const query = {
+        ...this.$refs.crudList.$apollo.queries.items.options,
+        variables: JSON.parse(
+          this.$refs.crudList.$apollo.queries.items.previousVariablesJson
+        ),
+      };
+      // Read the data from cache for query
+      const storedData = store.readQuery(query);
+
+      if (!storedData) {
+        // There are no data in the cache yet
+        return;
+      }
+
+      const vrIndex = storedData.items.findIndex(
+        (validityRange) => validityRange.id === this.timeGrids.range.id
+      );
+
+      // Remove item from stored data
+      const tgIndex = storedData.items[vrIndex].timeGrids.findIndex(
+        (m) => m.id === this.timeGrids.deleteItem.id
+      );
+      storedData.items[vrIndex].timeGrids.splice(tgIndex, 1);
+
+      // Write data back to the cache
+      store.writeQuery({ ...query, data: storedData });
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..274df9db207462cf7d81edff2f94b7350e5cfd56
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue
@@ -0,0 +1,120 @@
+<script setup>
+import ForeignKeyField from "aleksis.core/components/generic/forms/ForeignKeyField.vue";
+import DateField from "aleksis.core/components/generic/forms/DateField.vue";
+import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField.vue";
+</script>
+
+<template>
+  <foreign-key-field
+    v-bind="$attrs"
+    v-on="$listeners"
+    :fields="headers"
+    create-item-i18n-key="lesrooster.validity_range.create_validity_range"
+    :gql-query="gqlQuery"
+    :gql-create-mutation="gqlCreateMutation"
+    :gql-patch-mutation="{}"
+    :default-item="defaultItem"
+    :get-create-data="getCreateData"
+    :get-patch-data="getPatchData"
+    return-object
+  >
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #schoolTerm.field="{ attrs, on }">
+      <div aria-required="true">
+        <school-term-field
+          v-bind="attrs"
+          v-on="on"
+          return-object
+          :rules="required"
+          required
+        />
+      </div>
+    </template>
+
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #dateStart.field="{ attrs, on, item }">
+      <div aria-required="true">
+        <date-field
+          v-bind="attrs"
+          v-on="on"
+          :rules="required"
+          :max="item ? item.dateEnd : undefined"
+        ></date-field>
+      </div>
+    </template>
+
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #dateEnd.field="{ attrs, on, item }">
+      <div aria-required="true">
+        <date-field
+          v-bind="attrs"
+          v-on="on"
+          required
+          :rules="required"
+          :min="item ? item.dateStart : undefined"
+        ></date-field>
+      </div>
+    </template>
+  </foreign-key-field>
+</template>
+
+<script>
+import { validityRanges, createValidityRange } from "./validityRange.graphql";
+
+export default {
+  name: "ValidityRangeField",
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t("lesrooster.validity_range.name"),
+          value: "name",
+        },
+        {
+          text: this.$t("school_term.title"),
+          value: "schoolTerm",
+        },
+        {
+          text: this.$t("lesrooster.validity_range.date_start"),
+          value: "dateStart",
+        },
+        {
+          text: this.$t("lesrooster.validity_range.date_end"),
+          value: "dateEnd",
+        },
+      ],
+      i18nKey: "lesrooster.validity_range",
+      gqlQuery: validityRanges,
+      gqlCreateMutation: createValidityRange,
+      defaultItem: {
+        name: "",
+        dateStart: "",
+        dateEnd: "",
+        schoolTerm: "",
+      },
+      required: [(value) => !!value || this.$t("forms.errors.required")],
+    };
+  },
+  methods: {
+    getCreateData(item) {
+      console.log("in getCreateData", item);
+      return {
+        ...item,
+        schoolTerm: item.schoolTerm?.id,
+      };
+    },
+    getPatchData(items) {
+      console.log("patch items", items);
+      return items.map((item) => ({
+        id: item.id,
+        name: item.name,
+        dateStart: item.dateStart,
+        dateEnd: item.dateEnd,
+        schoolTerm: item.schoolTerm.id,
+      }));
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeStatusChip.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeStatusChip.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6cbbe27b840b19723b07034638e7b115a3e0041d
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeStatusChip.vue
@@ -0,0 +1,32 @@
+<script>
+import { defineComponent } from "vue";
+import validityRangeStatuses from "./validityRangeStatuses";
+export default defineComponent({
+  name: "ValidityRangeStatusChip",
+  props: {
+    value: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      validityRangeStatuses,
+    };
+  },
+  computed: {
+    status() {
+      return validityRangeStatuses[this.value];
+    },
+  },
+});
+</script>
+
+<template>
+  <v-chip v-bind="$attrs" :color="status.color" outlined>
+    <v-icon left>{{ status.icon }}</v-icon>
+    {{ $t(status.textKey) }}
+  </v-chip>
+</template>
+
+<style scoped></style>
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeStatusField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeStatusField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..81408add87e970248b378cdc4a052def6037413d
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeStatusField.vue
@@ -0,0 +1,24 @@
+<template>
+  <v-select v-bind="$attrs" v-on="$listeners" :items="items" item-value="value">
+    <template #selection="{ item, index }">
+      <validity-range-status-chip :value="item.value" />
+    </template>
+  </v-select>
+</template>
+
+<script>
+import validityRangeStatuses from "./validityRangeStatuses";
+import ValidityRangeStatusChip from "./ValidityRangeStatusChip.vue";
+export default {
+  name: "ValidityRangeStatusField",
+  extends: "v-select",
+  components: { ValidityRangeStatusChip },
+  data() {
+    return {
+      items: Object.values(validityRangeStatuses).map((item) => {
+        return { ...item, text: this.$t(item.textKey) };
+      }),
+    };
+  },
+};
+</script>
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..5c15b259d0c77e832b3b11cce0c27c1d57be05ed
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRange.graphql
@@ -0,0 +1,135 @@
+query validityRanges($orderBy: [String], $filters: JSONString) {
+  items: validityRanges(orderBy: $orderBy, filters: $filters) {
+    id
+    name
+    status
+    schoolTerm {
+      id
+      name
+    }
+    timeGrids {
+      id
+      group {
+        id
+        name
+        shortName
+      }
+    }
+    dateStart
+    dateEnd
+    canEdit
+    canDelete
+  }
+}
+
+mutation createValidityRange($input: CreateValidityRangeInput!) {
+  createValidityRange(input: $input) {
+    item: validityRange {
+      id
+      name
+      status
+      schoolTerm {
+        id
+        name
+      }
+      timeGrids {
+        id
+        group {
+          id
+          name
+          shortName
+        }
+      }
+      dateStart
+      dateEnd
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation deleteValidityRange($id: ID!) {
+  deleteValidityRange(id: $id) {
+    ok
+  }
+}
+
+mutation deleteValidityRanges($ids: [ID]!) {
+  deleteValidityRanges(ids: $ids) {
+    deletionCount
+  }
+}
+
+mutation updateValidityRanges($input: [BatchPatchValidityRangeInput]!) {
+  batchMutation: updateValidityRanges(input: $input) {
+    items: validityRanges {
+      id
+      name
+      status
+      schoolTerm {
+        id
+        name
+      }
+      timeGrids {
+        id
+        group {
+          id
+          name
+          shortName
+        }
+      }
+      dateStart
+      dateEnd
+      canEdit
+      canDelete
+    }
+  }
+}
+
+query currentValidityRange {
+  currentValidityRange {
+    id
+    name
+    dateStart
+    dateEnd
+  }
+}
+
+mutation createTimeGrid($input: CreateTimeGridInput!) {
+  createTimeGrid(input: $input) {
+    item: timeGrid {
+      id
+      group {
+        id
+        name
+        shortName
+      }
+      validityRange {
+        id
+      }
+    }
+  }
+}
+
+mutation deleteTimeGrid($id: ID!) {
+  deleteTimeGrid(id: $id) {
+    ok
+  }
+}
+
+query timeGrids($orderBy: [String], $filters: JSONString) {
+  items: timeGrids(orderBy: $orderBy, filters: $filters) {
+    id
+    group {
+      id
+      shortName
+      name
+    }
+    validityRange {
+      id
+      name
+      dateStart
+      dateEnd
+    }
+  }
+}
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/validityRangeStatuses.js b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRangeStatuses.js
new file mode 100644
index 0000000000000000000000000000000000000000..783ff0a5ff7e78d78c9d10b3048534e698c90602
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/validityRangeStatuses.js
@@ -0,0 +1,15 @@
+export default {
+  DRAFT: {
+    value: "DRAFT",
+    textKey: "lesrooster.validity_range.status.draft",
+    color: "warning",
+    icon: "mdi-progress-wrench",
+  },
+
+  PUBLISHED: {
+    value: "PUBLISHED",
+    textKey: "lesrooster.validity_range.status.published",
+    color: "success",
+    icon: "mdi-check-circle-outline",
+  },
+};
diff --git a/aleksis/apps/lesrooster/frontend/index.js b/aleksis/apps/lesrooster/frontend/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..1a82aec7706253dd24cc8dd41d0f29c0995abf73
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/index.js
@@ -0,0 +1,124 @@
+import { hasPersonValidator } from "aleksis.core/routeValidators";
+
+export default {
+  component: () => import("aleksis.core/components/Parent.vue"),
+  meta: {
+    inMenu: true,
+    titleKey: "lesrooster.menu_title",
+    icon: "mdi-timetable",
+    validators: [hasPersonValidator],
+    permission: "lesrooster.view_lesrooster_menu_rule",
+  },
+  children: [
+    {
+      path: "validity_ranges/",
+      component: () => import("./components/validity_range/ValidityRange.vue"),
+      name: "lesrooster.validity_ranges",
+      meta: {
+        inMenu: true,
+        titleKey: "lesrooster.validity_range.menu_title",
+        icon: "mdi-calendar-expand-horizontal-outline",
+        permission: "lesrooster.view_validityranges_rule",
+      },
+    },
+    {
+      path: "raster/",
+      component: () => import("./components/lesson_raster/LessonRaster.vue"),
+      name: "lesrooster.lesson_raster",
+      meta: {
+        inMenu: true,
+        titleKey: "lesrooster.lesson_raster.menu_title",
+        toolbarTitle: "lesrooster.lesson_raster.menu_title",
+        icon: "mdi-grid-large",
+        permission: "lesrooster.manage_lesson_raster_rule",
+      },
+    },
+    {
+      path: "timebound_course_configs/plan_courses/",
+      component: () =>
+        import(
+          "./components/timebound_course_config/TimeboundCourseConfigRaster.vue"
+        ),
+      name: "lesrooster.planCourses",
+      meta: {
+        inMenu: true,
+        titleKey: "lesrooster.timebound_course_config.raster_menu_title",
+        icon: "mdi-clock-edit-outline",
+        permission: "lesrooster.view_timeboundcourseconfigs_rule",
+      },
+    },
+    {
+      path: "timetable/",
+      component: () =>
+        import("./components/timetable_management/TimetableManagement.vue"),
+      name: "lesrooster.timetable_management_select",
+      meta: {
+        inMenu: true,
+        titleKey: "lesrooster.timetable_management.menu_title",
+        toolbarTitle: "lesrooster.timetable_management.menu_title",
+        icon: "mdi-magnet",
+        permission: "lesrooster.plan_timetables_rule",
+      },
+      children: [
+        {
+          path: ":id(\\d+)/",
+          component: () =>
+            import("./components/timetable_management/TimetableManagement.vue"),
+          name: "lesrooster.timetable_management",
+          props: true,
+          meta: {
+            permission: "lesrooster.plan_timetables_rule",
+          },
+        },
+      ],
+    },
+    {
+      path: "supervisions/",
+      component: () => import("./components/supervision/Supervision.vue"),
+      name: "lesrooster.supervisions",
+      meta: {
+        inMenu: true,
+        titleKey: "lesrooster.supervision.menu_title",
+        icon: "mdi-seesaw",
+        permission: "lesrooster.view_supervisions_rule",
+      },
+    },
+    {
+      path: "slots/",
+      component: () =>
+        import("./components/breaks_and_slots/LesroosterSlot.vue"),
+      name: "lesrooster.slots",
+      meta: {
+        inMenu: true,
+        titleKey: "lesrooster.slot.menu_title",
+        icon: "mdi-border-none-variant",
+        permission: "lesrooster.view_slots_rule",
+      },
+    },
+    {
+      path: "breaks/",
+      component: () => import("./components/breaks_and_slots/Break.vue"),
+      name: "lesrooster.breaks",
+      meta: {
+        inMenu: true,
+        titleKey: "lesrooster.break.menu_title",
+        icon: "mdi-timer-sand-paused",
+        permission: "lesrooster.view_breakslots_rule",
+      },
+    },
+    {
+      path: "timebound_course_configs/",
+      component: () =>
+        import(
+          "./components/timebound_course_config/TimeboundCourseConfigCRUDTable.vue"
+        ),
+      name: "lesrooster.timeboundCourseConfigs",
+      meta: {
+        inMenu: true,
+        titleKey: "lesrooster.timebound_course_config.crud_table_menu_title",
+        icon: "mdi-timetable",
+        permission: "lesrooster.view_timeboundcourseconfigs_rule",
+      },
+    },
+  ],
+};
diff --git a/aleksis/apps/lesrooster/frontend/messages/de.json b/aleksis/apps/lesrooster/frontend/messages/de.json
new file mode 100644
index 0000000000000000000000000000000000000000..7f151145f424740ff0475eaaf2b34794baee5c2c
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/messages/de.json
@@ -0,0 +1,143 @@
+{
+  "lesrooster": {
+    "menu_title": "Unterrichtsmanagement",
+    "validity_range": {
+      "menu_title": "Gültigkeitszeiträume",
+      "title": "Gültigkeitszeitraum",
+      "title_plural": "Gültigkeitszeitraum",
+      "name": "Name",
+      "date_start": "Startdatum",
+      "date_end": "Enddatum",
+      "create_validity_range": "Gültigkeitszeitraum erstellen",
+      "status_label": "Status",
+      "status": {
+        "draft": "Entwurf",
+        "published": "Veröffentlicht"
+      },
+      "time_grid": {
+        "generic": "Generisch (Platzhalter für alle Gruppen)",
+        "explanations": {
+          "none_created": "Diesem Gültigkeitszeitraum wurden noch keine Gruppen zugeordnet, er kann daher noch nicht verwendet werden.",
+          "only_one_group": "Alle mit dem Gültigkeitszeitraum verbundenen Daten (wie Unterrichtszeiten) sind nur für die folgende Gruppe gültig.",
+          "only_generic": "Alle mit dem Gültigkeitszeitraum verbundenen Daten (wie Unterrichtszeiten) sind für alle Gruppen identisch.",
+          "multiple_set": "Mit dem Gültigkeitszeitraum verbundene Daten (wie Unterrichtszeiten) können sich für folgende Gruppen unterscheiden."
+        },
+        "create": "Gruppe auswählen",
+        "create_long": "Gruppenspezifischen Gültigkeitszeitraum erstellen",
+        "fields": {
+          "is_generic": "Ist generisch",
+          "group": "Gruppe"
+        },
+        "confirm_delete_body": "Wenn diese Gruppe von diesem Gültigkeitszeitraum entfernt wird, werden alle zugehörigen Daten wie Unterrichtszeiten oder Stundenpläne gelöscht.",
+        "repr": {
+          "default": "{0} ({1})",
+          "generic": "{name} (generisch/gültig für alle)"
+        }
+      }
+    },
+    "slot": {
+      "menu_title": "Zeitfenster",
+      "title": "Zeitfenster",
+      "title_plural": "Zeitfenster",
+      "name": "Name",
+      "weekday": "Wochentag",
+      "weekdays": "Wochentage",
+      "period": "Stunde",
+      "period_lte": "Stunde bis",
+      "period_gte": "Stunde ab",
+      "time_start": "Startzeitpunkt",
+      "time_end": "Endzeitpunkt",
+      "create_slot": "Zeitfenster erstellen",
+      "create_items": "Zeitfenster erstellen",
+      "create_items_success": "Zeitfenster erfolgreich erstellt.",
+      "create_items_error": "Fehler beim Erstellen der Zeitfenster.",
+      "confirm_delete_multiple_slots": "Wollen Sie wirklich alle Zeitfenster am {day} löschen?",
+      "repr": "Zeitfenster in Stunde {period}, von {timeStart} bis {timeEnd}"
+    },
+    "break": {
+      "menu_title": "Pausen",
+      "title": "Pause",
+      "title_plural": "Pausen",
+      "create_item": "Pause erstellen",
+      "create_items": "Pausen erstellen",
+      "create_items_success": "Pausen erfolgreich erstellt.",
+      "create_items_error": "Fehler beim Erstellen der Pausen.",
+      "repr": {
+        "default": "Pause von {timeStart} bis {timeEnd}",
+        "weekday_short": "{weekday}, {timeStart} bis {timeEnd}"
+      }
+    },
+    "timebound_course_config": {
+      "crud_table_menu_title": "Kurskonfigurationen",
+      "raster_menu_title": "Kurse planen",
+      "title": "Kurskonfiguration",
+      "title_plural": "Kurskonfigurationen",
+      "lesson_quota": "Stundenpensum",
+      "course": "Kurs",
+      "groups": "Gruppen",
+      "teachers": "Lehrkräfte",
+      "teachers_for": "Lehrkräfte für",
+      "subject_teachers": "Fachlehrkräfte",
+      "all_teachers": "Alle Lehrkräfte",
+      "no_course_selected": "Kein Kurs ausgewählt",
+      "create_timebound_course_config": "Kurskonfiguration erstellen",
+      "subject": "Fach"
+    },
+    "lesson_raster": {
+      "menu_title": "Stundenraster"
+    },
+    "timetable_management": {
+      "menu_title": "Stundenplanung",
+      "for_group": "Stundenplanung für die Gruppe {group}",
+      "timetable_for": "Stundenplan für {name}",
+      "no_lessons": "Es gibt noch keine Stunden in diesem Plan",
+      "no_slots": "Für diesen Plan sind noch keine Unterrichtszeiten definiert",
+      "back": "Zurück zur Schulstruktur",
+      "lessons_used_ratio": "{lessonsUsed}/{lessonQuota}",
+      "lessons_used_ratio_total": "{lessonsUsed}/{lessonQuota} Stunden verplant",
+      "lesson_fields": {
+        "subject": "Fach",
+        "rooms": "Räume",
+        "teachers": "Lehrer"
+      },
+      "snacks": {
+        "lesson_edit": {
+          "success": "Stunde erfolgreich bearbeitet.",
+          "error": "Fehler beim Bearbeiten der Stunde."
+        },
+        "lesson_create": {
+          "success": "Stunde erfolgreich erstellt.",
+          "error": "Fehler beim Erstellen der Stunde."
+        },
+        "lesson_move": {
+          "success": "Stunde erfolgreich verschoben.",
+          "error": "Fehler beim Verschieben der Stunde."
+        },
+        "lesson_change_length": {
+          "success": "Stundenlänge erfolgreich angepasst",
+          "error": "Fehler beim Verändern der Stundenlänge."
+        }
+      }
+    },
+    "supervision": {
+      "menu_title": "Aufsichten",
+      "title": "Aufsicht",
+      "title_plural": "Aufsichten",
+      "create_supervision": "Aufsicht erstellen",
+      "break_slot": "Pause",
+      "rooms": "Räume",
+      "teachers": "Lehrkräfte",
+      "subject": "Fach"
+    }
+  },
+  "actions": {
+    "copy_to_day": "Zu anderem Tag übernehmen",
+    "search_courses": "Kurse durchsuchen",
+    "copy_last_configuration": "Aus anderem Zeitraum übernehmen",
+    "confirm_copy_last_configuration": "Soll wirklich eine andere Konfiguration in diesen Zeitraum übernommen werden?",
+    "confirm_copy_last_configuration_message": "Diese Aktion wird alle bestehenden Daten in diesem Zeitraum löschen. Diese Aktion kann nicht rückgängig gemacht werden."
+  },
+  "labels": {
+    "select_validity_range": "Gültigkeitszeitraum auswählen"
+  }
+}
diff --git a/aleksis/apps/lesrooster/frontend/messages/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json
new file mode 100644
index 0000000000000000000000000000000000000000..2a5da55f1a73095e9479c5609a6c4553a80ac5b7
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/messages/en.json
@@ -0,0 +1,143 @@
+{
+  "lesrooster": {
+    "menu_title": "Lesson Management",
+    "validity_range": {
+      "menu_title": "Validity Ranges",
+      "title": "Validity Range",
+      "title_plural": "Validity Ranges",
+      "name": "Name",
+      "date_start": "Start Date",
+      "date_end": "End Date",
+      "create_validity_range": "Create Validity Range",
+      "status_label": "Status",
+      "status": {
+        "draft": "Draft",
+        "published": "Published"
+      },
+      "time_grid": {
+        "generic": "Generic (catch-all)",
+        "explanations": {
+          "none_created": "You haven't configured which groups this validity range is valid for, so it cannot be used yet.",
+          "only_one_group": "The data connected to this validity range (e.g. slots) is only valid for the group below.",
+          "only_generic": "All data connected to this validity range (e.g. slots) is the same for any group.",
+          "multiple_set": "Data connected to this validity range (e.g. slots) can be different for the groups below."
+        },
+        "create": "Select group",
+        "create_long": "Create group-specific validity range",
+        "fields": {
+          "is_generic": "Is generic",
+          "group": "Group"
+        },
+        "confirm_delete_body": "If you remove this group from the validity range, all connected data, like slots and lessons are deleted.",
+        "repr": {
+          "default": "{0} ({1})",
+          "generic": "{name} (generic/catch-all)"
+        }
+      }
+    },
+    "slot": {
+      "menu_title": "Slots",
+      "title": "Slot",
+      "title_plural": "Slots",
+      "name": "Name",
+      "weekday": "Weekday",
+      "weekdays": "Weekdays",
+      "period": "Period",
+      "period_lte": "Period until",
+      "period_gte": "Period from",
+      "time_start": "Start Time",
+      "time_end": "End Time",
+      "create_slot": "Create Slot",
+      "create_items": "Create Slots",
+      "create_items_success": "The slots were created successfully.",
+      "create_items_error": "Error creating slots.",
+      "confirm_delete_multiple_slots": "Do you really want to delete all slots and breaks on {day}?",
+      "repr": "Slot in period {period}, from {timeStart} to {timeEnd}"
+    },
+    "break": {
+      "menu_title": "Breaks",
+      "title": "Break",
+      "title_plural": "Breaks",
+      "create_item": "Create Break",
+      "create_items": "Create Breaks",
+      "create_items_success": "The breaks where created successfully.",
+      "create_items_error": "Error creating breaks.",
+      "repr": {
+        "default": "Break from {timeStart} to {timeEnd}",
+        "weekday_short": "{weekday}, {timeStart} to {timeEnd}"
+      }
+    },
+    "timebound_course_config": {
+      "crud_table_menu_title": "Timebound course configs",
+      "raster_menu_title": "Plan courses",
+      "title": "Timebound course config",
+      "title_plural": "Timebound course configs",
+      "lesson_quota": "Scheduled lesson quota",
+      "course": "Course",
+      "groups": "Groups",
+      "teachers": "Teachers",
+      "teachers_for": "Teachers for",
+      "subject_teachers": "Teachers for this subject",
+      "all_teachers": "All teachers",
+      "no_course_selected": "No course selected",
+      "create_timebound_course_config": "Create timebound course config",
+      "subject": "Subject"
+    },
+    "lesson_raster": {
+      "menu_title": "Lesson Raster"
+    },
+    "timetable_management": {
+      "menu_title": "Timetable Management",
+      "for_group": "Timetable management for group {group}",
+      "back": "Back to school structure",
+      "timetable_for": "Timetable for {name}",
+      "no_lessons": "No lessons in this plan",
+      "no_slots": "There are no slots defined for this plan",
+      "lessons_used_ratio": "{lessonsUsed}/{lessonQuota}",
+      "lessons_used_ratio_total": "{lessonsUsed}/{lessonQuota} lessons planned",
+      "lesson_fields": {
+        "subject": "Subject",
+        "rooms": "Rooms",
+        "teachers": "Teachers"
+      },
+      "snacks": {
+        "lesson_edit": {
+          "success": "Lesson updated successfully.",
+          "error": "Error updating lesson."
+        },
+        "lesson_create": {
+          "success": "Lesson created successfully",
+          "error": "Error creating lesson."
+        },
+        "lesson_move": {
+          "success": "Lesson moved successfully.",
+          "error": "Error moving lesson."
+        },
+        "lesson_change_length": {
+          "success": "Lesson length changed successfully.",
+          "error": "Error changing length of lesson."
+        }
+      }
+    },
+    "supervision": {
+      "menu_title": "Supervisions",
+      "title": "Supervision",
+      "title_plural": "Supervisions",
+      "create_supervision": "Create supervision",
+      "break_slot": "Break",
+      "rooms": "Rooms",
+      "teachers": "Teachers",
+      "subject": "Subject"
+    }
+  },
+  "actions": {
+    "copy_to_day": "Copy to another day",
+    "search_courses": "Search Courses",
+    "copy_last_configuration": "Copy from different range",
+    "confirm_copy_last_configuration": "Do you really want to copy another configuration to this range?",
+    "confirm_copy_last_configuration_message": "This will overwrite all existing data in this range. This action cannot be undone."
+  },
+  "labels": {
+    "select_validity_range": "Select Validity Range"
+  }
+}
diff --git a/aleksis/apps/lesrooster/managers.py b/aleksis/apps/lesrooster/managers.py
index f6cc01f1c3f81ca05fa572b361fb4dd7ca329a36..10703b00c271ed9d9be8e74b639c47aa0d76d390 100644
--- a/aleksis/apps/lesrooster/managers.py
+++ b/aleksis/apps/lesrooster/managers.py
@@ -9,5 +9,3 @@ class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin):
 
 class ValidityRangeManager(AlekSISBaseManagerWithoutMigrations):
     """Manager for validity ranges."""
-
-    queryset_class = ValidityRangeQuerySet
diff --git a/aleksis/apps/lesrooster/migrations/0001_initial.py b/aleksis/apps/lesrooster/migrations/0001_initial.py
index a41304866a4dce9ce84e3778ba6b6c2998ce4a98..1cea6d5a7f046be66b2c49ac31e453d0db445b04 100644
--- a/aleksis/apps/lesrooster/migrations/0001_initial.py
+++ b/aleksis/apps/lesrooster/migrations/0001_initial.py
@@ -15,8 +15,8 @@ class Migration(migrations.Migration):
         ("core", "0052_site_related_name"),
         ("contenttypes", "0002_remove_content_type_name"),
         ("sites", "0002_alter_domain_unique"),
-        ("chronos", "0015_managed_by_site"),
-        ("cursus", "0003_add_course_lesson_quota"),
+        ("chronos", "0015_add_managed_by_app_label"),
+        ("cursus", "0001_initial"),
     ]
 
     operations = [
diff --git a/aleksis/apps/lesrooster/migrations/0002_timeboundcourseconfig.py b/aleksis/apps/lesrooster/migrations/0002_timeboundcourseconfig.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe6ca47de4f94b0229f70b23b08c42ff998e6e41
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0002_timeboundcourseconfig.py
@@ -0,0 +1,96 @@
+# Generated by Django 4.2.3 on 2023-07-31 12:12
+
+import aleksis.core.managers
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("sites", "0002_alter_domain_unique"),
+        ("core", "0052_site_related_name"),
+        ("cursus", "0001_initial"),
+        ("lesrooster", "0001_initial"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="TimeboundCourseConfig",
+            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)),
+                (
+                    "scheduled_slot_count",
+                    models.PositiveSmallIntegerField(
+                        blank=True,
+                        null=True,
+                        verbose_name="Number of slots this course is scheduled to fill per week",
+                    ),
+                ),
+                (
+                    "course",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="lr_timebound_course_configs",
+                        to="cursus.course",
+                        verbose_name="Course",
+                    ),
+                ),
+                (
+                    "site",
+                    models.ForeignKey(
+                        default=1,
+                        editable=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="+",
+                        to="sites.site",
+                    ),
+                ),
+                (
+                    "teachers",
+                    models.ManyToManyField(
+                        related_name="lr_timebound_course_configs",
+                        to="core.person",
+                        verbose_name="Teachers",
+                    ),
+                ),
+                (
+                    "validity_range",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="lr_timebound_course_configs",
+                        to="lesrooster.validityrange",
+                        verbose_name="Linked validity range",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Timebound course config",
+                "verbose_name_plural": "Timebound course configs",
+            },
+            managers=[
+                ("objects", aleksis.core.managers.AlekSISBaseManager()),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name="timeboundcourseconfig",
+            constraint=models.UniqueConstraint(
+                fields=("course", "validity_range"), name="lr_unique_course_config_per_range"
+            ),
+        ),
+    ]
diff --git a/aleksis/apps/lesrooster/migrations/0003_timegrid.py b/aleksis/apps/lesrooster/migrations/0003_timegrid.py
new file mode 100644
index 0000000000000000000000000000000000000000..453e2e4947cc1ff057f1421b726a6981f8fa9589
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0003_timegrid.py
@@ -0,0 +1,81 @@
+# Generated by Django 4.2.4 on 2023-08-14 18:42
+
+import aleksis.core.managers
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("core", "0052_site_related_name"),
+        ("sites", "0002_alter_domain_unique"),
+        ("lesrooster", "0002_timeboundcourseconfig"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="TimeGrid",
+            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)),
+                (
+                    "group",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="time_grids",
+                        to="core.group",
+                        verbose_name="Group",
+                    ),
+                ),
+                (
+                    "site",
+                    models.ForeignKey(
+                        default=1,
+                        editable=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="+",
+                        to="sites.site",
+                    ),
+                ),
+                (
+                    "validity_range",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="time_grids",
+                        to="lesrooster.validityrange",
+                        verbose_name="Linked validity range",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Time Grid",
+                "verbose_name_plural": "Time Grids",
+            },
+            managers=[
+                ("objects", aleksis.core.managers.AlekSISBaseManager()),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name="timegrid",
+            constraint=models.UniqueConstraint(
+                fields=("validity_range", "group"), name="lr_unique_validity_range_group_time_grid"
+            ),
+        ),
+    ]
diff --git a/aleksis/apps/lesrooster/migrations/0004_slot_timegrid.py b/aleksis/apps/lesrooster/migrations/0004_slot_timegrid.py
new file mode 100644
index 0000000000000000000000000000000000000000..009c5e25e1c2fb3e867df4d4250b77b3ccd84d13
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0004_slot_timegrid.py
@@ -0,0 +1,41 @@
+# Generated by Django 4.2.4 on 2023-08-14 18:44
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("lesrooster", "0003_timegrid"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="lesson",
+            options={
+                "ordering": [
+                    "slot_start__time_grid__validity_range__date_start",
+                    "slot_start__weekday",
+                    "slot_start__time_start",
+                    "subject",
+                ],
+                "verbose_name": "Lesson",
+                "verbose_name_plural": "Lessons",
+            },
+        ),
+        migrations.AddField(
+            model_name="slot",
+            name="time_grid",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="slots",
+                to="lesrooster.timegrid",
+                verbose_name="Time Grid",
+                null=True,
+                blank=True
+            ),
+            preserve_default=False,
+        ),
+    ]
diff --git a/aleksis/apps/lesrooster/migrations/0005_migrate_slot_to_timegrid.py b/aleksis/apps/lesrooster/migrations/0005_migrate_slot_to_timegrid.py
new file mode 100644
index 0000000000000000000000000000000000000000..191820bb0d9ebc9776612e8205ffc8fcf054cd5a
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0005_migrate_slot_to_timegrid.py
@@ -0,0 +1,28 @@
+# Generated by Django 4.2.4 on 2023-08-14 18:44
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+def _migrate_values(apps, schema_editor):
+    TimeGrid = apps.get_model("lesrooster", "TimeGrid")
+    Slot = apps.get_model("lesrooster", "Slot")
+
+    for slot in Slot.objects.all():
+        tgs = TimeGrid.objects.filter(validity_range=slot.validity_range)
+        if tgs:
+            tg = tgs.first()
+        else:
+            tg = TimeGrid.objects.create(validity_range=slot.validity_range)
+        slot.time_grid = tg
+        slot.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("lesrooster", "0004_slot_timegrid"),
+    ]
+
+    operations = [
+        migrations.RunPython(_migrate_values),
+    ]
diff --git a/aleksis/apps/lesrooster/migrations/0006_slot_drop_validityrange.py b/aleksis/apps/lesrooster/migrations/0006_slot_drop_validityrange.py
new file mode 100644
index 0000000000000000000000000000000000000000..6af2f9a1608c649102765b556ba4b79722cf6c2a
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0006_slot_drop_validityrange.py
@@ -0,0 +1,39 @@
+# Generated by Django 4.2.4 on 2023-08-14 18:44
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("lesrooster", "0005_migrate_slot_to_timegrid"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="slot",
+            name="time_grid",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="slots",
+                to="lesrooster.timegrid",
+                verbose_name="Time Grid",
+            ),
+        ),
+        migrations.RemoveConstraint(
+            model_name="slot",
+            name="lr_unique_period_per_range",
+        ),
+        migrations.RemoveField(
+            model_name="slot",
+            name="validity_range",
+        ),
+        migrations.AddConstraint(
+            model_name="slot",
+            constraint=models.UniqueConstraint(
+                fields=("weekday", "period", "time_grid"), name="lr_unique_period_per_range"
+            ),
+        ),
+    ]
diff --git a/aleksis/apps/lesrooster/migrations/0007_rename_scheduled_slot_count_timeboundcourseconfig_lesson_quota.py b/aleksis/apps/lesrooster/migrations/0007_rename_scheduled_slot_count_timeboundcourseconfig_lesson_quota.py
new file mode 100644
index 0000000000000000000000000000000000000000..7b5dcd30d82b614a1f8afef90e9eb9c3029f5edf
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0007_rename_scheduled_slot_count_timeboundcourseconfig_lesson_quota.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.10 on 2023-08-14 16:29
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("lesrooster", "0006_slot_drop_validityrange"),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name="timeboundcourseconfig",
+            old_name="scheduled_slot_count",
+            new_name="lesson_quota",
+        ),
+    ]
diff --git a/aleksis/apps/lesrooster/migrations/0008_one_default_time_grid.py b/aleksis/apps/lesrooster/migrations/0008_one_default_time_grid.py
new file mode 100644
index 0000000000000000000000000000000000000000..0ba71368ff540da131759b2319e4532629a18a64
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0008_one_default_time_grid.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.2.4 on 2023-08-15 19:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("lesrooster", "0007_rename_scheduled_slot_count_timeboundcourseconfig_lesson_quota"),
+    ]
+
+    operations = [
+        migrations.AddConstraint(
+            model_name="timegrid",
+            constraint=models.UniqueConstraint(
+                condition=models.Q(("group", None)),
+                fields=("validity_range",),
+                name="lr_one_default_time_grid",
+            ),
+        ),
+    ]
diff --git a/aleksis/apps/lesrooster/migrations/0009_lesroosterglobalpermissions.py b/aleksis/apps/lesrooster/migrations/0009_lesroosterglobalpermissions.py
new file mode 100644
index 0000000000000000000000000000000000000000..1a8a7f06fe9588dd791c4e2513041a6d82a92a2a
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0009_lesroosterglobalpermissions.py
@@ -0,0 +1,31 @@
+# Generated by Django 4.2.4 on 2023-08-15 21:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("lesrooster", "0008_one_default_time_grid"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="LesroosterGlobalPermissions",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+                    ),
+                ),
+            ],
+            options={
+                "permissions": (
+                    ("view_lesson_raster", "Can view lesson raster"),
+                    ("view_timetable_creation", "Can view timetable creation"),
+                ),
+                "managed": False,
+            },
+        ),
+    ]
diff --git a/aleksis/apps/lesrooster/migrations/0010_multiple_validity_ranges_per_time.py b/aleksis/apps/lesrooster/migrations/0010_multiple_validity_ranges_per_time.py
new file mode 100644
index 0000000000000000000000000000000000000000..a974e04f5c75f4a7297f5e487b263b116e4ef7e2
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0010_multiple_validity_ranges_per_time.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.2.4 on 2023-08-16 18:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("lesrooster", "0009_lesroosterglobalpermissions"),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name="validityrange",
+            name="lr_unique_dates_per_term",
+        ),
+        migrations.AddConstraint(
+            model_name="validityrange",
+            constraint=models.UniqueConstraint(
+                condition=models.Q(("status", "published")),
+                fields=("school_term", "date_start", "date_end"),
+                name="lr_unique_dates_per_term",
+            ),
+        ),
+    ]
diff --git a/aleksis/apps/lesrooster/migrations/0011_alter_lesroosterglobalpermissions_options.py b/aleksis/apps/lesrooster/migrations/0011_alter_lesroosterglobalpermissions_options.py
new file mode 100644
index 0000000000000000000000000000000000000000..ba2b950c409de3b1e953a6c979cf260c7e7b5619
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0011_alter_lesroosterglobalpermissions_options.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.4 on 2023-08-22 12:20
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("lesrooster", "0010_multiple_validity_ranges_per_time"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="lesroosterglobalpermissions",
+            options={
+                "managed": False,
+                "permissions": (
+                    ("manage_lesson_raster", "Can manage lesson raster"),
+                    ("plan_timetables", "Can plan timetables"),
+                ),
+            },
+        ),
+    ]
diff --git a/aleksis/apps/lesrooster/migrations/0012_alter_timeboundcourseconfig_lesson_quota_and_more.py b/aleksis/apps/lesrooster/migrations/0012_alter_timeboundcourseconfig_lesson_quota_and_more.py
new file mode 100644
index 0000000000000000000000000000000000000000..2175c10bd3c6c2d9052c78d5c5d1802e003b5b32
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0012_alter_timeboundcourseconfig_lesson_quota_and_more.py
@@ -0,0 +1,37 @@
+# Generated by Django 4.2.4 on 2023-09-16 18:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("lesrooster", "0011_alter_lesroosterglobalpermissions_options"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="timeboundcourseconfig",
+            name="lesson_quota",
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                help_text="Number of slots this course is scheduled to fill per week",
+                null=True,
+                verbose_name="Lesson quota",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="slot",
+            constraint=models.CheckConstraint(
+                check=models.Q(("time_start__lte", models.F("time_end"))),
+                name="time_start_lte_time_end",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="validityrange",
+            constraint=models.CheckConstraint(
+                check=models.Q(("date_start__lte", models.F("date_end"))),
+                name="date_start_lte_date_end",
+            ),
+        ),
+    ]
diff --git a/aleksis/apps/lesrooster/migrations/0013_supervision_subject_supervisionsubstitution_subject.py b/aleksis/apps/lesrooster/migrations/0013_supervision_subject_supervisionsubstitution_subject.py
new file mode 100644
index 0000000000000000000000000000000000000000..9425e44222147a49549c7bc956c866bad0fea61c
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0013_supervision_subject_supervisionsubstitution_subject.py
@@ -0,0 +1,39 @@
+# Generated by Django 4.2.4 on 2023-09-13 18:50
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("cursus", "0001_initial"),
+        ("lesrooster", "0012_alter_timeboundcourseconfig_lesson_quota_and_more"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="supervision",
+            name="subject",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="lr_supervisions",
+                to="cursus.subject",
+                verbose_name="Subject",
+            ),
+        ),
+        migrations.AddField(
+            model_name="supervisionsubstitution",
+            name="subject",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="lr_supervision_substitutions",
+                to="cursus.subject",
+                verbose_name="Subject",
+            ),
+        ),
+    ]
diff --git a/aleksis/apps/lesrooster/migrations/0014_remove_breakslot_period_after.py b/aleksis/apps/lesrooster/migrations/0014_remove_breakslot_period_after.py
new file mode 100644
index 0000000000000000000000000000000000000000..24fac30b27ecefdcf94a8bc7e1a6f5d5b8d99e58
--- /dev/null
+++ b/aleksis/apps/lesrooster/migrations/0014_remove_breakslot_period_after.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.5 on 2023-10-26 13:12
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("lesrooster", "0013_supervision_subject_supervisionsubstitution_subject"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="breakslot",
+            name="period_after",
+        ),
+    ]
diff --git a/aleksis/apps/lesrooster/model_extensions.py b/aleksis/apps/lesrooster/model_extensions.py
index 7401fac79572039dfcbee9d422d81c8dabaef301..93a246ff2ab66394942ce82689eac3abfa981300 100644
--- a/aleksis/apps/lesrooster/model_extensions.py
+++ b/aleksis/apps/lesrooster/model_extensions.py
@@ -1,9 +1,13 @@
 from django.utils.translation import gettext as _
 
-from jsonstore import BooleanField
+from jsonstore import BooleanField, IntegerField
 
-from aleksis.core.models import Room
+from aleksis.core.models import Person, Room
 
 Room.field(
     is_supervision_area=BooleanField(verbose_name=_("Is supervision area"), null=True, blank=True)
 )
+
+Person.field(
+    lesson_quota=IntegerField(verbose_name=_("Lesson quota as a teacher"), null=True, blank=True)
+)
diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py
index 5fe0a3f383fb00bca757ac59370b2ed21537aef1..13708570540847e94e4ed9d51148a91c9ef66a8e 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -1,15 +1,15 @@
-from copy import deepcopy
 from datetime import date, datetime
 from typing import Optional, Union
 
 from django.core.exceptions import ValidationError
 from django.db import models
-from django.db.models import Q, QuerySet
+from django.db.models import F, Q, QuerySet
 from django.utils import timezone
 from django.utils.formats import date_format, time_format
 from django.utils.functional import classproperty
 from django.utils.translation import gettext_lazy as _
 
+import recurrence
 from calendarweek import CalendarWeek
 from calendarweek.django import i18n_day_abbr_choices_lazy, i18n_day_name_choices_lazy
 from recurrence.fields import RecurrenceField
@@ -17,10 +17,10 @@ from recurrence.fields import RecurrenceField
 from aleksis.apps.chronos.managers import RoomPropertiesMixin, TeacherPropertiesMixin
 from aleksis.apps.chronos.models import LessonEvent, SupervisionEvent
 from aleksis.apps.cursus.models import Course, Subject
-from aleksis.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel
+from aleksis.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel, GlobalPermissionModel
 from aleksis.core.models import Group, Holiday, Person, Room, SchoolTerm
 
-from .managers import ValidityRangeManager
+from .managers import ValidityRangeManager, ValidityRangeQuerySet
 
 
 class ValidityRangeStatus(models.TextChoices):
@@ -33,7 +33,7 @@ class ValidityRangeStatus(models.TextChoices):
 class ValidityRange(ExtensibleModel):
     """A validity range is a date range in which certain data are valid."""
 
-    objects = ValidityRangeManager()
+    objects = ValidityRangeManager.from_queryset(ValidityRangeQuerySet)()
 
     school_term = models.ForeignKey(
         SchoolTerm,
@@ -53,6 +53,10 @@ class ValidityRange(ExtensibleModel):
         default=ValidityRangeStatus.DRAFT,
     )
 
+    @property
+    def published(self):
+        return self.status == ValidityRangeStatus.PUBLISHED.value
+
     @classmethod
     def get_current(cls, day: Optional[date] = None) -> Optional["ValidityRange"]:
         """Get the currently active validity range."""
@@ -80,13 +84,19 @@ class ValidityRange(ExtensibleModel):
             ):
                 raise ValidationError(_("The validity range must be within the school term."))
 
-        qs = ValidityRange.objects.within_dates(self.date_start, self.date_end)
-        if self.pk:
-            qs = qs.exclude(pk=self.pk)
-        if qs.exists():
-            raise ValidationError(
-                _("There is already a validity range for this time or a part of this time.")
+        if self.status == ValidityRangeStatus.PUBLISHED.value:
+            qs = ValidityRange.objects.within_dates(self.date_start, self.date_end).filter(
+                status=ValidityRangeStatus.PUBLISHED
             )
+            if self.pk:
+                qs = qs.exclude(pk=self.pk)
+            if qs.exists():
+                raise ValidationError(
+                    _(
+                        "There is already a published validity range "
+                        "for this time or a part of this time."
+                    )
+                )
 
     def __str__(self) -> str:
         return self.name or f"{date_format(self.date_start)}–{date_format(self.date_end)}"
@@ -97,7 +107,12 @@ class ValidityRange(ExtensibleModel):
         constraints = [
             # Heads up: Uniqueness per term implies uniqueness per site
             models.UniqueConstraint(
-                fields=["school_term", "date_start", "date_end"], name="lr_unique_dates_per_term"
+                fields=["school_term", "date_start", "date_end"],
+                condition=Q(status=ValidityRangeStatus.PUBLISHED),
+                name="lr_unique_dates_per_term",
+            ),
+            models.CheckConstraint(
+                check=Q(date_start__lte=F("date_end")), name="date_start_lte_date_end"
             ),
         ]
         indexes = [
@@ -105,17 +120,52 @@ class ValidityRange(ExtensibleModel):
         ]
 
 
+class TimeGrid(ExtensibleModel):
+    validity_range = models.ForeignKey(
+        ValidityRange,
+        on_delete=models.CASCADE,
+        related_name="time_grids",
+        verbose_name=_("Linked validity range"),
+    )
+
+    group = models.ForeignKey(
+        Group,
+        verbose_name=_("Group"),
+        on_delete=models.SET_NULL,
+        related_name="time_grids",
+        blank=True,
+        null=True,
+    )
+
+    def __str__(self):
+        if self.group:
+            return f"{self.validity_range}: {self.group}"
+        return str(self.validity_range)
+
+    class Meta:
+        verbose_name = _("Time Grid")
+        verbose_name_plural = _("Time Grids")
+        constraints = [
+            models.UniqueConstraint(
+                fields=["validity_range", "group"], name="lr_unique_validity_range_group_time_grid"
+            ),
+            models.UniqueConstraint(
+                fields=["validity_range"], condition=Q(group=None), name="lr_one_default_time_grid"
+            ),
+        ]
+
+
 class Slot(ExtensiblePolymorphicModel):
     """A slot is a time period in which a lesson can take place."""
 
     WEEKDAY_CHOICES = i18n_day_name_choices_lazy()
     WEEKDAY_CHOICES_SHORT = i18n_day_abbr_choices_lazy()
 
-    validity_range = models.ForeignKey(
-        ValidityRange,
+    time_grid = models.ForeignKey(
+        TimeGrid,
         on_delete=models.CASCADE,
-        related_name="lr_slots",
-        verbose_name=_("Linked validity range"),
+        related_name="slots",
+        verbose_name=_("Time Grid"),
     )
 
     name = models.CharField(verbose_name=_("Name"), max_length=255, blank=True)
@@ -151,6 +201,9 @@ class Slot(ExtensiblePolymorphicModel):
             day = self.get_date(date_ref)
         return timezone.make_aware(datetime.combine(day, self.time_start))
 
+    def get_first_datetime(self) -> datetime:
+        return self.get_datetime_start(self.time_grid.validity_range.date_start)
+
     def get_datetime_end(self, date_ref: Union[CalendarWeek, int, date]) -> datetime:
         """Get datetime of lesson end in a specific week or on a specific day."""
         if isinstance(date_ref, date):
@@ -159,11 +212,17 @@ class Slot(ExtensiblePolymorphicModel):
             day = self.get_date(date_ref)
         return timezone.make_aware(datetime.combine(day, self.time_end))
 
+    def get_last_datetime(self) -> datetime:
+        return self.get_datetime_end(self.time_grid.validity_range.date_end)
+
     class Meta:
         constraints = [
             # Heads up: Uniqueness per validity range implies validity per site
             models.UniqueConstraint(
-                fields=["weekday", "period", "validity_range"], name="lr_unique_period_per_range"
+                fields=["weekday", "period", "time_grid"], name="lr_unique_period_per_range"
+            ),
+            models.CheckConstraint(
+                check=Q(time_start__lte=F("time_end")), name="time_start_lte_time_end"
             ),
         ]
         ordering = ["weekday", "period"]
@@ -248,46 +307,62 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel):
 
     def clean(self):
         """Ensure that the slots are in the same validity range."""
-        if self.slot_start.validity != self.slot_end.validity:
-            raise ValidationError(_("The slots must be in the same validity range."))
+        if self.slot_start.time_grid != self.slot_end.time_grid:
+            raise ValidationError(_("The slots must be in the same time grid."))
+
+    @property
+    def real_recurrence(self) -> "recurrence.Recurrence":
+        """Get the real recurrence adjusted to the validity range and including holidays."""
+        if not self.recurrence:
+            return
+        rrules = self.recurrence.rrules
+        for rrule in rrules:
+            rrule.until = self.slot_end.get_last_datetime()
+        pattern = recurrence.Recurrence(
+            dtstart=self.slot_start.get_first_datetime(),
+            rrules=rrules,
+        )
+        pattern.exdates = Holiday.get_ex_dates(
+            self.slot_start.get_first_datetime(), self.slot_end.get_last_datetime(), pattern
+        )
+        return pattern
 
     def sync(self) -> LessonEvent:
         """Sync the lesson with its lesson event."""
-        week_start = CalendarWeek.from_date(self.slot_start.validity_range.date_start)
-        week_end = CalendarWeek.from_date(self.slot_start.validity_range.date_end)
+        week_start = CalendarWeek.from_date(self.slot_start.time_grid.validity_range.date_start)
+        week_end = CalendarWeek.from_date(self.slot_start.time_grid.validity_range.date_end)
         datetime_start = self.slot_start.get_datetime_start(week_start)
         datetime_end = self.slot_end.get_datetime_end(week_start)
         datetime_end_series = self.slot_end.get_datetime_end(week_end)
 
-        lesson_event, __ = LessonEvent.objects.update_or_create(
-            lesson=self,
-            defaults={
-                "course": self.course,
-                "subject": self.subject,
-                "datetime_start": datetime_start,
-                "datetime_end": datetime_end,
-            },
-        )
-        if self.recurrence:
-            lesson_event.recurrences = deepcopy(self.recurrence)
-            lesson_event.recurrences.exdates += Holiday.get_ex_dates(
-                datetime_start, datetime_end_series, self.recurrence
-            )
+        if not self.lesson_event:
+            lesson_event = LessonEvent()
+        else:
+            lesson_event = self.lesson_event
+
+        lesson_event.course = self.course
+        lesson_event.subject = self.subject
+        lesson_event.datetime_start = datetime_start
+        lesson_event.datetime_end = datetime_end
+
+        lesson_event.recurrences = self.real_recurrence
+
         lesson_event.save()
 
+        lesson_event.groups.set(self.course.groups.all())
+        lesson_event.teachers.set(self.teachers.all())
+        lesson_event.rooms.set(self.rooms.all())
+
         if self.lesson_event != lesson_event:
             self.lesson_event = lesson_event
             self.save()
 
-        lesson_event.groups.set(self.course.groups.all())
-        lesson_event.teachers.set(self.teachers.all())
-        lesson_event.rooms.set(self.rooms.all())
         return lesson_event
 
     class Meta:
         # Heads up: Link to slot implies uniqueness per site
         ordering = [
-            "slot_start__validity_range__date_start",
+            "slot_start__time_grid__validity_range__date_start",
             "slot_start__weekday",
             "slot_start__time_start",
             "subject",
@@ -299,15 +374,8 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel):
 class BreakSlot(Slot):
     """A break is a time period that can supervised and in which no lessons take place."""
 
-    period_after = models.IntegerField(
-        verbose_name=_("Period after"),
-    )
-
     def __str__(self) -> str:
-        return (
-            f"{self.period_after - 1}./{self.period_after}. "
-            f"({time_format(self.time_start)} - {time_format(self.time_end)})"
-        )
+        return f"{time_format(self.time_start)} - {time_format(self.time_end)}"
 
     class Meta:
         verbose_name = _("Break")
@@ -342,6 +410,14 @@ class Supervision(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel):
         verbose_name=_("Break Slot"),
         related_name="lr_supervisions",
     )
+    subject = models.ForeignKey(
+        Subject,
+        on_delete=models.CASCADE,
+        verbose_name=_("Subject"),
+        related_name="lr_supervisions",
+        blank=True,
+        null=True,
+    )
 
     # Recurrence rules allow to define a series of supervisions
     # Common examples are weekly or every second week
@@ -365,35 +441,51 @@ class Supervision(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel):
         verbose_name = _("Supervision")
         verbose_name_plural = _("Supervisions")
 
+    @property
+    def real_recurrence(self) -> "recurrence.Recurrence":
+        """Get the real recurrence adjusted to the validity range and including holidays."""
+        if not self.recurrence:
+            return
+        rrules = self.recurrence.rrules
+        for rrule in rrules:
+            rrule.until = self.break_slot.get_last_datetime()
+        pattern = recurrence.Recurrence(
+            dtstart=self.break_slot.get_first_datetime(),
+            rrules=rrules,
+        )
+        pattern.exdates = Holiday.get_ex_dates(
+            self.break_slot.get_first_datetime(), self.break_slot.get_last_datetime(), pattern
+        )
+        return pattern
+
     def sync(self) -> SupervisionEvent:
         """Sync the supervision with its supervision event."""
-        week_start = CalendarWeek.from_date(self.break_slot.validity_range.date_start)
-        week_end = CalendarWeek.from_date(self.break_slot.validity_range.date_end)
+        week_start = CalendarWeek.from_date(self.break_slot.time_grid.validity_range.date_start)
+        week_end = CalendarWeek.from_date(self.break_slot.time_grid.validity_range.date_end)
         datetime_start = self.break_slot.get_datetime_start(week_start)
         datetime_end = self.break_slot.get_datetime_end(week_start)
         datetime_end_series = self.break_slot.get_datetime_end(week_end)
 
-        supervision_event, __ = SupervisionEvent.objects.update_or_create(
-            supervision=self,
-            defaults={
-                "datetime_start": datetime_start,
-                "datetime_end": datetime_end,
-            },
-        )
+        if self.supervision_event:
+            supervision_event = self.supervision_event
+        else:
+            supervision_event = SupervisionEvent()
+
+        supervision_event.datetime_start = datetime_start
+        supervision_event.datetime_end = datetime_end
+        supervision_event.subject = self.subject
+
+        supervision_event.recurrences = self.real_recurrence
 
-        if self.recurrence:
-            supervision_event.recurrences = deepcopy(self.recurrence)
-            supervision_event.recurrences.exdates += Holiday.get_ex_dates(
-                datetime_start, datetime_end_series, self.recurrence
-            )
         supervision_event.save()
 
+        supervision_event.teachers.set(self.teachers.all())
+        supervision_event.rooms.set(self.rooms.all())
+
         if self.supervision_event != supervision_event:
             self.supervision_event = supervision_event
             self.save()
 
-        supervision_event.teachers.set(self.teachers.all())
-        supervision_event.rooms.set(self.rooms.all())
         return supervision_event
 
 
@@ -463,26 +555,29 @@ class Substitution(RoomPropertiesMixin, TeacherPropertiesMixin, ExtensibleModel)
         week = CalendarWeek.from_date(self.date)
         if not self.lesson.lesson_event:
             return None
-        lesson_event, __ = LessonEvent.objects.update_or_create(
-            substitution=self,
-            defaults={
-                "amends": self.lesson.lesson_event,
-                "course": self.lesson.course,
-                "subject": self.subject,
-                "datetime_start": self.lesson.slot_start.get_datetime_start(week),
-                "datetime_end": self.lesson.slot_end.get_datetime_end(week),
-                "cancelled": self.cancelled,
-                "comment": self.comment,
-            },
-        )
 
-        if self.lesson_event != lesson_event:
-            self.lesson_event = lesson_event
-            self.save()
+        if self.lesson_event:
+            lesson_event = self.lesson_event
+        else:
+            lesson_event = LessonEvent()
+
+        lesson_event.amends = self.lesson.lesson_event
+        lesson_event.course = self.lesson.course
+        lesson_event.subject = self.subject
+        lesson_event.datetime_start = self.lesson.slot_start.get_datetime_start(week)
+        lesson_event.datetime_end = self.lesson.slot_end.get_datetime_end(week)
+        lesson_event.cancelled = self.cancelled
+        lesson_event.comment = self.comment
+        lesson_event.save()
 
         lesson_event.groups.set(self.lesson.course.groups.all())
         lesson_event.teachers.set(self.teachers.all())
         lesson_event.rooms.set(self.rooms.all())
+
+        if self.lesson_event != lesson_event:
+            self.lesson_event = lesson_event
+            self.save()
+
         return lesson_event
 
     class Meta:
@@ -531,6 +626,14 @@ class SupervisionSubstitution(TeacherPropertiesMixin, ExtensibleModel):
         related_name="lr_supervision_substitutions",
         blank=True,
     )
+    subject = models.ForeignKey(
+        Subject,
+        on_delete=models.CASCADE,
+        verbose_name=_("Subject"),
+        related_name="lr_supervision_substitutions",
+        blank=True,
+        null=True,
+    )
 
     cancelled = models.BooleanField(default=False, verbose_name=_("Cancelled?"))
 
@@ -554,22 +657,26 @@ class SupervisionSubstitution(TeacherPropertiesMixin, ExtensibleModel):
         week = CalendarWeek.from_date(self.date)
         if not self.supervision.supervision_event:
             return None
-        supervision_event, __ = SupervisionEvent.objects.update_or_create(
-            supervision_substitution=self,
-            defaults={
-                "amends": self.supervision.supervision_event,
-                "datetime_start": self.supervision.break_slot.get_datetime_start(week),
-                "datetime_end": self.supervision.break_slot.get_datetime_end(week),
-                "cancelled": self.cancelled,
-                "comment": self.comment,
-            },
-        )
+
+        if self.supervision_event:
+            supervision_event = self.supervision_event
+        else:
+            supervision_event = SupervisionEvent()
+
+        supervision_event.amends = self.supervision.supervision_event
+        supervision_event.datetime_start = self.supervision.break_slot.get_datetime_start(week)
+        supervision_event.datetime_end = self.supervision.break_slot.get_datetime_end(week)
+        supervision_event.cancelled = self.cancelled
+        supervision_event.comment = self.comment
+        supervision_event.subject = self.subject
+        supervision_event.save()
+
+        supervision_event.teachers.set(self.teachers.all())
 
         if self.supervision_event != supervision_event:
             self.supervision_event = supervision_event
             self.save()
 
-        supervision_event.teachers.set(self.teachers.all())
         return supervision_event
 
     class Meta:
@@ -584,3 +691,54 @@ class SupervisionSubstitution(TeacherPropertiesMixin, ExtensibleModel):
         ]
         verbose_name = _("Supervision Substitution")
         verbose_name_plural = _("Supervision Substitutions")
+
+
+class TimeboundCourseConfig(ExtensibleModel):
+    """A timebound course config is the specific configuration of a course.
+
+    It consists of a course and a validity range.
+    """
+
+    course = models.ForeignKey(
+        Course,
+        on_delete=models.CASCADE,
+        verbose_name=_("Course"),
+        related_name="lr_timebound_course_configs",
+    )
+    validity_range = models.ForeignKey(
+        ValidityRange,
+        on_delete=models.CASCADE,
+        verbose_name=_("Linked validity range"),
+        related_name="lr_timebound_course_configs",
+    )
+
+    lesson_quota = models.PositiveSmallIntegerField(
+        verbose_name=_("Lesson quota"),
+        help_text=_("Number of slots this course is scheduled to fill per week"),
+        blank=True,
+        null=True,
+    )
+    teachers = models.ManyToManyField(
+        Person,
+        verbose_name=_("Teachers"),
+        related_name="lr_timebound_course_configs",
+    )
+
+    class Meta:
+        constraints = [
+            # Heads up: Uniqueness per validity range implies validity per site
+            models.UniqueConstraint(
+                fields=["course", "validity_range"], name="lr_unique_course_config_per_range"
+            ),
+        ]
+        verbose_name = _("Timebound course config")
+        verbose_name_plural = _("Timebound course configs")
+
+
+class LesroosterGlobalPermissions(GlobalPermissionModel):
+    class Meta:
+        managed = False
+        permissions = (
+            ("manage_lesson_raster", _("Can manage lesson raster")),
+            ("plan_timetables", _("Can plan timetables")),
+        )
diff --git a/aleksis/apps/lesrooster/rules.py b/aleksis/apps/lesrooster/rules.py
new file mode 100644
index 0000000000000000000000000000000000000000..13146da91f6d9c25587e7f2f2c3b8497771f7ebc
--- /dev/null
+++ b/aleksis/apps/lesrooster/rules.py
@@ -0,0 +1,298 @@
+from rules import add_perm
+
+from aleksis.core.util.predicates import (
+    has_any_object,
+    has_global_perm,
+    has_object_perm,
+    has_person,
+)
+
+from .models import (
+    BreakSlot,
+    Lesson,
+    Slot,
+    Supervision,
+    SupervisionSubstitution,
+    TimeboundCourseConfig,
+    TimeGrid,
+    ValidityRange,
+)
+
+manage_lesson_raster_predicate = has_person & has_global_perm("lesrooster.manage_lesson_raster")
+add_perm("lesrooster.manage_lesson_raster_rule", manage_lesson_raster_predicate)
+
+plan_timetables_predicate = has_person & has_global_perm("lesrooster.plan_timetables")
+add_perm("lesrooster.plan_timetables_rule", plan_timetables_predicate)
+
+
+# Slots
+view_slots_predicate = has_person & (
+    has_global_perm("lesrooster.view_slot")
+    | has_any_object("lesrooster.view_slot", Slot)
+    | manage_lesson_raster_predicate
+    | plan_timetables_predicate
+)
+add_perm("lesrooster.view_slots_rule", view_slots_predicate)
+
+view_slot_predicate = has_person & (
+    has_global_perm("lesrooster.view_slot")
+    | has_object_perm("lesrooster.view_slot")
+    | manage_lesson_raster_predicate
+    | plan_timetables_predicate
+)
+add_perm("lesrooster.view_slot_rule", view_slot_predicate)
+
+create_slot_predicate = has_person & (
+    has_global_perm("lesrooster.add_slot") | manage_lesson_raster_predicate
+)
+add_perm("lesrooster.create_slot_rule", create_slot_predicate)
+
+edit_slot_predicate = view_slot_predicate & (
+    has_global_perm("lesrooster.change_slot")
+    | has_object_perm("lesrooster.change_slot")
+    | manage_lesson_raster_predicate
+)
+add_perm("lesrooster.edit_slot_rule", edit_slot_predicate)
+
+delete_slot_predicate = view_slot_predicate & (
+    has_global_perm("lesrooster.delete_slot")
+    | has_object_perm("lesrooster.delete_slot")
+    | manage_lesson_raster_predicate
+)
+add_perm("lesrooster.delete_slot_rule", delete_slot_predicate)
+
+# Break slots
+
+view_break_slots_predicate = has_person & (
+    has_global_perm("lesrooster.view_breakslot")
+    | has_any_object("lesrooster.view_breakslot", BreakSlot)
+    | manage_lesson_raster_predicate
+    | plan_timetables_predicate
+)
+add_perm("lesrooster.view_breakslots_rule", view_break_slots_predicate)
+
+view_break_slot_predicate = has_person & (
+    has_global_perm("lesrooster.view_breakslot")
+    | has_object_perm("lesrooster.view_breakslot")
+    | manage_lesson_raster_predicate
+    | plan_timetables_predicate
+)
+add_perm("lesrooster.view_breakslot_rule", view_break_slot_predicate)
+
+create_break_slot_predicate = has_person & (
+    has_global_perm("lesrooster.add_breakslot") | manage_lesson_raster_predicate
+)
+add_perm("lesrooster.create_breakslot_rule", create_break_slot_predicate)
+
+edit_break_slot_predicate = view_break_slot_predicate & (
+    has_global_perm("lesrooster.change_breakslot")
+    | has_object_perm("lesrooster.change_breakslot")
+    | manage_lesson_raster_predicate
+)
+add_perm("lesrooster.edit_breakslot_rule", edit_break_slot_predicate)
+
+delete_break_slot_predicate = view_break_slot_predicate & (
+    has_global_perm("lesrooster.delete_breakslot")
+    | has_object_perm("lesrooster.delete_breakslot")
+    | manage_lesson_raster_predicate
+)
+add_perm("lesrooster.delete_breakslot_rule", delete_break_slot_predicate)
+
+
+# Lessons
+view_lessons_predicate = has_person & (
+    has_global_perm("lesrooster.view_lesson")
+    | has_any_object("lesrooster.view_lesson", Lesson)
+    | plan_timetables_predicate
+)
+add_perm("lesrooster.view_lessons_rule", view_lessons_predicate)
+
+view_lesson_predicate = has_person & (
+    has_global_perm("lesrooster.view_lesson")
+    | has_object_perm("lesrooster.view_lesson")
+    | plan_timetables_predicate
+)
+add_perm("lesrooster.view_lesson_rule", view_lesson_predicate)
+
+create_lesson_predicate = has_person & (
+    has_global_perm("lesrooster.add_lesson") | plan_timetables_predicate
+)
+add_perm("lesrooster.create_lesson_rule", create_lesson_predicate)
+
+edit_lesson_predicate = view_lesson_predicate & (
+    has_global_perm("lesrooster.change_lesson")
+    | has_object_perm("lesrooster.change_lesson")
+    | plan_timetables_predicate
+)
+add_perm("lesrooster.edit_lesson_rule", edit_lesson_predicate)
+
+delete_lesson_predicate = view_lesson_predicate & (
+    has_global_perm("lesrooster.delete_lesson")
+    | has_object_perm("lesrooster.delete_lesson")
+    | plan_timetables_predicate
+)
+add_perm("lesrooster.delete_lesson_rule", delete_lesson_predicate)
+
+
+# Supervisions
+view_supervisions_predicate = has_person & (
+    has_global_perm("lesrooster.view_supervision")
+    | has_any_object("lesrooster.view_supervision", Supervision)
+)
+add_perm("lesrooster.view_supervisions_rule", view_supervisions_predicate)
+
+view_supervision_predicate = has_person & (
+    has_global_perm("lesrooster.view_supervision") | has_object_perm("lesrooster.view_supervision")
+)
+add_perm("lesrooster.view_supervision_rule", view_supervision_predicate)
+
+create_supervision_predicate = has_person & has_global_perm("lesrooster.add_supervision")
+add_perm("lesrooster.create_supervision_rule", create_supervision_predicate)
+
+edit_supervision_predicate = view_supervision_predicate & (
+    has_global_perm("lesrooster.change_supervision")
+    | has_object_perm("lesrooster.change_supervision")
+)
+add_perm("lesrooster.edit_supervision_rule", edit_supervision_predicate)
+
+delete_supervision_predicate = view_supervision_predicate & (
+    has_global_perm("lesrooster.delete_supervision")
+    | has_object_perm("lesrooster.delete_supervision")
+)
+add_perm("lesrooster.delete_supervision_rule", delete_supervision_predicate)
+
+
+# Supervision substitutions
+view_supervision_substitutions_predicate = has_person & (
+    has_global_perm("lesrooster.view_supervisionsubstitution")
+    | has_any_object("lesrooster.view_supervisionsubstitution", SupervisionSubstitution)
+)
+add_perm("lesrooster.view_supervisionsubstitutions_rule", view_supervision_substitutions_predicate)
+
+view_supervision_substitution_predicate = has_person & (
+    has_global_perm("lesrooster.view_supervisionsubstitution")
+    | has_object_perm("lesrooster.view_supervisionsubstitution")
+)
+add_perm("lesrooster.view_supervisionsubstitution_rule", view_supervision_substitution_predicate)
+
+create_supervision_substitution_predicate = has_person & has_global_perm(
+    "lesrooster.add_supervisionsubstitution"
+)
+add_perm(
+    "lesrooster.create_supervisionsubstitution_rule", create_supervision_substitution_predicate
+)
+
+edit_supervision_substitution_predicate = view_supervision_substitution_predicate & (
+    has_global_perm("lesrooster.change_supervisionsubstitution")
+    | has_object_perm("lesrooster.change_supervisionsubstitution")
+)
+add_perm("lesrooster.edit_supervisionsubstitution_rule", edit_supervision_substitution_predicate)
+
+delete_supervision_substitution_predicate = view_supervision_substitution_predicate & (
+    has_global_perm("lesrooster.delete_supervisionsubstitution")
+    | has_object_perm("lesrooster.delete_supervisionsubstitution")
+)
+add_perm(
+    "lesrooster.delete_supervisionsubstitution_rule", delete_supervision_substitution_predicate
+)
+
+# Timebound course configs
+
+view_timebound_course_configs_predicate = has_person & (
+    has_global_perm("lesrooster.view_timeboundcourseconfig")
+    | has_any_object("lesrooster.view_timeboundcourseconfig", TimeboundCourseConfig)
+)
+add_perm("lesrooster.view_timeboundcourseconfigs_rule", view_timebound_course_configs_predicate)
+
+view_timebound_course_config_predicate = has_person & (
+    has_global_perm("lesrooster.view_timeboundcourseconfig")
+    | has_object_perm("lesrooster.view_timeboundcourseconfig")
+    | plan_timetables_predicate
+)
+add_perm("lesrooster.view_timeboundcourseconfig_rule", view_timebound_course_config_predicate)
+
+create_timebound_course_config_predicate = has_person & has_global_perm(
+    "lesrooster.add_timeboundcourseconfig"
+)
+add_perm("lesrooster.create_timeboundcourseconfig_rule", create_timebound_course_config_predicate)
+
+edit_timebound_course_config_predicate = view_timebound_course_config_predicate & (
+    has_global_perm("lesrooster.change_timeboundcourseconfig")
+    | has_object_perm("lesrooster.change_timeboundcourseconfig")
+)
+add_perm("lesrooster.edit_timeboundcourseconfig_rule", edit_timebound_course_config_predicate)
+
+delete_timebound_course_config_predicate = view_timebound_course_config_predicate & (
+    has_global_perm("lesrooster.delete_timeboundcourseconfig")
+    | has_object_perm("lesrooster.delete_timeboundcourseconfig")
+)
+add_perm("lesrooster.delete_timeboundcourseconfig_rule", delete_timebound_course_config_predicate)
+
+
+# Validity ranges
+
+view_validity_ranges_predicate = has_person & (
+    has_global_perm("lesrooster.view_validityrange")
+    | has_any_object("lesrooster.view_validityrange", ValidityRange)
+)
+add_perm("lesrooster.view_validityranges_rule", view_validity_ranges_predicate)
+
+view_validity_range_predicate = has_person & (
+    has_global_perm("lesrooster.view_validityrange")
+    | has_object_perm("lesrooster.view_validityrange")
+    | plan_timetables_predicate
+)
+add_perm("lesrooster.view_validityrange_rule", view_validity_range_predicate)
+
+create_validity_range_predicate = has_person & has_global_perm("lesrooster.add_validityrange")
+add_perm("lesrooster.create_validityrange_rule", create_validity_range_predicate)
+
+edit_validity_range_predicate = view_validity_range_predicate & (
+    has_global_perm("lesrooster.change_validityrange")
+    | has_object_perm("lesrooster.change_validityrange")
+)
+add_perm("lesrooster.edit_validityrange_rule", edit_validity_range_predicate)
+
+delete_validity_range_predicate = view_validity_range_predicate & (
+    has_global_perm("lesrooster.delete_validityrange")
+    | has_object_perm("lesrooster.delete_validityrange")
+)
+add_perm("lesrooster.delete_validityrange_rule", delete_validity_range_predicate)
+
+# Time grids
+view_time_grids_predicate = has_person & (
+    has_global_perm("lesrooster.view_timegrid")
+    | has_any_object("lesrooster.view_timegrid", TimeGrid)
+)
+add_perm("lesrooster.view_timegrids_rule", view_time_grids_predicate)
+
+view_time_grid_predicate = has_person & (
+    has_global_perm("lesrooster.view_timegrid")
+    | has_object_perm("lesrooster.view_timegrid")
+    | plan_timetables_predicate
+)
+add_perm("lesrooster.view_timegrid_rule", view_time_grid_predicate)
+
+create_time_grid_predicate = has_person & has_global_perm("lesrooster.add_timegrid")
+add_perm("lesrooster.create_timegrid_rule", create_time_grid_predicate)
+
+edit_time_grid_predicate = view_time_grid_predicate & (
+    has_global_perm("lesrooster.change_timegrid") | has_object_perm("lesrooster.change_timegrid")
+)
+add_perm("lesrooster.edit_timegrid_rule", edit_time_grid_predicate)
+
+delete_time_grid_predicate = view_time_grid_predicate & (
+    has_global_perm("lesrooster.delete_timegrid") | has_object_perm("lesrooster.delete_timegrid")
+)
+add_perm("lesrooster.delete_timegrid_rule", delete_time_grid_predicate)
+
+
+view_lesrooster_menu_predicate = (
+    view_validity_ranges_predicate
+    | view_slots_predicate
+    | view_break_slots_predicate
+    | view_timebound_course_configs_predicate
+    | manage_lesson_raster_predicate
+    | plan_timetables_predicate
+)
+add_perm("lesrooster.view_lesrooster_menu_rule", view_lesrooster_menu_predicate)
diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..29951626e85c9c7eb5ff6db559cc09ea64ce3aaf
--- /dev/null
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -0,0 +1,294 @@
+from itertools import chain
+
+from django.db.models import Prefetch, Q
+
+import graphene
+from guardian.shortcuts import get_objects_for_user
+
+from aleksis.apps.cursus.models import Course, Subject
+from aleksis.apps.cursus.schema import CourseInterface
+from aleksis.core.models import Group
+from aleksis.core.schema.base import FilterOrderList
+from aleksis.core.schema.group import GroupType
+
+from ..models import (
+    BreakSlot,
+    Lesson,
+    Slot,
+    Supervision,
+    TimeboundCourseConfig,
+    TimeGrid,
+    ValidityRange,
+)
+from .break_slot import (
+    BreakSlotBatchCreateMutation,
+    BreakSlotBatchDeleteMutation,
+    BreakSlotBatchPatchMutation,
+    BreakSlotCreateMutation,
+    BreakSlotDeleteMutation,
+    BreakSlotType,
+)
+from .lesson import (
+    LessonBatchDeleteMutation,
+    LessonBatchPatchMutation,
+    LessonCreateMutation,
+    LessonDeleteMutation,
+    LessonPatchMutation,
+    LessonType,
+)
+from .slot import (
+    CarryOverSlotsMutation,
+    CopySlotsFromDifferentTimeGridMutation,
+    SlotBatchCreateMutation,
+    SlotBatchDeleteMutation,
+    SlotBatchPatchMutation,
+    SlotCreateMutation,
+    SlotDeleteMutation,
+    SlotType,
+)
+from .supervision import (
+    SupervisionBatchDeleteMutation,
+    SupervisionBatchPatchMutation,
+    SupervisionCreateMutation,
+    SupervisionDeleteMutation,
+    SupervisionType,
+)
+from .time_grid import (
+    TimeGridBatchDeleteMutation,
+    TimeGridCreateMutation,
+    TimeGridDeleteMutation,
+    TimeGridType,
+)
+from .timebound_course_config import (
+    LesroosterExtendedSubjectType,
+    TimeboundCourseConfigBatchCreateMutation,
+    TimeboundCourseConfigBatchPatchMutation,
+    TimeboundCourseConfigCreateMutation,
+    TimeboundCourseConfigDeleteMutation,
+    TimeboundCourseConfigType,
+)
+from .validity_range import (
+    ValidityRangeBatchDeleteMutation,
+    ValidityRangeBatchPatchMutation,
+    ValidityRangeCreateMutation,
+    ValidityRangeDeleteMutation,
+    ValidityRangeType,
+)
+
+
+class Query(graphene.ObjectType):
+    break_slots = FilterOrderList(BreakSlotType)
+    slots = FilterOrderList(SlotType)
+    timebound_course_configs = FilterOrderList(TimeboundCourseConfigType)
+    validity_ranges = FilterOrderList(ValidityRangeType)
+    time_grids = FilterOrderList(TimeGridType)
+    lessons = FilterOrderList(LessonType)
+    supervisions = FilterOrderList(SupervisionType)
+
+    course_objects_for_group = graphene.List(
+        CourseInterface,
+        group=graphene.ID(required=True),
+        time_grid=graphene.ID(required=True),
+    )
+    lesson_objects_for_group = graphene.List(
+        LessonType,
+        group=graphene.ID(required=True),
+        time_grid=graphene.ID(required=True),
+    )
+    lesson_objects_for_teacher = graphene.List(
+        LessonType,
+        teacher=graphene.ID(required=True),
+        time_grid=graphene.ID(required=True),
+    )
+    lesson_objects_for_room = graphene.List(
+        LessonType,
+        room=graphene.ID(required=True),
+        time_grid=graphene.ID(required=True),
+    )
+
+    current_validity_range = graphene.Field(ValidityRangeType)
+
+    lesrooster_extended_subjects = FilterOrderList(
+        LesroosterExtendedSubjectType, groups=graphene.List(graphene.ID)
+    )
+
+    groups_by_time_grid = graphene.List(GroupType, time_grid=graphene.ID(required=True))
+
+    @staticmethod
+    def resolve_break_slots(root, info):
+        if not info.context.user.has_perm("lesrooster.view_breakslot_rule"):
+            return get_objects_for_user(info.context.user, "lesrooster.view_breakslot", BreakSlot)
+        return BreakSlot.objects.all()
+
+    @staticmethod
+    def resolve_slots(root, info):
+        # Note: This does also return `Break` objects (but with type set to Slot). This is intended
+        slots = Slot.objects.non_polymorphic()
+        if not info.context.user.has_perm("lesrooster.view_slot_rule"):
+            return get_objects_for_user(info.context.user, "lesrooster.view_slot", slots)
+        return slots
+
+    @staticmethod
+    def resolve_timebound_course_configs(root, info):
+        tccs = TimeboundCourseConfig.objects.all()
+        if not info.context.user.has_perm("lesrooster.view_timeboundcourseconfig_rule"):
+            return get_objects_for_user(
+                info.context.user, "lesrooster.view_timeboundcourseconfig", tccs
+            )
+        return tccs
+
+    @staticmethod
+    def resolve_validity_ranges(root, info):
+        if not info.context.user.has_perm("lesrooster.view_validityrange_rule"):
+            return get_objects_for_user(
+                info.context.user, "lesrooster.view_validityrange", ValidityRange
+            )
+        return ValidityRange.objects.all()
+
+    @staticmethod
+    def resolve_time_grids(root, info):
+        if not info.context.user.has_perm("lesrooster.view_timegrid_rule"):
+            return get_objects_for_user(info.context.user, "lesrooster.view_timegrid", TimeGrid)
+        return TimeGrid.objects.all()
+
+    @staticmethod
+    def resolve_supervisions(root, info):
+        if not info.context.user.has_perm("lesrooster.view_supervision_rule"):
+            return get_objects_for_user(
+                info.context.user, "lesrooster.view_supervision", Supervision
+            )
+        return Supervision.objects.all()
+
+    @staticmethod
+    def resolve_lesrooster_extended_subjects(root, info, groups):
+        subjects = Subject.objects.all().prefetch_related(
+            Prefetch(
+                "courses",
+                queryset=get_objects_for_user(
+                    info.context.user, "cursus.view_course", Course.objects.all()
+                ).filter(groups__in=groups),
+            )
+        )
+        if not info.context.user.has_perm("lesrooster.view_subject_rule"):
+            return get_objects_for_user(info.context.user, "cursus.view_subject", subjects)
+        return subjects
+
+    @staticmethod
+    def resolve_current_validity_range(root, info):
+        validity_range = ValidityRange.current
+        if info.context.user.has_perm("lesrooster.view_validityrange_rule", validity_range):
+            return validity_range
+
+    def resolve_course_objects_for_group(root, info, group, time_grid):
+        if not info.context.user.has_perm("lesrooster.plan_timetables_rule"):
+            return []
+
+        group = Group.objects.get(pk=group)
+
+        if not group:
+            return []
+
+        courses = Course.objects.filter(
+            (Q(groups__in=group.child_groups.all()) | Q(groups=group))
+            & Q(lr_timebound_course_configs__isnull=True)
+        )
+
+        timebound_course_configs = TimeboundCourseConfig.objects.filter(
+            Q(validity_range__time_grids__in=time_grid)
+            & (Q(course__groups__in=group.child_groups.all()) | Q(course__groups=group))
+        )
+
+        return list(chain(courses, timebound_course_configs))
+
+    @staticmethod
+    def resolve_lesson_objects_for_group(root, info, group, time_grid):
+        if not info.context.user.has_perm("lesrooster.plan_timetables_rule"):
+            return []
+
+        group = Group.objects.get(pk=group)
+
+        if not group:
+            return []
+
+        courses = Course.objects.filter(Q(groups__in=group.child_groups.all()) | Q(groups=group))
+
+        return Lesson.objects.filter(
+            course__in=courses,
+            slot_start__time_grid_id=time_grid,
+            slot_end__time_grid_id=time_grid,
+        )
+
+    @staticmethod
+    def resolve_lesson_objects_for_teacher(root, info, teacher, time_grid):
+        if not info.context.user.has_perm("lesrooster.plan_timetables_rule"):
+            return []
+
+        return Lesson.objects.filter(
+            Q(teachers=teacher) | Q(course__teachers=teacher),
+            slot_start__time_grid_id=time_grid,
+            slot_end__time_grid_id=time_grid,
+        )
+
+    @staticmethod
+    def resolve_lesson_objects_for_room(root, info, room, time_grid):
+        if not info.context.user.has_perm("lesrooster.plan_timetables_rule"):
+            return []
+
+        return Lesson.objects.filter(
+            rooms=room,
+            slot_start__time_grid_id=time_grid,
+            slot_end__time_grid_id=time_grid,
+        )
+
+    @staticmethod
+    def resolve_groups_by_time_grid(root, info, time_grid=None, **kwargs):
+        if not info.context.user.has_perm("lesrooster.plan_timetables_rule"):
+            return []
+
+        return (
+            Group.objects.filter(school_term__lr_validity_ranges__time_grids__id=time_grid)
+            .annotate(has_cg=Q(child_groups__isnull=False))
+            .order_by("-has_cg", "name")
+        )
+
+
+class Mutation(graphene.ObjectType):
+    create_break_slot = BreakSlotCreateMutation.Field()
+    create_break_slots = BreakSlotBatchCreateMutation.Field()
+    delete_break_slot = BreakSlotDeleteMutation.Field()
+    delete_break_slots = BreakSlotBatchDeleteMutation.Field()
+    update_break_slots = BreakSlotBatchPatchMutation.Field()
+
+    create_slot = SlotCreateMutation.Field()
+    create_slots = SlotBatchCreateMutation.Field()
+    delete_slot = SlotDeleteMutation.Field()
+    delete_slots = SlotBatchDeleteMutation.Field()
+    update_slots = SlotBatchPatchMutation.Field()
+
+    batch_create_timebound_course_config = TimeboundCourseConfigBatchCreateMutation.Field()
+    create_timebound_course_config = TimeboundCourseConfigCreateMutation.Field()
+    delete_timebound_course_config = TimeboundCourseConfigDeleteMutation.Field()
+    update_timebound_course_configs = TimeboundCourseConfigBatchPatchMutation.Field()
+    carry_over_slots = CarryOverSlotsMutation.Field()
+    copy_slots_from_grid = CopySlotsFromDifferentTimeGridMutation.Field()
+
+    create_validity_range = ValidityRangeCreateMutation.Field()
+    delete_validity_range = ValidityRangeDeleteMutation.Field()
+    delete_validity_ranges = ValidityRangeBatchDeleteMutation.Field()
+    update_validity_ranges = ValidityRangeBatchPatchMutation.Field()
+
+    create_time_grid = TimeGridCreateMutation.Field()
+    delete_time_grid = TimeGridDeleteMutation.Field()
+    delete_time_grids = TimeGridBatchDeleteMutation.Field()
+    update_time_grids = TimeGridBatchDeleteMutation.Field()
+
+    create_lesson = LessonCreateMutation.Field()
+    delete_lesson = LessonDeleteMutation.Field()
+    delete_lessons = LessonBatchDeleteMutation.Field()
+    update_lesson = LessonPatchMutation.Field()
+    update_lessons = LessonBatchPatchMutation.Field()
+
+    create_supervision = SupervisionCreateMutation.Field()
+    delete_supervision = SupervisionDeleteMutation.Field()
+    delete_supervisions = SupervisionBatchDeleteMutation.Field()
+    update_supervisions = SupervisionBatchPatchMutation.Field()
diff --git a/aleksis/apps/lesrooster/schema/break_slot.py b/aleksis/apps/lesrooster/schema/break_slot.py
new file mode 100644
index 0000000000000000000000000000000000000000..fbc54e417fdd85dc7e626fa5cca51a0e207446d2
--- /dev/null
+++ b/aleksis/apps/lesrooster/schema/break_slot.py
@@ -0,0 +1,107 @@
+import graphene
+from graphene_django.types import DjangoObjectType
+from graphene_django_cud.mutations import (
+    DjangoBatchCreateMutation,
+    DjangoBatchDeleteMutation,
+    DjangoBatchPatchMutation,
+    DjangoCreateMutation,
+)
+from guardian.shortcuts import get_objects_for_user
+
+from aleksis.core.schema.base import (
+    DeleteMutation,
+    DjangoFilterMixin,
+    PermissionBatchDeleteMixin,
+    PermissionBatchPatchMixin,
+    PermissionsTypeMixin,
+)
+
+from ..models import BreakSlot
+from .slot import slot_filters
+
+break_filters = slot_filters.copy()
+
+
+class BreakSlotType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
+    model = graphene.String(default_value="Break")
+
+    class Meta:
+        model = BreakSlot
+        fields = (
+            "id",
+            "time_grid",
+            "name",
+            "weekday",
+            "period",
+            "time_start",
+            "time_end",
+        )
+        filter_fields = break_filters
+
+    @classmethod
+    def get_queryset(cls, queryset, info):
+        if not info.context.user.has_perm("lesrooster.view_breakslot_rule"):
+            return get_objects_for_user(info.context.user, "lesrooster.view_breakslot", queryset)
+        return queryset
+
+
+class BreakSlotCreateMutation(DjangoCreateMutation):
+    class Meta:
+        model = BreakSlot
+        return_field_name = "breakSlot"
+        field_types = {"weekday": graphene.Int()}
+        only_fields = (
+            "id",
+            "time_grid",
+            "name",
+            "weekday",
+            "period",
+            "time_start",
+            "time_end",
+        )
+        permissions = ("lesrooster.create_breakslot_rule",)
+
+
+class BreakSlotDeleteMutation(DeleteMutation):
+    klass = BreakSlot
+    permission_required = "lesrooster.delete_breakslot_rule"
+
+
+class BreakSlotBatchCreateMutation(PermissionBatchPatchMixin, DjangoBatchCreateMutation):
+    class Meta:
+        model = BreakSlot
+        return_field_name = "breakSlots"
+        field_types = {"weekday": graphene.Int()}
+        only_fields = (
+            "id",
+            "time_grid",
+            "name",
+            "weekday",
+            "period",
+            "time_start",
+            "time_end",
+        )
+        permissions = ("lesrooster.create_breakslot_rule",)
+
+
+class BreakSlotBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
+    class Meta:
+        model = BreakSlot
+        permissions = ("lesrooster.delete_breakslot_rule",)
+
+
+class BreakSlotBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
+    class Meta:
+        model = BreakSlot
+        return_field_name = "breakSlots"
+        field_types = {"weekday": graphene.Int()}
+        permissions = ("lesrooster.edit_breakslot_rule",)
+        only_fields = (
+            "id",
+            "time_grid",
+            "name",
+            "weekday",
+            "period",
+            "time_start",
+            "time_end",
+        )
diff --git a/aleksis/apps/lesrooster/schema/lesson.py b/aleksis/apps/lesrooster/schema/lesson.py
new file mode 100644
index 0000000000000000000000000000000000000000..de013bc0bc4dcccd286fa3011e5aec775ee025df
--- /dev/null
+++ b/aleksis/apps/lesrooster/schema/lesson.py
@@ -0,0 +1,121 @@
+import graphene
+from graphene_django.types import DjangoObjectType
+from graphene_django_cud.mutations import (
+    DjangoBatchDeleteMutation,
+    DjangoBatchPatchMutation,
+    DjangoCreateMutation,
+    DjangoPatchMutation,
+)
+from guardian.shortcuts import get_objects_for_user
+from recurrence import Recurrence, deserialize, serialize
+
+from aleksis.core.schema.base import (
+    DeleteMutation,
+    DjangoFilterMixin,
+    OptimisticResponseTypeMixin,
+    PermissionBatchDeleteMixin,
+    PermissionBatchPatchMixin,
+    PermissionPatchMixin,
+    PermissionsTypeMixin,
+)
+
+from ..models import Lesson
+
+
+class LessonType(
+    PermissionsTypeMixin, DjangoFilterMixin, OptimisticResponseTypeMixin, DjangoObjectType
+):
+    recurrence = graphene.String()
+
+    class Meta:
+        model = Lesson
+        fields = ("id", "course", "slot_start", "slot_end", "rooms", "teachers", "subject")
+        filter_fields = {
+            "id": ["exact"],
+            "slot_start": ["exact"],
+            "slot_end": ["exact"],
+        }
+
+    @classmethod
+    def get_queryset(cls, queryset, info):
+        if not info.context.user.has_perm("lesrooster.view_lesson_rule"):
+            return get_objects_for_user(info.context.user, "lesrooster.view_lesson", queryset)
+        return queryset
+
+    @staticmethod
+    def resolve_recurrence(root, info, **kwargs):
+        return serialize(root.recurrence)
+
+
+class LessonCreateMutation(DjangoCreateMutation):
+    class Meta:
+        model = Lesson
+        only_fields = (
+            "id",
+            "course",
+            "slot_start",
+            "slot_end",
+            "rooms",
+            "teachers",
+            "subject",
+            "recurrence",
+        )
+        field_types = {"recurrence": graphene.String()}
+        permissions = ("lesrooster.create_lesson_rule",)
+
+    @classmethod
+    def handle_recurrence(cls, value: str, name, info) -> Recurrence:
+        return deserialize(value)
+
+
+class LessonDeleteMutation(DeleteMutation):
+    klass = Lesson
+    permission_required = "lesrooster.delete_lesson_rule"
+
+
+class LessonBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
+    class Meta:
+        model = Lesson
+        permissions = ("lesrooster.delete_lesson_rule",)
+
+
+class LessonBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
+    class Meta:
+        model = Lesson
+        only_fields = (
+            "id",
+            "course",
+            "slot_start",
+            "slot_end",
+            "rooms",
+            "teachers",
+            "subject",
+            "recurrence",
+        )
+        field_types = {"recurrence": graphene.String()}
+        permissions = ("lesrooster.edit_lesson_rule",)
+
+    @classmethod
+    def handle_recurrence(cls, value: str, name, info) -> Recurrence:
+        return deserialize(value)
+
+
+class LessonPatchMutation(PermissionPatchMixin, DjangoPatchMutation):
+    class Meta:
+        model = Lesson
+        only_fields = (
+            "id",
+            "course",
+            "slot_start",
+            "slot_end",
+            "rooms",
+            "teachers",
+            "subject",
+            "recurrence",
+        )
+        field_types = {"recurrence": graphene.String()}
+        permissions = ("lesrooster.edit_lesson_rule",)
+
+    @classmethod
+    def handle_recurrence(cls, value: str, name, info) -> Recurrence:
+        return deserialize(value)
diff --git a/aleksis/apps/lesrooster/schema/slot.py b/aleksis/apps/lesrooster/schema/slot.py
new file mode 100644
index 0000000000000000000000000000000000000000..6c2de6a501613bd058dd72a141912d644ee01b87
--- /dev/null
+++ b/aleksis/apps/lesrooster/schema/slot.py
@@ -0,0 +1,196 @@
+from django.core.exceptions import PermissionDenied
+
+import graphene
+from graphene_django.types import DjangoObjectType
+from graphene_django_cud.mutations import (
+    DjangoBatchCreateMutation,
+    DjangoBatchDeleteMutation,
+    DjangoBatchPatchMutation,
+    DjangoCreateMutation,
+)
+from guardian.shortcuts import get_objects_for_user
+
+from aleksis.core.schema.base import (
+    DeleteMutation,
+    DjangoFilterMixin,
+    PermissionBatchDeleteMixin,
+    PermissionBatchPatchMixin,
+    PermissionsTypeMixin,
+)
+
+from ..models import BreakSlot, Slot, TimeGrid
+
+slot_filters = {
+    "id": ["exact"],
+    "name": ["exact", "icontains"],
+    "weekday": ["exact", "in"],
+    "period": ["exact", "lt", "lte", "gt", "gte"],
+    "time_start": ["exact", "lt", "lte", "gt", "gte"],
+    "time_end": ["exact", "lt", "lte", "gt", "gte"],
+    "time_grid": ["exact"],
+}
+
+
+class SlotType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
+    model = graphene.String(default_value="Default")
+
+    class Meta:
+        model = Slot
+        fields = ("id", "time_grid", "name", "weekday", "period", "time_start", "time_end")
+        filter_fields = slot_filters
+
+    @classmethod
+    def get_queryset(cls, queryset, info):
+        if not info.context.user.has_perm("lesrooster.view_slot_rule"):
+            return get_objects_for_user(info.context.user, "lesrooster.view_slot", queryset)
+        return queryset
+
+    @staticmethod
+    def resolve_model(root, info):
+        return root.get_real_instance_class().__name__
+
+
+class SlotCreateMutation(DjangoCreateMutation):
+    class Meta:
+        model = Slot
+        field_types = {"weekday": graphene.Int()}
+        only_fields = ("id", "time_grid", "name", "weekday", "period", "time_start", "time_end")
+        permissions = ("lesrooster.create_slot_rule",)
+
+
+class SlotDeleteMutation(DeleteMutation):
+    klass = Slot
+    permission_required = "lesrooster.delete_slot"
+
+
+class SlotBatchCreateMutation(PermissionBatchPatchMixin, DjangoBatchCreateMutation):
+    class Meta:
+        model = Slot
+        field_types = {"weekday": graphene.Int()}
+        only_fields = ("id", "time_grid", "name", "weekday", "period", "time_start", "time_end")
+        permissions = ("lesrooster.create_slot_rule",)
+
+
+class SlotBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
+    class Meta:
+        model = Slot
+        permissions = ("lesrooster.delete_slot",)
+
+
+class SlotBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
+    class Meta:
+        model = Slot
+        field_types = {"weekday": graphene.Int()}
+        permissions = ("lesrooster.change_slot",)
+        only_fields = ("id", "time_grid", "name", "weekday", "period", "time_start", "time_end")
+
+
+class CarryOverSlotsMutation(graphene.Mutation):
+    class Arguments:
+        time_grid = graphene.ID()
+        from_day = graphene.Int()
+        to_day = graphene.Int()
+
+        only = graphene.List(graphene.ID, required=False)
+
+    deleted = graphene.List(graphene.ID)
+    result = graphene.List(SlotType)
+
+    @classmethod
+    def mutate(cls, root, info, time_grid, from_day, to_day, only=None):
+        if not info.context.user.has_perm("lesrooster.edit_slot_rule"):
+            raise PermissionDenied()
+
+        if only is None:
+            only = []
+
+        time_grid = TimeGrid.objects.get(id=time_grid)
+
+        slots_on_day = Slot.objects.filter(weekday=from_day, time_grid=time_grid)
+
+        if only and len(only) > 0:
+            slots_on_day = slots_on_day.filter(id__in=only)
+
+        result = []
+        new_ids = []
+
+        for slot in slots_on_day:
+            defaults = {"name": slot.name, "time_start": slot.time_start, "time_end": slot.time_end}
+
+            if slot.period is not None:
+                new_slot = Slot.objects.non_polymorphic().update_or_create(
+                    weekday=to_day, time_grid=time_grid, period=slot.period, defaults=defaults
+                )[0]
+
+            else:
+                new_slot = Slot.objects.non_polymorphic().update_or_create(
+                    weekday=to_day,
+                    time_grid=time_grid,
+                    time_start=slot.time_start,
+                    time_end=slot.time_end,
+                    defaults=defaults,
+                )[0]
+
+            result.append(new_slot)
+            new_ids.append(new_slot.pk)
+
+        if not only or not len(only):
+            objects_to_delete = Slot.objects.filter(weekday=to_day, time_grid=time_grid).exclude(
+                pk__in=new_ids
+            )
+            objects_to_delete.delete()
+
+            deleted = objects_to_delete.values_list("id", flat=True)
+
+        else:
+            deleted = []
+
+        return CarryOverSlotsMutation(
+            deleted=deleted,
+            result=result,
+        )
+
+
+class CopySlotsFromDifferentTimeGridMutation(graphene.Mutation):
+    class Arguments:
+        time_grid = graphene.ID()
+        from_time_grid = graphene.ID()
+
+    deleted = graphene.List(graphene.ID)
+    result = graphene.List(SlotType)
+
+    @classmethod
+    def mutate(cls, root, info, time_grid, from_time_grid):
+        if not info.context.user.has_perm("lesrooster.edit_slot_rule"):
+            raise PermissionDenied()
+
+        time_grid = TimeGrid.objects.get(id=time_grid)
+        from_time_grid = TimeGrid.objects.get(id=from_time_grid)
+
+        # Check for each slot in the from_time_grid if it exists in the time_grid, if not, create it
+        slots = Slot.objects.filter(time_grid=from_time_grid)
+
+        result = []
+
+        for slot in slots:
+            slot: BreakSlot | Slot
+            klass = slot.get_real_instance_class()
+
+            defaults = {"name": slot.name, "time_start": slot.time_start, "time_end": slot.time_end}
+
+            result.append(
+                klass.objects.update_or_create(
+                    weekday=slot.weekday, time_grid=time_grid, period=slot.period, defaults=defaults
+                )[0].id
+            )
+
+        # Delete all slots in the time_grid that are not in the from_time_grid
+        objects_to_delete = Slot.objects.filter(time_grid=time_grid).exclude(id__in=result)
+        objects_to_delete.delete()
+
+        deleted = objects_to_delete.values_list("id", flat=True)
+
+        return CopySlotsFromDifferentTimeGridMutation(
+            deleted=deleted,
+            result=Slot.objects.filter(time_grid=time_grid).non_polymorphic(),
+        )
diff --git a/aleksis/apps/lesrooster/schema/supervision.py b/aleksis/apps/lesrooster/schema/supervision.py
new file mode 100644
index 0000000000000000000000000000000000000000..e028cf8662ccc758f2c4bbbe2574f0bf8a0bc2a7
--- /dev/null
+++ b/aleksis/apps/lesrooster/schema/supervision.py
@@ -0,0 +1,103 @@
+import graphene
+from graphene_django.types import DjangoObjectType
+from graphene_django_cud.mutations import (
+    DjangoBatchDeleteMutation,
+    DjangoBatchPatchMutation,
+    DjangoCreateMutation,
+)
+from guardian.shortcuts import get_objects_for_user
+from recurrence import Recurrence, deserialize, serialize
+
+from aleksis.core.schema.base import (
+    DeleteMutation,
+    DjangoFilterMixin,
+    PermissionBatchDeleteMixin,
+    PermissionBatchPatchMixin,
+    PermissionsTypeMixin,
+)
+
+from ..models import Supervision
+
+supervision_filters = {
+    "id": ["exact"],
+    "rooms": ["in"],
+    "teachers": ["in"],
+    "subject": ["exact"],
+    "break_slot": ["exact"],
+    "break_slot__time_grid": ["exact"],
+}
+
+
+class SupervisionType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
+    recurrence = graphene.String()
+
+    class Meta:
+        model = Supervision
+        fields = (
+            "id",
+            "rooms",
+            "teachers",
+            "subject",
+            "break_slot",
+        )
+        filter_fields = supervision_filters
+
+    @classmethod
+    def get_queryset(cls, queryset, info):
+        if not info.context.user.has_perm("lesrooster.view_supervision_rule"):
+            return get_objects_for_user(info.context.user, "lesrooster.view_supervision", queryset)
+        return queryset
+
+    @staticmethod
+    def resolve_recurrence(root, info, **kwargs):
+        return serialize(root.recurrence)
+
+
+class SupervisionCreateMutation(DjangoCreateMutation):
+    class Meta:
+        model = Supervision
+        field_types = {"recurrence": graphene.String()}
+        only_fields = (
+            "id",
+            "rooms",
+            "teachers",
+            "subject",
+            "break_slot",
+            "recurrence",
+        )
+        permissions = ("lesrooster.create_supervision_rule",)
+
+    @classmethod
+    def handle_recurrence(cls, value: str, name, info) -> Recurrence:
+        return deserialize(value)
+
+
+class SupervisionDeleteMutation(DeleteMutation):
+    klass = Supervision
+    permission_required = "lesrooster.delete_supervision_rule"
+
+
+class SupervisionBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
+    class Meta:
+        model = Supervision
+        permissions = ("lesrooster.delete_supervision_rule",)
+
+
+class SupervisionBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
+    class Meta:
+        model = Supervision
+        only_fields = (
+            "id",
+            "rooms",
+            "teachers",
+            "subject",
+            "break_slot",
+            "recurrence",
+        )
+        field_types = {"recurrence": graphene.String()}
+        exclude = ("managed_by_app_label",)
+        permissions = ("lesrooster.create_supervision_rule",)
+
+    @classmethod
+    def handle_recurrence(cls, value: str, name, info) -> Recurrence:
+        return deserialize(value)
diff --git a/aleksis/apps/lesrooster/schema/time_grid.py b/aleksis/apps/lesrooster/schema/time_grid.py
new file mode 100644
index 0000000000000000000000000000000000000000..906b16ce6d78ab81a991b783506d6d5777569ece
--- /dev/null
+++ b/aleksis/apps/lesrooster/schema/time_grid.py
@@ -0,0 +1,73 @@
+from graphene_django.types import DjangoObjectType
+from graphene_django_cud.mutations import (
+    DjangoBatchDeleteMutation,
+    DjangoBatchPatchMutation,
+    DjangoCreateMutation,
+)
+from guardian.shortcuts import get_objects_for_user
+
+from aleksis.core.schema.base import (
+    DeleteMutation,
+    DjangoFilterMixin,
+    PermissionBatchDeleteMixin,
+    PermissionBatchPatchMixin,
+    PermissionsTypeMixin,
+)
+
+from ..models import TimeGrid
+
+
+class TimeGridType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
+    class Meta:
+        model = TimeGrid
+        fields = (
+            "id",
+            "validity_range",
+            "group",
+        )
+        filter_fields = {
+            "id": ["exact"],
+            "group": ["exact", "in"],
+            "validity_range": ["exact", "in"],
+            "validity_range__date_start": ["exact", "lt", "lte", "gt", "gte"],
+            "validity_range__date_end": ["exact", "lt", "lte", "gt", "gte"],
+        }
+
+    @classmethod
+    def get_queryset(cls, queryset, info):
+        if not info.context.user.has_perm("lesrooster.view_timegrid_rule"):
+            return get_objects_for_user(info.context.user, "lesrooster.view_timegrid", queryset)
+        return queryset
+
+
+class TimeGridCreateMutation(DjangoCreateMutation):
+    class Meta:
+        model = TimeGrid
+        permissions = ("lesrooster.create_timegrid_rule",)
+        only_fields = (
+            "id",
+            "validity_range",
+            "group",
+        )
+
+
+class TimeGridDeleteMutation(DeleteMutation):
+    klass = TimeGrid
+    permission_required = "lesrooster.delete_timegrid_rule"
+
+
+class TimeGridBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
+    class Meta:
+        model = TimeGrid
+        permissions = ("lesrooster.delete_timegrid_rule",)
+
+
+class TimeGridBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
+    class Meta:
+        model = TimeGrid
+        permissions = ("lesrooster.edit_timegrid_rule",)
+        only_fields = (
+            "id",
+            "validity_range",
+            "group",
+        )
diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..dc5e37487829762633197d80cd079fed42d8cb5e
--- /dev/null
+++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py
@@ -0,0 +1,112 @@
+import graphene
+from graphene_django.types import DjangoObjectType
+from graphene_django_cud.mutations import (
+    DjangoBatchCreateMutation,
+    DjangoBatchPatchMutation,
+    DjangoCreateMutation,
+)
+from guardian.shortcuts import get_objects_for_user
+
+from aleksis.apps.cursus.models import Course, Subject
+from aleksis.apps.cursus.schema import CourseInterface, CourseType, SubjectType
+from aleksis.core.schema.base import (
+    DeleteMutation,
+    DjangoFilterMixin,
+    PermissionBatchPatchMixin,
+    PermissionsTypeMixin,
+)
+
+from ..models import TimeboundCourseConfig
+
+timebound_course_config_filters = {"course": ["in"], "validity_range": ["in"], "teachers": [""]}
+
+
+class TimeboundCourseConfigType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
+    class Meta:
+        model = TimeboundCourseConfig
+        interfaces = (CourseInterface,)
+        fields = ("id", "course", "validity_range", "lesson_quota", "teachers")
+        filter_fields = timebound_course_config_filters
+
+    @classmethod
+    def get_queryset(cls, queryset, info):
+        if not info.context.user.has_perm("lesrostter.view_timeboundcourseconfig_rule"):
+            return get_objects_for_user(
+                info.context.user,
+                "lesrooster.view_timeboundcourseconfig",
+                root.lr_timebound_course_configs.all(),
+            )
+        return queryset
+
+    @staticmethod
+    def resolve_name(root, info, **kwargs):
+        return root.course.name
+
+    @staticmethod
+    def resolve_subject(root, info, **kwargs):
+        return root.course.subject
+
+    @staticmethod
+    def resolve_groups(root, info, **kwargs):
+        return root.course.groups.all()
+
+    @staticmethod
+    def resolve_lesson_quota(root, info, **kwargs):
+        return root.lesson_quota
+
+    @staticmethod
+    def resolve_teachers(root, info, **kwargs):
+        return root.teachers.all()
+
+    @staticmethod
+    def resolve_course_id(root, info, **kwargs):
+        return root.course.id
+
+
+class LesroosterExtendedCourseType(CourseType):
+    class Meta:
+        model = Course
+
+    lr_timebound_course_configs = graphene.List(TimeboundCourseConfigType)
+
+    @staticmethod
+    def resolve_lr_timebound_course_configs(root, info, **kwargs):
+        if not info.context.user.has_perm("lesrostter.view_timeboundcourseconfig_rule"):
+            return get_objects_for_user(
+                info.context.user,
+                "lesrooster.view_timeboundcourseconfig",
+                root.lr_timebound_course_configs.all(),
+            )
+
+
+class LesroosterExtendedSubjectType(SubjectType):
+    class Meta:
+        model = Subject
+
+    courses = graphene.List(LesroosterExtendedCourseType)
+
+
+class TimeboundCourseConfigCreateMutation(DjangoCreateMutation):
+    class Meta:
+        model = TimeboundCourseConfig
+        fields = ("id", "course", "validity_range", "lesson_quota", "teachers")
+        permissions = ("lesrooster.create_timeboundcourseconfig_rule",)
+
+
+class TimeboundCourseConfigBatchCreateMutation(DjangoBatchCreateMutation):
+    class Meta:
+        model = TimeboundCourseConfig
+        fields = ("id", "course", "validity_range", "lesson_quota", "teachers")
+        permissions = ("lesrooster.create_timeboundcourseconfig_rule",)
+
+
+class TimeboundCourseConfigDeleteMutation(DeleteMutation):
+    klass = TimeboundCourseConfig
+    permission_required = "lesrooster.delete_timeboundcourseconfig_rule"
+
+
+class TimeboundCourseConfigBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
+    class Meta:
+        model = TimeboundCourseConfig
+        fields = ("id", "course", "validity_range", "lesson_quota", "teachers")
+        permissions = ("lesrooster.change_timeboundcourseconfig_rule",)
diff --git a/aleksis/apps/lesrooster/schema/validity_range.py b/aleksis/apps/lesrooster/schema/validity_range.py
new file mode 100644
index 0000000000000000000000000000000000000000..040fc2778846be7d0e32354bd534fef0034b8880
--- /dev/null
+++ b/aleksis/apps/lesrooster/schema/validity_range.py
@@ -0,0 +1,83 @@
+import graphene
+from graphene_django.types import DjangoObjectType
+from graphene_django_cud.mutations import (
+    DjangoBatchDeleteMutation,
+    DjangoBatchPatchMutation,
+    DjangoCreateMutation,
+)
+from guardian.shortcuts import get_objects_for_user
+
+from aleksis.core.schema.base import (
+    DeleteMutation,
+    DjangoFilterMixin,
+    PermissionBatchDeleteMixin,
+    PermissionBatchPatchMixin,
+    PermissionsTypeMixin,
+)
+
+from ..models import ValidityRange
+
+
+class ValidityRangeType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
+    class Meta:
+        model = ValidityRange
+        fields = ("id", "school_term", "name", "date_start", "date_end", "status", "time_grids")
+        filter_fields = {
+            "id": ["exact"],
+            "school_term": ["exact", "in"],
+            "status": ["exact"],
+            "name": ["icontains", "exact"],
+            "date_start": ["exact", "lt", "lte", "gt", "gte"],
+            "date_end": ["exact", "lt", "lte", "gt", "gte"],
+        }
+
+    @classmethod
+    def get_queryset(cls, queryset, info):
+        if not info.context.user.has_perm("lesrooster.view_validityrange_rule"):
+            return get_objects_for_user(
+                info.context.user, "lesrooster.view_validityrange", queryset
+            )
+        return queryset
+
+
+class ValidityRangeCreateMutation(DjangoCreateMutation):
+    class Meta:
+        model = ValidityRange
+        permissions = ("lesrooster.create_validity_range_rule",)
+        only_fields = (
+            "id",
+            "school_term",
+            "name",
+            "date_start",
+            "date_end",
+            "status",
+            "time_grids",
+        )
+        field_types = {"status": graphene.String()}
+
+
+class ValidityRangeDeleteMutation(DeleteMutation):
+    klass = ValidityRange
+    permission_required = "lesrooster.delete_validityrange_rule"
+
+
+class ValidityRangeBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
+    class Meta:
+        model = ValidityRange
+        permissions = ("lesrooster.delete_validityrange_rule",)
+
+
+class ValidityRangeBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
+    class Meta:
+        model = ValidityRange
+        permissions = ("lesrooster.change_validityrange",)
+        only_fields = (
+            "id",
+            "school_term",
+            "name",
+            "date_start",
+            "date_end",
+            "status",
+            "time_grids",
+        )
+        field_types = {"status": graphene.String()}
diff --git a/aleksis/apps/lesrooster/util/signal_handlers.py b/aleksis/apps/lesrooster/util/signal_handlers.py
index e7049055a0a41ace40090229455d8efee3b042ee..d15f674b40912816be283eac885a89f68821c2f5 100644
--- a/aleksis/apps/lesrooster/util/signal_handlers.py
+++ b/aleksis/apps/lesrooster/util/signal_handlers.py
@@ -13,3 +13,44 @@ def m2m_changed_handler(sender, instance, action, **kwargs):
     if hasattr(instance, "sync"):
         logging.debug(f"Syncing {instance} (of type {sender}) after m2m_changed signal")
         instance.sync()
+
+
+def pre_delete_handler(sender, instance, **kwargs):
+    """Sync the instance with Chronos after it has been deleted."""
+    if hasattr(instance, "lesson_event"):
+        logging.debug(
+            f"Delete lesson event {instance.lesson_event} after deletion of lesson {instance}"
+        )
+        del_obj = instance.lesson_event.delete()
+    elif hasattr(instance, "supervision_event"):
+        logging.debug(
+            f"Delete supervision event {instance.supervision_event} "
+            f"after deletion of lesson {instance}"
+        )
+        del_obj = instance.supervision_event.delete()
+
+
+def create_time_grid_for_new_validity_range(sender, instance, created, **kwargs):
+    from ..models import TimeGrid  # noqa
+
+    if created:
+        TimeGrid.objects.create(validity_range=instance)
+
+
+def publish_validity_range(sender, instance, created, **kwargs):
+    from ..models import Lesson, Substitution, Supervision, SupervisionSubstitution
+
+    # FIXME Move this to a background job
+    objs_to_update = (
+        list(Lesson.objects.filter(slot_start__time_grid__validity_range=instance))
+        + list(Supervision.objects.filter(break_slot__time_grid__validity_range=instance))
+        + list(Substitution.objects.filter(lesson__slot_start__time_grid__validity_range=instance))
+        + list(
+            SupervisionSubstitution.objects.filter(
+                supervision__break_slot__time_grid__validity_range=instance
+            )
+        )
+    )
+    for obj in objs_to_update:
+        logging.info(f"Syncing object {obj} ({type(obj)}, {obj.pk})")
+        obj.sync()
diff --git a/pyproject.toml b/pyproject.toml
index f2e32c70f56651ce81d931ebd2a4804452c3df53..44e01f071012e663e54a94cdcf69ff8d46ac816e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,7 +35,8 @@ priority = "supplemental"
 [tool.poetry.dependencies]
 python = "^3.9"
 AlekSIS-Core = "^4.0.dev0"
-AlekSIS-App-Cursus = { path = "../AlekSIS-App-Cursus" }
+AlekSIS-App-Chronos = "^4.0.dev1"
+AlekSIS-App-Cursus = "^0.1.dev0"
 django-recurrence = "^1.11.1"
 
 [tool.poetry.plugins."aleksis.app"]
@@ -50,7 +51,6 @@ safety = "^2.3.5"
 flake8 = "^6.0.0"
 flake8-django = "~1.2.0"
 flake8-fixme = "^1.1.1"
-flake8-mypy = "^17.8.0"
 flake8-bandit = "^4.1.1"
 flake8-builtins = "^2.0.0"
 flake8-docstrings = "^1.5.0"