diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs
index 9b4a9d3..23d4644 100644
--- a/LehrerApp.Core/Models/Planning.cs
+++ b/LehrerApp.Core/Models/Planning.cs
@@ -258,6 +258,19 @@ public class ReportGrade
public string? OverrideValue { get; set; }
public string? OverrideReason { get; set; }
public bool IsLocked { get; set; }
+ /// Nur am Datensatz "Gesamtes Schuljahr": Note der Projektprüfung, wenn sie in
+ /// diesem Fach abgelegt wurde (Klasse 10). Zählt in der Endjahresnote wie ein weiteres
+ /// Halbjahr.
+ public string? ProjectExamGrade { get; set; }
+ /// Wie , für die mündliche Abschlussprüfung im
+ /// gewählten Fach.
+ public string? OralExamGrade { get; set; }
+ /// Note der schriftlichen Abschlussprüfung (Deutsch, Mathematik, erste
+ /// Fremdsprache), nur am Jahres-Datensatz.
+ public string? WrittenExamGrade { get; set; }
+ /// Note einer zusätzlichen mündlichen Prüfung ("Nachprüfung", § 8 Abs. 3 und 4
+ /// PrüfV Sek. I), nur am Jahres-Datensatz. Zur Verrechnung siehe docs/Datenmodell.md.
+ public string? AdditionalOralExamGrade { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
diff --git a/LehrerApp.Core/Services/GradingService.cs b/LehrerApp.Core/Services/GradingService.cs
index f1a1136..1f76b2d 100644
--- a/LehrerApp.Core/Services/GradingService.cs
+++ b/LehrerApp.Core/Services/GradingService.cs
@@ -87,7 +87,7 @@ public class GradingService
/// Wandelt eine Notenangabe mit optionaler Tendenz ("3+", "2-") in einen rechnerischen
/// Notenwert um. Tendenzen verschieben um ein Drittel in Richtung der besseren Note
/// (z.B. "3+" → 2,67, "2-" → 2,33) — der in deutschen Notensystemen übliche Drittelabstand.
- private static double? ParseGradeValue(string grade)
+ public static double? ParseGradeValue(string grade)
{
var trimmed = grade.Trim();
if (trimmed.Length == 0) return null;
@@ -132,6 +132,18 @@ public class GradingService
List<(string Grade, double Weight)> participationGrades,
List<(string Grade, double Weight)> otherGrades,
GradingScheme scheme, GradingSystem system, RoundingRule rounding)
+ {
+ var weighted = CalculateReportGradeValue(examGrades, participationGrades, otherGrades, scheme);
+ return weighted is null ? null : RoundToGrade(weighted.Value, system, rounding);
+ }
+
+ /// Wie , liefert aber den ungerundeten gewichteten Wert —
+ /// für Ansichten, die zeigen sollen, wie knapp eine Note zwischen zwei Stufen liegt.
+ public double? CalculateReportGradeValue(
+ List<(string Grade, double Weight)> examGrades,
+ List<(string Grade, double Weight)> participationGrades,
+ List<(string Grade, double Weight)> otherGrades,
+ GradingScheme scheme)
{
var buckets = new List<(double Avg, double Percent)>();
if (examGrades.Count > 0) buckets.Add((WeightedAverage(examGrades), scheme.ExamsPercent));
@@ -141,8 +153,7 @@ public class GradingService
var totalPercent = buckets.Sum(b => b.Percent);
if (buckets.Count == 0 || totalPercent <= 0) return null;
- var weighted = buckets.Sum(b => b.Avg * b.Percent) / totalPercent;
- return RoundToGrade(weighted, system, rounding);
+ return buckets.Sum(b => b.Avg * b.Percent) / totalPercent;
}
}
diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs
index 0df1a47..11ad303 100644
--- a/LehrerApp.Desktop.Tests/Fakes.cs
+++ b/LehrerApp.Desktop.Tests/Fakes.cs
@@ -256,6 +256,14 @@ public class FakeAspects : IParticipationAspectRepository
public void Delete(Guid id) => _all.RemoveAll(a => a.Id == id);
}
+public class FakeSections(List? initial = null) : IParticipationSectionRepository
+{
+ private readonly List _all = initial ?? [];
+ public List GetByGroup(Guid groupId) => _all.Where(s => s.GroupId == groupId).ToList();
+ public void Save(ParticipationSection section) { _all.RemoveAll(s => s.Id == section.Id); _all.Add(section); }
+ public void Delete(Guid id) => _all.RemoveAll(s => s.Id == id);
+}
+
public class FakeGrades : IGradeRepository
{
private readonly List _all = [];
diff --git a/LehrerApp.Desktop.Tests/ReportGradeAssistantViewModelTests.cs b/LehrerApp.Desktop.Tests/ReportGradeAssistantViewModelTests.cs
new file mode 100644
index 0000000..d19e466
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/ReportGradeAssistantViewModelTests.cs
@@ -0,0 +1,388 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using LehrerApp.Desktop.ViewModels.Groups;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+/// Tests für den Zeugnisnoten-Assistenten: eigenständige Halbjahre (H1 nur als Kontext in H2),
+/// Endjahresnote aus den beiden Halbjahresnoten, Speichern/Festschreiben über dieselben
+/// ReportGrade-Datensätze wie der Zeugnisnoten-Dialog sowie die beiden Graph-Modi.
+public sealed class ReportGradeAssistantViewModelTests
+{
+ private static readonly Guid GroupId = Guid.NewGuid();
+ private static readonly Student Anna = new() { Id = Guid.NewGuid(), FirstName = "Anna", LastName = "Beispiel" };
+ private static readonly Student Ben = new() { Id = Guid.NewGuid(), FirstName = "Ben", LastName = "Muster" };
+
+ private sealed class Fixture
+ {
+ public List Exams { get; } = [];
+ public FakeResults Results { get; } = new();
+ public FakeGrades Grades { get; } = new();
+ public FakeReportGrades ReportGrades { get; } = new();
+ public List Sessions { get; } = [];
+ public FakeEntries Entries { get; } = new();
+ public List Sections { get; } = [];
+
+ public ReportGradeAssistantViewModel Build(DateOnly today, int gradeLevel = 8)
+ {
+ var schemes = new FakeSchemes();
+ schemes.SetForGroup(GroupId, new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 });
+ return new ReportGradeAssistantViewModel(
+ Grades, new FakeExams(Exams), Results, new FakeStudents([Anna, Ben]), new FakeMemberships([]),
+ schemes, ReportGrades, new FakeSessions(Sessions), Entries, new FakeAspects(),
+ new FakeSections(Sections), new AttendanceBalanceService(), new GradingService(),
+ GroupId, GroupType.Class, GradingSystem.Grades1To6, "Testkurs", "2025/26", gradeLevel, today);
+ }
+
+ public Exam AddExam(string title, DateOnly date, string annaGrade)
+ {
+ var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = title, Date = date };
+ Exams.Add(exam);
+ Results.Add(new ExamResult { ExamId = exam.Id, StudentId = Anna.Id, Grade = annaGrade });
+ return exam;
+ }
+
+ public void AddParticipation(DateOnly date, string value, string? note = null) =>
+ Grades.Add(new Grade { StudentId = Anna.Id, GroupId = GroupId, Category = GradeCategory.Participation,
+ Date = date, Value = value, Note = note });
+ }
+
+ private static readonly DateOnly InH1 = new(2025, 11, 10);
+ private static readonly DateOnly InH2 = new(2026, 4, 20);
+
+ [Fact]
+ public void Vorbelegung_WaehltDasLaufendeHalbjahr()
+ {
+ Assert.Equal(ParticipationPeriod.H1, new Fixture().Build(InH1).SelectedPeriod.Period);
+ Assert.Equal(ParticipationPeriod.H2, new Fixture().Build(InH2).SelectedPeriod.Period);
+ }
+
+ [Fact]
+ public void Halbjahr_BerechnetNoteUndZeigtTeilbereiche()
+ {
+ var f = new Fixture();
+ f.AddExam("K1", new DateOnly(2025, 10, 1), "2");
+ f.AddParticipation(new DateOnly(2025, 10, 15), "3");
+
+ var vm = f.Build(InH1);
+
+ // (2*50 + 3*40) / 90 = 2,44 -> 2
+ Assert.Equal("2", vm.CalculatedDisplay);
+ Assert.Contains("2,44", vm.CalculationDetail);
+ Assert.Equal(["2,00", "3,00", "–"], vm.Tiles.Select(t => t.Value));
+ Assert.Equal("2", vm.Students[0].GradeDisplay);
+ Assert.Equal(AssistantGradeStatus.Open, vm.Students[0].Status);
+ }
+
+ [Fact]
+ public void ZweitesHalbjahr_ErstesZaehltNichtWirdAberAlsKontextGezeigt()
+ {
+ var f = new Fixture();
+ f.AddExam("K1", new DateOnly(2025, 10, 1), "5");
+ f.AddExam("K2", new DateOnly(2026, 3, 1), "2");
+ f.AddParticipation(new DateOnly(2025, 12, 1), "5");
+
+ var vm = f.Build(InH2);
+
+ Assert.Equal("2", vm.CalculatedDisplay);
+ Assert.Equal(2, vm.Works.Count);
+ Assert.True(vm.Works[0].IsContext);
+ Assert.False(vm.Works[1].IsContext);
+ Assert.True(Assert.Single(vm.ParticipationGrades).IsContext);
+
+ vm.ShowPreviousHalf = false;
+
+ Assert.Equal("K2", Assert.Single(vm.Works).Title);
+ Assert.Empty(vm.ParticipationGrades);
+ Assert.Equal("2", vm.CalculatedDisplay);
+ }
+
+ [Fact]
+ public void Schuljahr_VerrechnetHalbjahresnotenUndBevorzugtGespeicherteEntscheidung()
+ {
+ var f = new Fixture();
+ f.AddExam("K1", new DateOnly(2025, 10, 1), "2");
+ f.AddExam("K2", new DateOnly(2026, 3, 1), "4");
+ var vm = f.Build(InH2);
+
+ vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
+
+ // (2 + 4) / 2 = 3
+ Assert.Equal("3", vm.CalculatedDisplay);
+ Assert.Equal(["2", "4"], vm.Tiles.Select(t => t.Value));
+
+ // Pädagogisch übersteuerte Halbjahresnote geht in die Verrechnung ein, nicht die berechnete.
+ f.ReportGrades.Save(new ReportGrade { StudentId = Anna.Id, GroupId = GroupId, Period = "1. Halbjahr",
+ CalculatedValue = "2", OverrideValue = "1", OverrideReason = "starke Entwicklung" });
+ vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.H2);
+ vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
+
+ // (1 + 4) / 2 = 2,5 -> kaufmännisch 3, pädagogisch 2
+ Assert.Equal("3", vm.CalculatedDisplay);
+ Assert.Contains("2,50", vm.CalculationDetail);
+ vm.RoundingRule = RoundingRule.Pedagogical;
+ Assert.Equal("2", vm.CalculatedDisplay);
+ }
+
+ [Fact]
+ public void Klasse10_PruefungsnotenZaehlenWieWeitereHalbjahre()
+ {
+ var f = new Fixture();
+ f.AddExam("K1", new DateOnly(2025, 10, 1), "2");
+ f.AddExam("K2", new DateOnly(2026, 3, 1), "2");
+ var vm = f.Build(InH2, gradeLevel: 10);
+ vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
+
+ Assert.True(vm.CanEnterExamGrades);
+ Assert.Equal("2", vm.CalculatedDisplay);
+
+ // (2 + 2 + 5) / 3 = 3
+ vm.ProjectExamGrade = "5";
+ Assert.Equal("3", vm.CalculatedDisplay);
+ Assert.Equal(3, vm.Tiles.Count);
+
+ // (2 + 2 + 5 + 5) / 4 = 3,5 -> 4
+ vm.OralExamGrade = "5";
+ Assert.Equal("4", vm.CalculatedDisplay);
+ Assert.Contains("4 Teilnoten", vm.CalculationDetail);
+
+ vm.SaveCommand.Execute(null);
+
+ var record = f.ReportGrades.GetByStudentGroupPeriod(Anna.Id, GroupId, "Gesamtes Schuljahr");
+ Assert.Equal("5", record!.ProjectExamGrade);
+ Assert.Equal("5", record.OralExamGrade);
+ Assert.Equal("4", record.CalculatedValue);
+ Assert.Equal("4", vm.Students[0].GradeDisplay);
+ }
+
+ private static ReportGradeAssistantViewModel Klasse10Schuljahr(Fixture f, string h1, string h2)
+ {
+ f.AddExam("K1", new DateOnly(2025, 10, 1), h1);
+ f.AddExam("K2", new DateOnly(2026, 3, 1), h2);
+ var vm = f.Build(InH2, gradeLevel: 10);
+ vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
+ return vm;
+ }
+
+ [Fact]
+ public void Klasse10_SchriftlichePruefungGehtMitEinemDrittelEin()
+ {
+ var vm = Klasse10Schuljahr(new Fixture(), "2", "2");
+
+ // § 14 Abs. 3 ObSchVO: (2 * 2 + 5) / 3 = 3,0
+ vm.WrittenExamGrade = "5";
+
+ Assert.Equal("3", vm.CalculatedDisplay);
+ }
+
+ [Fact]
+ public void Klasse10_ZusaetzlicheMuendlicheImSchriftlichenFach_EinsZuEinsOhneRundung()
+ {
+ var f = new Fixture();
+ var vm = Klasse10Schuljahr(f, "4", "4");
+ vm.WrittenExamGrade = "6";
+
+ // (4 + 4 + 6) / 3 = 4,67 -> 5
+ Assert.Equal("5", vm.CalculatedDisplay);
+
+ // § 13 Abs. 2 PrüfV: (6 + 3) / 2 = 4,5 — bleibt ungerundet; (4 + 4 + 4,5) / 3 = 4,17 -> 4
+ vm.AdditionalOralExamGrade = "3";
+
+ Assert.Equal("4", vm.CalculatedDisplay);
+ Assert.Contains("4,17", vm.CalculationDetail);
+ var tile = vm.Tiles.Single(t => t.Label == "Prüfungsleistung");
+ Assert.Equal("4,5", tile.Value);
+ Assert.Contains("zwischen 4 und 5", tile.Hint);
+
+ vm.SaveCommand.Execute(null);
+ var record = f.ReportGrades.GetByStudentGroupPeriod(Anna.Id, GroupId, "Gesamtes Schuljahr");
+ Assert.Equal("6", record!.WrittenExamGrade);
+ Assert.Equal("3", record.AdditionalOralExamGrade);
+ }
+
+ [Fact]
+ public void GenauZwischenZweiNoten_WeistAufPaedagogischeEntscheidungHin()
+ {
+ var f = new Fixture();
+ f.AddExam("K1", new DateOnly(2025, 10, 1), "2");
+ f.AddExam("K2", new DateOnly(2026, 3, 1), "3");
+ var vm = f.Build(InH2);
+ Assert.Empty(vm.BorderlineHint);
+
+ vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
+
+ // (2 + 3) / 2 = 2,5
+ Assert.Contains("zwischen 2 und 3", vm.BorderlineHint);
+
+ vm.ToggleLockCommand.Execute(null);
+ Assert.Empty(vm.BorderlineHint);
+ }
+
+ [Fact]
+ public void Klasse10_NachpruefungOhneAbschlusspruefung_ZweiZuEins()
+ {
+ var vm = Klasse10Schuljahr(new Fixture(), "5", "5");
+
+ // § 13 Abs. 3 PrüfV: (2 * 5 + 3) / 3 = 4,33 -> 4
+ vm.AdditionalOralExamGrade = "3";
+
+ Assert.Equal("4", vm.CalculatedDisplay);
+ }
+
+ [Fact]
+ public void Klasse10_NurEinHalbjahr_UnterrichtlicheLeistungZaehltTrotzdemDoppelt()
+ {
+ var f = new Fixture();
+ f.AddExam("K2", new DateOnly(2026, 3, 1), "2");
+ var vm = f.Build(InH2, gradeLevel: 10);
+ vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
+
+ // (2 * 2 + 5) / 3 = 3 — nicht (2 + 5) / 2 = 3,5 -> 4
+ vm.OralExamGrade = "5";
+
+ Assert.Equal("3", vm.CalculatedDisplay);
+ }
+
+ [Fact]
+ public void Klasse10_WiderspruechlichePruefungen_WerdenAbgelehnt()
+ {
+ var f = new Fixture();
+ var vm = Klasse10Schuljahr(f, "3", "3");
+
+ vm.WrittenExamGrade = "3";
+ vm.OralExamGrade = "2";
+ vm.SaveCommand.Execute(null);
+ Assert.NotEmpty(vm.OralExamGradeError);
+
+ vm.WrittenExamGrade = "";
+ vm.AdditionalOralExamGrade = "4";
+ vm.SaveCommand.Execute(null);
+ Assert.NotEmpty(vm.AdditionalOralExamGradeError);
+ Assert.Equal(0, f.ReportGrades.SavedCount);
+ }
+
+ [Fact]
+ public void Klasse10_UngueltigePruefungsnote_WirdNichtGespeichert()
+ {
+ var f = new Fixture();
+ f.AddExam("K1", new DateOnly(2025, 10, 1), "2");
+ var vm = f.Build(InH2, gradeLevel: 10);
+ vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
+
+ vm.ProjectExamGrade = "gut";
+ vm.SaveCommand.Execute(null);
+
+ Assert.NotEmpty(vm.ProjectExamGradeError);
+ Assert.Equal(0, f.ReportGrades.SavedCount);
+ }
+
+ [Fact]
+ public void PruefungsnotenNurInKlasse10UndNurFuerDasSchuljahr()
+ {
+ var klasse8 = new Fixture().Build(InH2);
+ klasse8.SelectedPeriod = klasse8.PeriodOptions.Single(p => p.Period == ParticipationPeriod.FullYear);
+ Assert.False(klasse8.CanEnterExamGrades);
+
+ Assert.False(new Fixture().Build(InH2, gradeLevel: 10).CanEnterExamGrades);
+ }
+
+ [Fact]
+ public void Speichern_UebersteuernOhneBegruendung_WirdAbgelehnt()
+ {
+ var f = new Fixture();
+ f.AddExam("K1", new DateOnly(2025, 10, 1), "3");
+ var vm = f.Build(InH1);
+
+ vm.OverrideValue = "2";
+ vm.SaveCommand.Execute(null);
+
+ Assert.NotEmpty(vm.OverrideReasonError);
+ Assert.Equal(0, f.ReportGrades.SavedCount);
+
+ vm.OverrideReason = "mündlich deutlich stärker";
+ vm.SaveCommand.Execute(null);
+
+ var record = f.ReportGrades.GetByStudentGroupPeriod(Anna.Id, GroupId, "1. Halbjahr");
+ Assert.NotNull(record);
+ Assert.Equal("3", record.CalculatedValue);
+ Assert.Equal("2", record.OverrideValue);
+ Assert.Equal("2", vm.Students[0].GradeDisplay);
+ Assert.Equal(AssistantGradeStatus.Saved, vm.Students[0].Status);
+ }
+
+ [Fact]
+ public void SpeichernUndWeiter_GehtZumNaechstenSchueler()
+ {
+ var f = new Fixture();
+ f.AddExam("K1", new DateOnly(2025, 10, 1), "3");
+ var vm = f.Build(InH1);
+
+ vm.SaveAndNextCommand.Execute(null);
+
+ Assert.Equal(Ben.Id, vm.SelectedStudent!.StudentId);
+ Assert.Equal("2 / 2", vm.ProgressText);
+ Assert.False(vm.NextStudentCommand.CanExecute(null));
+ }
+
+ [Fact]
+ public void Festschreiben_FriertBerechnetenStandEin()
+ {
+ var f = new Fixture();
+ f.AddExam("K1", new DateOnly(2025, 10, 1), "3");
+ var vm = f.Build(InH1);
+
+ vm.ToggleLockCommand.Execute(null);
+
+ Assert.True(vm.IsLocked);
+ Assert.Equal(AssistantGradeStatus.Locked, vm.Students[0].Status);
+
+ // Eine später eingetragene Arbeit ändert den festgeschriebenen Stand nicht mehr.
+ f.AddExam("K2", new DateOnly(2025, 12, 1), "1");
+ vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.H2);
+ vm.SelectedPeriod = vm.PeriodOptions.Single(p => p.Period == ParticipationPeriod.H1);
+ Assert.Equal("3", vm.CalculatedDisplay);
+
+ vm.ToggleLockCommand.Execute(null);
+ Assert.False(vm.IsLocked);
+ Assert.Equal("2", vm.CalculatedDisplay);
+ }
+
+ [Fact]
+ public void Mitarbeitsgraph_AbschnitteBlaetternUndVerlaufAmStueck()
+ {
+ var f = new Fixture();
+ f.Sections.Add(new ParticipationSection { GroupId = GroupId, Label = "Abschnitt 1",
+ StartDate = new DateOnly(2025, 8, 20), EndDate = new DateOnly(2025, 9, 30) });
+ f.Sections.Add(new ParticipationSection { GroupId = GroupId, Label = "Abschnitt 2",
+ StartDate = new DateOnly(2025, 10, 1), EndDate = new DateOnly(2025, 10, 31) });
+ f.AddParticipation(new DateOnly(2025, 9, 30), "2", "Abschnitt: Abschnitt 1");
+ foreach (var date in new[] { new DateOnly(2025, 9, 2), new DateOnly(2025, 10, 7), new DateOnly(2025, 11, 4) })
+ {
+ var session = new ParticipationSession { Id = Guid.NewGuid(), GroupId = GroupId, Date = date };
+ f.Sessions.Add(session);
+ f.Entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = Anna.Id,
+ Ratings = [new AspectRating { Key = "quality", Value = 1 }] });
+ }
+
+ var vm = f.Build(InH1);
+
+ Assert.True(vm.IsSectionMode);
+ Assert.Equal("1 / 3", vm.SectionPosition);
+ Assert.Contains("Abschnittsnote 2", vm.SectionTitle);
+ Assert.Single(Assert.Single(vm.ChartGroups).Points);
+ Assert.False(vm.PreviousSectionCommand.CanExecute(null));
+
+ vm.NextSectionCommand.Execute(null);
+ Assert.Contains("keine Abschnittsnote", vm.SectionTitle);
+ vm.NextSectionCommand.Execute(null);
+ Assert.Contains("vorläufig", vm.SectionTitle);
+ Assert.True(vm.ChartGroups[0].IsOpen);
+ Assert.False(vm.NextSectionCommand.CanExecute(null));
+
+ vm.IsSectionMode = false;
+
+ Assert.Equal(3, vm.ChartGroups.Count);
+ Assert.Equal(3, vm.ChartGroups.Sum(g => g.Points.Count));
+ }
+}
diff --git a/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs b/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs
index 98010ab..2d4b204 100644
--- a/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs
+++ b/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs
@@ -186,6 +186,33 @@ public class ReportGradeCalculationTests
Assert.Equal("1", annaNachSperre.FinalDisplay);
}
+ [Fact]
+ public void Schuljahr_WirdAusHalbjahresnotenUndPruefungsnotenVerrechnet()
+ {
+ // H1: eine 1 und eine 2 -> 1,5 -> 2. H2: eine 4 -> 4.
+ var h1a = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2025, 9, 10), Title = "K1" };
+ var h1b = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2025, 11, 10), Title = "K2" };
+ var h2 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2026, 3, 10), Title = "K3" };
+ var results = new FakeResults();
+ results.Add(new ExamResult { ExamId = h1a.Id, StudentId = Anna.Id, Grade = "1" });
+ results.Add(new ExamResult { ExamId = h1b.Id, StudentId = Anna.Id, Grade = "2" });
+ results.Add(new ExamResult { ExamId = h2.Id, StudentId = Anna.Id, Grade = "4" });
+ var reportGrades = new FakeReportGrades();
+
+ var vm = BuildViewModel(new FakeGrades(), new FakeSchemes(), reportGrades, new FakeExams([h1a, h1b, h2]), results);
+
+ // Aus den Halbjahresnoten: (2 + 4) / 2 = 3 — nicht aus allen Arbeiten ((1+2+4)/3 = 2,33 -> 2).
+ Assert.Equal("3", vm.Rows.Single(r => r.StudentId == Anna.Id).CalculatedValue);
+
+ // Klasse 10: eine im Fach abgelegte Projektprüfung zählt wie ein drittes Halbjahr.
+ reportGrades.Save(new ReportGrade { StudentId = Anna.Id, GroupId = GroupId, Period = "Gesamtes Schuljahr",
+ ProjectExamGrade = "6" });
+ vm.Refresh();
+
+ // (2 + 4 + 6) / 3 = 4
+ Assert.Equal("4", vm.Rows.Single(r => r.StudentId == Anna.Id).CalculatedValue);
+ }
+
[Fact]
public void ExportCsv_EnthaeltAlleSchuelerDerGruppe()
{
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationTimelineBuilder.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationTimelineBuilder.cs
new file mode 100644
index 0000000..d626c72
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationTimelineBuilder.cs
@@ -0,0 +1,209 @@
+using CommunityToolkit.Mvvm.Input;
+using LehrerApp.Core.Interfaces;
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+
+namespace LehrerApp.Desktop.ViewModels.Groups;
+
+///
+/// Baut die Zeitleisten-Punkte eines Schülers (Mitarbeit-Sitzungen, Arbeiten, sonstige Noten) für
+/// ParticipationTimelineChart. Gemeinsame Grundlage von Mitarbeits-Assistent und
+/// Zeugnisnoten-Assistent, damit beide denselben Graphen mit denselben Regeln zeigen.
+///
+public sealed class ParticipationTimelineBuilder
+{
+ /// Kennzeichnet in eine Abschnittsnote der Mitarbeit.
+ public const string SectionNotePrefix = "Abschnitt: ";
+
+ private readonly IParticipationRepository _entries;
+ private readonly IExamRepository _exams;
+ private readonly IExamResultRepository _results;
+ private readonly IGradeRepository _grades;
+ private readonly IReadOnlyDictionary _membershipsByStudent;
+ private readonly GradingService _grading;
+ private readonly Guid _groupId;
+ private readonly GradingSystem _gradingSystem;
+ private readonly List _sessions;
+ private readonly Dictionary _aspectWeights;
+ // Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der
+ // Gewichtung, analog zu ParticipationGradeDialogViewModel.
+ private readonly Dictionary _aspectsByKey;
+
+ public IReadOnlyList Sessions => _sessions;
+
+ public ParticipationTimelineBuilder(
+ IParticipationSessionRepository sessions, IParticipationRepository entries,
+ IParticipationAspectRepository aspects, IExamRepository exams, IExamResultRepository results,
+ IGradeRepository grades, IReadOnlyDictionary membershipsByStudent,
+ GradingService grading, Guid groupId, GradingSystem gradingSystem)
+ {
+ _entries = entries; _exams = exams; _results = results; _grades = grades;
+ _membershipsByStudent = membershipsByStudent; _grading = grading;
+ _groupId = groupId; _gradingSystem = gradingSystem;
+
+ var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId))
+ .GroupBy(a => a.Key).Select(g => g.Last()).ToList();
+ _aspectWeights = applicableAspects.ToDictionary(a => a.Key, a => a.Weight);
+ _aspectsByKey = applicableAspects.ToDictionary(a => a.Key);
+ _sessions = sessions.GetByGroup(groupId).OrderBy(s => s.Date).ToList();
+ }
+
+ public static string BandLabel(ParticipationSection section) =>
+ $"{section.Label}\n{section.StartDate:dd.MM.}–{section.EndDate:dd.MM.}";
+
+ public static string OpenBandLabel(DateOnly openStart) => $"läuft seit {openStart:dd.MM.}";
+
+ /// Beginn des laufenden, noch nicht abgeschlossenen Abschnitts.
+ public DateOnly OpenStart(IReadOnlyCollection sections, DateOnly today) =>
+ sections.Count > 0 ? sections.Max(s => s.EndDate).AddDays(1)
+ : (_sessions.Count > 0 ? _sessions.Min(s => s.Date) : today);
+
+ public bool IsStudentActiveOn(Guid studentId, DateOnly date) =>
+ !_membershipsByStudent.TryGetValue(studentId, out var membership)
+ || GroupMembershipService.IsActiveOn(membership, date);
+
+ /// Alle Punkte des Schülers, chronologisch sortiert.
+ public List BuildPoints(Guid studentId)
+ {
+ var points = new List();
+
+ foreach (var session in _sessions)
+ {
+ if (!IsStudentActiveOn(studentId, session.Date)) continue;
+ var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
+ points.Add(BuildSessionPoint(session, entry, studentId));
+ }
+ foreach (var exam in _exams.GetByGroup(_groupId).OrderBy(e => e.Date))
+ {
+ if (!IsStudentActiveOn(studentId, exam.Date)) continue;
+ var result = _results.GetByExamAndStudent(exam.Id, studentId);
+ if (result is null) continue;
+ points.Add(BuildExamPoint(exam, result));
+ }
+ foreach (var grade in _grades.GetByStudentAndGroup(studentId, _groupId)
+ .Where(g => g.Category != GradeCategory.Participation)
+ .Where(g => IsStudentActiveOn(studentId, g.Date)))
+ {
+ points.Add(BuildOtherGradePoint(grade));
+ }
+ return points.OrderBy(p => p.Date).ToList();
+ }
+
+ /// Notenvorschlag aus den bewerteten Sitzungen im Zeitraum; null ohne bewertete Sitzung.
+ public string? ComputeSuggestion(Guid studentId, DateOnly start, DateOnly end)
+ {
+ var points = new List();
+ foreach (var session in _sessions.Where(s => s.Date >= start && s.Date <= end)
+ .Where(s => IsStudentActiveOn(studentId, s.Date)))
+ {
+ var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
+ if (entry is null) continue;
+ var weighted = WeightedRating(entry);
+ if (weighted is not null) points.Add(weighted.Value);
+ }
+ return points.Count == 0 ? null : _grading.ParticipationGrade(points.Average(), _gradingSystem);
+ }
+
+ private WizardTimelinePoint BuildSessionPoint(ParticipationSession session, ParticipationEntry? entry, Guid studentId)
+ {
+ var weightedValue = entry is not null ? WeightedRating(entry) : null;
+ var ratingLabel = weightedValue is not null ? RatingLabel((int)Math.Round(weightedValue.Value, MidpointRounding.AwayFromZero)) : "";
+ var note = entry?.Note;
+ var tooltip = $"{session.Date:dd.MM.yyyy}" +
+ (ratingLabel.Length > 0 ? $" · {ratingLabel}" : "") +
+ (string.IsNullOrWhiteSpace(note) ? "" : $" · {note}");
+
+ var point = new WizardTimelinePoint(session.Date, isExam: false, ratingLabel, examLabel: "",
+ hasNote: !string.IsNullOrWhiteSpace(note), tooltip: tooltip)
+ {
+ Homework = entry is null ? null : HomeworkDisplay.Effective(entry),
+ AttendanceIcon = AttendanceDisplay.ShortLabel(entry?.Attendance),
+ AttendanceTooltip = AttendanceDisplay.Label(entry?.Attendance),
+ AttendanceColor = AttendanceDisplay.Color(entry?.Attendance),
+ AspectValues = entry?.Ratings.ToDictionary(r => r.Key, r => r.Value)
+ ?? new Dictionary(),
+ WeightedValue = weightedValue,
+ OverallGrade = weightedValue is null ? "" : _grading.ParticipationGrade(weightedValue.Value, _gradingSystem),
+ };
+ point.ToggleHomeworkCommand = new RelayCommand(() => ToggleHomeworkAt(session.Id, studentId, point));
+ point.CycleAttendanceCommand = new RelayCommand(() => CycleAttendanceAt(session.Id, studentId, point));
+ return point;
+ }
+
+ private static WizardTimelinePoint BuildExamPoint(Exam exam, ExamResult result)
+ {
+ var examLabel = result.Absent ? $"{exam.Title}: abw." : $"{exam.Title}: {result.Grade}";
+ return new WizardTimelinePoint(exam.Date, isExam: true, ratingLabel: "", examLabel: examLabel,
+ hasNote: false, tooltip: $"{exam.Date:dd.MM.yyyy} · Klausur {examLabel}");
+ }
+
+ private static WizardTimelinePoint BuildOtherGradePoint(Grade grade)
+ {
+ var category = grade.Category switch
+ {
+ GradeCategory.Oral => "Mündlich",
+ GradeCategory.Homework => "Hausaufgabe",
+ GradeCategory.Project => "Projekt",
+ _ => "Sonstige Leistung",
+ };
+ var detail = string.IsNullOrWhiteSpace(grade.Note) ? category : grade.Note.Trim();
+ var label = $"{detail}: {grade.Value}";
+ return new WizardTimelinePoint(grade.Date, isExam: true, ratingLabel: "", examLabel: label,
+ hasNote: false, tooltip: $"{grade.Date:dd.MM.yyyy} · {category} · {label}");
+ }
+
+ private double? WeightedRating(ParticipationEntry entry)
+ {
+ if (entry.Ratings.Count == 0) return null;
+ var weightSum = 0.0; var valueSum = 0.0;
+ foreach (var r in entry.Ratings)
+ {
+ var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
+ if (w <= 0) continue;
+ valueSum += NormalizedValue(r) * w; weightSum += w;
+ }
+ return weightSum <= 0 ? null : valueSum / weightSum;
+ }
+
+ private double NormalizedValue(AspectRating r) => _aspectsByKey.TryGetValue(r.Key, out var aspect)
+ ? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints)
+ : r.Value;
+
+ private static string RatingLabel(int v) => v switch
+ {
+ >= 2 => "++", 1 => "+", 0 => "~", -1 => "−", _ => "−−",
+ };
+
+ private void ToggleHomeworkAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
+ {
+ var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
+ ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
+ entry.Homework = HomeworkDisplay.Next(HomeworkDisplay.Effective(entry));
+ entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework);
+ _entries.Save(entry);
+ point.Homework = entry.Homework;
+ }
+
+ private void CycleAttendanceAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
+ {
+ var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
+ ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
+ entry.Attendance = entry.Attendance switch
+ {
+ null => AttendanceStatus.Present,
+ AttendanceStatus.Present => AttendanceStatus.ExcusePending,
+ AttendanceStatus.ExcusePending => AttendanceStatus.Excused,
+ AttendanceStatus.Excused => AttendanceStatus.Unexcused,
+ AttendanceStatus.Unexcused => AttendanceStatus.Truant,
+ AttendanceStatus.Truant => AttendanceStatus.OtherSchoolEvent,
+ AttendanceStatus.OtherSchoolEvent => null,
+ _ => null,
+ };
+ _entries.Save(entry);
+ // Das Setzen jeder ObservableProperty kann unmittelbar ein Neuzeichnen auslösen.
+ // Deshalb muss die zum aktiven Symbol gehörende Farbe zuerst bereitstehen.
+ point.AttendanceColor = AttendanceDisplay.Color(entry.Attendance);
+ point.AttendanceTooltip = AttendanceDisplay.Label(entry.Attendance);
+ point.AttendanceIcon = AttendanceDisplay.ShortLabel(entry.Attendance);
+ }
+}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs
index cc60b59..d3bf711 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs
@@ -12,13 +12,9 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class ParticipationWizardDialogViewModel : ObservableObject
{
- private const string AbschnittPrefix = "Abschnitt: ";
+ private const string AbschnittPrefix = ParticipationTimelineBuilder.SectionNotePrefix;
- private readonly IParticipationSessionRepository _sessions;
- private readonly IParticipationRepository _entries;
private readonly IParticipationSectionRepository _sectionRepo;
- private readonly IExamRepository _exams;
- private readonly IExamResultRepository _results;
private readonly IGradeRepository _grades;
private readonly IUnitRepository _units;
private readonly Dictionary _membershipsByStudent;
@@ -28,12 +24,8 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
private readonly GradingSystem _gradingSystem;
private readonly List _students;
- private readonly List _allSessions;
private readonly List _sectionList;
- private readonly Dictionary _aspectWeights;
- // Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der
- // Gewichtung, analog zu ParticipationGradeDialogViewModel.
- private readonly Dictionary _aspectsByKey;
+ private readonly ParticipationTimelineBuilder _timeline;
public string GroupLabel { get; }
@@ -79,22 +71,19 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
IGradeRepository grades, IUnitRepository units, GradingService grading,
Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel)
{
- _sessions = sessions; _entries = entries; _sectionRepo = sectionRepo;
- _exams = exams; _results = results; _grades = grades; _units = units; _grading = grading;
+ _sectionRepo = sectionRepo;
+ _grades = grades; _units = units; _grading = grading;
_groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem;
GroupLabel = groupLabel;
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
- var applicableAspects = aspects.GetDefaults().Concat(aspects.GetByGroup(groupId))
- .GroupBy(a => a.Key).Select(g => g.Last()).ToList();
- _aspectWeights = applicableAspects.ToDictionary(a => a.Key, a => a.Weight);
- _aspectsByKey = applicableAspects.ToDictionary(a => a.Key);
+ _timeline = new ParticipationTimelineBuilder(sessions, entries, aspects, exams, results, grades,
+ _membershipsByStudent, grading, groupId, gradingSystem);
var schoolYearRange = GroupMembershipService.SchoolYearPeriod(schoolYear, SchoolYearPeriodKind.FullYear);
_students = students.GetByGroup(groupId)
.Where(s => !_membershipsByStudent.TryGetValue(s.Id, out var membership)
|| GroupMembershipService.Overlaps(membership, schoolYearRange.From, schoolYearRange.To))
.OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
- _allSessions = sessions.GetByGroup(groupId).OrderBy(s => s.Date).ToList();
_sectionList = sectionRepo.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList();
_rollupPeriod = RollupPeriodOptions[0];
@@ -131,153 +120,28 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
private void BuildTimeline(Guid studentId)
{
- var points = new List<(DateOnly Date, WizardTimelinePoint Point)>();
-
- foreach (var session in _allSessions)
- {
- if (!IsStudentActiveOn(studentId, session.Date)) continue;
- var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
- points.Add((session.Date, BuildSessionPoint(session, entry, studentId)));
- }
- foreach (var exam in _exams.GetByGroup(_groupId).OrderBy(e => e.Date))
- {
- if (!IsStudentActiveOn(studentId, exam.Date)) continue;
- var result = _results.GetByExamAndStudent(exam.Id, studentId);
- if (result is null) continue;
- points.Add((exam.Date, BuildExamPoint(exam, result)));
- }
- foreach (var grade in _grades.GetByStudentAndGroup(studentId, _groupId)
- .Where(g => g.Category != GradeCategory.Participation)
- .Where(g => IsStudentActiveOn(studentId, g.Date)))
- {
- points.Add((grade.Date, BuildOtherGradePoint(grade)));
- }
- points = points.OrderBy(p => p.Date).ToList();
+ var points = _timeline.BuildPoints(studentId);
Timeline.Clear();
foreach (var section in _sectionList)
{
- var group = new WizardSectionGroup($"{section.Label}\n{section.StartDate:dd.MM.}–{section.EndDate:dd.MM.}", isOpen: false);
- foreach (var (date, point) in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate))
+ var group = new WizardSectionGroup(ParticipationTimelineBuilder.BandLabel(section), isOpen: false);
+ foreach (var point in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate))
group.Points.Add(point);
Timeline.Add(group);
}
var openStart = ComputeOpenStart();
- var openGroup = new WizardSectionGroup($"läuft seit {openStart:dd.MM.}", isOpen: true);
- foreach (var (date, point) in points.Where(p => p.Date >= openStart))
+ var openGroup = new WizardSectionGroup(ParticipationTimelineBuilder.OpenBandLabel(openStart), isOpen: true);
+ foreach (var point in points.Where(p => p.Date >= openStart))
openGroup.Points.Add(point);
Timeline.Add(openGroup);
}
- private WizardTimelinePoint BuildSessionPoint(ParticipationSession session, ParticipationEntry? entry, Guid studentId)
- {
- var weightedValue = entry is not null ? WeightedRating(entry) : null;
- var ratingLabel = weightedValue is not null ? RatingLabel((int)Math.Round(weightedValue.Value, MidpointRounding.AwayFromZero)) : "";
- var note = entry?.Note;
- var tooltip = $"{session.Date:dd.MM.yyyy}" +
- (ratingLabel.Length > 0 ? $" · {ratingLabel}" : "") +
- (string.IsNullOrWhiteSpace(note) ? "" : $" · {note}");
-
- var point = new WizardTimelinePoint(session.Date, isExam: false, ratingLabel, examLabel: "",
- hasNote: !string.IsNullOrWhiteSpace(note), tooltip: tooltip)
- {
- Homework = entry is null ? null : HomeworkDisplay.Effective(entry),
- AttendanceIcon = AttendanceDisplay.ShortLabel(entry?.Attendance),
- AttendanceTooltip = AttendanceDisplay.Label(entry?.Attendance),
- AttendanceColor = AttendanceDisplay.Color(entry?.Attendance),
- AspectValues = entry?.Ratings.ToDictionary(r => r.Key, r => r.Value)
- ?? new Dictionary(),
- WeightedValue = weightedValue,
- OverallGrade = weightedValue is null ? "" : _grading.ParticipationGrade(weightedValue.Value, _gradingSystem),
- };
- point.ToggleHomeworkCommand = new RelayCommand(() => ToggleHomeworkAt(session.Id, studentId, point));
- point.CycleAttendanceCommand = new RelayCommand(() => CycleAttendanceAt(session.Id, studentId, point));
- return point;
- }
-
- private WizardTimelinePoint BuildExamPoint(Exam exam, ExamResult result)
- {
- var examLabel = result.Absent ? $"{exam.Title}: abw." : $"{exam.Title}: {result.Grade}";
- return new WizardTimelinePoint(exam.Date, isExam: true, ratingLabel: "", examLabel: examLabel,
- hasNote: false, tooltip: $"{exam.Date:dd.MM.yyyy} · Klausur {examLabel}");
- }
-
- private static WizardTimelinePoint BuildOtherGradePoint(Grade grade)
- {
- var category = grade.Category switch
- {
- GradeCategory.Oral => "Mündlich",
- GradeCategory.Homework => "Hausaufgabe",
- GradeCategory.Project => "Projekt",
- _ => "Sonstige Leistung",
- };
- var detail = string.IsNullOrWhiteSpace(grade.Note) ? category : grade.Note.Trim();
- var label = $"{detail}: {grade.Value}";
- return new WizardTimelinePoint(grade.Date, isExam: true, ratingLabel: "", examLabel: label,
- hasNote: false, tooltip: $"{grade.Date:dd.MM.yyyy} · {category} · {label}");
- }
-
- private double? WeightedRating(ParticipationEntry entry)
- {
- if (entry.Ratings.Count == 0) return null;
- var weightSum = 0.0; var valueSum = 0.0;
- foreach (var r in entry.Ratings)
- {
- var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
- if (w <= 0) continue;
- valueSum += NormalizedValue(r) * w; weightSum += w;
- }
- return weightSum <= 0 ? null : valueSum / weightSum;
- }
-
- private double NormalizedValue(AspectRating r) => _aspectsByKey.TryGetValue(r.Key, out var aspect)
- ? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints)
- : r.Value;
-
- private static string RatingLabel(int v) => v switch
- {
- >= 2 => "++", 1 => "+", 0 => "~", -1 => "−", _ => "−−",
- };
-
- private void ToggleHomeworkAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
- {
- var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
- ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
- entry.Homework = HomeworkDisplay.Next(HomeworkDisplay.Effective(entry));
- entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework);
- _entries.Save(entry);
- point.Homework = entry.Homework;
- }
-
- private void CycleAttendanceAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
- {
- var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
- ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
- entry.Attendance = entry.Attendance switch
- {
- null => AttendanceStatus.Present,
- AttendanceStatus.Present => AttendanceStatus.ExcusePending,
- AttendanceStatus.ExcusePending => AttendanceStatus.Excused,
- AttendanceStatus.Excused => AttendanceStatus.Unexcused,
- AttendanceStatus.Unexcused => AttendanceStatus.Truant,
- AttendanceStatus.Truant => AttendanceStatus.OtherSchoolEvent,
- AttendanceStatus.OtherSchoolEvent => null,
- _ => null,
- };
- _entries.Save(entry);
- // Das Setzen jeder ObservableProperty kann unmittelbar ein Neuzeichnen auslösen.
- // Deshalb muss die zum aktiven Symbol gehörende Farbe zuerst bereitstehen.
- point.AttendanceColor = AttendanceDisplay.Color(entry.Attendance);
- point.AttendanceTooltip = AttendanceDisplay.Label(entry.Attendance);
- point.AttendanceIcon = AttendanceDisplay.ShortLabel(entry.Attendance);
- }
-
// ── Abschnitte ────────────────────────────────────────────────────────────
private DateOnly ComputeOpenStart() =>
- _sectionList.Count > 0 ? _sectionList.Max(s => s.EndDate).AddDays(1)
- : (_allSessions.Count > 0 ? _allSessions.Min(s => s.Date) : DateOnly.FromDateTime(DateTime.Today));
+ _timeline.OpenStart(_sectionList, DateOnly.FromDateTime(DateTime.Today));
/// Schlägt als Abschnittsende bewusst nicht immer "heute" vor: Wenn seit Abschnittsbeginn
/// eine Unterrichtseinheit (4.2) abgeschlossen wurde, ist deren Ende meist die fachlich
@@ -314,31 +178,11 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
}
var openStart = ComputeOpenStart();
- var suggestion = ComputeSuggestion(studentId, openStart, DateOnly.FromDateTime(DateTime.Today));
+ var suggestion = _timeline.ComputeSuggestion(studentId, openStart, DateOnly.FromDateTime(DateTime.Today));
Sections.Add(new WizardSectionRow("(läuft)", openStart, DateOnly.FromDateTime(DateTime.Today),
suggestion ?? "", isOpen: true));
}
- private string? ComputeSuggestion(Guid studentId, DateOnly start, DateOnly end)
- {
- var points = new List();
- foreach (var session in _allSessions.Where(s => s.Date >= start && s.Date <= end)
- .Where(s => IsStudentActiveOn(studentId, s.Date)))
- {
- var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
- if (entry is null || entry.Ratings.Count == 0) continue;
- var weightSum = 0.0; var valueSum = 0.0;
- foreach (var r in entry.Ratings)
- {
- var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
- if (w <= 0) continue;
- valueSum += NormalizedValue(r) * w; weightSum += w;
- }
- if (weightSum > 0) points.Add(valueSum / weightSum);
- }
- return points.Count == 0 ? null : _grading.ParticipationGrade(points.Average(), _gradingSystem);
- }
-
private void SaveSectionGrade(WizardSectionRow row)
{
if (string.IsNullOrWhiteSpace(row.Value)) { row.StatusMessage = "Wert darf nicht leer sein."; return; }
@@ -382,7 +226,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
foreach (var student in _students)
{
- var suggestion = ComputeSuggestion(student.Id, start, end);
+ var suggestion = _timeline.ComputeSuggestion(student.Id, start, end);
if (suggestion is null) continue;
_grades.Save(new Grade
{
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs
new file mode 100644
index 0000000..1d2fe88
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs
@@ -0,0 +1,664 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LehrerApp.Core.Interfaces;
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using System.Collections.ObjectModel;
+using System.Globalization;
+
+namespace LehrerApp.Desktop.ViewModels.Groups;
+
+// ── Zeugnisnoten-Assistent ───────────────────────────────────────────────────
+//
+// Geht die Lerngruppe Schüler für Schüler durch und zieht alles zusammen, was für die
+// Halbjahres- bzw. Endjahresnote zählt: berechnete Note mit Teilbereichen, Mitarbeitsgraph
+// (abschnittsweise blätterbar oder als durchgehender Verlauf), Arbeiten und Einzelnoten.
+// Ergänzt den Zeugnisnoten-Dialog (2.4), ersetzt ihn nicht — beide schreiben dieselben
+// ReportGrade-Datensätze (gleiche Period-Bezeichnungen), der Dialog bleibt die schnelle Liste.
+//
+// Halbjahre sind eigenständig: Leistungen aus dem 1. Halbjahr zählen nicht in die Note des
+// 2. Halbjahrs, werden dort aber ausgegraut als Kontext gezeigt (Trends). Die Endjahresnote
+// wird aus den Halbjahresnoten verrechnet, nicht aus allen Einzelleistungen; in Klasse 10 lassen
+// sich dafür zusätzlich Projekt- und mündliche Prüfung eintragen (siehe ReportGradeCalculator).
+public partial class ReportGradeAssistantViewModel : ObservableObject
+{
+ private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
+
+ private const int ExamGradeLevel = 10;
+
+ private readonly IReportGradeRepository _reportGrades;
+ private readonly IParticipationRepository _participation;
+ private readonly AttendanceBalanceService _attendanceBalance;
+ private readonly GradingService _grading;
+ private readonly ParticipationTimelineBuilder _timeline;
+ private readonly ReportGradeCalculator _calculator;
+ private readonly Guid _groupId;
+ private readonly int _gradeLevel;
+ private readonly DateOnly _today;
+
+ private readonly List _allStudents;
+ private readonly Dictionary _membershipsByStudent;
+ private readonly List _sections;
+
+ private string? _calculatedValue;
+ private bool _showing;
+ private List _sectionViews = [];
+ private int _sectionIndex;
+ private bool _loading;
+
+ public string GroupLabel { get; }
+
+ [ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
+ [ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial;
+ [ObservableProperty] private string _schemeSummary = "";
+ [ObservableProperty] private AssistantStudentItem? _selectedStudent;
+ [ObservableProperty] private bool _showPreviousHalf = true;
+
+ // Kopf: Note des gewählten Schülers
+ [ObservableProperty] private string _calculatedDisplay = "–";
+ [ObservableProperty] private string _calculationDetail = "";
+ [ObservableProperty] private string _borderlineHint = "";
+ [ObservableProperty] private string _absenceRateDisplay = "";
+ [ObservableProperty] private string _absenceRateColorHex = "#8A8A8A";
+ [ObservableProperty] private string? _overrideValue;
+ [ObservableProperty] private string? _overrideReason;
+ [ObservableProperty] private string _overrideReasonError = "";
+ [ObservableProperty] private bool _isLocked;
+ [ObservableProperty] private string _statusMessage = "";
+
+ // Klasse 10: Prüfungsnoten, die in der Endjahresnote wie ein weiteres Halbjahr zählen
+ [ObservableProperty] private string? _projectExamGrade;
+ [ObservableProperty] private string? _oralExamGrade;
+ [ObservableProperty] private string? _writtenExamGrade;
+ [ObservableProperty] private string? _additionalOralExamGrade;
+ [ObservableProperty] private string _projectExamGradeError = "";
+ [ObservableProperty] private string _oralExamGradeError = "";
+ [ObservableProperty] private string _writtenExamGradeError = "";
+ [ObservableProperty] private string _additionalOralExamGradeError = "";
+
+ // Mitarbeitsgraph
+ [ObservableProperty] private bool _isSectionMode = true;
+ [ObservableProperty] private double _timelineZoom = 1.0;
+ [ObservableProperty] private IReadOnlyList _chartGroups = [];
+ [ObservableProperty] private string _sectionTitle = "";
+ [ObservableProperty] private string _sectionPosition = "";
+
+ public List PeriodOptions { get; } =
+ [
+ new(ParticipationPeriod.H1, "1. Halbjahr"),
+ new(ParticipationPeriod.H2, "2. Halbjahr"),
+ new(ParticipationPeriod.FullYear, "Gesamtes Schuljahr"),
+ ];
+ public string[] RoundingOptions { get; } = RoundingRuleDisplay.Options;
+
+ // Für die ComboBox: deutsche Anzeige statt des rohen Enum-Namens.
+ public string RoundingRuleName
+ {
+ get => RoundingRuleDisplay.Label(RoundingRule);
+ set => RoundingRule = RoundingRuleDisplay.FromLabel(value);
+ }
+
+ public ObservableCollection Students { get; } = [];
+ public ObservableCollection Tiles { get; } = [];
+ public ObservableCollection Works { get; } = [];
+ public ObservableCollection ParticipationGrades { get; } = [];
+ public ObservableCollection OtherGrades { get; } = [];
+
+ public bool HasStudent => SelectedStudent is not null;
+ public string StudentName => SelectedStudent?.Name ?? "";
+ public string ProgressText => SelectedStudent is null
+ ? "" : $"{Students.IndexOf(SelectedStudent) + 1} / {Students.Count}";
+ public string LockLabel => IsLocked ? "Entsperren" : "Festschreiben";
+ public string FinalDisplay => !string.IsNullOrWhiteSpace(OverrideValue) ? OverrideValue!.Trim() : CalculatedDisplay;
+ public bool IsContinuousMode => !IsSectionMode;
+ /// Nur im 2. Halbjahr gibt es ein Vorhalbjahr, das als Kontext eingeblendet werden kann.
+ public bool CanShowPreviousHalf => SelectedPeriod.Period == ParticipationPeriod.H2;
+ /// Projekt- und mündliche Prüfung gibt es nur in Klasse 10 und nur für die Endjahresnote.
+ public bool CanEnterExamGrades =>
+ SelectedPeriod.Period == ParticipationPeriod.FullYear && _gradeLevel == ExamGradeLevel;
+ public bool HasWorks => Works.Count > 0;
+ public bool HasParticipationGrades => ParticipationGrades.Count > 0;
+ public bool HasOtherGrades => OtherGrades.Count > 0;
+
+ public ReportGradeAssistantViewModel(IGradeRepository grades, IExamRepository exams,
+ IExamResultRepository results, IStudentRepository students, IGroupMembershipRepository memberships,
+ IGradingSchemeRepository schemes, IReportGradeRepository reportGrades,
+ IParticipationSessionRepository participationSessions, IParticipationRepository participation,
+ IParticipationAspectRepository aspects, IParticipationSectionRepository sections,
+ AttendanceBalanceService attendanceBalance, GradingService grading,
+ Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear,
+ int gradeLevel, DateOnly? today = null)
+ {
+ _reportGrades = reportGrades; _participation = participation;
+ _attendanceBalance = attendanceBalance; _grading = grading;
+ _groupId = groupId; _gradeLevel = gradeLevel;
+ _today = today ?? DateOnly.FromDateTime(DateTime.Today);
+ GroupLabel = groupLabel;
+
+ _membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
+ _allStudents = students.GetByGroup(groupId).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToList();
+ _sections = sections.GetByGroup(groupId).OrderBy(s => s.StartDate).ToList();
+ _timeline = new ParticipationTimelineBuilder(participationSessions, participation, aspects, exams, results,
+ grades, _membershipsByStudent, grading, groupId, gradingSystem);
+ _calculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades,
+ _membershipsByStudent, grading, groupId, groupType, gradingSystem, schoolYear);
+
+ // Vorbelegung: das Halbjahr, in dem wir uns gerade befinden.
+ var (h1From, h1To) = _calculator.Range(ParticipationPeriod.H1);
+ _selectedPeriod = _today >= h1From && _today <= h1To ? PeriodOptions[0] : PeriodOptions[1];
+ Reload();
+ }
+
+ partial void OnSelectedPeriodChanged(ParticipationPeriodOption value)
+ {
+ OnPropertyChanged(nameof(CanShowPreviousHalf));
+ OnPropertyChanged(nameof(CanEnterExamGrades));
+ Reload();
+ }
+
+ partial void OnRoundingRuleChanged(RoundingRule value) => Reload();
+ partial void OnShowPreviousHalfChanged(bool value) => ShowStudent();
+
+ partial void OnSelectedStudentChanged(AssistantStudentItem? value)
+ {
+ OnPropertyChanged(nameof(HasStudent));
+ OnPropertyChanged(nameof(StudentName));
+ OnPropertyChanged(nameof(ProgressText));
+ PreviousStudentCommand.NotifyCanExecuteChanged();
+ NextStudentCommand.NotifyCanExecuteChanged();
+ if (!_loading) ShowStudent();
+ }
+
+ partial void OnIsSectionModeChanged(bool value)
+ {
+ OnPropertyChanged(nameof(IsContinuousMode));
+ ApplyChartMode();
+ }
+
+ partial void OnIsLockedChanged(bool value) => OnPropertyChanged(nameof(LockLabel));
+ partial void OnOverrideValueChanged(string? value) => OnPropertyChanged(nameof(FinalDisplay));
+ partial void OnCalculatedDisplayChanged(string value) => OnPropertyChanged(nameof(FinalDisplay));
+
+ // Eine eingetippte Prüfungsnote wirkt sofort auf die angezeigte Berechnung, gespeichert wird
+ // sie erst mit der Zeugnisnote.
+ partial void OnProjectExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); }
+ partial void OnOralExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); }
+ partial void OnWrittenExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); }
+ partial void OnAdditionalOralExamGradeChanged(string? value) { if (!_showing) ShowCalculation(); }
+
+ private void ClearErrors()
+ {
+ OverrideReasonError = ""; ProjectExamGradeError = ""; OralExamGradeError = "";
+ WrittenExamGradeError = ""; AdditionalOralExamGradeError = "";
+ }
+
+ // ── Laden ────────────────────────────────────────────────────────────────
+
+ private void Reload()
+ {
+ _calculator.Reload();
+ SchemeSummary = _calculator.SchemeSummary;
+
+ var (from, to) = _calculator.Range(SelectedPeriod.Period);
+ var previousId = SelectedStudent?.StudentId;
+
+ _loading = true;
+ Students.Clear();
+ foreach (var student in _allStudents)
+ {
+ if (_membershipsByStudent.TryGetValue(student.Id, out var membership)
+ && !GroupMembershipService.Overlaps(membership, from, to)) continue;
+ var item = new AssistantStudentItem(student.Id, student.FullName);
+ RefreshItem(item);
+ Students.Add(item);
+ }
+ SelectedStudent = Students.FirstOrDefault(s => s.StudentId == previousId) ?? Students.FirstOrDefault();
+ _loading = false;
+
+ OnPropertyChanged(nameof(ProgressText));
+ PreviousStudentCommand.NotifyCanExecuteChanged();
+ NextStudentCommand.NotifyCanExecuteChanged();
+ ShowStudent();
+ }
+
+ private void RefreshItem(AssistantStudentItem item)
+ {
+ var record = _reportGrades.GetByStudentGroupPeriod(item.StudentId, _groupId, SelectedPeriod.Label);
+ var calculated = record is { IsLocked: true }
+ ? NullIfEmpty(record.CalculatedValue)
+ : _calculator.Calculate(item.StudentId, SelectedPeriod.Period, RoundingRule).Rounded;
+ item.Update(NullIfEmpty(record?.OverrideValue) ?? calculated ?? "–", record switch
+ {
+ null => AssistantGradeStatus.Open,
+ { IsLocked: true } => AssistantGradeStatus.Locked,
+ _ => AssistantGradeStatus.Saved,
+ });
+ }
+
+ // ── Berechnung ───────────────────────────────────────────────────────────
+
+ // ── Anzeige des gewählten Schülers ───────────────────────────────────────
+
+ private void ShowStudent()
+ {
+ StatusMessage = "";
+ ClearErrors();
+ Works.Clear(); ParticipationGrades.Clear(); OtherGrades.Clear();
+
+ if (SelectedStudent is null)
+ {
+ _calculatedValue = null;
+ Tiles.Clear();
+ CalculatedDisplay = "–"; CalculationDetail = ""; BorderlineHint = ""; AbsenceRateDisplay = "";
+ OverrideValue = null; OverrideReason = null; IsLocked = false;
+ _sectionViews = [];
+ ApplyChartMode();
+ NotifyLists();
+ return;
+ }
+
+ var studentId = SelectedStudent.StudentId;
+ var (from, to) = _calculator.Range(SelectedPeriod.Period);
+ _membershipsByStudent.TryGetValue(studentId, out var membership);
+
+ var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label);
+ _showing = true;
+ IsLocked = record is { IsLocked: true };
+ OverrideValue = record?.OverrideValue;
+ OverrideReason = record?.OverrideReason;
+ ProjectExamGrade = CanEnterExamGrades ? record?.ProjectExamGrade : null;
+ OralExamGrade = CanEnterExamGrades ? record?.OralExamGrade : null;
+ WrittenExamGrade = CanEnterExamGrades ? record?.WrittenExamGrade : null;
+ AdditionalOralExamGrade = CanEnterExamGrades ? record?.AdditionalOralExamGrade : null;
+ _showing = false;
+
+ ShowCalculation();
+ ShowAbsenceRate(studentId, from, to);
+
+ // Im 2. Halbjahr auf Wunsch das Vorhalbjahr als Kontext mitzeigen — ohne Einfluss auf die Note.
+ var displayFrom = CanShowPreviousHalf && ShowPreviousHalf
+ ? _calculator.Range(ParticipationPeriod.FullYear).From : from;
+ BuildWorks(studentId, membership, displayFrom, from, to);
+ BuildGradeRows(studentId, membership, displayFrom, from, to);
+ BuildSections(studentId, displayFrom, to);
+ NotifyLists();
+ }
+
+ /// Berechnete Note und Teilnoten-Kacheln des gewählten Schülers.
+ private void ShowCalculation()
+ {
+ if (SelectedStudent is null) return;
+ var studentId = SelectedStudent.StudentId;
+ var period = SelectedPeriod.Period;
+ // In Klasse 10 gelten die gerade eingetragenen Prüfungsnoten, sonst die gespeicherten.
+ YearExamGrades? typed = CanEnterExamGrades
+ ? new YearExamGrades(ProjectExamGrade, OralExamGrade, WrittenExamGrade, AdditionalOralExamGrade)
+ : null;
+ var result = _calculator.Calculate(studentId, period, RoundingRule, typed);
+
+ // Festgeschrieben heißt: der gespeicherte Stand gilt, es wird nicht neu berechnet.
+ _calculatedValue = IsLocked
+ ? NullIfEmpty(_reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label)?.CalculatedValue)
+ : result.Rounded;
+ CalculatedDisplay = _calculatedValue ?? "–";
+ // Genau auf der Grenze entscheidet die gewählte Rundung nur formal — der Hinweis macht
+ // sichtbar, dass hier die Lehrkraft entscheiden sollte.
+ var between = !IsLocked && result.Value is { } value ? ReportGradeCalculator.BetweenGrades(value) : null;
+ BorderlineHint = between is null ? "" : $"Liegt genau {between} — pädagogische Entscheidung.";
+
+ Tiles.Clear();
+ if (period == ParticipationPeriod.FullYear)
+ {
+ var parts = _calculator.YearParts(studentId, RoundingRule, typed);
+ foreach (var part in parts)
+ Tiles.Add(new AssistantTile(part.Label, part.Final ?? "–", part.Hint));
+ CalculationDetail = result.Value is null
+ ? "Noch keine Halbjahresnote vorhanden."
+ : $"aus {parts.Count(p => p.Final is not null)} Teilnoten {Format(result.Value.Value)} vor Rundung";
+ }
+ else
+ {
+ var scheme = _calculator.Scheme;
+ Tiles.Add(BucketTile("Schriftlich", result.Written, scheme.ExamsPercent));
+ Tiles.Add(BucketTile("Mitarbeit", result.Participation, scheme.ParticipationPercent));
+ Tiles.Add(BucketTile("Sonstige", result.Other, scheme.OtherPercent));
+ CalculationDetail = result.Value is null
+ ? "Noch keine Leistungen im gewählten Zeitraum."
+ : $"gewichtet {Format(result.Value.Value)} vor Rundung";
+ }
+ }
+
+ private void NotifyLists()
+ {
+ OnPropertyChanged(nameof(HasWorks));
+ OnPropertyChanged(nameof(HasParticipationGrades));
+ OnPropertyChanged(nameof(HasOtherGrades));
+ }
+
+ private static AssistantTile BucketTile(string label, double? average, double percent) =>
+ new(label, average.HasValue ? Format(average.Value) : "–", $"{percent:0.#} %");
+
+ private void ShowAbsenceRate(Guid studentId, DateOnly from, DateOnly to)
+ {
+ // Wie im Zeugnisnoten-Dialog (2.4.5): nur Sitzungen dieser Gruppe im gewählten Zeitraum.
+ var attendance = _timeline.Sessions
+ .Where(s => s.Date >= from && s.Date <= to)
+ .Select(s => (s.Date, _participation.GetBySessionAndStudent(s.Id, studentId)?.Attendance))
+ .ToList();
+ var rate = _attendanceBalance.Calculate(attendance, from, to).AbsenceRatePercent;
+ AbsenceRateDisplay = $"{rate:0.#} % gefehlt";
+ AbsenceRateColorHex = rate >= ReportGradeRow.HighAbsenceThresholdPercent ? "#D64545" : "#8A8A8A";
+ }
+
+ private void BuildWorks(Guid studentId, GroupMembership? membership, DateOnly displayFrom, DateOnly from, DateOnly to)
+ {
+ foreach (var exam in _calculator.Exams.Where(e => e.Date >= displayFrom && e.Date <= to))
+ {
+ if (!ReportGradeCalculator.ExamApplies(exam, membership)) continue;
+ var results = _calculator.ResultsByExam[exam.Id];
+ results.TryGetValue(studentId, out var own);
+ // Nachschreib- und Wiederholungsarbeiten betreffen nur einzelne Schüler — ohne eigenes
+ // Ergebnis gehören sie nicht in diese Übersicht.
+ if (own is null && exam.RepeatOfExamId.HasValue) continue;
+
+ var gradeDisplay = own switch
+ {
+ null => "–",
+ { Absent: true } => "abw.",
+ _ => own.Grade ?? "–",
+ };
+
+ var percentDisplay = "";
+ if (own is { Absent: false })
+ {
+ var maximum = ExamScoringService.Maximum(exam.Tasks, own.Variant);
+ if (maximum > 0) percentDisplay = $"{own.TotalPoints / maximum * 100:0} %";
+ }
+
+ var classGrades = results.Values.Where(r => !r.Absent && r.Grade is not null)
+ .Select(r => (r.Grade!, 1.0)).ToList();
+ var classAverage = classGrades.Count > 0 ? $"Ø Kurs {Format(_grading.WeightedAverage(classGrades))}" : "";
+
+ var kind = exam.Kind.Label();
+ if (exam.IsMakeUp) kind += " · Nachschreibarbeit";
+ else if (exam.RepeatOfExamId.HasValue) kind += " · Wiederholung";
+
+ Works.Add(new AssistantWorkCard(exam.Title, exam.Date, kind, gradeDisplay, percentDisplay,
+ classAverage, isContext: exam.Date < from));
+ }
+ }
+
+ private void BuildGradeRows(Guid studentId, GroupMembership? membership, DateOnly displayFrom, DateOnly from, DateOnly to)
+ {
+ foreach (var grade in _calculator.Grades
+ .Where(g => g.StudentId == studentId && g.Date >= displayFrom && g.Date <= to)
+ .Where(g => membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
+ {
+ var isParticipation = grade.Category == GradeCategory.Participation;
+ var category = GradeCategoryDisplay.Label(grade.Category);
+ var label = string.IsNullOrWhiteSpace(grade.Note)
+ ? category
+ : isParticipation ? grade.Note.Trim() : $"{category}: {grade.Note.Trim()}";
+ var row = new AssistantGradeRow(label, grade.Date, grade.Value, isContext: grade.Date < from);
+ (isParticipation ? ParticipationGrades : OtherGrades).Add(row);
+ }
+ }
+
+ // ── Mitarbeitsgraph: Abschnitte / Verlauf ────────────────────────────────
+
+ private sealed record SectionView(WizardSectionGroup Group, string Title);
+
+ private void BuildSections(Guid studentId, DateOnly displayFrom, DateOnly to)
+ {
+ var points = _timeline.BuildPoints(studentId).Where(p => p.Date >= displayFrom && p.Date <= to).ToList();
+ var sectionGrades = _calculator.Grades
+ .Where(g => g.StudentId == studentId && g.Category == GradeCategory.Participation && g.Note is not null)
+ .ToList();
+
+ var views = new List();
+ foreach (var section in _sections.Where(s => s.EndDate >= displayFrom && s.StartDate <= to))
+ {
+ var group = new WizardSectionGroup(ParticipationTimelineBuilder.BandLabel(section), isOpen: false);
+ foreach (var point in points.Where(p => p.Date >= section.StartDate && p.Date <= section.EndDate))
+ group.Points.Add(point);
+ var grade = sectionGrades.FirstOrDefault(g =>
+ g.Note == ParticipationTimelineBuilder.SectionNotePrefix + section.Label)?.Value;
+ views.Add(new SectionView(group,
+ $"{section.Label} · {section.StartDate:dd.MM.}–{section.EndDate:dd.MM.} · " +
+ (grade is null ? "keine Abschnittsnote" : $"Abschnittsnote {grade}")));
+ }
+
+ // Der laufende Abschnitt endet erst mit dem nächsten Abschluss im Mitarbeits-Assistenten;
+ // bis dahin gibt es nur einen Vorschlag aus den bisherigen Sitzungen.
+ var openStart = _timeline.OpenStart(_sections, _today);
+ if (openStart <= to)
+ {
+ var group = new WizardSectionGroup(ParticipationTimelineBuilder.OpenBandLabel(openStart), isOpen: true);
+ foreach (var point in points.Where(p => p.Date >= openStart))
+ group.Points.Add(point);
+ var end = _today < to ? _today : to;
+ var suggestion = _timeline.ComputeSuggestion(studentId, openStart < displayFrom ? displayFrom : openStart, end);
+ views.Add(new SectionView(group,
+ $"Laufender Abschnitt seit {openStart:dd.MM.} · " +
+ (suggestion is null ? "noch kein Vorschlag" : $"vorläufig {suggestion}")));
+ }
+
+ _sectionViews = views;
+ // Beim Schülerwechsel im selben Abschnitt bleiben — so lassen sich Schüler abschnittsweise vergleichen.
+ _sectionIndex = Math.Clamp(_sectionIndex, 0, Math.Max(0, views.Count - 1));
+ ApplyChartMode();
+ }
+
+ private void ApplyChartMode()
+ {
+ if (_sectionViews.Count == 0)
+ {
+ ChartGroups = [];
+ SectionTitle = ""; SectionPosition = "";
+ }
+ else if (IsSectionMode)
+ {
+ var view = _sectionViews[_sectionIndex];
+ ChartGroups = [view.Group];
+ SectionTitle = view.Title;
+ SectionPosition = $"{_sectionIndex + 1} / {_sectionViews.Count}";
+ }
+ else
+ {
+ ChartGroups = _sectionViews.Select(v => v.Group).ToList();
+ SectionTitle = ""; SectionPosition = "";
+ }
+ PreviousSectionCommand.NotifyCanExecuteChanged();
+ NextSectionCommand.NotifyCanExecuteChanged();
+ }
+
+ private bool CanGoPreviousSection => IsSectionMode && _sectionIndex > 0;
+ private bool CanGoNextSection => IsSectionMode && _sectionIndex < _sectionViews.Count - 1;
+
+ [RelayCommand(CanExecute = nameof(CanGoPreviousSection))]
+ private void PreviousSection() { _sectionIndex--; ApplyChartMode(); }
+
+ [RelayCommand(CanExecute = nameof(CanGoNextSection))]
+ private void NextSection() { _sectionIndex++; ApplyChartMode(); }
+
+ // ── Navigation ───────────────────────────────────────────────────────────
+
+ private int StudentIndex => SelectedStudent is null ? -1 : Students.IndexOf(SelectedStudent);
+ private bool CanGoPreviousStudent => StudentIndex > 0;
+ private bool CanGoNextStudent => StudentIndex >= 0 && StudentIndex < Students.Count - 1;
+
+ [RelayCommand(CanExecute = nameof(CanGoPreviousStudent))]
+ private void PreviousStudent() => SelectedStudent = Students[StudentIndex - 1];
+
+ [RelayCommand(CanExecute = nameof(CanGoNextStudent))]
+ private void NextStudent() => SelectedStudent = Students[StudentIndex + 1];
+
+ // ── Speichern / Festschreiben ────────────────────────────────────────────
+
+ [RelayCommand]
+ private void Save() => TrySave();
+
+ [RelayCommand]
+ private void SaveAndNext()
+ {
+ if (!TrySave()) return;
+ if (CanGoNextStudent) NextStudent();
+ }
+
+ private bool TrySave()
+ {
+ if (SelectedStudent is null || IsLocked) return false;
+ ClearErrors();
+ var valid = true;
+ if (!string.IsNullOrWhiteSpace(OverrideValue) && string.IsNullOrWhiteSpace(OverrideReason))
+ {
+ OverrideReasonError = "Für ein manuelles Übersteuern ist eine Begründung Pflicht.";
+ valid = false;
+ }
+ if (CanEnterExamGrades)
+ {
+ if (!IsGradeOrEmpty(ProjectExamGrade)) { ProjectExamGradeError = "Keine gültige Note."; valid = false; }
+ if (!IsGradeOrEmpty(OralExamGrade)) { OralExamGradeError = "Keine gültige Note."; valid = false; }
+ if (!IsGradeOrEmpty(WrittenExamGrade)) { WrittenExamGradeError = "Keine gültige Note."; valid = false; }
+ if (!IsGradeOrEmpty(AdditionalOralExamGrade)) { AdditionalOralExamGradeError = "Keine gültige Note."; valid = false; }
+
+ // Ein Fach ist entweder schriftliches oder mündliches Prüfungsfach; eine zusätzliche
+ // mündliche Prüfung gibt es nur im schriftlichen Prüfungsfach oder ganz ohne Abschlussprüfung.
+ var hasOral = NullIfEmpty(OralExamGrade) is not null;
+ if (hasOral && NullIfEmpty(WrittenExamGrade) is not null)
+ {
+ OralExamGradeError = "Entweder schriftliche oder mündliche Abschlussprüfung.";
+ valid = false;
+ }
+ if (hasOral && NullIfEmpty(AdditionalOralExamGrade) is not null)
+ {
+ AdditionalOralExamGradeError = "Im Fach der mündlichen Abschlussprüfung nicht vorgesehen.";
+ valid = false;
+ }
+ }
+ if (!valid) return false;
+
+ var record = _reportGrades.GetByStudentGroupPeriod(SelectedStudent.StudentId, _groupId, SelectedPeriod.Label)
+ ?? new ReportGrade { StudentId = SelectedStudent.StudentId, GroupId = _groupId, Period = SelectedPeriod.Label };
+ record.CalculatedValue = _calculatedValue ?? "";
+ record.OverrideValue = NullIfEmpty(OverrideValue);
+ record.OverrideReason = NullIfEmpty(OverrideReason);
+ if (CanEnterExamGrades)
+ {
+ record.ProjectExamGrade = NullIfEmpty(ProjectExamGrade);
+ record.OralExamGrade = NullIfEmpty(OralExamGrade);
+ record.WrittenExamGrade = NullIfEmpty(WrittenExamGrade);
+ record.AdditionalOralExamGrade = NullIfEmpty(AdditionalOralExamGrade);
+ }
+ record.UpdatedAt = DateTime.UtcNow;
+ _reportGrades.Save(record);
+
+ RefreshItem(SelectedStudent);
+ StatusMessage = "Gespeichert.";
+ return true;
+ }
+
+ [RelayCommand]
+ private void ToggleLock()
+ {
+ if (SelectedStudent is null) return;
+ var studentId = SelectedStudent.StudentId;
+ var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label);
+ if (!IsLocked)
+ {
+ // Festschreiben sichert immer zuerst den aktuell angezeigten Stand.
+ if (!TrySave()) return;
+ record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, SelectedPeriod.Label);
+ }
+ if (record is null) return;
+
+ record.IsLocked = !record.IsLocked;
+ record.UpdatedAt = DateTime.UtcNow;
+ _reportGrades.Save(record);
+ RefreshItem(SelectedStudent);
+ ShowStudent();
+ StatusMessage = record.IsLocked ? "Festgeschrieben." : "Entsperrt.";
+ }
+
+ private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ /// Ganze Note bzw. Punktzahl, optional mit Tendenz ("3+", "2-") — dieselbe Schreibweise, die
+ /// die Notenberechnung versteht; alles andere würde dort stillschweigend ausgelassen.
+ private static bool IsGradeOrEmpty(string? value)
+ {
+ var text = NullIfEmpty(value);
+ return text is null || int.TryParse(text.TrimEnd('+', '-').TrimEnd(), out _);
+ }
+
+ private static string Format(double value) => value.ToString("0.00", German);
+}
+
+// ── Bausteine ────────────────────────────────────────────────────────────────
+
+public enum AssistantGradeStatus { Open, Saved, Locked }
+
+public partial class AssistantStudentItem(Guid studentId, string name) : ObservableObject
+{
+ public Guid StudentId { get; } = studentId;
+ public string Name { get; } = name;
+
+ [ObservableProperty] private string _gradeDisplay = "–";
+ [ObservableProperty] private AssistantGradeStatus _status;
+
+ public string StatusLabel => Status switch
+ {
+ AssistantGradeStatus.Locked => "festgeschrieben",
+ AssistantGradeStatus.Saved => "gespeichert",
+ _ => "offen",
+ };
+
+ public string StatusColorHex => Status switch
+ {
+ AssistantGradeStatus.Locked => "#2E9D57",
+ AssistantGradeStatus.Saved => "#2E86DE",
+ _ => "#8A8A8A",
+ };
+
+ public void Update(string gradeDisplay, AssistantGradeStatus status)
+ {
+ GradeDisplay = gradeDisplay;
+ Status = status;
+ }
+
+ partial void OnStatusChanged(AssistantGradeStatus value)
+ {
+ OnPropertyChanged(nameof(StatusLabel));
+ OnPropertyChanged(nameof(StatusColorHex));
+ }
+}
+
+public class AssistantTile(string label, string value, string hint)
+{
+ public string Label { get; } = label;
+ public string Value { get; } = value;
+ public string Hint { get; } = hint;
+}
+
+public class AssistantWorkCard(string title, DateOnly date, string kindLabel, string gradeDisplay,
+ string percentDisplay, string classAverageDisplay, bool isContext)
+{
+ public string Title { get; } = title;
+ public string DateDisplay { get; } = date.ToString("dd.MM.yyyy");
+ public string KindLabel { get; } = kindLabel;
+ public string GradeDisplay { get; } = gradeDisplay;
+ public string PercentDisplay { get; } = percentDisplay;
+ public string ClassAverageDisplay { get; } = classAverageDisplay;
+ /// Vorhalbjahr: nur zum Vergleich gezeigt, zählt nicht in die aktuelle Note.
+ public bool IsContext { get; } = isContext;
+ public double DisplayOpacity => IsContext ? 0.45 : 1.0;
+ public string ContextLabel => IsContext ? "1. Halbjahr · zählt nicht" : "";
+}
+
+public class AssistantGradeRow(string label, DateOnly date, string value, bool isContext)
+{
+ public string Label { get; } = label;
+ public string DateDisplay { get; } = date.ToString("dd.MM.yyyy");
+ public string Value { get; } = value;
+ public bool IsContext { get; } = isContext;
+ public double DisplayOpacity => IsContext ? 0.45 : 1.0;
+}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs
new file mode 100644
index 0000000..d894974
--- /dev/null
+++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs
@@ -0,0 +1,233 @@
+using LehrerApp.Core.Interfaces;
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+
+namespace LehrerApp.Desktop.ViewModels.Groups;
+
+public sealed record ReportGradeResult(double? Value, string? Rounded, double? Written, double? Participation, double? Other);
+
+/// Eine Teilnote der Endjahresnote: Halbjahresnote oder Prüfungsleistung.
+/// Rechenwert, wenn er sich nicht aus ergibt —
+/// etwa eine Prüfungsleistung, die zwischen zwei Noten liegt.
+public sealed record YearPart(string Label, string? Final, string Hint, double Weight = 1.0, double? Value = null);
+
+/// Prüfungsnoten der Klasse 10, soweit in diesem Fach abgelegt.
+public readonly record struct YearExamGrades(string? Project, string? Oral,
+ string? Written = null, string? AdditionalOral = null);
+
+///
+/// Gemeinsame Zeugnisnoten-Berechnung für Zeugnisnoten-Dialog (2.4) und Zeugnisnoten-Assistent
+/// (2.7), damit beide für denselben Datensatz denselben Wert zeigen.
+///
+/// Halbjahre sind eigenständig und werden aus den Einzelleistungen ihres Zeitraums gewichtet.
+/// Die Endjahresnote wird dagegen aus den Halbjahresnoten verrechnet (gleich gewichtet).
+///
+/// Klasse 10 (Bremen): In einem Prüfungsfach ergibt sich die Gesamtnote zu zwei Dritteln aus der
+/// unterrichtlichen Leistung und zu einem Drittel aus der Prüfungsleistung (§ 14 Abs. 3 der
+/// Verordnung über die Sekundarstufe I der Oberschule) — die Prüfung zählt also wie ein drittes
+/// Halbjahr. Die Prüfungsleistung ist die Note der schriftlichen bzw. mündlichen
+/// Abschlussprüfung. Kommt in einem schriftlichen Prüfungsfach eine zusätzliche mündliche
+/// Prüfung hinzu, werden beide 1:1 verrechnet (§ 13 Abs. 2 PrüfV Sek. I). In einem Fach ohne
+/// Abschlussprüfung zählt eine zusätzliche mündliche Prüfung selbst als Prüfungsleistung, also
+/// 2:1 (§ 13 Abs. 3 PrüfV Sek. I). Die Projektprüfung zählt nach Schulpraxis als weiteres
+/// Halbjahr.
+///
+/// Bewusst nicht umgesetzt sind die Rundungsvorgaben der Verordnungen: Noten sind
+/// Leistungsbewertungen, die Entscheidung zwischen zwei Noten ist pädagogisch. Eine
+/// Prüfungsleistung zwischen zwei Noten geht deshalb ungerundet in die Rechnung ein, und für die
+/// Endnote gilt die von der Lehrkraft gewählte Rundung samt Möglichkeit zum Übersteuern.
+///
+public sealed class ReportGradeCalculator
+{
+ public const string ProjectExamLabel = "Projektprüfung";
+ public const string OralExamLabel = "Mündliche Prüfung";
+ public const string WrittenExamLabel = "Schriftliche Prüfung";
+ public const string AdditionalOralExamLabel = "Zusätzliche mündliche Prüfung";
+
+ private readonly IGradeRepository _grades;
+ private readonly IExamRepository _exams;
+ private readonly IExamResultRepository _results;
+ private readonly IGradingSchemeRepository _schemes;
+ private readonly IReportGradeRepository _reportGrades;
+ private readonly IReadOnlyDictionary _membershipsByStudent;
+ private readonly GradingService _grading;
+ private readonly Guid _groupId;
+ private readonly GroupType _groupType;
+ private readonly GradingSystem _gradingSystem;
+ private readonly string _schoolYear;
+
+ public GradingScheme Scheme { get; private set; } = new();
+ public List Exams { get; private set; } = [];
+ public Dictionary> ResultsByExam { get; private set; } = [];
+ public List Grades { get; private set; } = [];
+
+ public string SchemeSummary =>
+ $"Klausuren {Scheme.ExamsPercent:0.#} % · Mitarbeit {Scheme.ParticipationPercent:0.#} % · " +
+ $"Sonstige {Scheme.OtherPercent:0.#} %";
+
+ public ReportGradeCalculator(IGradeRepository grades, IExamRepository exams, IExamResultRepository results,
+ IGradingSchemeRepository schemes, IReportGradeRepository reportGrades,
+ IReadOnlyDictionary membershipsByStudent, GradingService grading,
+ Guid groupId, GroupType groupType, GradingSystem gradingSystem, string schoolYear)
+ {
+ _grades = grades; _exams = exams; _results = results; _schemes = schemes;
+ _reportGrades = reportGrades; _membershipsByStudent = membershipsByStudent; _grading = grading;
+ _groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _schoolYear = schoolYear;
+ Reload();
+ }
+
+ /// Liest Schema, Arbeiten und Noten neu ein — vor jeder Neuberechnung einer ganzen Ansicht.
+ public void Reload()
+ {
+ Scheme = _schemes.GetByGroup(_groupId)
+ ?? _schemes.GetDefaultForType(_groupType)
+ ?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
+ Exams = _exams.GetByGroup(_groupId).OrderBy(e => e.Date).ToList();
+ ResultsByExam = Exams.ToDictionary(e => e.Id,
+ e => _results.GetByExam(e.Id).GroupBy(r => r.StudentId).ToDictionary(g => g.Key, g => g.First()));
+ Grades = _grades.GetByGroup(_groupId).OrderBy(g => g.Date).ToList();
+ }
+
+ public (DateOnly From, DateOnly To) Range(ParticipationPeriod period) =>
+ GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch
+ {
+ ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
+ ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
+ _ => SchoolYearPeriodKind.FullYear,
+ });
+
+ public static string PeriodLabel(ParticipationPeriod period) => period switch
+ {
+ ParticipationPeriod.H1 => "1. Halbjahr",
+ ParticipationPeriod.H2 => "2. Halbjahr",
+ _ => "Gesamtes Schuljahr",
+ };
+
+ public static bool ExamApplies(Exam exam, GroupMembership? membership)
+ {
+ if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) return false;
+ return !exam.Niveau.HasValue || membership?.Niveau == exam.Niveau;
+ }
+
+ /// Noch nicht gespeicherte Prüfungsnoten für die Jahresnote; ohne
+ /// Angabe gelten die am Jahres-Datensatz gespeicherten.
+ public ReportGradeResult Calculate(Guid studentId, ParticipationPeriod period, RoundingRule rounding,
+ YearExamGrades? examGrades = null)
+ {
+ if (period == ParticipationPeriod.FullYear)
+ {
+ var parts = YearParts(studentId, rounding, examGrades)
+ .Select(p => (Value: p.Value ?? (p.Final is null ? null : GradingService.ParseGradeValue(p.Final)), p.Weight))
+ .Where(p => p.Value.HasValue).ToList();
+ if (parts.Count == 0) return new ReportGradeResult(null, null, null, null, null);
+ var mean = parts.Sum(p => p.Value!.Value * p.Weight) / parts.Sum(p => p.Weight);
+ return new ReportGradeResult(mean, _grading.RoundToGrade(mean, _gradingSystem, rounding), null, null, null);
+ }
+
+ var (from, to) = Range(period);
+ _membershipsByStudent.TryGetValue(studentId, out var membership);
+
+ var examEntries = new List<(Exam Exam, string Grade)>();
+ foreach (var exam in Exams.Where(e => e.Date >= from && e.Date <= to))
+ {
+ if (!ExamApplies(exam, membership)) continue;
+ if (!ResultsByExam[exam.Id].TryGetValue(studentId, out var result)) continue;
+ if (result.Absent || result.Grade is null) continue;
+ examEntries.Add((exam, result.Grade));
+ }
+ var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(examEntries, _gradingSystem);
+ var written = writtenGrades.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
+
+ var studentGrades = Grades
+ .Where(g => g.StudentId == studentId && g.Date >= from && g.Date <= to)
+ .Where(g => membership is null || GroupMembershipService.IsActiveOn(membership, g.Date))
+ .ToList();
+ var participation = studentGrades.Where(g => g.Category == GradeCategory.Participation)
+ .Select(g => (Grade: g.Value, g.Weight)).ToList();
+ var other = studentGrades.Where(g => g.Category != GradeCategory.Participation)
+ .Select(g => (Grade: g.Value, g.Weight)).ToList();
+ // Tests, kontrollierte Hausaufgaben u. Ä. aus der Klausurfunktion zählen als Sonstige.
+ other.AddRange(testGrades.Select(grade => (Grade: grade, Weight: 1.0)));
+
+ var value = _grading.CalculateReportGradeValue(written, participation, other, Scheme);
+ return new ReportGradeResult(value,
+ value is null ? null : _grading.RoundToGrade(value.Value, _gradingSystem, rounding),
+ written.Count > 0 ? _grading.WeightedAverage(written) : null,
+ participation.Count > 0 ? _grading.WeightedAverage(participation) : null,
+ other.Count > 0 ? _grading.WeightedAverage(other) : null);
+ }
+
+ /// Die Teilnoten der Endjahresnote. Bei den Halbjahren geht eine gespeicherte Entscheidung
+ /// (Übersteuerung bzw. festgeschriebener Wert) der Live-Berechnung vor.
+ public List YearParts(Guid studentId, RoundingRule rounding, YearExamGrades? examGrades = null)
+ {
+ var parts = new List();
+ foreach (var half in new[] { ParticipationPeriod.H1, ParticipationPeriod.H2 })
+ {
+ var label = PeriodLabel(half);
+ var record = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, label);
+ var overridden = NullIfEmpty(record?.OverrideValue);
+ if (overridden is not null)
+ parts.Add(new YearPart(label, overridden, record!.IsLocked ? "festgeschrieben" : "übersteuert"));
+ else if (record is { IsLocked: true })
+ parts.Add(new YearPart(label, NullIfEmpty(record.CalculatedValue), "festgeschrieben"));
+ else
+ parts.Add(new YearPart(label, Calculate(studentId, half, rounding).Rounded, "berechnet"));
+ }
+
+ // Die unterrichtliche Leistung zählt immer doppelt gegenüber einer Prüfung — auch wenn
+ // nur eine der beiden Halbjahresnoten vorliegt.
+ if (parts.Count(p => p.Final is not null) == 1)
+ parts = parts.Select(p => p.Final is null ? p : p with { Weight = 2.0 }).ToList();
+
+ var exams = examGrades ?? StoredExamGrades(studentId);
+ if (NullIfEmpty(exams.Project) is { } project)
+ parts.Add(new YearPart(ProjectExamLabel, project, "zählt wie ein Halbjahr"));
+ if (ExamPerformance(exams) is { } performance)
+ parts.Add(performance);
+ return parts;
+ }
+
+ /// Die Prüfungsleistung des Fachs, die mit einem Drittel in die Gesamtnote eingeht.
+ private YearPart? ExamPerformance(YearExamGrades exams)
+ {
+ var written = NullIfEmpty(exams.Written);
+ var oral = NullIfEmpty(exams.Oral);
+ var additional = NullIfEmpty(exams.AdditionalOral);
+
+ if (written is not null && additional is not null)
+ {
+ // § 13 Abs. 2 PrüfV Sek. I: 1:1. Liegt das Ergebnis zwischen zwei Noten, wird hier
+ // nicht gerundet — es geht so in die Endnote ein, entschieden wird dort.
+ var mean = _grading.WeightedAverage([(written, 1.0), (additional, 1.0)]);
+ var between = BetweenGrades(mean);
+ return new YearPart("Prüfungsleistung", mean.ToString("0.##", German),
+ $"schriftlich {written} · mündlich {additional}" + (between is null ? "" : $" — {between}"),
+ Value: mean);
+ }
+ if (written is not null) return new YearPart(WrittenExamLabel, written, "ein Drittel der Note");
+ if (oral is not null) return new YearPart(OralExamLabel, oral, "ein Drittel der Note");
+ if (additional is not null) return new YearPart(AdditionalOralExamLabel, additional, "ein Drittel der Note");
+ return null;
+ }
+
+ public YearExamGrades StoredExamGrades(Guid studentId)
+ {
+ var year = _reportGrades.GetByStudentGroupPeriod(studentId, _groupId, PeriodLabel(ParticipationPeriod.FullYear));
+ return new YearExamGrades(year?.ProjectExamGrade, year?.OralExamGrade,
+ year?.WrittenExamGrade, year?.AdditionalOralExamGrade);
+ }
+
+ /// "zwischen 4 und 5", wenn der Wert genau auf der Grenze zweier Noten liegt — dann gibt
+ /// es rechnerisch keine richtige Seite, nur eine pädagogische Entscheidung.
+ public static string? BetweenGrades(double value)
+ {
+ var lower = Math.Floor(value);
+ return Math.Abs(value - lower - 0.5) < 0.0001 ? $"zwischen {lower:0} und {lower + 1:0}" : null;
+ }
+
+ private static readonly System.Globalization.CultureInfo German =
+ System.Globalization.CultureInfo.GetCultureInfo("de-DE");
+
+ private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs
index 18707e3..c6be0e5 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs
@@ -13,22 +13,15 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class ReportGradeDialogViewModel : ObservableObject
{
- private readonly IGradeRepository _grades;
- private readonly IExamRepository _exams;
- private readonly IExamResultRepository _results;
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
- private readonly IGradingSchemeRepository _schemes;
private readonly IReportGradeRepository _reportGrades;
+ private readonly ReportGradeCalculator _calculator;
private readonly IParticipationSessionRepository _participationSessions;
private readonly IParticipationRepository _participation;
private readonly AttendanceBalanceService _attendanceBalance;
- private readonly GradingService _grading;
private readonly Guid _groupId;
- private readonly GroupType _groupType;
- private readonly GradingSystem _gradingSystem;
private readonly string _groupLabel;
- private readonly string _schoolYear;
[ObservableProperty] private ParticipationPeriodOption _selectedPeriod;
[ObservableProperty] private RoundingRule _roundingRule = RoundingRule.Commercial;
@@ -59,12 +52,13 @@ public partial class ReportGradeDialogViewModel : ObservableObject
AttendanceBalanceService attendanceBalance, GradingService grading,
Guid groupId, GroupType groupType, GradingSystem gradingSystem, string groupLabel, string schoolYear)
{
- _grades = grades; _exams = exams; _results = results; _students = students;
- _memberships = memberships; _schemes = schemes; _reportGrades = reportGrades; _grading = grading;
+ _students = students; _memberships = memberships; _reportGrades = reportGrades;
_participationSessions = participationSessions; _participation = participation;
_attendanceBalance = attendanceBalance;
- _groupId = groupId; _groupType = groupType; _gradingSystem = gradingSystem; _groupLabel = groupLabel;
- _schoolYear = schoolYear;
+ _groupId = groupId; _groupLabel = groupLabel;
+ _calculator = new ReportGradeCalculator(grades, exams, results, schemes, reportGrades,
+ memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId), grading,
+ groupId, groupType, gradingSystem, schoolYear);
_selectedPeriod = PeriodOptions[0];
Recompute();
@@ -73,31 +67,19 @@ public partial class ReportGradeDialogViewModel : ObservableObject
partial void OnSelectedPeriodChanged(ParticipationPeriodOption value) => Recompute();
partial void OnRoundingRuleChanged(RoundingRule value) => Recompute();
- private GradingScheme ResolveScheme() =>
- _schemes.GetByGroup(_groupId)
- ?? _schemes.GetDefaultForType(_groupType)
- ?? new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
+ public void Refresh() => Recompute();
private void Recompute()
{
- var scheme = ResolveScheme();
- SchemeSummary = $"Klausuren {scheme.ExamsPercent:0.#} % · Mitarbeit {scheme.ParticipationPercent:0.#} % · " +
- $"Sonstige {scheme.OtherPercent:0.#} %";
+ _calculator.Reload();
+ SchemeSummary = _calculator.SchemeSummary;
var period = SelectedPeriod.Period;
var periodTag = SelectedPeriod.Label;
- var (periodFrom, periodTo) = GroupMembershipService.SchoolYearPeriod(_schoolYear, period switch
- {
- ParticipationPeriod.H1 => SchoolYearPeriodKind.H1,
- ParticipationPeriod.H2 => SchoolYearPeriodKind.H2,
- _ => SchoolYearPeriodKind.FullYear,
- });
+ var (periodFrom, periodTo) = _calculator.Range(period);
var students = _students.GetByGroup(_groupId);
var membershipsByStudent = _memberships.GetByGroup(_groupId).ToDictionary(m => m.StudentId);
- var exams = _exams.GetByGroup(_groupId).Where(e => e.Date >= periodFrom && e.Date <= periodTo).ToList();
- var resultsByExam = exams.ToDictionary(e => e.Id, e => _results.GetByExam(e.Id).ToDictionary(r => r.StudentId));
- var allGrades = _grades.GetByGroup(_groupId).Where(g => g.Date >= periodFrom && g.Date <= periodTo).ToList();
// Fehlquote (Nutzer-Feedback): je Schüler die Anwesenheits-Bilanz im gewählten Zeitraum,
// nur aus Sitzungen dieser Gruppe (anders als StudentDetailViewModel.LoadAttendanceBalance,
@@ -132,31 +114,9 @@ public partial class ReportGradeDialogViewModel : ObservableObject
continue;
}
- var examEntries = new List<(Exam Exam, string Grade)>();
- foreach (var exam in exams)
- {
- if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue;
- if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
- if (!resultsByExam[exam.Id].TryGetValue(student.Id, out var result)) continue;
- if (result.Absent || result.Grade is null) continue;
- examEntries.Add((exam, result.Grade));
- }
- var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(examEntries, _gradingSystem);
- var examGrades = writtenGrades.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
-
- var participationGrades = allGrades
- .Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation
- && (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
- .Select(g => (g.Value, g.Weight)).ToList();
- var otherGrades = allGrades
- .Where(g => g.StudentId == student.Id && g.Category != GradeCategory.Participation
- && (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
- .Select(g => (g.Value, g.Weight)).ToList();
- // Tests, kontrollierte Hausaufgaben u. Ä. aus der Klausurfunktion zählen als Sonstige.
- otherGrades.AddRange(testGrades.Select(grade => (grade, 1.0)));
-
- var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades,
- scheme, _gradingSystem, RoundingRule);
+ // Halbjahre aus den Einzelleistungen, das Schuljahr aus den Halbjahresnoten
+ // (plus ggf. Prüfungsnoten) — siehe ReportGradeCalculator.
+ var calculated = _calculator.Calculate(student.Id, period, RoundingRule).Rounded;
Rows.Add(ReportGradeRow.FromCalculated(student.Id, student.FullName, calculated, existing, absenceRate, Save, ToggleLock));
}
diff --git a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs
index ef41c96..44e3b79 100644
--- a/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/GradeOverviewTabView.axaml.cs
@@ -75,11 +75,33 @@ public partial class GradeOverviewTabView : UserControl
App.Services.GetRequiredService(),
_vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear);
- var dialog = new ReportGradeDialog { DataContext = dialogVm };
+ var dialog = new ReportGradeDialog { DataContext = dialogVm, OnOpenAssistant = ShowReportGradeAssistant };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null) await dialog.ShowDialog(owner);
}
+ private async Task ShowReportGradeAssistant(Window owner)
+ {
+ var dialogVm = new ReportGradeAssistantViewModel(
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ _vm!.GroupId, _vm.GroupType, _vm.GradingSystem, _vm.GroupLabel, _vm.SchoolYear,
+ App.Services.GetRequiredService().GetById(_vm.GroupId)?.GradeLevel ?? 0);
+
+ await new ReportGradeAssistantDialog { DataContext = dialogVm }.ShowDialog(owner);
+ }
+
private async Task ShowPerformanceOverviewDialog(GradeOverviewRow row)
{
var dialogVm = new StudentPerformanceOverviewViewModel(
diff --git a/LehrerApp.Desktop/Views/Groups/ReportGradeAssistantDialog.axaml b/LehrerApp.Desktop/Views/Groups/ReportGradeAssistantDialog.axaml
new file mode 100644
index 0000000..d1f7da6
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Groups/ReportGradeAssistantDialog.axaml
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/ReportGradeAssistantDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/ReportGradeAssistantDialog.axaml.cs
new file mode 100644
index 0000000..960e253
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Groups/ReportGradeAssistantDialog.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace LehrerApp.Desktop.Views.Groups;
+
+public partial class ReportGradeAssistantDialog : Window
+{
+ public ReportGradeAssistantDialog() => InitializeComponent();
+
+ private void OnClose(object? sender, RoutedEventArgs e) => Close();
+}
diff --git a/LehrerApp.Desktop/Views/Groups/ReportGradeDialog.axaml b/LehrerApp.Desktop/Views/Groups/ReportGradeDialog.axaml
index 14b5d0f..d7d4c02 100644
--- a/LehrerApp.Desktop/Views/Groups/ReportGradeDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/ReportGradeDialog.axaml
@@ -61,9 +61,11 @@
-
+
-
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/ReportGradeDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/ReportGradeDialog.axaml.cs
index 87d5b88..067cdfa 100644
--- a/LehrerApp.Desktop/Views/Groups/ReportGradeDialog.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/ReportGradeDialog.axaml.cs
@@ -10,6 +10,18 @@ public partial class ReportGradeDialog : Window
{
public ReportGradeDialog() => InitializeComponent();
+ /// Öffnet den Zeugnisnoten-Assistenten über diesem Dialog; vom Aufrufer gesetzt, der die
+ /// Gruppendaten kennt.
+ public Func? OnOpenAssistant { get; set; }
+
+ private async void OnOpenAssistantClick(object? sender, RoutedEventArgs e)
+ {
+ if (OnOpenAssistant is null) return;
+ await OnOpenAssistant(this);
+ // Der Assistent schreibt dieselben Zeugnisnoten — Liste danach neu aufbauen.
+ (DataContext as ReportGradeDialogViewModel)?.Refresh();
+ }
+
private async void OnExportClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not ReportGradeDialogViewModel vm) return;
diff --git a/LehrerApp.Tests/GradingServiceTests.cs b/LehrerApp.Tests/GradingServiceTests.cs
index 125d761..79f9ef8 100644
--- a/LehrerApp.Tests/GradingServiceTests.cs
+++ b/LehrerApp.Tests/GradingServiceTests.cs
@@ -290,6 +290,24 @@ public class GradingServiceTests
// ── CalculateReportGrade ──────────────────────────────────────────────────
+ [Fact]
+ public void CalculateReportGradeValue_LiefertUngerundetenGewichtetenWert()
+ {
+ var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
+
+ var value = _sut.CalculateReportGradeValue([("2", 1.0)], [("3", 1.0)], [("1", 1.0)], scheme);
+
+ Assert.Equal(2.3, value!.Value, 3);
+ }
+
+ [Fact]
+ public void CalculateReportGradeValue_OhneWerte_Null()
+ {
+ var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
+
+ Assert.Null(_sut.CalculateReportGradeValue([], [], [], scheme));
+ }
+
[Fact]
public void CalculateReportGrade_AlleBereicheVorhanden_GewichtetGemaessSchema()
{
diff --git a/TODO.md b/TODO.md
index c2b48e4..37a6940 100644
--- a/TODO.md
+++ b/TODO.md
@@ -309,6 +309,72 @@ eigenes Werkzeug, nur im Kopf überschlagen.
damit auch die "Gesamt"-Spalte der Notenübersicht (2.1.2) und `CalculateReportGrade`, die
beide auf `WeightedAverage` aufbauen.
+### 2.7 Zeugnisnoten-Assistent (Nutzer-Feedback)
+
+Selten gebraucht, dann aber wichtig: vor Zeugnissen, Sprechtagen und blauen Briefen die Leistung
+eines Schülers noch einmal im Zusammenhang sehen, statt nur eine berechnete Zahl in der Liste.
+"Letztendlich keine sturen Rechnungen, sondern pädagogische Entscheidungen." Bewusst nicht auf
+oberster Ebene — Einstieg über den Button "Assistent …" im Zeugnisnoten-Dialog (2.4), der als
+schnelle Listenansicht bestehen bleibt.
+
+- [x] **2.7.1** Assistent mit Schülerliste (Status offen / gespeichert / festgeschrieben) und einer
+ Seite je Schüler: berechnete Note mit ungerundetem Wert und Teilbereichen, Fehlquote,
+ Übersteuern mit Pflichtbegründung, Speichern / "Speichern & weiter" / Festschreiben.
+ [ReportGradeAssistantViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/ReportGradeAssistantViewModels.cs),
+ [ReportGradeAssistantDialog.axaml](LehrerApp.Desktop/Views/Groups/ReportGradeAssistantDialog.axaml).
+ Schreibt dieselben `ReportGrade`-Datensätze wie der Dialog (gleiche `Period`-Bezeichnungen),
+ der Dialog baut seine Liste nach dem Schließen des Assistenten neu auf. Neue
+ `GradingService.CalculateReportGradeValue()` liefert den ungerundeten Wert, damit sichtbar
+ ist, wie knapp eine Note zwischen zwei Stufen liegt.
+- [x] **2.7.2** Mitarbeitsgraph in zwei Modi auf Basis des bestehenden
+ `ParticipationTimelineChart`: "Abschnitte" (ein Abschnitt je Seite mit seiner Abschnittsnote,
+ blätterbar; beim Schülerwechsel bleibt der Abschnitt stehen) und "Verlauf" (ganzer Zeitraum
+ durchgehend scrollbar). Der laufende Abschnitt zeigt den Notenvorschlag als "vorläufig".
+ Die Punkt-Erzeugung wurde dafür aus dem Mitarbeits-Assistenten in den gemeinsamen
+ [ParticipationTimelineBuilder](LehrerApp.Desktop/ViewModels/Groups/ParticipationTimelineBuilder.cs)
+ herausgezogen — beide Assistenten zeigen denselben Graphen nach denselben Regeln.
+- [x] **2.7.3** Arbeiten als Karten (Note, Prozent, Kursschnitt, Art, Nachschreib-/Wiederholungs-
+ Kennzeichen), dazu Mitarbeits- und sonstige Noten. **Halbjahre sind eigenständig:** Leistungen
+ des 1. Halbjahrs zählen nicht in die Note des 2. Halbjahrs, werden dort aber ausgegraut als
+ Kontext gezeigt (abschaltbar), weil sich Trends so besser erkennen lassen.
+- [x] **2.7.4** Endjahresnote: wird aus den Halbjahresnoten verrechnet (gleich gewichtet), nicht
+ aus allen Einzelleistungen des Jahres. Eine gespeicherte Entscheidung (Übersteuerung bzw.
+ festgeschriebener Wert) geht dabei der Live-Berechnung des Halbjahrs vor. Gilt auch für den
+ Zeugnisnoten-Dialog (2.4), der "Gesamtes Schuljahr" zuvor aus allen Einzelleistungen
+ gerechnet hat — beide Ansichten nutzen jetzt den gemeinsamen
+ [ReportGradeCalculator](LehrerApp.Desktop/ViewModels/Groups/ReportGradeCalculator.cs).
+- [x] **2.7.4a** Sonderfall Klasse 10: die Note der Projektprüfung und/oder der mündlichen
+ Prüfung zählt, wenn sie in diesem Fach abgelegt wurde, wie ein drittes bzw. viertes Halbjahr
+ in die Endjahresnote ("klingt dämlich, aber so wird es berechnet"). Eingabe im Assistenten,
+ nur bei `GradeLevel == 10` und Zeitraum "Gesamtes Schuljahr"; gespeichert als
+ `ReportGrade.ProjectExamGrade`/`OralExamGrade` am Jahres-Datensatz (siehe
+ `docs/Datenmodell.md`). Der Dialog rechnet gespeicherte Prüfungsnoten mit, bietet aber
+ bewusst keine eigene Eingabe dafür.
+- [x] **2.7.4b** Nachprüfungen Klasse 10, gegen die Verordnung über die Sekundarstufe I der
+ Oberschule (Stand 06/2025) und die PrüfV Sek. I geprüft. Zwei weitere Felder am
+ Jahres-Datensatz: `WrittenExamGrade` (schriftliche Abschlussprüfung) und
+ `AdditionalOralExamGrade` (zusätzliche mündliche Prüfung). Die Prüfungsleistung zählt ein
+ Drittel, die unterrichtliche Leistung zwei Drittel (§ 14 Abs. 3 ObSchVO) — auch wenn nur
+ eine Halbjahresnote vorliegt. Zusätzliche mündliche Prüfung im schriftlichen Prüfungsfach:
+ 1:1 mit der schriftlichen Prüfung (§ 13 Abs. 2 PrüfV); ohne Abschlussprüfung im Fach: 2:1
+ (§ 13 Abs. 3 PrüfV). Widersprüchliche Eingaben (schriftliche *und* mündliche
+ Abschlussprüfung; Nachprüfung im mündlichen Prüfungsfach) werden abgelehnt.
+ **Entschieden (Nutzer):** (1) Die Projektprüfung bleibt ein eigenes Halbjahr (Schulpraxis),
+ auch wenn § 4a Abs. 3 sie in die Halbjahresnote einfließen lässt. (2) Die Rundungsvorgaben
+ der Verordnungen werden nicht nachgebildet — "es sind Leistungsbewertungen, mit denen nicht
+ gerechnet werden kann", die Entscheidung ist pädagogisch: eine Prüfungsleistung zwischen
+ zwei Noten geht ungerundet weiter, die Rundungsauswahl bleibt frei, und der Assistent zeigt
+ einen Hinweis, wenn der Wert genau zwischen zwei Noten liegt. (3) Maßgeblich ist der
+ neueste Stand der Verordnung (06/2025), ohne Übergangsregelung für ältere Jahrgänge.
+- [ ] **2.7.5** Vorläufige Noten: Stichtag "Stand bis …" (rechnet nur, speichert nichts; der
+ laufende Mitarbeits-Abschnitt zählt mit seinem Vorschlag mit) und gespeicherte Notenstände
+ (Datum, Anlass wie Sprechtag / Schülerrückfrage / blauer Brief, vorläufige schriftliche,
+ mündliche und Gesamtnote, Notiz) — mehrere je Halbjahr, getrennt von der endgültigen
+ `ReportGrade`.
+- [ ] **2.7.6** Noch im Detail zu besprechen: Grenzfall-Filter, Trendhinweise, Begründungsvorschlag
+ beim Übersteuern, Kompetenzblock (Stärken / Baustellen), Gesprächsblatt als PDF,
+ Schülermodus.
+
---
## 3. Mündliche Mitarbeit — offene Punkte
diff --git a/docs/Datenmodell.md b/docs/Datenmodell.md
index 3208942..e63cf48 100644
--- a/docs/Datenmodell.md
+++ b/docs/Datenmodell.md
@@ -89,6 +89,31 @@ zu `MembershipPeriod`). `CalculatedValue` ist das zuletzt berechnete Ergebnis, `
überschreibt es bei pädagogischem Ermessen und erfordert `OverrideReason`. Nach dem Festschreiben
(`IsLocked`) wird der Datensatz bei einer Neuberechnung nicht mehr verändert.
+Die beiden Halbjahre werden jeweils aus den Einzelleistungen ihres Zeitraums berechnet und sind
+voneinander unabhängig. "Gesamtes Schuljahr" wird dagegen aus den Halbjahresnoten verrechnet
+(gleich gewichtet), nicht aus allen Einzelleistungen des Jahres.
+
+Vier Felder sind nur am Jahres-Datensatz einer 10. Klasse belegt und halten Prüfungsnoten fest,
+soweit die Prüfung in diesem Fach abgelegt wurde:
+
+- `WrittenExamGrade` / `OralExamGrade`: schriftliche bzw. mündliche Abschlussprüfung. Ein Fach
+ hat höchstens eine von beiden. Die Prüfungsleistung geht mit einem Drittel in die Endnote ein,
+ die unterrichtliche Leistung (beide Halbjahre) mit zwei Dritteln — § 14 Abs. 3 der Verordnung
+ über die Sekundarstufe I der Oberschule.
+- `AdditionalOralExamGrade`: zusätzliche mündliche Prüfung ("Nachprüfung"). Im schriftlichen
+ Prüfungsfach wird sie 1:1 mit der schriftlichen Prüfung zur Prüfungsleistung verrechnet
+ (§ 13 Abs. 2 PrüfV Sek. I). In einem Fach ohne Abschlussprüfung ist sie selbst die
+ Prüfungsleistung, also 2:1 (§ 13 Abs. 3 PrüfV Sek. I).
+- `ProjectExamGrade`: Projektprüfung, zählt wie ein weiteres Halbjahr. Das ist die Praxis der
+ Schule; der Wortlaut der Verordnung (§ 4a Abs. 3: fließt in die Halbjahresnote des
+ zugeordneten Fachs ein) wird bewusst nicht nachgebildet.
+
+Die Rundungsvorgaben der Verordnungen sind bewusst nicht umgesetzt: Noten sind
+Leistungsbewertungen, die Entscheidung zwischen zwei Noten ist pädagogisch. Eine Prüfungsleistung
+zwischen zwei Noten geht ungerundet in die Rechnung ein; für die Endnote gilt die von der
+Lehrkraft gewählte Rundung, und der Assistent weist darauf hin, wenn der Wert genau zwischen
+zwei Noten liegt. Stand der Verordnungen: Oberschulverordnung in der Fassung von Juni 2025.
+
## Bewusst gespeicherte Momentaufnahmen
Einige berechnete Werte bleiben absichtlich gespeichert: