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..7b58f9ff34ef0cc5853ac41c434183767e4f22bd
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue
@@ -0,0 +1,143 @@
+<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>
+
+    <template #isGeneric.field="{ attrs, on, item }">
+        <v-switch
+          v-bind="attrs"
+          v-on="on"
+          :disabled="genericDisabled(item)"
+        ></v-switch>
+      </template>
+
+      <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>
\ No newline at end of file
diff --git a/aleksis/apps/lesrooster/frontend/messages/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json
index 335732b838a3283209cf7283beb376d87de6f8c5..636bb70aebfee31268bc1e470a4a1ba0c4d2669f 100644
--- a/aleksis/apps/lesrooster/frontend/messages/en.json
+++ b/aleksis/apps/lesrooster/frontend/messages/en.json
@@ -18,11 +18,16 @@
           "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."
+        "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": {