Compare commits
2
Commits
da4b88c93d
...
84c03962ef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84c03962ef | ||
|
|
b7c7d46ee4 |
@@ -26,6 +26,13 @@ public class LearningGroup
|
||||
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
|
||||
/// </summary>
|
||||
public int? WebUntisLessonId { get; set; }
|
||||
/// <summary>
|
||||
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
|
||||
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
|
||||
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
|
||||
/// hochgestufte Gruppen bleiben unverknüpft (kein rückwirkendes Verknüpfen vorgesehen).
|
||||
/// </summary>
|
||||
public Guid? PreviousGroupId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ public sealed class GroupRolloverService(
|
||||
IsActive = true,
|
||||
IsOwnClass = source.IsOwnClass,
|
||||
IsDifferentiated = source.IsDifferentiated,
|
||||
PreviousGroupId = source.Id,
|
||||
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
|
||||
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
|
||||
};
|
||||
|
||||
@@ -18,8 +18,8 @@ public sealed class GroupDetailViewModelTests
|
||||
var grades = new FakeGrades();
|
||||
|
||||
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
|
||||
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students,
|
||||
new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()),
|
||||
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
|
||||
new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
|
||||
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||
students, memberships, groups, new FakeCompetencyDomains()),
|
||||
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
||||
|
||||
@@ -12,10 +12,13 @@ public sealed class GroupOverviewViewModelTests
|
||||
{
|
||||
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
|
||||
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
|
||||
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) =>
|
||||
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]),
|
||||
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
|
||||
FakeResults? results = null, FakeGroups? groups = null) =>
|
||||
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), results ?? new FakeResults(),
|
||||
sessions ?? new FakeSessions([]),
|
||||
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
||||
tasks ?? new FakeWorkTasks(), new AttendanceBalanceService(), new SchoolYearService());
|
||||
tasks ?? new FakeWorkTasks(), groups ?? new FakeGroups([]), new AttendanceBalanceService(),
|
||||
new GradingService(), new SchoolYearService());
|
||||
|
||||
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
|
||||
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
|
||||
@@ -76,6 +79,86 @@ public sealed class GroupOverviewViewModelTests
|
||||
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_OhneVerknuepfteVorgaengergruppe_BleibtVerborgen()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6 }]);
|
||||
var vm = NewVm(groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.False(vm.HasYearComparison);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_VorgaengerOhneKlausurergebnisse_BleibtVerborgen()
|
||||
{
|
||||
var previousId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([
|
||||
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||
]);
|
||||
var vm = NewVm(groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.False(vm.HasYearComparison);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_ZeigtDurchschnitteBeiderJahreAn()
|
||||
{
|
||||
var previousId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([
|
||||
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||
]);
|
||||
|
||||
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
|
||||
var currentExam = new Exam { Id = Guid.NewGuid(), GroupId = groupId };
|
||||
var exams = new FakeExams([previousExam, currentExam]);
|
||||
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "2" });
|
||||
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "4" });
|
||||
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Grade = "1" });
|
||||
// Abwesende und leere Ergebnisse fließen bewusst nicht in den Schnitt ein.
|
||||
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Absent = true, Grade = "6" });
|
||||
|
||||
var vm = NewVm(exams: exams, results: results, groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.HasYearComparison);
|
||||
Assert.Contains("Dieses Jahr: Ø 1,0 (1 Klausuren)", vm.YearComparisonLabel);
|
||||
Assert.Contains("Vorjahr: Ø 3,0 (2 Klausuren)", vm.YearComparisonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_OhneEigeneKlausurenZeigtNurVorjahr()
|
||||
{
|
||||
var previousId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([
|
||||
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||
]);
|
||||
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
|
||||
var exams = new FakeExams([previousExam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "3" });
|
||||
|
||||
var vm = NewVm(exams: exams, results: results, groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.HasYearComparison);
|
||||
Assert.Contains("noch keine Klausur", vm.YearComparisonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
|
||||
{
|
||||
|
||||
@@ -712,6 +712,31 @@ public sealed class TimetableViewModelTests
|
||||
Assert.Equal("1", period2.BadgeText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Doppelstunde_ZeigtLessonThemaInBeidenKacheln_AuchOhnePhasenUndStundenraster()
|
||||
{
|
||||
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var group = new LearningGroup { Name = "10c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 3 });
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 4 });
|
||||
var lesson = new Lesson
|
||||
{
|
||||
GroupId = group.Id, Date = date, LessonNumber = 3, Topic = "Werkstoffprüfung",
|
||||
Phases = [],
|
||||
};
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
|
||||
|
||||
var first = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 3);
|
||||
var second = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 4);
|
||||
Assert.Equal("Werkstoffprüfung", first.Topic);
|
||||
Assert.Equal("Werkstoffprüfung", second.Topic);
|
||||
Assert.Equal(lesson.Id, second.Lesson?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_KeineAnstehendenFerien_KeinBadge()
|
||||
{
|
||||
|
||||
@@ -33,12 +33,15 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
|
||||
private readonly ILessonRepository _lessons;
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository _examResults;
|
||||
private readonly IParticipationSessionRepository _sessions;
|
||||
private readonly IParticipationRepository _entries;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IWorkTaskRepository _tasks;
|
||||
private readonly IGroupRepository _groups;
|
||||
private readonly AttendanceBalanceService _attendanceBalance;
|
||||
private readonly GradingService _grading;
|
||||
private readonly SchoolYearService _schoolYear;
|
||||
|
||||
private Guid _groupId;
|
||||
@@ -48,6 +51,8 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
[ObservableProperty] private string _nextLessonLabel = "";
|
||||
[ObservableProperty] private bool _hasNextExam;
|
||||
[ObservableProperty] private string _nextExamLabel = "";
|
||||
[ObservableProperty] private bool _hasYearComparison;
|
||||
[ObservableProperty] private string _yearComparisonLabel = "";
|
||||
[ObservableProperty] private string _participationHintLabel = "";
|
||||
[ObservableProperty] private bool _participationHintIsStale;
|
||||
[ObservableProperty] private bool _hasOpenHomeworkCheck;
|
||||
@@ -105,13 +110,14 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
}
|
||||
|
||||
public GroupOverviewViewModel(ILessonRepository lessons, IExamRepository exams,
|
||||
IParticipationSessionRepository sessions, IParticipationRepository entries,
|
||||
IStudentRepository students, IDocumentationRepository documentation, IWorkTaskRepository tasks,
|
||||
AttendanceBalanceService attendanceBalance, SchoolYearService schoolYear)
|
||||
IExamResultRepository examResults, IParticipationSessionRepository sessions,
|
||||
IParticipationRepository entries, IStudentRepository students,
|
||||
IDocumentationRepository documentation, IWorkTaskRepository tasks, IGroupRepository groups,
|
||||
AttendanceBalanceService attendanceBalance, GradingService grading, SchoolYearService schoolYear)
|
||||
{
|
||||
_lessons = lessons; _exams = exams; _sessions = sessions;
|
||||
_lessons = lessons; _exams = exams; _examResults = examResults; _sessions = sessions;
|
||||
_entries = entries; _students = students; _documentation = documentation; _tasks = tasks;
|
||||
_attendanceBalance = attendanceBalance; _schoolYear = schoolYear;
|
||||
_groups = groups; _attendanceBalance = attendanceBalance; _grading = grading; _schoolYear = schoolYear;
|
||||
}
|
||||
|
||||
public void Initialize(Guid groupId, string groupName)
|
||||
@@ -126,6 +132,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
LoadNextLesson(today);
|
||||
LoadNextExam(today);
|
||||
LoadYearComparison();
|
||||
LoadParticipationHint(today);
|
||||
LoadOpenHomeworkCheck(today);
|
||||
LoadOpenExcuses(today);
|
||||
@@ -156,6 +163,38 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
NextExamLabel = next is null ? "" : $"{next.Date:dd.MM.yyyy} — {next.Title}";
|
||||
}
|
||||
|
||||
// ── Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) ──────────────────
|
||||
//
|
||||
// Setzt voraus, dass diese Gruppe über GroupRolloverService aus einer Vorgängergruppe
|
||||
// hochgestuft wurde (LearningGroup.PreviousGroupId) — vor Einführung dieses Felds
|
||||
// hochgestufte Gruppen bleiben unverknüpft und zeigen keinen Vergleich. Nur sichtbar, wenn
|
||||
// im Vorjahr tatsächlich Klausurergebnisse vorliegen; ohne die ist kein Vergleich möglich,
|
||||
// auch wenn eine Verknüpfung besteht.
|
||||
|
||||
private void LoadYearComparison()
|
||||
{
|
||||
var group = _groups.GetById(_groupId);
|
||||
var previous = group?.PreviousGroupId is Guid previousId ? ComputeExamAverage(previousId) : null;
|
||||
if (previous is null) { HasYearComparison = false; return; }
|
||||
|
||||
var current = ComputeExamAverage(_groupId);
|
||||
YearComparisonLabel = current is null
|
||||
? $"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren) — dieses Jahr noch keine Klausur."
|
||||
: $"Dieses Jahr: Ø {current.Value.Average:0.0} ({current.Value.Count} Klausuren) · " +
|
||||
$"Vorjahr: Ø {previous.Value.Average:0.0} ({previous.Value.Count} Klausuren)";
|
||||
HasYearComparison = true;
|
||||
}
|
||||
|
||||
private (double Average, int Count)? ComputeExamAverage(Guid groupId)
|
||||
{
|
||||
var grades = _exams.GetByGroup(groupId)
|
||||
.SelectMany(e => _examResults.GetByExam(e.Id))
|
||||
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
|
||||
.Select(r => (Grade: r.Grade!, Weight: 1.0))
|
||||
.ToList();
|
||||
return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count);
|
||||
}
|
||||
|
||||
/// Erinnert nicht an eine Note, sondern schlicht daran, überhaupt wieder eine Sitzung
|
||||
/// anzulegen — genau das vergisst man in Kursen, die man seltener unterrichtet, zuerst.
|
||||
private void LoadParticipationHint(DateOnly today)
|
||||
|
||||
@@ -570,9 +570,10 @@ public partial class TimetableViewModel : ObservableObject
|
||||
p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true;
|
||||
|
||||
/// Eine Doppelstunde wird als eine Lesson an der ersten Periode gespeichert. Für die zweite
|
||||
/// Rasterzelle liefern wir dieselbe Lesson nur dann, wenn der Stundenplan dort unmittelbar
|
||||
/// fortgesetzt wird und der geplante Verlauf länger als die erste Periode ist. Eine exakt an
|
||||
/// der Zielperiode verankerte Lesson hat immer Vorrang.
|
||||
/// Rasterzelle liefern wir dieselbe Lesson, wenn der Stundenplan dort unmittelbar mit derselben
|
||||
/// Lerngruppe fortgesetzt wird. Das darf nicht von bereits eingetragenen Phasendauern abhängen:
|
||||
/// Gerade ein früher Entwurf hat oft erst Datum und Thema, gehört aber trotzdem zum kompletten
|
||||
/// Doppelstundenblock. Eine exakt an der Zielperiode verankerte Lesson hat immer Vorrang.
|
||||
private Lesson? FindLessonForSlot(Guid groupId, DateOnly date, int period)
|
||||
{
|
||||
var lessons = _lessons.GetByGroupAndDate(groupId, date);
|
||||
@@ -584,14 +585,22 @@ public partial class TimetableViewModel : ObservableObject
|
||||
var withoutPeriod = lessons.Where(l => l.LessonNumber is null).ToList();
|
||||
if (withoutPeriod.Count == 1) return withoutPeriod[0];
|
||||
|
||||
var previous = lessons.Where(l => l.LessonNumber is int p && p == period - 1)
|
||||
.OrderByDescending(l => l.UpdatedAt).FirstOrDefault();
|
||||
if (previous?.LessonNumber is not int anchor) return null;
|
||||
var isConsecutiveSlot = _slots.GetByGroup(groupId)
|
||||
.Any(s => s.Weekday == date.DayOfWeek && s.PeriodNumber == period);
|
||||
var firstPeriodMinutes = _periodSchedule.GetDurationMinutes(anchor);
|
||||
return isConsecutiveSlot && firstPeriodMinutes > 0 &&
|
||||
previous.Phases.Sum(p => p.DurationMinutes) > firstPeriodMinutes ? previous : null;
|
||||
var periodsInBlock = _slots.GetByGroup(groupId)
|
||||
.Where(s => s.Weekday == date.DayOfWeek)
|
||||
.Select(s => s.PeriodNumber)
|
||||
.ToHashSet();
|
||||
|
||||
// Vom angefragten Slot innerhalb desselben lückenlosen Blocks rückwärts zur verankerten
|
||||
// Lesson laufen. Damit funktionieren neben Doppelstunden auch seltene Dreifachblöcke.
|
||||
for (var candidatePeriod = period - 1;
|
||||
periodsInBlock.Contains(candidatePeriod) && periodsInBlock.Contains(candidatePeriod + 1);
|
||||
candidatePeriod--)
|
||||
{
|
||||
var blockLesson = lessons.Where(l => l.LessonNumber == candidatePeriod)
|
||||
.OrderByDescending(l => l.UpdatedAt).FirstOrDefault();
|
||||
if (blockLesson is not null) return blockLesson;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>4.5.4: Hat die letzte vor <paramref name="date"/> liegende Lesson dieser Gruppe eine
|
||||
|
||||
@@ -66,6 +66,14 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Klausurschnitt-Vorjahresvergleich (Nutzer-Feedback) -->
|
||||
<Border Classes="card" IsVisible="{Binding HasYearComparison}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="VORJAHRESVERGLEICH" Classes="cardTitle"/>
|
||||
<TextBlock Text="{Binding YearComparisonLabel}" TextWrapping="Wrap" Classes="cardBody"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Mitarbeit -->
|
||||
<Border Classes="card">
|
||||
<StackPanel>
|
||||
|
||||
@@ -40,6 +40,7 @@ public sealed class GroupRolloverServiceTests
|
||||
Assert.True(target.IsDifferentiated);
|
||||
Assert.Null(target.WebUntisLessonId);
|
||||
Assert.False(source.IsActive);
|
||||
Assert.Equal(source.Id, target.PreviousGroupId);
|
||||
|
||||
var copied = Assert.Single(memberships.GetByGroup(target.Id));
|
||||
Assert.NotEqual(oldMembership.Id, copied.Id);
|
||||
|
||||
@@ -2530,6 +2530,19 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
|
||||
die einzige Bereichsnavigation. Das Verwaltungsmenü sitzt direkt an jeder Karte und ist per
|
||||
Automation-Namen zugänglich. `GroupListViewModelTests` prüfen Direktnavigation, kontextuelle
|
||||
Bearbeitung, Suche und Archivierung.
|
||||
- [x] **7.2.7** Schuljahresübergreifende Auswertung, erster Baustein: Klausurschnitt-Vergleich
|
||||
mit der Vorgängergruppe (Nutzer-Feedback). Neues Feld `LearningGroup.PreviousGroupId`, von
|
||||
`GroupRolloverService.RollOver` (7.2.2) automatisch auf die Ausgangsgruppe gesetzt — vor
|
||||
dieser Ergänzung hochgestufte Gruppen bleiben unverknüpft, ein rückwirkendes manuelles
|
||||
Verknüpfen ist bewusst nicht Teil dieses Durchgangs. Neue Karte "Vorjahresvergleich" im
|
||||
Kurs-Tab "Übersicht" (`GroupOverviewViewModel`) zeigt den gepoolten Klausurdurchschnitt
|
||||
(alle nicht-abwesenden `ExamResult`-Einträge über alle Klausuren der Gruppe, via
|
||||
`GradingService.WeightedAverage`) dieses Jahres neben dem der verknüpften Vorgängergruppe;
|
||||
nur sichtbar, wenn im Vorjahr tatsächlich Klausurergebnisse vorliegen. **Bewusst
|
||||
zurückgestellt:** ein Vergleich, welcher Notenschlüssel bei welcher Aufgabenstruktur wie
|
||||
gewirkt hat — `Exam.GradingKey` ist eine reine Momentaufnahme ohne Verweis auf die
|
||||
verwendete `GradingKeyTemplate`, eine saubere Gruppierung dafür bräuchte zunächst diesen
|
||||
Verweis.
|
||||
|
||||
### 7.3 Import
|
||||
|
||||
|
||||
Reference in New Issue
Block a user