From 216708e87981358e75cadb253d7e9795ba99f752 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Tue, 1 Sep 2026 23:52:38 +0200 Subject: [PATCH] =?UTF-8?q?Gesamtbild:=20konfigurierbare=20Gewichtung,=20D?= =?UTF-8?q?=C3=A4mpfung=20nach=20Vorgang,=20R=C3=BCckfall-Warnung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feste Score-Gewichte durch einen Einstellungsdialog ersetzt. Ein geschlossener Vorgang dämpft ab seinem Schließ-Datum den zu seinen Schlagwörtern passenden Score-Bereich (Anwesenheit/Verspätung/ Klassenbuch), ohne Schlagwort-Treffer vorsichtshalber alle drei — die faktische Fehlquote bleibt davon unberührt. Verschlechtert sich die Lage danach wieder spürbar, erscheint eine "erneut auffällig"-Warnung im bestehenden "Muster erkannt"-Panel. Co-Authored-By: Claude Sonnet 5 --- .../Services/PatternScoreSettingsService.cs | 40 ++++ .../ClassTeacherViewModelsTests.cs | 190 ++++++++++++++++++ LehrerApp.Desktop/AppBootstrapper.cs | 1 + .../ClassTeacherOverviewViewModel.cs | 181 ++++++++++++++--- .../PatternScoreWeightsDialogViewModel.cs | 42 ++++ .../ClassTeacherOverviewView.axaml | 7 +- .../ClassTeacherOverviewView.axaml.cs | 18 ++ .../PatternScoreWeightsDialog.axaml | 48 +++++ .../PatternScoreWeightsDialog.axaml.cs | 16 ++ TODO.md | 36 +++- 10 files changed, 543 insertions(+), 36 deletions(-) create mode 100644 LehrerApp.Core/Services/PatternScoreSettingsService.cs create mode 100644 LehrerApp.Desktop/ViewModels/ClassTeacher/PatternScoreWeightsDialogViewModel.cs create mode 100644 LehrerApp.Desktop/Views/ClassTeacher/PatternScoreWeightsDialog.axaml create mode 100644 LehrerApp.Desktop/Views/ClassTeacher/PatternScoreWeightsDialog.axaml.cs diff --git a/LehrerApp.Core/Services/PatternScoreSettingsService.cs b/LehrerApp.Core/Services/PatternScoreSettingsService.cs new file mode 100644 index 0000000..d96075b --- /dev/null +++ b/LehrerApp.Core/Services/PatternScoreSettingsService.cs @@ -0,0 +1,40 @@ +using System.Text.Json; + +namespace LehrerApp.Core.Services; + +/// Gewichte für ClassTeacherRosterRow.PatternScore (Klassenlehrer-Bereich, "Gesamtbild") +/// — Default-Werte entsprechen den ursprünglich fest verdrahteten Konstanten. +public sealed class PatternScoreWeights +{ + public double UnexcusedDayWeight { get; set; } = 3.0; + public double ExcusedDayWeight { get; set; } = 1.0; + public double LateDayWeight { get; set; } = 0.5; + public double NegativeClassRegisterEntryWeight { get; set; } = 1.0; + public double ConcerningKeywordBonus { get; set; } = 1.5; + public double SuspensionKeywordWeight { get; set; } = 15.0; +} + +/// Speichert die Gewichte lokal, gleiches Muster wie WorkloadSettingsService/ +/// DashboardSettingsService. +public sealed class PatternScoreSettingsService +{ + private readonly string _configPath; + + public PatternScoreSettingsService(string appDataPath) => + _configPath = Path.Combine(appDataPath, "patternscoreweights.json"); + + public PatternScoreWeights Load() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) + ?? new PatternScoreWeights(); + } + catch { /* beschädigte Konfiguration -> Standardgewichte */ } + return new PatternScoreWeights(); + } + + public void Save(PatternScoreWeights weights) => + File.WriteAllText(_configPath, JsonSerializer.Serialize(weights)); +} diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index b2d4966..84a1e6e 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -1,4 +1,5 @@ using LehrerApp.Core.Models; +using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.ClassTeacher; using Xunit; @@ -889,4 +890,193 @@ public sealed class ClassTeacherViewModelsTests Assert.Empty(result); } + + // ── Konfigurierbare Gewichtung + Dämpfung/Rückfall-Warnung nach geschlossenem Vorgang ───────── + + [Fact] + public void MatchDomains_ErkenntBereicheAnSchluesselwoertern() + { + Assert.Equal([VorgangScoreDomain.Attendance], ClassTeacherRosterRow.MatchDomains(["Absentismus"])); + Assert.Equal([VorgangScoreDomain.Lateness], ClassTeacherRosterRow.MatchDomains(["Verspätungen"])); + Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Konflikte"])); + Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Mitarbeit"])); + Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Hausaufgaben"])); + Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Eskalation"])); + } + + [Fact] + public void MatchDomains_OhneErkanntesSchlagwortDaemptAlleBereiche() + { + // Nutzer-Entscheidung: ein Vorgang darf beim Schließen nie folgenlos bleiben, auch wenn sein + // Schlagwort (z.B. "Elternkontakt") keinem bekannten Score-Bereich zugeordnet werden kann. + var domains = ClassTeacherRosterRow.MatchDomains(["Elternkontakt"]); + + Assert.Equal(3, domains.Count); + Assert.Contains(VorgangScoreDomain.Attendance, domains); + Assert.Contains(VorgangScoreDomain.Lateness, domains); + Assert.Contains(VorgangScoreDomain.Classbook, domains); + } + + [Fact] + public void ComputeDampeningCutoffs_NimmtJeBereichDasJuengsteSchliessDatum() + { + var closed = new List + { + new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 10) }, + new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 20) }, + new() { Tags = ["Verspätungen"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 5) }, + }; + + var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed); + + Assert.Equal(new DateOnly(2026, 8, 20), cutoffs[VorgangScoreDomain.Attendance]); + Assert.Equal(new DateOnly(2026, 8, 5), cutoffs[VorgangScoreDomain.Lateness]); + Assert.False(cutoffs.ContainsKey(VorgangScoreDomain.Classbook)); + } + + [Fact] + public void ComputeDampeningCutoffs_IgnoriertVorgaengeOhneSchliessDatum() + { + var closed = new List { new() { Tags = ["Absentismus"], Status = VorgangStatus.Open } }; + + var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed); + + Assert.Empty(cutoffs); + } + + [Fact] + public void PatternScore_BenutztUebergebeneGewichteStattFesterKonstanten() + { + var student = Student(1001, "Ada Müller"); + var yearAbsences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 6, 270, + ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), + }; + var customWeights = new PatternScoreWeights { UnexcusedDayWeight = 10.0 }; + + var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [], + new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), + weights: customWeights)); + + Assert.Equal(10.0, row.PatternScore); + } + + [Fact] + public void PatternScore_DaempftNurDenZumVorgangPassendenBereichNachAbschluss() + { + var student = Student(1001, "Ada Müller"); + var yearAbsences = new[] + { + // Vor dem Abschluss: darf für den Score nicht mehr zählen. + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270, + ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), + // Nach dem Abschluss: zählt weiter. + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270, + ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), + // Verspätung, anderer Bereich: bleibt von der auf "Absentismus" beschränkten Dämpfung unberührt. + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 1), "Müller Ada", 1001, 0, 5, + ["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false), + }; + var closedVorgaengeByNameKey = new Dictionary> + { + [UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang + { + Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed, + ClosedAt = new DateTime(2026, 8, 12), + }], + }; + + var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [], + new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), + closedVorgaengeByNameKey: closedVorgaengeByNameKey)); + + // 1 unentschuldigter Tag nach Abschluss (3,0) + 1 unbeeinflusster Verspätungstag (0,5) = 3,5. + Assert.Equal(3.5, row.PatternScore); + // Die Quote bleibt von der Dämpfung unberührt: beide unentschuldigten Tage zählen weiter. + Assert.Equal(2, row.YearUnexcusedDayCount); + Assert.Equal(2, row.YearAbsenceDayCount); + } + + [Fact] + public void PatternScore_VorgangOhneErkennbaresSchlagwortDaemptAlleBereiche() + { + var student = Student(1001, "Ada Müller"); + var yearAbsences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 0, 5, + ["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false), + }; + var closedVorgaengeByNameKey = new Dictionary> + { + [UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang + { + Title = "Elterngespräch", Tags = ["Elternkontakt"], Status = VorgangStatus.Closed, + ClosedAt = new DateTime(2026, 8, 12), + }], + }; + + var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [], + new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), + closedVorgaengeByNameKey: closedVorgaengeByNameKey)); + + Assert.Equal(0.0, row.PatternScore); + } + + [Fact] + public void RegressionScore_IstNullOhneNeueEreignisseNachAbschluss() + { + var student = Student(1001, "Ada Müller"); + var yearAbsences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270, + ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), + }; + var closedVorgaengeByNameKey = new Dictionary> + { + [UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang + { + Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed, + ClosedAt = new DateTime(2026, 8, 12), + }], + }; + + var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [], + new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), + closedVorgaengeByNameKey: closedVorgaengeByNameKey)); + + Assert.Equal(0.0, row.RegressionScore); + Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle); + } + + [Fact] + public void RegressionScore_ErkenntNeueUnentschuldigteTageNachAbschluss() + { + var student = Student(1001, "Ada Müller"); + var yearAbsences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270, + ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), + // Nach Abschluss erneut unentschuldigt gefehlt. + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270, + ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), + }; + var closedVorgaengeByNameKey = new Dictionary> + { + [UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang + { + Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed, + ClosedAt = new DateTime(2026, 8, 12), + }], + }; + + var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [], + new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), + closedVorgaengeByNameKey: closedVorgaengeByNameKey)); + + Assert.Equal(3.0, row.RegressionScore); + Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle); + // Quote weiterhin unangetastet. + Assert.Equal(2, row.YearUnexcusedDayCount); + } } diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 2c1e60b..ba7dcb2 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -196,6 +196,7 @@ public static class AppBootstrapper services.AddSingleton(_ => new PeriodScheduleService(appData)); services.AddSingleton(_ => new WorkloadSettingsService(appData)); services.AddSingleton(_ => new DashboardSettingsService(appData)); + services.AddSingleton(_ => new PatternScoreSettingsService(appData)); services.AddSingleton(_ => new WindowSettingsService(appData)); services.AddSingleton(_ => new AppearanceSettingsService(appData)); services.AddSingleton(); diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index fd0dc8e..206eea5 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -14,6 +14,10 @@ namespace LehrerApp.Desktop.ViewModels.ClassTeacher; /// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen. public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger } +/// Die drei Auffälligkeits-Achsen, die ein geschlossener per +/// Schlagwort gezielt dämpfen kann, siehe . +public enum VorgangScoreDomain { Attendance, Lateness, Classbook } + public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday, string? AbsenceTooltip, bool HasRecentClassRegisterEntry) { @@ -130,22 +134,6 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b (HasSuspensionEntry ? " · Suspendierung vermerkt" : "") + (HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : ""); - // Feste Gewichte statt einstellbarer Werte (Nutzer-Entscheidung): unentschuldigt fällt am - // stärksten ins Gewicht ("geschwänzt"), entschuldigte Abwesenheit mittel, reine Verspätung - // ("verpennt") am wenigsten — genau die vom Nutzer gewünschte Trennung, nur als Zahl statt als - // Statustext. Bei Bedarf später anpassbar, siehe TODO.md. - private const double UnexcusedDayWeight = 3.0; - private const double ExcusedDayWeight = 1.0; - private const double LateDayWeight = 0.5; - // Klassenbuch-Einträge anderer Lehrkräfte (Nutzer-Feedback): ein einzelner "Negativ"-Eintrag - // wiegt ungefähr wie eine entschuldigte Fehlzeit — erst die Häufung ("durch die Decke gehen") - // treibt den Score merklich. Die beiden genannten Muster (fehlende Hausaufgaben/schlechte - // Mitarbeit) zählen zusätzlich etwas stärker, weil sie explizit als wiederkehrend relevant - // genannt wurden. "Suspendiert" ist bewusst ein Ausreißer-Gewicht, das den Score sofort nach - // oben reißt, unabhängig vom sonstigen Verlauf. - private const double NegativeClassRegisterEntryWeight = 1.0; - private const double ConcerningKeywordBonus = 1.5; - private const double SuspensionKeywordWeight = 15.0; private static readonly string[] ConcerningKeywords = ["hausaufgabe", "mitarbeit"]; private static readonly string[] SuspensionKeywords = ["suspendier"]; @@ -157,12 +145,19 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b /// zusätzliche Komplexität einer Verfallskurve. Bezieht neben Fehlzeiten auch Klassenbucheinträge /// anderer Lehrkräfte mit ein () — beides sind /// unabhängige Auffälligkeits-Achsen (Anwesenheit vs. Verhalten/Leistung), die sich addieren - /// statt sich gegenseitig zu verdrängen. - public double PatternScore => - YearUnexcusedDayCount * UnexcusedDayWeight + - YearExcusedDayCount * ExcusedDayWeight + - YearLateDayCount * LateDayWeight + - ClassRegisterScoreComponent; + /// statt sich gegenseitig zu verdrängen. Gewichte sind einstellbar (, + /// Nutzer-Entscheidung nach anfänglich festen Konstanten) und die Bereiche eines geschlossenen + /// sind ab dessen Schließ-Datum aus dem Score ausgeklammert (), + /// deshalb jetzt ein zur Build-Zeit berechneter Wert statt einer reinen Formel über die + /// (unveränderten, für die Quote weiter vollständigen) Year*-Zähler. + public double PatternScore { get; init; } + + /// Score-Anteil, der NACH dem Schließen des zuletzt geschlossenen Vorgangs dieser/dieses + /// Schülers*in neu hinzugekommen ist, beschränkt auf dessen eigene Themenbereiche + /// () — Grundlage für die "erneut auffällig"-Warnung. 0 ohne + /// geschlossenen Vorgang oder ohne neue Ereignisse seitdem. + public double RegressionScore { get; init; } + public string? RegressionVorgangTitle { get; init; } private static bool IsLateReason(string reason) => reason.Contains("verspät", StringComparison.OrdinalIgnoreCase); @@ -175,15 +170,80 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b e.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) == true || e.Text?.Contains(k, StringComparison.OrdinalIgnoreCase) == true); - private static double ClassRegisterEntryWeight(UntisForeignClassRegisterEventDto e) + private static double ClassRegisterEntryWeight(UntisForeignClassRegisterEventDto e, PatternScoreWeights weights) { var weight = 0.0; - if (IsNegativeClassRegisterEntry(e)) weight += NegativeClassRegisterEntryWeight; - if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += ConcerningKeywordBonus; - if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += SuspensionKeywordWeight; + if (IsNegativeClassRegisterEntry(e)) weight += weights.NegativeClassRegisterEntryWeight; + if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += weights.ConcerningKeywordBonus; + if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += weights.SuspensionKeywordWeight; return weight; } + // ── Dämpfung nach geschlossenem Vorgang (Nutzer-Entscheidung: bereichsbezogen statt komplett) ── + + private static readonly string[] AttendanceDomainKeywords = ["absent", "fehlzeit", "schwänz", "unentschuldigt"]; + private static readonly string[] LatenessDomainKeywords = ["versp"]; + private static readonly string[] ClassbookDomainKeywords = + ["konflikt", "eskalat", "mitarbeit", "hausaufgabe", "klassenbuch"]; + + /// Ordnet die Freitext-Schlagwörter eines Vorgangs den Score-Bereichen zu, die er dämpfen soll + /// (gleiche Contains-Konvention wie die "Negativ"/"verspät"-Erkennung andernorts). Kein Treffer + /// auf irgendein Schlagwort -> alle drei Bereiche, damit ein geschlossener Vorgang nie + /// folgenlos bleibt (z.B. bei einem reinen "Elternkontakt"-Schlagwort ohne klare Zuordnung). + public static IReadOnlySet MatchDomains(IReadOnlyList tags) + { + var domains = new HashSet(); + if (tags.Any(t => AttendanceDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase)))) + domains.Add(VorgangScoreDomain.Attendance); + if (tags.Any(t => LatenessDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase)))) + domains.Add(VorgangScoreDomain.Lateness); + if (tags.Any(t => ClassbookDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase)))) + domains.Add(VorgangScoreDomain.Classbook); + if (domains.Count == 0) + domains = [VorgangScoreDomain.Attendance, VorgangScoreDomain.Lateness, VorgangScoreDomain.Classbook]; + return domains; + } + + /// Pro Bereich das jüngste Schließ-Datum unter den passenden geschlossenen Vorgängen — Ereignisse + /// vor diesem Datum zählen für diesen Bereich nicht mehr in , die volle + /// Historie (Year*-Zähler, Quote) bleibt davon unberührt. + public static IReadOnlyDictionary ComputeDampeningCutoffs( + IReadOnlyList closedVorgaenge) + { + var cutoffs = new Dictionary(); + foreach (var v in closedVorgaenge) + { + if (v.ClosedAt is not { } closedAt) continue; + var closedDate = DateOnly.FromDateTime(closedAt); + foreach (var domain in MatchDomains(v.Tags)) + if (!cutoffs.TryGetValue(domain, out var existing) || closedDate > existing) + cutoffs[domain] = closedDate; + } + return cutoffs; + } + + /// Gemeinsame Score-Formel für und : + /// liefert je Bereich das früheste zählende Datum (Standard + /// = alles zählt, = Bereich + /// bewusst ausgeklammert — so lässt sich derselbe Code für "gedämpfter Gesamt-Score" und + /// "nur die Bereiche eines einzelnen Vorgangs, nur danach" wiederverwenden). + private static double ComputeScore(IReadOnlyList yearEntries, + IReadOnlyList yearRegisterEntries, + Func cutoffFor, PatternScoreWeights weights) + { + var attendanceCutoff = cutoffFor(VorgangScoreDomain.Attendance); + var latenessCutoff = cutoffFor(VorgangScoreDomain.Lateness); + var classbookCutoff = cutoffFor(VorgangScoreDomain.Classbook); + var unexcused = yearEntries.Count(r => r.IsUnexcused && r.Date >= attendanceCutoff); + var excused = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate && r.Date >= attendanceCutoff); + var late = yearEntries.Count(r => !r.IsUnexcused && r.IsLate && r.Date >= latenessCutoff); + var classRegister = yearRegisterEntries + .Where(e => TryDate(e.Date, out var d) && d >= classbookCutoff) + .Sum(e => ClassRegisterEntryWeight(e, weights)); + return unexcused * weights.UnexcusedDayWeight + excused * weights.ExcusedDayWeight + + late * weights.LateDayWeight + classRegister; + } + public static IReadOnlyList Build( IReadOnlyList students, IReadOnlyList todayAbsences, @@ -193,8 +253,11 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b int schoolDaysElapsed = 0, int holidayWeekdaysExcluded = 0, DateOnly termStart = default, - IReadOnlyList? yearClassRegisterEntries = null) + IReadOnlyList? yearClassRegisterEntries = null, + PatternScoreWeights? weights = null, + IReadOnlyDictionary>? closedVorgaengeByNameKey = null) { + var effectiveWeights = weights ?? new PatternScoreWeights(); var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today); var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null) .GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First()); @@ -223,6 +286,24 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null) ?? yearByName.GetValueOrDefault(nameKey) ?? []; var yearRegisterEntries = yearRegisterByName.GetValueOrDefault(nameKey) ?? []; + + // Dämpfung/Rückfall (Nutzer-Entscheidung: bereichsbezogen statt kompletter Reset). + var closedVorgaenge = closedVorgaengeByNameKey?.GetValueOrDefault(nameKey) ?? []; + var dampeningCutoffs = ComputeDampeningCutoffs(closedVorgaenge); + var patternScore = ComputeScore(yearEntries, yearRegisterEntries, + d => dampeningCutoffs.GetValueOrDefault(d, DateOnly.MinValue), effectiveWeights); + + var mostRecentClosed = closedVorgaenge.Where(v => v.ClosedAt.HasValue) + .OrderByDescending(v => v.ClosedAt).FirstOrDefault(); + var regressionScore = 0.0; + if (mostRecentClosed is not null) + { + var relevantDomains = MatchDomains(mostRecentClosed.Tags); + var closedDate = DateOnly.FromDateTime(mostRecentClosed.ClosedAt!.Value); + regressionScore = ComputeScore(yearEntries, yearRegisterEntries, + d => relevantDomains.Contains(d) ? closedDate : DateOnly.MaxValue, effectiveWeights); + } + return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null, absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}", registerEntries.Count > 0) @@ -238,7 +319,10 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate), YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry), HasSuspensionEntry = yearRegisterEntries.Any(e => MatchesAnyKeyword(e, SuspensionKeywords)), - ClassRegisterScoreComponent = yearRegisterEntries.Sum(ClassRegisterEntryWeight), + ClassRegisterScoreComponent = yearRegisterEntries.Sum(e => ClassRegisterEntryWeight(e, effectiveWeights)), + PatternScore = patternScore, + RegressionScore = regressionScore, + RegressionVorgangTitle = mostRecentClosed?.Title, SchoolDaysElapsed = schoolDaysElapsed, HolidayWeekdaysExcluded = holidayWeekdaysExcluded, TermStart = termStart, @@ -317,6 +401,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject private readonly IWorkTaskRepository _workTasks; private readonly IStudentRepository _students; private readonly IDocumentationRepository _documentation; + private readonly IVorgangRepository _vorgaenge; + private readonly PatternScoreSettingsService _patternScoreSettings; private readonly IParticipationRepository _participation; private readonly IParticipationSessionRepository _participationSessions; private readonly AppLogger? _logger; @@ -405,7 +491,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis, UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks, - IStudentRepository students, IDocumentationRepository documentation, IParticipationRepository participation, + IStudentRepository students, IDocumentationRepository documentation, IVorgangRepository vorgaenge, + PatternScoreSettingsService patternScoreSettings, IParticipationRepository participation, IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab, ClassTeacherCasesViewModel casesTab, AppLogger? logger = null) { @@ -416,6 +503,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject _workTasks = workTasks; _students = students; _documentation = documentation; + _vorgaenge = vorgaenge; + _patternScoreSettings = patternScoreSettings; _participation = participation; _participationSessions = participationSessions; _logger = logger; @@ -487,9 +576,21 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed; var recentRegisterEntries = classRegisterTask.Result .Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList(); + // Geschlossene Vorgänge je Klassen-Roster-Namen (für die bereichsbezogene Score-Dämpfung/ + // Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden + // dieser Klasse (z.B. BuildAttendanceParticipationNotices). + var localStudents = _students.GetAll(); + var closedVorgaengeByNameKey = studentsTask.Result + .Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents))) + .Where(x => x.Student is not null) + .ToDictionary( + x => UntisNameMatching.NameKey(x.Roster.DisplayName), + x => (IReadOnlyList)_vorgaenge.GetByStudent(x.Student!.Id) + .Where(v => v.Status == VorgangStatus.Closed).ToList()); foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences, recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed, - holidayWeekdaysExcluded, termStart, classRegisterTask.Result)) Roster.Add(row); + holidayWeekdaysExcluded, termStart, classRegisterTask.Result, + _patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row); StudentCount = Roster.Count; TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); @@ -503,6 +604,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject BuildPatternNotices(absenceDaysYear, sevenDayStart); BuildWeekdayPatternNotices(absenceDaysYear); BuildAttendanceParticipationNotices(); + BuildVorgangRegressionNotices(); BuildOwnDocumentationCounts(); BuildOpenExcuses(absenceDaysYear, today); LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}"; @@ -835,6 +937,25 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject OnPropertyChanged(nameof(HasPatternNotices)); } + /// Schwelle für die "erneut auffällig"-Warnung nach einem geschlossenen Vorgang (Nutzer- + /// Entscheidung): erst ab einem spürbaren Muster, nicht schon bei der ersten Kleinigkeit nach + /// Abschluss — ≈ 1 unentschuldigter Tag oder vergleichbar ( + /// Default 3,0). + private const double RegressionThreshold = 3.0; + + private void BuildVorgangRegressionNotices() + { + foreach (var row in Roster.Where(r => r.RegressionScore >= RegressionThreshold && + r.RegressionVorgangTitle is not null)) + { + if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue; + PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName, + $"Nach Bearbeitung von \"{row.RegressionVorgangTitle}\" erneut auffällig", + ClassTeacherStatusKind.Danger, CanCreateReminder: true)); + } + OnPropertyChanged(nameof(HasPatternNotices)); + } + /// Nacharbeiten-/Kritisch-Zählung für die eigene Dokumentation der Klasse — bewusst nicht auf /// die letzten 7 Tage begrenzt wie : ein seit Wochen /// offener Entwurf oder ein als "Kritisch" markierter Eintrag soll nicht aus der Kennzahl diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/PatternScoreWeightsDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/PatternScoreWeightsDialogViewModel.cs new file mode 100644 index 0000000..da6ac55 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/PatternScoreWeightsDialogViewModel.cs @@ -0,0 +1,42 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Services; + +namespace LehrerApp.Desktop.ViewModels.ClassTeacher; + +/// Dialog zum Anpassen der für den "Gesamtbild"-Reiter +/// der Klassenlehreransicht — ursprünglich feste Konstanten, auf Nutzerwunsch jetzt einstellbar. +public partial class PatternScoreWeightsDialogViewModel : ObservableObject +{ + private readonly PatternScoreSettingsService _settings; + + [ObservableProperty] private double _unexcusedDayWeight; + [ObservableProperty] private double _excusedDayWeight; + [ObservableProperty] private double _lateDayWeight; + [ObservableProperty] private double _negativeClassRegisterEntryWeight; + [ObservableProperty] private double _concerningKeywordBonus; + [ObservableProperty] private double _suspensionKeywordWeight; + + public PatternScoreWeightsDialogViewModel(PatternScoreSettingsService settings) + { + _settings = settings; + var weights = settings.Load(); + UnexcusedDayWeight = weights.UnexcusedDayWeight; + ExcusedDayWeight = weights.ExcusedDayWeight; + LateDayWeight = weights.LateDayWeight; + NegativeClassRegisterEntryWeight = weights.NegativeClassRegisterEntryWeight; + ConcerningKeywordBonus = weights.ConcerningKeywordBonus; + SuspensionKeywordWeight = weights.SuspensionKeywordWeight; + } + + [RelayCommand] + private void Save() => _settings.Save(new PatternScoreWeights + { + UnexcusedDayWeight = UnexcusedDayWeight, + ExcusedDayWeight = ExcusedDayWeight, + LateDayWeight = LateDayWeight, + NegativeClassRegisterEntryWeight = NegativeClassRegisterEntryWeight, + ConcerningKeywordBonus = ConcerningKeywordBonus, + SuspensionKeywordWeight = SuspensionKeywordWeight, + }); +} diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml index 62de6c2..20abba8 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml @@ -227,7 +227,7 @@ - +