Arbeitszeitnacherfassung
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-09-06 13:51:17 +02:00
parent 73453fd88d
commit 709ea88c2a
9 changed files with 161 additions and 6 deletions
@@ -137,7 +137,7 @@ public partial class DashboardViewModel : ObservableObject
public int OpenTaskCount => OpenTasks.Count;
public int UpcomingCount => UpcomingDates.Count;
public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count
+ OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count;
+ OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count + MissingTeachingTimeEntries.Count;
public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden";
public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben";
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
@@ -467,6 +467,10 @@ public partial class TimeTrackingViewModel : ObservableObject
private readonly IWorkTaskRepository _tasks;
private readonly ITimetableSlotRepository _timetableSlots;
private readonly PeriodScheduleService _periodSchedule;
private readonly ISchoolHolidayRepository? _schoolHolidays;
private readonly PublicHolidayService? _publicHolidays;
private readonly SchoolCalendarSettingsService? _calendarSettings;
private readonly ISubstitutionEntryRepository? _substitutions;
// Nutzer-Feedback: "man beginnt ja auch vermutlich vor 7:50" (erste Stunde) und "wird auch
// nicht aus dem Unterricht nach Hause rennen" (nach der letzten) - grobe, aber plausible
@@ -474,6 +478,8 @@ public partial class TimeTrackingViewModel : ObservableObject
// vor, gespeichert wird erst nach ausdrücklicher Bestätigung dort (siehe SuggestTeachingTime).
private const int BufferBeforeFirstPeriodMinutes = 15;
private const int BufferAfterLastPeriodMinutes = 10;
private const int MissingTeachingTimeLookbackDays = 14;
private const int MissingTeachingTimeTodayDelayMinutes = 30;
public const string NoTaskOption = "Keine Aufgabe";
@@ -490,6 +496,7 @@ public partial class TimeTrackingViewModel : ObservableObject
public ObservableCollection<TimeEntryListItem> WeekEntries { get; } = [];
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
public ObservableCollection<TeachingTimeGapItem> MissingTeachingTimeEntries { get; } = [];
public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche";
/// Ob heute laut Stundenplan überhaupt Unterricht ansteht - steuert, ob der
@@ -502,14 +509,22 @@ public partial class TimeTrackingViewModel : ObservableObject
/// im Dialog bleibt aber immer nötig, nichts wird automatisch gespeichert (siehe Puffer-
/// Konstanten oben).
public Func<TimeOnly, TimeOnly, Task<TimeEntry?>>? OnSuggestTeachingTime { get; set; }
public Func<TeachingTimeGapItem, Task<TimeEntry?>>? OnAddMissingTeachingTime { get; set; }
public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks,
ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule)
ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule,
ISchoolHolidayRepository? schoolHolidays = null, PublicHolidayService? publicHolidays = null,
SchoolCalendarSettingsService? calendarSettings = null,
ISubstitutionEntryRepository? substitutions = null)
{
_entries = entries;
_tasks = tasks;
_timetableSlots = timetableSlots;
_periodSchedule = periodSchedule;
_schoolHolidays = schoolHolidays;
_publicHolidays = publicHolidays;
_calendarSettings = calendarSettings;
_substitutions = substitutions;
Load();
}
@@ -550,6 +565,7 @@ public partial class TimeTrackingViewModel : ObservableObject
minutes, maxMinutes > 0 ? minutes / (double)maxMinutes : 0));
}
RefreshMissingTeachingTime(today);
OnPropertyChanged(nameof(TotalWeekMinutesDisplay));
}
@@ -605,6 +621,59 @@ public partial class TimeTrackingViewModel : ObservableObject
Refresh();
}
[RelayCommand]
private async Task AddMissingTeachingTime(TeachingTimeGapItem? item)
{
if (item is null || OnAddMissingTeachingTime is null) return;
var result = await OnAddMissingTeachingTime(item);
if (result is null) return;
_entries.Save(result);
Refresh();
}
private void RefreshMissingTeachingTime(DateOnly today)
{
MissingTeachingTimeEntries.Clear();
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
var timetableSlots = _timetableSlots.GetAll();
var schoolHolidays = _schoolHolidays?.GetAll() ?? [];
var publicHolidayDates = _publicHolidays is not null && _calendarSettings is not null
? Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet()
: [];
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
var teachingCategory = TaskCategoryDisplay.Label(TaskCategory.Teaching);
for (var date = firstDay; date <= today; date = date.AddDays(1))
{
if (publicHolidayDates.Contains(date)
|| schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate))
continue;
var daySlots = timetableSlots.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;
var lastPeriodEnd = periodTimes.Max(t => t.End);
if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes))
continue;
if (_entries.GetByDate(date).Any(e => e.Category == teachingCategory)) continue;
MissingTeachingTimeEntries.Add(new TeachingTimeGapItem(
date,
periodTimes.Min(t => t.Start).AddMinutes(-BufferBeforeFirstPeriodMinutes),
lastPeriodEnd.AddMinutes(BufferAfterLastPeriodMinutes)));
}
}
/// <summary>
/// Frühester Beginn / spätestes Ende aller heutigen Stundenplan-Perioden (alle Gruppen, nicht
/// auf eine einzelne beschränkt - der Unterrichtstag als Ganzes), je um die oben definierten
@@ -647,6 +716,17 @@ public class TimeEntryListItem(TimeEntry model, string? taskTitle)
public string Description => Model.Description ?? "";
}
public sealed class TeachingTimeGapItem(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);
public string TimeDisplay { get; } = $"{windowStart:HH:mm}{windowEnd:HH:mm} Uhr";
}
public class CategoryTimeSummary(string category, int minutes, double barFraction)
{
public string Category { get; } = category;