Hausaufgaben feature und Stundeplan fix
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-09-08 18:01:42 +02:00
parent 86e114580f
commit e86125e5f9
7 changed files with 388 additions and 2 deletions
@@ -0,0 +1,229 @@
using System.Collections.ObjectModel;
using System.Text.RegularExpressions;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - <see cref="AssignedStudent"/>
/// kann manuell per Auswahlliste gesetzt werden, gleiches Muster wie
/// <see cref="WebUntisLessonAbsenceComparisonViewModel"/> und
/// <see cref="LehrerApp.Desktop.ViewModels.Students.WebUntisDocumentationComparisonViewModel"/>.</summary>
public partial class WebUntisHomeworkRow : ObservableObject
{
public required string ClassName { get; init; }
public required DateOnly Date { get; init; }
public required string UntisStudentName { get; init; }
public string? SubjectLabel { get; init; }
public string? Text { get; init; }
public required IReadOnlyList<Student> Candidates { get; init; }
internal Guid? MatchedSubjectId { get; init; }
internal Action<WebUntisHomeworkRow>? OnAssignmentChanged { get; init; }
public string DateLabel => Date.ToString("dd.MM.yyyy");
public bool CanApply => SessionId is not null;
[ObservableProperty] private Student? _assignedStudent;
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
[ObservableProperty] private Guid? _sessionId;
[ObservableProperty] private bool _selected;
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
}
/// <summary>Abgleich negativer Klassenbucheinträge zum Stichwort "Hausaufgabe" (eigene, siehe
/// <see cref="WebUntisIntegrationService.GetOwnClassRegisterEventsAsync"/>) gegen
/// <see cref="ParticipationEntry.Homework"/> - Dashboard-weit statt pro Lerngruppe, aus demselben
/// Grund wie beim Dokumentations-Abgleich (der WebUntis-"-alle-"-Bericht ist klassenübergreifend,
/// siehe TODO.md). Nutzer-Feedback: WebUntis liefert für Klassenbucheinträge weder eine externe
/// Schülerkennung noch eine feste Fach-/Lerngruppenzuordnung, aber der Lehrkraft-eigene Text enthält
/// praktisch immer das Untis-Fachkürzel - darüber wird die passende Lerngruppe (SubjectId + aktive
/// Mitgliedschaft am Eintragsdatum) aufgelöst, mehrdeutige Treffer bleiben unaufgelöst statt zu raten.
/// Ein Eintrag setzt lokal ausschließlich <see cref="HomeworkStatus.MissingOpen"/> vor, und auch nur,
/// wenn dort noch gar kein Status hinterlegt ist - bereits vorhandene, feinere Erfassungen ("Teilweise
/// angefertigt", "nachgereicht" usw.) werden nie automatisch überschrieben, sondern nur zum Vergleich
/// danebengestellt (Nutzerwunsch).</summary>
public partial class WebUntisHomeworkComparisonViewModel : ObservableObject
{
private readonly WebUntisIntegrationService _untis;
private readonly IStudentRepository _students;
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly IGroupMembershipRepository _memberships;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
private readonly SchoolYearService _schoolYears;
public ObservableCollection<WebUntisHomeworkRow> Rows { get; } = [];
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-7);
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
[ObservableProperty] private bool _busy;
public WebUntisHomeworkComparisonViewModel(WebUntisIntegrationService untis, IStudentRepository students,
IGroupRepository groups, ISubjectRepository subjects, IGroupMembershipRepository memberships,
IParticipationSessionRepository sessions, IParticipationRepository participation,
SchoolYearService schoolYears)
{
_untis = untis; _students = students; _groups = groups; _subjects = subjects;
_memberships = memberships; _sessions = sessions; _participation = participation;
_schoolYears = schoolYears;
}
[RelayCommand]
private async Task Load()
{
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
Busy = true; Rows.Clear();
try
{
var ownStudents = _students.GetAll();
var globalIndex = BuildNameIndex(ownStudents);
// Wie beim Dokumentations-Abgleich zusätzlich pro Klasse indiziert, um gleiche Namen in
// verschiedenen Klassen unterscheiden zu können.
var classIndexes = _groups.GetAll()
.Where(g => g.Type == GroupType.Class)
.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()),
StringComparer.OrdinalIgnoreCase);
var shortNameIndex = _subjects.GetAll()
.Where(s => !string.IsNullOrWhiteSpace(s.ShortName))
.GroupBy(s => s.ShortName.Trim().ToUpperInvariant())
.Where(g => g.Count() == 1) // mehrdeutiges Kürzel lieber nicht zuordnen als raten
.ToDictionary(g => g.Key, g => g.First());
var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end);
var ordered = entries
.Where(IsMissingHomeworkEntry)
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName);
void ResolveLocalMatch(WebUntisHomeworkRow row)
{
if (row.AssignedStudent is not { } student)
{
row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
return;
}
var group = ResolveGroup(student, row.MatchedSubjectId, row.Date);
var session = group is null ? null
: _sessions.GetByGroup(group.Id).FirstOrDefault(s => s.Date == row.Date);
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id);
var current = entry is null ? null : HomeworkDisplay.Effective(entry);
row.SessionId = session?.Id;
row.LocalStatus = session is null
? row.MatchedSubjectId is null ? "Fachkürzel nicht erkannt"
: group is null ? "kein passender Kurs gefunden" : "keine lokale Stunde"
: HomeworkDisplay.Label(current);
// Nur vorbelegen, wenn lokal noch überhaupt nichts erfasst ist - jeder vorhandene
// Status (auch ein bereits gesetztes "fehlt") bleibt unangetastet, siehe Klassenkommentar.
row.Selected = session is not null && current is null;
}
foreach (var (entry, date) in ordered)
{
var nameKey = NameKey(entry.StudentName);
var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex)
? classIndex.GetValueOrDefault(nameKey)
: null)
?? globalIndex.GetValueOrDefault(nameKey);
var subject = MatchSubject(entry.Text, shortNameIndex) ?? MatchSubject(entry.CategoryName, shortNameIndex);
var row = new WebUntisHomeworkRow
{
ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName,
SubjectLabel = subject?.ShortName, Text = entry.Text, Candidates = ownStudents,
MatchedSubjectId = subject?.Id, OnAssignmentChanged = ResolveLocalMatch,
};
Rows.Add(row);
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
}
var unresolvedStudent = Rows.Count(x => x.AssignedStudent is null);
var unresolvedSubject = Rows.Count(x => x.MatchedSubjectId is null);
Status = $"{Rows.Count} Einträge \"fehlende Hausaufgabe\" erhalten" +
(unresolvedStudent > 0 ? $", {unresolvedStudent} bitte manuell zuordnen" : "") +
(unresolvedSubject > 0 ? $", bei {unresolvedSubject} kein Fachkürzel im Text erkannt" : "") +
$". {Rows.Count(x => x.CanApply)} einer lokalen Stunde zuordenbar.";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; }
}
[RelayCommand]
private void Apply()
{
var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList();
foreach (var row in selected)
{
var studentId = row.AssignedStudent!.Id;
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
entry.Homework = HomeworkStatus.MissingOpen;
entry.HomeworkMissing = true;
entry.UpdatedAt = DateTime.UtcNow;
_participation.Save(entry);
row.LocalStatus = HomeworkDisplay.Label(HomeworkStatus.MissingOpen);
row.Selected = false;
}
Status = $"{selected.Count} Hausaufgaben-Status übernommen.";
}
/// Nur Lerngruppen (nicht die Klasse selbst), bei denen der/die Schüler*in am Eintragsdatum aktiv
/// Mitglied ist und deren Fach zum erkannten Kürzel passt - mehr als ein Treffer bleibt bewusst
/// unaufgelöst statt irgendeinen davon zu wählen.
private LearningGroup? ResolveGroup(Student student, Guid? subjectId, DateOnly date)
{
if (subjectId is null) return null;
var schoolYear = _schoolYears.CurrentSchoolYear(date);
var candidates = _memberships.GetByStudent(student.Id)
.Where(m => GroupMembershipService.IsActiveOn(m, date))
.Select(m => _groups.GetById(m.GroupId))
.Where(g => g is not null && g.SubjectId == subjectId && g.SchoolYear == schoolYear)
.Cast<LearningGroup>()
.ToList();
return candidates.Count == 1 ? candidates[0] : null;
}
// Gleiche Heuristik wie ClassTeacherDetailsViewModel.ContainsHomework, zusätzlich auf negative
// Einträge eingeschränkt (eine positive "Hausaufgabe"-Kategorie wäre kein Fehlen-Signal).
private static bool IsMissingHomeworkEntry(UntisClassRegisterEventDto entry) =>
string.Equals(entry.CategoryGroup, "Negativ", StringComparison.OrdinalIgnoreCase) &&
(entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true);
private static Subject? MatchSubject(string? text, IReadOnlyDictionary<string, Subject> shortNameIndex)
{
if (string.IsNullOrWhiteSpace(text)) return null;
foreach (var token in Regex.Split(text, @"[^\p{L}\p{Nd}]+"))
if (token.Length > 0 && shortNameIndex.TryGetValue(token.ToUpperInvariant(), out var subject))
return subject;
return null;
}
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
// Wie bei den übrigen WebUntis-Abgleichen: beide Namensreihenfolgen registriert, aber nur falls
// innerhalb der Kandidaten eindeutig.
private static Dictionary<string, Student> BuildNameIndex(IReadOnlyList<Student> candidates) =>
candidates
.SelectMany(student => new[]
{
NameKey($"{student.LastName} {student.FirstName}"),
NameKey($"{student.FirstName} {student.LastName}"),
}.Select(key => (Key: key, Student: student)))
.GroupBy(x => x.Key)
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
.ToDictionary(group => group.Key, group => group.First().Student);
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
@@ -150,8 +150,11 @@ public partial class MainWindowViewModel : ObservableObject
private TimetableViewModel GetTimetable()
{
// WeekOffset bewusst nicht zurücksetzen: TimetableViewModel ist ein DI-Singleton, die
// zuletzt angezeigte Woche bleibt also automatisch über Tab-Wechsel hinweg erhalten
// (Nutzer-Feedback: beim Planen der nächsten Woche nervt es, nach jedem kurzen Abstecher
// in einen anderen Bereich wieder manuell dorthin zurückblättern zu müssen).
var timetable = _services.GetRequiredService<TimetableViewModel>();
timetable.WeekOffset = 0;
timetable.Load();
timetable.ActiveTabIndex = 0;
return timetable;
@@ -28,6 +28,8 @@
<WrapPanel Grid.Row="1" Orientation="Horizontal" ItemSpacing="8" LineSpacing="8" Margin="0,10,0,0">
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
VerticalAlignment="Center"/>
<Button Content="Fehlende Hausaufgaben abgleichen…" Click="OnCompareWebUntisHomeworkClick"
VerticalAlignment="Center"/>
<Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
VerticalAlignment="Center"/>
</WrapPanel>
@@ -5,7 +5,9 @@ using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Desktop.Views.Workload;
using Microsoft.Extensions.DependencyInjection;
@@ -42,4 +44,20 @@ public partial class DashboardView : UserControl
App.Services.GetRequiredService<SchoolYearService>());
await new WebUntisDocumentationComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
}
private async void OnCompareWebUntisHomeworkClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return;
var dialogVm = new WebUntisHomeworkComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<ISubjectRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
await new WebUntisHomeworkComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
}
}
@@ -0,0 +1,58 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisHomeworkComparisonDialog"
x:DataType="vm:WebUntisHomeworkComparisonViewModel"
Title="Fehlende Hausaufgaben mit WebUntis abgleichen" Width="1100" Height="680"
MinWidth="900" MinHeight="480" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="24" RowSpacing="8">
<StackPanel Grid.Row="0" Spacing="4">
<TextBlock Text="Fehlende Hausaufgaben mit WebUntis abgleichen" Classes="dialogtitle"/>
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.65"
Text="Nur eigene, negative Klassenbucheinträge zum Stichwort „Hausaufgabe“. Fach wird über das Untis-Fachkürzel im Text erkannt. Zeilen mit bereits vorhandenem lokalem Hausaufgaben-Status (z.B. „Teilweise“, „nachgereicht“) werden nie überschrieben, nur zum Vergleich angezeigt."/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
<CalendarDatePicker SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/>
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
</StackPanel>
<Grid Grid.Row="2" ColumnDefinitions="Auto,70,60,55,1.1*,1.1*,1.4*,1.2*" ColumnSpacing="8" Margin="4,0">
<TextBlock Grid.Column="1" Text="Datum" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="2" Text="Klasse" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="3" Text="Fach" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="4" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="5" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="6" Text="Text" FontSize="11" Opacity="0.6"/>
<TextBlock Grid.Column="7" Text="Lokaler Hausaufgaben-Status" FontSize="11" Opacity="0.6"/>
</Grid>
<ScrollViewer Grid.Row="3">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WebUntisHomeworkRow">
<Grid ColumnDefinitions="Auto,70,60,55,1.1*,1.1*,1.4*,1.2*" ColumnSpacing="8" Margin="0,3">
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
<TextBlock Grid.Column="1" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding ClassName}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding SubjectLabel}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
<ComboBox Grid.Column="5" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
DisplayMemberBinding="{Binding FullName}" PlaceholderText="Schüler*in wählen…"
HorizontalAlignment="Stretch"/>
<TextBlock Grid.Column="6" Text="{Binding Text}" TextWrapping="Wrap" VerticalAlignment="Center"/>
<TextBlock Grid.Column="7" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Grid Grid.Row="4" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Schließen" Click="OnClose"/>
<Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Groups;
public partial class WebUntisHomeworkComparisonDialog : Window
{
public WebUntisHomeworkComparisonDialog() => InitializeComponent();
private void OnClose(object? sender, RoutedEventArgs e) => Close();
}
+66 -1
View File
@@ -982,7 +982,9 @@ angezeigten Woche geklebt, nicht nur in der einen Woche, zu der es gehört). Fü
berechnen `HolidayBadgeFor`/`IsLastBeforeExamFor` die Badges deshalb jetzt je Zelle am dort
gezeigten Datum statt an "heute" — das Bearbeiten-Raster (zeigt ohnehin nur das wiederkehrende
Muster ohne Datum) behält die alte, "heute"-verankerte Berechnung. Beim erneuten Navigieren in den
Stundenplan (Sidebar-Klick) springt die Ansicht wieder auf die laufende Woche zurück.
Stundenplan (Sidebar-Klick) sprang die Ansicht ursprünglich wieder auf die laufende Woche zurück
siehe "Nachtrag zu 4.3, Bugfix Wochennavigation (September 2026)" am Ende dieses Abschnitts, wo
dieses Verhalten auf Nutzer-Feedback hin wieder entfernt wurde.
**Nachtrag zu 4.3, fünfte Iteration (Aufsichten + Vertretung):** Nutzer-Feedback: zwischen manchen
Stunden ist auch Pausenaufsicht zu erledigen, und gelegentlich kommen Sonderfälle vor — eine
@@ -1930,6 +1932,69 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
Vorgänge je Klassen-Roster-Namen über denselben Namensabgleich wie die übrigen `Build*`-Methoden
dieser Klasse auf (`IVorgangRepository.GetByStudent`).
**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
manuell zur zuvor betrachteten Woche zurückgeblättert werden ("man muss nicht nur einmal
hinspringen, sondern jedes Mal wieder neu"). Ursache war das in der vierten Iteration dokumentierte
Verhalten, dass `MainWindowViewModel.GetTimetable()` bei jedem erneuten Aufruf der Seitenleisten-
Navigation `TimetableViewModel.WeekOffset` explizit auf 0 zurücksetzte — obwohl `TimetableViewModel`
als DI-Singleton ohnehin über die gesamte Sitzung hinweg bestehen bleibt und `WeekOffset` daher ohne
diese Zeile ganz von selbst erhalten bliebe. Der Reset wurde entfernt; ein separater Zustands-
Singleton/eine statische Variable dafür war nicht nötig. Das erklärt vermutlich auch den zweiten
gemeldeten Effekt ("der Zurück-Button springt manchmal zwei Wochen zurück"): wer z.B. in Woche +1
plante, kurz wegnavigierte und danach einmal "" klickte, landete wegen des Resets in Woche -1 statt
der erwarteten Woche 0 — gefühlt ein Sprung um zwei Wochen, obwohl `PreviousWeekCommand`/
`NextWeekCommand` selbst korrekt je Klick nur um eine Woche verschieben (siehe
`NextWeek_BadgeGehoertZurAngezeigtenWoche_NichtZuHeute`, das zwei aufeinanderfolgende Klicks bewusst
als zwei Wochen erwartet). Der "Diese Woche"-Button existierte bereits seit der vierten Iteration
(`CurrentWeekCommand`, nur sichtbar wenn `!IsCurrentWeek`).
**Nachtrag zu 4.3, Abgleich fehlender Hausaufgaben (September 2026):** Nutzer-Feedback: negative
Klassenbucheinträge zur Kategorie "fehlende HA" sind ein brauchbares Signal, ob ein/e Schüler*in die
Hausaufgabe nicht gemacht hat, und sollen als Vorschlag mit der eigenen Erfassung
(`ParticipationEntry.Homework`) abgeglichen werden können — ausdrücklich nicht die erteilte
Hausaufgabe selbst (`Lesson.Homework`), das bleibt ein offener, separater Punkt.
- Neuer Dashboard-weiter Dialog `WebUntisHomeworkComparisonViewModel`/`WebUntisHomeworkComparisonDialog`
([LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs](LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs)),
Button "Fehlende Hausaufgaben abgleichen…" neben dem bereits vorhandenen "Klassenbuch
abgleichen…" auf dem Dashboard. Baut auf demselben Muster wie
`WebUntisDocumentationComparisonViewModel` (Dashboard-weit, da der WebUntis-"-alle-"-Bericht
klassenübergreifend ist) und `WebUntisLessonAbsenceComparisonViewModel` (schreibt wie dort über
`IParticipationRepository.GetBySessionAndStudent`/`Save`).
- **Filter:** `CategoryGroup == "Negativ"` und `CategoryName`/`Text` enthält "Hausauf" — dieselbe
Substring-Heuristik wie `ClassTeacherDetailsViewModel.ContainsHomework`, nur zusätzlich auf
negative Einträge eingeschränkt.
- **Fach-Erkennung:** Der Klassenbuch-Bericht liefert kein zuverlässiges Fach-Feld, aber laut Nutzer
enthält der mitgeschickte Text praktisch immer das Untis-Fachkürzel. Deshalb wird `Text` (Fallback
`CategoryName`) gegen `Subject.ShortName` alle Lerngruppen-Fächer abgeglichen (Wortgrenzen-Split,
nicht `Contains`, um kurze Kürzel nicht versehentlich in anderen Wörtern zu treffen); mehrdeutige
Kürzel (zwei Fächer mit demselben `ShortName`) werden bewusst nicht zugeordnet statt zu raten.
- **Lerngruppe/Stunde auflösen:** aus dem per Namensabgleich gefundenen `Student` werden über
`IGroupMembershipRepository.GetByStudent` alle am Eintragsdatum aktiven Mitgliedschaften
(`GroupMembershipService.IsActiveOn`) genommen und auf das erkannte Fach + Schuljahr des
Eintragsdatums eingeschränkt; bleibt mehr als eine Lerngruppe übrig, bleibt die Zeile unaufgelöst
statt irgendeine zu wählen (gleiches Vorsichtsprinzip wie beim Namensabgleich). Die
`ParticipationSession` der Lerngruppe am Eintragsdatum liefert dann die Stunde für
`IParticipationRepository`.
- **Kein Überschreiben (Kernanforderung):** eine Zeile wird nur vorausgewählt, wenn
`HomeworkDisplay.Effective(entry)` noch `null` ist, also lokal überhaupt noch nichts erfasst wurde
— jeder bereits vorhandene Status (auch ein bereits gesetztes "fehlt", nicht nur "Teilweise"/
"nachgereicht") bleibt unangetastet und wird nur zum Vergleich daneben angezeigt; die Checkbox
bleibt trotzdem bedienbar, falls doch bewusst überschrieben werden soll. Übernommen wird immer nur
`HomeworkStatus.MissingOpen` ("Nachreichen offen") — nie automatisch `MissingOverdue`, die weitere
Nachverfolgung bleibt laut Nutzer App-intern.
- **Bewusst kein Caching:** `GetOwnClassRegisterEventsAsync` wird wie beim bereits bestehenden
`WebUntisDocumentationComparisonViewModel` direkt (ungecacht) aufgerufen statt über
`UntisReportCacheService`, dessen Cache-Tabellen aktuell klassen-/`GetForeignClassRegisterEventsAsync`-
spezifisch aufgebaut sind (ein Schema-Umbau für die eigenen, klassenübergreifenden Einträge wäre ein
eigenständiger, deutlich größerer Umbau gewesen, siehe Nutzer-Rückfrage). Beide "eigene Einträge"-
Dialoge lösen pro Öffnen einen eigenen Live-Abruf aus; falls das in der Praxis stört, wäre eine
gemeinsame Cache-Erweiterung ein sinnvoller separater Nachtrag.
- Aus Konsistenz mit den beiden bestehenden, strukturell identischen Abgleichs-ViewModels bewusst
ungetestet gelassen: `WebUntisIntegrationService` ist `sealed` und nicht auf ein Interface
gemockt, `WebUntisLessonAbsenceComparisonViewModel`/`WebUntisDocumentationComparisonViewModel`
haben aus demselben Grund ebenfalls keine Tests.
### 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.