diff --git a/LehrerApp.Core/Models/LearningGroup.cs b/LehrerApp.Core/Models/LearningGroup.cs
index 70d8605..8329a5e 100644
--- a/LehrerApp.Core/Models/LearningGroup.cs
+++ b/LehrerApp.Core/Models/LearningGroup.cs
@@ -13,6 +13,13 @@ public class LearningGroup
public bool IsActive { get; set; } = true;
public bool IsOwnClass { get; set; }
public bool IsDifferentiated { get; set; }
+ ///
+ /// Default true (alte Datensätze ohne dieses Feld verhalten sich damit unverändert wie bisher
+ /// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder
+ /// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten
+ /// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
+ ///
+ public bool RequiresLessonPlanning { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
diff --git a/LehrerApp.Core/Models/Workload.cs b/LehrerApp.Core/Models/Workload.cs
index 450b7d0..430065c 100644
--- a/LehrerApp.Core/Models/Workload.cs
+++ b/LehrerApp.Core/Models/Workload.cs
@@ -103,6 +103,6 @@ public class TimeEntry
public string? Description { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
-public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other }
+public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teaching }
public enum WorkTaskStatus { Open, InProgress, Done }
public enum TaskRecurrence { None, Weekly, Monthly }
diff --git a/LehrerApp.Core/Services/DashboardSettingsService.cs b/LehrerApp.Core/Services/DashboardSettingsService.cs
index 14fda94..f11aa93 100644
--- a/LehrerApp.Core/Services/DashboardSettingsService.cs
+++ b/LehrerApp.Core/Services/DashboardSettingsService.cs
@@ -15,7 +15,7 @@ public sealed class DashboardSettingsService
public static readonly string[] DefaultCardOrder =
[
"today", "tasks", "calendar", "excuses", "upcoming",
- "corrections", "alerts", "attendance", "support", "groups",
+ "corrections", "unplanned", "alerts", "attendance", "support", "groups",
];
private readonly string _configPath;
diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs
index e54e76a..3cfc311 100644
--- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs
@@ -26,14 +26,23 @@ public sealed class DashboardViewModelTests
return new DashboardSettingsService(tempPath);
}
+ private static SchoolCalendarSettingsService NewCalendarSettings()
+ {
+ var tempPath = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(), $"lehrerapp-schoolcalendar-tests-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempPath);
+ return new SchoolCalendarSettingsService(tempPath);
+ }
+
private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson,
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null,
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
- DashboardSettingsService? dashboardSettings = null)
+ DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
+ FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null)
{
- var lessons = new FakeLessons();
+ lessons ??= new FakeLessons();
lessons.Add(lesson);
return new DashboardViewModel(
new FakeGroups([group]), new FakeSubjects([]), lessons,
@@ -42,7 +51,9 @@ public sealed class DashboardViewModelTests
tasks ?? new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(),
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
- new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings());
+ new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
+ schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
+ substitutions ?? new FakeSubstitutionEntries());
}
[Fact]
@@ -227,4 +238,119 @@ public sealed class DashboardViewModelTests
Assert.False(saved.Single(c => c.Key == "today").IsVisible);
Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1);
}
+
+ // ── Ungeplante Stunden (Nutzer-Feedback) ──────────────────────────────────
+
+ [Fact]
+ public void UnplannedLessons_StundeplanSlotOhneLessonMitThema_ErscheintInDerListe()
+ {
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var group = new LearningGroup { Name = "9c" };
+ var slots = new FakeTimetableSlots();
+ slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
+ // "lesson" liegt bewusst weit in der Vergangenheit, damit sie nicht in den heute/morgen-
+ // Vorgriff fällt und den Test verfälscht.
+ var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
+
+ var vm = BuildVm(group, pastLesson, slots: slots);
+
+ var item = Assert.Single(vm.UnplannedLessons);
+ Assert.Equal(group.Id, item.GroupId);
+ Assert.Equal(1, item.PeriodNumber);
+ Assert.Equal("Heute", item.DateDisplay);
+ }
+
+ [Fact]
+ public void UnplannedLessons_LessonMitThemaVorhanden_ErscheintNicht()
+ {
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var group = new LearningGroup { Name = "9c" };
+ var slots = new FakeTimetableSlots();
+ slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
+ var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 1, Topic = "Redoxreaktionen" };
+
+ var vm = BuildVm(group, lesson, slots: slots);
+
+ Assert.Empty(vm.UnplannedLessons);
+ }
+
+ [Fact]
+ public void UnplannedLessons_GruppeOhnePlanungsbedarf_WirdIgnoriert()
+ {
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var group = new LearningGroup { Name = "Klassenrat", RequiresLessonPlanning = false };
+ var slots = new FakeTimetableSlots();
+ slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
+ var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
+
+ var vm = BuildVm(group, pastLesson, slots: slots);
+
+ Assert.Empty(vm.UnplannedLessons);
+ }
+
+ [Fact]
+ public void UnplannedLessons_Feiertag_WirdUebersprungen()
+ {
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var group = new LearningGroup { Name = "9c" };
+ var slots = new FakeTimetableSlots();
+ slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
+ var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
+ var schoolHolidays = new FakeSchoolHolidays();
+ schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = today, EndDate = today.AddDays(1) });
+
+ var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
+
+ Assert.Empty(vm.UnplannedLessons);
+ }
+
+ [Fact]
+ public void UnplannedLessons_DoppelstundeMitThemaAmErstenSlot_ZweiterSlotWirdNichtGemeldet()
+ {
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var group = new LearningGroup { Name = "9c" };
+ var slots = new FakeTimetableSlots();
+ slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
+ slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
+ var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" };
+
+ var vm = BuildVm(group, lesson, slots: slots);
+
+ Assert.Empty(vm.UnplannedLessons);
+ }
+
+ [Fact]
+ public void UnplannedLessons_DoppelstundeOhneThema_BeideSlotsWerdenGemeldet()
+ {
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var group = new LearningGroup { Name = "9c" };
+ var slots = new FakeTimetableSlots();
+ slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
+ slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
+ var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "" };
+
+ var vm = BuildVm(group, lesson, slots: slots);
+
+ Assert.Equal(2, vm.UnplannedLessons.Count);
+ Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3);
+ Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4);
+ }
+
+ [Fact]
+ public void UnplannedLessons_StundenausfallEingetragen_WirdNichtGemeldet()
+ {
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var group = new LearningGroup { Name = "9c" };
+ var slots = new FakeTimetableSlots();
+ slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 5 });
+ slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 6 });
+ var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 5, Topic = "Alt" };
+ var substitutions = new FakeSubstitutionEntries();
+ substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 5 });
+ substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 6 });
+
+ var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
+
+ Assert.Empty(vm.UnplannedLessons);
+ }
}
diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs
index 449168b..a8594b0 100644
--- a/LehrerApp.Desktop.Tests/Fakes.cs
+++ b/LehrerApp.Desktop.Tests/Fakes.cs
@@ -1,5 +1,6 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Sync;
@@ -47,6 +48,14 @@ public static class TestSupport
Directory.CreateDirectory(tempPath);
return new EventQueue(Path.Combine(tempPath, "queue.db"));
}
+
+ /// Analog zu , eigenes Temp-Verzeichnis je Aufruf.
+ public static PeriodScheduleService BuildPeriodScheduleService()
+ {
+ var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempPath);
+ return new PeriodScheduleService(tempPath);
+ }
}
public class FakeStudents(List all) : IStudentRepository
diff --git a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs
index dfb69a8..6788c61 100644
--- a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs
@@ -388,4 +388,53 @@ public sealed class LessonDialogViewModelTests
Assert.False(vm.HasTimeBudgetInfo);
}
+
+ // ── Datumsvorschlag für eine neue Stunde (Nutzer-Feedback: Feld stand sonst immer auf
+ // "heute", was die Doppelstunden-Erkennung stillschweigend auf eine Einzelperiode
+ // zurückfallen ließ, wenn heute zufällig nicht der Unterrichtstag ist) ────────────────────
+
+ [Fact]
+ public void NeueStunde_OhneStundenplanEintraege_BleibtBeimHeutigenDatum()
+ {
+ var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); // FakeTimetableSlots leer
+
+ Assert.Equal(DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"), vm.DateText);
+ }
+
+ [Fact]
+ public void NeueStunde_LetzteStundeInDerZukunft_SchlaegtNaechstenPassendenWochentagDanachVor()
+ {
+ var unitId = Guid.NewGuid();
+ var groupId = Guid.NewGuid();
+ var futureLessonDate = new DateOnly(2099, 1, 6); // Dienstag
+ var lessons = new FakeLessons();
+ lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = futureLessonDate, LessonNumber = 3 });
+ var slots = new FakeTimetableSlots();
+ slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
+
+ var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
+ slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
+
+ // Nächster Dienstag nach dem 06.01.2099 (einem Dienstag) ist der 13.01.2099.
+ Assert.Equal("13.01.2099", vm.DateText);
+ }
+
+ [Fact]
+ public void NeueStunde_LetzteStundeInDerVergangenheit_SchlaegtNaechstenPassendenWochentagAbHeuteVor()
+ {
+ var unitId = Guid.NewGuid();
+ var groupId = Guid.NewGuid();
+ var lessons = new FakeLessons();
+ lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2000, 1, 1), LessonNumber = 3 });
+ var weekday = DateTime.Today.DayOfWeek; // garantiert am selben oder folgenden Tag erreichbar
+ var slots = new FakeTimetableSlots();
+ slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 });
+
+ var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
+ slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
+
+ var suggested = DateOnly.ParseExact(vm.DateText, "dd.MM.yyyy");
+ Assert.True(suggested >= DateOnly.FromDateTime(DateTime.Today));
+ Assert.Equal(weekday, suggested.DayOfWeek);
+ }
}
diff --git a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs
index 7f3421f..7be2b94 100644
--- a/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/WorkloadViewModelTests.cs
@@ -1,3 +1,4 @@
+using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Workload;
@@ -288,14 +289,16 @@ public sealed class AddEditWorkTaskDialogViewModelTests
public sealed class TimeTrackingViewModelTests
{
+ private static TimeTrackingViewModel BuildVm(ITimeEntryRepository entries, IWorkTaskRepository tasks,
+ FakeTimetableSlots? slots = null) =>
+ new(entries, tasks, slots ?? new FakeTimetableSlots(), TestSupport.BuildPeriodScheduleService());
+
[Fact]
public void StartStopTimer_SpeichertZeiteintragMitMindestensEinerMinute()
{
var entries = new FakeTimeEntries();
- var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks())
- {
- SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction),
- };
+ var vm = BuildVm(entries, new FakeWorkTasks());
+ vm.SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction);
vm.StartTimerCommand.Execute(null);
Assert.True(vm.IsTimerRunning);
@@ -314,7 +317,7 @@ public sealed class TimeTrackingViewModelTests
var task = new WorkTask { Title = "T", Category = TaskCategory.Meeting };
var tasks = new FakeWorkTasks();
tasks.Add(task);
- var vm = new TimeTrackingViewModel(new FakeTimeEntries(), tasks);
+ var vm = BuildVm(new FakeTimeEntries(), tasks);
vm.SelectedTimerTask = task;
@@ -328,7 +331,7 @@ public sealed class TimeTrackingViewModelTests
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today).AddDays(-30), DurationMinutes = 45 });
- var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
+ var vm = BuildVm(entries, new FakeWorkTasks());
Assert.Single(vm.WeekEntries);
Assert.Equal(30, vm.WeekEntries[0].Model.DurationMinutes);
@@ -343,7 +346,7 @@ public sealed class TimeTrackingViewModelTests
entries.Add(new TimeEntry { Category = "Korrektur", Date = today, DurationMinutes = 20 });
entries.Add(new TimeEntry { Category = "Verwaltung", Date = today, DurationMinutes = 10 });
- var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
+ var vm = BuildVm(entries, new FakeWorkTasks());
var korrektur = vm.CategorySummaries.Single(s => s.Category == "Korrektur");
Assert.Equal("50 min", korrektur.MinutesDisplay);
@@ -356,7 +359,7 @@ public sealed class TimeTrackingViewModelTests
{
var entries = new FakeTimeEntries();
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
- var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
+ var vm = BuildVm(entries, new FakeWorkTasks());
vm.DeleteEntryCommand.Execute(vm.WeekEntries[0]);
@@ -367,7 +370,7 @@ public sealed class TimeTrackingViewModelTests
public async Task AddEntry_RuftOnAddEntryAufUndSpeichertErgebnis()
{
var entries = new FakeTimeEntries();
- var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
+ var vm = BuildVm(entries, new FakeWorkTasks());
vm.OnAddEntry = () => Task.FromResult(
new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 15 });
@@ -375,6 +378,64 @@ public sealed class TimeTrackingViewModelTests
Assert.Single(vm.WeekEntries);
}
+
+ // ── Unterrichtszeit-Vorschlag (Nutzer-Feedback: Zeiterfassung soll bei der Unterrichtszeit
+ // nachfragen statt sie komplett automatisch zu erfassen) ────────────────────────────────────
+
+ [Fact]
+ public void HasTeachingTimeSuggestionToday_OhneStundenplanHeute_IstFalse()
+ {
+ var vm = BuildVm(new FakeTimeEntries(), new FakeWorkTasks()); // leere FakeTimetableSlots
+
+ Assert.False(vm.HasTeachingTimeSuggestionToday);
+ }
+
+ [Fact]
+ public async Task SuggestTeachingTime_SchlaegtBeginnMitPufferVorErsterUndNachLetzterPeriodeVor()
+ {
+ var today = DateTime.Today.DayOfWeek;
+ var slots = new FakeTimetableSlots();
+ slots.Add(new TimetableSlot { Weekday = today, PeriodNumber = 1 });
+ slots.Add(new TimetableSlot { Weekday = today, PeriodNumber = 2 });
+ var periodSchedule = TestSupport.BuildPeriodScheduleService();
+ periodSchedule.SetPeriods([
+ new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(7, 50), End = new TimeOnly(8, 35) },
+ new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 35), End = new TimeOnly(9, 20) },
+ ]);
+ var entries = new FakeTimeEntries();
+ var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks(), slots, periodSchedule);
+
+ Assert.True(vm.HasTeachingTimeSuggestionToday);
+
+ (TimeOnly Start, TimeOnly End)? suggested = null;
+ vm.OnSuggestTeachingTime = (start, end) =>
+ {
+ suggested = (start, end);
+ return Task.FromResult(new TimeEntry
+ {
+ Category = "Unterricht", Date = DateOnly.FromDateTime(DateTime.Today),
+ StartTime = start, EndTime = end, DurationMinutes = (int)(end - start).TotalMinutes,
+ });
+ };
+
+ await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
+
+ Assert.Equal(new TimeOnly(7, 35), suggested!.Value.Start); // 7:50 - 15 Min Puffer
+ Assert.Equal(new TimeOnly(9, 30), suggested.Value.End); // 9:20 + 10 Min Puffer
+ Assert.Single(entries.GetByDate(DateOnly.FromDateTime(DateTime.Today)));
+ }
+
+ [Fact]
+ public async Task SuggestTeachingTime_OhneStundenplanHeute_TutNichts()
+ {
+ var vm = BuildVm(new FakeTimeEntries(), new FakeWorkTasks());
+ var called = false;
+ vm.OnSuggestTeachingTime = (_, _) => { called = true; return Task.FromResult(null); };
+
+ await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
+
+ Assert.False(called);
+ }
}
public sealed class AddTimeEntryDialogViewModelTests
diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs
index cbe7916..41c380a 100644
--- a/LehrerApp.Desktop/App.axaml.cs
+++ b/LehrerApp.Desktop/App.axaml.cs
@@ -101,6 +101,7 @@ public class App : Application
dash.OnNavigateToStudent = id => main.NavigateToStudent(id);
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
+ dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
// StudentList → StudentDetail + Anlegen
var sl = Services.GetRequiredService();
diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
index b7552cd..9f4c393 100644
--- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs
@@ -30,10 +30,18 @@ public partial class DashboardViewModel : ObservableObject
private readonly AttendanceBalanceService _attendanceBalance;
private readonly SchoolYearService _sy;
private readonly DashboardSettingsService _dashboardSettings;
+ private readonly ISchoolHolidayRepository _schoolHolidays;
+ private readonly PublicHolidayService _publicHolidays;
+ private readonly SchoolCalendarSettingsService _calendarSettings;
+ private readonly ISubstitutionEntryRepository _substitutions;
private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14;
private const int UpcomingWithinDays = 30;
+ /// Nutzer-Feedback: "der zeitliche Vorgriff sollte sinnvoll sein" - heute + morgen ist knapp
+ /// genug, dass die Erinnerung nicht zu einer ignorierbaren Dauerliste wird, aber früh genug,
+ /// um sich abends noch vorzubereiten.
+ private const int UnplannedLessonsLookaheadDays = 1;
[ObservableProperty] private string _greeting = "";
[ObservableProperty] private string _currentDate = "";
@@ -53,6 +61,7 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection SupportPlanReviews { get; } = [];
public ObservableCollection UpcomingDates { get; } = [];
public ObservableCollection OpenCorrections { get; } = [];
+ public ObservableCollection UnplannedLessons { get; } = [];
public ObservableCollection Alerts { get; } = [];
public ObservableCollection SelectedDayEvents { get; } = [];
public ObservableCollection DashboardCards { get; } = [];
@@ -66,6 +75,9 @@ public partial class DashboardViewModel : ObservableObject
// Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt.
public Action? OnNavigateToLesson { get; set; }
public Action? OnNavigateToExam { get; set; }
+ // Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
+ // (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
+ public Action? OnNavigateToUnplannedLesson { get; set; }
public DashboardCardOption TodayCard => Card("today");
public DashboardCardOption TasksCard => Card("tasks");
@@ -73,6 +85,7 @@ public partial class DashboardViewModel : ObservableObject
public DashboardCardOption ExcusesCard => Card("excuses");
public DashboardCardOption UpcomingCard => Card("upcoming");
public DashboardCardOption CorrectionsCard => Card("corrections");
+ public DashboardCardOption UnplannedCard => Card("unplanned");
public DashboardCardOption AlertsCard => Card("alerts");
public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption SupportCard => Card("support");
@@ -85,7 +98,9 @@ public partial class DashboardViewModel : ObservableObject
IParticipationRepository participationEntries, IStudentRepository students,
IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots,
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
- DashboardSettingsService dashboardSettings)
+ DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
+ PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
+ ISubstitutionEntryRepository substitutions)
{
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -93,6 +108,8 @@ public partial class DashboardViewModel : ObservableObject
_students = students; _documentation = documentation;
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
+ _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
+ _substitutions = substitutions;
LoadDashboardCards();
Load();
}
@@ -157,6 +174,7 @@ public partial class DashboardViewModel : ObservableObject
LoadSupportPlanReviews(today);
LoadUpcomingDates(groups, today);
LoadOpenCorrections(groups, today);
+ LoadUnplannedLessons(groups, today);
LoadAlerts(groups, today);
}
@@ -264,6 +282,87 @@ public partial class DashboardViewModel : ObservableObject
}
}
+ // ── Ungeplante Stunden (Nutzer-Feedback: Erinnerung an Stunden ohne Thema) ────────────────
+ //
+ // Nur heute + UnplannedLessonsLookaheadDays (bewusst knapp, siehe Konstante oben) — für jede
+ // Gruppe mit RequiresLessonPlanning und einem Stundenplan-Slot an diesem Wochentag wird
+ // geprüft, ob dafür bereits eine Lesson mit Thema existiert. Gruppen ohne eigenen Verlaufsplan
+ // (Klassenrat, Willkommenskreis) lassen sich in den Stammdaten der Gruppe ausnehmen. Ein Slot,
+ // für den an diesem Datum ein SubstitutionKind.Cancelled-Eintrag (4.3, "Stundenausfall")
+ // vorliegt, fällt ganz weg — gleiche Prüfung (Datum + Stundennummer, ohne Gruppenbezug) wie in
+ // TimetableViewModel.BuildToday.
+
+ private void LoadUnplannedLessons(IReadOnlyDictionary groups, DateOnly today)
+ {
+ UnplannedLessons.Clear();
+ var lastDay = today.AddDays(UnplannedLessonsLookaheadDays);
+ var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1)
+ .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
+ .Select(h => h.Date).ToHashSet();
+ var schoolHolidays = _schoolHolidays.GetAll();
+
+ var items = new List();
+ foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning))
+ {
+ var slots = _timetableSlots.GetByGroup(group.Id);
+ if (slots.Count == 0) continue;
+
+ for (var date = today; date <= lastDay; date = date.AddDays(1))
+ {
+ if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
+ var daySlots = slots.Where(s => s.Weekday == date.DayOfWeek).ToList();
+ if (daySlots.Count == 0) continue;
+ var periodsWithSlot = daySlots.Select(s => s.PeriodNumber).ToHashSet();
+ var dayLessons = _lessons.GetByGroupAndDate(group.Id, date).ToList();
+ var cancelledPeriods = _substitutions.GetByDate(date)
+ .Where(s => s.Kind == SubstitutionKind.Cancelled)
+ .Select(s => s.PeriodNumber).ToHashSet();
+
+ foreach (var slot in daySlots)
+ {
+ if (cancelledPeriods.Contains(slot.PeriodNumber)) continue;
+ var lesson = dayLessons.FirstOrDefault(l => l.LessonNumber == slot.PeriodNumber);
+ if (lesson is not null)
+ {
+ if (!string.IsNullOrWhiteSpace(lesson.Topic)) continue;
+ }
+ else if (IsCoveredByEarlierDoppelstunde(dayLessons, periodsWithSlot, slot.PeriodNumber))
+ {
+ continue;
+ }
+ items.Add(new UnplannedLessonItem(group.Id, group.Name, date, slot.PeriodNumber, today));
+ }
+ }
+ }
+ foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber).ThenBy(i => i.GroupName))
+ UnplannedLessons.Add(item);
+ }
+
+ /// Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die
+ /// bei einer früheren Stundennummer beginnt — gleiches Prinzip wie
+ /// PlanningViewModels.LessonDialogViewModel.RecomputeTimeBudget: ausgehend von der Vorperiode
+ /// wird rückwärts so lange die jeweils vorherige Periode geprüft, wie der Stundenplan dafür
+ /// noch einen Slot hat. Trifft man dabei auf eine Lesson, entscheidet deren Thema (vorhanden =
+ /// Doppelstunde bereits geplant); trifft man auf eine Periode ohne Lesson, wird weiter
+ /// zurückgegangen; bricht die Slot-Kette ab, ohne eine Lesson gefunden zu haben, ist die Periode
+ /// nicht abgedeckt.
+ private static bool IsCoveredByEarlierDoppelstunde(
+ List dayLessons, HashSet periodsWithSlot, int periodNumber)
+ {
+ for (var period = periodNumber - 1; periodsWithSlot.Contains(period); period--)
+ {
+ var lesson = dayLessons.FirstOrDefault(l => l.LessonNumber == period);
+ if (lesson is not null) return !string.IsNullOrWhiteSpace(lesson.Topic);
+ }
+ return false;
+ }
+
+ /// Dupliziert absichtlich TimetableViewModel.IsFreeDay/die gleichnamige Prüfung in
+ /// PlanningViewModels.GenerateLessonSeriesDialogViewModel.Save() — zwei Zeilen, kein
+ /// Service-Aufwand für eine dritte Fundstelle.
+ private static bool IsFreeDay(DateOnly date, List schoolHolidays, HashSet publicHolidayDates) =>
+ publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate);
+
// ── Auffälligkeiten (9.5) ────────────────────────────────────────────────
private void LoadAlerts(IReadOnlyDictionary groups, DateOnly today)
@@ -330,7 +429,7 @@ public partial class DashboardViewModel : ObservableObject
{
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
- "corrections" => "Offene Korrekturen", "alerts" => "Auffälligkeiten",
+ "corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", _ => key,
};
@@ -524,6 +623,8 @@ public partial class DashboardViewModel : ObservableObject
}
[RelayCommand] private void OpenCorrection(CorrectionProgressItem? item)
{ if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); }
+ [RelayCommand] private void OpenUnplannedLesson(UnplannedLessonItem? item)
+ { if (item is not null) OnNavigateToUnplannedLesson?.Invoke(item.GroupId); }
[RelayCommand] private void OpenAlert(DashboardAlertItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand] private void Refresh() => Load();
@@ -685,6 +786,16 @@ public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string tit
public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total;
}
+public sealed class UnplannedLessonItem(Guid groupId, string groupName, DateOnly date, int periodNumber, DateOnly today)
+{
+ public Guid GroupId { get; } = groupId;
+ public string GroupName { get; } = groupName;
+ public DateOnly Date { get; } = date;
+ public int PeriodNumber { get; } = periodNumber;
+ public string DateDisplay => Date == today ? "Heute" : Date == today.AddDays(1) ? "Morgen" : Date.ToString("dd.MM.");
+ public string Display => $"{GroupName} · {PeriodNumber}. Stunde";
+}
+
public enum AlertSeverity { Medium, High }
public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName,
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
index bcd48cf..dd6d5db 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
@@ -810,6 +810,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private int? _hoursPerWeek;
[ObservableProperty] private bool _isOwnClass;
[ObservableProperty] private bool _isDifferentiated;
+ [ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _gradeLevelError = "";
@@ -848,6 +849,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
HoursPerWeek = group.HoursPerWeek;
IsOwnClass = group.IsOwnClass;
IsDifferentiated = group.IsDifferentiated;
+ RequiresLessonPlanning = group.RequiresLessonPlanning;
OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText));
}
@@ -893,6 +895,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.HoursPerWeek = HoursPerWeek;
Result.IsOwnClass = IsOwnClass;
Result.IsDifferentiated = IsDifferentiated;
+ Result.RequiresLessonPlanning = RequiresLessonPlanning;
_groups.Save(Result);
}
}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs
index 2922c0b..41d722b 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs
@@ -711,9 +711,35 @@ public partial class LessonDialogViewModel : ObservableObject
StatusName = LessonStatusDisplay.ToName(editingLesson.Status);
foreach (var p in editingLesson.Phases) AddPhaseInternal(p);
}
+ else
+ {
+ DateText = SuggestNextLessonDate().ToString("dd.MM.yyyy");
+ }
RecomputeTimes();
}
+ ///
+ /// Vorbelegung für eine neue Stunde: statt des sonst über den Feld-Default eingesetzten
+ /// heutigen Datums (das an einem beliebigen Wochentag steht und die Doppelstunden-Erkennung
+ /// in stillschweigend auf eine Einzelperiode zurückfallen
+ /// lässt, wenn der Wochentag nicht zufällig passt) der nächste laut Stundenplan (4.3) für
+ /// diese Gruppe passende Wochentag — ab der letzten Stunde dieser Einheit, oder ab heute, wenn
+ /// noch keine Stunde in dieser Einheit existiert oder die letzte in der Vergangenheit liegt.
+ /// Ohne Stundenplan-Einträge für die Gruppe (z.B. Klassenrat) bleibt es beim heutigen Datum.
+ ///
+ private DateOnly SuggestNextLessonDate()
+ {
+ var weekdays = _timetableSlots.GetByGroup(_groupId).Select(s => s.Weekday).ToHashSet();
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ if (weekdays.Count == 0) return today;
+
+ var lastLessonDate = _lessons.GetByUnit(_unitId).Select(l => l.Date)
+ .DefaultIfEmpty(today.AddDays(-1)).Max();
+ var candidate = lastLessonDate >= today ? lastLessonDate.AddDays(1) : today;
+ while (!weekdays.Contains(candidate.DayOfWeek)) candidate = candidate.AddDays(1);
+ return candidate;
+ }
+
[RelayCommand]
private void AddPhase()
{
diff --git a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs
index 6d11446..32cd5cb 100644
--- a/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Workload/WorkloadViewModels.cs
@@ -46,6 +46,7 @@ public static class TaskCategoryDisplay
TaskCategory.Admin => "Verwaltung",
TaskCategory.Meeting => "Besprechung",
TaskCategory.Other => "Sonstiges",
+ TaskCategory.Teaching => "Unterricht",
_ => c.ToString(),
};
@@ -333,6 +334,15 @@ public partial class TimeTrackingViewModel : ObservableObject
{
private readonly ITimeEntryRepository _entries;
private readonly IWorkTaskRepository _tasks;
+ private readonly ITimetableSlotRepository _timetableSlots;
+ private readonly PeriodScheduleService _periodSchedule;
+
+ // Nutzer-Feedback: "man beginnt ja auch vermutlich vor 7:50" (erste Stunde) und "wird auch
+ // nicht aus dem Unterricht nach Hause rennen" (nach der letzten) - grobe, aber plausible
+ // Puffer für den Unterrichtszeit-Vorschlag. Der Vorschlag füllt nur den Nacherfassen-Dialog
+ // vor, gespeichert wird erst nach ausdrücklicher Bestätigung dort (siehe SuggestTeachingTime).
+ private const int BufferBeforeFirstPeriodMinutes = 15;
+ private const int BufferAfterLastPeriodMinutes = 10;
public const string NoTaskOption = "Keine Aufgabe";
@@ -351,12 +361,24 @@ public partial class TimeTrackingViewModel : ObservableObject
public ObservableCollection CategorySummaries { get; } = [];
public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche";
- public Func>? OnAddEntry { get; set; }
+ /// Ob heute laut Stundenplan überhaupt Unterricht ansteht - steuert, ob der
+ /// "Unterrichtszeit übernehmen"-Button überhaupt sichtbar ist.
+ public bool HasTeachingTimeSuggestionToday => ComputeTodaysTeachingWindow() is not null;
- public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks)
+ public Func>? OnAddEntry { get; set; }
+ /// Wie OnAddEntry, aber öffnet den Dialog mit vorbefüllter Kategorie "Unterricht" und den
+ /// laut Stundenplan/Stundenraster vorgeschlagenen Beginn-/Ende-Zeiten - eine echte Bestätigung
+ /// im Dialog bleibt aber immer nötig, nichts wird automatisch gespeichert (siehe Puffer-
+ /// Konstanten oben).
+ public Func>? OnSuggestTeachingTime { get; set; }
+
+ public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks,
+ ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule)
{
_entries = entries;
_tasks = tasks;
+ _timetableSlots = timetableSlots;
+ _periodSchedule = periodSchedule;
Load();
}
@@ -442,6 +464,38 @@ public partial class TimeTrackingViewModel : ObservableObject
Refresh();
}
+ [RelayCommand]
+ private async Task SuggestTeachingTime()
+ {
+ if (OnSuggestTeachingTime is null || ComputeTodaysTeachingWindow() is not var (start, end)) return;
+ var result = await OnSuggestTeachingTime(start, end);
+ if (result is null) return;
+ _entries.Save(result);
+ Refresh();
+ }
+
+ ///
+ /// Frühester Beginn / spätestes Ende aller heutigen Stundenplan-Perioden (alle Gruppen, nicht
+ /// auf eine einzelne beschränkt - der Unterrichtstag als Ganzes), je um die oben definierten
+ /// Puffer erweitert. Ohne Stundenplan-Eintrag heute oder ohne im Stundenraster hinterlegte
+ /// Zeiten gibt es keinen Vorschlag (null) statt einer erfundenen Zeit.
+ ///
+ private (TimeOnly Start, TimeOnly End)? ComputeTodaysTeachingWindow()
+ {
+ var today = DateOnly.FromDateTime(DateTime.Today).DayOfWeek;
+ var periodTimes = _timetableSlots.GetAll()
+ .Where(s => s.Weekday == today)
+ .Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
+ .Where(t => t is not null)
+ .Select(t => t!.Value)
+ .ToList();
+ if (periodTimes.Count == 0) return null;
+
+ var start = periodTimes.Min(t => t.Start).AddMinutes(-BufferBeforeFirstPeriodMinutes);
+ var end = periodTimes.Max(t => t.End).AddMinutes(BufferAfterLastPeriodMinutes);
+ return (start, end);
+ }
+
[RelayCommand]
private void DeleteEntry(TimeEntryListItem? item)
{
diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml
index 1c70d9c..1a7e847 100644
--- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml
+++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml
@@ -54,7 +54,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml b/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml
index a1e0b99..897430e 100644
--- a/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml
+++ b/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml
@@ -66,9 +66,13 @@
-
+
-
+
+
diff --git a/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml.cs b/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml.cs
index 8f120d5..4b876d1 100644
--- a/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Workload/TimeTrackingView.axaml.cs
@@ -14,10 +14,15 @@ public partial class TimeTrackingView : UserControl
{
base.OnDataContextChanged(e);
if (DataContext is TimeTrackingViewModel vm)
- vm.OnAddEntry = ShowAddEntryDialog;
+ {
+ vm.OnAddEntry = () => ShowAddEntryDialog();
+ vm.OnSuggestTeachingTime = (start, end) => ShowAddEntryDialog(prefillCategory: "Unterricht",
+ prefillStart: start, prefillEnd: end);
+ }
}
- private async Task ShowAddEntryDialog()
+ private async Task ShowAddEntryDialog(string? prefillCategory = null,
+ TimeOnly? prefillStart = null, TimeOnly? prefillEnd = null)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
@@ -25,6 +30,9 @@ public partial class TimeTrackingView : UserControl
var tasks = App.Services.GetRequiredService()
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
var vm = new AddTimeEntryDialogViewModel(tasks);
+ if (prefillCategory is not null) vm.SelectedCategory = prefillCategory;
+ if (prefillStart is { } s) vm.StartTimeText = s.ToString("HH:mm");
+ if (prefillEnd is { } e) vm.EndTimeText = e.ToString("HH:mm");
var dialog = new AddTimeEntryDialog { DataContext = vm };
await dialog.ShowDialog(owner);
return vm.Result;
diff --git a/TODO.md b/TODO.md
index f802851..35fd703 100644
--- a/TODO.md
+++ b/TODO.md
@@ -460,6 +460,20 @@ Redesign:
Viewer unabhängig ab `Lesson.StartTime`, nicht ab einer gemeinsamen Verzweigungsstelle im
Hauptweg — deutlich einfacher und für den schnellen Überblick ausreichend.
+**Nachtrag — Datum-Vorbelegung beim manuellen "+ Stunde"-Dialog:** Nutzer-Bug-Report: eine
+manuell angelegte Doppelstunde zeigte immer 45 statt 90 Minuten Zeitbedarf. Ursache war kein
+Fehler in der Doppelstunden-Erkennung (Nachtrag zu 4.2.2 oben) selbst, sondern dass `DateText`
+im `LessonDialogViewModel` beim Neuanlegen immer auf "heute" vorbelegt war — traf dieses Datum
+nicht auf den Wochentag der eingetragenen Stundennummer im Stundenplan, lief die
+weekday-basierte Nachschlage-Logik für die Folgeperiode ins Leere und die Erkennung blieb
+stumm bei 45 Minuten, ohne dass das im UI ersichtlich war. `LessonDialogViewModel.
+SuggestNextLessonDate()` ersetzt jetzt den "heute"-Default beim Neuanlegen: sie ermittelt aus
+`TimetableSlot` alle Wochentage, an denen die Gruppe laut Stundenplan Unterricht hat, nimmt das
+späteste bereits existierende `Lesson.Date` der Einheit (oder heute, falls die Einheit noch
+leer ist) und rollt von dort vorwärts auf den nächsten passenden Wochentag. Fehlt ein
+Stundenplan-Eintrag für die Gruppe, bleibt "heute" als Fallback erhalten (keine Verhaltens-
+änderung für Gruppen ohne Stundenplan).
+
### 4.3 Stundenplan
- [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository —
[Planning.cs](LehrerApp.Core/Models/Planning.cs),
@@ -1101,6 +1115,22 @@ Auswertung (6.3.3), der am noch nicht existierenden Kapitel 11 (Export-Infrastru
Zeiteintrag existiert; dafür braucht `WorkTaskListViewModel` jetzt zusätzlich
`ITimeEntryRepository`.
+**Nachtrag — Komfort-Funktion "Unterrichtszeit heute übernehmen":** Nutzer-Wunsch: Unterrichtszeit
+soll nicht komplett automatisch erfasst werden ("weiß nicht, ob das rechtlich sinnvoll ist"),
+aber ein Vorschlag mit Rückfrage ist erwünscht — man beginnt vor der ersten Stunde und geht nicht
+sofort nach der letzten. Neuer Button "🕓 Unterrichtszeit heute übernehmen" in
+`TimeTrackingView` (nur sichtbar, wenn die Gruppe heute laut Stundenplan überhaupt Unterricht
+hat — `TimeTrackingViewModel.HasTeachingTimeSuggestionToday`). `ComputeTodaysTeachingWindow()`
+ermittelt aus allen `TimetableSlot`-Einträgen des heutigen Wochentags (`ITimetableSlotRepository`)
+und dem Stundenraster (6.2/4.2, `PeriodScheduleService`) die früheste Start- und späteste
+Endzeit des Tages, zieht einen festen Puffer davor (`BufferBeforeFirstPeriodMinutes = 15`) und
+danach (`BufferAfterLastPeriodMinutes = 10`) ab/dazu. Klick öffnet den bestehenden
+`AddTimeEntryDialog` (6.2.2) mit Kategorie "Unterricht" sowie Start/Ende vorbefüllt — bewusst
+weiterhin ein normaler, vom Nutzer bestätigter Nacherfassungs-Dialog, keine automatische
+Buchung ohne Blick darauf. Neuer `TaskCategory.Teaching`-Wert (`TaskCategoryDisplay.Label`:
+"Unterricht") ans Ende des Enums angehängt, um bestehende serialisierte Werte nicht zu
+verschieben.
+
### 6.3 Auswertung
- [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) —
dritter Tab "Auswertung" (`WorkloadEvaluationViewModel`). Zeitraum wahlweise Monat
@@ -1294,6 +1324,46 @@ Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreit
Unterricht und Klausuren werden jetzt im Detailbereich der Kalenderkachel angezeigt und
verlinkt. Weitere Terminarten können später über das vorhandene `CalendarEventItem` ergänzt
werden, sobald dafür ein eigenes Termine-Modell existiert.
+- [x] **9.9** Kachel "Ungeplante Stunden": erinnert an Stunden ohne Thema, für die noch kein
+ Verlaufsplan existiert. Nutzer-Wunsch: der zeitliche Vorgriff soll sinnvoll begrenzt sein
+ (morgens sollte wenigstens ein Stundenthema bereits bekannt sein) und einzelne Gruppen ohne
+ inhaltlichen Verlaufsplan (Klassenrat, Willkommenskreis) sollen sich ausnehmen lassen.
+ **Umsetzung:** `DashboardViewModel.LoadUnplannedLessons` läuft für jede Gruppe mit
+ `LearningGroup.RequiresLessonPlanning == true` (neues Feld, Default `true` — bestehende
+ Gruppen erhalten es beim LiteDB-Deserialisieren automatisch, keine explizite Migration
+ nötig) über die `TimetableSlot`-Einträge von heute bis morgen
+ (`UnplannedLessonsLookaheadDays = 1`, Nutzer hat sich im Dialog explizit für "Heute +
+ morgen" statt eines längeren Vorgriffs entschieden) und meldet jeden Termin, zu dem entweder
+ keine `Lesson` existiert oder deren `Topic` leer ist. Ferientage/Feiertage werden wie überall
+ sonst übersprungen (`IsFreeDay`, dieselbe Prüfung wie in `TimetableViewModel`/
+ `GenerateLessonSeriesDialogViewModel` — bewusst dupliziert statt in einen gemeinsamen
+ Service extrahiert, konsistent mit dem bestehenden Muster). Klick auf einen Eintrag springt
+ in die Lerngruppe, Tab "Planung" (`OnNavigateToUnplannedLesson`,
+ `NavigateToGroupDetail(id, 6)`), damit das Thema direkt ergänzt werden kann — bewusst
+ anderes Sprungziel als die "Heutige Stunden"-Kachel (9.2, Tab "Mitarbeit"). Deaktivierbar je
+ Gruppe über eine neue Checkbox "Benötigt Unterrichtsplanung" im Gruppen-Stammdaten-Dialog.
+
+**Nachtrag zu 9.9 (Bugfix Doppelstunden-Erkennung):** Nutzer-Bug-Report: eine als Doppelstunde
+geplante 3./4. Stunde (eine `Lesson` mit `LessonNumber = 3` und Thema, die laut Stundenraster
+90 Minuten abdeckt, siehe Nachtrag zu 4.2.2) wurde auf dem Dashboard trotzdem als "4. Stunde noch
+ungeplant" gemeldet, weil `LoadUnplannedLessons` je `TimetableSlot` stur auf eine `Lesson` mit
+exakt derselben `LessonNumber` prüfte — für Periode 4 gibt es bei einer Doppelstunde aber bewusst
+keine eigene `Lesson`. Neue Hilfsmethode `IsCoveredByEarlierDoppelstunde` wendet dieselbe
+Rückwärts-Erkennung wie `LessonDialogViewModel.RecomputeTimeBudget` an: ausgehend von der
+Vorperiode wird so lange rückwärts geprüft, wie der Stundenplan dort ebenfalls einen Slot hat;
+trifft man auf eine `Lesson` mit Thema, gilt die Periode als abgedeckt, trifft man auf eine
+`Lesson` ohne Thema, bricht die Suche ab (diese Periode bleibt eine eigene, ungeplante Stunde).
+Eine eigene, direkt zugeordnete `Lesson` ohne Thema wird also weiterhin gemeldet — nur die
+implizit mitabgedeckte Folgeperiode einer bereits geplanten Doppelstunde nicht mehr.
+
+**Nachtrag zu 9.9 (Bugfix Stundenausfall):** Zweiter Nutzer-Bug-Report: eine laut Stundenplan
+regulär stattfindende, aber per Vertretungs-Dialog als `SubstitutionKind.Cancelled` eingetragene
+Stunde (4.3, "Stundenausfall") wurde trotzdem als ungeplant gemeldet, obwohl sie an diesem Tag
+gar nicht stattfindet. `LoadUnplannedLessons` prüft jetzt zusätzlich `ISubstitutionEntryRepository.
+GetByDate(date)` auf `Cancelled`-Einträge und überspringt betroffene Stundennummern vollständig —
+gleiche Abfrage (Datum + Stundennummer, bewusst ohne Gruppenbezug, siehe `SubstitutionEntry.
+PeriodNumber`-Doku) wie in `TimetableViewModel.BuildToday` für die "Heute"-Ansicht des
+Stundenplans.
---