Dashboard-Erinnerung für fehlende Unterrichtszeit-Erfassung
CI / build-and-test (push) Canceled after 0s

Neue Karte "Unterrichtszeit nacherfassen" schaut 14 Tage zurück und
listet Schultage mit Unterricht laut Stundenplan ohne zugehörigen
"Unterricht"-Zeiteintrag (Ferien/Feiertage/komplett ausgefallene Tage
ausgenommen). Für heute erscheint der Eintrag erst 30 Minuten nach dem
laut Stundenplan letzten Unterrichtsende. Klick auf "Erfassen" öffnet
den bestehenden Nacherfassen-Dialog vorbelegt mit Datum, Kategorie
"Unterricht" und berechnetem Zeitfenster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 19:38:57 +02:00
co-authored by Claude Sonnet 5
parent 3f71f7f371
commit 23abe7af63
6 changed files with 276 additions and 4 deletions
@@ -16,6 +16,7 @@ public sealed class DashboardSettingsService
[ [
"today", "tasks", "calendar", "excuses", "upcoming", "today", "tasks", "calendar", "excuses", "upcoming",
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload", "corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
"missingteachingtime",
]; ];
private readonly string _configPath; private readonly string _configPath;
@@ -60,7 +60,8 @@ public sealed class DashboardViewModelTests
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null, DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null) FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
FakeTimeEntries? timeEntries = null)
{ {
lessons ??= new FakeLessons(); lessons ??= new FakeLessons();
lessons.Add(lesson); lessons.Add(lesson);
@@ -73,7 +74,7 @@ public sealed class DashboardViewModelTests
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(), schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents); substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents);
} }
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der /// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
@@ -137,6 +138,120 @@ public sealed class DashboardViewModelTests
Assert.Empty(vm.ExamWeekLoads); Assert.Empty(vm.ExamWeekLoads);
} }
[Fact]
public void MissingTeachingTime_VergangenerUnterrichtstagOhneErfassung_WirdGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible);
}
[Fact]
public void MissingTeachingTime_BereitsErfassterTag_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var timeEntries = new FakeTimeEntries();
timeEntries.Add(new TimeEntry { Date = pastDay, Category = "Unterricht", DurationMinutes = 45 });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
[Fact]
public void MissingTeachingTime_KomplettAusgefallenerTag_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry { Date = pastDay, Kind = SubstitutionKind.Cancelled, PeriodNumber = 1 });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
[Fact]
public void MissingTeachingTime_SchulferienTagWirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var schoolHolidays = new FakeSchoolHolidays();
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = pastDay, EndDate = pastDay });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
[Fact]
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück.
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(2);
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today);
}
[Fact]
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück.
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(-2);
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today);
}
[Fact] [Fact]
public void TodaysLessons_LoestRaumUeberPassendenStundenplanSlotAuf() public void TodaysLessons_LoestRaumUeberPassendenStundenplanSlotAuf()
{ {
+25
View File
@@ -3,6 +3,7 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling; using Avalonia.Styling;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Data; using LehrerApp.Data;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
@@ -153,6 +154,7 @@ public class App : Application
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit" dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren" dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung" dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
dash.OnAddMissingTeachingTime = item => ShowMissingTeachingTimeDialog(item, dash);
var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>(); var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>();
examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups); examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups);
@@ -208,6 +210,29 @@ public class App : Application
Services.GetRequiredService<WorkTaskListViewModel>().Load(); Services.GetRequiredService<WorkTaskListViewModel>().Load();
} }
private static async Task ShowMissingTeachingTimeDialog(MissingTeachingTimeItem item, DashboardViewModel dashboard)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
{ MainWindow: { } owner }) return;
var tasks = Services.GetRequiredService<IWorkTaskRepository>()
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
var vm = new AddTimeEntryDialogViewModel(tasks)
{
DateText = item.Date.ToString("dd.MM.yyyy"),
SelectedCategory = "Unterricht",
StartTimeText = item.WindowStart.ToString("HH:mm"),
EndTimeText = item.WindowEnd.ToString("HH:mm"),
};
var dialog = new AddTimeEntryDialog { DataContext = vm };
await dialog.ShowDialog<bool>(owner);
if (vm.Result is null) return;
Services.GetRequiredService<ITimeEntryRepository>().Save(vm.Result);
dashboard.RefreshCommand.Execute(null);
Services.GetRequiredService<TimeTrackingViewModel>().Load();
}
private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard) private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard)
{ {
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
@@ -35,6 +35,7 @@ public partial class DashboardViewModel : ObservableObject
private readonly PublicHolidayService _publicHolidays; private readonly PublicHolidayService _publicHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings; private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly ISubstitutionEntryRepository _substitutions; private readonly ISubstitutionEntryRepository _substitutions;
private readonly ITimeEntryRepository _timeEntries;
private readonly IAnnualPlanEventRepository? _annualPlanEvents; private readonly IAnnualPlanEventRepository? _annualPlanEvents;
private readonly SchoolWeatherService? _schoolWeather; private readonly SchoolWeatherService? _schoolWeather;
@@ -50,6 +51,18 @@ public partial class DashboardViewModel : ObservableObject
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine /// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize. /// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
private const int AttendanceMinSampleSize = 8; private const int AttendanceMinSampleSize = 8;
/// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal
/// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht
/// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren
/// geht, sobald der nächste Schultag anbricht.
private const int MissingTeachingTimeLookbackDays = 14;
/// Für den heutigen Tag soll die Erinnerung nicht schon mitten im Unterricht auftauchen —
/// erst diese Zeitspanne nach dem laut Stundenplan letzten Unterrichtsende.
private const int MissingTeachingTimeTodayDelayMinutes = 30;
/// Gleiche Puffer-Idee wie TimeTrackingViewModel.ComputeTodaysTeachingWindow (bewusst hier
/// noch einmal definiert statt geteilt — der Vorschlag ist nur zwei Zeilen Rechnung).
private const int TeachingWindowBufferBeforeMinutes = 15;
private const int TeachingWindowBufferAfterMinutes = 10;
/// Vorausschau für die Klausurwochen-Karte (Nutzer-Feedback): weit genug, um eine sich /// 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 /// anbahnende Häufung noch rechtzeitig vor dem Anlegen weiterer Klausuren zu zeigen, aber
/// keine Vorschau auf das ganze Schuljahr. /// keine Vorschau auf das ganze Schuljahr.
@@ -77,6 +90,7 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = []; public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = []; public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = []; public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
public ObservableCollection<MissingTeachingTimeItem> MissingTeachingTimeEntries { get; } = [];
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = []; public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = []; public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = []; public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
@@ -101,6 +115,10 @@ public partial class DashboardViewModel : ObservableObject
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe // Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann. // (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; } public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
// Öffnet den Nacherfassen-Dialog für einen Tag mit fehlender Unterrichtszeit-Erfassung
// (Nutzer-Feedback), vorbelegt mit Datum, Kategorie "Unterricht" und dem laut Stundenplan
// berechneten Zeitfenster — echtes Speichern bleibt eine bewusste Bestätigung im Dialog.
public Func<MissingTeachingTimeItem, Task>? OnAddMissingTeachingTime { get; set; }
public DashboardCardOption TodayCard => Card("today"); public DashboardCardOption TodayCard => Card("today");
public DashboardCardOption TasksCard => Card("tasks"); public DashboardCardOption TasksCard => Card("tasks");
@@ -112,6 +130,7 @@ public partial class DashboardViewModel : ObservableObject
public DashboardCardOption AlertsCard => Card("alerts"); public DashboardCardOption AlertsCard => Card("alerts");
public DashboardCardOption AttendanceCard => Card("attendance"); public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption ExamLoadCard => Card("examload"); public DashboardCardOption ExamLoadCard => Card("examload");
public DashboardCardOption MissingTeachingTimeCard => Card("missingteachingtime");
public DashboardCardOption SupportCard => Card("support"); public DashboardCardOption SupportCard => Card("support");
public DashboardCardOption GroupsCard => Card("groups"); public DashboardCardOption GroupsCard => Card("groups");
public int TodayLessonCount => TodaysLessons.Count; public int TodayLessonCount => TodaysLessons.Count;
@@ -133,7 +152,8 @@ public partial class DashboardViewModel : ObservableObject
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy, PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays, DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null, ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null) AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
{ {
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
@@ -143,6 +163,7 @@ public partial class DashboardViewModel : ObservableObject
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings; _attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_timeEntries = timeEntries;
_substitutions = substitutions; _substitutions = substitutions;
_annualPlanEvents = annualPlanEvents; _annualPlanEvents = annualPlanEvents;
_schoolWeather = schoolWeather; _schoolWeather = schoolWeather;
@@ -216,6 +237,7 @@ public partial class DashboardViewModel : ObservableObject
LoadOpenExcuses(groups.Values.ToList(), today); LoadOpenExcuses(groups.Values.ToList(), today);
LoadAttendanceWarnings(today); LoadAttendanceWarnings(today);
LoadExamWeekLoads(groups, today); LoadExamWeekLoads(groups, today);
LoadMissingTeachingTime(today);
LoadSupportPlanReviews(today); LoadSupportPlanReviews(today);
LoadUpcomingDates(groups, today); LoadUpcomingDates(groups, today);
LoadOpenCorrections(groups, today); LoadOpenCorrections(groups, today);
@@ -236,6 +258,7 @@ public partial class DashboardViewModel : ObservableObject
AlertsCard.IsEmpty = Alerts.Count == 0; AlertsCard.IsEmpty = Alerts.Count == 0;
AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0; AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0;
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0; ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
MissingTeachingTimeCard.IsEmpty = MissingTeachingTimeEntries.Count == 0;
SupportCard.IsEmpty = SupportPlanReviews.Count == 0; SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
GroupsCard.IsEmpty = CurrentGroups.Count == 0; GroupsCard.IsEmpty = CurrentGroups.Count == 0;
@@ -351,6 +374,54 @@ public partial class DashboardViewModel : ObservableObject
ExamWeekLoads.Add(new ExamWeekLoadItem(week.IsoWeek, week.WeekStart, week.WeekEnd, week.ExamCount)); ExamWeekLoads.Add(new ExamWeekLoadItem(week.IsoWeek, week.WeekStart, week.WeekEnd, week.ExamCount));
} }
// ── Unterrichtszeit-Erfassung nachholen (Nutzer-Feedback) ─────────────────
//
// "Ich vergesse es, und fasse die App außerhalb der Schule manchmal nicht mehr an" — schaut
// deshalb bewusst ein paar Tage zurück statt nur auf heute. Ein Tag zählt als erfasst, sobald
// irgendein TimeEntry der Kategorie "Unterricht" an diesem Datum existiert; die genaue
// Zeitüberschneidung mit dem Stundenplan wird nicht geprüft (dieselbe grobe Betrachtung wie
// beim bestehenden "Unterrichtszeit übernehmen"-Vorschlag für heute).
private void LoadMissingTeachingTime(DateOnly today)
{
MissingTeachingTimeEntries.Clear();
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet();
var schoolHolidays = _schoolHolidays.GetAll();
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
var items = new List<MissingTeachingTimeItem>();
for (var date = firstDay; date <= today; date = date.AddDays(1))
{
if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
var daySlots = _timetableSlots.GetAll().Where(s => s.Weekday == date.DayOfWeek).ToList();
if (daySlots.Count == 0) continue;
var cancelledPeriods = _substitutions.GetByDate(date)
.Where(s => s.Kind == SubstitutionKind.Cancelled)
.Select(s => s.PeriodNumber).ToHashSet();
var periodTimes = daySlots.Where(s => !cancelledPeriods.Contains(s.PeriodNumber))
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
.Where(t => t is not null).Select(t => t!.Value).ToList();
if (periodTimes.Count == 0) continue; // alle Stunden entfallen oder kein Zeitraster hinterlegt
var lastPeriodEnd = periodTimes.Max(t => t.End);
if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes))
continue; // heute: erst 30 Minuten nach Unterrichtsschluss erinnern
if (_timeEntries.GetByDate(date).Any(e => e.Category == "Unterricht")) continue;
var windowStart = periodTimes.Min(t => t.Start).AddMinutes(-TeachingWindowBufferBeforeMinutes);
var windowEnd = lastPeriodEnd.AddMinutes(TeachingWindowBufferAfterMinutes);
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
}
foreach (var item in items.OrderBy(i => i.Date))
MissingTeachingTimeEntries.Add(item);
}
// ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────── // ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
private void LoadSupportPlanReviews(DateOnly today) private void LoadSupportPlanReviews(DateOnly today)
@@ -576,7 +647,8 @@ public partial class DashboardViewModel : ObservableObject
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine", "excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten", "corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage", "attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", "examload" => "Klausurwochen", _ => key, "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
"missingteachingtime" => "Unterrichtszeit nacherfassen", _ => key,
}; };
private void ApplyCardLayout() private void ApplyCardLayout()
@@ -626,6 +698,15 @@ public partial class DashboardViewModel : ObservableObject
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item) [RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } { if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand]
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
{
if (item is null || OnAddMissingTeachingTime is null) return;
await OnAddMissingTeachingTime(item);
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
UpdateDashboardSummary();
}
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today) private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
{ {
OpenExcuses.Clear(); OpenExcuses.Clear();
@@ -1002,6 +1083,16 @@ public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd,
public string CountDisplay => ExamCount == 1 ? "1 Klausur" : $"{ExamCount} Klausuren"; public string CountDisplay => ExamCount == 1 ? "1 Klausur" : $"{ExamCount} Klausuren";
} }
public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOnly windowEnd)
{
private static readonly CultureInfo De = new("de-DE");
public DateOnly Date { get; } = date;
public TimeOnly WindowStart { get; } = windowStart;
public TimeOnly WindowEnd { get; } = windowEnd;
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
}
// ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────────── // ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
public class SupportPlanDueItem public class SupportPlanDueItem
@@ -444,6 +444,32 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Unterrichtszeit nacherfassen (Nutzer-Feedback) -->
<Border Grid.Column="{Binding MissingTeachingTimeCard.Column}" Grid.Row="{Binding MissingTeachingTimeCard.Row}"
IsVisible="{Binding MissingTeachingTimeCard.EffectiveIsVisible}" Margin="0,0,8,8" VerticalAlignment="Top"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="UNTERRICHTSZEIT NACHERFASSEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding MissingTeachingTimeEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:MissingTeachingTimeItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<TextBlock Grid.Column="0" Text="{Binding DateDisplay}" FontSize="13"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Erfassen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).AddMissingTeachingTimeCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine fehlende Unterrichtszeit-Erfassung." Classes="emptyhint"
IsVisible="{Binding !MissingTeachingTimeEntries.Count}"/>
</StackPanel>
</Border>
<!-- Förderplan-Wiedervorlage (5.3.2) --> <!-- Förderplan-Wiedervorlage (5.3.2) -->
<Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}" <Border Grid.Column="{Binding SupportCard.Column}" Grid.Row="{Binding SupportCard.Row}"
IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top" IsVisible="{Binding SupportCard.EffectiveIsVisible}" Margin="8,0,0,8" VerticalAlignment="Top"
+14
View File
@@ -2444,6 +2444,20 @@ Buchung ohne Blick darauf. Neuer `TaskCategory.Teaching`-Wert (`TaskCategoryDisp
"Unterricht") ans Ende des Enums angehängt, um bestehende serialisierte Werte nicht zu "Unterricht") ans Ende des Enums angehängt, um bestehende serialisierte Werte nicht zu
verschieben. verschieben.
**Nachtrag — Dashboard-Erinnerung "Unterrichtszeit nacherfassen" (Nutzer-Feedback):** "Ich vergesse
es, und fasse die App außerhalb der Schule manchmal nicht mehr an" — der Vorschlag oben deckte nur
den heutigen Tag ab und nur, solange man die Arbeitszeit-Ansicht überhaupt öffnet. Neue
Dashboard-Karte (`DashboardViewModel.LoadMissingTeachingTime`) schaut die letzten 14 Tage zurück
und listet jeden Schultag, an dem laut Stundenplan (alle Gruppen, wie beim bestehenden Vorschlag)
Unterricht stattfand, aber noch kein `TimeEntry` der Kategorie "Unterricht" existiert — Ferien,
Feiertage und per `SubstitutionKind.Cancelled` komplett ausgefallene Tage zählen nicht mit
(gleiche Prüfung wie bei "Ungeplante Stunden", 9.x). Für den heutigen Tag erscheint der Eintrag
bewusst erst 30 Minuten nach dem laut Stundenplan letzten Unterrichtsende
(`MissingTeachingTimeTodayDelayMinutes`), damit die Erinnerung nicht schon mitten im Unterricht
auftaucht. Klick auf "Erfassen" öffnet denselben `AddTimeEntryDialog` wie der bestehende
Tages-Vorschlag, vorbelegt mit Datum, Kategorie "Unterricht" und dem für diesen Tag berechneten
Zeitfenster — auch hier bleibt die Bestätigung im Dialog Pflicht, nichts wird automatisch gebucht.
### 6.3 Auswertung ### 6.3 Auswertung
- [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) — - [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) —
dritter Tab "Auswertung" (`WorkloadEvaluationViewModel`). Zeitraum wahlweise Monat dritter Tab "Auswertung" (`WorkloadEvaluationViewModel`). Zeitraum wahlweise Monat