diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs
index 25db686..772cd1e 100644
--- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs
+++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs
@@ -905,6 +905,25 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(["Ada"], result.Select(d => d.Title));
}
+ [Fact]
+ public void FilterOwnDocumentation_ZeigtVerwaistenKlassenEintragUndFindetTeilnehmerPerVorname()
+ {
+ var rosterMatches = new List<(Guid StudentId, string DisplayName)>
+ {
+ (Guid.NewGuid(), "Karim Alshurbaji"), (Guid.NewGuid(), "Saleh Al-Anezi"),
+ };
+ var orphan = new Documentation
+ {
+ StudentId = Guid.Empty, GroupId = null, Date = new DateOnly(2026, 9, 7),
+ Title = "Vorfall", Participants = ["Karim", "Saleh"],
+ };
+
+ var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation([orphan], rosterMatches,
+ new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30), "Karim");
+
+ Assert.Same(orphan, Assert.Single(result));
+ }
+
// ── Vorgang: Fallmappe für Klassenbuch- und Dokumentationseinträge ───────────────────────────
[Fact]
diff --git a/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs
index faf0cc1..7f5d394 100644
--- a/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs
@@ -228,6 +228,20 @@ public sealed class DocumentationDialogViewModelTests
Assert.NotEqual("", vm.StudentError);
}
+ [Fact]
+ public void OhneGeladeneSchuelerlisteUndOhneKontext_SaveErzeugtKeinenVerwaistenEintrag()
+ {
+ var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage())
+ {
+ Title = "Vorfall", TypeName = "Vorkommnis",
+ };
+
+ vm.SaveCommand.Execute(null);
+
+ Assert.Null(vm.Result);
+ Assert.Contains("noch nicht geladen", vm.StudentError);
+ }
+
[Fact]
public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe()
{
diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs
index 1241703..daa4da2 100644
--- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs
@@ -219,6 +219,25 @@ public sealed class SettingsViewModelTests
Assert.Empty(queue.GetUnreviewed());
}
+ [Fact]
+ public void ClearSyncConflicts_LeertProtokollUndQueue()
+ {
+ var queue = TestSupport.BuildEventQueue();
+ foreach (var resolution in new[] { "LocalWon", "RemoteWon" })
+ queue.AddConflict(new ConflictEntry
+ {
+ LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
+ RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
+ Resolution = resolution,
+ });
+ var vm = BuildViewModel(eventQueue: queue);
+
+ vm.ClearSyncConflictsCommand.Execute(null);
+
+ Assert.Empty(vm.SyncConflicts);
+ Assert.Empty(queue.GetUnreviewed());
+ }
+
[Fact]
public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt()
{
diff --git a/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs b/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs
index 7bef6ab..b0ebc33 100644
--- a/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs
+++ b/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs
@@ -61,7 +61,7 @@ public sealed class UntisSyncServiceTests
}
[Fact]
- public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung()
+ public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndUeberspringtUnveraenderteWiederholung()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
@@ -81,7 +81,7 @@ public sealed class UntisSyncServiceTests
// Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId.
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
- Assert.Equal(1, second.SubstitutionCount);
+ Assert.Equal(0, second.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
@@ -278,7 +278,7 @@ public sealed class UntisSyncServiceTests
// Erneuter Poll mit demselben, weiterhin unverändert vorhandenen Termin darf keinen
// zweiten Eintrag erzeugen (Idempotenz über ExternalId=Uid).
var second = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
- Assert.Equal(1, second.SubstitutionCount);
+ Assert.Equal(0, second.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
diff --git a/LehrerApp.Desktop/Services/UntisSyncService.cs b/LehrerApp.Desktop/Services/UntisSyncService.cs
index a4818f4..cbdbd36 100644
--- a/LehrerApp.Desktop/Services/UntisSyncService.cs
+++ b/LehrerApp.Desktop/Services/UntisSyncService.cs
@@ -118,11 +118,20 @@ public class UntisSyncService : IDisposable
var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today,
existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today));
+ var savedSubstitutionCount = 0;
foreach (var candidate in diffResult.SubstitutionsToSave)
{
var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null;
- if (existing is not null) candidate.Id = existing.Id;
+ // Der Diff liefert auch weiterhin bestehende WebUntis-Abweichungen als Kandidaten.
+ // Ein unverändertes Upsert würde über Repository.OnChange bei jedem Poll erneut ein
+ // Sync-Ereignis erzeugen und bei vielen aktiven Abweichungen das Protokoll fluten.
+ if (existing is not null)
+ {
+ candidate.Id = existing.Id;
+ if (SubstitutionContentEquals(existing, candidate)) continue;
+ }
_substitutions.Save(candidate);
+ savedSubstitutionCount++;
}
// Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe
// UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser
@@ -135,9 +144,17 @@ public class UntisSyncService : IDisposable
foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot);
foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id);
- return new UntisPollResult(events.Count, diffResult.SubstitutionsToSave.Count);
+ return new UntisPollResult(events.Count, savedSubstitutionCount);
}
+ private static bool SubstitutionContentEquals(SubstitutionEntry left, SubstitutionEntry right) =>
+ left.Date == right.Date && left.Kind == right.Kind &&
+ left.PeriodNumber == right.PeriodNumber && left.AfterPeriod == right.AfterPeriod &&
+ left.FromPeriod == right.FromPeriod && left.ToPeriod == right.ToPeriod &&
+ left.IsAllDay == right.IsAllDay && left.GroupId == right.GroupId &&
+ left.GroupLabel == right.GroupLabel && left.Description == right.Description &&
+ left.Notes == right.Notes && left.ExternalId == right.ExternalId;
+
// Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster
// hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive
// "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall
diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs
index ad9854e..0b4fc45 100644
--- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs
@@ -199,6 +199,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
public bool ShowAbsenceListEmpty => !ShowMonthlyCalendar && !HasAbsenceEntries;
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0;
+ public bool CanAddOwnDocumentation => !Busy && _rosterMatches.Count > 0;
/// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen ()
/// — im Modus "Eigene Dokumentation" ausgeblendet, dort gibt es kein Äquivalent zu CategoryGroup.
public bool ShowCategoryAggregates => HasCategoryAggregates && !ShowOwnDocumentation;
@@ -431,7 +432,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
_loadedStart, _loadedEnd, StudentFilter);
foreach (var d in entries)
{
- var name = _rosterMatches.First(m => m.StudentId == d.StudentId).DisplayName;
+ var name = DocumentationStudentDisplay(d, _rosterMatches);
OwnDocumentationEntries.Add(new DocumentationItem(d, name));
}
OwnDocumentationCount = OwnDocumentationEntries.Count;
@@ -452,7 +453,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
{
var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet();
return all
- .Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId) && d.Date >= start && d.Date <= end)
+ .Where(d => !d.IsDeleted &&
+ (matchedIds.Contains(d.StudentId) || IsUnassignedClassDocumentation(d)) &&
+ d.Date >= start && d.Date <= end)
.Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter))
.OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date)
.ToList();
@@ -462,14 +465,37 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, string studentFilter)
{
if (string.IsNullOrWhiteSpace(studentFilter)) return true;
+ if (IsUnassignedClassDocumentation(d))
+ return d.Participants.Any(p => NameContainsSearchTerm(p, studentFilter));
var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName;
- return name is not null && UntisNameMatching.NamesMatch(name, studentFilter);
+ return name is not null && NameContainsSearchTerm(name, studentFilter);
+ }
+
+ private static bool IsUnassignedClassDocumentation(Documentation d) =>
+ d.StudentId == Guid.Empty && d.GroupId is null;
+
+ private static bool NameContainsSearchTerm(string name, string search)
+ {
+ if (UntisNameMatching.NamesMatch(name, search)) return true;
+ var searchKey = UntisNameMatching.NameKey(search);
+ return searchKey.Length > 0 && UntisNameMatching.NameKey(name).Split(' ').Contains(searchKey);
+ }
+
+ private static string DocumentationStudentDisplay(Documentation d,
+ IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches)
+ {
+ if (d.StudentId != Guid.Empty)
+ return rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName
+ ?? "Unbekannter Schülerbezug";
+ return d.Participants.Count > 0
+ ? $"Ohne festen Bezug · {string.Join(", ", d.Participants)}"
+ : "Ohne Schülerbezug";
}
[RelayCommand]
private async Task AddOwnDocumentation()
{
- if (OnEditOwnDocumentation is null) return;
+ if (OnEditOwnDocumentation is null || !CanAddOwnDocumentation) return;
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
var result = await OnEditOwnDocumentation(options, null);
if (result is null) return;
@@ -553,6 +579,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
OnPropertyChanged(nameof(HasCategoryAggregates));
OnPropertyChanged(nameof(ShowCategoryAggregates));
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
+ OnPropertyChanged(nameof(CanAddOwnDocumentation));
OnPropertyChanged(nameof(UntisCriticalCount));
}
}
diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Sync.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Sync.cs
index a234b6d..660761e 100644
--- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Sync.cs
+++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Sync.cs
@@ -229,6 +229,13 @@ public partial class SettingsViewModel
_eventQueue.MarkReviewed(item.Id);
SyncConflicts.Remove(item);
}
+
+ [RelayCommand]
+ private void ClearSyncConflicts()
+ {
+ _eventQueue.ClearConflicts();
+ SyncConflicts.Clear();
+ }
}
public class SyncConflictListItem(ConflictEntry c)
diff --git a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs
index 047391e..051897f 100644
--- a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs
@@ -304,6 +304,11 @@ public partial class DocumentationDialogViewModel : ObservableObject
var valid = true;
if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; }
+ if (!CanPickStudent && _studentId == Guid.Empty && _contextGroupId is null)
+ {
+ StudentError = "Die Schülerliste ist noch nicht geladen. Dialog schließen und erneut öffnen.";
+ valid = false;
+ }
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml
index 5a25f53..bf78571 100644
--- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml
+++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml
@@ -175,6 +175,7 @@
diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
index 5960ae9..a455f35 100644
--- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
+++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
@@ -895,11 +895,15 @@
IsVisible="{Binding PairingStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
-
+
+
+
+
+ Text="Ein anderes Gerät hat dieselbe Änderung gleichzeitig gemacht. Der Konflikt ist bereits automatisch aufgelöst; hier steht nur das Ergebnis zur Kontrolle."/>
diff --git a/LehrerApp.Sync.Tests/EventQueueTests.cs b/LehrerApp.Sync.Tests/EventQueueTests.cs
index a9bbf26..65c22d4 100644
--- a/LehrerApp.Sync.Tests/EventQueueTests.cs
+++ b/LehrerApp.Sync.Tests/EventQueueTests.cs
@@ -33,6 +33,25 @@ public sealed class EventQueueTests
Assert.Null(exception);
}
+ [Fact]
+ public void ClearConflicts_EntferntAlleKonflikte()
+ {
+ using var temp = new TempEventQueue();
+ temp.Queue.AddConflict(new ConflictEntry
+ {
+ LocalEvent = MakeEvent(), RemoteEvent = MakeEvent(), Resolution = "LocalWon",
+ });
+ temp.Queue.AddConflict(new ConflictEntry
+ {
+ LocalEvent = MakeEvent(), RemoteEvent = MakeEvent(), Resolution = "RemoteWon",
+ });
+
+ temp.Queue.ClearConflicts();
+
+ Assert.Empty(temp.Queue.GetUnreviewed());
+ Assert.Equal(0, temp.Queue.ConflictCount());
+ }
+
[Fact]
public void GetKnownServerSeq_UnbekannteEntitaet_LiefertNull()
{
diff --git a/LehrerApp.Sync/EventQueue.cs b/LehrerApp.Sync/EventQueue.cs
index 058f14b..3b1a991 100644
--- a/LehrerApp.Sync/EventQueue.cs
+++ b/LehrerApp.Sync/EventQueue.cs
@@ -74,6 +74,7 @@ public class EventQueue : IDisposable
conflict.Reviewed = true;
_conflicts.Update(conflict);
}
+ public void ClearConflicts() => _conflicts.DeleteAll();
// ── Lokale Versionsverfolgung je Entität (optimistische Nebenläufigkeitskontrolle) ──────
// Merkt sich pro Entität die zuletzt bekannte ServerSeq — Grundlage für SyncEvent.