Files
LehrerApp/LehrerApp.Desktop/ViewModels/Groups/ParticipationTimelineBuilder.cs
T
adminandClaude Opus 5.5 67c5a906d1
CI / build-and-test (push) Canceled after 0s
Zeugnisnoten-Assistent mit Mitarbeitsverlauf und Prüfungsnoten Klasse 10
Neuer Assistent (Einstieg im Zeugnisnoten-Dialog) geht die Lerngruppe
Schüler für Schüler durch: berechnete Note mit ungerundetem Wert und
Teilbereichen, Mitarbeitsgraph abschnittsweise oder als Verlauf, Arbeiten
und Einzelnoten. Das 1. Halbjahr erscheint im 2. Halbjahr nur als Kontext.

Die Endjahresnote wird jetzt in Dialog und Assistent aus den
Halbjahresnoten verrechnet (gemeinsamer ReportGradeCalculator). In Klasse 10
kommen Projektprüfung, schriftliche/mündliche Abschlussprüfung und
zusätzliche mündliche Prüfung hinzu, gewichtet nach Oberschulverordnung und
PrüfV Sek. I; gerundet wird dabei bewusst nicht automatisch.

Die Zeitleisten-Erzeugung des Mitarbeits-Assistenten liegt dafür im
gemeinsamen ParticipationTimelineBuilder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:09:33 +02:00

210 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
/// <summary>
/// Baut die Zeitleisten-Punkte eines Schülers (Mitarbeit-Sitzungen, Arbeiten, sonstige Noten) für
/// <c>ParticipationTimelineChart</c>. Gemeinsame Grundlage von Mitarbeits-Assistent und
/// Zeugnisnoten-Assistent, damit beide denselben Graphen mit denselben Regeln zeigen.
/// </summary>
public sealed class ParticipationTimelineBuilder
{
/// Kennzeichnet in <see cref="Grade.Note"/> eine Abschnittsnote der Mitarbeit.
public const string SectionNotePrefix = "Abschnitt: ";
private readonly IParticipationRepository _entries;
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly IGradeRepository _grades;
private readonly IReadOnlyDictionary<Guid, GroupMembership> _membershipsByStudent;
private readonly GradingService _grading;
private readonly Guid _groupId;
private readonly GradingSystem _gradingSystem;
private readonly List<ParticipationSession> _sessions;
private readonly Dictionary<string, double> _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<string, ParticipationAspect> _aspectsByKey;
public IReadOnlyList<ParticipationSession> Sessions => _sessions;
public ParticipationTimelineBuilder(
IParticipationSessionRepository sessions, IParticipationRepository entries,
IParticipationAspectRepository aspects, IExamRepository exams, IExamResultRepository results,
IGradeRepository grades, IReadOnlyDictionary<Guid, GroupMembership> membershipsByStudent,
GradingService grading, Guid groupId, GradingSystem gradingSystem)
{
_entries = entries; _exams = exams; _results = results; _grades = grades;
_membershipsByStudent = membershipsByStudent; _grading = grading;
_groupId = groupId; _gradingSystem = gradingSystem;
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);
_sessions = sessions.GetByGroup(groupId).OrderBy(s => s.Date).ToList();
}
public static string BandLabel(ParticipationSection section) =>
$"{section.Label}\n{section.StartDate:dd.MM.}–{section.EndDate:dd.MM.}";
public static string OpenBandLabel(DateOnly openStart) => $"läuft seit {openStart:dd.MM.}";
/// Beginn des laufenden, noch nicht abgeschlossenen Abschnitts.
public DateOnly OpenStart(IReadOnlyCollection<ParticipationSection> sections, DateOnly today) =>
sections.Count > 0 ? sections.Max(s => s.EndDate).AddDays(1)
: (_sessions.Count > 0 ? _sessions.Min(s => s.Date) : today);
public bool IsStudentActiveOn(Guid studentId, DateOnly date) =>
!_membershipsByStudent.TryGetValue(studentId, out var membership)
|| GroupMembershipService.IsActiveOn(membership, date);
/// Alle Punkte des Schülers, chronologisch sortiert.
public List<WizardTimelinePoint> BuildPoints(Guid studentId)
{
var points = new List<WizardTimelinePoint>();
foreach (var session in _sessions)
{
if (!IsStudentActiveOn(studentId, session.Date)) continue;
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
points.Add(BuildSessionPoint(session, entry, studentId));
}
foreach (var exam in _exams.GetByGroup(_groupId).OrderBy(e => e.Date))
{
if (!IsStudentActiveOn(studentId, exam.Date)) continue;
var result = _results.GetByExamAndStudent(exam.Id, studentId);
if (result is null) continue;
points.Add(BuildExamPoint(exam, result));
}
foreach (var grade in _grades.GetByStudentAndGroup(studentId, _groupId)
.Where(g => g.Category != GradeCategory.Participation)
.Where(g => IsStudentActiveOn(studentId, g.Date)))
{
points.Add(BuildOtherGradePoint(grade));
}
return points.OrderBy(p => p.Date).ToList();
}
/// Notenvorschlag aus den bewerteten Sitzungen im Zeitraum; null ohne bewertete Sitzung.
public string? ComputeSuggestion(Guid studentId, DateOnly start, DateOnly end)
{
var points = new List<double>();
foreach (var session in _sessions.Where(s => s.Date >= start && s.Date <= end)
.Where(s => IsStudentActiveOn(studentId, s.Date)))
{
var entry = _entries.GetBySessionAndStudent(session.Id, studentId);
if (entry is null) continue;
var weighted = WeightedRating(entry);
if (weighted is not null) points.Add(weighted.Value);
}
return points.Count == 0 ? null : _grading.ParticipationGrade(points.Average(), _gradingSystem);
}
private WizardTimelinePoint BuildSessionPoint(ParticipationSession session, ParticipationEntry? entry, Guid studentId)
{
var weightedValue = entry is not null ? WeightedRating(entry) : null;
var ratingLabel = weightedValue is not null ? RatingLabel((int)Math.Round(weightedValue.Value, MidpointRounding.AwayFromZero)) : "";
var note = entry?.Note;
var tooltip = $"{session.Date:dd.MM.yyyy}" +
(ratingLabel.Length > 0 ? $" · {ratingLabel}" : "") +
(string.IsNullOrWhiteSpace(note) ? "" : $" · {note}");
var point = new WizardTimelinePoint(session.Date, isExam: false, ratingLabel, examLabel: "",
hasNote: !string.IsNullOrWhiteSpace(note), tooltip: tooltip)
{
Homework = entry is null ? null : HomeworkDisplay.Effective(entry),
AttendanceIcon = AttendanceDisplay.ShortLabel(entry?.Attendance),
AttendanceTooltip = AttendanceDisplay.Label(entry?.Attendance),
AttendanceColor = AttendanceDisplay.Color(entry?.Attendance),
AspectValues = entry?.Ratings.ToDictionary(r => r.Key, r => r.Value)
?? new Dictionary<string, int>(),
WeightedValue = weightedValue,
OverallGrade = weightedValue is null ? "" : _grading.ParticipationGrade(weightedValue.Value, _gradingSystem),
};
point.ToggleHomeworkCommand = new RelayCommand(() => ToggleHomeworkAt(session.Id, studentId, point));
point.CycleAttendanceCommand = new RelayCommand(() => CycleAttendanceAt(session.Id, studentId, point));
return point;
}
private static WizardTimelinePoint BuildExamPoint(Exam exam, ExamResult result)
{
var examLabel = result.Absent ? $"{exam.Title}: abw." : $"{exam.Title}: {result.Grade}";
return new WizardTimelinePoint(exam.Date, isExam: true, ratingLabel: "", examLabel: examLabel,
hasNote: false, tooltip: $"{exam.Date:dd.MM.yyyy} · Klausur {examLabel}");
}
private static WizardTimelinePoint BuildOtherGradePoint(Grade grade)
{
var category = grade.Category switch
{
GradeCategory.Oral => "Mündlich",
GradeCategory.Homework => "Hausaufgabe",
GradeCategory.Project => "Projekt",
_ => "Sonstige Leistung",
};
var detail = string.IsNullOrWhiteSpace(grade.Note) ? category : grade.Note.Trim();
var label = $"{detail}: {grade.Value}";
return new WizardTimelinePoint(grade.Date, isExam: true, ratingLabel: "", examLabel: label,
hasNote: false, tooltip: $"{grade.Date:dd.MM.yyyy} · {category} · {label}");
}
private double? WeightedRating(ParticipationEntry entry)
{
if (entry.Ratings.Count == 0) return null;
var weightSum = 0.0; var valueSum = 0.0;
foreach (var r in entry.Ratings)
{
var w = _aspectWeights.TryGetValue(r.Key, out var aw) ? aw : 1.0;
if (w <= 0) continue;
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 => "−", _ => "−−",
};
private void ToggleHomeworkAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.Homework = HomeworkDisplay.Next(HomeworkDisplay.Effective(entry));
entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework);
_entries.Save(entry);
point.Homework = entry.Homework;
}
private void CycleAttendanceAt(Guid sessionId, Guid studentId, WizardTimelinePoint point)
{
var entry = _entries.GetBySessionAndStudent(sessionId, studentId)
?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId };
entry.Attendance = entry.Attendance switch
{
null => AttendanceStatus.Present,
AttendanceStatus.Present => AttendanceStatus.ExcusePending,
AttendanceStatus.ExcusePending => AttendanceStatus.Excused,
AttendanceStatus.Excused => AttendanceStatus.Unexcused,
AttendanceStatus.Unexcused => AttendanceStatus.Truant,
AttendanceStatus.Truant => AttendanceStatus.OtherSchoolEvent,
AttendanceStatus.OtherSchoolEvent => null,
_ => null,
};
_entries.Save(entry);
// Das Setzen jeder ObservableProperty kann unmittelbar ein Neuzeichnen auslösen.
// Deshalb muss die zum aktiven Symbol gehörende Farbe zuerst bereitstehen.
point.AttendanceColor = AttendanceDisplay.Color(entry.Attendance);
point.AttendanceTooltip = AttendanceDisplay.Label(entry.Attendance);
point.AttendanceIcon = AttendanceDisplay.ShortLabel(entry.Attendance);
}
}