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 @@ - + - + - -