Defuse handled dashboard warnings and add a snooze action
CI / build-and-test (push) Canceled after 0s

Cross-class warnings (attendance, homework backlog, lateness) stayed red
and at the top even after being acted on. A warning now turns gray and
sorts to the bottom of its group once a recently-updated open Vorgang
with a matching tag exists, or once explicitly snoozed for 1/2/4 weeks.
Both are time-limited: once the grace period lapses without the
underlying number actually improving, the warning goes active again on
the next dashboard load - no background job needed since it's just
recomputed on every RebuildAttention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-26 00:48:39 +02:00
1 parent 3270b48850
commit 54ad7f7419
15 files changed
+421 -18

No files matched your search

@@ -218,6 +218,14 @@ public interface IVorgangRepository
/// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation).
void Delete(Guid id);
}
/// Rein lokaler "Zurückstellen"-Zeitstempel für Dashboard-Warnungen je (StudentId, WarningTag) —
/// kein Sync, siehe DashboardWarningSnooze.
public interface IDashboardWarningSnoozeRepository
{
DashboardWarningSnooze? Get(Guid studentId, string warningTag);
List<DashboardWarningSnooze> GetAll();
void Save(DashboardWarningSnooze snooze);
}
public interface IWorkTaskRepository
{
List<WorkTask> GetByStatus(WorkTaskStatus status);
@@ -0,0 +1,17 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// "Zurückstellen" einer klassenübergreifenden Dashboard-Warnung (Fehlzeiten/Hausaufgaben/
/// Verspätungen, siehe DashboardViewModel) für eine Person — ein rein lokaler Erinnerungs-
/// Zeitstempel je (StudentId, WarningTag), bewusst nicht über den Sync repliziert (siehe
/// IDashboardWarningSnoozeRepository): eher ein Job-/Fetch-Zustand als echte Fallakten-Daten wie
/// Vorgang/Documentation.
/// </summary>
public class DashboardWarningSnooze
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid StudentId { get; set; }
/// Dieselben Strings wie VorgangTagDisplay.Suggestions ("Absentismus"/"Hausaufgaben"/"Verspätungen").
public string WarningTag { get; set; } = "";
public DateOnly SnoozedUntil { get; set; }
}
+3
View File
@@ -76,6 +76,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<UntisHubJobState> UntisHubJobStates => _db.GetCollection<UntisHubJobState>("untis_hub_job_states");
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
public ILiteCollection<DashboardWarningSnooze> DashboardWarningSnoozes => _db.GetCollection<DashboardWarningSnooze>("dashboard_warning_snoozes");
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
public void Checkpoint() => _db.Checkpoint();
@@ -694,6 +695,8 @@ public class LiteDbContext : IDisposable
UntisStudentRosterCache.EnsureIndex(x => x.ClassName);
UntisCacheFetchStates.EnsureIndex("ux_class_kind",
BsonExpression.Create("STRING($.ClassName) + ':' + STRING($.Kind)"), unique: true);
DashboardWarningSnoozes.EnsureIndex("ux_student_tag",
BsonExpression.Create("STRING($.StudentId) + ':' + STRING($.WarningTag)"), unique: true);
}
public void Dispose() => _db.Dispose();
@@ -901,6 +901,14 @@ public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetch
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
}
public class DashboardWarningSnoozeRepository(LiteDbContext db) : IDashboardWarningSnoozeRepository
{
public DashboardWarningSnooze? Get(Guid studentId, string warningTag) =>
db.DashboardWarningSnoozes.FindOne(s => s.StudentId == studentId && s.WarningTag == warningTag);
public List<DashboardWarningSnooze> GetAll() => db.DashboardWarningSnoozes.FindAll().ToList();
public void Save(DashboardWarningSnooze snooze) => db.DashboardWarningSnoozes.Upsert(snooze);
}
public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository
{
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
@@ -1,3 +1,4 @@
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels;
@@ -143,6 +144,7 @@ public sealed class DashboardViewModelTests
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
FakeVorgangRepository? vorgaenge = null, FakeDashboardWarningSnoozeRepository? warningSnoozes = null,
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null,
@@ -157,6 +159,7 @@ public sealed class DashboardViewModelTests
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
vorgaenge ?? new FakeVorgangRepository(), warningSnoozes ?? new FakeDashboardWarningSnoozeRepository(),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
@@ -951,6 +954,162 @@ public sealed class DashboardViewModelTests
Assert.Empty(Items(vm, AttentionKind.Attendance));
}
// ── Entschärfen (Nutzer-Feedback: "erledigt" wirkt grau/hinten, ohne dauerhaft stumm zu bleiben) ──
private static (Student Handled, Student Active) BuildTwoOverThresholdStudents(
LearningGroup group, DateOnly today, FakeSessions sessions, FakeEntries entries)
{
var handled = new Student { FirstName = "Anna", LastName = "Bearbeitet" };
var active = new Student { FirstName = "Ben", LastName = "Unbearbeitet" };
foreach (var student in new[] { handled, active })
for (var i = 0; i < 10; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present;
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
}
return (handled, active);
}
[Fact]
public void AttendanceWarnings_MitKuerzlichBearbeitetemVorgang_IstEntschaerftUndAmEndeSortiert()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var (handled, active) = BuildTwoOverThresholdStudents(group, today, sessions, entries);
var vorgaenge = new FakeVorgangRepository();
vorgaenge.Add(new Vorgang
{
Title = "Fehlzeiten", StudentIds = [handled.Id], Tags = ["Absentismus"],
Status = VorgangStatus.Open, UpdatedAt = DateTime.UtcNow.AddDays(-2),
});
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([handled, active]), sessions: sessions, entries: entries,
vorgaenge: vorgaenge);
var items = Items(vm, AttentionKind.Attendance).ToList();
Assert.Equal(2, items.Count);
Assert.False(items[0].IsDefused);
Assert.Equal(active.FullName, items[0].Title);
Assert.True(items[1].IsDefused);
Assert.Equal(handled.FullName, items[1].Title);
}
[Fact]
public void AttendanceWarnings_VorgangAelterAlsFrist_BleibtScharf()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var (handled, _) = BuildTwoOverThresholdStudents(group, today, sessions, entries);
var vorgaenge = new FakeVorgangRepository();
// 20 Tage her — jenseits der 14-Tage-Frist (VorgangDefusalGraceDays).
vorgaenge.Add(new Vorgang
{
Title = "Fehlzeiten", StudentIds = [handled.Id], Tags = ["Absentismus"],
Status = VorgangStatus.Open, UpdatedAt = DateTime.UtcNow.AddDays(-20),
});
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([handled]), sessions: sessions, entries: entries, vorgaenge: vorgaenge);
Assert.False(Assert.Single(Items(vm, AttentionKind.Attendance)).IsDefused);
}
[Fact]
public void AttendanceWarnings_AktiveZurueckstellungIstEntschaerft_AbgelaufeneNichtMehr()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var (handled, active) = BuildTwoOverThresholdStudents(group, today, sessions, entries);
var snoozes = new FakeDashboardWarningSnoozeRepository();
snoozes.Save(new DashboardWarningSnooze { StudentId = handled.Id, WarningTag = "Absentismus", SnoozedUntil = today.AddDays(7) });
snoozes.Save(new DashboardWarningSnooze { StudentId = active.Id, WarningTag = "Absentismus", SnoozedUntil = today.AddDays(-1) });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([handled, active]), sessions: sessions, entries: entries,
warningSnoozes: snoozes);
var byName = Items(vm, AttentionKind.Attendance).ToDictionary(i => i.Title);
Assert.True(byName[handled.FullName].IsDefused);
Assert.False(byName[active.FullName].IsDefused);
}
[Fact]
public async Task SnoozeAction_FragtNachUndEntschaerftDieWarnungSofort()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
for (var i = 0; i < 10; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present;
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
}
var snoozes = new FakeDashboardWarningSnoozeRepository();
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries, warningSnoozes: snoozes);
string? shownMessage = null;
vm.OnPickSnoozeDuration = message => { shownMessage = message; return Task.FromResult<int?>(14); };
var item = Assert.Single(Items(vm, AttentionKind.Attendance));
Assert.False(item.IsDefused);
var snoozeAction = Assert.Single(item.Actions, a => a.Label == "Zurückstellen");
await ((IAsyncRelayCommand)snoozeAction.Command).ExecuteAsync(null);
Assert.Contains(student.FullName, shownMessage);
var saved = Assert.Single(snoozes.GetAll());
Assert.Equal(student.Id, saved.StudentId);
Assert.Equal("Absentismus", saved.WarningTag);
Assert.Equal(today.AddDays(14), saved.SnoozedUntil);
Assert.True(Assert.Single(Items(vm, AttentionKind.Attendance)).IsDefused);
}
[Fact]
public async Task SnoozeAction_AbbruchSpeichertNichts()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
for (var i = 0; i < 10; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present;
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
}
var snoozes = new FakeDashboardWarningSnoozeRepository();
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries, warningSnoozes: snoozes);
vm.OnPickSnoozeDuration = _ => Task.FromResult<int?>(null);
var item = Assert.Single(Items(vm, AttentionKind.Attendance));
var snoozeAction = Assert.Single(item.Actions, a => a.Label == "Zurückstellen");
await ((IAsyncRelayCommand)snoozeAction.Command).ExecuteAsync(null);
Assert.Empty(snoozes.GetAll());
Assert.False(Assert.Single(Items(vm, AttentionKind.Attendance)).IsDefused);
}
[Fact]
public async Task AddTask_SpeichertErgebnisUndAktualisiertOffeneAufgaben()
{
+9
View File
@@ -341,6 +341,15 @@ public class FakeVorgangRepository : IVorgangRepository
public void Delete(Guid id) { var v = _all.FirstOrDefault(x => x.Id == id); if (v is not null) v.IsDeleted = true; }
}
public class FakeDashboardWarningSnoozeRepository : IDashboardWarningSnoozeRepository
{
private readonly List<DashboardWarningSnooze> _all = [];
public DashboardWarningSnooze? Get(Guid studentId, string warningTag) =>
_all.FirstOrDefault(s => s.StudentId == studentId && s.WarningTag == warningTag);
public List<DashboardWarningSnooze> GetAll() => _all.ToList();
public void Save(DashboardWarningSnooze snooze) { _all.RemoveAll(s => s.Id == snooze.Id); _all.Add(snooze); }
}
public class FakeAttachmentStorage : IAttachmentStorage
{
private readonly Dictionary<string, byte[]> _blobs = [];
+12
View File
@@ -15,6 +15,7 @@ using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.ViewModels.Workload;
using LehrerApp.Desktop.Views;
using LehrerApp.Desktop.Views.Shared;
using LehrerApp.Desktop.Views.Workload;
using LehrerApp.Sync;
using Microsoft.Extensions.DependencyInjection;
@@ -239,6 +240,7 @@ public class App : Application
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
dash.OnAddMissingTeachingTime = item => ShowMissingTeachingTimeDialog(item, dash);
dash.OnPickSnoozeDuration = ShowSnoozeDurationDialog;
var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>();
examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups);
@@ -294,6 +296,16 @@ public class App : Application
Services.GetRequiredService<WorkTaskListViewModel>().Load();
}
private static async Task<int?> ShowSnoozeDurationDialog(string message)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
{ MainWindow: { } owner }) return null;
var vm = new SnoozeDurationDialogInfo { Message = message };
var dialog = new SnoozeDurationDialog { DataContext = vm };
return await dialog.ShowDialog<int?>(owner);
}
private static async Task ShowMissingTeachingTimeDialog(MissingTeachingTimeItem item, DashboardViewModel dashboard)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
+1
View File
@@ -168,6 +168,7 @@ public static class AppBootstrapper
services.AddSingleton<ILessonRepository, LessonRepository>();
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
services.AddSingleton<IVorgangRepository, VorgangRepository>();
services.AddSingleton<IDashboardWarningSnoozeRepository, DashboardWarningSnoozeRepository>();
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
+11 -1
View File
@@ -49,13 +49,22 @@ public sealed class AttentionItem
/// bei Attendance/MissingHomework/Lateness gesetzt, weil ein AttentionItem dieser Arten per
/// Konstruktion nur entsteht, wenn der jeweilige Schwellenwert bereits überschritten ist.</summary>
public bool IsWarningTrailing { get; }
/// <summary>Nur bei Attendance/MissingHomework/Lateness gesetzt: ein offener Vorgang mit
/// passendem Tag wurde kürzlich bearbeitet, oder die Warnung wurde manuell zurückgestellt (siehe
/// DashboardViewModel.SnoozeWarningAsync) — beides zeitlich befristet, damit eine ausbleibende
/// Besserung nach Ablauf der Frist wieder auffällt statt dauerhaft entschärft zu bleiben. Steuert
/// nur die Optik (grau statt rot) und die Sortierung (ans Ende der Gruppe); der Schwellenwert-
/// Vergleich selbst bleibt unverändert, die Warnung verschwindet also ganz, sobald sich die
/// zugrunde liegende Zahl tatsächlich bessert.</summary>
public bool IsDefused { get; }
public IReadOnlyList<AttentionAction> Actions { get; }
public Action? Navigate { get; }
public AttentionItem(AttentionKind kind, string title, string subtitle = "", string trailingText = "",
string dateDisplay = "", bool isOverdue = false, AlertSeverity? severity = null,
int progressPercent = 0, string progressLabel = "", bool hasProgress = false,
bool isWarningTrailing = false, IReadOnlyList<AttentionAction>? actions = null, Action? navigate = null)
bool isWarningTrailing = false, bool isDefused = false,
IReadOnlyList<AttentionAction>? actions = null, Action? navigate = null)
{
Kind = kind;
Title = title;
@@ -68,6 +77,7 @@ public sealed class AttentionItem
ProgressLabel = progressLabel;
HasProgress = hasProgress;
IsWarningTrailing = isWarningTrailing;
IsDefused = isDefused;
Actions = actions ?? [];
Navigate = navigate;
}
@@ -27,6 +27,8 @@ public partial class DashboardViewModel : ObservableObject
private readonly IParticipationRepository _participationEntries;
private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation;
private readonly IVorgangRepository _vorgaenge;
private readonly IDashboardWarningSnoozeRepository _warningSnoozes;
private readonly ITimetableSlotRepository _timetableSlots;
private readonly PeriodScheduleService _periodSchedule;
private readonly AttendanceBalanceService _attendanceBalance;
@@ -62,6 +64,17 @@ public partial class DashboardViewModel : ObservableObject
/// Nutzer-Entscheidung: ab 8 Verspätungen (Late/SignificantlyLate) im laufenden Schuljahr
/// (alle Gruppen) wird gewarnt.
private const int LatenessWarningThreshold = 8;
/// Nutzer-Feedback: eine Warnung, zu der bereits ein offener Vorgang mit passendem Tag existiert
/// ODER die manuell zurückgestellt wurde, erscheint "entschärft" (grau, ans Ende der Gruppe
/// einsortiert) statt weiter rot hervorgehoben — aber nur befristet: bessert sich die zugrunde
/// liegende Zahl innerhalb dieser Frist nicht, soll die Warnung wieder auffallen, statt durch
/// einen dauerhaft offenen Vorgang für immer entschärft zu bleiben. Für den Vorgang-Fall zählt
/// dessen UpdatedAt (jede Interaktion setzt die Frist zurück); für "Zurückstellen" wählt die
/// Nutzerin die Frist selbst (siehe OnPickSnoozeDuration).
private const int VorgangDefusalGraceDays = 14;
private const string AbsentismusTag = "Absentismus";
private const string HausaufgabenTag = "Hausaufgaben";
private const string VerspaetungenTag = "Verspätungen";
/// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal
/// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht
/// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren
@@ -151,6 +164,9 @@ public partial class DashboardViewModel : ObservableObject
// string vorgeschlagener Titel) — eigenes Delegate statt OnNavigateToStudent zu erweitern, damit
// dessen übrige Aufrufer (Förderplan, Auffälligkeiten, Termine) unverändert bleiben.
public Action<Guid, string, string>? OnNavigateToStudentCase { get; set; }
// Fragt beim Klick auf "Zurückstellen" die gewünschte Frist ab (Anzeigetext, z.B. "Fehlzeiten –
// Anna Beispiel") und liefert die Anzahl Tage zurück, oder null bei Abbruch.
public Func<string, Task<int?>>? OnPickSnoozeDuration { get; set; }
// Sprungziel eigens für eine Stunde in TodaysLessons (9.2) — bewusst getrennt von
// OnNavigateToGroup (Lerngruppen-Kacheln, Tab "Übersicht"), da der Sprung von einer konkreten
// Stunde aus sinnvollerweise direkt in die Mitarbeitserfassung führt.
@@ -194,7 +210,8 @@ public partial class DashboardViewModel : ObservableObject
IReportGradeRepository reportGrades, IGroupMembershipRepository memberships,
IWorkTaskRepository tasks, IParticipationSessionRepository participationSessions,
IParticipationRepository participationEntries, IStudentRepository students,
IDocumentationRepository documentation, ITimetableSlotRepository timetableSlots,
IDocumentationRepository documentation, IVorgangRepository vorgaenge,
IDashboardWarningSnoozeRepository warningSnoozes, ITimetableSlotRepository timetableSlots,
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
@@ -208,6 +225,7 @@ public partial class DashboardViewModel : ObservableObject
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
_participationSessions = participationSessions; _participationEntries = participationEntries;
_students = students; _documentation = documentation;
_vorgaenge = vorgaenge; _warningSnoozes = warningSnoozes;
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
@@ -401,6 +419,71 @@ public partial class DashboardViewModel : ObservableObject
private Dictionary<Guid, DateOnly> BuildSessionDateLookup() =>
_participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date);
// ── Entschärfen (Nutzer-Feedback) ─────────────────────────────────────────
//
// Eine der drei klassenübergreifenden Warnungen gilt als "entschärft" (grau, ans Ende ihrer
// Gruppe sortiert), solange entweder ein offener Vorgang mit passendem Tag kürzlich bearbeitet
// wurde, oder eine manuelle Rückstellung noch läuft — beides zeitlich befristet (siehe
// VorgangDefusalGraceDays-Kommentar), damit ausbleibende Besserung nach Fristablauf wieder
// auffällt statt dauerhaft entschärft zu bleiben. Beide Nachschlage-Mengen bulk-geladen wie
// BuildSessionDateLookup, um kein N+1 über alle Schüler/Vorgänge zu erzeugen.
private HashSet<(Guid StudentId, string Tag)> BuildRecentVorgangTagSet()
{
var cutoffUtc = DateTime.UtcNow.AddDays(-VorgangDefusalGraceDays);
var set = new HashSet<(Guid, string)>();
foreach (var v in _vorgaenge.GetAll())
{
if (v.Status != VorgangStatus.Open) continue;
// Siehe CLAUDE.md (LiteDB-Details): DateTime-Vergleiche nach dem Utc→Local-Roundtrip
// sind nur nach beidseitigem ToUniversalTime() sicher.
if (v.UpdatedAt.ToUniversalTime() < cutoffUtc) continue;
foreach (var studentId in v.StudentIds)
foreach (var tag in v.Tags)
set.Add((studentId, tag.ToLowerInvariant()));
}
return set;
}
private HashSet<(Guid StudentId, string Tag)> BuildActiveSnoozeSet(DateOnly today) =>
_warningSnoozes.GetAll().Where(s => s.SnoozedUntil >= today)
.Select(s => (s.StudentId, s.WarningTag.ToLowerInvariant())).ToHashSet();
private static bool IsDefused(Guid studentId, string tag,
HashSet<(Guid StudentId, string Tag)> recentVorgaenge, HashSet<(Guid StudentId, string Tag)> activeSnoozes)
{
var key = (studentId, tag.ToLowerInvariant());
return recentVorgaenge.Contains(key) || activeSnoozes.Contains(key);
}
/// Von der "Zurückstellen"-Aktion einer Warnung aufgerufen (siehe ToAttentionItem-Überladungen
/// unten) — fragt über OnPickSnoozeDuration die Frist ab und speichert sie, sofern nicht
/// abgebrochen. Aktualisiert danach nur die drei klassenübergreifenden Warnungen neu, kein
/// voller Dashboard-Reload nötig (gleiches Muster wie ResolveExcuse/AddMissingTeachingTime).
private async Task SnoozeWarningAsync(Guid studentId, string tag, string label)
{
if (OnPickSnoozeDuration is null) return;
var days = await OnPickSnoozeDuration(label);
if (days is null) return;
var snooze = _warningSnoozes.Get(studentId, tag) ?? new DashboardWarningSnooze
{ StudentId = studentId, WarningTag = tag };
snooze.SnoozedUntil = DateOnly.FromDateTime(_now()).AddDays(days.Value);
_warningSnoozes.Save(snooze);
var today = DateOnly.FromDateTime(_now());
LoadAttendanceWarnings(today);
LoadMissingHomeworkWarnings(today);
LoadLatenessWarnings(today);
RebuildAttention();
UpdateDashboardSummary();
}
private AttentionAction SnoozeAction(Guid studentId, string tag, string label) => new("Zurückstellen",
new AsyncRelayCommand(() => SnoozeWarningAsync(studentId, tag, label)));
// ── Fehlzeiten-Warnung (5.2.3) ────────────────────────────────────────────
private void LoadAttendanceWarnings(DateOnly today)
{
_attendanceItems.Clear();
@@ -408,6 +491,8 @@ public partial class DashboardViewModel : ObservableObject
var from = _sy.SchoolYearStart(schoolYear);
var to = _sy.SchoolYearEnd(schoolYear);
var sessionDates = BuildSessionDateLookup();
var recentVorgaenge = BuildRecentVorgangTagSet();
var activeSnoozes = BuildActiveSnoozeSet(today);
var items = new List<AttendanceWarningItem>();
foreach (var student in _students.GetAll())
@@ -422,14 +507,18 @@ public partial class DashboardViewModel : ObservableObject
if (balance.TotalChecked >= AttendanceMinSampleSize && balance.ExceedsThreshold)
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
}
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
_attendanceItems.Add(ToAttentionItem(item));
var ordered = items
.Select(i => (Item: i, Defused: IsDefused(i.StudentId, AbsentismusTag, recentVorgaenge, activeSnoozes)))
.OrderBy(x => x.Defused).ThenByDescending(x => x.Item.AbsenceRatePercent);
foreach (var (item, defused) in ordered)
_attendanceItems.Add(ToAttentionItem(item, defused));
}
private AttentionItem ToAttentionItem(AttendanceWarningItem w) => new(
private AttentionItem ToAttentionItem(AttendanceWarningItem w, bool isDefused) => new(
AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %",
isWarningTrailing: true,
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, "Absentismus", "Fehlzeiten"));
isWarningTrailing: true, isDefused: isDefused,
actions: [SnoozeAction(w.StudentId, AbsentismusTag, $"Fehlzeiten – {w.StudentName}")],
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, AbsentismusTag, "Fehlzeiten"));
// ── Hausaufgaben-Warnung (Nutzer-Feedback: analog zur Fehlzeiten-Warnung, klassenübergreifend) ──
@@ -440,6 +529,8 @@ public partial class DashboardViewModel : ObservableObject
var from = _sy.SchoolYearStart(schoolYear);
var to = _sy.SchoolYearEnd(schoolYear);
var sessionDates = BuildSessionDateLookup();
var recentVorgaenge = BuildRecentVorgangTagSet();
var activeSnoozes = BuildActiveSnoozeSet(today);
var items = new List<MissingHomeworkWarningItem>();
foreach (var student in _students.GetAll())
@@ -450,14 +541,18 @@ public partial class DashboardViewModel : ObservableObject
if (count >= MissingHomeworkWarningThreshold)
items.Add(new MissingHomeworkWarningItem(student.Id, student.FullName, count));
}
foreach (var item in items.OrderByDescending(i => i.MissingCount))
_missingHomeworkItems.Add(ToAttentionItem(item));
var ordered = items
.Select(i => (Item: i, Defused: IsDefused(i.StudentId, HausaufgabenTag, recentVorgaenge, activeSnoozes)))
.OrderBy(x => x.Defused).ThenByDescending(x => x.Item.MissingCount);
foreach (var (item, defused) in ordered)
_missingHomeworkItems.Add(ToAttentionItem(item, defused));
}
private AttentionItem ToAttentionItem(MissingHomeworkWarningItem w) => new(
private AttentionItem ToAttentionItem(MissingHomeworkWarningItem w, bool isDefused) => new(
AttentionKind.MissingHomework, w.StudentName, trailingText: $"{w.MissingCount} offen",
isWarningTrailing: true,
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, "Hausaufgaben", "Hausaufgaben"));
isWarningTrailing: true, isDefused: isDefused,
actions: [SnoozeAction(w.StudentId, HausaufgabenTag, $"Hausaufgaben – {w.StudentName}")],
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, HausaufgabenTag, "Hausaufgaben"));
// ── Verspätungs-Warnung (Nutzer-Feedback: analog zur Fehlzeiten-Warnung, klassenübergreifend) ──
//
@@ -472,6 +567,8 @@ public partial class DashboardViewModel : ObservableObject
var from = _sy.SchoolYearStart(schoolYear);
var to = _sy.SchoolYearEnd(schoolYear);
var sessionDates = BuildSessionDateLookup();
var recentVorgaenge = BuildRecentVorgangTagSet();
var activeSnoozes = BuildActiveSnoozeSet(today);
var items = new List<LatenessWarningItem>();
foreach (var student in _students.GetAll())
@@ -482,14 +579,18 @@ public partial class DashboardViewModel : ObservableObject
if (count >= LatenessWarningThreshold)
items.Add(new LatenessWarningItem(student.Id, student.FullName, count));
}
foreach (var item in items.OrderByDescending(i => i.LateCount))
_latenessItems.Add(ToAttentionItem(item));
var ordered = items
.Select(i => (Item: i, Defused: IsDefused(i.StudentId, VerspaetungenTag, recentVorgaenge, activeSnoozes)))
.OrderBy(x => x.Defused).ThenByDescending(x => x.Item.LateCount);
foreach (var (item, defused) in ordered)
_latenessItems.Add(ToAttentionItem(item, defused));
}
private AttentionItem ToAttentionItem(LatenessWarningItem w) => new(
private AttentionItem ToAttentionItem(LatenessWarningItem w, bool isDefused) => new(
AttentionKind.Lateness, w.StudentName, trailingText: $"{w.LateCount}× verspätet",
isWarningTrailing: true,
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, "Verspätungen", "Verspätungen"));
isWarningTrailing: true, isDefused: isDefused,
actions: [SnoozeAction(w.StudentId, VerspaetungenTag, $"Verspätungen – {w.StudentName}")],
navigate: () => OnNavigateToStudentCase?.Invoke(w.StudentId, VerspaetungenTag, "Verspätungen"));
// ── Klausurwochen (Nutzer-Feedback) ───────────────────────────────────────
//
@@ -37,6 +37,14 @@
<Setter Property="Width" Value="5"/>
<Setter Property="Background" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
<!-- Entschärft (Nutzer-Feedback): ein offener Vorgang mit passendem Tag wurde kürzlich
bearbeitet, oder die Warnung wurde zurückgestellt (siehe AttentionItem.IsDefused) — grau
statt rot, damit sie nicht mehr die gleiche Dringlichkeit signalisiert, aber als Erinnerung
sichtbar bleibt. Nach ".attendance" deklariert, damit sie bei gleicher Klassenanzahl gewinnt. -->
<Style Selector="Border.severitybar.defused">
<Setter Property="Width" Value="5"/>
<Setter Property="Background" Value="{DynamicResource SystemControlForegroundBaseMediumLowBrush}"/>
</Style>
<Style Selector="TextBlock.attentionTrailing">
<Setter Property="FontSize" Value="10"/>
<Setter Property="Opacity" Value="0.6"/>
@@ -47,6 +55,11 @@
<Setter Property="Opacity" Value="1"/>
<Setter Property="Foreground" Value="{DynamicResource AppStatusDangerBrush}"/>
</Style>
<Style Selector="TextBlock.attentionTrailing.warning.defused">
<Setter Property="Foreground" Value="{DynamicResource SystemControlForegroundBaseMediumBrush}"/>
<Setter Property="FontWeight" Value="Normal"/>
<Setter Property="Opacity" Value="0.7"/>
</Style>
</UserControl.Styles>
<ScrollViewer Padding="24">
@@ -498,7 +511,8 @@
<Border Grid.Column="0" Classes="severitybar"
Classes.high="{Binding IsHighSeverity}"
Classes.medium="{Binding IsMediumSeverity}"
Classes.attendance="{Binding IsCrossClassWarning}" Margin="0,0,9,0"/>
Classes.attendance="{Binding IsCrossClassWarning}"
Classes.defused="{Binding IsDefused}" Margin="0,0,9,0"/>
<!-- Bewusst immer als Button (statt je nach IsClickable ein anderes Root-Element):
ein einheitliches Template für alle sieben Arten. Bei Excuse/MissingTeachingTime
ist Navigate null, der Klick auf die Zeile selbst ist dann folgenlos — die
@@ -513,6 +527,7 @@
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding TrailingText}"
Classes="attentionTrailing" Classes.warning="{Binding IsWarningTrailing}"
Classes.defused="{Binding IsDefused}"
VerticalAlignment="Center" Margin="8,0,0,0"
IsVisible="{Binding HasTrailingText}"/>
<TextBlock Grid.Column="1" Text="{Binding DateDisplay}" FontSize="12"
@@ -0,0 +1,18 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LehrerApp.Desktop.Views.Shared.SnoozeDurationDialog"
x:CompileBindings="False"
Title="Warnung zurückstellen"
Width="420" SizeToContent="Height"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto,Auto" Margin="24" RowSpacing="14">
<TextBlock Grid.Row="0" Text="Warnung zurückstellen" Classes="dialogtitle"/>
<TextBlock Grid.Row="1" Text="{Binding Message}" TextWrapping="Wrap" Opacity="0.8"/>
<Grid Grid.Row="2" ColumnDefinitions="*,8,*,8,*">
<Button Grid.Column="0" Content="1 Woche" HorizontalAlignment="Stretch" Click="OnPick7"/>
<Button Grid.Column="2" Content="2 Wochen" HorizontalAlignment="Stretch" Click="OnPick14"/>
<Button Grid.Column="4" Content="1 Monat" HorizontalAlignment="Stretch" Click="OnPick30"/>
</Grid>
<Button Grid.Row="3" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
</Grid>
</Window>
@@ -0,0 +1,14 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Shared;
public partial class SnoozeDurationDialog : Window
{
public SnoozeDurationDialog() => InitializeComponent();
private void OnPick7(object? sender, RoutedEventArgs e) => Close(7);
private void OnPick14(object? sender, RoutedEventArgs e) => Close(14);
private void OnPick30(object? sender, RoutedEventArgs e) => Close(30);
private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
}
@@ -0,0 +1,7 @@
namespace LehrerApp.Desktop.Views.Shared;
/// Generisches DataContext für <see cref="SnoozeDurationDialog"/>.
public class SnoozeDurationDialogInfo
{
public string Message { get; init; } = "";
}
+21
View File
@@ -1975,6 +1975,27 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
beide Ansichten teilen sich nur `Vorgang`/`VorgangItem`/`VorgangDialogViewModel`, keine
ViewModel-Logik. `AttentionItem.IsAttendanceWarning` wurde zu `IsCrossClassWarning` verallgemeinert
(deckt jetzt alle drei klassenübergreifenden Warnarten mit derselben Balkenfarbe ab).
- **Nachtrag, Entschärfen/Zurückstellen (September 2026):** Nutzer-Feedback: eine der drei
klassenübergreifenden Warnungen bleibt auch nach dem "Reagieren" (Vorgang angelegt) unverändert
rot ganz oben stehen — es fehlte eine sichtbare Rückmeldung, dass sich bereits jemand darum
kümmert, und ein Weg, eine Warnung ohne Vorgang bewusst für eine Weile zu ignorieren. Neues
`AttentionItem.IsDefused` (grau statt rot, ans Ende der jeweiligen Gruppe sortiert) wird jetzt
aus zwei Quellen gespeist: (1) ein **offener** Vorgang mit passendem Tag, dessen `UpdatedAt`
nicht älter als `VorgangDefusalGraceDays` (14 Tage) ist — jede Interaktion mit dem Vorgang
(Speichern über `VorgangRepository.Save`, das `UpdatedAt` ohnehin bei jedem Aufruf setzt)
verlängert die Frist automatisch; (2) ein manuelles **"Zurückstellen"** (neue Inline-Aktion an
jeder der drei Warnungen, Dauer 1/2/4 Wochen per neuem `SnoozeDurationDialog`), gespeichert in
einer neuen, bewusst NICHT synchronisierten Tabelle (`DashboardWarningSnooze`/
`IDashboardWarningSnoozeRepository`, gleiches Muster wie `IUntisCacheFetchStateRepository` — ein
reiner lokaler Erinnerungs-Zeitstempel je (StudentId, WarningTag), keine Fallakten-Daten wie
Vorgang/Documentation, deshalb kein `db.OnChange`). Zentrale Bedingung des Nutzer-Feedbacks:
**beides ist befristet** — läuft die Frist ab, ohne dass sich die zugrunde liegende Zahl
tatsächlich verbessert hat (der Schwellenwert-Vergleich selbst bleibt unverändert; bessert sich
die Zahl, verschwindet die Warnung ohnehin ganz), taucht die Warnung automatisch wieder scharf
auf — es gibt dafür keinen Hintergrund-Job, die Neubewertung passiert einfach bei jedem
`DashboardViewModel.Load()`/`RebuildAttention()`. `DashboardView.axaml` bekam dafür einen neuen
`.defused`-Stil (nach `.attendance` deklariert, damit er bei gleicher Klassenanzahl gewinnt) für
Severity-Balken und Fehlzeiten-Prozent-Text.
**Nachtrag zu 4.3, Bugfix Wochennavigation (September 2026):** Nutzer-Feedback: beim Planen der
nächsten Woche musste nach jedem kurzen Abstecher in einen anderen Bereich der Sidebar wieder