diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index e293479..8b1c354 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -509,11 +509,14 @@ public sealed class ClassTeacherViewModelsTests termStart: new DateOnly(2026, 8, 1))); Assert.True(row.HasYearSummary); - Assert.Equal(2, row.YearAbsenceDayCount); + Assert.Equal(1, row.YearAbsenceDayCount); Assert.Equal(1, row.YearUnexcusedDayCount); - Assert.Equal(10, row.YearAbsenceRatePercent); - Assert.Contains("10 %", row.YearSummaryLabel); - Assert.Contains("2 von 20", row.YearSummaryTooltip); + Assert.Equal(2, row.YearAbsencePeriodCount); + Assert.Equal(2, row.YearExcusedPeriodCount); + Assert.Equal(5, row.YearAbsenceRatePercent); + Assert.Contains("5 %", row.YearSummaryLabel); + Assert.Contains("1 von 20", row.YearSummaryTooltip); + Assert.Contains("2 Fehlstunden", row.YearSummaryTooltip); Assert.Contains("seit 01.08.", row.YearSummaryTooltip); Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip); } @@ -577,24 +580,30 @@ public sealed class ClassTeacherViewModelsTests } [Fact] - public void RosterBuild_UnentschuldigtUndVerspaetetZaehltAlsUnentschuldigtNichtAlsVerspaetung() + public void RosterBuild_UnentschuldigteFehlstundeZaehltNichtAlsGanzerFehltag() { - // Wer unentschuldigt UND verspätet ist, bleibt der schwerwiegendere Fall (dieselbe - // Rangfolge wie AttentionRank/StatusKind/BuildTrend: Unentschuldigt vor Verspätet) — zählt - // deshalb weiter voll in die Quote statt in YearLateDayCount zu verschwinden. + // Kernfall: Eine Schülerin fehlt aus Protest eine Unterrichtsstunde. Das bleibt + // unentschuldigt und score-relevant, darf im Zeugnis-/Jahresstand aber nicht als ganzer + // unentschuldigter Fehltag erscheinen. var students = new[] { Student(1001, "Ada Müller") }; var yearAbsences = new[] { - new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 0, 60, - ["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 1, 45, + ["Deu"], [1], ["nicht entsch."], ["Absent"], "Aus Protest gegangen", null, false), }; var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1))); - Assert.Equal(1, row.YearAbsenceDayCount); - Assert.Equal(1, row.YearUnexcusedDayCount); + Assert.Equal(0, row.YearAbsenceDayCount); + Assert.Equal(0, row.YearUnexcusedDayCount); + Assert.Equal(1, row.YearAbsencePeriodCount); + Assert.Equal(1, row.YearUnexcusedPeriodCount); Assert.Equal(0, row.YearLateDayCount); + Assert.Equal(0, row.YearAbsenceRatePercent); + Assert.Contains("1 Fehlstunde", row.YearSummaryLabel); + Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip); + Assert.Equal(0.5, row.PatternScore, precision: 10); // 1 von typischerweise 6 Stunden × Tagesgewicht 3 } [Fact] @@ -754,7 +763,8 @@ public sealed class ClassTeacherViewModelsTests var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 10)); - Assert.Equal(1, row.YearAbsenceDayCount); + Assert.Equal(0, row.YearAbsenceDayCount); + Assert.Equal(2, row.YearAbsencePeriodCount); } [Fact] @@ -963,9 +973,9 @@ public sealed class ClassTeacherViewModelsTests var absences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1001, 1, 45, - ["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false), + ["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, true), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Fehlt Cem", 1001, 1, 45, - ["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false), + ["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, true), }; var names = new Dictionary { [UntisNameMatching.NameKey("Fehlt Cem")] = "Cem Fehlt" }; @@ -978,6 +988,26 @@ public sealed class ClassTeacherViewModelsTests Assert.False(notice.CanCreateReminder); } + [Fact] + public void DetectLatePatterns_StundenweisesUnentschuldigtesFehlenBleibtEigeneWarnstufe() + { + var absences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 21), "Protest Ada", 1001, 1, 45, + ["Deu"], [2], ["nicht entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 24), "Protest Ada", 1001, 1, 45, + ["Mat"], [4], ["nicht entsch."], ["Absent"], null, null, false), + }; + var names = new Dictionary + { [UntisNameMatching.NameKey("Protest Ada")] = "Ada Protest" }; + + var notice = Assert.Single(ClassTeacherOverviewViewModel.DetectLatePatterns( + absences, names, sevenDayStart: new DateOnly(2026, 9, 20))); + + Assert.Contains("stundenweise unentschuldigte Fehlzeiten", notice.Message); + Assert.Equal(ClassTeacherStatusKind.Warning, notice.Kind); + } + // ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ────────────────── [Fact] diff --git a/LehrerApp.Desktop.Tests/VorgangBausteineTests.cs b/LehrerApp.Desktop.Tests/VorgangBausteineTests.cs index 780442c..0f1f0b1 100644 --- a/LehrerApp.Desktop.Tests/VorgangBausteineTests.cs +++ b/LehrerApp.Desktop.Tests/VorgangBausteineTests.cs @@ -83,10 +83,12 @@ public sealed class VorgangBausteineTests Assert.Equal(8, facts.AbsentPeriods); Assert.Equal(1, facts.UnexcusedDays); + Assert.Equal(2, facts.PartialAbsentPeriods); + Assert.Equal(0, facts.UnexcusedPartialPeriods); Assert.Single(facts.LateDays); Assert.Equal(1, facts.NegativeRegisterCount); Assert.Single(facts.HomeworkEntries); - Assert.Contains("8 Fehlstunden an 2 Tagen", facts.AbsenceShort); + Assert.Contains("1 ganzer Fehltag und 2 weitere Fehlstunden", facts.AbsenceShort); var labels = facts.QuickBlocks(withName: true).Select(b => b.Label).ToList(); Assert.Contains("Fehlzeiten · Beispiel Anna", labels); @@ -98,6 +100,23 @@ public sealed class VorgangBausteineTests Assert.StartsWith("Sehr geehrte Erziehungsberechtigte von Anna Beispiel", letter.ParentLetterData!.DraftContent); } + [Fact] + public void KlassenlehrerStand_UnentschuldigteFehlstundeWirdNichtAlsFehltagBezeichnet() + { + var student = new StudentOption(Guid.NewGuid(), "Beispiel Anna"); + var today = new DateOnly(2026, 9, 28); + var facts = ClassTeacherStudentFacts.Build(student, "Anna Beispiel", "2026/27", today, + [Absence(today.AddDays(-4), "Anna Beispiel", 1, unexcused: true)], []); + + Assert.Equal(0, facts.FullDays); + Assert.Equal(0, facts.UnexcusedDays); + Assert.Equal(1, facts.PartialAbsentPeriods); + Assert.Equal(1, facts.UnexcusedPartialPeriods); + Assert.Contains("0 ganze Fehltage und 1 weitere Fehlstunde", facts.AbsenceShort); + Assert.Contains("unentschuldigt: 0 ganze Tage, 1 Fehlstunde", facts.AbsenceShort); + Assert.Contains("1 Fehlstunde · Unentschuldigt", facts.AbsenceBlock); + } + [Fact] public void Vorlage_KlassenleitungInformieren() { diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCaseFacts.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCaseFacts.cs index 6bbb4e4..2f4ef10 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCaseFacts.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherCaseFacts.cs @@ -22,7 +22,10 @@ public sealed record ClassTeacherStudentFacts(StudentOption Student, string Natu public int AbsentPeriods => AbsenceDays.Sum(d => d.TotalAbsentPeriods); public int FullDays => AbsenceDays.Count(d => d.CountsAsFullDay); - public int UnexcusedDays => AbsenceDays.Count(d => d.IsUnexcused); + public int UnexcusedDays => AbsenceDays.Count(d => d.CountsAsFullDay && d.IsUnexcused); + public int PartialAbsentPeriods => AbsenceDays.Where(d => !d.CountsAsFullDay).Sum(d => d.TotalAbsentPeriods); + public int UnexcusedPartialPeriods => AbsenceDays + .Where(d => !d.CountsAsFullDay && d.IsUnexcused).Sum(d => d.TotalAbsentPeriods); public int NegativeRegisterCount => RegisterEntries.Count(r => r.IsDangerStatus); public IReadOnlyList HomeworkEntries => RegisterEntries .Where(r => ($"{r.CategoryName} {r.Text}").Contains("Hausauf", StringComparison.OrdinalIgnoreCase)).ToList(); @@ -30,15 +33,17 @@ public sealed record ClassTeacherStudentFacts(StudentOption Student, string Natu private string Stand => $"WebUntis-Stand {AsOf:dd.MM.yyyy}"; public string AbsenceShort => - $"{AbsentPeriods} Fehlstunde{(AbsentPeriods == 1 ? "" : "n")} an {AbsenceDays.Count} Tag{(AbsenceDays.Count == 1 ? "" : "en")} " + - $"im Schuljahr {SchoolYear} (davon {UnexcusedDays} Tag{(UnexcusedDays == 1 ? "" : "e")} unentschuldigt, " + - $"{FullDays} ganze Fehltag{(FullDays == 1 ? "" : "e")})"; + $"{FullDayLabel(FullDays)} und " + + $"{PartialAbsentPeriods} weitere Fehlstunde{(PartialAbsentPeriods == 1 ? "" : "n")} " + + $"an {AbsenceDays.Count} Tag{(AbsenceDays.Count == 1 ? "" : "en")} im Schuljahr {SchoolYear} " + + $"(unentschuldigt: {FullDayCountLabel(UnexcusedDays)}, " + + $"{UnexcusedPartialPeriods} Fehlstunde{(UnexcusedPartialPeriods == 1 ? "" : "n")})"; public string LatenessShort => $"{LateDays.Count} Verspätung{(LateDays.Count == 1 ? "" : "en")} im Schuljahr {SchoolYear}"; public string RegisterShort => $"{RegisterEntries.Count} Klassenbucheintr{(RegisterEntries.Count == 1 ? "ag" : "äge")} im Schuljahr {SchoolYear}, davon {NegativeRegisterCount} negativ"; public string HomeworkShort => $"{HomeworkEntries.Count} Klassenbucheintr{(HomeworkEntries.Count == 1 ? "ag" : "äge")} zu Hausaufgaben im Schuljahr {SchoolYear}"; - public string AbsenceBlock => $"{Stand}: {AbsenceShort}.\n" + List("Fehltage", AbsenceDays.Select(d => + public string AbsenceBlock => $"{Stand}: {AbsenceShort}.\n" + List("Fehlzeiten", AbsenceDays.Select(d => $"{d.DateLabel}: {d.KindLabel} · {d.FriendlyStatusLabel}" + (string.IsNullOrWhiteSpace(d.DetailLabel) ? "" : $" · {d.DetailLabel}")).ToList()); public string LatenessBlock => $"{Stand}: {LatenessShort}.\n" + List("Termine", LateDays.Select(d => @@ -50,6 +55,12 @@ public sealed record ClassTeacherStudentFacts(StudentOption Student, string Natu $"{r.DateLabel}" + (string.IsNullOrWhiteSpace(r.Subject) ? "" : $" · {r.Subject}") + ": " + string.Join(" – ", new[] { r.CategoryName, r.Text }.Where(v => !string.IsNullOrWhiteSpace(v))); + private static string FullDayLabel(int count) => + count == 1 ? "1 ganzer Fehltag" : $"{count} ganze Fehltage"; + + private static string FullDayCountLabel(int count) => + count == 1 ? "1 ganzer Tag" : $"{count} ganze Tage"; + private static string List(string header, IReadOnlyList lines) { var sb = new StringBuilder($"{header}:"); diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs index 0b4fc45..ce57eeb 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs @@ -66,6 +66,11 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName IReadOnlyList PeriodNumbers, IReadOnlyList Statuses, IReadOnlyList AbsenceReasons, string? Note, string? ExcuseNote, bool CountsAsFullDay) { + /// Nur für die Gewichtung von stundenweisen Fehlzeiten im Auffälligkeitsscore. Zeugnis- und + /// Anzeigezahlen verwenden weiterhin die echten WebUntis-Felder CountsAsFullDay bzw. + /// TotalAbsentPeriods und rechnen keine Stunden künstlich in Fehltage um. + public const int TypicalPeriodsPerSchoolDayForScoring = 6; + public string DateLabel => Date.ToString("dd.MM.yyyy"); public string StudentDisplayName { @@ -83,6 +88,11 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName r.Contains("verspät", StringComparison.OrdinalIgnoreCase)); public bool IsUnexcused => Statuses.Any(s => s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase)); + public bool IsPartialAbsence => !CountsAsFullDay && (!IsLate || IsUnexcused); + public double AbsenceDayEquivalentForScoring => IsLate && !IsUnexcused ? 0d : CountsAsFullDay ? 1d : + Math.Min(1d, (TotalAbsentPeriods > 0 + ? TotalAbsentPeriods + : TotalAbsentMinutes / 45d) / TypicalPeriodsPerSchoolDayForScoring); public string KindLabel => IsLate ? $"{TotalAbsentMinutes} Min. verspätet" : CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}"; public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" : diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index c60126d..175f2d3 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -81,21 +81,22 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b /// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein /// sagt für Zeugnis/Attestpflicht wenig aus). zählt Werktage /// abzüglich der über WebUntis geladenen Ferien (). - /// Zählt bewusst NUR echte Fehltage (unentschuldigt oder entschuldigt abwesend), keine reinen - /// Verspätungstage mehr — Nutzer-Feedback: die Quote behandelte "10× 5 Min. verspätet" bisher - /// exakt wie "10× ganztägig unentschuldigt gefehlt", obwohl beides für die eigene Reaktion - /// (Elterngespräch vs. Achselzucken) grundverschieden ist. Reine Verspätung landet stattdessen - /// separat in (gleiche Kategorisierung wie schon länger im - /// Wochentrend, siehe : Unentschuldigt vor - /// Verspätet vor Entschuldigt, überschneidungsfrei). + /// Zählt bewusst NUR von WebUntis als ganze Fehltage gekennzeichnete Tage. Stundenweises + /// Fehlen bleibt davon getrennt in — auch wenn es + /// unentschuldigt ist. Reine Verspätung landet separat in . public int YearAbsenceDayCount { get; init; } public int YearUnexcusedDayCount { get; init; } + /// Fehlstunden aus nicht ganztägigen Fehlzeiten. Ganze Fehltage werden nicht zusätzlich noch + /// einmal als ihre enthaltenen Unterrichtsstunden gezählt. + public int YearAbsencePeriodCount { get; init; } + public int YearUnexcusedPeriodCount { get; init; } + public int YearExcusedPeriodCount { get; init; } /// Tage, die WebUntis als Verspätung führt (ohne Unentschuldigt-Status) — zählen bewusst nicht /// in /, tragen aber mit /// reduziertem Gewicht zu bei. public int YearLateDayCount { get; init; } - /// Entschuldigt abwesende Tage (weder unentschuldigt noch nur verspätet) — Kehrwert von - /// innerhalb von . + /// Entschuldigte ganze Fehltage — Gegenstück zu innerhalb + /// von . Stunden stehen getrennt in den Period-Eigenschaften. public int YearExcusedDayCount { get; init; } /// Klassenbucheinträge anderer Lehrkräfte über das ganze bisherige Schuljahr mit /// CategoryGroup "Negativ" (Nutzer-Feedback: sollen ebenfalls ins Auffälligkeitsbild @@ -121,23 +122,33 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b /// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin. public DateOnly TermStart { get; init; } public bool HasYearSummary => SchoolDaysElapsed > 0 && - (YearAbsenceDayCount > 0 || YearLateDayCount > 0 || YearNegativeClassRegisterCount > 0); + (YearAbsenceDayCount > 0 || YearAbsencePeriodCount > 0 || YearLateDayCount > 0 || + YearNegativeClassRegisterCount > 0); public int YearAbsenceRatePercent => SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed); public string YearSummaryLabel => !HasYearSummary ? "" : - YearAbsenceDayCount > 0 ? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" + YearAbsenceDayCount > 0 ? $"{YearAbsenceRatePercent} % ganze Fehltage" + + (YearAbsencePeriodCount > 0 ? $" · {PeriodLabel(YearAbsencePeriodCount)}" : "") + : YearAbsencePeriodCount > 0 ? $"{PeriodLabel(YearAbsencePeriodCount)} seit Schuljahresbeginn" : YearLateDayCount > 0 ? $"{YearLateDayCount}× verspätet, keine Fehltage seit Schuljahresbeginn" : $"{YearNegativeClassRegisterCount} negative Klassenbucheinträge seit Schuljahresbeginn"; public string? YearSummaryTooltip => !HasYearSummary ? null : (YearAbsenceDayCount > 0 - ? $"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" + - (YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") - : $"Keine Fehltage seit {TermStart:dd.MM.}") + + ? $"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} als ganzer Fehltag" + + (YearUnexcusedDayCount > 0 ? $" · davon {YearUnexcusedDayCount} unentschuldigt" : "") + : $"Keine ganzen Fehltage seit {TermStart:dd.MM.}") + + (YearAbsencePeriodCount > 0 + ? $" · {PeriodLabel(YearAbsencePeriodCount)}" + + (YearUnexcusedPeriodCount > 0 ? $" ({YearUnexcusedPeriodCount} unentschuldigt)" : "") + : "") + (YearLateDayCount > 0 ? $" · {YearLateDayCount} Tage verspätet (zählt nicht zur Quote)" : "") + (YearNegativeClassRegisterCount > 0 ? $" · {YearNegativeClassRegisterCount} negative Klassenbucheinträge" : "") + (HasSuspensionEntry ? " · Suspendierung vermerkt" : "") + (HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : ""); + private static string PeriodLabel(int count) => + $"{count} Fehlstunde{(count == 1 ? "" : "n")}"; + private static readonly string[] ConcerningKeywords = ["hausaufgabe", "mitarbeit"]; private static readonly string[] SuspensionKeywords = ["suspendier"]; @@ -238,8 +249,10 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b 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 unexcused = yearEntries.Where(r => r.IsUnexcused && r.Date >= attendanceCutoff) + .Sum(r => r.AbsenceDayEquivalentForScoring); + var excused = yearEntries.Where(r => !r.IsUnexcused && !r.IsLate && r.Date >= attendanceCutoff) + .Sum(r => r.AbsenceDayEquivalentForScoring); 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) @@ -314,13 +327,17 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b { TodayAbsence = absence, HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate), - // Unentschuldigt vor Verspätet vor Entschuldigt, überschneidungsfrei — dieselbe - // Kategorisierung wie im Wochentrend (BuildTrend). Nur Verspätung (nicht auch - // unentschuldigt) fällt bewusst aus YearAbsenceDayCount heraus, siehe dort. - YearAbsenceDayCount = yearEntries.Count(r => r.IsUnexcused || !r.IsLate), - YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused), + // Ganze Fehltage und stundenweises Fehlen bleiben getrennt. Insbesondere macht + // "unentschuldigt" aus einer einzelnen Fehlstunde keinen ganzen Fehltag. + YearAbsenceDayCount = yearEntries.Count(r => !r.IsLate && r.CountsAsFullDay), + YearUnexcusedDayCount = yearEntries.Count(r => !r.IsLate && r.CountsAsFullDay && r.IsUnexcused), + YearAbsencePeriodCount = yearEntries.Where(r => r.IsPartialAbsence).Sum(r => r.TotalAbsentPeriods), + YearUnexcusedPeriodCount = yearEntries.Where(r => r.IsPartialAbsence && r.IsUnexcused) + .Sum(r => r.TotalAbsentPeriods), + YearExcusedPeriodCount = yearEntries.Where(r => r.IsPartialAbsence && !r.IsUnexcused) + .Sum(r => r.TotalAbsentPeriods), YearLateDayCount = yearEntries.Count(r => !r.IsUnexcused && r.IsLate), - YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate), + YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate && r.CountsAsFullDay), YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry), HasSuspensionEntry = yearRegisterEntries.Any(e => MatchesAnyKeyword(e, SuspensionKeywords)), ClassRegisterScoreComponent = yearRegisterEntries.Sum(e => ClassRegisterEntryWeight(e, effectiveWeights)), @@ -1017,12 +1034,17 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName; var weekRows = rows.Where(row => row.Date >= sevenDayStart).ToList(); var lateDaysWeek = weekRows.Count(row => row.IsLate); - var unexcusedDaysWeek = weekRows.Count(row => row.IsUnexcused && !row.IsLate); + var unexcusedDaysWeek = weekRows.Count(row => row.IsUnexcused && !row.IsLate && row.CountsAsFullDay); + var unexcusedPartialWeek = weekRows.Count(row => row.IsUnexcused && row.IsPartialAbsence); var lateDaysYear = rows.Count(row => row.IsLate); if (unexcusedDaysWeek >= 2) notices.Add(new ClassTeacherPatternNotice(displayName, $"{unexcusedDaysWeek} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger)); + else if (unexcusedPartialWeek >= 2) + notices.Add(new ClassTeacherPatternNotice(displayName, + $"{unexcusedPartialWeek} stundenweise unentschuldigte Fehlzeiten in 7 Tagen", + ClassTeacherStatusKind.Warning)); else if (lateDaysYear >= lateYearEscalationThreshold) notices.Add(new ClassTeacherPatternNotice(displayName, $"{lateDaysYear} Verspätungen seit Schuljahresbeginn – Elterngespräch oder Brief erwägen",