Gesamtbild: konfigurierbare Gewichtung, Dämpfung nach Vorgang, Rückfall-Warnung
CI / build-and-test (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 23:52:38 +02:00
co-authored by Claude Sonnet 5
parent 0d5fb6d146
commit 216708e879
10 changed files with 543 additions and 36 deletions
@@ -0,0 +1,40 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
/// <summary>Gewichte für ClassTeacherRosterRow.PatternScore (Klassenlehrer-Bereich, "Gesamtbild")
/// — Default-Werte entsprechen den ursprünglich fest verdrahteten Konstanten.</summary>
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;
}
/// <summary>Speichert die Gewichte lokal, gleiches Muster wie WorkloadSettingsService/
/// DashboardSettingsService.</summary>
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<PatternScoreWeights>(File.ReadAllText(_configPath))
?? new PatternScoreWeights();
}
catch { /* beschädigte Konfiguration -> Standardgewichte */ }
return new PatternScoreWeights();
}
public void Save(PatternScoreWeights weights) =>
File.WriteAllText(_configPath, JsonSerializer.Serialize(weights));
}
@@ -1,4 +1,5 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher; using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Xunit; using Xunit;
@@ -889,4 +890,193 @@ public sealed class ClassTeacherViewModelsTests
Assert.Empty(result); 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<Vorgang>
{
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<Vorgang> { 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<string, IReadOnlyList<Vorgang>>
{
[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<string, IReadOnlyList<Vorgang>>
{
[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<string, IReadOnlyList<Vorgang>>
{
[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<string, IReadOnlyList<Vorgang>>
{
[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);
}
} }
+1
View File
@@ -196,6 +196,7 @@ public static class AppBootstrapper
services.AddSingleton(_ => new PeriodScheduleService(appData)); services.AddSingleton(_ => new PeriodScheduleService(appData));
services.AddSingleton(_ => new WorkloadSettingsService(appData)); services.AddSingleton(_ => new WorkloadSettingsService(appData));
services.AddSingleton(_ => new DashboardSettingsService(appData)); services.AddSingleton(_ => new DashboardSettingsService(appData));
services.AddSingleton(_ => new PatternScoreSettingsService(appData));
services.AddSingleton(_ => new WindowSettingsService(appData)); services.AddSingleton(_ => new WindowSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData)); services.AddSingleton(_ => new AppearanceSettingsService(appData));
services.AddSingleton<PlanningExchangeService>(); services.AddSingleton<PlanningExchangeService>();
@@ -14,6 +14,10 @@ namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary> /// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger } public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
/// <summary>Die drei Auffälligkeits-Achsen, die ein geschlossener <see cref="Vorgang"/> per
/// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary>
public enum VorgangScoreDomain { Attendance, Lateness, Classbook }
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday, public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
string? AbsenceTooltip, bool HasRecentClassRegisterEntry) string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
{ {
@@ -130,22 +134,6 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
(HasSuspensionEntry ? " · Suspendierung vermerkt" : "") + (HasSuspensionEntry ? " · Suspendierung vermerkt" : "") +
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : ""); (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[] ConcerningKeywords = ["hausaufgabe", "mitarbeit"];
private static readonly string[] SuspensionKeywords = ["suspendier"]; 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 /// zusätzliche Komplexität einer Verfallskurve. Bezieht neben Fehlzeiten auch Klassenbucheinträge
/// anderer Lehrkräfte mit ein (<see cref="ClassRegisterScoreComponent"/>) — beides sind /// anderer Lehrkräfte mit ein (<see cref="ClassRegisterScoreComponent"/>) — beides sind
/// unabhängige Auffälligkeits-Achsen (Anwesenheit vs. Verhalten/Leistung), die sich addieren /// unabhängige Auffälligkeits-Achsen (Anwesenheit vs. Verhalten/Leistung), die sich addieren
/// statt sich gegenseitig zu verdrängen. /// statt sich gegenseitig zu verdrängen. Gewichte sind einstellbar (<see cref="PatternScoreWeights"/>,
public double PatternScore => /// Nutzer-Entscheidung nach anfänglich festen Konstanten) und die Bereiche eines geschlossenen
YearUnexcusedDayCount * UnexcusedDayWeight + /// <see cref="Vorgang"/> sind ab dessen Schließ-Datum aus dem Score ausgeklammert (<see cref="Build"/>),
YearExcusedDayCount * ExcusedDayWeight + /// deshalb jetzt ein zur Build-Zeit berechneter Wert statt einer reinen Formel über die
YearLateDayCount * LateDayWeight + /// (unveränderten, für die Quote weiter vollständigen) Year*-Zähler.
ClassRegisterScoreComponent; 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
/// (<see cref="MatchDomains"/>) — 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) => private static bool IsLateReason(string reason) =>
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase); 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.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) == true ||
e.Text?.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; var weight = 0.0;
if (IsNegativeClassRegisterEntry(e)) weight += NegativeClassRegisterEntryWeight; if (IsNegativeClassRegisterEntry(e)) weight += weights.NegativeClassRegisterEntryWeight;
if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += ConcerningKeywordBonus; if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += weights.ConcerningKeywordBonus;
if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += SuspensionKeywordWeight; if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += weights.SuspensionKeywordWeight;
return weight; 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<VorgangScoreDomain> MatchDomains(IReadOnlyList<string> tags)
{
var domains = new HashSet<VorgangScoreDomain>();
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 <see cref="PatternScore"/>, die volle
/// Historie (Year*-Zähler, Quote) bleibt davon unberührt.
public static IReadOnlyDictionary<VorgangScoreDomain, DateOnly> ComputeDampeningCutoffs(
IReadOnlyList<Vorgang> closedVorgaenge)
{
var cutoffs = new Dictionary<VorgangScoreDomain, DateOnly>();
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 <see cref="PatternScore"/> und <see cref="RegressionScore"/>:
/// <paramref name="cutoffFor"/> liefert je Bereich das früheste zählende Datum (Standard
/// <see cref="DateOnly.MinValue"/> = alles zählt, <see cref="DateOnly.MaxValue"/> = 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<ClassAbsenceDaySummaryRow> yearEntries,
IReadOnlyList<UntisForeignClassRegisterEventDto> yearRegisterEntries,
Func<VorgangScoreDomain, DateOnly> 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<ClassTeacherRosterRow> Build( public static IReadOnlyList<ClassTeacherRosterRow> Build(
IReadOnlyList<UntisStudentRosterCacheEntry> students, IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences, IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
@@ -193,8 +253,11 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
int schoolDaysElapsed = 0, int schoolDaysElapsed = 0,
int holidayWeekdaysExcluded = 0, int holidayWeekdaysExcluded = 0,
DateOnly termStart = default, DateOnly termStart = default,
IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null) IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null,
PatternScoreWeights? weights = null,
IReadOnlyDictionary<string, IReadOnlyList<Vorgang>>? closedVorgaengeByNameKey = null)
{ {
var effectiveWeights = weights ?? new PatternScoreWeights();
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today); var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null) var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First()); .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) var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
?? yearByName.GetValueOrDefault(nameKey) ?? []; ?? yearByName.GetValueOrDefault(nameKey) ?? [];
var yearRegisterEntries = yearRegisterByName.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, return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}", absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
registerEntries.Count > 0) registerEntries.Count > 0)
@@ -238,7 +319,10 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate), YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate),
YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry), YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry),
HasSuspensionEntry = yearRegisterEntries.Any(e => MatchesAnyKeyword(e, SuspensionKeywords)), 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, SchoolDaysElapsed = schoolDaysElapsed,
HolidayWeekdaysExcluded = holidayWeekdaysExcluded, HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
TermStart = termStart, TermStart = termStart,
@@ -317,6 +401,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
private readonly IWorkTaskRepository _workTasks; private readonly IWorkTaskRepository _workTasks;
private readonly IStudentRepository _students; private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation; private readonly IDocumentationRepository _documentation;
private readonly IVorgangRepository _vorgaenge;
private readonly PatternScoreSettingsService _patternScoreSettings;
private readonly IParticipationRepository _participation; private readonly IParticipationRepository _participation;
private readonly IParticipationSessionRepository _participationSessions; private readonly IParticipationSessionRepository _participationSessions;
private readonly AppLogger? _logger; private readonly AppLogger? _logger;
@@ -405,7 +491,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis, public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks, 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, IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
ClassTeacherCasesViewModel casesTab, AppLogger? logger = null) ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
{ {
@@ -416,6 +503,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
_workTasks = workTasks; _workTasks = workTasks;
_students = students; _students = students;
_documentation = documentation; _documentation = documentation;
_vorgaenge = vorgaenge;
_patternScoreSettings = patternScoreSettings;
_participation = participation; _participation = participation;
_participationSessions = participationSessions; _participationSessions = participationSessions;
_logger = logger; _logger = logger;
@@ -487,9 +576,21 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed; var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
var recentRegisterEntries = classRegisterTask.Result var recentRegisterEntries = classRegisterTask.Result
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList(); .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<Vorgang>)_vorgaenge.GetByStudent(x.Student!.Id)
.Where(v => v.Status == VorgangStatus.Closed).ToList());
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences, foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed, recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
holidayWeekdaysExcluded, termStart, classRegisterTask.Result)) Roster.Add(row); holidayWeekdaysExcluded, termStart, classRegisterTask.Result,
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
StudentCount = Roster.Count; StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
@@ -503,6 +604,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
BuildPatternNotices(absenceDaysYear, sevenDayStart); BuildPatternNotices(absenceDaysYear, sevenDayStart);
BuildWeekdayPatternNotices(absenceDaysYear); BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices(); BuildAttendanceParticipationNotices();
BuildVorgangRegressionNotices();
BuildOwnDocumentationCounts(); BuildOwnDocumentationCounts();
BuildOpenExcuses(absenceDaysYear, today); BuildOpenExcuses(absenceDaysYear, today);
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}"; LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
@@ -835,6 +937,25 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
OnPropertyChanged(nameof(HasPatternNotices)); 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 (<see cref="PatternScoreWeights.UnexcusedDayWeight"/>
/// 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 /// Nacharbeiten-/Kritisch-Zählung für die eigene Dokumentation der Klasse — bewusst nicht auf
/// die letzten 7 Tage begrenzt wie <see cref="RecentClassRegisterCount"/>: ein seit Wochen /// die letzten 7 Tage begrenzt wie <see cref="RecentClassRegisterCount"/>: ein seit Wochen
/// offener Entwurf oder ein als "Kritisch" markierter Eintrag soll nicht aus der Kennzahl /// offener Entwurf oder ein als "Kritisch" markierter Eintrag soll nicht aus der Kennzahl
@@ -0,0 +1,42 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>Dialog zum Anpassen der <see cref="PatternScoreWeights"/> für den "Gesamtbild"-Reiter
/// der Klassenlehreransicht — ursprünglich feste Konstanten, auf Nutzerwunsch jetzt einstellbar.</summary>
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,
});
}
@@ -227,7 +227,7 @@
<Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}" <Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="8" Padding="8" Margin="0,0,0,4"> CornerRadius="8" Padding="8" Margin="0,0,0,4">
<Grid ColumnDefinitions="Auto,Auto,Auto,Auto,*,250" ColumnSpacing="8"> <Grid ColumnDefinitions="Auto,Auto,Auto,Auto,Auto,*,250" ColumnSpacing="8">
<Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}" <Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}"
Content="Auffällig" Command="{Binding ShowAlertsCommand}"/> Content="Auffällig" Command="{Binding ShowAlertsCommand}"/>
<Button Grid.Column="1" Classes="filter" Classes.active="{Binding ClassRegisterFilterSelected}" <Button Grid.Column="1" Classes="filter" Classes.active="{Binding ClassRegisterFilterSelected}"
@@ -243,7 +243,10 @@
<Button Grid.Column="3" Classes="filter" Classes.active="{Binding PatternScoreFilterSelected}" <Button Grid.Column="3" Classes="filter" Classes.active="{Binding PatternScoreFilterSelected}"
Content="Gesamtbild" Command="{Binding ShowPatternScoreCommand}" Content="Gesamtbild" Command="{Binding ShowPatternScoreCommand}"
ToolTip.Tip="Sortiert nach Auffälligkeits-Score seit Schuljahresbeginn (unentschuldigt zählt am stärksten, Verspätung am wenigsten) statt nur nach dem heutigen Status."/> ToolTip.Tip="Sortiert nach Auffälligkeits-Score seit Schuljahresbeginn (unentschuldigt zählt am stärksten, Verspätung am wenigsten) statt nur nach dem heutigen Status."/>
<TextBox Grid.Column="5" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Schüler*in suchen…" <Button Grid.Column="4" Content="⚙ Gewichtung" FontSize="12" Padding="10,4"
Background="Transparent" Click="OnEditPatternScoreWeights"
ToolTip.Tip="Legt fest, wie stark einzelne Ereignisse in den Auffälligkeits-Score der 'Gesamtbild'-Sortierung einfließen."/>
<TextBox Grid.Column="6" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Schüler*in suchen…"
FontSize="12" MinHeight="32"/> FontSize="12" MinHeight="32"/>
</Grid> </Grid>
</Border> </Border>
@@ -1,8 +1,26 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.ClassTeacher; namespace LehrerApp.Desktop.Views.ClassTeacher;
public partial class ClassTeacherOverviewView : UserControl public partial class ClassTeacherOverviewView : UserControl
{ {
public ClassTeacherOverviewView() => InitializeComponent(); 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<PatternScoreSettingsService>());
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);
}
} }
@@ -0,0 +1,48 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.PatternScoreWeightsDialog"
x:DataType="vm:PatternScoreWeightsDialogViewModel"
Title="Gewichtung „Gesamtbild&#8220;"
Width="440" SizeToContent="Height" MinHeight="300"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="12">
<TextBlock Text="Gewichtung „Gesamtbild&#8220;" Classes="dialogtitle"/>
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.6"
Text="Bestimmt, wie stark einzelne Ereignisse in den Auffälligkeits-Score einfließen, nach dem der „Gesamtbild&#8220;-Reiter sortiert. Wirkt erst nach erneutem Laden der Übersicht."/>
<Grid ColumnDefinitions="*,110" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="10">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Unentschuldigt (pro Tag)" VerticalAlignment="Center"/>
<NumericUpDown Grid.Row="0" Grid.Column="1" Value="{Binding UnexcusedDayWeight}"
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Entschuldigt abwesend (pro Tag)" VerticalAlignment="Center"/>
<NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding ExcusedDayWeight}"
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="Nur verspätet (pro Tag)" VerticalAlignment="Center"/>
<NumericUpDown Grid.Row="2" Grid.Column="1" Value="{Binding LateDayWeight}"
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="Negativer Klassenbucheintrag" VerticalAlignment="Center"
TextWrapping="Wrap"/>
<NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding NegativeClassRegisterEntryWeight}"
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="+ Hausaufgaben-/Mitarbeit-Schlüsselwort" VerticalAlignment="Center"
TextWrapping="Wrap"/>
<NumericUpDown Grid.Row="4" Grid.Column="1" Value="{Binding ConcerningKeywordBonus}"
Minimum="0" Maximum="50" FormatString="0.0" Increment="0.1" ShowButtonSpinner="False"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="+ „Suspendiert&#8220;-Schlüsselwort" VerticalAlignment="Center"
TextWrapping="Wrap"/>
<NumericUpDown Grid.Row="5" Grid.Column="1" Value="{Binding SuspensionKeywordWeight}"
Minimum="0" Maximum="100" FormatString="0.0" Increment="0.5" ShowButtonSpinner="False"/>
</Grid>
</StackPanel>
<Button Grid.Row="1" Content="Fertig" HorizontalAlignment="Right" Margin="0,20,0,0" Click="OnClose"/>
</Grid>
</Window>
@@ -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();
}
}
+32 -4
View File
@@ -1897,10 +1897,38 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
Konstruktor-Callback-Muster wie `onLink`/`onUnlink`), `ClassTeacherCasesViewModel.EditDocumentation` Konstruktor-Callback-Muster wie `onLink`/`onUnlink`), `ClassTeacherCasesViewModel.EditDocumentation`
speichert das Ergebnis direkt über `IDocumentationRepository`, unabhängig davon, welche Vorgänge speichert das Ergebnis direkt über `IDocumentationRepository`, unabhängig davon, welche Vorgänge
den Eintrag verlinken. den Eintrag verlinken.
**Bewusst zurückgestellt ("Runde 2", Nutzer-Wunsch):** dass ein bearbeiteter/geschlossener Vorgang **"Runde 2" (September 2026, direkt im Anschluss umgesetzt):** einstellbare Gewichte plus
automatisch den `PatternScore` dämpft, sowie Trend-Indikatoren ("bewegt sich wieder in die Score-Dämpfung nach geschlossenem Vorgang inkl. Rückfall-Warnung — siehe eigener Eintrag unten.
falsche Richtung", z.B. Fehlzeiten oder Konflikt-Einträge nehmen erneut zu) — reines Anlegen/ - [x] **"Klassenlehrer"-Feature — konfigurierbare Gewichtung + Score-Dämpfung/Rückfall-Warnung nach
Verknüpfen/Verwalten in dieser Runde, keine Rückwirkung auf Score oder Sortierung. 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 ### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3