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 @@
-
+
-
+
diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml.cs b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml.cs
index 4de2158..388c59d 100644
--- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml.cs
+++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml.cs
@@ -1,8 +1,26 @@
using Avalonia.Controls;
+using Avalonia.Interactivity;
+using LehrerApp.Core.Services;
+using LehrerApp.Desktop.ViewModels.ClassTeacher;
+using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class ClassTeacherOverviewView : UserControl
{
public ClassTeacherOverviewView() => InitializeComponent();
+
+ private async void OnEditPatternScoreWeights(object? sender, RoutedEventArgs e)
+ {
+ var owner = TopLevel.GetTopLevel(this) as Window;
+ if (owner is null) return;
+
+ var vm = new PatternScoreWeightsDialogViewModel(App.Services.GetRequiredService());
+ var dialog = new PatternScoreWeightsDialog { DataContext = vm };
+ await dialog.ShowDialog(owner);
+
+ // Neue Gewichtung soll sofort in der "Gesamtbild"-Sortierung wirken, nicht erst beim
+ // nächsten manuellen Aktualisieren.
+ if (DataContext is ClassTeacherOverviewViewModel overviewVm) overviewVm.LoadCommand.Execute(null);
+ }
}
diff --git a/LehrerApp.Desktop/Views/ClassTeacher/PatternScoreWeightsDialog.axaml b/LehrerApp.Desktop/Views/ClassTeacher/PatternScoreWeightsDialog.axaml
new file mode 100644
index 0000000..f0f237d
--- /dev/null
+++ b/LehrerApp.Desktop/Views/ClassTeacher/PatternScoreWeightsDialog.axaml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/ClassTeacher/PatternScoreWeightsDialog.axaml.cs b/LehrerApp.Desktop/Views/ClassTeacher/PatternScoreWeightsDialog.axaml.cs
new file mode 100644
index 0000000..2851c13
--- /dev/null
+++ b/LehrerApp.Desktop/Views/ClassTeacher/PatternScoreWeightsDialog.axaml.cs
@@ -0,0 +1,16 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using LehrerApp.Desktop.ViewModels.ClassTeacher;
+
+namespace LehrerApp.Desktop.Views.ClassTeacher;
+
+public partial class PatternScoreWeightsDialog : Window
+{
+ public PatternScoreWeightsDialog() => InitializeComponent();
+
+ private void OnClose(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is PatternScoreWeightsDialogViewModel vm) vm.SaveCommand.Execute(null);
+ Close();
+ }
+}
diff --git a/TODO.md b/TODO.md
index f6eed98..d443820 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1897,10 +1897,38 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
Konstruktor-Callback-Muster wie `onLink`/`onUnlink`), `ClassTeacherCasesViewModel.EditDocumentation`
speichert das Ergebnis direkt über `IDocumentationRepository`, unabhängig davon, welche Vorgänge
den Eintrag verlinken.
- **Bewusst zurückgestellt ("Runde 2", Nutzer-Wunsch):** dass ein bearbeiteter/geschlossener Vorgang
- automatisch den `PatternScore` dämpft, sowie Trend-Indikatoren ("bewegt sich wieder in die
- falsche Richtung", z.B. Fehlzeiten oder Konflikt-Einträge nehmen erneut zu) — reines Anlegen/
- Verknüpfen/Verwalten in dieser Runde, keine Rückwirkung auf Score oder Sortierung.
+ **"Runde 2" (September 2026, direkt im Anschluss umgesetzt):** einstellbare Gewichte plus
+ Score-Dämpfung nach geschlossenem Vorgang inkl. Rückfall-Warnung — siehe eigener Eintrag unten.
+- [x] **"Klassenlehrer"-Feature — konfigurierbare Gewichtung + Score-Dämpfung/Rückfall-Warnung nach
+ geschlossenem Vorgang (September 2026, "Runde 2"):**
+ - **Gewichtung:** `PatternScoreWeights`/`PatternScoreSettingsService`
+ (`LehrerApp.Core/Services/PatternScoreSettingsService.cs`, gleiches JSON-in-appData-Muster wie
+ `WorkloadSettingsService`/`DashboardSettingsService`) ersetzen die bisherigen festen Konstanten.
+ Neuer Dialog `PatternScoreWeightsDialog` (Vorbild `ParticipationAspectsDialog`: `NumericUpDown`
+ pro Gewicht), erreichbar über einen neuen "⚙ Gewichtung"-Button neben dem "Gesamtbild"-Filter;
+ beim Schließen wird `LoadCommand` erneut ausgeführt, damit die neue Gewichtung sofort wirkt.
+ - **Dämpfung (Nutzer-Entscheidung: bereichsbezogen statt komplett):** die Schlagwörter eines
+ geschlossenen Vorgangs bestimmen per Schlüsselwort-Erkennung (`ClassTeacherRosterRow.MatchDomains`,
+ gleiche Contains-Konvention wie die bestehende "Negativ"/"verspät"-Erkennung), welcher der drei
+ `VorgangScoreDomain`-Bereiche (Attendance/Lateness/Classbook) gedämpft wird — Ereignisse vor dem
+ Schließ-Datum zählen ab dann nicht mehr in `PatternScore`. Kein Schlagwort erkannt (z.B. reines
+ "Elternkontakt") → vorsichtshalber alle drei Bereiche gedämpft, damit ein geschlossener Vorgang
+ nie folgenlos bleibt. **Bewusst unverändert:** die faktische "% Fehlzeit"-Quote
+ (`YearAbsenceDayCount`/`YearAbsenceRatePercent`) — nur die `PatternScore`-Sortierung wird
+ gedämpft, nicht die für Zeugnis/Attestpflicht relevante Historie.
+ - **Rückfall-Warnung:** neues `RegressionScore`/`RegressionVorgangTitle` je Roster-Zeile — der
+ Score-Anteil, der seit dem Schließ-Datum des zuletzt geschlossenen Vorgangs (nur in dessen
+ eigenen Bereichen) neu hinzugekommen ist. `ClassTeacherOverviewViewModel.BuildVorgangRegressionNotices`
+ (Vorbild `BuildAttendanceParticipationNotices`) meldet ab einer Schwelle von 3,0 (≈ 1
+ unentschuldigter Tag, Nutzer-Entscheidung: "erst ab spürbarem Muster", nicht bei jeder
+ Kleinigkeit) einen Danger-Hinweis "Nach Bearbeitung von '{Titel}' erneut auffällig" in die
+ bestehende "Muster erkannt"-Seitenleiste, inkl. vorhandenem "+"-Wiedervorlage-Button — keine
+ neue UI dafür nötig.
+ - `ClassTeacherRosterRow.Build` bekam dafür zwei weitere optionale Parameter (`weights`,
+ `closedVorgaengeByNameKey`, beide mit Default = heutiges Verhalten, volle Rückwärtskompatibilität
+ für bestehende Tests/Aufrufer). `ClassTeacherOverviewViewModel.Load` löst die geschlossenen
+ Vorgänge je Klassen-Roster-Namen über denselben Namensabgleich wie die übrigen `Build*`-Methoden
+ dieser Klasse auf (`IVorgangRepository.GetByStudent`).
### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3