From 54ad7f741926a1ffb1befbf11084f67b02586c65 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Sat, 26 Sep 2026 00:48:39 +0200 Subject: [PATCH] Defuse handled dashboard warnings and add a snooze action Cross-class warnings (attendance, homework backlog, lateness) stayed red and at the top even after being acted on. A warning now turns gray and sorts to the bottom of its group once a recently-updated open Vorgang with a matching tag exists, or once explicitly snoozed for 1/2/4 weeks. Both are time-limited: once the grace period lapses without the underlying number actually improving, the warning goes active again on the next dashboard load - no background job needed since it's just recomputed on every RebuildAttention. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Interfaces/IRepositories.cs | 8 + .../Models/DashboardWarningSnooze.cs | 17 ++ LehrerApp.Data/LiteDbContext.cs | 3 + .../Repositories/AllRepositories.cs | 8 + .../DashboardViewModelTests.cs | 159 ++++++++++++++++++ LehrerApp.Desktop.Tests/Fakes.cs | 9 + LehrerApp.Desktop/App.axaml.cs | 12 ++ LehrerApp.Desktop/AppBootstrapper.cs | 1 + LehrerApp.Desktop/ViewModels/AttentionItem.cs | 12 +- .../ViewModels/DashboardViewModel.cs | 133 +++++++++++++-- .../Views/Dashboard/DashboardView.axaml | 17 +- .../Views/Shared/SnoozeDurationDialog.axaml | 18 ++ .../Shared/SnoozeDurationDialog.axaml.cs | 14 ++ .../Views/Shared/SnoozeDurationDialogInfo.cs | 7 + TODO.md | 21 +++ 15 files changed, 421 insertions(+), 18 deletions(-) create mode 100644 LehrerApp.Core/Models/DashboardWarningSnooze.cs create mode 100644 LehrerApp.Desktop/Views/Shared/SnoozeDurationDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Shared/SnoozeDurationDialog.axaml.cs create mode 100644 LehrerApp.Desktop/Views/Shared/SnoozeDurationDialogInfo.cs diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index fe73cf3..92eb79a 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -218,6 +218,14 @@ public interface IVorgangRepository /// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation). void Delete(Guid id); } +/// Rein lokaler "Zurückstellen"-Zeitstempel für Dashboard-Warnungen je (StudentId, WarningTag) — +/// kein Sync, siehe DashboardWarningSnooze. +public interface IDashboardWarningSnoozeRepository +{ + DashboardWarningSnooze? Get(Guid studentId, string warningTag); + List GetAll(); + void Save(DashboardWarningSnooze snooze); +} public interface IWorkTaskRepository { List GetByStatus(WorkTaskStatus status); diff --git a/LehrerApp.Core/Models/DashboardWarningSnooze.cs b/LehrerApp.Core/Models/DashboardWarningSnooze.cs new file mode 100644 index 0000000..995ca78 --- /dev/null +++ b/LehrerApp.Core/Models/DashboardWarningSnooze.cs @@ -0,0 +1,17 @@ +namespace LehrerApp.Core.Models; + +/// +/// "Zurückstellen" einer klassenübergreifenden Dashboard-Warnung (Fehlzeiten/Hausaufgaben/ +/// Verspätungen, siehe DashboardViewModel) für eine Person — ein rein lokaler Erinnerungs- +/// Zeitstempel je (StudentId, WarningTag), bewusst nicht über den Sync repliziert (siehe +/// IDashboardWarningSnoozeRepository): eher ein Job-/Fetch-Zustand als echte Fallakten-Daten wie +/// Vorgang/Documentation. +/// +public class DashboardWarningSnooze +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid StudentId { get; set; } + /// Dieselben Strings wie VorgangTagDisplay.Suggestions ("Absentismus"/"Hausaufgaben"/"Verspätungen"). + public string WarningTag { get; set; } = ""; + public DateOnly SnoozedUntil { get; set; } +} diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index ccd2be5..c6782f4 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -76,6 +76,7 @@ public class LiteDbContext : IDisposable public ILiteCollection UntisHubJobStates => _db.GetCollection("untis_hub_job_states"); public ILiteCollection UntisStudentRosterCache => _db.GetCollection("untis_student_roster_cache"); public ILiteCollection AnnualPlanEvents => _db.GetCollection("annual_plan_events"); + public ILiteCollection DashboardWarningSnoozes => _db.GetCollection("dashboard_warning_snoozes"); public ILiteCollection TrashedItems => _db.GetCollection("trash"); public void Checkpoint() => _db.Checkpoint(); @@ -694,6 +695,8 @@ public class LiteDbContext : IDisposable UntisStudentRosterCache.EnsureIndex(x => x.ClassName); UntisCacheFetchStates.EnsureIndex("ux_class_kind", BsonExpression.Create("STRING($.ClassName) + ':' + STRING($.Kind)"), unique: true); + DashboardWarningSnoozes.EnsureIndex("ux_student_tag", + BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.WarningTag)"), unique: true); } public void Dispose() => _db.Dispose(); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 3bd9cdb..e938299 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -901,6 +901,14 @@ public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetch public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state); } +public class DashboardWarningSnoozeRepository(LiteDbContext db) : IDashboardWarningSnoozeRepository +{ + public DashboardWarningSnooze? Get(Guid studentId, string warningTag) => + db.DashboardWarningSnoozes.FindOne(s => s.StudentId == studentId && s.WarningTag == warningTag); + public List GetAll() => db.DashboardWarningSnoozes.FindAll().ToList(); + public void Save(DashboardWarningSnooze snooze) => db.DashboardWarningSnoozes.Upsert(snooze); +} + public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository { public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) => diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index 494d2f3..1fb7a4b 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -1,3 +1,4 @@ +using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.ViewModels; @@ -143,6 +144,7 @@ public sealed class DashboardViewModelTests FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null, FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null, FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null, + FakeVorgangRepository? vorgaenge = null, FakeDashboardWarningSnoozeRepository? warningSnoozes = null, DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeSessions? sessions = null, FakeEntries? entries = null, @@ -157,6 +159,7 @@ public sealed class DashboardViewModelTests reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]), tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(), + vorgaenge ?? new FakeVorgangRepository(), warningSnoozes ?? new FakeDashboardWarningSnoozeRepository(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(), @@ -951,6 +954,162 @@ public sealed class DashboardViewModelTests Assert.Empty(Items(vm, AttentionKind.Attendance)); } + // ── Entschärfen (Nutzer-Feedback: "erledigt" wirkt grau/hinten, ohne dauerhaft stumm zu bleiben) ── + + private static (Student Handled, Student Active) BuildTwoOverThresholdStudents( + LearningGroup group, DateOnly today, FakeSessions sessions, FakeEntries entries) + { + var handled = new Student { FirstName = "Anna", LastName = "Bearbeitet" }; + var active = new Student { FirstName = "Ben", LastName = "Unbearbeitet" }; + foreach (var student in new[] { handled, active }) + for (var i = 0; i < 10; i++) + { + var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) }; + sessions.Save(session); + var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present; + entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status }); + } + return (handled, active); + } + + [Fact] + public void AttendanceWarnings_MitKuerzlichBearbeitetemVorgang_IstEntschaerftUndAmEndeSortiert() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + var (handled, active) = BuildTwoOverThresholdStudents(group, today, sessions, entries); + + var vorgaenge = new FakeVorgangRepository(); + vorgaenge.Add(new Vorgang + { + Title = "Fehlzeiten", StudentIds = [handled.Id], Tags = ["Absentismus"], + Status = VorgangStatus.Open, UpdatedAt = DateTime.UtcNow.AddDays(-2), + }); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([handled, active]), sessions: sessions, entries: entries, + vorgaenge: vorgaenge); + + var items = Items(vm, AttentionKind.Attendance).ToList(); + Assert.Equal(2, items.Count); + Assert.False(items[0].IsDefused); + Assert.Equal(active.FullName, items[0].Title); + Assert.True(items[1].IsDefused); + Assert.Equal(handled.FullName, items[1].Title); + } + + [Fact] + public void AttendanceWarnings_VorgangAelterAlsFrist_BleibtScharf() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + var (handled, _) = BuildTwoOverThresholdStudents(group, today, sessions, entries); + + var vorgaenge = new FakeVorgangRepository(); + // 20 Tage her — jenseits der 14-Tage-Frist (VorgangDefusalGraceDays). + vorgaenge.Add(new Vorgang + { + Title = "Fehlzeiten", StudentIds = [handled.Id], Tags = ["Absentismus"], + Status = VorgangStatus.Open, UpdatedAt = DateTime.UtcNow.AddDays(-20), + }); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([handled]), sessions: sessions, entries: entries, vorgaenge: vorgaenge); + + Assert.False(Assert.Single(Items(vm, AttentionKind.Attendance)).IsDefused); + } + + [Fact] + public void AttendanceWarnings_AktiveZurueckstellungIstEntschaerft_AbgelaufeneNichtMehr() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + var (handled, active) = BuildTwoOverThresholdStudents(group, today, sessions, entries); + + var snoozes = new FakeDashboardWarningSnoozeRepository(); + snoozes.Save(new DashboardWarningSnooze { StudentId = handled.Id, WarningTag = "Absentismus", SnoozedUntil = today.AddDays(7) }); + snoozes.Save(new DashboardWarningSnooze { StudentId = active.Id, WarningTag = "Absentismus", SnoozedUntil = today.AddDays(-1) }); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([handled, active]), sessions: sessions, entries: entries, + warningSnoozes: snoozes); + + var byName = Items(vm, AttentionKind.Attendance).ToDictionary(i => i.Title); + Assert.True(byName[handled.FullName].IsDefused); + Assert.False(byName[active.FullName].IsDefused); + } + + [Fact] + public async Task SnoozeAction_FragtNachUndEntschaerftDieWarnungSofort() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + for (var i = 0; i < 10; i++) + { + var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) }; + sessions.Save(session); + var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present; + entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status }); + } + var snoozes = new FakeDashboardWarningSnoozeRepository(); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([student]), sessions: sessions, entries: entries, warningSnoozes: snoozes); + + string? shownMessage = null; + vm.OnPickSnoozeDuration = message => { shownMessage = message; return Task.FromResult(14); }; + + var item = Assert.Single(Items(vm, AttentionKind.Attendance)); + Assert.False(item.IsDefused); + var snoozeAction = Assert.Single(item.Actions, a => a.Label == "Zurückstellen"); + await ((IAsyncRelayCommand)snoozeAction.Command).ExecuteAsync(null); + + Assert.Contains(student.FullName, shownMessage); + var saved = Assert.Single(snoozes.GetAll()); + Assert.Equal(student.Id, saved.StudentId); + Assert.Equal("Absentismus", saved.WarningTag); + Assert.Equal(today.AddDays(14), saved.SnoozedUntil); + Assert.True(Assert.Single(Items(vm, AttentionKind.Attendance)).IsDefused); + } + + [Fact] + public async Task SnoozeAction_AbbruchSpeichertNichts() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + for (var i = 0; i < 10; i++) + { + var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) }; + sessions.Save(session); + var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present; + entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status }); + } + var snoozes = new FakeDashboardWarningSnoozeRepository(); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, + students: new FakeStudents([student]), sessions: sessions, entries: entries, warningSnoozes: snoozes); + vm.OnPickSnoozeDuration = _ => Task.FromResult(null); + + var item = Assert.Single(Items(vm, AttentionKind.Attendance)); + var snoozeAction = Assert.Single(item.Actions, a => a.Label == "Zurückstellen"); + await ((IAsyncRelayCommand)snoozeAction.Command).ExecuteAsync(null); + + Assert.Empty(snoozes.GetAll()); + Assert.False(Assert.Single(Items(vm, AttentionKind.Attendance)).IsDefused); + } + [Fact] public async Task AddTask_SpeichertErgebnisUndAktualisiertOffeneAufgaben() { diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index d95f39e..978e397 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -341,6 +341,15 @@ public class FakeVorgangRepository : IVorgangRepository public void Delete(Guid id) { var v = _all.FirstOrDefault(x => x.Id == id); if (v is not null) v.IsDeleted = true; } } +public class FakeDashboardWarningSnoozeRepository : IDashboardWarningSnoozeRepository +{ + private readonly List _all = []; + public DashboardWarningSnooze? Get(Guid studentId, string warningTag) => + _all.FirstOrDefault(s => s.StudentId == studentId && s.WarningTag == warningTag); + public List GetAll() => _all.ToList(); + public void Save(DashboardWarningSnooze snooze) { _all.RemoveAll(s => s.Id == snooze.Id); _all.Add(snooze); } +} + public class FakeAttachmentStorage : IAttachmentStorage { private readonly Dictionary _blobs = []; diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index a03247b..72d21a3 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -15,6 +15,7 @@ using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Workload; using LehrerApp.Desktop.Views; +using LehrerApp.Desktop.Views.Shared; using LehrerApp.Desktop.Views.Workload; using LehrerApp.Sync; using Microsoft.Extensions.DependencyInjection; @@ -239,6 +240,7 @@ public class App : Application dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren" dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung" dash.OnAddMissingTeachingTime = item => ShowMissingTeachingTimeDialog(item, dash); + dash.OnPickSnoozeDuration = ShowSnoozeDurationDialog; var examsOverview = Services.GetRequiredService(); examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups); @@ -294,6 +296,16 @@ public class App : Application Services.GetRequiredService().Load(); } + private static async Task ShowSnoozeDurationDialog(string message) + { + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime + { MainWindow: { } owner }) return null; + + var vm = new SnoozeDurationDialogInfo { Message = message }; + var dialog = new SnoozeDurationDialog { DataContext = vm }; + return await dialog.ShowDialog(owner); + } + private static async Task ShowMissingTeachingTimeDialog(MissingTeachingTimeItem item, DashboardViewModel dashboard) { if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 54ffc04..69f43e6 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -168,6 +168,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/LehrerApp.Desktop/ViewModels/AttentionItem.cs b/LehrerApp.Desktop/ViewModels/AttentionItem.cs index adbf831..164ca18 100644 --- a/LehrerApp.Desktop/ViewModels/AttentionItem.cs +++ b/LehrerApp.Desktop/ViewModels/AttentionItem.cs @@ -49,13 +49,22 @@ public sealed class AttentionItem /// bei Attendance/MissingHomework/Lateness gesetzt, weil ein AttentionItem dieser Arten per /// Konstruktion nur entsteht, wenn der jeweilige Schwellenwert bereits überschritten ist. public bool IsWarningTrailing { get; } + /// Nur bei Attendance/MissingHomework/Lateness gesetzt: ein offener Vorgang mit + /// passendem Tag wurde kürzlich bearbeitet, oder die Warnung wurde manuell zurückgestellt (siehe + /// DashboardViewModel.SnoozeWarningAsync) — beides zeitlich befristet, damit eine ausbleibende + /// Besserung nach Ablauf der Frist wieder auffällt statt dauerhaft entschärft zu bleiben. Steuert + /// nur die Optik (grau statt rot) und die Sortierung (ans Ende der Gruppe); der Schwellenwert- + /// Vergleich selbst bleibt unverändert, die Warnung verschwindet also ganz, sobald sich die + /// zugrunde liegende Zahl tatsächlich bessert. + public bool IsDefused { get; } public IReadOnlyList Actions { get; } public Action? Navigate { get; } public AttentionItem(AttentionKind kind, string title, string subtitle = "", string trailingText = "", string dateDisplay = "", bool isOverdue = false, AlertSeverity? severity = null, int progressPercent = 0, string progressLabel = "", bool hasProgress = false, - bool isWarningTrailing = false, IReadOnlyList? actions = null, Action? navigate = null) + bool isWarningTrailing = false, bool isDefused = false, + IReadOnlyList? actions = null, Action? navigate = null) { Kind = kind; Title = title; @@ -68,6 +77,7 @@ public sealed class AttentionItem ProgressLabel = progressLabel; HasProgress = hasProgress; IsWarningTrailing = isWarningTrailing; + IsDefused = isDefused; Actions = actions ?? []; Navigate = navigate; } diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index cdcffe2..4285ef1 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -27,6 +27,8 @@ public partial class DashboardViewModel : ObservableObject private readonly IParticipationRepository _participationEntries; private readonly IStudentRepository _students; private readonly IDocumentationRepository _documentation; + private readonly IVorgangRepository _vorgaenge; + private readonly IDashboardWarningSnoozeRepository _warningSnoozes; private readonly ITimetableSlotRepository _timetableSlots; private readonly PeriodScheduleService _periodSchedule; private readonly AttendanceBalanceService _attendanceBalance; @@ -62,6 +64,17 @@ public partial class DashboardViewModel : ObservableObject /// Nutzer-Entscheidung: ab 8 Verspätungen (Late/SignificantlyLate) im laufenden Schuljahr /// (alle Gruppen) wird gewarnt. private const int LatenessWarningThreshold = 8; + /// Nutzer-Feedback: eine Warnung, zu der bereits ein offener Vorgang mit passendem Tag existiert + /// ODER die manuell zurückgestellt wurde, erscheint "entschärft" (grau, ans Ende der Gruppe + /// einsortiert) statt weiter rot hervorgehoben — aber nur befristet: bessert sich die zugrunde + /// liegende Zahl innerhalb dieser Frist nicht, soll die Warnung wieder auffallen, statt durch + /// einen dauerhaft offenen Vorgang für immer entschärft zu bleiben. Für den Vorgang-Fall zählt + /// dessen UpdatedAt (jede Interaktion setzt die Frist zurück); für "Zurückstellen" wählt die + /// Nutzerin die Frist selbst (siehe OnPickSnoozeDuration). + private const int VorgangDefusalGraceDays = 14; + private const string AbsentismusTag = "Absentismus"; + private const string HausaufgabenTag = "Hausaufgaben"; + private const string VerspaetungenTag = "Verspätungen"; /// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal /// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht /// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren @@ -151,6 +164,9 @@ public partial class DashboardViewModel : ObservableObject // string vorgeschlagener Titel) — eigenes Delegate statt OnNavigateToStudent zu erweitern, damit // dessen übrige Aufrufer (Förderplan, Auffälligkeiten, Termine) unverändert bleiben. public Action? OnNavigateToStudentCase { get; set; } + // Fragt beim Klick auf "Zurückstellen" die gewünschte Frist ab (Anzeigetext, z.B. "Fehlzeiten – + // Anna Beispiel") und liefert die Anzahl Tage zurück, oder null bei Abbruch. + public Func>? OnPickSnoozeDuration { get; set; } // Sprungziel eigens für eine Stunde in TodaysLessons (9.2) — bewusst getrennt von // OnNavigateToGroup (Lerngruppen-Kacheln, Tab "Übersicht"), da der Sprung von einer konkreten // Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt. @@ -194,7 +210,8 @@ public partial class DashboardViewModel : ObservableObject IReportGradeRepository reportGrades, IGroupMembershipRepository memberships, IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions, IParticipationRepository participationEntries, IStudentRepository students, - IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots, + IDocumentationRepository documentation, IVorgangRepository vorgaenge, + IDashboardWarningSnoozeRepository warningSnoozes, ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy, DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, @@ -208,6 +225,7 @@ public partial class DashboardViewModel : ObservableObject _examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships; _participationSessions = participationSessions; _participationEntries = participationEntries; _students = students; _documentation = documentation; + _vorgaenge = vorgaenge; _warningSnoozes = warningSnoozes; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule; _attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings; _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; @@ -401,6 +419,71 @@ public partial class DashboardViewModel : ObservableObject private Dictionary BuildSessionDateLookup() => _participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date); + // ── Entschärfen (Nutzer-Feedback) ───────────────────────────────────────── + // + // Eine der drei klassenübergreifenden Warnungen gilt als "entschärft" (grau, ans Ende ihrer + // Gruppe sortiert), solange entweder ein offener Vorgang mit passendem Tag kürzlich bearbeitet + // wurde, oder eine manuelle Rückstellung noch läuft — beides zeitlich befristet (siehe + // VorgangDefusalGraceDays-Kommentar), damit ausbleibende Besserung nach Fristablauf wieder + // auffällt statt dauerhaft entschärft zu bleiben. Beide Nachschlage-Mengen bulk-geladen wie + // BuildSessionDateLookup, um kein N+1 über alle Schüler/Vorgänge zu erzeugen. + + private HashSet<(Guid StudentId, string Tag)> BuildRecentVorgangTagSet() + { + var cutoffUtc = DateTime.UtcNow.AddDays(-VorgangDefusalGraceDays); + var set = new HashSet<(Guid, string)>(); + foreach (var v in _vorgaenge.GetAll()) + { + if (v.Status != VorgangStatus.Open) continue; + // Siehe CLAUDE.md (LiteDB-Details): DateTime-Vergleiche nach dem Utc→Local-Roundtrip + // sind nur nach beidseitigem ToUniversalTime() sicher. + if (v.UpdatedAt.ToUniversalTime() < cutoffUtc) continue; + foreach (var studentId in v.StudentIds) + foreach (var tag in v.Tags) + set.Add((studentId, tag.ToLowerInvariant())); + } + return set; + } + + private HashSet<(Guid StudentId, string Tag)> BuildActiveSnoozeSet(DateOnly today) => + _warningSnoozes.GetAll().Where(s => s.SnoozedUntil >= today) + .Select(s => (s.StudentId, s.WarningTag.ToLowerInvariant())).ToHashSet(); + + private static bool IsDefused(Guid studentId, string tag, + HashSet<(Guid StudentId, string Tag)> recentVorgaenge, HashSet<(Guid StudentId, string Tag)> activeSnoozes) + { + var key = (studentId, tag.ToLowerInvariant()); + return recentVorgaenge.Contains(key) || activeSnoozes.Contains(key); + } + + /// Von der "Zurückstellen"-Aktion einer Warnung aufgerufen (siehe ToAttentionItem-Überladungen + /// unten) — fragt über OnPickSnoozeDuration die Frist ab und speichert sie, sofern nicht + /// abgebrochen. Aktualisiert danach nur die drei klassenübergreifenden Warnungen neu, kein + /// voller Dashboard-Reload nötig (gleiches Muster wie ResolveExcuse/AddMissingTeachingTime). + private async Task SnoozeWarningAsync(Guid studentId, string tag, string label) + { + if (OnPickSnoozeDuration is null) return; + var days = await OnPickSnoozeDuration(label); + if (days is null) return; + + var snooze = _warningSnoozes.Get(studentId, tag) ?? new DashboardWarningSnooze + { StudentId = studentId, WarningTag = tag }; + snooze.SnoozedUntil = DateOnly.FromDateTime(_now()).AddDays(days.Value); + _warningSnoozes.Save(snooze); + + var today = DateOnly.FromDateTime(_now()); + LoadAttendanceWarnings(today); + LoadMissingHomeworkWarnings(today); + LoadLatenessWarnings(today); + RebuildAttention(); + UpdateDashboardSummary(); + } + + private AttentionAction SnoozeAction(Guid studentId, string tag, string label) => new("Zurückstellen", + new AsyncRelayCommand(() => SnoozeWarningAsync(studentId, tag, label))); + + // ── Fehlzeiten-Warnung (5.2.3) ──────────────────────────────────────────── + private void LoadAttendanceWarnings(DateOnly today) { _attendanceItems.Clear(); @@ -408,6 +491,8 @@ public partial class DashboardViewModel : ObservableObject var from = _sy.SchoolYearStart(schoolYear); var to = _sy.SchoolYearEnd(schoolYear); var sessionDates = BuildSessionDateLookup(); + var recentVorgaenge = BuildRecentVorgangTagSet(); + var activeSnoozes = BuildActiveSnoozeSet(today); var items = new List(); foreach (var student in _students.GetAll()) @@ -422,14 +507,18 @@ public partial class DashboardViewModel : ObservableObject if (balance.TotalChecked >= AttendanceMinSampleSize && balance.ExceedsThreshold) items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent)); } - foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent)) - _attendanceItems.Add(ToAttentionItem(item)); + var ordered = items + .Select(i => (Item: i, Defused: IsDefused(i.StudentId, AbsentismusTag, recentVorgaenge, activeSnoozes))) + .OrderBy(x => x.Defused).ThenByDescending(x => x.Item.AbsenceRatePercent); + foreach (var (item, defused) in ordered) + _attendanceItems.Add(ToAttentionItem(item, defused)); } - private AttentionItem ToAttentionItem(AttendanceWarningItem w) => new( + private AttentionItem ToAttentionItem(AttendanceWarningItem w, bool isDefused) => new( AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %", - isWarningTrailing: true, - navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, "Absentismus", "Fehlzeiten")); + isWarningTrailing: true, isDefused: isDefused, + actions: [SnoozeAction(w.StudentId, AbsentismusTag, $"Fehlzeiten – {w.StudentName}")], + navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, AbsentismusTag, "Fehlzeiten")); // ── Hausaufgaben-Warnung (Nutzer-Feedback: analog zur Fehlzeiten-Warnung, klassenübergreifend) ── @@ -440,6 +529,8 @@ public partial class DashboardViewModel : ObservableObject var from = _sy.SchoolYearStart(schoolYear); var to = _sy.SchoolYearEnd(schoolYear); var sessionDates = BuildSessionDateLookup(); + var recentVorgaenge = BuildRecentVorgangTagSet(); + var activeSnoozes = BuildActiveSnoozeSet(today); var items = new List(); foreach (var student in _students.GetAll()) @@ -450,14 +541,18 @@ public partial class DashboardViewModel : ObservableObject if (count >= MissingHomeworkWarningThreshold) items.Add(new MissingHomeworkWarningItem(student.Id, student.FullName, count)); } - foreach (var item in items.OrderByDescending(i => i.MissingCount)) - _missingHomeworkItems.Add(ToAttentionItem(item)); + var ordered = items + .Select(i => (Item: i, Defused: IsDefused(i.StudentId, HausaufgabenTag, recentVorgaenge, activeSnoozes))) + .OrderBy(x => x.Defused).ThenByDescending(x => x.Item.MissingCount); + foreach (var (item, defused) in ordered) + _missingHomeworkItems.Add(ToAttentionItem(item, defused)); } - private AttentionItem ToAttentionItem(MissingHomeworkWarningItem w) => new( + private AttentionItem ToAttentionItem(MissingHomeworkWarningItem w, bool isDefused) => new( AttentionKind.MissingHomework, w.StudentName, trailingText: $"{w.MissingCount} offen", - isWarningTrailing: true, - navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, "Hausaufgaben", "Hausaufgaben")); + isWarningTrailing: true, isDefused: isDefused, + actions: [SnoozeAction(w.StudentId, HausaufgabenTag, $"Hausaufgaben – {w.StudentName}")], + navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, HausaufgabenTag, "Hausaufgaben")); // ── Verspätungs-Warnung (Nutzer-Feedback: analog zur Fehlzeiten-Warnung, klassenübergreifend) ── // @@ -472,6 +567,8 @@ public partial class DashboardViewModel : ObservableObject var from = _sy.SchoolYearStart(schoolYear); var to = _sy.SchoolYearEnd(schoolYear); var sessionDates = BuildSessionDateLookup(); + var recentVorgaenge = BuildRecentVorgangTagSet(); + var activeSnoozes = BuildActiveSnoozeSet(today); var items = new List(); foreach (var student in _students.GetAll()) @@ -482,14 +579,18 @@ public partial class DashboardViewModel : ObservableObject if (count >= LatenessWarningThreshold) items.Add(new LatenessWarningItem(student.Id, student.FullName, count)); } - foreach (var item in items.OrderByDescending(i => i.LateCount)) - _latenessItems.Add(ToAttentionItem(item)); + var ordered = items + .Select(i => (Item: i, Defused: IsDefused(i.StudentId, VerspaetungenTag, recentVorgaenge, activeSnoozes))) + .OrderBy(x => x.Defused).ThenByDescending(x => x.Item.LateCount); + foreach (var (item, defused) in ordered) + _latenessItems.Add(ToAttentionItem(item, defused)); } - private AttentionItem ToAttentionItem(LatenessWarningItem w) => new( + private AttentionItem ToAttentionItem(LatenessWarningItem w, bool isDefused) => new( AttentionKind.Lateness, w.StudentName, trailingText: $"{w.LateCount}× verspätet", - isWarningTrailing: true, - navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, "Verspätungen", "Verspätungen")); + isWarningTrailing: true, isDefused: isDefused, + actions: [SnoozeAction(w.StudentId, VerspaetungenTag, $"Verspätungen – {w.StudentName}")], + navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, VerspaetungenTag, "Verspätungen")); // ── Klausurwochen (Nutzer-Feedback) ─────────────────────────────────────── // diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index 927a5dc..f6607e8 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -37,6 +37,14 @@ + + + @@ -498,7 +511,8 @@ + Classes.attendance="{Binding IsCrossClassWarning}" + Classes.defused="{Binding IsDefused}" Margin="0,0,9,0"/>