From da4b88c93da3f69fb3639c8cd8b6fb172327dbcf Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sun, 30 Aug 2026 02:11:20 +0200 Subject: [PATCH] =?UTF-8?q?Pers=C3=B6nliche=20Klausurlast=20pro=20Woche,?= =?UTF-8?q?=20Mitarbeit-Reset=20und=20Sitzplan-Strichliste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Klausuren: neuer ExamWeekLoadService zählt eigene Klausuren je ISO-Woche über alle Kurse hinweg (Klassen-Kollisionen deckt bereits der externe Klausurplaner ab). Hinweis direkt im ExamDialog beim Datum, zusätzlich neue Dashboard-Karte "Klausurwochen" für die 60-Tage-Vorausschau. - Mitarbeit: Schnelleingabe-Dialog kann eine Bewertung jetzt per Entf auf "nicht bewertet" zurücksetzen (bisher nur im Sitzplatz-Dialog möglich). - Sitzplan: Strichliste "gemeldet"/"gemeldet und drangekommen" direkt auf der Sitzplatz-Kachel (ParticipationEntry.RaisedHandCount/CalledOnCount); schlägt im Sitzplatz-Dialog eine Quantitätsbewertung vor (ein Klick zum Übernehmen, nie automatisch). Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Models/Participation.cs | 32 +++++++ .../Services/DashboardSettingsService.cs | 2 +- .../Services/ExamWeekLoadService.cs | 54 ++++++++++++ .../DashboardViewModelTests.cs | 62 +++++++++++++ .../ExamDialogViewModelTests.cs | 64 ++++++++++++++ .../QuickInputViewModelTests.cs | 20 +++++ .../SeatingPlanViewModelTests.cs | 83 ++++++++++++++++++ .../ViewModels/DashboardViewModel.cs | 38 +++++++- .../ViewModels/Groups/ExamViewModels.cs | 22 +++++ .../Groups/ParticipationViewModels.cs | 11 ++- .../Groups/SeatingPlanViewModels.cs | 56 +++++++++++- .../Views/Dashboard/DashboardView.axaml | 24 ++++++ .../Views/Groups/ExamDialog.axaml | 2 + .../ParticipationQuickInputDialog.axaml.cs | 1 + .../Views/Groups/SeatAssessmentDialog.axaml | 4 + .../Views/Groups/SeatingPlanTabView.axaml | 15 ++++ LehrerApp.Tests/ExamWeekLoadServiceTests.cs | 86 +++++++++++++++++++ .../ParticipationCountSuggestionTests.cs | 32 +++++++ TODO.md | 40 +++++++++ 19 files changed, 640 insertions(+), 8 deletions(-) create mode 100644 LehrerApp.Core/Services/ExamWeekLoadService.cs create mode 100644 LehrerApp.Desktop.Tests/ExamDialogViewModelTests.cs create mode 100644 LehrerApp.Tests/ExamWeekLoadServiceTests.cs create mode 100644 LehrerApp.Tests/ParticipationCountSuggestionTests.cs diff --git a/LehrerApp.Core/Models/Participation.cs b/LehrerApp.Core/Models/Participation.cs index a6d8b27..af32d6a 100644 --- a/LehrerApp.Core/Models/Participation.cs +++ b/LehrerApp.Core/Models/Participation.cs @@ -24,6 +24,14 @@ public class ParticipationEntry // Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder. public bool HomeworkMissing { get; set; } public AttendanceStatus? Attendance { get; set; } + // Strichliste im Sitzplan (Nutzer-Feedback): RaisedHandCount zählt jede beobachtete Meldung, + // CalledOnCount die Teilmenge davon, bei der der Schüler auch drangekommen ist — + // "drangekommen" ist immer auch eine Meldung und erhöht daher immer beide Zähler zugleich, + // nie CalledOnCount allein. Ergibt nebenbei eine Aufrufgerechtigkeits-Quote + // (CalledOnCount / RaisedHandCount) und schlägt die Quantitätsbewertung vor, siehe + // ParticipationCountSuggestion. + public int RaisedHandCount { get; set; } + public int CalledOnCount { get; set; } public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } @@ -160,3 +168,27 @@ public static class ParticipationRatingScale _ => value, }; } + +/// +/// Nutzer-Feedback: die Strichliste im Sitzplan () +/// schlägt direkt eine Quantitätsbewertung vor — wie oft sich jemand meldet, ist ja bereits die +/// Quantität. Nur ein Richtwert, kein fester Automatismus: die Lehrkraft übernimmt den Vorschlag +/// per Klick oder ignoriert ihn. Bewusst nur für (der +/// Standardtyp des "quantity"-Aspekts) — für individuell umkonfigurierte Aspekttypen gäbe es keine +/// sinnvolle, unmissverständliche Abbildung. +/// +public static class ParticipationCountSuggestion +{ + public static int? SuggestQuantity(AspectValueType type, int raisedHandCount) + { + if (type != AspectValueType.Scale5) return null; + return raisedHandCount switch + { + 0 => -2, + 1 => -1, + 2 or 3 => 0, + 4 or 5 => 1, + _ => 2, + }; + } +} diff --git a/LehrerApp.Core/Services/DashboardSettingsService.cs b/LehrerApp.Core/Services/DashboardSettingsService.cs index f11aa93..9366a8f 100644 --- a/LehrerApp.Core/Services/DashboardSettingsService.cs +++ b/LehrerApp.Core/Services/DashboardSettingsService.cs @@ -15,7 +15,7 @@ public sealed class DashboardSettingsService public static readonly string[] DefaultCardOrder = [ "today", "tasks", "calendar", "excuses", "upcoming", - "corrections", "unplanned", "alerts", "attendance", "support", "groups", + "corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload", ]; private readonly string _configPath; diff --git a/LehrerApp.Core/Services/ExamWeekLoadService.cs b/LehrerApp.Core/Services/ExamWeekLoadService.cs new file mode 100644 index 0000000..77e0abe --- /dev/null +++ b/LehrerApp.Core/Services/ExamWeekLoadService.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +/// +/// Zählt eigene Klausuren pro ISO-Kalenderwoche über alle Lerngruppen hinweg. Nutzer-Feedback: +/// die klassenbezogene Kollisionsprüfung (mehrere Klausuren einer einzelnen Klasse in einer +/// Woche) übernimmt bereits der schulische Klausurplaner (externes Klassenbuch) — was dort +/// 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". +/// +public static class ExamWeekLoadService +{ + /// Ab dieser Anzahl eigener Klausuren in derselben Woche gilt die Woche als spürbar belastet. + /// Nutzer-Feedback: zwei Klausuren aus zwei unterrichteten Fächern in derselben Woche sind + /// normal, eine dritte ist die eigentliche Häufung. + public const int WarningThreshold = 3; + + /// Anzahl Klausuren (ohne , z.B. die gerade im Dialog + /// bearbeitete), die in dieselbe ISO-Kalenderwoche wie fallen. + 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)); + } + + /// Gruppiert die übergebenen Klausuren nach ISO-Kalenderwoche und liefert nur die Wochen mit + /// mindestens Klausuren, chronologisch sortiert — für einen + /// vorausschauenden Überblick (z.B. Dashboard), nicht für die Einzelprüfung beim Anlegen. + public static List FindOverloadedWeeks(IEnumerable exams) => + exams + .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())) + .OrderBy(w => w.WeekStart) + .ToList(); + + private static (int Year, int Week) IsoWeek(DateOnly date) + { + var dt = date.ToDateTime(TimeOnly.MinValue); + return (ISOWeek.GetYear(dt), ISOWeek.GetWeekOfYear(dt)); + } + + private static DateOnly WeekStart(int isoYear, int isoWeek) => + DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday)); +} + +public readonly record struct ExamWeekLoad(int IsoYear, int IsoWeek, DateOnly WeekStart, int ExamCount) +{ + public DateOnly WeekEnd => WeekStart.AddDays(6); +} diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index 1fcf3f1..dc6235f 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -1,6 +1,7 @@ using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.ViewModels; +using System.Globalization; using Xunit; namespace LehrerApp.Desktop.Tests; @@ -75,6 +76,67 @@ public sealed class DashboardViewModelTests substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents); } + /// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der + /// Klausurwochen-Vorausschau — unabhängig davon, welcher Wochentag "heute" gerade ist. + private static DateOnly NextIsoWeekMonday() + { + var reference = DateTime.Today.AddDays(7); + return DateOnly.FromDateTime( + ISOWeek.ToDateTime(ISOWeek.GetYear(reference), ISOWeek.GetWeekOfYear(reference), DayOfWeek.Monday)); + } + + [Fact] + public void ExamWeekLoads_DreiGeplanteKlausurenInDerselbenWoche_ErzeugtEintrag() + { + var group = new LearningGroup { Name = "9c" }; + var today = DateOnly.FromDateTime(DateTime.Today); + var monday = NextIsoWeekMonday(); + var exams = new FakeExams([ + new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned }, + new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned }, + new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned }, + ]); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams); + + var entry = Assert.Single(vm.ExamWeekLoads); + Assert.Equal(3, entry.ExamCount); + Assert.True(vm.ExamLoadCard.EffectiveIsVisible); + } + + [Fact] + public void ExamWeekLoads_WenigeKlausurenProWoche_BleibtLeer() + { + var group = new LearningGroup { Name = "9c" }; + var today = DateOnly.FromDateTime(DateTime.Today); + var monday = NextIsoWeekMonday(); + var exams = new FakeExams([ + new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned }, + new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned }, + ]); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams); + + Assert.Empty(vm.ExamWeekLoads); + } + + [Fact] + public void ExamWeekLoads_BereitsDurchgefuehrteKlausurenZaehlenNicht() + { + var group = new LearningGroup { Name = "9c" }; + var today = DateOnly.FromDateTime(DateTime.Today); + var monday = NextIsoWeekMonday(); + var exams = new FakeExams([ + new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Conducted }, + new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Graded }, + new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned }, + ]); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams); + + Assert.Empty(vm.ExamWeekLoads); + } + [Fact] public void TodaysLessons_LoestRaumUeberPassendenStundenplanSlotAuf() { diff --git a/LehrerApp.Desktop.Tests/ExamDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/ExamDialogViewModelTests.cs new file mode 100644 index 0000000..db8f94b --- /dev/null +++ b/LehrerApp.Desktop.Tests/ExamDialogViewModelTests.cs @@ -0,0 +1,64 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class ExamDialogViewModelTests +{ + private static ExamDialogViewModel BuildViewModel(List existingExams, string dateText, + Exam? editingExam = null) + { + var vm = new ExamDialogViewModel(new FakeExams(existingExams), new FakeCompetencyDomains(), + new FakeGradingKeyTemplates(), new GradingService(), Guid.NewGuid(), null, 9, + GradingSystem.Grades1To6, "Chemie", isDifferentiated: false, editingExam, null); + vm.DateText = dateText; + return vm; + } + + private static Exam MakeExam(DateOnly date) => new() { GroupId = Guid.NewGuid(), Title = "x", Date = date }; + + [Fact] + public void WeekLoadHint_WenigeKlausurenInDerWoche_BleibtLeer() + { + var monday = new DateOnly(2026, 3, 16); + var existing = new List { MakeExam(monday) }; + + var vm = BuildViewModel(existing, monday.AddDays(1).ToString("dd.MM.yyyy")); + + Assert.Equal("", vm.WeekLoadHint); + } + + [Fact] + public void WeekLoadHint_DritteKlausurInDerWoche_ZeigtHinweis() + { + var monday = new DateOnly(2026, 3, 16); + var existing = new List { MakeExam(monday), MakeExam(monday.AddDays(1)) }; + + var vm = BuildViewModel(existing, monday.AddDays(2).ToString("dd.MM.yyyy")); + + Assert.Contains("3 Klausuren", vm.WeekLoadHint); + } + + [Fact] + public void WeekLoadHint_BearbeiteteKlausurZaehltNichtDoppelt() + { + var monday = new DateOnly(2026, 3, 16); + var editing = MakeExam(monday); + var existing = new List { editing, MakeExam(monday.AddDays(1)) }; + + // Nur die eigene Klausur (editing) + eine weitere -> 2 insgesamt, kein Hinweis. + var vm = BuildViewModel(existing, monday.ToString("dd.MM.yyyy"), editing); + + Assert.Equal("", vm.WeekLoadHint); + } + + [Fact] + public void WeekLoadHint_UngueltigesDatum_BleibtLeer() + { + var vm = BuildViewModel([], "keindatum"); + + Assert.Equal("", vm.WeekLoadHint); + } +} diff --git a/LehrerApp.Desktop.Tests/QuickInputViewModelTests.cs b/LehrerApp.Desktop.Tests/QuickInputViewModelTests.cs index fb75fdd..fca4c2d 100644 --- a/LehrerApp.Desktop.Tests/QuickInputViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/QuickInputViewModelTests.cs @@ -83,6 +83,26 @@ public sealed class QuickInputViewModelTests Assert.Equal(0.4, vm.CurrentStudentContentOpacity); } + [Fact] + public void ClearRating_SetztVersehentlichGesetzteBewertungAufNichtBewertetZurueck() + { + var aspects = new List + { + new(new ParticipationAspect { Key = "quality", Label = "Qualität" }), + }; + var rows = new List + { + new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []), + }; + var vm = new QuickInputViewModel(rows, aspects); + vm.SetRatingByNumber(4); // aus Versehen eine Note für Qualität vergeben + + vm.ClearRating(); + + Assert.Null(vm.AspectRows[0].Value); + Assert.Null(rows[0].GetRating("quality")); + } + [Fact] public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf() { diff --git a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs index 571e333..46c06f7 100644 --- a/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SeatingPlanViewModelTests.cs @@ -483,6 +483,35 @@ public sealed class SeatingPlanViewModelTests Assert.True(entry.HomeworkMissing); } + [Fact] + public void SitzplatzBewertung_SchlaegtQuantitaetAusStrichlisteVor() + { + var groupId = Guid.NewGuid(); + var studentId = Guid.NewGuid(); + var today = DateOnly.FromDateTime(DateTime.Today); + var session = new ParticipationSession { GroupId = groupId, Date = today }; + var sessions = new FakeSessions([session]); + var entries = new FakeEntries(); + entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = studentId, RaisedHandCount = 3 }); + + var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(), + groupId, studentId, "Beispiel, Anna", canEdit: true); + + var quantityRow = vm.AspectRows.Single(r => r.Key == "quantity"); + Assert.True(quantityRow.HasSuggestion); + Assert.Equal(0, quantityRow.SuggestedValue); // 3 gemeldet -> "∼" laut ParticipationCountSuggestion + Assert.Contains("3× gemeldet", quantityRow.SuggestionLabel); + + var qualityRow = vm.AspectRows.Single(r => r.Key == "quality"); + Assert.False(qualityRow.HasSuggestion); + + quantityRow.ApplySuggestionCommand.Execute(null); + + Assert.Equal(0, quantityRow.Value); + var updatedEntry = entries.GetBySessionAndStudent(session.Id, studentId)!; + Assert.Equal(0, updatedEntry.Ratings.Single(r => r.Key == "quantity").Value); + } + [Fact] public void SitzplatzBewertung_ArchiviertOhneSitzung_LegtKeineNeueSitzungAn() { @@ -517,6 +546,60 @@ public sealed class SeatingPlanViewModelTests Assert.Null(vm.SelectedSession); } + [Fact] + public void TallyRaisedHand_ErhoehtNurGemeldetZaehler() + { + var groupId = Guid.NewGuid(); + var student = new Student { FirstName = "Anna", LastName = "A" }; + var plan = new SeatingPlan + { + GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1, + Assignments = [new SeatAssignment { StudentId = student.Id }], + }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]), + new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]), + sessions, entries, new FakeAspects()); + vm.Initialize(groupId, isReadOnly: false); + var seat = vm.Seats.Single(); + + seat.TallyRaisedHandCommand.Execute(null); + seat.TallyRaisedHandCommand.Execute(null); + + Assert.Equal(2, seat.RaisedHandCount); + Assert.Equal(0, seat.CalledOnCount); + var session = Assert.Single(sessions.GetByGroup(groupId)); + var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!; + Assert.Equal(2, entry.RaisedHandCount); + Assert.Equal(0, entry.CalledOnCount); + } + + [Fact] + public void TallyCalledOn_ErhoehtBeideZaehler() + { + var groupId = Guid.NewGuid(); + var student = new Student { FirstName = "Anna", LastName = "A" }; + var plan = new SeatingPlan + { + GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1, + Assignments = [new SeatAssignment { StudentId = student.Id }], + }; + var sessions = new FakeSessions([]); + var entries = new FakeEntries(); + var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]), + new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]), + sessions, entries, new FakeAspects()); + vm.Initialize(groupId, isReadOnly: false); + var seat = vm.Seats.Single(); + + seat.TallyRaisedHandCommand.Execute(null); // erste Meldung, nicht drangekommen + seat.TallyCalledOnCommand.Execute(null); // zweite Meldung, drangekommen + + Assert.Equal(2, seat.RaisedHandCount); + Assert.Equal(1, seat.CalledOnCount); + } + [Fact] public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn() { diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index b6be559..7924cda 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -50,6 +50,10 @@ public partial class DashboardViewModel : ObservableObject /// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine /// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize. private const int AttendanceMinSampleSize = 8; + /// Vorausschau für die Klausurwochen-Karte (Nutzer-Feedback): weit genug, um eine sich + /// anbahnende Häufung noch rechtzeitig vor dem Anlegen weiterer Klausuren zu zeigen, aber + /// keine Vorschau auf das ganze Schuljahr. + private const int ExamLoadLookaheadDays = 60; [ObservableProperty] private string _greeting = ""; [ObservableProperty] private string _currentDate = ""; @@ -72,6 +76,7 @@ public partial class DashboardViewModel : ObservableObject public ObservableCollection CalendarDays { get; } = []; public ObservableCollection OpenExcuses { get; } = []; public ObservableCollection AttendanceWarnings { get; } = []; + public ObservableCollection ExamWeekLoads { get; } = []; public ObservableCollection SupportPlanReviews { get; } = []; public ObservableCollection UpcomingDates { get; } = []; public ObservableCollection OpenCorrections { get; } = []; @@ -106,6 +111,7 @@ public partial class DashboardViewModel : ObservableObject public DashboardCardOption UnplannedCard => Card("unplanned"); public DashboardCardOption AlertsCard => Card("alerts"); public DashboardCardOption AttendanceCard => Card("attendance"); + public DashboardCardOption ExamLoadCard => Card("examload"); public DashboardCardOption SupportCard => Card("support"); public DashboardCardOption GroupsCard => Card("groups"); public int TodayLessonCount => TodaysLessons.Count; @@ -209,6 +215,7 @@ public partial class DashboardViewModel : ObservableObject LoadCalendar(); LoadOpenExcuses(groups.Values.ToList(), today); LoadAttendanceWarnings(today); + LoadExamWeekLoads(groups, today); LoadSupportPlanReviews(today); LoadUpcomingDates(groups, today); LoadOpenCorrections(groups, today); @@ -228,6 +235,7 @@ public partial class DashboardViewModel : ObservableObject UnplannedCard.IsEmpty = UnplannedLessons.Count == 0; AlertsCard.IsEmpty = Alerts.Count == 0; AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0; + ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0; SupportCard.IsEmpty = SupportPlanReviews.Count == 0; GroupsCard.IsEmpty = CurrentGroups.Count == 0; @@ -322,6 +330,27 @@ public partial class DashboardViewModel : ObservableObject AttendanceWarnings.Add(item); } + // ── Klausurwochen (Nutzer-Feedback) ─────────────────────────────────────── + // + // Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene + // Kollisionsprüfung, die bereits der schulische Klausurplaner übernimmt (siehe + // ExamWeekLoadService). Nur geplante, noch bevorstehende Klausuren zählen: die + // Vorausschau soll helfen, eine sich anbahnende Woche zu erkennen, bevor man eine weitere + // Klausur genau dort einträgt — bereits durchgeführte/korrigierte Klausuren werden schon + // von der Karte "Offene Korrekturen" abgedeckt. + + private void LoadExamWeekLoads(IReadOnlyDictionary groups, DateOnly today) + { + ExamWeekLoads.Clear(); + var lastDay = today.AddDays(ExamLoadLookaheadDays); + var upcoming = groups.Keys + .SelectMany(gid => _exams.GetByGroup(gid)) + .Where(e => e.Status == ExamStatus.Planned && e.Date >= today && e.Date <= lastDay); + + foreach (var week in ExamWeekLoadService.FindOverloadedWeeks(upcoming)) + ExamWeekLoads.Add(new ExamWeekLoadItem(week.IsoWeek, week.WeekStart, week.WeekEnd, week.ExamCount)); + } + // ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────── private void LoadSupportPlanReviews(DateOnly today) @@ -547,7 +576,7 @@ public partial class DashboardViewModel : ObservableObject "excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine", "corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten", "attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage", - "groups" => "Meine Lerngruppen", _ => key, + "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen", _ => key, }; private void ApplyCardLayout() @@ -966,6 +995,13 @@ public class AttendanceWarningItem(Guid studentId, string studentName, double ab public double AbsenceRatePercent { get; } = absenceRatePercent; } +public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd, int examCount) +{ + public int ExamCount { get; } = examCount; + public string Label => $"KW {isoWeek} ({weekStart:dd.MM.}–{weekEnd:dd.MM.})"; + public string CountDisplay => ExamCount == 1 ? "1 Klausur" : $"{ExamCount} Klausuren"; +} + // ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────────── public class SupportPlanDueItem diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs index 9a27cd3..c39716c 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs @@ -32,6 +32,7 @@ public partial class ExamDialogViewModel : ObservableObject [ObservableProperty] private string _titleError = ""; [ObservableProperty] private string _dateTextError = ""; [ObservableProperty] private string _returnedAtTextError = ""; + [ObservableProperty] private string _weekLoadHint = ""; [ObservableProperty] private double _totalPoints; [ObservableProperty] private bool _hasCompetencyCatalog; [ObservableProperty] private bool _useWeighting; @@ -108,6 +109,7 @@ public partial class ExamDialogViewModel : ObservableObject } foreach (var t in Tasks) t.ShowWeight = UseWeighting; RecomputeTotals(); + UpdateWeekLoadHint(); } partial void OnUseWeightingChanged(bool value) @@ -115,6 +117,26 @@ public partial class ExamDialogViewModel : ObservableObject foreach (var t in Tasks) t.ShowWeight = value; } + partial void OnDateTextChanged(string value) => UpdateWeekLoadHint(); + + /// Nutzer-Feedback: persönliche Klausurlast pro Woche — die klassenbezogene + /// Kollisionsprüfung übernimmt bereits der schulische Klausurplaner (siehe + /// 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)) + { + WeekLoadHint = ""; + return; + } + + var existing = ExamWeekLoadService.CountInSameWeek(_exams.GetAll(), date, _editingExam?.Id); + var totalIfSaved = existing + 1; + WeekLoadHint = totalIfSaved >= ExamWeekLoadService.WarningThreshold + ? $"Damit hättest du {totalIfSaved} Klausuren in dieser Woche." + : ""; + } + [RelayCommand] private void AddTask() { diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs index b42f23a..8f01c2c 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs @@ -824,7 +824,7 @@ public partial class QuickInputViewModel : ObservableObject _ => "1–5 bewerten", }; return $"{ratingHint} · Q/W/E/R/T Aspekt wählen · Leertaste/↓ nächster Aspekt · ↑ vorheriger Aspekt · " + - "Enter/→ nächster Schüler · Backspace/← vorheriger Schüler · +/− anpassen · Esc schließen"; + "Enter/→ nächster Schüler · Backspace/← vorheriger Schüler · +/− anpassen · Entf nicht bewertet · Esc schließen"; } } @@ -902,6 +902,13 @@ public partial class QuickInputViewModel : ObservableObject ApplyRating(steps[Math.Clamp(idx + 1, 0, steps.Count - 1)]); } + /// Nutzer-Feedback: eine aus Versehen gesetzte Bewertung (z.B. Qualität, obwohl der Schüler + /// sich nie gemeldet hat) muss sich wieder auf "nicht bewertet" zurücksetzen lassen, ohne + /// einen inhaltlich falschen Wert stehen lassen zu müssen. Bewusst eine eigene Aktion statt + /// über 0/1 zu laufen — 0 ("--") ist bei Quantität ein echter, beobachteter Wert ("hat sich + /// nicht gemeldet"), kein Platzhalter für "nicht ermittelt". + public void ClearRating() => ApplyRating(null); + public void DecrementRating() { var cell = GetCurrentCell(); @@ -918,7 +925,7 @@ public partial class QuickInputViewModel : ObservableObject ApplyRating(steps[Math.Clamp(idx - 1, 0, steps.Count - 1)]); } - private void ApplyRating(int val) + private void ApplyRating(int? val) { if (_rows.Count == 0 || !_aspects.Any()) return; var key = _aspects[Math.Clamp(AspectIndex, 0, _aspects.Count - 1)].Key; diff --git a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs index b76ebc6..7d21db3 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs @@ -227,7 +227,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject ?? StudentSeatOption.Empty; var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column); Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged, - CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden)); + CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation)); } UpdateAssignmentSummary(); RefreshSeatLessonData(); @@ -266,6 +266,22 @@ public partial class SeatingPlanTabViewModel : ObservableObject } } + /// Strichliste im Sitzplan (Nutzer-Feedback): "drangekommen" ist immer auch eine Meldung und + /// erhöht daher beide Zähler — es gibt kein "drangekommen, ohne sich gemeldet zu haben". + private void TallyParticipation(SeatCellViewModel seat, bool calledOn) + { + if (!IsEditable || seat.SelectedOption.StudentId is not Guid studentId) return; + var session = EnsureTodaySession(); + if (session is null) return; + + var entry = _participation.GetBySessionAndStudent(session.Id, studentId) + ?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId }; + entry.RaisedHandCount++; + if (calledOn) entry.CalledOnCount++; + _participation.Save(entry); + RefreshSeatLessonData(); + } + private void ToggleSituationTag(SeatCellViewModel seat, string tag) { if (!IsEditable || _documentation is null || @@ -531,6 +547,8 @@ public partial class SeatCellViewModel : ObservableObject [ObservableProperty] private double _lessonOpacity = 1.0; [ObservableProperty] private string _attendanceBadge = ""; [ObservableProperty] private string _homeworkBadge = ""; + [ObservableProperty] private int _raisedHandCount; + [ObservableProperty] private int _calledOnCount; public ObservableCollection SituationTags { get; } = []; public bool HasAttendanceBadge => AttendanceBadge.Length > 0; public bool HasHomeworkBadge => HomeworkBadge.Length > 0; @@ -555,10 +573,13 @@ public partial class SeatCellViewModel : ObservableObject /// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen. public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity; + private readonly Action _tally; + public SeatCellViewModel(int row, int column, ObservableCollection options, StudentSeatOption selectedOption, Action onChanged, bool canEdit, Action? toggleSituationTag = null, bool canRecordLesson = false, - bool isHidden = false, Action? toggleHidden = null) + bool isHidden = false, Action? toggleHidden = null, + Action? tally = null) { Row = row; Column = column; @@ -570,10 +591,18 @@ public partial class SeatCellViewModel : ObservableObject _canRecordLesson = canRecordLesson; _isHidden = isHidden; _toggleHidden = toggleHidden ?? (_ => { }); + _tally = tally ?? ((_, _) => { }); foreach (var tag in SituationTagChoice.DefaultTags) SituationTags.Add(new SituationTagChoice(tag, false, value => _toggleSituationTag(this, value))); } + /// Strichliste im Sitzplan (Nutzer-Feedback): schnelles Mitzählen während des Unterrichts, + /// ohne dafür den vollen Sitzplatz-Bewertungsdialog öffnen zu müssen — bewusst nur ein + /// Hochzählen ohne Korrekturmöglichkeit hier direkt auf der Kachel; die Rohwerte bleiben über + /// den Bewertungsdialog einsehbar und fließen dort als Quantitäts-Vorschlag ein. + [RelayCommand] private void TallyRaisedHand() => _tally(this, false); + [RelayCommand] private void TallyCalledOn() => _tally(this, true); + partial void OnSelectedOptionChanged(StudentSeatOption value) { OnPropertyChanged(nameof(IsOccupied)); @@ -615,6 +644,8 @@ public partial class SeatCellViewModel : ObservableObject var attendance = entry?.Attendance; AttendanceBadge = attendance is null ? "" : AttendanceDisplay.ShortLabel(attendance); HomeworkBadge = entry is null ? "" : HomeworkDisplay.Symbol(HomeworkDisplay.Effective(entry)); + RaisedHandCount = entry?.RaisedHandCount ?? 0; + CalledOnCount = entry?.CalledOnCount ?? 0; LessonOpacity = attendance is not null and not AttendanceStatus.Present and not AttendanceStatus.Late and not AttendanceStatus.SignificantlyLate ? 0.42 : 1.0; var selected = tags.ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -690,7 +721,10 @@ public partial class SeatAssessmentViewModel : ObservableObject foreach (var (aspect, index) in aspectDefinitions.Select((a, i) => (a, i))) { var value = _entry?.Ratings.FirstOrDefault(r => r.Key == aspect.Key)?.Value; - AspectRows.Add(new SeatAssessmentAspectRow(index, aspect, value, ApplyRating)); + // Quantitäts-Vorschlag aus der Sitzplan-Strichliste (Nutzer-Feedback): nur für den + // eingebauten "quantity"-Aspekt, siehe ParticipationCountSuggestion. + var raisedHandCount = aspect.Key == "quantity" ? _entry?.RaisedHandCount : null; + AspectRows.Add(new SeatAssessmentAspectRow(index, aspect, value, ApplyRating, raisedHandCount)); } BuildAttendanceChoices(); @@ -847,11 +881,22 @@ public partial class SeatAssessmentAspectRow : ObservableObject public string DisplayValue => ParticipationRatingScale.DisplayLabel(ValueType, Value); public ObservableCollection Choices { get; } = []; + /// Quantitäts-Vorschlag aus der Sitzplan-Strichliste (Nutzer-Feedback), siehe + /// ParticipationCountSuggestion — null, wenn keine Strichliste zu diesem Aspekt gehört. + private readonly int? _raisedHandCount; + public int? SuggestedValue { get; } + public bool HasSuggestion => SuggestedValue.HasValue && SuggestedValue != Value; + public string SuggestionLabel => + $"{_raisedHandCount}× gemeldet → Vorschlag: {ParticipationRatingScale.DisplayLabel(ValueType, SuggestedValue)}"; + public SeatAssessmentAspectRow(int index, ParticipationAspect aspect, int? value, - Action apply) + Action apply, int? raisedHandCount = null) { Index = index; Key = aspect.Key; Label = aspect.Label; ValueType = aspect.ValueType; MaxPoints = aspect.MaxPoints; _value = value; _apply = apply; + _raisedHandCount = raisedHandCount; + SuggestedValue = raisedHandCount is int rhc + ? ParticipationCountSuggestion.SuggestQuantity(ValueType, rhc) : null; var steps = ValueType == AspectValueType.Points ? Enumerable.Range(0, Math.Min(MaxPoints, 9) + 1).Select(v => (v, v.ToString())).ToList() : ParticipationRatingScale.Steps(ValueType).ToList(); @@ -864,10 +909,13 @@ public partial class SeatAssessmentAspectRow : ObservableObject { Value = value; OnPropertyChanged(nameof(DisplayValue)); + OnPropertyChanged(nameof(HasSuggestion)); foreach (var choice in Choices) choice.IsSelected = choice.Value == value; _apply(Key, value); } + [RelayCommand] private void ApplySuggestion() => ApplyValue(SuggestedValue); + public void Adjust(int delta) { if (ValueType == AspectValueType.Points) diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index 04ed60e..1f735ef 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -420,6 +420,30 @@ + + + + + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationQuickInputDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/ParticipationQuickInputDialog.axaml.cs index ba2a8c5..cfea0a5 100644 --- a/LehrerApp.Desktop/Views/Groups/ParticipationQuickInputDialog.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/ParticipationQuickInputDialog.axaml.cs @@ -58,6 +58,7 @@ public partial class ParticipationQuickInputDialog : Window case Key.OemPlus or Key.Add: vm.IncrementRating(); e.Handled = true; break; case Key.OemMinus or Key.Subtract: vm.DecrementRating(); e.Handled = true; break; + case Key.Delete: vm.ClearRating(); e.Handled = true; break; case Key.Escape: Close(); e.Handled = true; break; diff --git a/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml b/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml index d2202e8..2349690 100644 --- a/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/SeatAssessmentDialog.axaml @@ -58,6 +58,10 @@ + + + diff --git a/LehrerApp.Tests/ExamWeekLoadServiceTests.cs b/LehrerApp.Tests/ExamWeekLoadServiceTests.cs new file mode 100644 index 0000000..5d596bd --- /dev/null +++ b/LehrerApp.Tests/ExamWeekLoadServiceTests.cs @@ -0,0 +1,86 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using Xunit; + +namespace LehrerApp.Tests; + +public class ExamWeekLoadServiceTests +{ + private static Exam MakeExam(DateOnly date, ExamStatus status = ExamStatus.Planned) => new() + { + GroupId = Guid.NewGuid(), Title = "Klausur", Status = status, Date = date, + }; + + [Fact] + public void CountInSameWeek_ZaehltNurKlausurenInDerselbenIsoWoche() + { + // KW 12/2026: Montag 16.03. bis Sonntag 22.03. + var monday = new DateOnly(2026, 3, 16); + var exams = new List + { + MakeExam(monday), + MakeExam(monday.AddDays(4)), // Freitag derselben Woche + MakeExam(monday.AddDays(-1)), // Sonntag der Vorwoche + MakeExam(monday.AddDays(7)), // Montag der Folgewoche + }; + + var count = ExamWeekLoadService.CountInSameWeek(exams, monday.AddDays(2)); + + Assert.Equal(2, count); + } + + [Fact] + public void CountInSameWeek_SchliesstAngegebeneKlausurAus() + { + var monday = new DateOnly(2026, 3, 16); + var editing = MakeExam(monday); + var exams = new List { editing, MakeExam(monday.AddDays(1)) }; + + var count = ExamWeekLoadService.CountInSameWeek(exams, monday, editing.Id); + + Assert.Equal(1, count); + } + + [Fact] + public void FindOverloadedWeeks_MeldetNurWochenAbDemSchwellenwert() + { + var monday = new DateOnly(2026, 3, 16); + var exams = new List + { + MakeExam(monday), MakeExam(monday.AddDays(1)), // KW 12: 2 -> unauffällig + MakeExam(monday.AddDays(7)), MakeExam(monday.AddDays(8)), MakeExam(monday.AddDays(9)), // KW 13: 3 -> Meldung + }; + + var overloaded = ExamWeekLoadService.FindOverloadedWeeks(exams); + + var week = Assert.Single(overloaded); + Assert.Equal(3, week.ExamCount); + Assert.Equal(monday.AddDays(7), week.WeekStart); + } + + [Fact] + public void FindOverloadedWeeks_OhneHaeufung_GibtLeereListeZurueck() + { + var monday = new DateOnly(2026, 3, 16); + var exams = new List { MakeExam(monday), MakeExam(monday.AddDays(7)) }; + + Assert.Empty(ExamWeekLoadService.FindOverloadedWeeks(exams)); + } + + [Fact] + public void FindOverloadedWeeks_SortiertChronologisch() + { + var laterWeek = new DateOnly(2026, 4, 20); + var earlierWeek = new DateOnly(2026, 3, 16); + var exams = new List + { + MakeExam(laterWeek), MakeExam(laterWeek.AddDays(1)), MakeExam(laterWeek.AddDays(2)), + MakeExam(earlierWeek), MakeExam(earlierWeek.AddDays(1)), MakeExam(earlierWeek.AddDays(2)), + }; + + var overloaded = ExamWeekLoadService.FindOverloadedWeeks(exams); + + Assert.Equal(2, overloaded.Count); + Assert.True(overloaded[0].WeekStart < overloaded[1].WeekStart); + } +} diff --git a/LehrerApp.Tests/ParticipationCountSuggestionTests.cs b/LehrerApp.Tests/ParticipationCountSuggestionTests.cs new file mode 100644 index 0000000..ca06c7b --- /dev/null +++ b/LehrerApp.Tests/ParticipationCountSuggestionTests.cs @@ -0,0 +1,32 @@ +using LehrerApp.Core.Models; +using Xunit; + +namespace LehrerApp.Tests; + +/// Tests für den Quantitäts-Vorschlag aus der Sitzplan-Strichliste (Nutzer-Feedback): +/// wie oft sich jemand meldet, schlägt direkt eine Quantitätsbewertung vor. +public sealed class ParticipationCountSuggestionTests +{ + [Theory] + [InlineData(0, -2)] + [InlineData(1, -1)] + [InlineData(2, 0)] + [InlineData(3, 0)] + [InlineData(4, 1)] + [InlineData(5, 1)] + [InlineData(6, 2)] + [InlineData(20, 2)] + public void SuggestQuantity_Scale5_BildetMeldungszahlAufStufeAb(int raisedHandCount, int expected) + { + Assert.Equal(expected, ParticipationCountSuggestion.SuggestQuantity(AspectValueType.Scale5, raisedHandCount)); + } + + [Theory] + [InlineData(AspectValueType.Scale3)] + [InlineData(AspectValueType.Binary)] + [InlineData(AspectValueType.Points)] + public void SuggestQuantity_AndereTypen_GibtKeinenVorschlag(AspectValueType type) + { + Assert.Null(ParticipationCountSuggestion.SuggestQuantity(type, 5)); + } +} diff --git a/TODO.md b/TODO.md index 5d77213..dc38c96 100644 --- a/TODO.md +++ b/TODO.md @@ -144,6 +144,22 @@ unterrichten oft mehrere Kurse gleichzeitig und brauchen einen gruppenübergreif `GradingService`/`GradeBarItem`) — kein Kompetenz-Breakdown in dieser Iteration. Button "Zum Kurs" springt in den Klausuren-Tab der jeweiligen Gruppe (dort wie gehabt Zugriff auf Kompetenzen-Tab, 8.2/8.3). +- [x] **1.6.7** Persönliche Klausurlast pro Woche (Nutzer-Feedback). Bewusst **keine** + klassenbezogene Kollisionsprüfung (mehrere Klausuren einer einzelnen Klasse in einer + Woche) — das übernimmt bereits der schulische Klausurplaner (externes Klassenbuch) mit + eigenem Alarm. Was dort fehlt: die eigene Belastung über alle Kurse hinweg, unabhängig von + der Klasse — mehr Klausuren in derselben Woche bedeuten unabhängig davon mehr + Erstellungsaufwand vorher und mehr Korrekturaufwand danach. Neuer + `ExamWeekLoadService` (`LehrerApp.Core/Services/ExamWeekLoadService.cs`) zählt eigene + Klausuren je ISO-Kalenderwoche; da eine LiteDB-Datei genau einem Nutzer gehört, sind "alle + Klausuren in der Datenbank" bereits gleichbedeutend mit "meine Klausuren". Ab drei + Klausuren in derselben Woche gilt sie als spürbar belastet (zwei aus zwei unterrichteten + Fächern sind normal, eine dritte die eigentliche Häufung). Zwei Einstiege: (a) im + `ExamDialog` direkt beim Ändern des Datums ein Hinweis, wenn die Klausur die Woche über + den Schwellenwert heben würde — greift am Entscheidungspunkt, bevor überhaupt gespeichert + wird; (b) neue Dashboard-Karte "Klausurwochen" (9), die bevorstehende Wochen (60 Tage + Vorausschau) mit auffälliger Häufung unter den noch **geplanten** Klausuren auflistet — + bereits durchgeführte/korrigierte Klausuren deckt schon die Karte "Offene Korrekturen" ab. --- @@ -281,6 +297,16 @@ Siehe [ParticipationViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/Participa Anwesenheitsstatus ("⚠ Abwesend — Krank, unentschuldigt" o.ä.) in Orange. Bewusst kein Blockieren der Eingabe (manche Bewertungssysteme erwarten trotzdem einen expliziten Eintrag) — nur ein visueller Hinweis. +- [x] **3.1.6** Bewertung wieder auf "nicht bewertet" zurücksetzen können (Nutzer-Feedback: eine + aus Versehen gesetzte Bewertung — z.B. Qualität, obwohl sich der Schüler nie gemeldet hat — + muss sich zurücknehmen lassen, ohne einen inhaltlich falschen Wert stehen lassen zu müssen). + Im Sitzplatz-Dialog (`SeatAssessmentDialog`) gab es das bereits über "Löschen"/Backspace je + Aspekt; in der session-weiten Schnelleingabe (`ParticipationQuickInputDialog`, Q/W/E/R/T + + Zifferntasten) fehlte ein entsprechender Weg — Backspace ist dort bereits für "vorheriger + Schüler" belegt. Neue Taste **Entf** ruft `QuickInputViewModel.ClearRating()` auf + (`ApplyRating` nimmt jetzt `int?` statt `int` an); bewusst eine eigene Aktion statt über 0 + zu laufen, da 0 ("--") bei Quantität ein echter, beobachteter Wert ist, kein Platzhalter für + "nicht ermittelt". ### 3.2 Aggregation zur Mitarbeitsnote - [x] **3.2.1** Gewichtung je Aspekt konfigurierbar (z.B. Qualität 50 %, Quantität 30 %, Experiment 20 %). @@ -330,6 +356,20 @@ Mitarbeit-Note für denselben Zeitraum statt sie zu duplizieren (erkannt über d und die Kalender-Aggregation (`DayAgg`/`CalendarDayCell`/`CalendarEventItem`) bereits, sodass sich der dritte Termintyp am Exam-Vorbild (In-Memory-Filterung nach `GetByGroup`, keine neue Repository-Methode) ergänzen ließ. +- [x] **3.3.5** Strichliste "gemeldet"/"gemeldet und drangekommen" direkt im Sitzplan + (Nutzer-Feedback, als Ergänzung zur "ohne Mitarbeitsbewertung"-Zählung aus dem + Aufrufgerechtigkeits-Gedanken): zwei neue Felder `ParticipationEntry.RaisedHandCount`/ + `.CalledOnCount` — "drangekommen" ist immer auch eine Meldung und erhöht daher immer + beide Zähler zugleich, nie `CalledOnCount` allein. Zwei kompakte Zähl-Buttons auf jeder + belegten Sitzplatz-Kachel im Anzeigemodus (`ShowLessonOverview`, gleiche Sichtbarkeit wie + die Anwesenheits-/Hausaufgaben-Badges) erlauben das Mitzählen während der Stunde, ohne den + vollen Sitzplatz-Dialog öffnen zu müssen — bewusst nur Hochzählen, keine Korrektur direkt + auf der Kachel (die Rohwerte bleiben über den Sitzplatz-Dialog einsehbar). Tüpfelchen auf + dem i: die Strichliste schlägt im Sitzplatz-Dialog direkt eine Quantitätsbewertung vor + (neuer `ParticipationCountSuggestion.SuggestQuantity`, nur für den eingebauten + "quantity"-Aspekt mit Skalentyp `Scale5`) — als sichtbarer Vorschlag mit einem Klick zum + Übernehmen, nie automatisch gesetzt, damit eine bereits vorhandene bewusste Bewertung nie + stillschweigend überschrieben wird. ### Abschnittsnoten & Mitarbeits-Assistent (nicht aus dieser Liste, eigener Workflow-Bedarf)