From 16ff4875d885f0487c5bb7b6689b964c0a85bc3a Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 17 Aug 2026 00:12:16 +0200 Subject: [PATCH] =?UTF-8?q?3.1.3:=20Aspekt-Typen=20Scale3/Binary/Points=20?= =?UTF-8?q?in=20Raster=20und=20Dialog=20unterst=C3=BCtzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Klasse ParticipationRatingScale (Core) ist die einzige Quelle für Rohwerte je Aspekt-Typ. Scale3/Binary liegen bewusst direkt auf derselben -2..+2-Achse wie Scale5 (nur mit weniger Zwischenschritten), damit die bestehende Gewichtung/Mittelwertbildung zur Mitarbeitsnote unverändert kompatibel bleibt. Points ist grundverschieden (echter Zählwert 0..MaxPoints, neues Feld auf ParticipationAspect) und wird nur zur Aggregation linear auf dieselbe Achse normiert. Ohne diese Normierung hätte ein Punkte-Aspekt die Mitarbeitsnote verfälscht: drei Stellen summierten bisher den Rohwert direkt (ParticipationGradeDialog- ViewModel.Recompute, ParticipationWizardViewModels.WeightedRating und .ComputeSuggestion) - alle drei sind jetzt auf die Normierung umgestellt. Raster: Punkte-Aspekte zeigen ein NumericUpDown statt fester Stufen-Buttons. Schnelleingabe-Dialog: Zifferntasten/+/- sind jetzt typabhängig, Legende zeigt live die für den aktuellen Aspekt gültigen Tasten. Aspekt-Verwaltung um "Max. Punkte"-Feld ergänzt (nur bei Typ "Punkte" sichtbar). Bewusst nicht angefasst: die Trendlinien-Visualisierung im Mitarbeits-Assistenten bleibt fest auf die drei Standardaspekte zugeschnitten - eigener, größerer Umbau. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Models/Participation.cs | 42 +++++ .../ParticipationGradeAggregationTests.cs | 39 +++++ LehrerApp.Desktop.Tests/RatingCellTests.cs | 81 +++++++++ .../Groups/ParticipationGradeViewModels.cs | 9 +- .../Groups/ParticipationViewModels.cs | 164 +++++++++++++----- .../Groups/ParticipationWizardViewModels.cs | 19 +- .../Groups/ParticipationAspectsDialog.axaml | 23 ++- .../ParticipationQuickInputDialog.axaml | 3 +- .../ParticipationQuickInputDialog.axaml.cs | 6 + .../Groups/ParticipationTabView.axaml.cs | 98 +++++++---- .../ParticipationRatingScaleTests.cs | 80 +++++++++ TODO.md | 35 +++- 12 files changed, 502 insertions(+), 97 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/RatingCellTests.cs create mode 100644 LehrerApp.Tests/ParticipationRatingScaleTests.cs diff --git a/LehrerApp.Core/Models/Participation.cs b/LehrerApp.Core/Models/Participation.cs index 765ed94..dd3833c 100644 --- a/LehrerApp.Core/Models/Participation.cs +++ b/LehrerApp.Core/Models/Participation.cs @@ -96,6 +96,9 @@ public class ParticipationAspect public string Key { get; set; } = ""; public string Label { get; set; } = ""; public AspectValueType ValueType { get; set; } = AspectValueType.Scale5; + // Nur bei ValueType.Points relevant: die Rohbewertung ist dann ein echter Punktwert + // 0..MaxPoints (nicht auf der -2..+2-Achse), siehe ParticipationRatingScale. + public int MaxPoints { get; set; } = 5; public double Weight { get; set; } = 1.0; public bool IsActive { get; set; } = true; public int SortOrder { get; set; } @@ -113,3 +116,42 @@ public static class DefaultParticipationAspects new() { Key = "workphase", Label = "Arbeitsphase", ValueType = AspectValueType.Scale5, SortOrder = 2 }, ]; } + +/// +/// Definiert je , welche Rohwerte eine Bewertung annehmen kann und +/// wie sie sich auf die gemeinsame Qualitätsachse (-2..+2, wie ) +/// abbilden, auf der die Gewichtung/Mittelwertbildung zur Mitarbeitsnote (3.2) rechnet. +/// Scale3/Binary liegen bewusst direkt AUF dieser Achse (nur mit weniger Zwischenschritten) +/// statt einer eigenen Wertemenge zu bekommen — dadurch bleibt die bestehende Aggregation +/// unverändert kompatibel, ohne pro Bewertung erst umrechnen zu müssen. Nur "Points" hat eine +/// eigene, von der Lehrkraft festgelegte Obergrenze () +/// und wird für die Aggregation linear auf dieselbe Achse normiert (0 → -2, MaxPoints → +2). +/// +public static class ParticipationRatingScale +{ + /// Feste Rohwert/Label-Paare für Typen mit einer kleinen, festen Stufenzahl — Grundlage für + /// Raster-Buttons und Schnelleingabe-Zifferntasten. Leer für Points (freie Zahleneingabe). + public static IReadOnlyList<(int Value, string Label)> Steps(AspectValueType type) => type switch + { + AspectValueType.Scale5 => [(-2, "−−"), (-1, "−"), (0, "∼"), (1, "+"), (2, "++")], + AspectValueType.Scale3 => [(-2, "−"), (0, "∼"), (2, "+")], + AspectValueType.Binary => [(-2, "Nein"), (2, "Ja")], + _ => [], + }; + + public static string DisplayLabel(AspectValueType type, int? value) + { + if (value is null) return "·"; + if (type == AspectValueType.Points) return value.Value.ToString(); + var match = Steps(type).FirstOrDefault(s => s.Value == value.Value); + return match.Label ?? "·"; + } + + /// Bildet einen Rohwert auf die gemeinsame -2..+2-Achse ab, für die Mittelwertbildung in 3.2. + public static double Normalize(AspectValueType type, int value, int maxPoints) => type switch + { + AspectValueType.Points when maxPoints > 0 => Math.Clamp(value, 0, maxPoints) / (double)maxPoints * 4.0 - 2.0, + AspectValueType.Points => 0, // keine sinnvolle Obergrenze konfiguriert -> neutral werten + _ => value, + }; +} diff --git a/LehrerApp.Desktop.Tests/ParticipationGradeAggregationTests.cs b/LehrerApp.Desktop.Tests/ParticipationGradeAggregationTests.cs index 35d43aa..5e20abe 100644 --- a/LehrerApp.Desktop.Tests/ParticipationGradeAggregationTests.cs +++ b/LehrerApp.Desktop.Tests/ParticipationGradeAggregationTests.cs @@ -157,4 +157,43 @@ public class ParticipationGradeAggregationTests .Where(g => g.Category == GradeCategory.Participation).ToList(); Assert.Single(annaGrades); } + + /// 3.1.3: ein Punkte-Aspekt darf nicht als roher Zahlenwert in die Mittelwertbildung + /// einfließen (z.B. "4 Punkte" wäre auf der -2..+2-Achse ein Extremwert weit über +2) — + /// er muss vorher auf dieselbe Achse normiert werden wie Scale5/Scale3/Binary. + [Fact] + public void Recompute_PunkteAspekt_WirdVorDerMittelungAufMinus2Bis2AchseNormiert() + { + var groupId = Guid.NewGuid(); + var anna = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var students = new FakeStudents([anna]); + var memberships = new FakeMemberships([]); + + var aspects = new FakeAspects(); + aspects.Save(new ParticipationAspect + { + GroupId = groupId, Key = "raised_hand", Label = "Meldungen", + ValueType = AspectValueType.Points, MaxPoints = 4, + }); + + var session = new ParticipationSession { GroupId = groupId, Date = new DateOnly(2025, 9, 1) }; + var sessions = new FakeSessions([session]); + + var entries = new FakeEntries(); + // 2 von 4 Punkten -> Mitte der Skala, normiert genau auf 0. + entries.Add(new ParticipationEntry + { + SessionId = session.Id, StudentId = anna.Id, + Ratings = [new() { Key = "raised_hand", Value = 2 }], + }); + + var grades = new FakeGrades(); + var grading = new GradingService(); + var vm = new ParticipationGradeDialogViewModel(sessions, entries, aspects, students, memberships, + grades, grading, groupId, "2025/26", GradingSystem.Grades1To6); + + var row = Assert.Single(vm.Rows); + Assert.Equal(0.0, double.Parse(row.AverageDisplay.Replace(',', '.'), + System.Globalization.CultureInfo.InvariantCulture), precision: 2); + } } diff --git a/LehrerApp.Desktop.Tests/RatingCellTests.cs b/LehrerApp.Desktop.Tests/RatingCellTests.cs new file mode 100644 index 0000000..f7d2f4a --- /dev/null +++ b/LehrerApp.Desktop.Tests/RatingCellTests.cs @@ -0,0 +1,81 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für 3.1.3: RatingCell muss je AspectValueType unterschiedliche gültige Rohwerte und +/// Schrittweiten für Hoch/Runter (Raster) durchsetzen, nicht mehr pauschal -2..+2 wie zuvor. +public sealed class RatingCellTests +{ + [Fact] + public void CycleUp_Scale3_SpringtZwischenDenDreiStufen() + { + var cell = new RatingCell(Guid.NewGuid(), "k", null, AspectValueType.Scale3); + + cell.CycleUpCommand.Execute(null); + Assert.Equal(-2, cell.Value); + + cell.CycleUpCommand.Execute(null); + Assert.Equal(0, cell.Value); + + cell.CycleUpCommand.Execute(null); + Assert.Equal(2, cell.Value); + + cell.CycleUpCommand.Execute(null); // bleibt am oberen Ende + Assert.Equal(2, cell.Value); + } + + [Fact] + public void CycleUp_Binary_ToggeltNurZwischenDenBeidenExtremwerten() + { + var cell = new RatingCell(Guid.NewGuid(), "k", null, AspectValueType.Binary); + + cell.CycleUpCommand.Execute(null); + Assert.Equal(-2, cell.Value); + + cell.CycleUpCommand.Execute(null); + Assert.Equal(2, cell.Value); + } + + [Fact] + public void SetValue_Points_KapptAufMaxPoints() + { + var cell = new RatingCell(Guid.NewGuid(), "k", null, AspectValueType.Points, maxPoints: 4); + + cell.SetValue(99); + + Assert.Equal(4, cell.Value); + } + + [Fact] + public void CycleUp_Points_ZaehltEinzelnHochBisMaxPoints() + { + var cell = new RatingCell(Guid.NewGuid(), "k", 3, AspectValueType.Points, maxPoints: 4); + + cell.CycleUpCommand.Execute(null); + Assert.Equal(4, cell.Value); + + cell.CycleUpCommand.Execute(null); // bleibt am Maximum + Assert.Equal(4, cell.Value); + } + + [Fact] + public void CycleDown_Points_ZaehltEinzelnRunterBisNull() + { + var cell = new RatingCell(Guid.NewGuid(), "k", 1, AspectValueType.Points, maxPoints: 4); + + cell.CycleDownCommand.Execute(null); + Assert.Equal(0, cell.Value); + + cell.CycleDownCommand.Execute(null); // bleibt bei 0 + Assert.Equal(0, cell.Value); + } + + [Fact] + public void DisplayLabel_Scale5_UnveraendertGegenueberBisherigemVerhalten() + { + var cell = new RatingCell(Guid.NewGuid(), "k", 2); + Assert.Equal("++", cell.DisplayLabel); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs index 8ffb587..77d18ec 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs @@ -35,6 +35,9 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject public ObservableCollection Rows { get; } = []; public ObservableCollection AspectWeights { get; } = []; + // Für die Normierung von Punkte-Aspekten (3.1.3) auf die gemeinsame -2..+2-Achse vor der + // Gewichtung — AspectWeightItem kennt selbst nur Key/Label/Weight, nicht ValueType/MaxPoints. + private readonly Dictionary _aspectsByKey = []; public ParticipationGradeDialogViewModel( IParticipationSessionRepository sessions, IParticipationRepository entries, @@ -63,6 +66,7 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject foreach (var a in all) { + _aspectsByKey[a.Key] = a; var item = new AspectWeightItem(a, _aspects); item.OnChanged = Recompute; AspectWeights.Add(item); @@ -105,7 +109,10 @@ public partial class ParticipationGradeDialogViewModel : ObservableObject { var w = aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0; if (w <= 0) continue; - valueSum += r.Value * w; + var normalized = _aspectsByKey.TryGetValue(r.Key, out var aspect) + ? ParticipationRatingScale.Normalize(aspect.ValueType, r.Value, aspect.MaxPoints) + : r.Value; + valueSum += normalized * w; weightSum += w; } if (weightSum > 0) points.Add((session.Date, valueSum / weightSum)); diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs index 7f31327..385b284 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs @@ -383,7 +383,7 @@ public partial class ParticipationStudentRow : ObservableObject foreach (var a in aspects) { var existing = entry.Ratings.FirstOrDefault(r => r.Key == a.Key); - var cell = new RatingCell(id, a.Key, existing?.Value); + var cell = new RatingCell(id, a.Key, existing?.Value, a.ValueType, a.MaxPoints); cell.OnChanged = (sid, key, val) => OnRatingChanged?.Invoke(sid, key, val); Cells.Add(cell); } @@ -564,53 +564,64 @@ public partial class RatingCell : ObservableObject { public Guid StudentId { get; } public string AspectKey { get; } + public AspectValueType Type { get; } + public int MaxPoints { get; } [ObservableProperty] private int? _value; [ObservableProperty] private string _displayLabel = ""; public Action? OnChanged { get; set; } - public RatingCell(Guid studentId, string key, int? value) + public RatingCell(Guid studentId, string key, int? value, + AspectValueType type = AspectValueType.Scale5, int maxPoints = 5) { StudentId = studentId; AspectKey = key; - _value = value; + Type = type; + MaxPoints = maxPoints; + _value = type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, maxPoints) : value; UpdateLabel(); } public void SetValue(int? value) { - Value = value; + Value = Type == AspectValueType.Points && value is { } v ? Math.Clamp(v, 0, MaxPoints) : value; UpdateLabel(); - OnChanged?.Invoke(StudentId, AspectKey, value); + OnChanged?.Invoke(StudentId, AspectKey, Value); } [RelayCommand] private void CycleUp() { - var next = Value is null ? -2 : Math.Min(2, Value.Value + 1); - SetValue(next); + if (Type == AspectValueType.Points) + { + SetValue(Value is null ? 0 : Math.Min(MaxPoints, Value.Value + 1)); + return; + } + var steps = ParticipationRatingScale.Steps(Type).Select(s => s.Value).ToList(); + if (steps.Count == 0) return; + var idx = Value is null ? -1 : steps.IndexOf(Value.Value); + SetValue(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]); } [RelayCommand] private void CycleDown() { - var next = Value is null ? 2 : Math.Max(-2, Value.Value - 1); - SetValue(next); + if (Type == AspectValueType.Points) + { + SetValue(Value is null ? MaxPoints : Math.Max(0, Value.Value - 1)); + return; + } + var steps = ParticipationRatingScale.Steps(Type).Select(s => s.Value).ToList(); + if (steps.Count == 0) return; + var idx = Value is null ? steps.Count : steps.IndexOf(Value.Value); + SetValue(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]); } [RelayCommand] private void Clear() => SetValue(null); - private void UpdateLabel() => DisplayLabel = Value switch - { - 2 => "++", - 1 => "+", - 0 => "~", - -1 => "−", - -2 => "−−", - _ => "", - }; + private void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value); } // ── Kompetenz-Tags ──────────────────────────────────────────────────────────── @@ -650,7 +661,12 @@ public class AspectColumnDef { public string Key { get; } public string Label { get; } - public AspectColumnDef(ParticipationAspect a) { Key = a.Key; Label = a.Label; } + public AspectValueType ValueType { get; } + public int MaxPoints { get; } + public AspectColumnDef(ParticipationAspect a) + { + Key = a.Key; Label = a.Label; ValueType = a.ValueType; MaxPoints = a.MaxPoints; + } } public class ParticipationSessionItem @@ -712,6 +728,26 @@ public partial class QuickInputViewModel : ObservableObject public ObservableCollection AspectRows { get; } = []; + /// Wechselt je nach Typ des aktuell gewählten Aspekts (3.1.3) — Scale3/Binary haben andere + /// gültige Zifferntasten als Scale5, Points nutzt 0-9 als direkte Punkteingabe statt fester + /// Stufen. Ändert sich mit AspectIndex, siehe OnAspectIndexChanged. + public string HotkeyLegend + { + get + { + var ratingHint = CurrentAspectType() switch + { + AspectValueType.Scale5 => "1–5 bewerten", + AspectValueType.Scale3 => "1–3 bewerten", + AspectValueType.Binary => "1 Nein / 2 Ja", + AspectValueType.Points => $"0–9 Punkte eingeben (bis {CurrentAspectMaxPoints()})", + _ => "1–5 bewerten", + }; + return $"{ratingHint} · Q/W/E/R/T Aspekt wählen · Leertaste nächster Aspekt · " + + "Enter nächster Schüler · Backspace vorheriger · +/− anpassen · Esc schließen"; + } + } + public QuickInputViewModel(List rows, List aspects) { _rows = rows; @@ -719,6 +755,13 @@ public partial class QuickInputViewModel : ObservableObject if (rows.Any()) ShowStudent(0); } + partial void OnAspectIndexChanged(int value) => OnPropertyChanged(nameof(HotkeyLegend)); + + private AspectValueType CurrentAspectType() => + _aspects.Count == 0 ? AspectValueType.Scale5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].ValueType; + private int CurrentAspectMaxPoints() => + _aspects.Count == 0 ? 5 : _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].MaxPoints; + private void ShowStudent(int index) { if (index < 0 || index >= _rows.Count) return; @@ -731,7 +774,7 @@ public partial class QuickInputViewModel : ObservableObject foreach (var (a, i) in _aspects.Select((a, i) => (a, i))) { var val = row.GetRating(a.Key); - AspectRows.Add(new QuickAspectRow(i, a.Label, val, i == AspectIndex)); + AspectRows.Add(new QuickAspectRow(i, a.Label, val, i == AspectIndex, a.ValueType)); } UpdateCurrentAspect(); } @@ -748,26 +791,48 @@ public partial class QuickInputViewModel : ObservableObject public void SetRatingByNumber(int num) { - // 1=−−, 2=−, 3=~, 4=+, 5=++ - var val = num switch { 1 => -2, 2 => -1, 3 => 0, 4 => 1, 5 => 2, _ => (int?)null }; - if (val is null) return; - ApplyRating(val.Value); + var type = CurrentAspectType(); + if (type == AspectValueType.Points) + { + ApplyRating(Math.Clamp(num, 0, CurrentAspectMaxPoints())); + return; + } + var steps = ParticipationRatingScale.Steps(type); + if (num < 1 || num > steps.Count) return; + ApplyRating(steps[num - 1].Value); } public void IncrementRating() { var cell = GetCurrentCell(); if (cell is null) return; - var next = cell.Value is null ? -2 : Math.Min(2, cell.Value.Value + 1); - ApplyRating(next); + var type = CurrentAspectType(); + if (type == AspectValueType.Points) + { + var max = CurrentAspectMaxPoints(); + ApplyRating(cell.Value is null ? 0 : Math.Min(max, cell.Value.Value + 1)); + return; + } + var steps = ParticipationRatingScale.Steps(type).Select(s => s.Value).ToList(); + if (steps.Count == 0) return; + var idx = cell.Value is null ? -1 : steps.IndexOf(cell.Value.Value); + ApplyRating(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]); } public void DecrementRating() { var cell = GetCurrentCell(); if (cell is null) return; - var next = cell.Value is null ? 2 : Math.Max(-2, cell.Value.Value - 1); - ApplyRating(next); + var type = CurrentAspectType(); + if (type == AspectValueType.Points) + { + ApplyRating(cell.Value is null ? CurrentAspectMaxPoints() : Math.Max(0, cell.Value.Value - 1)); + return; + } + var steps = ParticipationRatingScale.Steps(type).Select(s => s.Value).ToList(); + if (steps.Count == 0) return; + var idx = cell.Value is null ? steps.Count : steps.IndexOf(cell.Value.Value); + ApplyRating(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]); } private void ApplyRating(int val) @@ -781,7 +846,7 @@ public partial class QuickInputViewModel : ObservableObject row.Value = val; row.UpdateLabel(); } - CurrentValueLabel = RatingLabel(val); + CurrentValueLabel = ParticipationRatingScale.DisplayLabel(CurrentAspectType(), val); } public void SelectAspect(int index) @@ -819,34 +884,28 @@ public partial class QuickInputViewModel : ObservableObject var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key; return _rows[StudentIndex].Cells.FirstOrDefault(c => c.AspectKey == key); } - - private static string RatingLabel(int? v) => v switch - { - 2 => "++", 1 => "+", 0 => "~", -1 => "−", -2 => "−−", _ => "", - }; } public partial class QuickAspectRow : ObservableObject { public int Index { get; } public string Label { get; } + public AspectValueType Type { get; } [ObservableProperty] private bool _isActive; [ObservableProperty] private string _displayLabel = ""; public int? Value { get; set; } - public QuickAspectRow(int index, string label, int? value, bool isActive) + public QuickAspectRow(int index, string label, int? value, bool isActive, AspectValueType type = AspectValueType.Scale5) { Index = index; Label = label; Value = value; IsActive = isActive; + Type = type; UpdateLabel(); } - public void UpdateLabel() => DisplayLabel = Value switch - { - 2 => "++", 1 => "+", 0 => "~", -1 => "−", -2 => "−−", _ => "·", - }; + public void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value); } // ── Schnelleingabe Anwesenheit / Hausaufgaben ──────────────────────────────── @@ -935,11 +994,15 @@ public partial class AspectEditItem : ObservableObject [ObservableProperty] private string _label; [ObservableProperty] private string _valueTypeName; + [ObservableProperty] private int _maxPoints; [ObservableProperty] private double _weight; [ObservableProperty] private bool _isActive; [ObservableProperty] private string _errorMessage = ""; public string[] ValueTypeOptions => AspectValueTypeDisplay.Options; + // Nur bei ValueType "Punkte" relevant (3.1.3) — MaxPoints-Feld nur dann in der Verwaltung + // anzeigen, siehe ParticipationAspectsDialog.axaml. + public bool IsPointsType => AspectValueTypeDisplay.FromName(ValueTypeName) == AspectValueType.Points; public Action? OnDelete { get; set; } public IRelayCommand DeleteCommand { get; } public IRelayCommand MoveUpCommand { get; } @@ -951,6 +1014,7 @@ public partial class AspectEditItem : ObservableObject _aspect = aspect; _repo = repo; _label = aspect.Label; _valueTypeName = AspectValueTypeDisplay.ToName(aspect.ValueType); + _maxPoints = aspect.MaxPoints; _weight = aspect.Weight; _isActive = aspect.IsActive; @@ -979,7 +1043,7 @@ public partial class AspectEditItem : ObservableObject private void TrySave(Action apply) { ErrorMessage = ""; - var before = (_aspect.Label, _aspect.ValueType, _aspect.Weight, _aspect.IsActive); + var before = (_aspect.Label, _aspect.ValueType, _aspect.MaxPoints, _aspect.Weight, _aspect.IsActive); apply(); try { _repo.Save(_aspect); } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) @@ -988,18 +1052,23 @@ public partial class AspectEditItem : ObservableObject // die Backing-Felder direkt gesetzt statt der generierten Properties — über die // Properties würde erneut On{X}Changed feuern und damit wieder TrySave aufrufen. #pragma warning disable MVVMTK0034 - (_aspect.Label, _aspect.ValueType, _aspect.Weight, _aspect.IsActive) = before; + (_aspect.Label, _aspect.ValueType, _aspect.MaxPoints, _aspect.Weight, _aspect.IsActive) = before; _label = _aspect.Label; _valueTypeName = AspectValueTypeDisplay.ToName(_aspect.ValueType); - _weight = _aspect.Weight; _isActive = _aspect.IsActive; + _maxPoints = _aspect.MaxPoints; _weight = _aspect.Weight; _isActive = _aspect.IsActive; #pragma warning restore MVVMTK0034 OnPropertyChanged(nameof(Label)); OnPropertyChanged(nameof(ValueTypeName)); - OnPropertyChanged(nameof(Weight)); OnPropertyChanged(nameof(IsActive)); + OnPropertyChanged(nameof(MaxPoints)); OnPropertyChanged(nameof(Weight)); OnPropertyChanged(nameof(IsActive)); ErrorMessage = ex.Message; } } partial void OnLabelChanged(string value) => TrySave(() => _aspect.Label = value); - partial void OnValueTypeNameChanged(string value) => TrySave(() => _aspect.ValueType = AspectValueTypeDisplay.FromName(value)); + partial void OnValueTypeNameChanged(string value) + { + TrySave(() => _aspect.ValueType = AspectValueTypeDisplay.FromName(value)); + OnPropertyChanged(nameof(IsPointsType)); + } + partial void OnMaxPointsChanged(int value) => TrySave(() => _aspect.MaxPoints = value); partial void OnWeightChanged(double value) => TrySave(() => _aspect.Weight = value); partial void OnIsActiveChanged(bool value) => TrySave(() => _aspect.IsActive = value); } @@ -1017,9 +1086,11 @@ public partial class ParticipationAspectsDialogViewModel : ObservableObject [ObservableProperty] private string _newKey = ""; [ObservableProperty] private string _newLabel = ""; [ObservableProperty] private string _newValueTypeName = AspectValueTypeDisplay.Options[0]; + [ObservableProperty] private int _newMaxPoints = 5; [ObservableProperty] private string _newAspectError = ""; public string[] ValueTypeOptions => AspectValueTypeDisplay.Options; + public bool IsNewPointsType => AspectValueTypeDisplay.FromName(NewValueTypeName) == AspectValueType.Points; public ObservableCollection Aspects { get; } = []; public Func>? OnConfirmDelete { get; set; } @@ -1029,6 +1100,8 @@ public partial class ParticipationAspectsDialogViewModel : ObservableObject Load(); } + partial void OnNewValueTypeNameChanged(string value) => OnPropertyChanged(nameof(IsNewPointsType)); + private void Load() { Aspects.Clear(); @@ -1069,6 +1142,7 @@ public partial class ParticipationAspectsDialogViewModel : ObservableObject Key = NewKey.Trim(), Label = NewLabel.Trim(), ValueType = AspectValueTypeDisplay.FromName(NewValueTypeName), + MaxPoints = NewMaxPoints, SortOrder = Aspects.Count, }; try { _repo.Save(aspect); } @@ -1080,7 +1154,7 @@ public partial class ParticipationAspectsDialogViewModel : ObservableObject Aspects.Add(CreateItem(aspect)); RefreshMoveState(); - NewKey = ""; NewLabel = ""; NewValueTypeName = AspectValueTypeDisplay.Options[0]; + NewKey = ""; NewLabel = ""; NewValueTypeName = AspectValueTypeDisplay.Options[0]; NewMaxPoints = 5; } private async void DeleteAspectAsync(AspectEditItem item) diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs index fa60c21..0b5e950 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs @@ -30,6 +30,9 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject 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; public string GroupLabel { get; } @@ -81,10 +84,10 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject GroupLabel = groupLabel; _membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId); - _aspectWeights = aspects.GetDefaults() - .Concat(aspects.GetByGroup(groupId)) - .GroupBy(a => a.Key) - .ToDictionary(g => g.Key, g => g.Last().Weight); + 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); var schoolYearRange = GroupMembershipService.SchoolYearPeriod(schoolYear, SchoolYearPeriodKind.FullYear); _students = students.GetByGroup(groupId) .Where(s => !_membershipsByStudent.TryGetValue(s.Id, out var membership) @@ -221,11 +224,15 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject { var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0; if (w <= 0) continue; - valueSum += r.Value * w; weightSum += w; + 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 => "−", _ => "−−", @@ -306,7 +313,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject { var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0; if (w <= 0) continue; - valueSum += r.Value * w; weightSum += w; + valueSum += NormalizedValue(r) * w; weightSum += w; } if (weightSum > 0) points.Add(valueSum / weightSum); } diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationAspectsDialog.axaml b/LehrerApp.Desktop/Views/Groups/ParticipationAspectsDialog.axaml index fccab13..49ba475 100644 --- a/LehrerApp.Desktop/Views/Groups/ParticipationAspectsDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/ParticipationAspectsDialog.axaml @@ -20,23 +20,27 @@ - + - + - -