feat: Komfort-Features für Unterrichtsplanung, Zeiterfassung und Dashboard

- Neue-Stunde-Dialog: Datum wird beim Anlegen anhand des Stundenplans
  und der letzten Stunde der Einheit vorbelegt statt auf "heute"
  (behebt eine falsch erkannte Doppelstunde, wenn "heute" nicht auf
  den passenden Wochentag fiel).
- Zeiterfassung: Button "Unterrichtszeit heute übernehmen" schlägt
  Start/Ende aus dem heutigen Stundenplan inkl. Puffer davor/danach vor.
- Dashboard: neue Kachel "Ungeplante Stunden" erinnert an Stunden ohne
  Thema für heute/morgen, mit Opt-out je Gruppe ("Benötigt
  Unterrichtsplanung"), Doppelstunden-Erkennung (keine doppelte Meldung
  für die zweite Periode) und Berücksichtigung von Stundenausfall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 11:30:50 +02:00
co-authored by Claude Sonnet 5
parent a36b1d1d40
commit fd509fc921
17 changed files with 585 additions and 23 deletions
@@ -46,6 +46,7 @@ public static class TaskCategoryDisplay
TaskCategory.Admin => "Verwaltung",
TaskCategory.Meeting => "Besprechung",
TaskCategory.Other => "Sonstiges",
TaskCategory.Teaching => "Unterricht",
_ => c.ToString(),
};
@@ -333,6 +334,15 @@ public partial class TimeTrackingViewModel : ObservableObject
{
private readonly ITimeEntryRepository _entries;
private readonly IWorkTaskRepository _tasks;
private readonly ITimetableSlotRepository _timetableSlots;
private readonly PeriodScheduleService _periodSchedule;
// 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
// Puffer für den Unterrichtszeit-Vorschlag. Der Vorschlag füllt nur den Nacherfassen-Dialog
// vor, gespeichert wird erst nach ausdrücklicher Bestätigung dort (siehe SuggestTeachingTime).
private const int BufferBeforeFirstPeriodMinutes = 15;
private const int BufferAfterLastPeriodMinutes = 10;
public const string NoTaskOption = "Keine Aufgabe";
@@ -351,12 +361,24 @@ public partial class TimeTrackingViewModel : ObservableObject
public ObservableCollection<CategoryTimeSummary> CategorySummaries { get; } = [];
public string TotalWeekMinutesDisplay => $"{WeekEntries.Sum(e => e.Model.DurationMinutes)} min diese Woche";
public Func<Task<TimeEntry?>>? OnAddEntry { get; set; }
/// Ob heute laut Stundenplan überhaupt Unterricht ansteht - steuert, ob der
/// "Unterrichtszeit übernehmen"-Button überhaupt sichtbar ist.
public bool HasTeachingTimeSuggestionToday => ComputeTodaysTeachingWindow() is not null;
public TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks)
public Func<Task<TimeEntry?>>? OnAddEntry { get; set; }
/// Wie OnAddEntry, aber öffnet den Dialog mit vorbefüllter Kategorie "Unterricht" und den
/// laut Stundenplan/Stundenraster vorgeschlagenen Beginn-/Ende-Zeiten - eine echte Bestätigung
/// 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 TimeTrackingViewModel(ITimeEntryRepository entries, IWorkTaskRepository tasks,
ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule)
{
_entries = entries;
_tasks = tasks;
_timetableSlots = timetableSlots;
_periodSchedule = periodSchedule;
Load();
}
@@ -442,6 +464,38 @@ public partial class TimeTrackingViewModel : ObservableObject
Refresh();
}
[RelayCommand]
private async Task SuggestTeachingTime()
{
if (OnSuggestTeachingTime is null || ComputeTodaysTeachingWindow() is not var (start, end)) return;
var result = await OnSuggestTeachingTime(start, end);
if (result is null) return;
_entries.Save(result);
Refresh();
}
/// <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
/// Puffer erweitert. Ohne Stundenplan-Eintrag heute oder ohne im Stundenraster hinterlegte
/// Zeiten gibt es keinen Vorschlag (null) statt einer erfundenen Zeit.
/// </summary>
private (TimeOnly Start, TimeOnly End)? ComputeTodaysTeachingWindow()
{
var today = DateOnly.FromDateTime(DateTime.Today).DayOfWeek;
var periodTimes = _timetableSlots.GetAll()
.Where(s => s.Weekday == today)
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
.Where(t => t is not null)
.Select(t => t!.Value)
.ToList();
if (periodTimes.Count == 0) return null;
var start = periodTimes.Min(t => t.Start).AddMinutes(-BufferBeforeFirstPeriodMinutes);
var end = periodTimes.Max(t => t.End).AddMinutes(BufferAfterLastPeriodMinutes);
return (start, end);
}
[RelayCommand]
private void DeleteEntry(TimeEntryListItem? item)
{