diff --git a/LehrerApp.Core/Models/Exam.cs b/LehrerApp.Core/Models/Exam.cs
index b9abe16..69e436b 100644
--- a/LehrerApp.Core/Models/Exam.cs
+++ b/LehrerApp.Core/Models/Exam.cs
@@ -25,6 +25,13 @@ public class Exam
/// Optional verknüpfte ursprüngliche Arbeit derselben Lerngruppe.
public Guid? RepeatOfExamId { get; set; }
public RepeatGradeRule RepeatGradeRule { get; set; } = RepeatGradeRule.Better;
+ /// Art der Leistungsüberprüfung. Nur zählt in die
+ /// schriftliche Note; alle anderen Arten fließen unter "Sonstige" ein.
+ public ExamKind Kind { get; set; } = ExamKind.Klausur;
+ /// Nachschreibarbeit: inhaltlich eigene Arbeit für Schüler, die die verknüpfte
+ /// erste Arbeit () versäumt haben. Anders als eine Wiederholung
+ /// wird sie nur von diesen Schülern geschrieben und ersetzt deren fehlende Note.
+ public bool IsMakeUp { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -68,3 +75,18 @@ public class ExamResult
public enum ExamStatus { Planned, Conducted, Graded, Returned }
public enum ExamVariant { A, B }
public enum RepeatGradeRule { Better, Repeat, Original }
+/// Reihenfolge ist persistiert (LiteDB speichert den Enum-Wert) — nur hinten anhängen.
+public enum ExamKind { Klausur, Test, KontrollierteHausaufgabe, Sonstiges }
+
+public static class ExamKindExtensions
+{
+ public static bool CountsAsWritten(this ExamKind kind) => kind == ExamKind.Klausur;
+
+ public static string Label(this ExamKind kind) => kind switch
+ {
+ ExamKind.Test => "Test",
+ ExamKind.KontrollierteHausaufgabe => "Kontrollierte Hausaufgabe",
+ ExamKind.Sonstiges => "Sonstige Arbeit",
+ _ => "Klausur",
+ };
+}
diff --git a/LehrerApp.Core/Services/ExamScoringService.cs b/LehrerApp.Core/Services/ExamScoringService.cs
index 725a237..9b673d1 100644
--- a/LehrerApp.Core/Services/ExamScoringService.cs
+++ b/LehrerApp.Core/Services/ExamScoringService.cs
@@ -44,33 +44,54 @@ public static class ExamScoringService
ExamVariant variant) => tasks.Select((task, index) =>
AppliesTo(task, variant) && index < points.Count ? points[index] : 0).Sum();
- /// Wählt pro verknüpfter Wiederholung genau eine Note. Andere Arbeiten bleiben erhalten.
+ ///
+ /// Wählt pro Arbeit samt allen verknüpften Folgearbeiten (Wiederholung, Nachschreibarbeit)
+ /// genau eine Note. Andere Arbeiten bleiben erhalten. Fehlt die erste Arbeit (Schüler war
+ /// abwesend), zählt die Nachschreibarbeit allein; hat der Schüler beide geschrieben, zählt bei
+ /// einer Nachschreibarbeit immer die erste — sie ersetzt nur eine fehlende Note.
+ ///
public static List EffectiveGrades(IEnumerable<(Exam Exam, string Grade)> entries,
GradingSystem system)
{
- var available = entries.ToList();
- var byId = available.ToDictionary(x => x.Exam.Id);
- var consumed = new HashSet();
- var result = new List();
- foreach (var entry in available)
+ var chains = new List>();
+ var chainByRoot = new Dictionary>();
+ foreach (var entry in entries)
{
- if (!consumed.Add(entry.Exam.Id)) continue;
- if (entry.Exam.RepeatOfExamId is { } originalId && byId.TryGetValue(originalId, out var original))
- {
- consumed.Add(originalId);
- result.Add(Choose(original.Grade, entry.Grade, entry.Exam.RepeatGradeRule, system));
- }
- else if (available.FirstOrDefault(x => x.Exam.RepeatOfExamId == entry.Exam.Id) is var repeat
- && repeat.Exam is not null)
- {
- consumed.Add(repeat.Exam.Id);
- result.Add(Choose(entry.Grade, repeat.Grade, repeat.Exam.RepeatGradeRule, system));
- }
- else result.Add(entry.Grade);
+ var root = entry.Exam.RepeatOfExamId ?? entry.Exam.Id;
+ if (!chainByRoot.TryGetValue(root, out var chain))
+ chains.Add(chainByRoot[root] = chain = []);
+ chain.Add(entry);
+ }
+
+ var result = new List();
+ foreach (var chain in chains)
+ {
+ // Die erste Arbeit steht vorn, Folgearbeiten chronologisch dahinter.
+ var ordered = chain.OrderBy(x => x.Exam.RepeatOfExamId is null ? 0 : 1)
+ .ThenBy(x => x.Exam.Date).ToList();
+ var current = ordered[0].Grade;
+ foreach (var next in ordered.Skip(1))
+ current = Choose(current, next.Grade,
+ next.Exam.IsMakeUp ? RepeatGradeRule.Original : next.Exam.RepeatGradeRule, system);
+ result.Add(current);
}
return result;
}
+ ///
+ /// Wie , trennt aber nach Art: Klausuren zählen als schriftliche
+ /// Note, Tests u. Ä. fließen unter "Sonstige" ein. Verknüpfte Arbeiten erben die Art der
+ /// ersten Arbeit (der Dialog erzwingt das), landen also immer im selben Topf.
+ ///
+ public static (List Written, List Other) EffectiveGradesByKind(
+ IEnumerable<(Exam Exam, string Grade)> entries, GradingSystem system)
+ {
+ var list = entries.ToList();
+ return (
+ EffectiveGrades(list.Where(x => x.Exam.Kind.CountsAsWritten()), system),
+ EffectiveGrades(list.Where(x => !x.Exam.Kind.CountsAsWritten()), system));
+ }
+
private static string Choose(string original, string repeat, RepeatGradeRule rule, GradingSystem system)
{
if (rule == RepeatGradeRule.Original) return original;
diff --git a/LehrerApp.Core/Services/ExamWeekLoadService.cs b/LehrerApp.Core/Services/ExamWeekLoadService.cs
index 77e0abe..02c2f73 100644
--- a/LehrerApp.Core/Services/ExamWeekLoadService.cs
+++ b/LehrerApp.Core/Services/ExamWeekLoadService.cs
@@ -10,7 +10,8 @@ namespace LehrerApp.Core.Services;
/// fehlt, ist die persönliche Belastung der Lehrkraft: mehr eigene Klausuren in derselben Woche
/// bedeuten unabhängig von der Klasse mehr Erstellungsaufwand vorher und mehr Korrekturaufwand
/// danach. Da eine LiteDB-Datei genau einem Nutzer gehört (siehe CLAUDE.md), sind "alle
-/// Klausuren in der Datenbank" bereits gleichbedeutend mit "meine Klausuren".
+/// Klausuren in der Datenbank" bereits gleichbedeutend mit "meine Klausuren". Tests und andere
+/// kurze Arbeiten () zählen bewusst nicht mit — sie sind kein Klausurblock.
///
public static class ExamWeekLoadService
{
@@ -24,7 +25,8 @@ public static class ExamWeekLoadService
public static int CountInSameWeek(IEnumerable allExams, DateOnly date, Guid? excludeExamId = null)
{
var (year, week) = IsoWeek(date);
- return allExams.Count(e => e.Id != excludeExamId && IsoWeek(e.Date) == (year, week));
+ return allExams.Count(e => e.Id != excludeExamId && e.Kind.CountsAsWritten()
+ && IsoWeek(e.Date) == (year, week));
}
/// Gruppiert die übergebenen Klausuren nach ISO-Kalenderwoche und liefert nur die Wochen mit
@@ -32,6 +34,7 @@ public static class ExamWeekLoadService
/// vorausschauenden Überblick (z.B. Dashboard), nicht für die Einzelprüfung beim Anlegen.
public static List FindOverloadedWeeks(IEnumerable exams) =>
exams
+ .Where(e => e.Kind.CountsAsWritten())
.GroupBy(e => IsoWeek(e.Date))
.Where(g => g.Count() >= WarningThreshold)
.Select(g => new ExamWeekLoad(g.Key.Year, g.Key.Week, WeekStart(g.Key.Year, g.Key.Week), g.Count()))
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs
index 50d5181..a850c0e 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs
@@ -42,6 +42,9 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
public ObservableCollection RepeatComparison { get; } = [];
public bool HasRepeatComparison => RepeatComparison.Count > 0;
public string RepeatComparisonTitle { get; private set; } = "";
+ /// "Wiederholung" bzw. "Nachschreibarbeit" — Beschriftung der Folgearbeit im Vergleich.
+ public string FollowUpLabel { get; private set; } = "Wiederholung";
+ public string FollowUpComparisonHeading => $"Vergleich mit der {FollowUpLabel}";
public ObservableCollection GradingKeyEntries { get; } = [];
public ExamEvaluationDialogViewModel(IExamRepository exams, IExamResultRepository results,
@@ -66,15 +69,17 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
var original = _exam.RepeatOfExamId is { } originalId ? _exams.GetById(originalId) : _exam;
if (original is null) return;
var repeat = _exam.RepeatOfExamId is not null ? _exam
- : _exams.GetByGroup(_exam.GroupId).FirstOrDefault(e => e.RepeatOfExamId == _exam.Id);
+ : _exams.GetByGroup(_exam.GroupId).Where(e => e.RepeatOfExamId == _exam.Id)
+ .OrderBy(e => e.Date).FirstOrDefault();
if (repeat is null) return;
- var rule = repeat.RepeatGradeRule switch
+ FollowUpLabel = repeat.IsMakeUp ? "Nachschreibarbeit" : "Wiederholung";
+ var rule = repeat.IsMakeUp ? "ersetzt fehlende Note" : repeat.RepeatGradeRule switch
{
RepeatGradeRule.Repeat => "Wiederholung zählt",
RepeatGradeRule.Original => "Erste Arbeit zählt",
_ => "Bessere zählt",
};
- RepeatComparisonTitle = $"Erste Arbeit: {original.Title} · Wiederholung: {repeat.Title} · {rule}";
+ RepeatComparisonTitle = $"Erste Arbeit: {original.Title} · {FollowUpLabel}: {repeat.Title} · {rule}";
var first = _results.GetByExam(original.Id).Where(r => !r.Absent && r.Grade is not null)
.ToDictionary(r => r.StudentId);
var second = _results.GetByExam(repeat.Id).Where(r => !r.Absent && r.Grade is not null)
@@ -96,6 +101,7 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
}
OnPropertyChanged(nameof(HasRepeatComparison));
OnPropertyChanged(nameof(RepeatComparisonTitle));
+ OnPropertyChanged(nameof(FollowUpLabel));
}
private void LoadResults()
@@ -271,7 +277,7 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
if (HasRepeatComparison)
{
csv.AddBlankRow().AddRow("Vergleich", RepeatComparisonTitle)
- .AddRow("Schüler", "Erste Arbeit", "Wiederholung", "Zählt");
+ .AddRow("Schüler", "Erste Arbeit", FollowUpLabel, "Zählt");
foreach (var item in RepeatComparison)
csv.AddRow(item.StudentName, item.OriginalGrade, item.RepeatGrade, item.EffectiveGrade);
}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
index ecc34e8..474564a 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs
@@ -54,6 +54,14 @@ public partial class ExamGradingDialogViewModel : ObservableObject
var membershipList = memberships.GetByGroup(groupId);
var existing = results.GetByExam(exam.Id).ToDictionary(r => r.StudentId);
+ // Nachschreibarbeit: nur Schüler, die die erste Arbeit nicht mitgeschrieben haben. Wer dort
+ // schon eine Note hat, taucht nicht auf — außer es gibt hier bereits ein Ergebnis (nichts
+ // verstecken, was schon eingetragen wurde).
+ var wroteOriginal = new HashSet();
+ if (exam.IsMakeUp && exam.RepeatOfExamId is { } originalId)
+ foreach (var r in results.GetByExam(originalId).Where(r => !r.Absent && r.Grade is not null))
+ wroteOriginal.Add(r.StudentId);
+
foreach (var s in enrolled.OrderBy(s => s.LastName).ThenBy(s => s.FirstName))
{
var membership = membershipList.FirstOrDefault(e => e.StudentId == s.Id);
@@ -64,6 +72,7 @@ public partial class ExamGradingDialogViewModel : ObservableObject
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
existing.TryGetValue(s.Id, out var result);
+ if (result is null && wroteOriginal.Contains(s.Id)) continue;
var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey,
ExamScoringService.Maximum(Tasks, ExamVariant.A), _grading);
row.OnChanged = SaveRow;
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs
index e85e7a2..e18694b 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs
@@ -46,11 +46,21 @@ public partial class ExamDialogViewModel : ObservableObject
[ObservableProperty] private string _repeatRuleName = "Bessere zählt";
[ObservableProperty] private string _repeatError = "";
[ObservableProperty] private string _taskPointsError = "";
+ [ObservableProperty] private string _kindName = ExamKind.Klausur.Label();
+ [ObservableProperty] private bool _isMakeUp;
public bool IsDifferentiated { get; }
public string[] NiveauOptions => NiveauDisplay.Options;
+ public string[] KindOptions { get; } = Enum.GetValues().Select(k => k.Label()).ToArray();
public string[] RepeatRuleOptions => ["Bessere zählt", "Wiederholung zählt", "Erste Arbeit zählt"];
public bool HasSelectedOriginalExam => SelectedOriginalExam is not null;
+ /// Art und Zählweise sind bei einer verknüpften Folgearbeit festgelegt: sie erbt die Art der
+ /// ersten Arbeit, eine Nachschreibarbeit ersetzt nur fehlende Noten (keine Regelwahl nötig).
+ public bool CanChooseKind => !HasSelectedOriginalExam;
+ public bool ShowRepeatRule => HasSelectedOriginalExam && !IsMakeUp;
+ public string LinkedExamLabel => IsMakeUp
+ ? "Nachschreibarbeit zu (optional)" : "Wiederholung oder Nachschreibarbeit zu (optional)";
+ private ExamKind Kind => Enum.GetValues().FirstOrDefault(k => k.Label() == KindName);
public ObservableCollection AvailableOriginalExams { get; } = [];
public bool TotalPointsWarning => TotalPoints <= 0;
@@ -63,7 +73,7 @@ public partial class ExamDialogViewModel : ObservableObject
public ObservableCollection AvailableTemplates { get; } = [];
public Exam? Result { get; private set; }
- public string DialogTitle => _editingExam is null ? "Neue Klausur anlegen" : "Klausur bearbeiten";
+ public string DialogTitle => _editingExam is null ? "Neue Arbeit anlegen" : "Arbeit bearbeiten";
public string SaveButtonText => _editingExam is null ? "Anlegen" : "Speichern";
/// Fach kommt von der Lerngruppe, nicht editierbar (jede Gruppe unterrichtet ein Fach).
@@ -87,9 +97,10 @@ public partial class ExamDialogViewModel : ObservableObject
_subjectName = defaultSubjectName;
IsDifferentiated = isDifferentiated;
var groupExams = _exams.GetByGroup(groupId);
+ // Mehrere Folgearbeiten je erster Arbeit sind erlaubt (z. B. Nachschreibtermin und
+ // später eine Wiederholung); Folgearbeiten selbst sind keine Bezugsarbeit.
foreach (var exam in groupExams
.Where(e => e.Id != editingExam?.Id && e.RepeatOfExamId is null)
- .Where(e => !groupExams.Any(r => r.RepeatOfExamId == e.Id && r.Id != editingExam?.Id))
.OrderByDescending(e => e.Date))
AvailableOriginalExams.Add(exam);
@@ -113,9 +124,11 @@ public partial class ExamDialogViewModel : ObservableObject
// Beim Duplizieren bewusst kein Niveau übernehmen — meist wird dupliziert, um
// gerade eine andere Niveau-Variante (E/G/Förder) derselben Klausur anzulegen.
SelectedNiveauName = isDuplicate ? "–" : NiveauDisplay.ToName(source.Niveau);
+ KindName = source.Kind.Label();
if (!isDuplicate)
{
SelectedOriginalExam = AvailableOriginalExams.FirstOrDefault(e => e.Id == source.RepeatOfExamId);
+ IsMakeUp = source.IsMakeUp && SelectedOriginalExam is not null;
RepeatRuleName = source.RepeatGradeRule switch
{
RepeatGradeRule.Repeat => "Wiederholung zählt",
@@ -145,7 +158,23 @@ public partial class ExamDialogViewModel : ObservableObject
{
foreach (var t in Tasks) t.ShowWeight = value;
}
- partial void OnSelectedOriginalExamChanged(Exam? value) => OnPropertyChanged(nameof(HasSelectedOriginalExam));
+ partial void OnSelectedOriginalExamChanged(Exam? value)
+ {
+ if (value is not null) KindName = value.Kind.Label();
+ else IsMakeUp = false;
+ OnPropertyChanged(nameof(HasSelectedOriginalExam));
+ OnPropertyChanged(nameof(CanChooseKind));
+ OnPropertyChanged(nameof(ShowRepeatRule));
+ UpdateWeekLoadHint();
+ }
+
+ partial void OnIsMakeUpChanged(bool value)
+ {
+ OnPropertyChanged(nameof(ShowRepeatRule));
+ OnPropertyChanged(nameof(LinkedExamLabel));
+ }
+
+ partial void OnKindNameChanged(string value) => UpdateWeekLoadHint();
[RelayCommand] private void ClearOriginalExam() => SelectedOriginalExam = null;
@@ -156,7 +185,8 @@ public partial class ExamDialogViewModel : ObservableObject
/// ExamWeekLoadService), hier geht es um die eigene Häufung über alle Kurse hinweg.
private void UpdateWeekLoadHint()
{
- if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
+ if (!Kind.CountsAsWritten()
+ || !DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
{
WeekLoadHint = "";
return;
@@ -354,7 +384,9 @@ public partial class ExamDialogViewModel : ObservableObject
if (gradingKeyError is not null) { GradingKeyValidation = gradingKeyError; valid = false; }
if (SelectedOriginalExam is not null && SelectedOriginalExam.Date >= date)
{
- RepeatError = "Die erste Arbeit muss vor der Wiederholung liegen.";
+ RepeatError = IsMakeUp
+ ? "Die erste Arbeit muss vor der Nachschreibarbeit liegen."
+ : "Die erste Arbeit muss vor der Wiederholung liegen.";
valid = false;
}
if (Tasks.Any(t => t.VariantName != "Beide") &&
@@ -376,6 +408,8 @@ public partial class ExamDialogViewModel : ObservableObject
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
Result.GradingKey = gradingKey;
Result.RepeatOfExamId = SelectedOriginalExam?.Id;
+ Result.Kind = SelectedOriginalExam?.Kind ?? Kind;
+ Result.IsMakeUp = IsMakeUp && SelectedOriginalExam is not null;
Result.RepeatGradeRule = RepeatRuleName switch
{
"Wiederholung zählt" => RepeatGradeRule.Repeat,
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs
index db9a3c5..4a515b7 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs
@@ -192,8 +192,8 @@ public partial class GradeOverviewTabViewModel : ObservableObject
cells.Add(display);
if (result.Grade is not null) examEntries.Add((exam, result.Grade));
}
- numeric.AddRange(ExamScoringService.EffectiveGrades(examEntries, _gradingSystem)
- .Select(grade => (Grade: grade, Weight: 1.0)));
+ var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(examEntries, _gradingSystem);
+ numeric.AddRange(writtenGrades.Concat(testGrades).Select(grade => (Grade: grade, Weight: 1.0)));
foreach (var key in gradeColumnKeys)
{
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs
index d5880e2..62f8af6 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs
@@ -213,7 +213,7 @@ public partial class GroupOverviewViewModel : ObservableObject
.Select(r => (r.StudentId, Exam: e, Grade: r.Grade!)))
.GroupBy(x => x.StudentId)
.SelectMany(g => ExamScoringService.EffectiveGrades(
- g.Select(x => (x.Exam, x.Grade)), system))
+ g.Where(x => x.Exam.Kind.CountsAsWritten()).Select(x => (x.Exam, x.Grade)), system))
.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count);
}
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
index f8a7c41..e94243d 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs
@@ -630,8 +630,15 @@ public class ExamSummary
Id = e.Id; Title = e.Title; Date = e.Date.ToString("dd.MM.yyyy"); Status = e.Status;
ReturnedAtDisplay = e.ReturnedAt?.ToString("dd.MM.yyyy") ?? "";
NiveauLabel = e.Niveau.HasValue ? NiveauDisplay.ToName(e.Niveau) : "alle";
- TypeLabel = e.RepeatOfExamId is not null ? (e.Tasks.Any(t => t.Variant is not null) ? "Wdh. · A/B" : "Wiederholung")
- : e.Tasks.Any(t => t.Variant is not null) ? "A/B" : "Klausur";
+ var hasVariants = e.Tasks.Any(t => t.Variant is not null);
+ var followUp = e.RepeatOfExamId is null ? null
+ : e.IsMakeUp ? (hasVariants ? "Nachschr. · A/B" : "Nachschreiben")
+ : hasVariants ? "Wdh. · A/B" : "Wiederholung";
+ // Folgearbeiten behalten ihre Art im Hintergrund (sie erben sie), die Spalte nennt nur
+ // die Beziehung; bei eigenständigen Arbeiten steht die Art selbst.
+ TypeLabel = followUp ?? (e.Kind == ExamKind.Klausur
+ ? (hasVariants ? "A/B" : "Klausur")
+ : hasVariants ? $"{e.Kind.Label()} · A/B" : e.Kind.Label());
StatusLabel = e.Status switch
{
ExamStatus.Planned => "Geplant",
diff --git a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs
index ab89a15..18707e3 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs
@@ -141,8 +141,8 @@ public partial class ReportGradeDialogViewModel : ObservableObject
if (result.Absent || result.Grade is null) continue;
examEntries.Add((exam, result.Grade));
}
- var examGrades = ExamScoringService.EffectiveGrades(examEntries, _gradingSystem)
- .Select(grade => (Grade: grade, Weight: 1.0)).ToList();
+ var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(examEntries, _gradingSystem);
+ var examGrades = writtenGrades.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
var participationGrades = allGrades
.Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation
@@ -152,6 +152,8 @@ public partial class ReportGradeDialogViewModel : ObservableObject
.Where(g => g.StudentId == student.Id && g.Category != GradeCategory.Participation
&& (membership is null || GroupMembershipService.IsActiveOn(membership, g.Date)))
.Select(g => (g.Value, g.Weight)).ToList();
+ // Tests, kontrollierte Hausaufgaben u. Ä. aus der Klausurfunktion zählen als Sonstige.
+ otherGrades.AddRange(testGrades.Select(grade => (grade, 1.0)));
var calculated = _grading.CalculateReportGrade(examGrades, participationGrades, otherGrades,
scheme, _gradingSystem, RoundingRule);
diff --git a/LehrerApp.Desktop/ViewModels/Groups/StudentPerformanceOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/StudentPerformanceOverviewViewModel.cs
index 0b7a218..1c554a0 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/StudentPerformanceOverviewViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/StudentPerformanceOverviewViewModel.cs
@@ -181,8 +181,8 @@ public partial class StudentPerformanceOverviewViewModel : ObservableObject
noteEquivalent, warnings.Count > 0, string.Join(" · ", warnings)));
if (noteEquivalent.HasValue) previousNoteEquivalent = noteEquivalent;
}
- _examGradesRaw = ExamScoringService.EffectiveGrades(effectiveExamEntries, _gradingSystem)
- .Select(grade => (Grade: grade, Weight: 1.0)).ToList();
+ var (writtenGrades, testGrades) = ExamScoringService.EffectiveGradesByKind(effectiveExamEntries, _gradingSystem);
+ _examGradesRaw = writtenGrades.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
_examsAverage = _examGradesRaw.Count > 0 ? _grading.WeightedAverage(_examGradesRaw) : null;
// ── Mitarbeit & Sonstige ──────────────────────────────────────────
@@ -207,6 +207,9 @@ public partial class StudentPerformanceOverviewViewModel : ObservableObject
_otherGradesRaw.Add((g.Value, g.Weight));
OtherRows.Add(new StudentSimpleGradeRow(g, SaveGrade));
}
+ // Tests u. Ä. aus der Klausurfunktion zählen mit, sind aber hier nicht editierbar
+ // (sie werden in der Punkteeingabe der Arbeit gepflegt).
+ _otherGradesRaw.AddRange(testGrades.Select(grade => (grade, 1.0)));
_otherAverage = _otherGradesRaw.Count > 0 ? _grading.WeightedAverage(_otherGradesRaw) : null;
ExamsAverageDisplay = _examsAverage.HasValue ? FormatGrade(_examsAverage.Value) : null;
diff --git a/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml
index ec23978..01ceb31 100644
--- a/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml
@@ -23,6 +23,13 @@
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
+
+
+
+
+
-
+
+ PlaceholderText="Keine Verknüpfung" HorizontalAlignment="Stretch">
@@ -69,8 +76,11 @@
+
+ IsVisible="{Binding ShowRepeatRule}"/>
diff --git a/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml
index 4387a87..bb0abfd 100644
--- a/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml
+++ b/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml
@@ -112,12 +112,12 @@
-
+
-
+
diff --git a/LehrerApp.Tests/ExamScoringServiceTests.cs b/LehrerApp.Tests/ExamScoringServiceTests.cs
index 3aac91b..d1564f8 100644
--- a/LehrerApp.Tests/ExamScoringServiceTests.cs
+++ b/LehrerApp.Tests/ExamScoringServiceTests.cs
@@ -75,4 +75,67 @@ public class ExamScoringServiceTests
Assert.Equal(["2"], ExamScoringService.EffectiveGrades(
[(original, "2")], GradingSystem.Grades1To6));
}
+
+ [Fact]
+ public void Nachschreibarbeit_ErsetztFehlendeNote()
+ {
+ var original = new Exam();
+ var makeUp = new Exam { RepeatOfExamId = original.Id, IsMakeUp = true };
+
+ // Schüler war bei der ersten Arbeit abwesend: nur die Nachschreibarbeit liegt vor.
+ Assert.Equal(["4"], ExamScoringService.EffectiveGrades([(makeUp, "4")], GradingSystem.Grades1To6));
+ }
+
+ [Fact]
+ public void Nachschreibarbeit_VerdraengtErsteNoteNie()
+ {
+ var original = new Exam();
+ var makeUp = new Exam { RepeatOfExamId = original.Id, IsMakeUp = true };
+
+ // Hat der Schüler beide geschrieben, zählt die erste — auch wenn die zweite besser ist.
+ Assert.Equal(["4"], ExamScoringService.EffectiveGrades(
+ [(original, "4"), (makeUp, "1")], GradingSystem.Grades1To6));
+ }
+
+ [Fact]
+ public void MehrereFolgearbeiten_ZaehlenGenauEinmal()
+ {
+ var original = new Exam { Date = new DateOnly(2026, 9, 1) };
+ var makeUp = new Exam { RepeatOfExamId = original.Id, IsMakeUp = true, Date = new DateOnly(2026, 9, 15) };
+ var repeat = new Exam { RepeatOfExamId = original.Id, Date = new DateOnly(2026, 10, 1) };
+
+ // Original versäumt, Nachschreiben mit 4, Wiederholung mit 3 (Regel "Bessere") → eine Note.
+ Assert.Equal(["3"], ExamScoringService.EffectiveGrades(
+ [(makeUp, "4"), (repeat, "3")], GradingSystem.Grades1To6));
+ // Alle drei geschrieben: Original 3, Nachschreiben ignoriert, Wiederholung 2 → 2.
+ Assert.Equal(["2"], ExamScoringService.EffectiveGrades(
+ [(original, "3"), (makeUp, "1"), (repeat, "2")], GradingSystem.Grades1To6));
+ }
+
+ [Fact]
+ public void EffectiveGradesByKind_TrenntKlausurenVonTests()
+ {
+ var klausur = new Exam();
+ var test = new Exam { Kind = ExamKind.Test };
+ var testMakeUp = new Exam { Kind = ExamKind.Test, RepeatOfExamId = test.Id, IsMakeUp = true };
+
+ var (written, other) = ExamScoringService.EffectiveGradesByKind(
+ [(klausur, "2"), (test, "3"), (testMakeUp, "5")], GradingSystem.Grades1To6);
+
+ Assert.Equal(["2"], written);
+ Assert.Equal(["3"], other);
+ }
+
+ [Fact]
+ public void ExamWeekLoad_ZaehltTestsNichtAlsKlausur()
+ {
+ var monday = new DateOnly(2026, 9, 7);
+ var exams = new[]
+ {
+ new Exam { Date = monday },
+ new Exam { Date = monday.AddDays(1), Kind = ExamKind.Test },
+ };
+
+ Assert.Equal(1, ExamWeekLoadService.CountInSameWeek(exams, monday.AddDays(2)));
+ }
}
diff --git a/TODO.md b/TODO.md
index c0b860d..4113724 100644
--- a/TODO.md
+++ b/TODO.md
@@ -37,6 +37,20 @@ Diese Regeln gelten für jede Aufgabe unten und müssen nicht wiederholt werden.
Im Wochenstundenplan stehen Klausur und letzte Stunde davor als Textbadges
oben in der Kachel; Klausurstunden haben zusätzlich einen gelben Rand.
+- [x] **Nachtrag 2026-10: Nachschreibarbeiten und Arbeitsarten (Test, kontrollierte Hausaufgabe).**
+ (a) `Exam.IsMakeUp` erweitert die Wiederholungs-Verknüpfung (`RepeatOfExamId`): eine
+ Nachschreibarbeit ist eine inhaltlich eigene Arbeit (eigene Aufgaben/Schlüssel) nur für
+ Schüler, die die erste Arbeit versäumt haben. Ihre Note ersetzt die fehlende; haben Schüler
+ beide geschrieben, zählt immer die erste. Die Punkteeingabe blendet Schüler mit vorhandener
+ Note der ersten Arbeit aus. An einer ersten Arbeit können nun mehrere Folgearbeiten hängen
+ (`ExamScoringService.EffectiveGrades` faltet die Kette). (b) `Exam.Kind` (`ExamKind`:
+ Klausur/Test/Kontrollierte Hausaufgabe/Sonstige Arbeit): dieselben Klausurfeatures
+ (Aufgaben, Schlüssel, Punkteeingabe, Auswertung), aber nur `Klausur` zählt in die
+ schriftliche Note; alle anderen Arten laufen als „Sonstige“ in Zeugnisnote und
+ Schülerleistung (`EffectiveGradesByKind`), tauchen in der Notenübersicht auf und zählen
+ nicht in Klausurwochen-Belastung und Vorjahresvergleich. Folgearbeiten erben die Art.
+ Bestehende Klausuren bleiben unverändert (Default `Klausur`).
+
Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits in
[Exam.cs](LehrerApp.Core/Models/Exam.cs), Repositories ebenfalls. Die komplette UI fehlt.