diff --git a/LehrerApp.Core/Models/LearningGroup.cs b/LehrerApp.Core/Models/LearningGroup.cs
index c2afd80..a4a0a89 100644
--- a/LehrerApp.Core/Models/LearningGroup.cs
+++ b/LehrerApp.Core/Models/LearningGroup.cs
@@ -26,6 +26,13 @@ public class LearningGroup
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
///
public int? WebUntisLessonId { get; set; }
+ ///
+ /// Vorgängergruppe aus dem Hochstufen () —
+ /// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
+ /// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
+ /// hochgestufte Gruppen bleiben unverknüpft (kein rückwirkendes Verknüpfen vorgesehen).
+ ///
+ public Guid? PreviousGroupId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
diff --git a/LehrerApp.Core/Services/GroupRolloverService.cs b/LehrerApp.Core/Services/GroupRolloverService.cs
index b7be352..80e0c62 100644
--- a/LehrerApp.Core/Services/GroupRolloverService.cs
+++ b/LehrerApp.Core/Services/GroupRolloverService.cs
@@ -60,6 +60,7 @@ public sealed class GroupRolloverService(
IsActive = true,
IsOwnClass = source.IsOwnClass,
IsDifferentiated = source.IsDifferentiated,
+ PreviousGroupId = source.Id,
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
};
diff --git a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs
index 6847b14..eb84f63 100644
--- a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs
@@ -18,8 +18,8 @@ public sealed class GroupDetailViewModelTests
var grades = new FakeGrades();
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
- new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students,
- new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()),
+ new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
+ new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
students, memberships, groups, new FakeCompetencyDomains()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
diff --git a/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs
index 5d49ea2..a37154f 100644
--- a/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs
+++ b/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs
@@ -12,10 +12,13 @@ public sealed class GroupOverviewViewModelTests
{
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
- FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) =>
- new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]),
+ FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
+ FakeResults? results = null, FakeGroups? groups = null) =>
+ new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), results ?? new FakeResults(),
+ sessions ?? new FakeSessions([]),
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
- tasks ?? new FakeWorkTasks(), new AttendanceBalanceService(), new SchoolYearService());
+ tasks ?? new FakeWorkTasks(), groups ?? new FakeGroups([]), new AttendanceBalanceService(),
+ new GradingService(), new SchoolYearService());
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
@@ -76,6 +79,86 @@ public sealed class GroupOverviewViewModelTests
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
}
+ [Fact]
+ public void Vorjahresvergleich_OhneVerknuepfteVorgaengergruppe_BleibtVerborgen()
+ {
+ var groupId = Guid.NewGuid();
+ var groups = new FakeGroups([new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6 }]);
+ var vm = NewVm(groups: groups);
+
+ vm.Initialize(groupId, "Testkurs");
+
+ Assert.False(vm.HasYearComparison);
+ }
+
+ [Fact]
+ public void Vorjahresvergleich_VorgaengerOhneKlausurergebnisse_BleibtVerborgen()
+ {
+ var previousId = Guid.NewGuid();
+ var groupId = Guid.NewGuid();
+ var groups = new FakeGroups([
+ new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
+ new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
+ ]);
+ var vm = NewVm(groups: groups);
+
+ vm.Initialize(groupId, "Testkurs");
+
+ Assert.False(vm.HasYearComparison);
+ }
+
+ [Fact]
+ public void Vorjahresvergleich_ZeigtDurchschnitteBeiderJahreAn()
+ {
+ var previousId = Guid.NewGuid();
+ var groupId = Guid.NewGuid();
+ var groups = new FakeGroups([
+ new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
+ new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
+ ]);
+
+ var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
+ var currentExam = new Exam { Id = Guid.NewGuid(), GroupId = groupId };
+ var exams = new FakeExams([previousExam, currentExam]);
+
+ var results = new FakeResults();
+ results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "2" });
+ results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "4" });
+ results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Grade = "1" });
+ // Abwesende und leere Ergebnisse fließen bewusst nicht in den Schnitt ein.
+ results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Absent = true, Grade = "6" });
+
+ var vm = NewVm(exams: exams, results: results, groups: groups);
+
+ vm.Initialize(groupId, "Testkurs");
+
+ Assert.True(vm.HasYearComparison);
+ Assert.Contains("Dieses Jahr: Ø 1,0 (1 Klausuren)", vm.YearComparisonLabel);
+ Assert.Contains("Vorjahr: Ø 3,0 (2 Klausuren)", vm.YearComparisonLabel);
+ }
+
+ [Fact]
+ public void Vorjahresvergleich_OhneEigeneKlausurenZeigtNurVorjahr()
+ {
+ var previousId = Guid.NewGuid();
+ var groupId = Guid.NewGuid();
+ var groups = new FakeGroups([
+ new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
+ new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
+ ]);
+ var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
+ var exams = new FakeExams([previousExam]);
+ var results = new FakeResults();
+ results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "3" });
+
+ var vm = NewVm(exams: exams, results: results, groups: groups);
+
+ vm.Initialize(groupId, "Testkurs");
+
+ Assert.True(vm.HasYearComparison);
+ Assert.Contains("noch keine Klausur", vm.YearComparisonLabel);
+ }
+
[Fact]
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
{
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs
index 75ac1c2..9962ae2 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs
@@ -33,12 +33,15 @@ public partial class GroupOverviewViewModel : ObservableObject
private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams;
+ private readonly IExamResultRepository _examResults;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _entries;
private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation;
private readonly IWorkTaskRepository _tasks;
+ private readonly IGroupRepository _groups;
private readonly AttendanceBalanceService _attendanceBalance;
+ private readonly GradingService _grading;
private readonly SchoolYearService _schoolYear;
private Guid _groupId;
@@ -48,6 +51,8 @@ public partial class GroupOverviewViewModel : ObservableObject
[ObservableProperty] private string _nextLessonLabel = "";
[ObservableProperty] private bool _hasNextExam;
[ObservableProperty] private string _nextExamLabel = "";
+ [ObservableProperty] private bool _hasYearComparison;
+ [ObservableProperty] private string _yearComparisonLabel = "";
[ObservableProperty] private string _participationHintLabel = "";
[ObservableProperty] private bool _participationHintIsStale;
[ObservableProperty] private bool _hasOpenHomeworkCheck;
@@ -105,13 +110,14 @@ public partial class GroupOverviewViewModel : ObservableObject
}
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
- IParticipationSessionRepository sessions, IParticipationRepository entries,
- IStudentRepository students, IDocumentationRepository documentation, IWorkTaskRepository tasks,
- AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear)
+ IExamResultRepository examResults, IParticipationSessionRepository sessions,
+ IParticipationRepository entries, IStudentRepository students,
+ IDocumentationRepository documentation, IWorkTaskRepository tasks, IGroupRepository groups,
+ AttendanceBalanceService attendanceBalance, GradingService grading, SchoolYearService schoolYear)
{
- _lessons = lessons; _exams = exams; _sessions = sessions;
+ _lessons = lessons; _exams = exams; _examResults = examResults; _sessions = sessions;
_entries = entries; _students = students; _documentation = documentation; _tasks = tasks;
- _attendanceBalance = attendanceBalance; _schoolYear = schoolYear;
+ _groups = groups; _attendanceBalance = attendanceBalance; _grading = grading; _schoolYear = schoolYear;
}
public void Initialize(Guid groupId, string groupName)
@@ -126,6 +132,7 @@ public partial class GroupOverviewViewModel : ObservableObject
var today = DateOnly.FromDateTime(DateTime.Today);
LoadNextLesson(today);
LoadNextExam(today);
+ LoadYearComparison();
LoadParticipationHint(today);
LoadOpenHomeworkCheck(today);
LoadOpenExcuses(today);
@@ -156,6 +163,38 @@ public partial class GroupOverviewViewModel : ObservableObject
NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}";
}
+ // ── Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) ──────────────────
+ //
+ // Setzt voraus, dass diese Gruppe über GroupRolloverService aus einer Vorgängergruppe
+ // hochgestuft wurde (LearningGroup.PreviousGroupId) — vor Einführung dieses Felds
+ // hochgestufte Gruppen bleiben unverknüpft und zeigen keinen Vergleich. Nur sichtbar, wenn
+ // im Vorjahr tatsächlich Klausurergebnisse vorliegen; ohne die ist kein Vergleich möglich,
+ // auch wenn eine Verknüpfung besteht.
+
+ private void LoadYearComparison()
+ {
+ var group = _groups.GetById(_groupId);
+ var previous = group?.PreviousGroupId is Guid previousId ? ComputeExamAverage(previousId) : null;
+ if (previous is null) { HasYearComparison = false; return; }
+
+ var current = ComputeExamAverage(_groupId);
+ YearComparisonLabel = current is null
+ ? $"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren) — dieses Jahr noch keine Klausur."
+ : $"Dieses Jahr: Ø {current.Value.Average:0.0} ({current.Value.Count} Klausuren) · " +
+ $"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren)";
+ HasYearComparison = true;
+ }
+
+ private (double Average, int Count)? ComputeExamAverage(Guid groupId)
+ {
+ var grades = _exams.GetByGroup(groupId)
+ .SelectMany(e => _examResults.GetByExam(e.Id))
+ .Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
+ .Select(r => (Grade: r.Grade!, Weight: 1.0))
+ .ToList();
+ return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count);
+ }
+
/// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung
/// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst.
private void LoadParticipationHint(DateOnly today)
diff --git a/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml b/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml
index 6d21d6c..5660e34 100644
--- a/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml
+++ b/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml
@@ -66,6 +66,14 @@
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Tests/GroupRolloverServiceTests.cs b/LehrerApp.Tests/GroupRolloverServiceTests.cs
index 4011ba9..c7c38e2 100644
--- a/LehrerApp.Tests/GroupRolloverServiceTests.cs
+++ b/LehrerApp.Tests/GroupRolloverServiceTests.cs
@@ -40,6 +40,7 @@ public sealed class GroupRolloverServiceTests
Assert.True(target.IsDifferentiated);
Assert.Null(target.WebUntisLessonId);
Assert.False(source.IsActive);
+ Assert.Equal(source.Id, target.PreviousGroupId);
var copied = Assert.Single(memberships.GetByGroup(target.Id));
Assert.NotEqual(oldMembership.Id, copied.Id);
diff --git a/TODO.md b/TODO.md
index dc38c96..ad2adbb 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2530,6 +2530,19 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
die einzige Bereichsnavigation. Das Verwaltungsmenü sitzt direkt an jeder Karte und ist per
Automation-Namen zugänglich. `GroupListViewModelTests` prüfen Direktnavigation, kontextuelle
Bearbeitung, Suche und Archivierung.
+- [x] **7.2.7** Schuljahresübergreifende Auswertung, erster Baustein: Klausurschnitt-Vergleich
+ mit der Vorgängergruppe (Nutzer-Feedback). Neues Feld `LearningGroup.PreviousGroupId`, von
+ `GroupRolloverService.RollOver` (7.2.2) automatisch auf die Ausgangsgruppe gesetzt — vor
+ dieser Ergänzung hochgestufte Gruppen bleiben unverknüpft, ein rückwirkendes manuelles
+ Verknüpfen ist bewusst nicht Teil dieses Durchgangs. Neue Karte "Vorjahresvergleich" im
+ Kurs-Tab "Übersicht" (`GroupOverviewViewModel`) zeigt den gepoolten Klausurdurchschnitt
+ (alle nicht-abwesenden `ExamResult`-Einträge über alle Klausuren der Gruppe, via
+ `GradingService.WeightedAverage`) dieses Jahres neben dem der verknüpften Vorgängergruppe;
+ nur sichtbar, wenn im Vorjahr tatsächlich Klausurergebnisse vorliegen. **Bewusst
+ zurückgestellt:** ein Vergleich, welcher Notenschlüssel bei welcher Aufgabenstruktur wie
+ gewirkt hat — `Exam.GradingKey` ist eine reine Momentaufnahme ohne Verweis auf die
+ verwendete `GradingKeyTemplate`, eine saubere Gruppierung dafür bräuchte zunächst diesen
+ Verweis.
### 7.3 Import