Kurs-Übersicht: fehlende Hausaufgaben; Erinnerungen als eigene Aufgabenart; Kalender-/Sitzplan-Bugfixes

Kurs-Übersicht: neue Karte "Fehlende Hausaufgaben" listet Schüler mit nicht
gemachter Hausaufgabe aus der letzten Mitarbeitssitzung.

Aufgabenverwaltung: WorkTask.Kind unterscheidet jetzt Arbeitsaufträge von
reinen Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse
machen") - eigener Button "🔔 Erinnerung", eigenes Icon in Liste/Dashboard,
läuft nicht in die Zeitauswertung ein.

Drei Bugfixes aus Nutzer-Feedback:
- Dashboard-Kalender zeigte eine Stunde doppelt, wenn sie über "Sitzung
  erzeugen" mit einer Mitarbeitssitzung verknüpft war.
- Der Sitzplan-Tab legte beim bloßen Öffnen einer Gruppe (nicht erst bei
  echter Nutzung) eine leere "Sitzplan"-Sitzung für heute an.
- Der "Meine Klasse"-Ring im Kalender erschien erst, sobald für den Tag
  eine Lesson existierte, statt schon laut Stundenplan.

Außerdem: Drawer-Icons pinnen jetzt explizit auf die farbige Emoji-Schrift
(Windows kann sonst je nach Font-Fallback auf eine einfarbige Variante
ausweichen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 23:17:18 +02:00
co-authored by Claude Sonnet 5
parent 4d2a89b14b
commit 52a1d45539
16 changed files with 505 additions and 57 deletions
@@ -25,9 +25,9 @@ public partial class GroupOverviewViewModel : ObservableObject
/// Unterhalb dieser Anzahl erfasster Anwesenheits-Einträge im laufenden Schuljahr wird eine
/// hohe Fehlquote bewusst NICHT gemeldet — wer zu Schuljahresbeginn zweimal fehlt, hat rein
/// rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
/// Stichprobe. Dasselbe Problem hat aktuell noch die Fehlzeiten-Warnung im Hauptdashboard
/// (AttendanceBalanceService/AttendanceWarningItem) — bewusst unangetastet, wird dort separat
/// nachgezogen.
/// Stichprobe. Dieselbe Konstante wie DashboardViewModel.AttendanceMinSampleSize
/// (Hauptdashboard-Fehlzeiten-Warnung, 5.2.3) — dort dasselbe Problem, mittlerweile ebenfalls
/// gefixt.
private const int AttendanceMinSampleSize = 8;
private readonly ILessonRepository _lessons;
@@ -55,6 +55,10 @@ public partial class GroupOverviewViewModel : ObservableObject
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public bool HasOpenExcuses => OpenExcuses.Count > 0;
[ObservableProperty] private string _missingHomeworkSessionLabel = "";
public ObservableCollection<MissingHomeworkItem> MissingHomeworkStudents { get; } = [];
public bool HasMissingHomework => MissingHomeworkStudents.Count > 0;
/// Absichtlich zurückhaltender formuliert/gestylt als das Hauptdashboard (kein "Warnfall") —
/// siehe <see cref="AttendanceMinSampleSize"/>.
public ObservableCollection<AttendanceWarningItem> AttendanceNotices { get; } = [];
@@ -97,6 +101,7 @@ public partial class GroupOverviewViewModel : ObservableObject
LoadOpenExcuses(today);
LoadDraftDocumentationCount();
LoadAttendanceNotices(today);
LoadMissingHomework();
}
private void LoadNextLesson(DateOnly today)
@@ -200,10 +205,9 @@ public partial class GroupOverviewViewModel : ObservableObject
.Count(d => d.IsDraft && (d.GroupId is null || d.GroupId == _groupId));
}
/// Bewusst kein 1:1-Abbild der Dashboard-Fehlzeiten-Warnung: dieselbe Quote
/// (AttendanceBalanceService.WarningThresholdPercent), aber erst ab
/// <see cref="AttendanceMinSampleSize"/> erfassten Terminen, damit ein Fehltag in der ersten
/// Schulwoche nicht sofort als auffällig gilt.
/// Dieselbe Mindeststichprobe wie DashboardViewModel.LoadAttendanceWarnings
/// (AttendanceMinSampleSize) — erst ab genug erfassten Terminen wird eine hohe Quote gemeldet,
/// damit ein Fehltag in der ersten Schulwoche nicht sofort als auffällig gilt.
private void LoadAttendanceNotices(DateOnly today)
{
AttendanceNotices.Clear();
@@ -225,15 +229,41 @@ public partial class GroupOverviewViewModel : ObservableObject
var items = new List<AttendanceWarningItem>();
foreach (var student in _students.GetByGroup(_groupId))
{
if (!entriesByStudent.TryGetValue(student.Id, out var entries) || entries.Count < AttendanceMinSampleSize)
continue;
if (!entriesByStudent.TryGetValue(student.Id, out var entries)) continue;
var balance = _attendanceBalance.Calculate(entries, from, to);
if (balance.ExceedsThreshold)
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))
AttendanceNotices.Add(item);
OnPropertyChanged(nameof(HasAttendanceNotices));
}
/// Schüler mit nicht gemachter Hausaufgabe aus der letzten Mitarbeitssitzung
/// (ParticipationEntry.HomeworkMissing, gepflegt über HomeworkDisplay.CountsAsMissing) —
/// bewusst nur die letzte Sitzung, nicht die gesamte Historie, analog zur
/// Hausaufgaben-Kontrolle in <see cref="LoadOpenHomeworkCheck"/>.
private void LoadMissingHomework()
{
MissingHomeworkStudents.Clear();
var last = _sessions.GetByGroup(_groupId).OrderByDescending(s => s.Date).FirstOrDefault();
if (last is not null)
{
MissingHomeworkSessionLabel = $"Aus der Sitzung vom {last.Date:dd.MM.yyyy}:";
foreach (var entry in _entries.GetBySession(last.Id).Where(e => e.HomeworkMissing))
{
var student = _students.GetById(entry.StudentId);
if (student is not null)
MissingHomeworkStudents.Add(new MissingHomeworkItem(student.FullName, HomeworkDisplay.Label(entry.Homework)));
}
}
OnPropertyChanged(nameof(HasMissingHomework));
}
}
public sealed class MissingHomeworkItem(string studentName, string statusLabel)
{
public string StudentName { get; } = studentName;
public string StatusLabel { get; } = statusLabel;
}
@@ -76,22 +76,39 @@ public partial class SeatingPlanTabViewModel : ObservableObject
NotifyCommands();
}
/// Legt bewusst KEINE Sitzung an, nur weil der Tab geöffnet wird — Initialize() läuft für
/// jede Gruppe schon beim bloßen Navigieren zur Kursübersicht (GroupDetailViewModel.LoadGroup),
/// unabhängig davon, ob der Sitzplan-Tab überhaupt angesehen wird. Eine Sitzung entsteht erst
/// bei der ersten tatsächlichen Bewertung/Markierung, siehe EnsureTodaySession().
private void LoadTodaySessions()
{
TodaySessions.Clear();
var today = DateOnly.FromDateTime(DateTime.Today);
var sessions = _sessions.GetByGroup(_groupId).Where(s => s.Date == today)
.OrderBy(s => s.CreatedAt).ToList();
if (sessions.Count == 0 && !_isReadOnly)
{
var created = new ParticipationSession { GroupId = _groupId, Date = today, Comment = "Sitzplan" };
_sessions.Save(created);
sessions.Add(created);
}
foreach (var session in sessions) TodaySessions.Add(new ParticipationSessionOption(session));
SelectedSession = TodaySessions.LastOrDefault();
}
/// Legt bei Bedarf die "Sitzung für heute" an, mit der Sitzplan-Bewertungen/-Markierungen
/// verknüpft werden — aber erst, wenn tatsächlich etwas bewertet/markiert wird (AssessStudent/
/// ToggleSituationTag), nicht schon beim Öffnen des Tabs.
private ParticipationSessionOption? EnsureTodaySession()
{
if (SelectedSession is not null) return SelectedSession;
if (!IsEditable) return null;
var created = new ParticipationSession
{
GroupId = _groupId, Date = DateOnly.FromDateTime(DateTime.Today), Comment = "Sitzplan",
};
_sessions.Save(created);
var option = new ParticipationSessionOption(created);
TodaySessions.Add(option);
SelectedSession = option;
return option;
}
partial void OnSelectedSessionChanged(ParticipationSessionOption? value) => RefreshSeatLessonData();
private void LoadStudentOptions()
@@ -193,19 +210,21 @@ public partial class SeatingPlanTabViewModel : ObservableObject
private void ToggleSituationTag(SeatCellViewModel seat, string tag)
{
if (!IsEditable || _documentation is null || SelectedSession is null ||
if (!IsEditable || _documentation is null ||
seat.SelectedOption.StudentId is not Guid studentId) return;
var session = EnsureTodaySession();
if (session is null) return;
var draft = _documentation.GetByStudent(studentId)
.FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == SelectedSession.Id);
.FirstOrDefault(d => d.IsDraft && d.ParticipationSessionId == session.Id);
if (draft is null)
{
draft = new Documentation
{
StudentId = studentId, GroupId = _groupId,
ParticipationSessionId = SelectedSession.Id,
LessonId = SelectedSession.LessonId,
ParticipationSessionId = session.Id,
LessonId = session.LessonId,
Type = DocumentationType.Incident,
Date = SelectedSession.Date,
Date = session.Date,
Title = tag,
IsDraft = true,
Tags = [tag],
@@ -297,9 +316,10 @@ public partial class SeatingPlanTabViewModel : ObservableObject
public async Task AssessStudent(SeatCellViewModel seat)
{
if (!seat.SelectedOption.StudentId.HasValue || OnAssessStudent is null) return;
var session = EnsureTodaySession();
var assessment = new SeatAssessmentViewModel(_sessions, _participation, _aspects,
_groupId, seat.SelectedOption.StudentId.Value, seat.SelectedOption.DisplayName, IsEditable,
SelectedSession?.Id);
session?.Id);
await OnAssessStudent(assessment);
RefreshSeatLessonData();
OnAssessmentChanged?.Invoke();