feat: Komfort-Features für Unterrichtsplanung, Zeiterfassung und Dashboard

- Neue-Stunde-Dialog: Datum wird beim Anlegen anhand des Stundenplans
  und der letzten Stunde der Einheit vorbelegt statt auf "heute"
  (behebt eine falsch erkannte Doppelstunde, wenn "heute" nicht auf
  den passenden Wochentag fiel).
- Zeiterfassung: Button "Unterrichtszeit heute übernehmen" schlägt
  Start/Ende aus dem heutigen Stundenplan inkl. Puffer davor/danach vor.
- Dashboard: neue Kachel "Ungeplante Stunden" erinnert an Stunden ohne
  Thema für heute/morgen, mit Opt-out je Gruppe ("Benötigt
  Unterrichtsplanung"), Doppelstunden-Erkennung (keine doppelte Meldung
  für die zweite Periode) und Berücksichtigung von Stundenausfall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 11:30:50 +02:00
co-authored by Claude Sonnet 5
parent a36b1d1d40
commit fd509fc921
17 changed files with 585 additions and 23 deletions
+7
View File
@@ -13,6 +13,13 @@ public class LearningGroup
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public bool IsOwnClass { get; set; } public bool IsOwnClass { get; set; }
public bool IsDifferentiated { get; set; } public bool IsDifferentiated { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool RequiresLessonPlanning { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
+1 -1
View File
@@ -103,6 +103,6 @@ public class TimeEntry
public string? Description { get; set; } public string? Description { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; 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 WorkTaskStatus { Open, InProgress, Done }
public enum TaskRecurrence { None, Weekly, Monthly } public enum TaskRecurrence { None, Weekly, Monthly }
@@ -15,7 +15,7 @@ public sealed class DashboardSettingsService
public static readonly string[] DefaultCardOrder = public static readonly string[] DefaultCardOrder =
[ [
"today", "tasks", "calendar", "excuses", "upcoming", "today", "tasks", "calendar", "excuses", "upcoming",
"corrections", "alerts", "attendance", "support", "groups", "corrections", "unplanned", "alerts", "attendance", "support", "groups",
]; ];
private readonly string _configPath; private readonly string _configPath;
@@ -26,14 +26,23 @@ public sealed class DashboardViewModelTests
return new DashboardSettingsService(tempPath); 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, private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson,
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null, FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null,
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null, FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null, FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = 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); lessons.Add(lesson);
return new DashboardViewModel( return new DashboardViewModel(
new FakeGroups([group]), new FakeSubjects([]), lessons, new FakeGroups([group]), new FakeSubjects([]), lessons,
@@ -42,7 +51,9 @@ public sealed class DashboardViewModelTests
tasks ?? new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(), tasks ?? new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(),
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), 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] [Fact]
@@ -227,4 +238,119 @@ public sealed class DashboardViewModelTests
Assert.False(saved.Single(c => c.Key == "today").IsVisible); Assert.False(saved.Single(c => c.Key == "today").IsVisible);
Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1); 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);
}
} }
+9
View File
@@ -1,5 +1,6 @@
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Sync; using LehrerApp.Sync;
@@ -47,6 +48,14 @@ public static class TestSupport
Directory.CreateDirectory(tempPath); Directory.CreateDirectory(tempPath);
return new EventQueue(Path.Combine(tempPath, "queue.db")); return new EventQueue(Path.Combine(tempPath, "queue.db"));
} }
/// Analog zu <see cref="BuildAiSettingsService"/>, 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<Student> all) : IStudentRepository public class FakeStudents(List<Student> all) : IStudentRepository
@@ -388,4 +388,53 @@ public sealed class LessonDialogViewModelTests
Assert.False(vm.HasTimeBudgetInfo); 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);
}
} }
@@ -1,3 +1,4 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Workload; using LehrerApp.Desktop.ViewModels.Workload;
@@ -288,14 +289,16 @@ public sealed class AddEditWorkTaskDialogViewModelTests
public sealed class TimeTrackingViewModelTests public sealed class TimeTrackingViewModelTests
{ {
private static TimeTrackingViewModel BuildVm(ITimeEntryRepository entries, IWorkTaskRepository tasks,
FakeTimetableSlots? slots = null) =>
new(entries, tasks, slots ?? new FakeTimetableSlots(), TestSupport.BuildPeriodScheduleService());
[Fact] [Fact]
public void StartStopTimer_SpeichertZeiteintragMitMindestensEinerMinute() public void StartStopTimer_SpeichertZeiteintragMitMindestensEinerMinute()
{ {
var entries = new FakeTimeEntries(); var entries = new FakeTimeEntries();
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks()) var vm = BuildVm(entries, new FakeWorkTasks());
{ vm.SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction);
SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction),
};
vm.StartTimerCommand.Execute(null); vm.StartTimerCommand.Execute(null);
Assert.True(vm.IsTimerRunning); Assert.True(vm.IsTimerRunning);
@@ -314,7 +317,7 @@ public sealed class TimeTrackingViewModelTests
var task = new WorkTask { Title = "T", Category = TaskCategory.Meeting }; var task = new WorkTask { Title = "T", Category = TaskCategory.Meeting };
var tasks = new FakeWorkTasks(); var tasks = new FakeWorkTasks();
tasks.Add(task); tasks.Add(task);
var vm = new TimeTrackingViewModel(new FakeTimeEntries(), tasks); var vm = BuildVm(new FakeTimeEntries(), tasks);
vm.SelectedTimerTask = task; 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), DurationMinutes = 30 });
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today).AddDays(-30), DurationMinutes = 45 }); 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.Single(vm.WeekEntries);
Assert.Equal(30, vm.WeekEntries[0].Model.DurationMinutes); 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 = "Korrektur", Date = today, DurationMinutes = 20 });
entries.Add(new TimeEntry { Category = "Verwaltung", Date = today, DurationMinutes = 10 }); 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"); var korrektur = vm.CategorySummaries.Single(s => s.Category == "Korrektur");
Assert.Equal("50 min", korrektur.MinutesDisplay); Assert.Equal("50 min", korrektur.MinutesDisplay);
@@ -356,7 +359,7 @@ public sealed class TimeTrackingViewModelTests
{ {
var entries = new FakeTimeEntries(); var entries = new FakeTimeEntries();
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 }); 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]); vm.DeleteEntryCommand.Execute(vm.WeekEntries[0]);
@@ -367,7 +370,7 @@ public sealed class TimeTrackingViewModelTests
public async Task AddEntry_RuftOnAddEntryAufUndSpeichertErgebnis() public async Task AddEntry_RuftOnAddEntryAufUndSpeichertErgebnis()
{ {
var entries = new FakeTimeEntries(); var entries = new FakeTimeEntries();
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks()); var vm = BuildVm(entries, new FakeWorkTasks());
vm.OnAddEntry = () => Task.FromResult<TimeEntry?>( vm.OnAddEntry = () => Task.FromResult<TimeEntry?>(
new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 15 }); new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 15 });
@@ -375,6 +378,64 @@ public sealed class TimeTrackingViewModelTests
Assert.Single(vm.WeekEntries); 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<TimeEntry?>(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<TimeEntry?>(null); };
await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
Assert.False(called);
}
} }
public sealed class AddTimeEntryDialogViewModelTests public sealed class AddTimeEntryDialogViewModelTests
+1
View File
@@ -101,6 +101,7 @@ public class App : Application
dash.OnNavigateToStudent = id => main.NavigateToStudent(id); dash.OnNavigateToStudent = id => main.NavigateToStudent(id);
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit" dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren" dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
// StudentList → StudentDetail + Anlegen // StudentList → StudentDetail + Anlegen
var sl = Services.GetRequiredService<StudentListViewModel>(); var sl = Services.GetRequiredService<StudentListViewModel>();
@@ -30,10 +30,18 @@ public partial class DashboardViewModel : ObservableObject
private readonly AttendanceBalanceService _attendanceBalance; private readonly AttendanceBalanceService _attendanceBalance;
private readonly SchoolYearService _sy; private readonly SchoolYearService _sy;
private readonly DashboardSettingsService _dashboardSettings; 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 OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14; private const int SupportPlanDueWithinDays = 14;
private const int UpcomingWithinDays = 30; 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 _greeting = "";
[ObservableProperty] private string _currentDate = ""; [ObservableProperty] private string _currentDate = "";
@@ -53,6 +61,7 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = []; public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = []; public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = []; public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
public ObservableCollection<UnplannedLessonItem> UnplannedLessons { get; } = [];
public ObservableCollection<DashboardAlertItem> Alerts { get; } = []; public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = []; public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = []; public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
@@ -66,6 +75,9 @@ public partial class DashboardViewModel : ObservableObject
// Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt. // Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt.
public Action<Guid>? OnNavigateToLesson { get; set; } public Action<Guid>? OnNavigateToLesson { get; set; }
public Action<Guid>? OnNavigateToExam { get; set; } public Action<Guid>? 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<Guid>? OnNavigateToUnplannedLesson { get; set; }
public DashboardCardOption TodayCard => Card("today"); public DashboardCardOption TodayCard => Card("today");
public DashboardCardOption TasksCard => Card("tasks"); public DashboardCardOption TasksCard => Card("tasks");
@@ -73,6 +85,7 @@ public partial class DashboardViewModel : ObservableObject
public DashboardCardOption ExcusesCard => Card("excuses"); public DashboardCardOption ExcusesCard => Card("excuses");
public DashboardCardOption UpcomingCard => Card("upcoming"); public DashboardCardOption UpcomingCard => Card("upcoming");
public DashboardCardOption CorrectionsCard => Card("corrections"); public DashboardCardOption CorrectionsCard => Card("corrections");
public DashboardCardOption UnplannedCard => Card("unplanned");
public DashboardCardOption AlertsCard => Card("alerts"); public DashboardCardOption AlertsCard => Card("alerts");
public DashboardCardOption AttendanceCard => Card("attendance"); public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption SupportCard => Card("support"); public DashboardCardOption SupportCard => Card("support");
@@ -85,7 +98,9 @@ public partial class DashboardViewModel : ObservableObject
IParticipationRepository participationEntries, IStudentRepository students, IParticipationRepository participationEntries, IStudentRepository students,
IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots, IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots,
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy, 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; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships; _examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -93,6 +108,8 @@ public partial class DashboardViewModel : ObservableObject
_students = students; _documentation = documentation; _students = students; _documentation = documentation;
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings; _attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_substitutions = substitutions;
LoadDashboardCards(); LoadDashboardCards();
Load(); Load();
} }
@@ -157,6 +174,7 @@ public partial class DashboardViewModel : ObservableObject
LoadSupportPlanReviews(today); LoadSupportPlanReviews(today);
LoadUpcomingDates(groups, today); LoadUpcomingDates(groups, today);
LoadOpenCorrections(groups, today); LoadOpenCorrections(groups, today);
LoadUnplannedLessons(groups, today);
LoadAlerts(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<Guid, LearningGroup> 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<UnplannedLessonItem>();
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);
}
/// <summary>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.</summary>
private static bool IsCoveredByEarlierDoppelstunde(
List<Lesson> dayLessons, HashSet<int> 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;
}
/// <summary>Dupliziert absichtlich TimetableViewModel.IsFreeDay/die gleichnamige Prüfung in
/// PlanningViewModels.GenerateLessonSeriesDialogViewModel.Save() — zwei Zeilen, kein
/// Service-Aufwand für eine dritte Fundstelle.</summary>
private static bool IsFreeDay(DateOnly date, List<SchoolHoliday> schoolHolidays, HashSet<DateOnly> publicHolidayDates) =>
publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate);
// ── Auffälligkeiten (9.5) ──────────────────────────────────────────────── // ── Auffälligkeiten (9.5) ────────────────────────────────────────────────
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today) private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
@@ -330,7 +429,7 @@ public partial class DashboardViewModel : ObservableObject
{ {
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender", "today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine", "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", "attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", _ => key, "groups" => "Meine Lerngruppen", _ => key,
}; };
@@ -524,6 +623,8 @@ public partial class DashboardViewModel : ObservableObject
} }
[RelayCommand] private void OpenCorrection(CorrectionProgressItem? item) [RelayCommand] private void OpenCorrection(CorrectionProgressItem? item)
{ if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); } { 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) [RelayCommand] private void OpenAlert(DashboardAlertItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand] private void Refresh() => Load(); [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 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 enum AlertSeverity { Medium, High }
public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName, public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName,
@@ -810,6 +810,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
[ObservableProperty] private int? _hoursPerWeek; [ObservableProperty] private int? _hoursPerWeek;
[ObservableProperty] private bool _isOwnClass; [ObservableProperty] private bool _isOwnClass;
[ObservableProperty] private bool _isDifferentiated; [ObservableProperty] private bool _isDifferentiated;
[ObservableProperty] private bool _requiresLessonPlanning = true;
[ObservableProperty] private string _nameError = ""; [ObservableProperty] private string _nameError = "";
[ObservableProperty] private string _gradeLevelError = ""; [ObservableProperty] private string _gradeLevelError = "";
@@ -848,6 +849,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
HoursPerWeek = group.HoursPerWeek; HoursPerWeek = group.HoursPerWeek;
IsOwnClass = group.IsOwnClass; IsOwnClass = group.IsOwnClass;
IsDifferentiated = group.IsDifferentiated; IsDifferentiated = group.IsDifferentiated;
RequiresLessonPlanning = group.RequiresLessonPlanning;
OnPropertyChanged(nameof(DialogTitle)); OnPropertyChanged(nameof(DialogTitle));
OnPropertyChanged(nameof(SaveButtonText)); OnPropertyChanged(nameof(SaveButtonText));
} }
@@ -893,6 +895,7 @@ public partial class AddGroupDialogViewModel : ObservableObject
Result.HoursPerWeek = HoursPerWeek; Result.HoursPerWeek = HoursPerWeek;
Result.IsOwnClass = IsOwnClass; Result.IsOwnClass = IsOwnClass;
Result.IsDifferentiated = IsDifferentiated; Result.IsDifferentiated = IsDifferentiated;
Result.RequiresLessonPlanning = RequiresLessonPlanning;
_groups.Save(Result); _groups.Save(Result);
} }
} }
@@ -711,9 +711,35 @@ public partial class LessonDialogViewModel : ObservableObject
StatusName = LessonStatusDisplay.ToName(editingLesson.Status); StatusName = LessonStatusDisplay.ToName(editingLesson.Status);
foreach (var p in editingLesson.Phases) AddPhaseInternal(p); foreach (var p in editingLesson.Phases) AddPhaseInternal(p);
} }
else
{
DateText = SuggestNextLessonDate().ToString("dd.MM.yyyy");
}
RecomputeTimes(); RecomputeTimes();
} }
/// <summary>
/// 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 <see cref="RecomputeTimeBudget"/> 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.
/// </summary>
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] [RelayCommand]
private void AddPhase() private void AddPhase()
{ {
@@ -46,6 +46,7 @@ public static class TaskCategoryDisplay
TaskCategory.Admin => "Verwaltung", TaskCategory.Admin => "Verwaltung",
TaskCategory.Meeting => "Besprechung", TaskCategory.Meeting => "Besprechung",
TaskCategory.Other => "Sonstiges", TaskCategory.Other => "Sonstiges",
TaskCategory.Teaching => "Unterricht",
_ => c.ToString(), _ => c.ToString(),
}; };
@@ -333,6 +334,15 @@ public partial class TimeTrackingViewModel : ObservableObject
{ {
private readonly ITimeEntryRepository _entries; private readonly ITimeEntryRepository _entries;
private readonly IWorkTaskRepository _tasks; 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"; public const string NoTaskOption = "Keine Aufgabe";
@@ -351,12 +361,24 @@ public partial class TimeTrackingViewModel : ObservableObject
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = []; public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche"; public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche";
public Func<Task<TimeEntry?>>? 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<Task<TimeEntry?>>? 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<TimeOnly, TimeOnly, Task<TimeEntry?>>? OnSuggestTeachingTime { get; set; }
public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks,
ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule)
{ {
_entries = entries; _entries = entries;
_tasks = tasks; _tasks = tasks;
_timetableSlots = timetableSlots;
_periodSchedule = periodSchedule;
Load(); Load();
} }
@@ -442,6 +464,38 @@ public partial class TimeTrackingViewModel : ObservableObject
Refresh(); 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();
}
/// <summary>
/// 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.
/// </summary>
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] [RelayCommand]
private void DeleteEntry(TimeEntryListItem? item) private void DeleteEntry(TimeEntryListItem? item)
{ {
@@ -54,7 +54,7 @@
</ItemsControl> </ItemsControl>
</Border> </Border>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto"> <Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
<!-- Heutige Stunden --> <!-- Heutige Stunden -->
<Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}" <Border Grid.Column="{Binding TodayCard.Column}" Grid.Row="{Binding TodayCard.Row}"
@@ -415,6 +415,36 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Ungeplante Stunden -->
<Border Grid.Column="{Binding UnplannedCard.Column}" Grid.Row="{Binding UnplannedCard.Row}"
IsVisible="{Binding UnplannedCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="UNGEPLANTE STUNDEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding UnplannedLessons}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UnplannedLessonItem">
<Button Background="Transparent" BorderThickness="0" Padding="0" Margin="0,5"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenUnplannedLessonCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Display}" FontSize="13"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="12"
VerticalAlignment="Center" Opacity="0.6"/>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine ungeplanten Stunden." Classes="emptyhint"
IsVisible="{Binding !UnplannedLessons.Count}"/>
</StackPanel>
</Border>
<!-- Auffälligkeiten (9.5) --> <!-- Auffälligkeiten (9.5) -->
<Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}" <Border Grid.Column="{Binding AlertsCard.Column}" Grid.Row="{Binding AlertsCard.Row}"
IsVisible="{Binding AlertsCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top" IsVisible="{Binding AlertsCard.IsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
@@ -74,6 +74,9 @@
<CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}" <CheckBox Content="Niveaudifferenzierung (E/G/Förder)" IsChecked="{Binding IsDifferentiated}"
ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/> ToolTip.Tip="Blendet im Schüler-Tab eine Niveau-Zuordnung je Schüler ein und erlaubt in Klausuren die Zuordnung zu einem Niveau."/>
<CheckBox Content="Benötigt Unterrichtsplanung" IsChecked="{Binding RequiresLessonPlanning}"
ToolTip.Tip="Deaktivieren für Gruppen ohne inhaltlichen Verlaufsplan (z.B. Klassenrat, Willkommenskreis) - blendet für diese Gruppe die Dashboard-Erinnerung &quot;Ungeplante Stunden&quot; aus."/>
</StackPanel> </StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0"> <Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
@@ -66,9 +66,13 @@
</Border> </Border>
<!-- Einträge / Nacherfassung (6.2.2) --> <!-- Einträge / Nacherfassung (6.2.2) -->
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Text="Einträge" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/> <TextBlock Grid.Column="0" Text="Einträge" FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content=" Nacherfassen" Command="{Binding AddEntryCommand}"/> <Button Grid.Column="1" Content="🕓 Unterrichtszeit heute übernehmen" Margin="0,0,8,0"
Command="{Binding SuggestTeachingTimeCommand}"
IsVisible="{Binding HasTeachingTimeSuggestionToday}"
ToolTip.Tip="Schlägt Beginn/Ende anhand des heutigen Stundenplans vor (mit etwas Puffer davor/danach) - im Dialog vor dem Speichern noch anpassbar."/>
<Button Grid.Column="2" Content=" Nacherfassen" Command="{Binding AddEntryCommand}"/>
</Grid> </Grid>
<ItemsControl ItemsSource="{Binding WeekEntries}"> <ItemsControl ItemsSource="{Binding WeekEntries}">
@@ -14,10 +14,15 @@ public partial class TimeTrackingView : UserControl
{ {
base.OnDataContextChanged(e); base.OnDataContextChanged(e);
if (DataContext is TimeTrackingViewModel vm) 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<TimeEntry?> ShowAddEntryDialog() private async Task<TimeEntry?> ShowAddEntryDialog(string? prefillCategory = null,
TimeOnly? prefillStart = null, TimeOnly? prefillEnd = null)
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null; if (owner is null) return null;
@@ -25,6 +30,9 @@ public partial class TimeTrackingView : UserControl
var tasks = App.Services.GetRequiredService<IWorkTaskRepository>() var tasks = App.Services.GetRequiredService<IWorkTaskRepository>()
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList(); .GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
var vm = new AddTimeEntryDialogViewModel(tasks); 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 }; var dialog = new AddTimeEntryDialog { DataContext = vm };
await dialog.ShowDialog<bool>(owner); await dialog.ShowDialog<bool>(owner);
return vm.Result; return vm.Result;
+70
View File
@@ -460,6 +460,20 @@ Redesign:
Viewer unabhängig ab `Lesson.StartTime`, nicht ab einer gemeinsamen Verzweigungsstelle im Viewer unabhängig ab `Lesson.StartTime`, nicht ab einer gemeinsamen Verzweigungsstelle im
Hauptweg — deutlich einfacher und für den schnellen Überblick ausreichend. 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 ### 4.3 Stundenplan
- [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository — - [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository —
[Planning.cs](LehrerApp.Core/Models/Planning.cs), [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 Zeiteintrag existiert; dafür braucht `WorkTaskListViewModel` jetzt zusätzlich
`ITimeEntryRepository`. `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 ### 6.3 Auswertung
- [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) — - [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) —
dritter Tab "Auswertung" (`WorkloadEvaluationViewModel`). Zeitraum wahlweise Monat 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 Unterricht und Klausuren werden jetzt im Detailbereich der Kalenderkachel angezeigt und
verlinkt. Weitere Terminarten können später über das vorhandene `CalendarEventItem` ergänzt verlinkt. Weitere Terminarten können später über das vorhandene `CalendarEventItem` ergänzt
werden, sobald dafür ein eigenes Termine-Modell existiert. 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.
--- ---