diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index aff862f7fc5171982107cbe1755bbe0bc3f70ffb..6406f3808fb9d9c1414718080f35d27bd06f2ddd 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -6,6 +6,14 @@ All notable changes to this project will be documented in this file.
 The format is based on `Keep a Changelog`_,
 and this project adheres to `Semantic Versioning`_.
 
+Unreleased
+----------
+
+Added
+~~~~~
+
+* Read-only view for showing and printing the planned timetables.
+
 `0.1.0.dev6`_ - 2024-12-24
 --------------------------
 
diff --git a/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue b/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue
index bc0205fe045c8ba045a7b13be511c7975158588d..0aa8dfa2b34b9071d08654f45639c88b288b0e87 100644
--- a/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/lesson_raster/LessonRaster.vue
@@ -61,7 +61,7 @@
                 </v-list-item-icon>
                 <v-list-item-content>
                   <v-list-item-title>
-                    {{ $t("actions.copy_last_configuration") }}
+                    {{ $t("lesrooster.actions.copy_last_configuration") }}
                   </v-list-item-title>
                 </v-list-item-content>
               </v-list-item>
@@ -114,7 +114,7 @@
                     <v-icon>mdi-application-export</v-icon>
                   </v-btn>
                 </template>
-                <span v-t="'actions.copy_to_day'"></span>
+                <span v-t="'lesrooster.actions.copy_to_day'"></span>
               </v-tooltip>
             </template>
             <v-list>
diff --git a/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue
index f274dcee31400ae30b58e7c20966fbd422f885dd..e44147e8055fc7879229f70aa127f4f6149d7af3 100644
--- a/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue
+++ b/aleksis/apps/lesrooster/frontend/components/lesson_raster/SlotCard.vue
@@ -85,7 +85,7 @@ export default defineComponent({
                     <v-icon>mdi-application-export</v-icon>
                   </v-btn>
                 </template>
-                <span v-t="'actions.copy_to_day'"></span>
+                <span v-t="'lesrooster.actions.copy_to_day'"></span>
               </v-tooltip>
             </template>
             <v-list>
diff --git a/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue b/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue
index 2a25319dab6e75a3540aa26b9b1e94517871c2fd..6ff5b3090fd92eefa625170cd5e440ac25014462 100644
--- a/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue
+++ b/aleksis/apps/lesrooster/frontend/components/supervision/Supervision.vue
@@ -139,7 +139,7 @@ import InlineCRUDList from "aleksis.core/components/generic/InlineCRUDList.vue";
     <!--    filled-->
     <!--    v-bind="attrs('break_slot__time_grid__exact')"-->
     <!--    v-on="on('break_slot__time_grid__exact')"-->
-    <!--    :label="$t('labels.select_validity_range')"-->
+    <!--    :label="$t('lesrooster.labels.select_validity_range')"-->
     <!--    hide-details-->
     <!--  />-->
     <!--</template>-->
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue
index 12b149fb9a710d30361a16b0cf27b742666f0944..954b2076706a0373b1a10f409ae6e2f849c4f6a0 100644
--- a/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timetable_management/TimetableManagement.vue
@@ -22,8 +22,8 @@ import SubjectField from "aleksis.apps.cursus/components/SubjectField.vue";
 import BundleCard from "./BundleCard.vue";
 
 import { RRule } from "rrule";
-import TeacherTimeTable from "./timetables/TeacherTimeTable.vue";
-import RoomTimeTable from "./timetables/RoomTimeTable.vue";
+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";
@@ -950,7 +950,7 @@ export default defineComponent({
         class="d-flex justify-space-between flex-wrap align-center"
       >
         <secondary-action-button
-          i18n-key="actions.copy_last_configuration"
+          i18n-key="lesrooster.actions.copy_last_configuration"
           block
           disabled
         />
@@ -1112,7 +1112,7 @@ export default defineComponent({
               rounded
               v-model="courseSearch"
               clearable
-              :label="$t('actions.search_courses')"
+              :label="$t('lesrooster.actions.search_courses')"
               :hint="totalLessonRatio"
               persistent-hint
             />
@@ -1200,13 +1200,13 @@ export default defineComponent({
             >
               <teacher-time-table
                 v-if="internalTimeGrid && selectedObjectType === 'teacher'"
-                :teacher-id="selectedObject"
+                :id="selectedObject"
                 :time-grid="timeGrid"
                 class="fill-height"
               />
               <room-time-table
                 v-if="internalTimeGrid && selectedObjectType === 'room'"
-                :room-id="selectedObject"
+                :id="selectedObject"
                 :time-grid="timeGrid"
                 class="fill-height"
               />
diff --git a/aleksis/apps/lesrooster/frontend/components/timetables/GroupTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/GroupTimeTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..473c47427ac2805baa1fb115c50214b998335861
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timetables/GroupTimeTable.vue
@@ -0,0 +1,38 @@
+<script>
+import { defineComponent } from "vue";
+import { lessonsGroup } from "./timetables.graphql";
+import MiniTimeTable from "./MiniTimeTable.vue";
+
+export default defineComponent({
+  name: "GroupTimeTable",
+  extends: MiniTimeTable,
+  props: {
+    id: {
+      type: String,
+      required: true,
+    },
+  },
+  computed: {
+    lessons() {
+      return this.lessonsGroup;
+    },
+    loading() {
+      return this.$apollo.queries.lessonsGroup.loading;
+    },
+  },
+  apollo: {
+    lessonsGroup: {
+      query: lessonsGroup,
+      variables() {
+        return {
+          timeGrid: this.timeGrid.id,
+          group: this.id,
+        };
+      },
+      skip() {
+        return this.timeGrid === null;
+      },
+    },
+  },
+});
+</script>
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/MiniTimeTable.vue
similarity index 91%
rename from aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue
rename to aleksis/apps/lesrooster/frontend/components/timetables/MiniTimeTable.vue
index f32e518e2d6a4c714640ab3e3eec95410a0fb000..a4d71574a7222e2b791648ae2758ebeedb488a41 100644
--- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/MiniTimeTable.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timetables/MiniTimeTable.vue
@@ -1,7 +1,7 @@
 <script>
 import { defineComponent } from "vue";
-import { slots } from "../../breaks_and_slots/slot.graphql";
-import LessonCard from "../LessonCard.vue";
+import { slots } from "../breaks_and_slots/slot.graphql";
+import LessonCard from "../timetable_management/LessonCard.vue";
 import MessageBox from "aleksis.core/components/generic/MessageBox.vue";
 
 export default defineComponent({
@@ -93,6 +93,9 @@ export default defineComponent({
 
       return weekdayPeriodSlots;
     },
+    loading() {
+      return false;
+    },
   },
   methods: {
     styleForWeekdayAndPeriod(weekday, period) {
@@ -105,7 +108,14 @@ export default defineComponent({
 </script>
 
 <template>
-  <div class="timetable">
+  <div v-if="loading" class="d-flex justify-center pa-10">
+    <v-progress-circular
+      indeterminate
+      color="primary"
+      :size="50"
+    ></v-progress-circular>
+  </div>
+  <div v-else class="timetable">
     <!-- Empty div to fill top-left corner -->
     <div></div>
     <v-card
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/RoomTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/RoomTimeTable.vue
similarity index 84%
rename from aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/RoomTimeTable.vue
rename to aleksis/apps/lesrooster/frontend/components/timetables/RoomTimeTable.vue
index a50154bbbf49f681223283176652128fa08f4c8e..a667de14404263982534c728e3777ca192edea59 100644
--- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/RoomTimeTable.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timetables/RoomTimeTable.vue
@@ -7,7 +7,7 @@ export default defineComponent({
   name: "RoomTimeTable",
   extends: MiniTimeTable,
   props: {
-    roomId: {
+    id: {
       type: String,
       required: true,
     },
@@ -16,6 +16,9 @@ export default defineComponent({
     lessons() {
       return this.lessonsRoom;
     },
+    loading() {
+      return this.$apollo.queries.lessonsRoom.loading;
+    },
   },
   apollo: {
     lessonsRoom: {
@@ -23,7 +26,7 @@ export default defineComponent({
       variables() {
         return {
           timeGrid: this.timeGrid.id,
-          room: this.roomId,
+          room: this.id,
         };
       },
       skip() {
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/TeacherTimeTable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/TeacherTimeTable.vue
similarity index 84%
rename from aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/TeacherTimeTable.vue
rename to aleksis/apps/lesrooster/frontend/components/timetables/TeacherTimeTable.vue
index 5ed3211176f9bb0ad66c654a5fe1c36747ff13b0..f8d89a4b564ac00126e72db72e29a36637b5423c 100644
--- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/TeacherTimeTable.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timetables/TeacherTimeTable.vue
@@ -7,7 +7,7 @@ export default defineComponent({
   name: "TeacherTimeTable",
   extends: MiniTimeTable,
   props: {
-    teacherId: {
+    id: {
       type: String,
       required: true,
     },
@@ -16,6 +16,9 @@ export default defineComponent({
     lessons() {
       return this.lessonsTeacher;
     },
+    loading() {
+      return this.$apollo.queries.lessonsTeacher.loading;
+    },
   },
   apollo: {
     lessonsTeacher: {
@@ -23,7 +26,7 @@ export default defineComponent({
       variables() {
         return {
           timeGrid: this.timeGrid.id,
-          teacher: this.teacherId,
+          teacher: this.id,
         };
       },
       skip() {
diff --git a/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue b/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3a7c8a8253039cde74e64f50b2d4d90af7273eed
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timetables/Timetable.vue
@@ -0,0 +1,127 @@
+<script setup>
+import TimetableWrapper from "aleksis.apps.chronos/components/TimetableWrapper.vue";
+import TimeGridField from "../validity_range/TimeGridField.vue";
+import RoomTimeTable from "./RoomTimeTable.vue";
+import GroupTimeTable from "./GroupTimeTable.vue";
+import TeacherTimeTable from "./TeacherTimeTable.vue";
+</script>
+<script>
+export default {
+  name: "Timetable",
+  data() {
+    return {
+      timeGrid: null,
+      selected: null,
+    };
+  },
+  watch: {
+    timeGrid(newTimeGrid) {
+      this.onSelected(this.selected);
+    },
+  },
+  computed: {
+    timetableAttrs() {
+      return {
+        id: this.$route.params.id,
+        timeGrid: this.timeGrid,
+      };
+    },
+  },
+  methods: {
+    onSelected(selected) {
+      this.selected = selected;
+      if (!selected && this.timeGrid) {
+        this.$router.push({
+          name: "lesrooster.timetableWithTimeGrid",
+          params: { timeGrid: this.timeGrid.id },
+        });
+      } else if (!selected && !this.timeGrid) {
+        this.$router.push({ name: "lesrooster.timetable" });
+      } else if (
+        selected.objId !== this.$route.params.id ||
+        selected.type.toLowerCase() !== this.$route.params.type ||
+        this.timeGrid.id !== this.$route.params.timeGrid
+      ) {
+        this.$router.push({
+          name: "lesrooster.timetableWithId",
+          params: {
+            timeGrid: this.timeGrid.id,
+            type: selected.type.toLowerCase(),
+            id: selected.objId,
+          },
+        });
+      }
+    },
+    setInitialTimeGrid(timeGrids) {
+      if (!this.timeGrid) {
+        this.timeGrid = timeGrids.find(
+          this.$route.params.timeGrid
+            ? (timeGrid) => timeGrid.id === this.$route.params.timeGrid
+            : (timeGrid) => timeGrid.validityRange.isCurrent && !timeGrid.group,
+        );
+      }
+    },
+  },
+};
+</script>
+
+<template>
+  <timetable-wrapper :on-selected="onSelected">
+    <template #additionalSelect="{ selected, mobile }">
+      <v-card
+        :class="{ 'mb-2': !mobile, 'mx-2 mt-2': mobile }"
+        :outlined="mobile"
+      >
+        <v-card-text>
+          <time-grid-field
+            outlined
+            filled
+            :label="$t('lesrooster.labels.select_validity_range')"
+            hide-details
+            with-dates
+            :enable-create="false"
+            v-model="timeGrid"
+            @items="setInitialTimeGrid"
+          >
+          </time-grid-field>
+        </v-card-text>
+      </v-card>
+    </template>
+    <template #additionalButton="{ selected, mobile }">
+      <div :class="{ 'full-width': mobile, 'd-flex': true }" v-if="selected">
+        <v-btn
+          outlined
+          color="secondary"
+          small
+          :class="{ 'mx-3 flex-grow-1': true, 'mb-3': mobile }"
+          :to="{
+            name: 'lesrooster.timetablePrint',
+            params: {
+              timeGrid: timeGrid.id,
+              type: selected.type.toLowerCase(),
+              id: selected.objId,
+            },
+          }"
+          target="_blank"
+        >
+          <v-icon left>mdi-printer-outline</v-icon>
+          {{ $t("lesrooster.timetable.print") }}
+        </v-btn>
+      </div>
+    </template>
+    <template #default="{ selected }">
+      <group-time-table
+        v-if="$route.params.type === 'group'"
+        v-bind="timetableAttrs"
+      />
+      <teacher-time-table
+        v-else-if="$route.params.type === 'teacher'"
+        v-bind="timetableAttrs"
+      />
+      <room-time-table
+        v-else-if="$route.params.type === 'room'"
+        v-bind="timetableAttrs"
+      />
+    </template>
+  </timetable-wrapper>
+</template>
diff --git a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql b/aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql
similarity index 66%
rename from aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql
rename to aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql
index 9bbcc2eb5a3aeaea2c5210acc1b3a59f85bad9ba..fa3c73e1e03ee94f6382d36ba000bce00059cd2f 100644
--- a/aleksis/apps/lesrooster/frontend/components/timetable_management/timetables/timetables.graphql
+++ b/aleksis/apps/lesrooster/frontend/components/timetables/timetables.graphql
@@ -111,3 +111,60 @@ query lessonsRoom($room: ID!, $timeGrid: ID!) {
     canDelete
   }
 }
+
+query lessonsGroup($group: ID!, $timeGrid: ID!) {
+  lessonsGroup: lessonsForGroup(group: $group, timeGrid: $timeGrid) {
+    id
+    bundle {
+      slotStart {
+        id
+        period
+        weekday
+      }
+      slotEnd {
+        id
+        period
+        weekday
+      }
+      recurrence
+    }
+    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
+      }
+    }
+    canEdit
+    canDelete
+  }
+}
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue
index 1004f59bf8ae61e3ce52d6d2d7e9e52213f1b81a..6e9d71abd6933bbdb1dea7d9bbe4559f449f135c 100644
--- a/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/CopyFromTimeGridMenu.vue
@@ -82,7 +82,7 @@ export default defineComponent({
       <template #activator="{ attrs, on }">
         <slot name="activator" :attrs="attrs" :on="on">
           <primary-action-button
-            i18n-key="actions.copy_last_configuration"
+            i18n-key="lesrooster.actions.copy_last_configuration"
             icon="mdi-content-copy"
           />
         </slot>
@@ -100,10 +100,10 @@ export default defineComponent({
 
     <confirm-dialog v-model="dialog" @confirm="confirm" @cancel="cancel">
       <template #title>
-        {{ $t("actions.confirm_copy_last_configuration") }}
+        {{ $t("lesrooster.actions.confirm_copy_last_configuration") }}
       </template>
       <template #text>
-        {{ $t("actions.confirm_copy_last_configuration_message") }}
+        {{ $t("lesrooster.actions.confirm_copy_last_configuration_message") }}
       </template>
     </confirm-dialog>
   </div>
diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue
index 73134aa416360dfdec9e2c07a906ed945cb0400b..87e3ee9339a5a0a636120cfd50fe98725f9f5d23 100644
--- a/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/TimeGridField.vue
@@ -47,6 +47,13 @@ export default defineComponent({
       required: [(value) => !!value || this.$t("forms.errors.required")],
     };
   },
+  props: {
+    withDates: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
   methods: {
     getCreateData(item) {
       return {
@@ -80,16 +87,21 @@ export default defineComponent({
       );
     },
     formatItem(item) {
-      if (item.group === null) {
-        return this.$t(
-          "lesrooster.validity_range.time_grid.repr.generic",
-          item.validityRange,
-        );
+      const data = {
+        name: item.validityRange.name,
+        group: item.group ? item.group.name : "",
+        start: this.$d(this.$parseISODate(item.validityRange.dateStart)),
+        end: this.$d(this.$parseISODate(item.validityRange.dateEnd)),
+      };
+
+      let key = "generic";
+      if (item.group !== null) {
+        key = "group";
+      }
+      if (this.withDates) {
+        key = "dates_" + key;
       }
-      return this.$t("lesrooster.validity_range.time_grid.repr.default", [
-        item.validityRange.name,
-        item.group.name,
-      ]);
+      return this.$t(`lesrooster.validity_range.time_grid.repr.${key}`, data);
     },
   },
 });
diff --git a/aleksis/apps/lesrooster/frontend/index.js b/aleksis/apps/lesrooster/frontend/index.js
index e9aa58e8041552f6add54b9bda69dc5854201f9a..df8ebf22621c5fc98ef5c4517c8e5f263931f04d 100644
--- a/aleksis/apps/lesrooster/frontend/index.js
+++ b/aleksis/apps/lesrooster/frontend/index.js
@@ -10,6 +10,47 @@ export default {
     permission: "lesrooster.view_lesrooster_menu_rule",
   },
   children: [
+    {
+      path: "timetable/",
+      component: () => import("./components/timetables/Timetable.vue"),
+      name: "lesrooster.timetable",
+      meta: {
+        inMenu: true,
+        titleKey: "lesrooster.timetable.menu_title",
+        toolbarTitle: "lesrooster.timetable.menu_title",
+        icon: "mdi-grid",
+        permission: "chronos.view_timetable_overview_rule",
+        fullWidth: true,
+      },
+      children: [
+        {
+          path: ":timeGrid(\\d+)/",
+          component: () => import("./components/timetables/Timetable.vue"),
+          name: "lesrooster.timetableWithTimeGrid",
+          meta: {
+            permission: "chronos.view_timetable_overview_rule",
+            fullWidth: true,
+          },
+        },
+        {
+          path: ":timeGrid(\\d+)/:type(\\w+)/:id(\\d+)/",
+          component: () => import("./components/timetables/Timetable.vue"),
+          name: "lesrooster.timetableWithId",
+          meta: {
+            permission: "chronos.view_timetable_overview_rule",
+            fullWidth: true,
+          },
+        },
+      ],
+    },
+    {
+      path: "timetable/:timeGrid(\\d+)/:type(\\w+)/:id(\\d+)/print/",
+      component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"),
+      name: "lesrooster.timetablePrint",
+      props: {
+        byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
+      },
+    },
     {
       path: "validity_ranges/",
       component: () => import("./components/validity_range/ValidityRange.vue"),
@@ -49,7 +90,7 @@ export default {
       },
     },
     {
-      path: "timetable/",
+      path: "timetable_management/",
       component: () =>
         import("./components/timetable_management/TimetableManagement.vue"),
       name: "lesrooster.timetable_management_select",
diff --git a/aleksis/apps/lesrooster/frontend/messages/de.json b/aleksis/apps/lesrooster/frontend/messages/de.json
index da44980d0e3f51e4ceccff6fb30c04ee4cb04835..f64ff4b7ec7fac98867dbad94a81adad08525e7f 100644
--- a/aleksis/apps/lesrooster/frontend/messages/de.json
+++ b/aleksis/apps/lesrooster/frontend/messages/de.json
@@ -1,15 +1,15 @@
 {
-  "actions": {
-    "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.",
-    "copy_last_configuration": "Aus anderem Zeitraum übernehmen",
-    "copy_to_day": "Zu anderem Tag übernehmen",
-    "search_courses": "Kurse durchsuchen"
-  },
-  "labels": {
-    "select_validity_range": "Gültigkeitszeitraum auswählen"
-  },
   "lesrooster": {
+    "actions": {
+      "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.",
+      "copy_last_configuration": "Aus anderem Zeitraum übernehmen",
+      "copy_to_day": "Zu anderem Tag übernehmen",
+      "search_courses": "Kurse durchsuchen"
+    },
+    "labels": {
+      "select_validity_range": "Gültigkeitszeitraum auswählen"
+    },
     "break": {
       "create_item": "Pause erstellen",
       "create_items": "Pausen erstellen",
@@ -26,7 +26,7 @@
     "lesson_raster": {
       "menu_title": "Stundenraster"
     },
-    "menu_title": "Unterrichtsmanagement",
+    "menu_title": "Stundenplanung",
     "slot": {
       "confirm_delete_multiple_slots": "Wollen Sie wirklich alle Zeitfenster am {day} löschen?",
       "create_items": "Zeitfenster erstellen",
@@ -46,6 +46,10 @@
       "weekday": "Wochentag",
       "weekdays": "Wochentage"
     },
+    "timetable": {
+      "menu_title": "Reguläre Stundenpläne",
+      "print": "Drucken"
+    },
     "supervision": {
       "break_slot": "Pause",
       "create_supervision": "Aufsicht erstellen",
diff --git a/aleksis/apps/lesrooster/frontend/messages/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json
index 72325fa572621c2e33dc878d6b4d10cd7025a154..1a619771a72d227630e5e50decf7e770daed28c6 100644
--- a/aleksis/apps/lesrooster/frontend/messages/en.json
+++ b/aleksis/apps/lesrooster/frontend/messages/en.json
@@ -1,6 +1,6 @@
 {
   "lesrooster": {
-    "menu_title": "Lesson Management",
+    "menu_title": "Lesson Planning",
     "validity_range": {
       "menu_title": "Validity Ranges",
       "title": "Validity Range",
@@ -36,8 +36,10 @@
         },
         "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)"
+          "default": "{name} ({group})",
+          "generic": "{name} (generic/catch-all)",
+          "dates_default": "{start}–{end} ({group})",
+          "dates_generic": "{start}–{end}"
         }
       }
     },
@@ -128,6 +130,10 @@
         }
       }
     },
+    "timetable": {
+      "menu_title": "Regular Timetables",
+      "print": "Print"
+    },
     "supervision": {
       "menu_title": "Supervisions",
       "title": "Supervision",
@@ -137,16 +143,16 @@
       "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"
     }
-  },
-  "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 a5ad9d114a621ed6cfcd39b4020a90a0d33dc15b..a4971e95e6b6d1b6caf735e681093130bf1d2c8e 100644
--- a/aleksis/apps/lesrooster/managers.py
+++ b/aleksis/apps/lesrooster/managers.py
@@ -1,8 +1,19 @@
-from typing import Optional
+from collections.abc import Iterable
+from datetime import time
+from typing import Optional, Union
 
-from django.db.models import QuerySet
+from django.db.models import Max, Min, Q, QuerySet
+from django.db.models.functions import Coalesce
 
-from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations, DateRangeQuerySetMixin
+from polymorphic.query import PolymorphicQuerySet
+
+from aleksis.apps.chronos.managers import TimetableType
+from aleksis.core.managers import (
+    AlekSISBaseManagerWithoutMigrations,
+    DateRangeQuerySetMixin,
+    PolymorphicBaseManager,
+)
+from aleksis.core.models import Group, Person, Room
 
 
 class TeacherPropertiesMixin:
@@ -53,3 +64,124 @@ class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin):
 
 class ValidityRangeManager(AlekSISBaseManagerWithoutMigrations):
     """Manager for validity ranges."""
+
+
+class SlotQuerySet(PolymorphicQuerySet):
+    def get_period_min(self) -> int:
+        """Get minimum period."""
+        return self.aggregate(period__min=Coalesce(Min("period"), 1)).get("period__min")
+
+    def get_period_max(self) -> int:
+        """Get maximum period."""
+        return self.aggregate(period__max=Coalesce(Max("period"), 7)).get("period__max")
+
+    def get_time_min(self) -> time | None:
+        """Get minimum time."""
+        return self.aggregate(Min("time_start")).get("time_start__min")
+
+    def get_time_max(self) -> time | None:
+        """Get maximum time."""
+        return self.aggregate(Max("time_end")).get("time_end__max")
+
+    def get_weekday_min(self) -> int:
+        """Get minimum weekday."""
+        return self.aggregate(weekday__min=Coalesce(Min("weekday"), 0)).get("weekday__min")
+
+    def get_weekday_max(self) -> int:
+        """Get maximum weekday."""
+        return self.aggregate(weekday__max=Coalesce(Max("weekday"), 6)).get("weekday__max")
+
+
+class SlotManager(PolymorphicBaseManager):
+    pass
+
+
+class LessonQuerySet(QuerySet):
+    def filter_participant(self, person: Union[Person, int]) -> "LessonQuerySet":
+        """Filter for all lessons a participant (student) attends."""
+        return self.filter(course__groups__members=person)
+
+    def filter_group(self, group: Union[Group, int]) -> "LessonQuerySet":
+        """Filter for all lessons a group (class) regularly attends."""
+        if isinstance(group, int):
+            group = Group.objects.get(pk=group)
+
+        return self.filter(
+            Q(course__groups=group) | Q(course__groups__parent_groups=group)
+        ).distinct()
+
+    def filter_groups(self, groups: Iterable[Group]) -> "LessonQuerySet":
+        """Filter for all lessons one of the groups regularly attends."""
+        return self.filter(
+            Q(course__groups__in=groups) | Q(course__groups__parent_groups__in=groups)
+        )
+
+    def filter_teacher(self, teacher: Union[Person, int]) -> "LessonQuerySet":
+        """Filter for all lessons given by a certain teacher."""
+        return self.filter(teachers=teacher)
+
+    def filter_room(self, room: Union[Room, int]) -> "LessonQuerySet":
+        """Filter for all lessons taking part in a certain room."""
+        return self.filter(rooms=room)
+
+    def filter_from_type(
+        self,
+        type_: TimetableType,
+        obj: Union[Person, Group, Room, int],
+    ) -> "LessonQuerySet":
+        """Filter lessons for a group, teacher or room by provided type."""
+        if type_ == TimetableType.GROUP:
+            return self.filter_group(obj)
+        elif type_ == TimetableType.TEACHER:
+            return self.filter_teacher(obj)
+        elif type_ == TimetableType.ROOM:
+            return self.filter_room(obj)
+        else:
+            return self.none()
+
+    def filter_from_person(self, person: Person) -> "LessonQuerySet":
+        """Filter lessons for a person."""
+        type_ = person.timetable_type
+
+        if type_ == TimetableType.TEACHER:
+            return self.filter_teacher(person)
+        elif type_ == TimetableType.GROUP:
+            return self.filter_participant(person)
+        else:
+            return self.none()
+
+
+class LessonManager(AlekSISBaseManagerWithoutMigrations):
+    pass
+
+
+class SupervisionQuerySet(QuerySet):
+    def filter_teacher(self, teacher: Union[Person, int]) -> "SupervisionQuerySet":
+        """Filter for all supervisions done by a certain teacher."""
+        return self.filter(teachers=teacher)
+
+    def filter_room(self, room: Union[Room, int]) -> "SupervisionQuerySet":
+        """Filter for all supervisions taking part in a certain room."""
+        return self.filter(rooms=room)
+
+    def filter_from_type(
+        self,
+        type_: TimetableType,
+        obj: Union[Person, Group, Room, int],
+    ) -> "SupervisionQuerySet":
+        """Filter supervisions for a eacher or room by provided type."""
+        if type_ == TimetableType.TEACHER:
+            return self.filter_teacher(obj)
+        elif type_ == TimetableType.ROOM:
+            return self.filter_room(obj)
+        else:
+            return self.none()
+
+    def filter_from_person(self, person: Person) -> Optional["SupervisionQuerySet"]:
+        """Filter supervisions for a person."""
+
+        return self.filter_teacher(person)
+
+
+class SupervisionManager(AlekSISBaseManagerWithoutMigrations):
+    pass
diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py
index 012b9ec38afe13421a651c7ee1fe337fe0e27ec4..7b222a23af2d6bb3cfe3ca3e2a96c9b10d676163 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -1,6 +1,6 @@
 import logging
 from collections.abc import Sequence
-from datetime import date, datetime, timedelta
+from datetime import date, datetime, time, timedelta
 from typing import Optional, Union
 
 from django.core.exceptions import ValidationError
@@ -26,7 +26,13 @@ from aleksis.core.models import Group, Holiday, Person, Room, SchoolTerm
 from aleksis.core.util.celery_progress import ProgressRecorder, render_progress_page
 
 from .managers import (
+    LessonManager,
+    LessonQuerySet,
     RoomPropertiesMixin,
+    SlotManager,
+    SlotQuerySet,
+    SupervisionManager,
+    SupervisionQuerySet,
     TeacherPropertiesMixin,
     ValidityRangeManager,
     ValidityRangeQuerySet,
@@ -238,6 +244,37 @@ class TimeGrid(ExtensibleModel):
         null=True,
     )
 
+    @property
+    def times_dict(self) -> dict[int, tuple[datetime, datetime]]:
+        slots = {}
+        for slot in self.slots.all():
+            slots[slot.period] = (slot.time_start, slot.time_end)
+        return slots
+
+    @property
+    def period_min(self) -> int:
+        return self.slots.get_period_min()
+
+    @property
+    def period_max(self) -> int:
+        return self.slots.get_period_max()
+
+    @property
+    def time_min(self) -> time | None:
+        return self.slots.get_time_min()
+
+    @property
+    def time_max(self) -> time | None:
+        return self.slots.get_time_max()
+
+    @property
+    def weekday_min(self) -> int:
+        return self.slots.get_weekday_min()
+
+    @property
+    def weekday_max(self) -> int:
+        return self.slots.get_weekday_max()
+
     def __str__(self):
         if self.group:
             return f"{self.validity_range}: {self.group}"
@@ -259,6 +296,8 @@ class TimeGrid(ExtensibleModel):
 class Slot(ExtensiblePolymorphicModel):
     """A slot is a time period in which a lesson can take place."""
 
+    objects = SlotManager.from_queryset(SlotQuerySet)()
+
     WEEKDAY_CHOICES = i18n_day_name_choices_lazy()
     WEEKDAY_CHOICES_SHORT = i18n_day_abbr_choices_lazy()
 
@@ -504,6 +543,8 @@ class LessonBundle(ExtensibleModel):
 class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel):
     """A lesson represents a single teaching event."""
 
+    objects = LessonManager.from_queryset(LessonQuerySet)()
+
     lesson_event = models.OneToOneField(
         LessonEvent,
         on_delete=models.SET_NULL,
@@ -587,6 +628,8 @@ class BreakSlot(Slot):
 class Supervision(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel):
     """A supervision is a time period in which a teacher supervises a room."""
 
+    objects = SupervisionManager.from_queryset(SupervisionQuerySet)()
+
     supervision_event = models.OneToOneField(
         SupervisionEvent,
         on_delete=models.SET_NULL,
diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index f67ad826acaa4fab36c41b7b1be0cb8de700f4c1..b36ddcd0ed798319d4bc71a56203b6bede0bece7 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -184,7 +184,7 @@ class Query(graphene.ObjectType):
     def resolve_time_grids(root, info):
         qs = filter_active_school_term(
             info.context, TimeGrid.objects.all(), "validity_range__school_term"
-        )
+        ).order_by("-validity_range__date_start")
         if not info.context.user.has_perm("lesrooster.view_timegrid_rule"):
             return graphene_django_optimizer.query(
                 get_objects_for_user(info.context.user, "lesrooster.view_timegrid", qs), info
diff --git a/aleksis/apps/lesrooster/static/css/lesrooster/timetable_print.css b/aleksis/apps/lesrooster/static/css/lesrooster/timetable_print.css
new file mode 100644
index 0000000000000000000000000000000000000000..9bed3576bd2494db75fa93c8f192622612a2a80a
--- /dev/null
+++ b/aleksis/apps/lesrooster/static/css/lesrooster/timetable_print.css
@@ -0,0 +1,63 @@
+.timetable-plan .row,
+.timetable-plan .col {
+  display: flex;
+  padding: 0;
+}
+
+.timetable-plan .row {
+  margin-bottom: 0;
+}
+
+.lesson-card,
+.timetable-title-card {
+  display: flex;
+  flex-grow: 1;
+  min-height: 40px;
+  box-shadow: none;
+  border: 1px solid black;
+  margin: -1px -1px 0 0;
+  border-radius: 0;
+  font-size: 11px;
+}
+
+.timetable-title-card .card-title {
+  margin-bottom: 0 !important;
+}
+
+.supervision-card {
+  min-height: 10px;
+  border-left: none;
+  border-right: none;
+}
+
+.lesson-card .card-content {
+  padding: 0;
+  text-align: center;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.lesson-card .card-content > div {
+  padding: 0;
+  flex: auto;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.timetable-title-card .card-content {
+  padding: 7px;
+  text-align: center;
+  width: 100%;
+}
+
+.lesson-card a {
+  color: inherit;
+}
+
+.card .card-title {
+  font-size: 18px;
+}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/elements.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/elements.html
new file mode 100644
index 0000000000000000000000000000000000000000..00646c2017926e6f0a253d9b248c8217ccb1fee3
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/elements.html
@@ -0,0 +1,7 @@
+<div class="card lesson-card">
+  <div class="card-content">
+    {% for element in elements %}
+        {% include "lesrooster/partials/lesson.html" with lesson=element %}
+    {% endfor %}
+  </div>
+</div>
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/group.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/group.html
new file mode 100644
index 0000000000000000000000000000000000000000..aba12c5d13f747c01b79e3545f3fd0be5d348f1f
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/group.html
@@ -0,0 +1 @@
+  {{ item.short_name }}{% if not forloop.last %},{% endif %}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/groups.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/groups.html
new file mode 100644
index 0000000000000000000000000000000000000000..6ffcaec7cc2f673bb40878e0ff176ffbce346f8f
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/groups.html
@@ -0,0 +1,5 @@
+{% if groups.count == 1 and groups.0.parent_groups.all and request.site.preferences.lesrooster__use_parent_groups %}
+  {% include "lesrooster/partials/groups_part.html" with groups=groups.0.parent_groups.all no_collapsible=no_collapsible %}
+{% else %}
+  {% include "lesrooster/partials/groups_part.html" with groups=groups no_collapsible=no_collapsible %}
+{% endif %}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/groups_part.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/groups_part.html
new file mode 100644
index 0000000000000000000000000000000000000000..97a9f049e981c27e68c4340c1f242cbbfb49973a
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/groups_part.html
@@ -0,0 +1,7 @@
+{% if groups.count > request.site.preferences.lesrooster__shorten_groups_limit and request.user.person.preferences.lesrooster__shorten_groups and not no_collapsible %}
+  {% include "components/text_collapsible.html" with template="lesrooster/partials/group.html" qs=groups %}
+{% else %}
+  {% for group in groups %}
+    {% include "lesrooster/partials/group.html" with item=group %}
+  {% endfor %}
+{% endif %}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/lesson.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/lesson.html
new file mode 100644
index 0000000000000000000000000000000000000000..969f71fb299a8524f272dcbc4b5b5ac78ae9a7f1
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/lesson.html
@@ -0,0 +1,25 @@
+{% load i18n %}
+
+<div style="{% include "lesrooster/partials/subject_colour.html" with subject=lesson.subject %}">
+  <p>
+      {# Teacher or room > Display classes #}
+      {% if type.value == "teacher" or type.value == "room" %}
+        {% if lesson.course.groups %}
+          {% include "lesrooster/partials/groups.html" with groups=lesson.course.groups.all %}
+        {% endif %}
+      {% endif %}
+
+      {# Class or room > Display teacher #}
+      {% if type.value == "room" or type.value == "group" %}
+        {% include "lesrooster/partials/teachers.html" with teachers=lesson.teachers.all %}
+      {% endif %}
+
+      {# Display subject #}
+      {% include "lesrooster/partials/subject.html" with subject=lesson.subject %}
+
+      {# Teacher or class > Display room #}
+      {% if type.value == "teacher" or type.value == "group" %}
+        {% include "lesrooster/partials/rooms.html" with rooms=lesson.rooms.all %}
+      {% endif %}
+  </p>
+</div>
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/rooms.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/rooms.html
new file mode 100644
index 0000000000000000000000000000000000000000..081540eefc6fbcca417e5dd25113986103a0de08
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/rooms.html
@@ -0,0 +1,3 @@
+{% for room in rooms %}
+  {{ room.short_name }}{% if not forloop.last %},{% endif %}
+{% endfor %}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/slot_time.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/slot_time.html
new file mode 100644
index 0000000000000000000000000000000000000000..b190c2eca0a172d23b36aa146ecbe098fdaec84b
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/slot_time.html
@@ -0,0 +1,18 @@
+{% load data_helpers %}
+
+<div class="card timetable-title-card">
+  <div class="card-content">
+
+    {# Lesson number #}
+    <div class="card-title left">
+      {{ slot.period }}.
+    </div>
+
+    {# Time dimension of lesson #}
+    <div class="right timetable-time grey-text text-darken-2">
+        <span>{{ slot.time_start|time }}</span>
+        <br/>
+        <span>{{ slot.time_end|time }}</span>
+    </div>
+  </div>
+</div>
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/subject.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/subject.html
new file mode 100644
index 0000000000000000000000000000000000000000..1b565a2f0825e2d9dff9d31699e18fdf390b1f6b
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/subject.html
@@ -0,0 +1,3 @@
+<strong>
+  {{ subject.short_name }}
+</strong>
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/subject_colour.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/subject_colour.html
new file mode 100644
index 0000000000000000000000000000000000000000..4cead55119b2c4b799c13018dc175c2fe414f059
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/subject_colour.html
@@ -0,0 +1,6 @@
+{% if subject.colour_fg %}
+  color: {{ subject.colour_fg }};
+{% endif %}
+{% if subject.colour_bg and subject.colour_bg != subject.colour_fg %}
+  background-color: {{ subject.colour_bg }};
+{% endif %}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/supervision.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/supervision.html
new file mode 100644
index 0000000000000000000000000000000000000000..6abd44eb98459de4d3283e5fe2a02c1794201854
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/supervision.html
@@ -0,0 +1,15 @@
+{% load i18n %}
+
+<div class="card lesson-card supervision-card">
+  <div class="card-content">
+    {% if supervision %}
+      <div>
+        <p>
+          <strong>{% trans "Supervision" %}</strong>
+          {% include "lesrooster/partials/rooms.html" with rooms=supervision.rooms.all %}
+          {% include "lesrooster/partials/teachers.html" with teachers=supervision.teachers.all %}
+        </p>
+      </div>
+    {% endif %}
+  </div>
+</div>
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/partials/teachers.html b/aleksis/apps/lesrooster/templates/lesrooster/partials/teachers.html
new file mode 100644
index 0000000000000000000000000000000000000000..73afb6e43670fc8cdba1f2e0e9c68c39153d0676
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/partials/teachers.html
@@ -0,0 +1,3 @@
+{% for teacher in teachers %}
+          {{ teacher.short_name }}{% if not forloop.last %},{% endif %}
+{% endfor %}
diff --git a/aleksis/apps/lesrooster/templates/lesrooster/timetable_print.html b/aleksis/apps/lesrooster/templates/lesrooster/timetable_print.html
new file mode 100644
index 0000000000000000000000000000000000000000..531c595f91fd3e6a2af078b9801cd9e286ae1de9
--- /dev/null
+++ b/aleksis/apps/lesrooster/templates/lesrooster/timetable_print.html
@@ -0,0 +1,56 @@
+{% extends 'core/base_print.html' %}
+
+{% load data_helpers static i18n %}
+
+{% block extra_head %}
+  <link rel="stylesheet" href="{% static 'css/lesrooster/timetable_print.css' %}">
+{% endblock %}
+
+{% block page_title %}
+  {% trans "Timetable" %} <i>{{ el.short_name|default:el.name }}</i>
+{% endblock %}
+{% block content %}
+
+  <div class="timetable-plan">
+    {#  Week days #}
+    <div class="row">
+      <div class="col s2">
+
+      </div>
+      {% for weekday in weekdays %}
+        <div class="col s2">
+          <div class="card timetable-title-card">
+            <div class="card-content">
+              <span class="card-title">
+                {{ weekday.1 }}
+              </span>
+            </div>
+          </div>
+        </div>
+      {% endfor %}
+    </div>
+
+    {% for row in timetable %}
+      <div class="row">
+        <div class="col s2">
+          {% if row.type == "period" %}
+            {% include "lesrooster/partials/slot_time.html" with slot=row.slot %}
+          {% endif %}
+        </div>
+
+        {% for col in row.cols %}
+          {# A lesson #}
+          <div class="col s2">
+            {% if col.type == "period" %}
+              {% include "lesrooster/partials/elements.html" with elements=col.col %}
+            {% else %}
+              {% include "lesrooster/partials/supervision.html" with supervision=col.col %}
+            {% endif %}
+          </div>
+        {% endfor %}
+      </div>
+    {% endfor %}
+  </div>
+
+  <small>{% trans "Validity range" %}: {{ time_grid.validity_range.date_start }}–{{ time_grid.validity_range.date_end }}</small>
+{% endblock %}
diff --git a/aleksis/apps/lesrooster/urls.py b/aleksis/apps/lesrooster/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..5794d92154aaa9fde3222dfb89533aedd36abe8e
--- /dev/null
+++ b/aleksis/apps/lesrooster/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path
+
+from . import views
+
+urlpatterns = [
+    path(
+        "timetable/<int:time_grid>/<str:type_>/<int:pk>/print/",
+        views.print_timetable,
+        name="timetable_print",
+    ),
+]
diff --git a/aleksis/apps/lesrooster/util/build.py b/aleksis/apps/lesrooster/util/build.py
new file mode 100644
index 0000000000000000000000000000000000000000..7d31cbdbe3ac84686b3cb578c62761d6c63070ff
--- /dev/null
+++ b/aleksis/apps/lesrooster/util/build.py
@@ -0,0 +1,77 @@
+from collections import OrderedDict
+from typing import Union
+
+from aleksis.apps.chronos.managers import TimetableType
+from aleksis.apps.lesrooster.models import BreakSlot, Lesson, Slot, Supervision, TimeGrid
+from aleksis.core.models import Group, Person, Room
+
+
+def build_timetable(
+    time_grid: TimeGrid,
+    type_: TimetableType,
+    obj: Union[Group, Room, Person],
+) -> list | None:
+    """Build regular timetable for the given time grid."""
+    slots = Slot.objects.filter(time_grid=time_grid).order_by("weekday", "time_start")
+    lesson_periods_per_slot = OrderedDict()
+    supervisions_per_slot = OrderedDict()
+    slot_map = OrderedDict()
+    for slot in slots:
+        lesson_periods_per_slot[slot] = []
+        supervisions_per_slot[slot] = []
+        slot_map.setdefault(slot.weekday, []).append(slot)
+
+    max_slots_weekday, max_slots = max(slot_map.items(), key=lambda x: len(x[1]))
+    max_slots = len(max_slots)
+
+    # Get matching lessons
+    lessons = Lesson.objects.filter(bundle__slot_start__time_grid=time_grid).filter_from_type(
+        type_, obj
+    )
+
+    # Sort lesson periods in a dict
+    for lesson in lessons:
+        lesson_periods_per_slot[lesson.bundle.all()[0].slot_start].append(lesson)
+
+    # Get matching supervisions
+    supervisions = Supervision.objects.filter(break_slot__time_grid=time_grid).filter_from_type(
+        type_, obj
+    )
+
+    for supervision in supervisions:
+        supervisions_per_slot[supervision.break_slot] = supervision
+
+    rows = []
+    for slot_idx in range(max_slots):  # period is period after break
+        left_slot = slot_map[max_slots_weekday][slot_idx]
+
+        if isinstance(left_slot, BreakSlot):
+            row = {"type": "break", "slot": left_slot}
+        else:
+            row = {
+                "type": "period",
+                "slot": left_slot,
+            }
+
+        cols = []
+
+        for weekday in range(time_grid.weekday_min, time_grid.weekday_max + 1):
+            if slot_idx > len(slot_map[weekday]) - 1:
+                continue
+            actual_slot = slot_map[weekday][slot_idx]
+
+            if isinstance(actual_slot, BreakSlot):
+                col = {"type": "break", "col": supervisions_per_slot.get(actual_slot)}
+
+            else:
+                col = {
+                    "type": "period",
+                    "col": (lesson_periods_per_slot.get(actual_slot, [])),
+                }
+
+            cols.append(col)
+
+        row["cols"] = cols
+        rows.append(row)
+
+    return rows
diff --git a/aleksis/apps/lesrooster/views.py b/aleksis/apps/lesrooster/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..d6bd30320cf5e4301ccded5bae19c5b99a5ec862
--- /dev/null
+++ b/aleksis/apps/lesrooster/views.py
@@ -0,0 +1,41 @@
+from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
+from django.shortcuts import get_object_or_404
+
+from rules.contrib.views import permission_required
+
+from aleksis.apps.chronos.managers import TimetableType
+from aleksis.apps.chronos.util.chronos_helpers import get_el_by_pk
+from aleksis.apps.lesrooster.models import Slot, TimeGrid
+from aleksis.apps.lesrooster.util.build import build_timetable
+from aleksis.core.util.pdf import render_pdf
+
+
+@permission_required("chronos.view_timetable_rule", fn=get_el_by_pk)
+def print_timetable(
+    request: HttpRequest,
+    time_grid: int,
+    type_: str,
+    pk: int,
+) -> HttpResponse:
+    """View a selected timetable for a person, group or room."""
+    context = {}
+
+    time_grid = get_object_or_404(TimeGrid, pk=time_grid)
+    el = get_el_by_pk(request, type_, pk, prefetch=True)
+
+    if isinstance(el, HttpResponseNotFound):
+        return HttpResponseNotFound()
+
+    type_ = TimetableType.from_string(type_)
+
+    timetable = build_timetable(time_grid, type_, el)
+    context["timetable"] = timetable
+
+    context["weekdays"] = Slot.WEEKDAY_CHOICES[time_grid.weekday_min : time_grid.weekday_max + 1]
+
+    context["time_grid"] = time_grid
+    context["type"] = type_
+    context["pk"] = pk
+    context["el"] = el
+
+    return render_pdf(request, "lesrooster/timetable_print.html", context)