From a088823b3058ca573a7275aa0633f95921df6c78 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sun, 30 Aug 2026 13:24:16 +0200 Subject: [PATCH] Notentendenzen (3+/2-) korrekt in Notenschnitt einrechnen, Abschnittsende-Vorschlag aus Einheit Bisher liess int.TryParse jede Note mit Tendenz durchfallen und WeightedAverage rechnete den betroffenen Bereich mit 0 statt sie auszulassen - faelschte Notenuebersicht, Zielnoten- und Was-waere-wenn-Rechner. Ausserdem schlaegt der Mitarbeits-Assistent beim Abschnitt-Abschliessen jetzt das Ende der zuletzt abgeschlossenen Unterrichtseinheit statt immer "heute" vor. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Services/GradingService.cs | 18 +++++++++++++- .../Groups/ParticipationWizardViewModels.cs | 24 +++++++++++++++++-- .../Groups/ParticipationTabView.axaml.cs | 1 + LehrerApp.Tests/GradingServiceTests.cs | 19 +++++++++++++++ TODO.md | 16 +++++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) diff --git a/LehrerApp.Core/Services/GradingService.cs b/LehrerApp.Core/Services/GradingService.cs index 6429df2..f1a1136 100644 --- a/LehrerApp.Core/Services/GradingService.cs +++ b/LehrerApp.Core/Services/GradingService.cs @@ -77,13 +77,29 @@ public class GradingService public double WeightedAverage(List<(string Grade, double Weight)> grades) { var numeric = grades - .Select(g => (Value: int.TryParse(g.Grade, out var n) ? (int?)n : null, g.Weight)) + .Select(g => (Value: ParseGradeValue(g.Grade), g.Weight)) .Where(g => g.Value.HasValue).ToList(); if (numeric.Count == 0) return 0; var total = numeric.Sum(g => g.Weight); return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total; } + /// 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) + { + var trimmed = grade.Trim(); + if (trimmed.Length == 0) return null; + + var tendency = 0.0; + var core = trimmed; + if (core.EndsWith('+')) { tendency = -1.0 / 3.0; core = core[..^1].TrimEnd(); } + else if (core.EndsWith('-')) { tendency = 1.0 / 3.0; core = core[..^1].TrimEnd(); } + + return int.TryParse(core, out var n) ? n + tendency : null; + } + /// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl. /// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard). /// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs index 0b5e950..cc60b59 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs @@ -20,6 +20,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject private readonly IExamRepository _exams; private readonly IExamResultRepository _results; private readonly IGradeRepository _grades; + private readonly IUnitRepository _units; private readonly Dictionary _membershipsByStudent; private readonly GradingService _grading; private readonly Guid _groupId; @@ -75,11 +76,11 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject IParticipationAspectRepository aspects, IParticipationSectionRepository sectionRepo, IStudentRepository students, IGroupMembershipRepository memberships, IExamRepository exams, IExamResultRepository results, - IGradeRepository grades, GradingService grading, + 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; _grading = grading; + _exams = exams; _results = results; _grades = grades; _units = units; _grading = grading; _groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem; GroupLabel = groupLabel; _membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId); @@ -98,6 +99,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject _rollupPeriod = RollupPeriodOptions[0]; NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}"; + NewSectionEndDateText = SuggestSectionEndDate().ToString("dd.MM.yyyy"); if (_students.Count > 0) ShowStudent(0); } @@ -277,6 +279,23 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject _sectionList.Count > 0 ? _sectionList.Max(s => s.EndDate).AddDays(1) : (_allSessions.Count > 0 ? _allSessions.Min(s => s.Date) : 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 + /// sinnvollere Zäsur für eine Mitarbeit-Teilnote als das Bearbeitungsdatum im Wizard. + private DateOnly SuggestSectionEndDate() + { + var openStart = ComputeOpenStart(); + var today = DateOnly.FromDateTime(DateTime.Today); + var completedUnitEnd = _units.GetByGroup(_groupId) + .Where(u => u.Status == UnitStatus.Completed && u.EndDate.HasValue) + .Select(u => u.EndDate!.Value) + .Where(d => d >= openStart && d <= today) + .OrderByDescending(d => d) + .Cast() + .FirstOrDefault(); + return completedUnitEnd ?? today; + } + private void BuildSections(Guid studentId) { Sections.Clear(); @@ -378,6 +397,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject } NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}"; + NewSectionEndDateText = SuggestSectionEndDate().ToString("dd.MM.yyyy"); BuildTimeline(CurrentStudentId); BuildSections(CurrentStudentId); } diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs index 151df30..479004d 100644 --- a/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs @@ -350,6 +350,7 @@ public partial class ParticipationTabView : UserControl App.Services.GetRequiredService(), App.Services.GetRequiredService(), App.Services.GetRequiredService(), + App.Services.GetRequiredService(), App.Services.GetRequiredService(), tabVm.GroupId, tabVm.SchoolYear, tabVm.GradingSystem, tabVm.GroupLabel); diff --git a/LehrerApp.Tests/GradingServiceTests.cs b/LehrerApp.Tests/GradingServiceTests.cs index 9752a7c..125d761 100644 --- a/LehrerApp.Tests/GradingServiceTests.cs +++ b/LehrerApp.Tests/GradingServiceTests.cs @@ -181,6 +181,25 @@ public class GradingServiceTests Assert.Equal(0, result); } + [Theory] + [InlineData("3+", 2.6667)] + [InlineData("2-", 2.3333)] + [InlineData("1+", 0.6667)] + [InlineData("6-", 6.3333)] + public void WeightedAverage_NotenMitTendenz_VerschiebtUmEinDrittel(string grade, double expected) + { + var result = _sut.WeightedAverage([(grade, 1.0)]); + Assert.Equal(expected, result, precision: 4); + } + + [Fact] + public void WeightedAverage_NotenMitTendenzGemischtMitGanzenNoten_GewichtetKorrekt() + { + // (3+ [2,667] + 2 [2,0]) / 2 = 2,333 + var result = _sut.WeightedAverage([("3+", 1.0), ("2", 1.0)]); + Assert.Equal(2.3333, result, precision: 4); + } + // ── RoundToGrade ────────────────────────────────────────────────────────── [Fact] diff --git a/TODO.md b/TODO.md index c7bdc0a..ea32588 100644 --- a/TODO.md +++ b/TODO.md @@ -252,6 +252,15 @@ eigenes Werkzeug, nur im Kopf überschlagen. **Bewusst zurückgestellt:** eine Übersicht über alle Fächer eines Schülers gleichzeitig (Gesamt-Zeugnisvorschau) — unterschiedliche Notenschemata/Gewichtungen pro Kurs wären ein eigener, größerer Umbau. +- [x] **2.6.5** Bugfix (Nutzer-Feedback): frei eingetragene Noten mit Tendenz ("3+", "2-") in den + editierbaren Mitarbeit-/Sonstige-Feldern (2.6.4) wurden von `GradingService.WeightedAverage` + nicht erkannt (`int.TryParse` schlägt bei einem Suffix fehl) — bestanden nur Tendenznoten in + einem Bereich, wurde er komplett auf 0 statt ausgelassen berechnet, was den Zielnoten-Rechner + und die Zeugnisnote verfälschte. Neue `GradingService.ParseGradeValue()` erkennt ein + abschließendes `+`/`-` und verschiebt um ein Drittel Richtung der besseren Note + (z.B. "3+" → 2,67, "2-" → 2,33, üblicher Drittelabstand deutscher Notensysteme) — betrifft + damit auch die "Gesamt"-Spalte der Notenübersicht (2.1.2) und `CalculateReportGrade`, die + beide auf `WeightedAverage` aufbauen. --- @@ -442,6 +451,13 @@ zusammenziehen, daraus die Halbjahresnote bilden": - Dashboard-Kachel "Offene Entschuldigungen": listet alle `ExcusePending`-Einträge der letzten 21 Tage gruppenübergreifend mit Direktauflösung; ältere Einträge werden ausgeblendet statt automatisch entschieden (pädagogische Entscheidung bleibt bei der Lehrkraft). +- **Nachtrag (Nutzer-Feedback, nach erster produktiver Nutzung):** Vorgeschlagenes Abschnittsende + im Assistenten war immer "heute" (`NewSectionEndDateText`), obwohl die fachlich naheliegende + Zäsur oft schon feststeht, wenn die aktuelle Unterrichtseinheit (4.1) abgeschlossen ist. Neue + `SuggestSectionEndDate()` schlägt stattdessen das `Unit.EndDate` der zuletzt abgeschlossenen + Einheit (`UnitStatus.Completed`) vor, sofern deren Ende im laufenden, noch offenen Abschnitt + liegt (zwischen dem Ende des letzten Abschnitts und heute) — sonst bleibt es bei "heute". Der + Vorschlag ist nur eine Vorbelegung des Textfelds, weiterhin frei überschreibbar. ---