Gesamtbild: konfigurierbare Gewichtung, Dämpfung nach Vorgang, Rückfall-Warnung
CI / build-and-test (push) Canceled after 0s
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:
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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“"
|
||||||
|
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“" 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“-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“-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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user