diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index 4401fdf..eef5d0d 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -56,6 +56,12 @@ public class Lesson public TimeOnly? StartTime { get; set; } public List Phases { get; set; } = []; public string? Homework { get; set; } + /// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert + /// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4). + public bool HomeworkChecked { get; set; } + /// Bewusstes Abschalten der Erinnerung, ohne eine tatsächliche Kontrolle vorzutäuschen (z.B. + /// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird). + public bool HomeworkCheckDismissed { get; set; } public string? Reflection { get; set; } public LessonStatus Status { get; set; } = LessonStatus.Planned; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; diff --git a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs index 6788c61..aacd082 100644 --- a/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/LessonDialogViewModelTests.cs @@ -179,6 +179,59 @@ public sealed class LessonDialogViewModelTests Assert.Single(lessons.GetByUnit(unitId)); } + [Fact] + public void HasHomeworkText_OhneHausaufgabe_IstFalse() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + Assert.False(vm.HasHomeworkText); + + vm.Homework = " "; + Assert.False(vm.HasHomeworkText); + + vm.Homework = "S. 42"; + Assert.True(vm.HasHomeworkText); + } + + [Fact] + public void Save_UebernimmtHomeworkCheckedUndDismissed() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + vm.Topic = "Brechung"; vm.DateText = "01.09.2025"; + vm.Homework = "S. 42"; vm.HomeworkChecked = true; + + vm.SaveCommand.Execute(null); + + Assert.True(vm.Result!.HomeworkChecked); + Assert.False(vm.Result.HomeworkCheckDismissed); + } + + [Fact] + public void HomeworkChecked_UndHomeworkCheckDismissed_SchliessenSichGegenseitigAus() + { + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); + + vm.HomeworkChecked = true; + Assert.False(vm.HomeworkCheckDismissed); + + vm.HomeworkCheckDismissed = true; + Assert.False(vm.HomeworkChecked); + } + + [Fact] + public void Bearbeiten_UebernimmtGespeicherteHomeworkFlags() + { + var editing = new Lesson + { + Topic = "Alt", Date = new DateOnly(2025, 9, 1), Homework = "S. 42", + HomeworkCheckDismissed = true, + }; + + var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), editing); + + Assert.True(vm.HomeworkCheckDismissed); + Assert.False(vm.HomeworkChecked); + } + [Fact] public void HasAlternativePath_AngehaktOeffnetZuweisungUndUebernimmtErgebnis() { diff --git a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs index db7130f..ebe60a2 100644 --- a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs @@ -132,6 +132,88 @@ public sealed class TimetableViewModelTests Assert.True(item.HasExam); } + [Fact] + public void Load_HeutigeStunde_VorherigeUnkontrollierteHausaufgabe_ZeigtBadge() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); + var lessons = new FakeLessons(); + lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(-3), Homework = "S. 42, Aufgabe 3" }); + var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons); + + var item = Assert.Single(vm.TodayItems); + Assert.True(item.HasUnhandledHomework); + } + + [Fact] + public void Load_HeutigeStunde_HausaufgabeKontrolliert_KeinBadge() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); + var lessons = new FakeLessons(); + lessons.Add(new Lesson + { + GroupId = group.Id, Date = today.AddDays(-3), Homework = "S. 42, Aufgabe 3", HomeworkChecked = true, + }); + var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons); + + var item = Assert.Single(vm.TodayItems); + Assert.False(item.HasUnhandledHomework); + } + + [Fact] + public void Load_HeutigeStunde_HausaufgabenKontrolleBewusstAbgeschaltet_KeinBadge() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); + var lessons = new FakeLessons(); + lessons.Add(new Lesson + { + GroupId = group.Id, Date = today.AddDays(-3), Homework = "S. 42, Aufgabe 3", + HomeworkCheckDismissed = true, + }); + var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons); + + var item = Assert.Single(vm.TodayItems); + Assert.False(item.HasUnhandledHomework); + } + + [Fact] + public void Load_HeutigeStunde_VorherigeStundeOhneHausaufgabe_KeinBadge() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "Q1 Chemie" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); + var lessons = new FakeLessons(); + lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(-3) }); + var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons); + + var item = Assert.Single(vm.TodayItems); + Assert.False(item.HasUnhandledHomework); + } + + [Fact] + public void Load_Wochenkachel_VorherigeUnkontrollierteHausaufgabe_ZeigtBadge() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var date = DateInCurrentWeek(DayOfWeek.Wednesday); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Wednesday, PeriodNumber = 1 }); + var lessons = new FakeLessons(); + lessons.Add(new Lesson { GroupId = group.Id, Date = date.AddDays(-2), Homework = "Vokabeln lernen" }); + var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons); + + var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Wednesday && c.PeriodNumber == 1); + Assert.True(cell.HasUnhandledHomework); + } + [Fact] public void OpenGroup_RuftOnNavigateToGroupMitDerGroupIdAuf() { diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 41d722b..7abcdc4 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -635,6 +635,8 @@ public partial class LessonDialogViewModel : ObservableObject [ObservableProperty] private string _topic = ""; [ObservableProperty] private string _startTimeText = ""; [ObservableProperty] private string _homework = ""; + [ObservableProperty] private bool _homeworkChecked; + [ObservableProperty] private bool _homeworkCheckDismissed; [ObservableProperty] private string _reflection = ""; [ObservableProperty] private string _statusName = LessonStatusDisplay.Options[0]; [ObservableProperty] private string _dateTextError = ""; @@ -646,6 +648,9 @@ public partial class LessonDialogViewModel : ObservableObject [ObservableProperty] private bool _hasTimeBudgetInfo; public string[] StatusOptions => LessonStatusDisplay.Options; + /// Nur bei vorhandenem Hausaufgabentext sinnvoll — steuert die Sichtbarkeit von + /// HomeworkChecked/HomeworkCheckDismissed im Dialog (4.5.4). + public bool HasHomeworkText => !string.IsNullOrWhiteSpace(Homework); public string[] MaterialSuggestions { get; } public string[] ShorthandSuggestions { get; } public ObservableCollection Phases { get; } = []; @@ -707,6 +712,8 @@ public partial class LessonDialogViewModel : ObservableObject Topic = editingLesson.Topic; StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? ""; Homework = editingLesson.Homework ?? ""; + HomeworkChecked = editingLesson.HomeworkChecked; + HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed; Reflection = editingLesson.Reflection ?? ""; StatusName = LessonStatusDisplay.ToName(editingLesson.Status); foreach (var p in editingLesson.Phases) AddPhaseInternal(p); @@ -803,6 +810,11 @@ public partial class LessonDialogViewModel : ObservableObject RecomputeTimes(); } partial void OnDateTextChanged(string value) => RecomputeTimes(); + partial void OnHomeworkChanged(string value) => OnPropertyChanged(nameof(HasHomeworkText)); + // "Kontrolliert" und "bewusst nicht kontrollieren" schließen sich gegenseitig aus — beides sind + // "erledigt"-Zustände mit unterschiedlicher Bedeutung, nie gleichzeitig sinnvoll. + partial void OnHomeworkCheckedChanged(bool value) { if (value) HomeworkCheckDismissed = false; } + partial void OnHomeworkCheckDismissedChanged(bool value) { if (value) HomeworkChecked = false; } /// Dauer ist die primäre Eingabe je Phase; die Uhrzeit wird daraus nur zur Anzeige /// abgeleitet — kumulativ ab "Beginn", sofern gesetzt (sonst bleibt sie leer). @@ -907,6 +919,8 @@ public partial class LessonDialogViewModel : ObservableObject Result.StartTime = startTime; Result.Phases = Phases.Select(p => p.ToModel()).ToList(); Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim(); + Result.HomeworkChecked = HomeworkChecked; + Result.HomeworkCheckDismissed = HomeworkCheckDismissed; Result.Reflection = string.IsNullOrWhiteSpace(Reflection) ? null : Reflection.Trim(); Result.Status = LessonStatusDisplay.FromName(StatusName); _lessons.Save(Result); diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs index a93da1f..065463c 100644 --- a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs @@ -197,7 +197,8 @@ public partial class TimetableViewModel : ObservableObject var lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault(); var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today); items.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name, - slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title)); + slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title, + HasUnhandledHomework(slot.GroupId, today))); } foreach (var sub in substitutionsToday.Where(s => s.Kind == SubstitutionKind.Lesson && @@ -331,7 +332,8 @@ public partial class TimetableViewModel : ObservableObject subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "", group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "", colorHex, holidayBadge, hasExam, isLastBeforeExam, - MentionsExperiment(lesson), slot.GroupId, isHoliday)); + MentionsExperiment(lesson), slot.GroupId, isHoliday, + HasUnhandledHomework(slot.GroupId, date))); } AddWeekSupervisionRowIfAny(period, dutiesByPeriod, substitutionsThisWeek, dateByWeekday); } @@ -392,6 +394,21 @@ public partial class TimetableViewModel : ObservableObject p.Activity?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true || p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true; + /// 4.5.4: Hat die letzte vor liegende Lesson dieser Gruppe eine + /// Hausaufgabe, die weder als kontrolliert noch als bewusst übersprungen markiert ist? Schaut + /// bewusst nur auf die unmittelbar vorherige Lesson (nicht auf die gesamte Historie) — sobald + /// eine neuere Lesson stattfindet, ist eine noch ältere offene Hausaufgabe nicht mehr das, worauf + /// sich "letzte Stunde" bezieht. Lookback-Fenster von 120 Tagen deckt auch längere Ferienpausen + /// ab, ohne unbegrenzt weit zurückzuscannen. + private bool HasUnhandledHomework(Guid groupId, DateOnly date) + { + var previousLesson = _lessons.GetByGroupAndRange(groupId, date.AddDays(-120), date.AddDays(-1)) + .OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0) + .FirstOrDefault(); + if (previousLesson is null || string.IsNullOrWhiteSpace(previousLesson.Homework)) return false; + return !previousLesson.HomeworkChecked && !previousLesson.HomeworkCheckDismissed; + } + /// Fällt auf einen gesetzlichen Feiertag oder in Schulferien? private static bool IsFreeDay(DateOnly date, List schoolHolidays, HashSet publicHolidayDates) => publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate); @@ -629,6 +646,7 @@ public class WeekCellItem public bool HasExam { get; private init; } public bool IsLastBeforeExam { get; private init; } public bool HasExperiment { get; private init; } + public bool HasUnhandledHomework { get; private init; } public bool IsHoliday { get; private init; } public string SupervisionLocation { get; private init; } = ""; public bool HasSupervision => SupervisionLocation.Length > 0; @@ -667,13 +685,14 @@ public class WeekCellItem public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel, string groupName, string room, string topic, string colorHex, string holidayBadge, - bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday) => new() + bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday, + bool hasUnhandledHomework = false) => new() { Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic, ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam, IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId, - IsHoliday = isHoliday, + IsHoliday = isHoliday, HasUnhandledHomework = hasUnhandledHomework, }; public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new() @@ -737,12 +756,14 @@ public class TodayLessonItem public bool HasLessonTopic => !string.IsNullOrWhiteSpace(LessonTopic); public bool HasExam => ExamTitle is not null; public bool HasGroupId => GroupId != Guid.Empty; + public bool HasUnhandledHomework { get; private init; } public TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room, - string colorHex, string? lessonTopic, string? examTitle) + string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false) { GroupId = groupId; PeriodNumber = periodNumber; GroupName = groupName; Room = room; ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle; + HasUnhandledHomework = hasUnhandledHomework; } public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new( diff --git a/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml b/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml index 6c29295..f2c3868 100644 --- a/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml @@ -126,6 +126,12 @@ + + + + diff --git a/LehrerApp.Desktop/Views/Planning/TimetableView.axaml b/LehrerApp.Desktop/Views/Planning/TimetableView.axaml index b2cb774..1f49228 100644 --- a/LehrerApp.Desktop/Views/Planning/TimetableView.axaml +++ b/LehrerApp.Desktop/Views/Planning/TimetableView.axaml @@ -96,6 +96,9 @@ TextWrapping="Wrap" IsVisible="{Binding HasLessonTopic}"/> + diff --git a/TODO.md b/TODO.md index 33fb5b6..2ca79c8 100644 --- a/TODO.md +++ b/TODO.md @@ -723,11 +723,29 @@ Ausdrücklich als Idee für später festgehalten, **nicht** jetzt umsetzen: Rückfrage an die Lehrkraft?). Muss überdacht werden, bevor das gebaut wird. - [ ] **4.5.3** Aus diesem Lesson-Viewer heraus weiter verzweigen können: in die Zeugnisnote/ Bewertung der Gruppe, und in die Schnelldialoge für Mitarbeit sowie Anwesenheit/Hausaufgaben. -- [ ] **4.5.4** Badges am Stundenplan/Lesson-Viewer, die anzeigen, ob eine Stunde bereits - "kontrolliert" ist — u.a. ob in der vorherigen Stunde Hausaufgaben erteilt wurden und ob - diese noch nicht kontrolliert sind. Muss sich pro Fall abschalten lassen: manchmal wird eine - Hausaufgabe bewusst nicht kontrolliert, ohne dass bis Schuljahresende ständig daran erinnert - werden soll. +- [x] **4.5.4** Badges am Stundenplan, die anzeigen, ob eine Hausaufgabe aus der letzten Stunde + noch nicht kontrolliert ist. + + **Umsetzung:** `Lesson` bekam zwei neue Felder, `HomeworkChecked`/`HomeworkCheckDismissed` + (LiteDB füllt sie für bestehende Datensätze automatisch mit `false`, keine Migration nötig). + `TimetableViewModel.HasUnhandledHomework(groupId, date)` sucht die letzte vor `date` + liegende Lesson dieser Gruppe (`ILessonRepository.GetByGroupAndRange`, 120-Tage-Fenster + wegen möglicher Ferienlücken) und meldet `true`, wenn diese eine nicht-leere `Homework` + hat und weder `HomeworkChecked` noch `HomeworkCheckDismissed` gesetzt ist. Bewusst nur die + unmittelbar vorherige Lesson, nicht die gesamte Historie — sobald eine neuere Stunde + stattfindet, ist eine noch ältere offene Hausaufgabe nicht mehr das, worauf sich "letzte + Stunde" bezieht. 📓-Badge dafür sowohl in der Tagesliste als auch im Wochenraster des + "Heute"-Tabs (`TodayLessonItem.HasUnhandledHomework`/`WeekCellItem.HasUnhandledHomework`). + + **Abweichung vom ursprünglichen Vorschlag** ("Klick-Toggle direkt am Badge"): jede + Wochenraster-Zelle ist bereits ein klickbarer `Button` (Navigation zur Lerngruppe) — + ein zweites klickbares Element im selben Badge hätte in Avalonia einen verschachtelten + Button-in-Button ergeben, mit unsicherem Klick-Routing. Stattdessen leben die beiden + Checkboxen "Kontrolliert"/"Nicht kontrollieren" (gegenseitig exklusiv, + `LessonDialogViewModel.HomeworkChecked`/`HomeworkCheckDismissed`) direkt im Verlaufsplan- + Editor neben dem Hausaufgabenfeld — genau dort, wo `Homework` ohnehin schon gepflegt wird. + Klick auf die Stunde im Stundenplan navigiert wie gehabt zur Lerngruppe/Planung, von dort + ist die betroffene (vorherige) Stunde ein Klick entfernt. - [ ] **4.5.5** Stunden aus dieser Ansicht heraus verschieben können, wenn kurzfristig etwas dazwischenkommt. - [ ] **4.5.6** Bei Doppelstunden (90-Minuten-Planung laut Verlaufsplan, 4.2.2) den Inhalt