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 @@
-
+
-
+
-
-
-
-
-
+
@@ -66,6 +70,11 @@
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationQuickInputDialog.axaml b/LehrerApp.Desktop/Views/Groups/ParticipationQuickInputDialog.axaml
index 8866f2a..07dc733 100644
--- a/LehrerApp.Desktop/Views/Groups/ParticipationQuickInputDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/ParticipationQuickInputDialog.axaml
@@ -49,8 +49,7 @@
-
+
diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationQuickInputDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/ParticipationQuickInputDialog.axaml.cs
index 36e8f57..84a5662 100644
--- a/LehrerApp.Desktop/Views/Groups/ParticipationQuickInputDialog.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/ParticipationQuickInputDialog.axaml.cs
@@ -21,11 +21,17 @@ public partial class ParticipationQuickInputDialog : Window
switch (e.Key)
{
+ case Key.D0 or Key.NumPad0: vm.SetRatingByNumber(0); e.Handled = true; break;
case Key.D1 or Key.NumPad1: vm.SetRatingByNumber(1); e.Handled = true; break;
case Key.D2 or Key.NumPad2: vm.SetRatingByNumber(2); e.Handled = true; break;
case Key.D3 or Key.NumPad3: vm.SetRatingByNumber(3); e.Handled = true; break;
case Key.D4 or Key.NumPad4: vm.SetRatingByNumber(4); e.Handled = true; break;
case Key.D5 or Key.NumPad5: vm.SetRatingByNumber(5); e.Handled = true; break;
+ // 6-9 sind nur für Punkte-Aspekte (3.1.3) sinnvoll, siehe QuickInputViewModel.SetRatingByNumber.
+ case Key.D6 or Key.NumPad6: vm.SetRatingByNumber(6); e.Handled = true; break;
+ case Key.D7 or Key.NumPad7: vm.SetRatingByNumber(7); e.Handled = true; break;
+ case Key.D8 or Key.NumPad8: vm.SetRatingByNumber(8); e.Handled = true; break;
+ case Key.D9 or Key.NumPad9: vm.SetRatingByNumber(9); e.Handled = true; break;
case Key.Q: vm.SelectAspect(0); e.Handled = true; break;
case Key.W: vm.SelectAspect(1); e.Handled = true; break;
diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs
index c1d1719..02fd4c9 100644
--- a/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs
@@ -2,6 +2,7 @@ using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using LehrerApp.Core.Interfaces;
+using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
@@ -103,39 +104,76 @@ public partial class ParticipationTabView : UserControl
: row.Cells.ElementAtOrDefault(cellIndex);
if (cell is null) return new TextBlock();
- var panel = new StackPanel
- {
- Orientation = Avalonia.Layout.Orientation.Horizontal,
- HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
- VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
- Spacing = 2,
- Margin = new Avalonia.Thickness(0, 2),
- };
-
- foreach (var (label, val) in new[] { ("−−", -2), ("−", -1), ("∼", 0), ("+", 1), ("++", 2) })
- {
- var btn = new Button
- {
- Content = label,
- Padding = new Avalonia.Thickness(5, 1),
- FontSize = 11,
- Opacity = cell.Value == val ? 1.0 : 0.3,
- IsEnabled = !isReadOnly,
- };
- var capturedVal = val;
- btn.Click += (_, _) => cell.SetValue(capturedVal);
- cell.PropertyChanged += (_, pe) =>
- {
- if (pe.PropertyName == nameof(RatingCell.Value))
- btn.Opacity = cell.Value == capturedVal ? 1.0 : 0.3;
- };
- panel.Children.Add(btn);
- }
-
- return panel;
+ // Punkte-Aspekte (3.1.3) sind eine freie Zahl 0..MaxPoints statt einer festen
+ // Stufenauswahl — dafür ein NumericUpDown statt der Stufen-Buttons unten.
+ return cell.Type == AspectValueType.Points
+ ? BuildPointsCell(cell, isReadOnly)
+ : BuildStepButtonsCell(cell, isReadOnly);
});
}
+ private static Control BuildStepButtonsCell(RatingCell cell, bool isReadOnly)
+ {
+ var panel = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ Spacing = 2,
+ Margin = new Avalonia.Thickness(0, 2),
+ };
+
+ foreach (var (val, label) in ParticipationRatingScale.Steps(cell.Type))
+ {
+ var btn = new Button
+ {
+ Content = label,
+ Padding = new Avalonia.Thickness(5, 1),
+ FontSize = 11,
+ Opacity = cell.Value == val ? 1.0 : 0.3,
+ IsEnabled = !isReadOnly,
+ };
+ var capturedVal = val;
+ btn.Click += (_, _) => cell.SetValue(capturedVal);
+ cell.PropertyChanged += (_, pe) =>
+ {
+ if (pe.PropertyName == nameof(RatingCell.Value))
+ btn.Opacity = cell.Value == capturedVal ? 1.0 : 0.3;
+ };
+ panel.Children.Add(btn);
+ }
+
+ return panel;
+ }
+
+ private static Control BuildPointsCell(RatingCell cell, bool isReadOnly)
+ {
+ var updown = new NumericUpDown
+ {
+ Minimum = 0,
+ Maximum = cell.MaxPoints,
+ Increment = 1,
+ FormatString = "0",
+ Value = cell.Value,
+ IsEnabled = !isReadOnly,
+ Width = 72,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ };
+ updown.ValueChanged += (_, e) =>
+ {
+ var newVal = e.NewValue.HasValue ? (int?)e.NewValue.Value : null;
+ if (newVal != cell.Value) cell.SetValue(newVal);
+ };
+ cell.PropertyChanged += (_, pe) =>
+ {
+ if (pe.PropertyName != nameof(RatingCell.Value)) return;
+ var current = cell.Value.HasValue ? (decimal?)cell.Value.Value : null;
+ if (updown.Value != current) updown.Value = current;
+ };
+ return updown;
+ }
+
private static IDataTemplate BuildHomeworkCellTemplate(bool isReadOnly)
{
return new FuncDataTemplate((row, _) =>
diff --git a/LehrerApp.Tests/ParticipationRatingScaleTests.cs b/LehrerApp.Tests/ParticipationRatingScaleTests.cs
new file mode 100644
index 0000000..8adaced
--- /dev/null
+++ b/LehrerApp.Tests/ParticipationRatingScaleTests.cs
@@ -0,0 +1,80 @@
+using LehrerApp.Core.Models;
+using Xunit;
+
+namespace LehrerApp.Tests;
+
+/// Tests für 3.1.3 (Aspekt-Typen Scale3/Binary/Points): ParticipationRatingScale definiert die
+/// Rohwerte je Typ und ihre Normierung auf die gemeinsame -2..+2-Achse für die Mitarbeitsnote (3.2).
+public sealed class ParticipationRatingScaleTests
+{
+ [Fact]
+ public void Steps_Scale5_LiefertFuenfStufenAufDerMinus2Bis2Achse()
+ {
+ var steps = ParticipationRatingScale.Steps(AspectValueType.Scale5);
+ Assert.Equal([-2, -1, 0, 1, 2], steps.Select(s => s.Value));
+ }
+
+ [Fact]
+ public void Steps_Scale3_LiefertDreiStufenAufDerselbenAchse()
+ {
+ var steps = ParticipationRatingScale.Steps(AspectValueType.Scale3);
+ Assert.Equal([-2, 0, 2], steps.Select(s => s.Value));
+ }
+
+ [Fact]
+ public void Steps_Binary_LiefertNurDieBeidenExtremwerte()
+ {
+ var steps = ParticipationRatingScale.Steps(AspectValueType.Binary);
+ Assert.Equal([-2, 2], steps.Select(s => s.Value));
+ }
+
+ [Fact]
+ public void Steps_Points_LiefertKeineFestenStufen()
+ {
+ Assert.Empty(ParticipationRatingScale.Steps(AspectValueType.Points));
+ }
+
+ [Fact]
+ public void DisplayLabel_OhneWert_ZeigtPlatzhalter()
+ {
+ Assert.Equal("·", ParticipationRatingScale.DisplayLabel(AspectValueType.Scale5, null));
+ }
+
+ [Fact]
+ public void DisplayLabel_Points_ZeigtRohenZahlenwert()
+ {
+ Assert.Equal("3", ParticipationRatingScale.DisplayLabel(AspectValueType.Points, 3));
+ }
+
+ [Theory]
+ [InlineData(0, 4, -2.0)]
+ [InlineData(2, 4, 0.0)]
+ [InlineData(4, 4, 2.0)]
+ [InlineData(1, 4, -1.0)]
+ public void Normalize_Points_BildetLinearAufDieMinus2Bis2AchseAb(int value, int maxPoints, double expected)
+ {
+ Assert.Equal(expected, ParticipationRatingScale.Normalize(AspectValueType.Points, value, maxPoints));
+ }
+
+ [Fact]
+ public void Normalize_Points_KappteAusserhalbDesBereichsLiegendeWerte()
+ {
+ Assert.Equal(2.0, ParticipationRatingScale.Normalize(AspectValueType.Points, 99, 4));
+ Assert.Equal(-2.0, ParticipationRatingScale.Normalize(AspectValueType.Points, -5, 4));
+ }
+
+ [Fact]
+ public void Normalize_Points_OhneMaxPoints_WertetNeutralStattDurchNullZuTeilen()
+ {
+ Assert.Equal(0.0, ParticipationRatingScale.Normalize(AspectValueType.Points, 3, 0));
+ }
+
+ [Theory]
+ [InlineData(AspectValueType.Scale5)]
+ [InlineData(AspectValueType.Scale3)]
+ [InlineData(AspectValueType.Binary)]
+ public void Normalize_NichtPunkteTypen_LiegenBereitsAufDerAchse(AspectValueType type)
+ {
+ Assert.Equal(1.0, ParticipationRatingScale.Normalize(type, 1, maxPoints: 5));
+ }
+}
diff --git a/TODO.md b/TODO.md
index 4f9b4c3..8d1f4d5 100644
--- a/TODO.md
+++ b/TODO.md
@@ -177,12 +177,35 @@ Siehe [ParticipationViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/Participa
8.1.1, `SortOrder` wird bei jeder Verschiebung sofort und lückenlos gespeichert.
**Drag & Drop nicht umgesetzt:** Hoch/Runter deckt die vollständige Bedienung bereits ab,
analog zur Drag-Entscheidung bei 4.3.3.
-- [ ] **3.1.3** Aspekt-Typen `Scale3`, `Binary`, `Points` in Raster und Dialog vollständig unterstützen
- (bisher primär `Scale5`). **Weiterhin offen:** 3.1.1 macht den Typ zwar anlegbar/speicherbar
- (`AspectValueTypeDisplay`, ComboBox in der Verwaltung), das Bewertungsraster und der
- Schnelleingabe-Dialog rendern und interpretieren Eingaben aber weiterhin nach dem
- Scale5-Schema — ein als `Binary`/`Points`/`Scale3` angelegter Aspekt wird dort noch nicht
- passend dargestellt. Bewusst getrennt von 3.1.1, da das die Bewertungs-UI selbst betrifft.
+- [x] **3.1.3** Aspekt-Typen `Scale3`, `Binary`, `Points` in Raster und Dialog vollständig
+ unterstützen (bisher primär `Scale5`) — Nutzer-Nachtrag, damit das Bewertungsfeature mit
+ selbst angelegten Aspekt-Typen produktiv nutzbar wird.
+ **Umsetzung:** Neue statische Klasse `ParticipationRatingScale` (Core) ist die einzige
+ Quelle für Rohwert/Label-Stufen je Typ und für die Normierung zur Aggregation. Bewusste
+ Design-Entscheidung: `Scale3`/`Binary` bekommen **keine eigene Wertemenge**, sondern liegen
+ direkt auf derselben -2..+2-Achse wie `Scale5` (Scale3: -2/0/+2, Binary: -2/+2 für
+ Nein/Ja) — dadurch bleibt die bestehende Gewichtung/Mittelwertbildung zur Mitarbeitsnote
+ (3.2) unverändert kompatibel, ohne pro Bewertung erst umrechnen zu müssen. Nur `Points` ist
+ grundverschieden (ein echter Zählwert 0..`MaxPoints`, neues Feld auf `ParticipationAspect`)
+ und wird ausschließlich zum Aggregationszeitpunkt linear auf dieselbe Achse normiert
+ (`ParticipationRatingScale.Normalize`, 0 → -2, MaxPoints → +2) — dafür mussten die drei
+ Stellen, die bisher `r.Value * gewicht` direkt aufsummierten
+ (`ParticipationGradeDialogViewModel.Recompute`, `ParticipationWizardViewModels.WeightedRating`
+ und `.ComputeSuggestion`), auf die Normierung umgestellt werden; ohne diese Korrektur hätte
+ ein Punkte-Aspekt die Mitarbeitsnote grob verfälscht (z.B. "4 Punkte" als Rohwert weit
+ außerhalb der -2..+2-Skala).
+ Raster (`ParticipationTabView`): Zellen für `Points`-Aspekte sind jetzt ein
+ `NumericUpDown` (0..MaxPoints) statt der fünf Stufen-Buttons, die anderen Typen zeigen die
+ jeweils passende Buttonzahl (2/3/5) aus `ParticipationRatingScale.Steps`.
+ Schnelleingabe-Dialog: Zifferntasten und +/− sind jetzt typabhängig (Scale3: Tasten 1–3,
+ Binary: 1/2, Points: Tasten 0–9 als direkte Zahleneingabe bis `MaxPoints`, sonst Cycling
+ mit +/−); die Tastenkürzel-Legende im Dialog zeigt dafür jetzt dynamisch die für den
+ aktuell gewählten Aspekt gültigen Tasten statt eines festen Textes.
+ Aspekt-Verwaltung (3.1.1) um ein "Max. Punkte"-Feld ergänzt, nur sichtbar bei Typ "Punkte".
+ **Bewusst nicht angefasst:** die spezialisierte Trendlinien-Visualisierung im
+ Mitarbeits-Assistenten (Kapitel 3.2-Erweiterung) ist weiterhin fest auf die drei
+ Standardaspekte (Qualität/Quantität/Arbeitsphase) zugeschnitten — eigene Trendlinien für
+ beliebige, selbst angelegte Aspekte wäre ein eigener, größerer Umbau dieser Ansicht.
- [x] **3.1.4** Aspekt deaktivieren statt löschen, damit alte Einträge gültig bleiben.
**Umsetzung:** Checkbox "Aktiv" je Zeile in der neuen Verwaltung (3.1.1); die bestehenden
Abfragen (`GetByGroup`/`GetDefaults`, für Bewertungsraster und -aggregation) filterten