Defuse handled dashboard warnings and add a snooze action
CI / build-and-test (push) Canceled after 0s
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:
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; }
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user