diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs
index c20d5e4..327a50b 100644
--- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs
+++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs
@@ -142,7 +142,7 @@ public sealed class ClassTeacherViewModelsTests
Assert.True(row.IsLate);
Assert.True(row.IsUnexcused);
Assert.Equal("20 Min. verspätet · Unentschuldigt", row.StatusText);
- Assert.Equal("#FF5A67", row.StatusColor);
+ Assert.Equal(ClassTeacherStatusKind.Danger, row.StatusKind);
}
[Fact]
@@ -171,6 +171,66 @@ public sealed class ClassTeacherViewModelsTests
rows.Select(r => r.StudentName));
}
+ [Fact]
+ public void RosterBuild_BerechnetFehlquoteSeitSchuljahresbeginn()
+ {
+ var students = new[] { Student(1001, "Ada Müller") };
+ var yearAbsences = new[]
+ {
+ new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 2, 90,
+ ["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
+ new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 6, 270,
+ ["Che"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
+ };
+
+ var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
+ new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20));
+
+ Assert.True(row.HasYearSummary);
+ Assert.Equal(2, 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.Contains("1 unentschuldigt", row.YearSummaryTooltip);
+ }
+
+ [Fact]
+ public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
+ {
+ var students = new[] { Student(1001, "Ada Müller") };
+
+ var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
+
+ Assert.False(row.HasYearSummary);
+ Assert.Equal("", row.YearSummaryLabel);
+ Assert.Null(row.YearSummaryTooltip);
+ }
+
+ [Fact]
+ public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
+ {
+ var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
+ var yearAbsences = new[]
+ {
+ new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Schmidt Ben", null, 2, 90,
+ ["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
+ };
+
+ var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26),
+ yearAbsences, schoolDaysElapsed: 10));
+
+ Assert.Equal(1, row.YearAbsenceDayCount);
+ }
+
+ [Fact]
+ public void TrendDay_BalkenbreitenSummierenSichZurGesamtbreite()
+ {
+ var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 40d, 20d, 40d);
+
+ Assert.Equal(100d, day.TotalBarWidth);
+ }
+
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
}
diff --git a/LehrerApp.Desktop/App.axaml b/LehrerApp.Desktop/App.axaml
index 1ff4bad..42534fa 100644
--- a/LehrerApp.Desktop/App.axaml
+++ b/LehrerApp.Desktop/App.axaml
@@ -2,6 +2,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LehrerApp.Desktop.App"
RequestedThemeVariant="Default">
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Styles/SemanticBrushes.axaml b/LehrerApp.Desktop/Styles/SemanticBrushes.axaml
new file mode 100644
index 0000000..93f7451
--- /dev/null
+++ b/LehrerApp.Desktop/Styles/SemanticBrushes.axaml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs
index 31df481..25c9c73 100644
--- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs
@@ -13,8 +13,11 @@ public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string StudentDisplayName => DisplayName(StudentName);
- public string CategoryColor => CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true
- ? "#FF5A67" : "#4C8DFF";
+ public ClassTeacherStatusKind CategoryKind =>
+ CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true
+ ? ClassTeacherStatusKind.Danger : ClassTeacherStatusKind.Info;
+ public bool IsDangerStatus => CategoryKind == ClassTeacherStatusKind.Danger;
+ public bool IsInfoStatus => CategoryKind == ClassTeacherStatusKind.Info;
private static string DisplayName(string value)
{
@@ -46,10 +49,6 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
public string StatusLabel => string.Join(", ", Statuses);
public string ReasonLabel => string.Join("; ", AbsenceReasons);
- /// Nutzer-Feedback: ein ganzer Fehltag ist in der Liste sonst nicht auf einen Blick erkennbar -
- /// dezente Hervorhebung statt einer weiteren Text-/Icon-Spalte.
- public string RowBackground => CountsAsFullDay ? "#1FFFA000" : "Transparent";
- public string? FullDayTooltip => CountsAsFullDay ? "Ganzer Fehltag" : null;
public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r =>
r.Contains("verspät", StringComparison.OrdinalIgnoreCase));
public bool IsUnexcused => Statuses.Any(s =>
@@ -58,7 +57,10 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}";
public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" :
Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel;
- public string StatusColor => IsUnexcused ? "#FF5A67" : IsLate ? "#F59E0B" : "#73C45A";
+ public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
+ IsLate ? ClassTeacherStatusKind.Warning : ClassTeacherStatusKind.Ok;
+ public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
+ public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
public string DetailLabel => string.Join(" · ", new[] { ReasonLabel, Note, ExcuseNote }
.Where(value => !string.IsNullOrWhiteSpace(value)));
diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs
index 6873416..71b2e7d 100644
--- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs
@@ -2,10 +2,17 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
+/// Semantische Statusstufe einer Zeile bzw. eines Hinweises. Bewusst ohne Farbwert im
+/// ViewModel: Die Zuordnung zu einem Pinsel passiert über Style-Klassen in XAML
+/// (Styles/SemanticBrushes.axaml), damit ein Theme-Wechsel hell/dunkel sie live mitnimmt —
+/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.
+public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
+
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
{
@@ -43,12 +50,30 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
}
}
- public string StatusColor => IsUnexcused ? "#FF5A67" :
- HasAbsenceToday ? "#F59E0B" : HasRecentClassRegisterEntry ? "#4C8DFF" : "#73C45A";
- public string StatusBarColor => StatusColor;
+ public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
+ HasAbsenceToday ? ClassTeacherStatusKind.Warning :
+ HasRecentClassRegisterEntry ? ClassTeacherStatusKind.Info : ClassTeacherStatusKind.Ok;
+ public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
+ public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
+ public bool IsInfoStatus => StatusKind == ClassTeacherStatusKind.Info;
public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" :
HasRecentClassRegisterEntry ? "Eintrag diese Woche" : "";
+ /// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
+ /// sagt für Zeugnis/Attestpflicht wenig aus). zählt nur
+ /// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag.
+ public int YearAbsenceDayCount { get; init; }
+ public int YearUnexcusedDayCount { get; init; }
+ public int SchoolDaysElapsed { get; init; }
+ public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
+ public int YearAbsenceRatePercent =>
+ SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
+ public string YearSummaryLabel => HasYearSummary
+ ? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
+ public string? YearSummaryTooltip => !HasYearSummary ? null :
+ $"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" +
+ (YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "");
+
private static bool IsLateReason(string reason) =>
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
@@ -56,7 +81,9 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
IReadOnlyList students,
IReadOnlyList todayAbsences,
IReadOnlyList recentClassRegisterEntries,
- DateOnly? today = null)
+ DateOnly? today = null,
+ IReadOnlyList? yearAbsences = null,
+ int schoolDaysElapsed = 0)
{
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
@@ -65,6 +92,11 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
.ToDictionary(g => g.Key, g => g.First());
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
.ToDictionary(g => g.Key, g => g.ToList());
+ var yearRows = yearAbsences ?? [];
+ var yearByKey = yearRows.Where(a => a.ExternKey is not null)
+ .GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
+ var yearByName = yearRows.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
+ .ToDictionary(g => g.Key, g => g.ToList());
return students.Select(student =>
{
@@ -72,12 +104,17 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null)
?? absenceByName.GetValueOrDefault(nameKey);
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
+ var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
+ ?? yearByName.GetValueOrDefault(nameKey) ?? [];
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
registerEntries.Count > 0)
{
TodayAbsence = absence,
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
+ YearAbsenceDayCount = yearEntries.Count,
+ YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
+ SchoolDaysElapsed = schoolDaysElapsed,
};
})
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
@@ -87,14 +124,31 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
-public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount, int LateCount,
- double AlertBarWidth, double UnexcusedBarWidth, double LateBarWidth);
-public sealed record ClassTeacherPatternNotice(string StudentName, string Message, string Color);
+/// Ein gestapelter Tages-Balken statt drei unabhängig skalierter (Nutzer-Feedback: die vorherige
+/// Version zeigte Alerts/Unentschuldigt/Verspätet als drei separate, gleich hohe Balken, obwohl
+/// Unentschuldigt und Verspätet Teilmengen von Alerts sind — das suggerierte fälschlich drei
+/// unabhängige Größen). Die drei Segmente sind überschneidungsfrei (Unentschuldigt hat Vorrang
+/// vor Verspätet, wie bei ) und ihre Breiten
+/// summieren sich exakt zu .
+public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount,
+ int LateExcusedCount, int ExcusedCount, double UnexcusedBarWidth, double LateExcusedBarWidth,
+ double ExcusedBarWidth)
+{
+ public double TotalBarWidth => UnexcusedBarWidth + LateExcusedBarWidth + ExcusedBarWidth;
+}
+public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
+ ClassTeacherStatusKind Kind)
+{
+ public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
+ public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
+ public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info;
+}
public partial class ClassTeacherOverviewViewModel : ObservableObject
{
private readonly WebUntisSettingsService _settings;
private readonly UntisReportCacheService _cache;
+ private readonly SchoolYearService _schoolYear;
public ClassTeacherDetailsViewModel DetailsTab { get; }
public ObservableCollection Roster { get; } = [];
@@ -139,10 +193,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public Func? OnNavigateToWorkload { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
- UntisReportCacheService cache, ClassTeacherDetailsViewModel detailsTab)
+ UntisReportCacheService cache, SchoolYearService schoolYear, ClassTeacherDetailsViewModel detailsTab)
{
_settings = settings;
_cache = cache;
+ _schoolYear = schoolYear;
DetailsTab = detailsTab;
}
@@ -176,16 +231,24 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
try
{
var today = DateOnly.FromDateTime(DateTime.Today);
- var start = today.AddDays(-6);
+ var sevenDayStart = today.AddDays(-6);
+ var trendDays = LastSchoolDays(today, 7);
+ // Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
+ // Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
+ // UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
+ // teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
+ var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
var studentsTask = _cache.GetStudentRosterAsync(className);
- var absencesTask = _cache.GetAbsencesAsync(className, start, today);
- var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, start, today);
+ var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
+ var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today);
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
- var absenceDays = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
- var todayAbsences = absenceDays.Where(a => a.Date == today).ToList();
+ var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
+ var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
+ var weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList();
+ var schoolDaysElapsed = CountWeekdays(yearStart, today);
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
- classRegisterTask.Result, today)) Roster.Add(row);
+ classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row);
StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
@@ -195,8 +258,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
RecentClassRegisterCount = classRegisterTask.Result.Count;
- BuildTrend(absenceDays, start, today);
- BuildPatternNotices(absenceDays);
+ BuildTrend(absenceDaysYear, trendDays);
+ BuildPatternNotices(weekAbsenceDays);
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
ApplyRosterFilter();
@@ -266,21 +329,50 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
NotifyRosterState();
}
- private void BuildTrend(IReadOnlyList absenceDays, DateOnly start, DateOnly end)
+ /// Letzte statt letzte 7 Kalendertage (Nutzer-Feedback: an zwei von
+ /// sieben Tagen war der Trend bislang systematisch leer, weil Wochenenden mitzählten). Ohne
+ /// Ferienkalender — siehe .
+ private void BuildTrend(IReadOnlyList absenceDays, IReadOnlyList days)
{
- var counts = Enumerable.Range(0, 7).Select(offset => start.AddDays(offset)).Select(date =>
+ var today = days[^1];
+ var counts = days.Select(date =>
{
var rows = absenceDays.Where(r => r.Date == date).ToList();
- return (Date: date, Alerts: rows.Count,
- Unexcused: rows.Count(r => r.Statuses.Any(s => s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase))),
- Late: rows.Count(r => r.TotalAbsentPeriods == 0 || r.AbsenceReasons.Any(a =>
- a.Contains("verspät", StringComparison.OrdinalIgnoreCase))));
+ var unexcused = rows.Count(r => r.IsUnexcused);
+ var lateExcused = rows.Count(r => !r.IsUnexcused && r.IsLate);
+ var excused = rows.Count(r => !r.IsUnexcused && !r.IsLate);
+ return (Date: date, Unexcused: unexcused, LateExcused: lateExcused, Excused: excused,
+ Total: unexcused + lateExcused + excused);
}).ToList();
- var max = Math.Max(1, counts.Max(c => c.Alerts));
+ var max = Math.Max(1, counts.Max(c => c.Total));
foreach (var day in counts)
- TrendDays.Add(new ClassTeacherTrendDay(day.Date == end ? "Heute" : day.Date.ToString("ddd"),
- day.Alerts, day.Unexcused, day.Late,
- 112d * day.Alerts / max, 112d * day.Unexcused / max, 112d * day.Late / max));
+ TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"),
+ day.Total, day.Unexcused, day.LateExcused, day.Excused,
+ 112d * day.Unexcused / max, 112d * day.LateExcused / max, 112d * day.Excused / max));
+ }
+
+ /// Letzte Werktage bis einschließlich , ohne
+ /// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
+ /// Schulferien, nur Wochenenden.
+ private static IReadOnlyList LastSchoolDays(DateOnly end, int count)
+ {
+ var days = new List();
+ for (var cursor = end; days.Count < count; cursor = cursor.AddDays(-1))
+ if (cursor.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday))
+ days.Add(cursor);
+ days.Reverse();
+ return days;
+ }
+
+ /// Nenner für — Werktage zwischen
+ /// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender.
+ private static int CountWeekdays(DateOnly start, DateOnly end)
+ {
+ if (end < start) return 0;
+ var count = 0;
+ for (var d = start; d <= end; d = d.AddDays(1))
+ if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++;
+ return count;
}
private void BuildPatternNotices(IReadOnlyList absenceDays)
@@ -294,10 +386,10 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate);
if (unexcusedDays >= 2)
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
- $"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", "#FF5A67"));
+ $"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
else if (lateDays >= 2)
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
- $"{lateDays}-mal verspätet in 7 Tagen", "#F59E0B"));
+ $"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
}
OnPropertyChanged(nameof(HasPatternNotices));
}
diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml
index 51d2ac0..4550f20 100644
--- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml
+++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherAbsencesView.axaml
@@ -3,6 +3,31 @@
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherAbsencesView"
x:DataType="vm:ClassTeacherDetailsViewModel">
+
+
+
+
+
+
+
+
+
+
@@ -10,11 +35,10 @@
+ IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
-
+
@@ -36,7 +60,7 @@
@@ -44,15 +68,20 @@
-
+
-
-
+
+
diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml
index 7ccd623..99675ec 100644
--- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml
+++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml
@@ -5,10 +5,14 @@
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherOverviewView"
x:DataType="vm:ClassTeacherOverviewViewModel">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
@@ -80,32 +154,37 @@
+ Background="Transparent" BorderThickness="0"
+ Foreground="{DynamicResource AppAccentTextBrush}"/>
-
+
-
+
-
+
-
+
@@ -113,7 +192,8 @@
-
@@ -224,7 +285,10 @@
-
+
diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml
index ee45f27..b9a249d 100644
--- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml
+++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml
@@ -3,6 +3,31 @@
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.ClassTeacher"
x:Class="LehrerApp.Desktop.Views.ClassTeacher.ClassTeacherRegisterView"
x:DataType="vm:ClassTeacherDetailsViewModel">
+
+
+
+
+
+
+
+
+
+
@@ -10,11 +35,10 @@
+ IsEnabled="{Binding !Busy}" Background="Transparent" Foreground="{DynamicResource AppAccentTextBrush}" VerticalAlignment="Center"/>
-
+
@@ -36,7 +60,7 @@
diff --git a/TODO.md b/TODO.md
index 1a61543..c1fd8ea 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1397,6 +1397,35 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
jetzt `UntisStudentRosterCacheEntry` statt des vollen `UntisStudentDto` entgegen (nur
`ExternKey`/`DisplayName` wurden je gebraucht).
+- [x] **"Klassenlehrer"-Feature — Trend & Fehlquote (August 2026):** Zwei Nutzer-Feedback-Punkte
+ auf einmal umgesetzt, weil sie sich denselben Datenabruf teilen.
+ - **Trend als gestapelter Balken statt drei unabhängig skalierten:** `ClassTeacherTrendDay`
+ lieferte bisher `AlertBarWidth`/`UnexcusedBarWidth`/`LateBarWidth`, jede für sich auf dasselbe
+ Maximum normiert — das suggerierte drei unabhängige Größen, obwohl Unentschuldigt und
+ Verspätet Teilmengen von Alerts sind. Jetzt drei überschneidungsfreie Segmente (Unentschuldigt
+ hat Vorrang vor Verspätet, gleiche Priorisierung wie `ClassTeacherRosterRow.AttentionRank`),
+ deren Breiten sich exakt zu `TotalBarWidth` summieren; in XAML als `Border ClipToBounds="True"`
+ mit `CornerRadius` um eine horizontale `StackPanel` aus drei quadratischen Segmenten gerendert,
+ damit nur die äußeren Kanten abgerundet sind.
+ - **Trend über die letzten 7 Schultage statt Kalendertage:** an zwei von sieben Tagen war der
+ Balken bislang systematisch leer, weil `start.AddDays(offset)` Wochenenden mitzählte. Neues
+ `ClassTeacherOverviewViewModel.LastSchoolDays` überspringt Samstag/Sonntag — bewusst ohne
+ Ferienkalender, die App kennt keine Schulferien (gilt auch für die Fehlquote unten).
+ - **Kumulierte Fehlquote seit Schuljahresbeginn je Schüler*in:** der bisherige Heute-Snapshot
+ sagt für Zeugnis/Attestpflicht wenig aus. `ClassTeacherRosterRow` bekommt
+ `YearAbsenceDayCount`/`YearUnexcusedDayCount`/`YearAbsenceRatePercent`
+ (`SchoolDaysElapsed` als Nenner, wieder nur Werktage ohne Ferienkalender) und zeigt sie als
+ dezente zweite Zeile unter dem Tagesstatus, Details im Tooltip. Dafür holt `Load()` die
+ Fehlzeiten jetzt für `[Schuljahresbeginn, heute]` statt nur der letzten 7 Tage — teuer ist das
+ nur beim ersten Öffnen pro Schuljahr, `UntisReportCacheService` cached den "kalten" Bereich
+ danach dauerhaft (siehe Cache-Eintrag oben). Klassenbuch-Abruf bleibt bewusst bei 7 Tagen
+ (für "Eintrag diese Woche"/`RecentClassRegisterCount` reicht das, ein Jahr an fremden
+ Klassenbucheinträgen zu cachen wäre unnötiges Datenvolumen).
+ Neue `SchoolYearService`-Abhängigkeit in `ClassTeacherOverviewViewModel` (Konstruktor-Injection,
+ Service war bereits als Singleton registriert).
+
+### 4.4 Wochen-/Tagesansicht
+
### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
@@ -3052,6 +3081,26 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau
ca. 23 XAML-Dateien mit fest codierten Hex-Farben statt Theme-Ressourcen — die
Umschaltung selbst funktioniert, aber einzelne Stellen dürften im Dunkelmodus optisch
nicht passen. Eigene, größere Aufgabe. Schriftgröße nicht umgesetzt.
+
+ **Nachtrag (August 2026) — Theme-Ressourcen, erster Schritt:** Neue
+ [SemanticBrushes.axaml](LehrerApp.Desktop/Styles/SemanticBrushes.axaml) definiert über
+ `ResourceDictionary.ThemeDictionaries` (Light/Dark) semantische Pinsel für Flächen
+ (`AppCardBackgroundBrush`, `AppListRowBackgroundBrush`, `AppChipBackgroundBrush`, …),
+ Akzent (`AppAccentTextBrush`, `AppFilterActive*Brush`, …) und Status
+ (`AppStatusOk/Info/Warning/DangerBrush`); eingebunden in `Application.Resources` von
+ `App.axaml`. Der Klassenlehrer-Bereich ist die erste vollständig umgestellte Ansicht — er
+ war mit ~36 Hex-Werten der stärkste Fall und im hellen Theme unbrauchbar (dunkle
+ Kartenflächen auf hellem Grund). Zwei Punkte, die beim Umstellen weiterer Ansichten
+ genauso gelten:
+ - Farben müssen per **`DynamicResource`** geholt werden — `StaticResource` löst einmalig
+ auf und bliebe beim Laufzeit-Wechsel stehen.
+ - Statusfarben dürfen **nicht als Hex-String aus dem ViewModel** kommen (bisheriges Muster
+ `StatusColor => "#FF5A67"`), aus demselben Grund. Stattdessen liefert das ViewModel eine
+ semantische Stufe (`ClassTeacherStatusKind`) plus `Is{Kind}Status`-Flags, und ein
+ Style-Selektor (`TextBlock.status.danger`, `Border.statusFill.warning`) setzt den Pinsel
+ — die Basisfarbe muss dabei ebenfalls aus einem Selektor kommen, sonst überstimmt sie das
+ lokale Attribut (siehe CLAUDE.md, Avalonia-XAML-Fallstricke).
+ Offen bleiben die übrigen Ansichten (Dashboard, Planung, Mitarbeit, Gruppen …).
- [ ] **12.5** Speicherort der Datenbank anzeigen und ändern.
- [ ] **12.6** Backup-Verwaltung (siehe 13.3) in den Einstellungen zugänglich machen.