CI / build-and-test (push) Canceled after 0s
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>
210 lines
10 KiB
C#
210 lines
10 KiB
C#
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);
|
||
}
|
||
}
|