diff --git a/LehrerApp.Desktop.Tests/HomeworkOverdueServiceTests.cs b/LehrerApp.Desktop.Tests/HomeworkOverdueServiceTests.cs new file mode 100644 index 0000000..fd66cea --- /dev/null +++ b/LehrerApp.Desktop.Tests/HomeworkOverdueServiceTests.cs @@ -0,0 +1,40 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class HomeworkOverdueServiceTests +{ + [Fact] + public void Advance_Nach14Kalendertagen_NurOffeneNachreichungen() + { + var today = new DateOnly(2026, 9, 25); + var group = new LearningGroup { IsActive = true }; + var archived = new LearningGroup { IsActive = false }; + var dueSession = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-14) }; + var recentSession = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-13) }; + var archivedSession = new ParticipationSession { GroupId = archived.Id, Date = today.AddDays(-30) }; + var open = new ParticipationEntry { SessionId = dueSession.Id, Homework = HomeworkStatus.MissingOpen }; + var legacy = new ParticipationEntry { SessionId = dueSession.Id, HomeworkMissing = true }; + var submitted = new ParticipationEntry { SessionId = dueSession.Id, Homework = HomeworkStatus.SubmittedLate }; + var partial = new ParticipationEntry { SessionId = dueSession.Id, Homework = HomeworkStatus.PartiallyCompleted }; + var recent = new ParticipationEntry { SessionId = recentSession.Id, Homework = HomeworkStatus.MissingOpen }; + var oldArchived = new ParticipationEntry { SessionId = archivedSession.Id, Homework = HomeworkStatus.MissingOpen }; + var entries = new FakeEntries(); + foreach (var entry in new[] { open, legacy, submitted, partial, recent, oldArchived }) entries.Add(entry); + var service = new HomeworkOverdueService(new FakeGroups([group, archived]), + new FakeSessions([dueSession, recentSession, archivedSession]), entries); + + Assert.Equal(2, service.Advance(today)); + Assert.Equal(HomeworkStatus.MissingOverdue, open.Homework); + Assert.Equal(HomeworkStatus.MissingOverdue, legacy.Homework); + Assert.True(open.HomeworkMissing); + Assert.True(legacy.HomeworkMissing); + Assert.Equal(HomeworkStatus.SubmittedLate, submitted.Homework); + Assert.Equal(HomeworkStatus.PartiallyCompleted, partial.Homework); + Assert.Equal(HomeworkStatus.MissingOpen, recent.Homework); + Assert.Equal(HomeworkStatus.MissingOpen, oldArchived.Homework); + Assert.Equal(0, service.Advance(today)); + } +} diff --git a/LehrerApp.Desktop.Tests/TeachingModeOutstandingHomeworkViewModelTests.cs b/LehrerApp.Desktop.Tests/TeachingModeOutstandingHomeworkViewModelTests.cs new file mode 100644 index 0000000..b495091 --- /dev/null +++ b/LehrerApp.Desktop.Tests/TeachingModeOutstandingHomeworkViewModelTests.cs @@ -0,0 +1,70 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class TeachingModeOutstandingHomeworkViewModelTests +{ + [Fact] + public void Refresh_ZeigtJungeOffeneNachreichungenUndFiltertAktuellAbwesende() + { + var date = new DateOnly(2026, 9, 25); + var group = new LearningGroup { Name = "10c Chemie" }; + var amelia = new Student { FirstName = "Amelia", LastName = "Gerste" }; + var ben = new Student { FirstName = "Ben", LastName = "Muster" }; + var carl = new Student { FirstName = "Carl", LastName = "Beispiel" }; + var oldMember = new Student { FirstName = "Dana", LastName = "Alt" }; + var previous = new ParticipationSession { GroupId = group.Id, Date = date.AddDays(-5) }; + var older = new ParticipationSession { GroupId = group.Id, Date = date.AddDays(-130) }; + var current = new ParticipationSession { GroupId = group.Id, Date = date }; + var memberships = new FakeMemberships([ + new GroupMembership { StudentId = amelia.Id, GroupId = group.Id }, + new GroupMembership { StudentId = ben.Id, GroupId = group.Id }, + new GroupMembership { StudentId = carl.Id, GroupId = group.Id }, + new GroupMembership { StudentId = oldMember.Id, GroupId = group.Id, + Period = MembershipPeriod.Custom, JoinedAt = date.AddDays(-30), LeftAt = date.AddDays(-1) }, + ]); + var entries = new FakeEntries(); + entries.Add(new ParticipationEntry { SessionId = previous.Id, StudentId = amelia.Id, + Homework = HomeworkStatus.MissingOverdue }); + var benEntry = new ParticipationEntry { SessionId = previous.Id, StudentId = ben.Id, + Homework = HomeworkStatus.MissingOpen }; + entries.Add(benEntry); + entries.Add(new ParticipationEntry { SessionId = previous.Id, StudentId = carl.Id, + Homework = HomeworkStatus.SubmittedLate }); + entries.Add(new ParticipationEntry { SessionId = previous.Id, StudentId = oldMember.Id, + Homework = HomeworkStatus.MissingOverdue }); + entries.Add(new ParticipationEntry { SessionId = older.Id, StudentId = ben.Id, + Homework = HomeworkStatus.MissingOverdue }); + entries.Add(new ParticipationEntry { SessionId = current.Id, StudentId = ben.Id, + Attendance = AttendanceStatus.Present }); + entries.Add(new ParticipationEntry { SessionId = current.Id, StudentId = amelia.Id, + Attendance = AttendanceStatus.ExcusePending }); + + var vm = new TeachingModeOutstandingHomeworkViewModel(group.Id, date, + new FakeStudents([amelia, ben, carl, oldMember]), memberships, + new FakeSessions([previous, older, current]), entries); + vm.SetCurrentSessionId(current.Id); + vm.IsExpanded = true; + + Assert.Equal(ben.FullName, Assert.Single(vm.Rows).StudentName); + Assert.Equal("Offene Nachreichungen (1)", vm.Header); + vm.IncludeAbsentStudents = true; + Assert.Equal(2, vm.Rows.Count); + Assert.Contains(vm.Rows, r => r.StudentName == amelia.FullName && + r.Status == HomeworkStatus.MissingOverdue); + + vm.IncludeAbsentStudents = false; + var currentAmelia = entries.GetBySessionAndStudent(current.Id, amelia.Id)!; + currentAmelia.Attendance = AttendanceStatus.Present; + entries.Save(currentAmelia); + vm.Refresh(); + Assert.Equal(2, vm.Rows.Count); + + benEntry.Homework = HomeworkStatus.SubmittedLate; + entries.Save(benEntry); + vm.Refresh(); + Assert.Equal(amelia.FullName, Assert.Single(vm.Rows).StudentName); + } +} diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index 4e3b2a0..3dbf0d2 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -28,6 +28,7 @@ public class App : Application private static bool _exitHandlerAttached; private static Task _initialPolls = Task.CompletedTask; private static readonly CancellationTokenSource StartupCancellation = new(); + private static DispatcherTimer? _homeworkOverdueTimer; public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -100,6 +101,7 @@ public class App : Application splash?.SetProgress(60, "Papierkorb aufräumen …"); await Task.Run(() => Services.GetRequiredService() .PurgeOlderThan(DateTime.UtcNow.AddDays(-30))); + await AdvanceHomeworkAsync(); if (!_exitHandlerAttached) { @@ -126,6 +128,7 @@ public class App : Application main.EnableFinalSync(syncEngine); desktop.MainWindow = main; main.Show(); + StartHomeworkOverdueTimer(); splash?.SetProgress(100, "Bereit"); windowToClose?.Close(); AppBootstrapper.Logger.Info($"Start: Hauptfenster nach {timer.ElapsedMilliseconds} ms geöffnet."); @@ -152,6 +155,30 @@ public class App : Application }); } + private static async Task AdvanceHomeworkAsync() + { + try + { + var changed = await Task.Run(() => Services.GetRequiredService() + .Advance(DateOnly.FromDateTime(DateTime.Today))); + if (changed > 0) AppBootstrapper.Logger.Info($"{changed} offene Hausaufgaben als nicht nachgereicht markiert."); + } + catch (Exception ex) { AppBootstrapper.Logger.Error("Hausaufgaben-Fristprüfung fehlgeschlagen.", ex); } + } + + private static void StartHomeworkOverdueTimer() + { + var timer = new DispatcherTimer { Interval = TimeSpan.FromHours(1) }; + timer.Tick += async (_, _) => + { + timer.Stop(); + await AdvanceHomeworkAsync(); + if (_serviceProvider is not null) timer.Start(); + }; + _homeworkOverdueTimer = timer; + timer.Start(); + } + /// Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den /// gesamten sichtbaren Baum automatisch neu, sobald sich /// ändert. @@ -165,6 +192,7 @@ public class App : Application private static void DisposeServices() { + _homeworkOverdueTimer?.Stop(); var serviceProvider = Interlocked.Exchange(ref _serviceProvider, null); if (serviceProvider is null) return; diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 1f7cbd9..af5a385 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -197,6 +197,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(_ => new SchoolCalendarSettingsService(appData)); diff --git a/LehrerApp.Desktop/Services/HomeworkOverdueService.cs b/LehrerApp.Desktop/Services/HomeworkOverdueService.cs new file mode 100644 index 0000000..75f07c5 --- /dev/null +++ b/LehrerApp.Desktop/Services/HomeworkOverdueService.cs @@ -0,0 +1,33 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Desktop.Services; + +/// Schließt nicht nachgereichte Hausaufgaben 14 Kalendertage nach der Unterrichtsstunde. +/// Nur noch offene Nachreichungen werden geändert; andere Stati und archivierte Gruppen bleiben +/// unangetastet. +public sealed class HomeworkOverdueService( + IGroupRepository groups, IParticipationSessionRepository sessions, + IParticipationRepository participation) +{ + public const int GracePeriodDays = 14; + + public int Advance(DateOnly today) + { + var cutoff = today.AddDays(-GracePeriodDays); + var activeGroupIds = groups.GetAll().Where(g => g.IsActive).Select(g => g.Id).ToHashSet(); + var changed = 0; + foreach (var session in sessions.GetAll() + .Where(s => s.Date <= cutoff && activeGroupIds.Contains(s.GroupId))) + foreach (var entry in participation.GetBySession(session.Id)) + { + if (entry.Homework != HomeworkStatus.MissingOpen && + !(entry.Homework is null && entry.HomeworkMissing)) continue; + entry.Homework = HomeworkStatus.MissingOverdue; + entry.HomeworkMissing = true; + participation.Save(entry); + changed++; + } + return changed; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/TeachingModeOutstandingHomeworkViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/TeachingModeOutstandingHomeworkViewModel.cs new file mode 100644 index 0000000..c5573be --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/TeachingModeOutstandingHomeworkViewModel.cs @@ -0,0 +1,77 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +public sealed record OutstandingHomeworkRow(string StudentName, DateOnly Date, HomeworkStatus Status) +{ + public string DateLabel => Date.ToString("dd.MM.yyyy"); + public string StatusLabel => HomeworkDisplay.Label(Status); +} + +/// Jüngste noch fehlende Hausaufgaben der aktuellen Lerngruppe. Liest bei jedem Öffnen +/// und auf Wunsch erneut, damit die Anwesenheit der laufenden Stunde berücksichtigt wird. +public partial class TeachingModeOutstandingHomeworkViewModel( + Guid groupId, DateOnly lessonDate, + IStudentRepository students, IGroupMembershipRepository memberships, + IParticipationSessionRepository sessions, IParticipationRepository participation) : ObservableObject +{ + private const int LookbackDays = 120; + private const int MaxRows = 20; + private Guid? _currentSessionId; + + public ObservableCollection Rows { get; } = []; + public string Header => $"Offene Nachreichungen ({Rows.Count})"; + public bool HasRows => Rows.Count > 0; + + [ObservableProperty] private bool _isExpanded; + [ObservableProperty] private bool _includeAbsentStudents; + + partial void OnIsExpandedChanged(bool value) + { + if (value) Refresh(); + } + + partial void OnIncludeAbsentStudentsChanged(bool value) => Refresh(); + + public void SetCurrentSessionId(Guid? sessionId) => _currentSessionId = sessionId; + + [RelayCommand] + public void Refresh() + { + Rows.Clear(); + var groupMemberships = memberships.GetByGroup(groupId); + var roster = students.GetByGroup(groupId) + .Where(s => groupMemberships.FirstOrDefault(m => m.StudentId == s.Id) is not { } membership || + GroupMembershipService.IsActiveOn(membership, lessonDate)) + .ToDictionary(s => s.Id); + + var absentIds = _currentSessionId is { } sessionId + ? participation.GetBySession(sessionId) + .Where(e => e.Attendance is not null and not AttendanceStatus.Present + and not AttendanceStatus.Late and not AttendanceStatus.SignificantlyLate) + .Select(e => e.StudentId).ToHashSet() + : []; + + var rows = sessions.GetByGroup(groupId) + .Where(s => s.Date < lessonDate && s.Date >= lessonDate.AddDays(-LookbackDays)) + .SelectMany(session => participation.GetBySession(session.Id) + .Select(entry => (session.Date, Entry: entry))) + .Where(x => roster.ContainsKey(x.Entry.StudentId) && + (IncludeAbsentStudents || !absentIds.Contains(x.Entry.StudentId))) + .Select(x => (x.Date, x.Entry.StudentId, Status: HomeworkDisplay.Effective(x.Entry))) + .Where(x => x.Status is { } status && HomeworkDisplay.CountsAsMissing(status)) + .OrderByDescending(x => x.Date) + .ThenBy(x => roster[x.StudentId].FullName) + .Take(MaxRows); + + foreach (var row in rows) + Rows.Add(new OutstandingHomeworkRow(roster[row.StudentId].FullName, row.Date, row.Status!.Value)); + OnPropertyChanged(nameof(Header)); + OnPropertyChanged(nameof(HasRows)); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/TeachingModeViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/TeachingModeViewModel.cs index 722494f..484cc7e 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/TeachingModeViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/TeachingModeViewModel.cs @@ -33,10 +33,12 @@ public class TeachingModeViewModel /// der richtigen Sitzung starten. public ParticipationTabViewModel Participation { get; } public TeachingModeHomeworkViewModel Homework { get; } + public TeachingModeOutstandingHomeworkViewModel? OutstandingHomework { get; } public TeachingModeViewModel(Lesson lesson, LearningGroup group, IAlternativeLessonPathRepository alternativePaths, ILessonRepository lessons, - SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation) + SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation, + TeachingModeOutstandingHomeworkViewModel? outstandingHomework = null) { GroupName = group.Name; Timeline = new TeachingTimelineViewModel(lesson, lessons, !group.IsActive); @@ -52,6 +54,16 @@ public class TeachingModeViewModel if (SeatingPlan.SelectedSession is { } linkedSession) Participation.SelectedSession = Participation.Sessions.FirstOrDefault(s => s.Id == linkedSession.Id); + OutstandingHomework = outstandingHomework; + OutstandingHomework?.SetCurrentSessionId(SeatingPlan.SelectedSession?.Id); + OutstandingHomework?.Refresh(); + SeatingPlan.OnAssessmentChanged = () => OutstandingHomework?.Refresh(); + SeatingPlan.PropertyChanged += (_, args) => + { + if (args.PropertyName != nameof(SeatingPlan.SelectedSession)) return; + OutstandingHomework?.SetCurrentSessionId(SeatingPlan.SelectedSession?.Id); + OutstandingHomework?.Refresh(); + }; // 4.5.4: dieselbe "letzte Stunde vor dieser"-Suche wie das Stundenplan-Badge // "Hausaufgabe kontrollieren" (TimetableViewModel.HasUnhandledHomework) - 120 Tage diff --git a/LehrerApp.Desktop/Views/Groups/TeachingModeWindow.axaml b/LehrerApp.Desktop/Views/Groups/TeachingModeWindow.axaml index 888d3c0..2e4e51a 100644 --- a/LehrerApp.Desktop/Views/Groups/TeachingModeWindow.axaml +++ b/LehrerApp.Desktop/Views/Groups/TeachingModeWindow.axaml @@ -157,6 +157,35 @@ + + + + +