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
@@ -1,3 +1,4 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Workload;
@@ -288,14 +289,16 @@ public sealed class AddEditWorkTaskDialogViewModelTests
public sealed class TimeTrackingViewModelTests
{
private static TimeTrackingViewModel BuildVm(ITimeEntryRepository entries, IWorkTaskRepository tasks,
FakeTimetableSlots? slots = null) =>
new(entries, tasks, slots ?? new FakeTimetableSlots(), TestSupport.BuildPeriodScheduleService());
[Fact]
public void StartStopTimer_SpeichertZeiteintragMitMindestensEinerMinute()
{
var entries = new FakeTimeEntries();
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks())
{
SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction),
};
var vm = BuildVm(entries, new FakeWorkTasks());
vm.SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction);
vm.StartTimerCommand.Execute(null);
Assert.True(vm.IsTimerRunning);
@@ -314,7 +317,7 @@ public sealed class TimeTrackingViewModelTests
var task = new WorkTask { Title = "T", Category = TaskCategory.Meeting };
var tasks = new FakeWorkTasks();
tasks.Add(task);
var vm = new TimeTrackingViewModel(new FakeTimeEntries(), tasks);
var vm = BuildVm(new FakeTimeEntries(), tasks);
vm.SelectedTimerTask = task;
@@ -328,7 +331,7 @@ public sealed class TimeTrackingViewModelTests
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today).AddDays(-30), DurationMinutes = 45 });
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
var vm = BuildVm(entries, new FakeWorkTasks());
Assert.Single(vm.WeekEntries);
Assert.Equal(30, vm.WeekEntries[0].Model.DurationMinutes);
@@ -343,7 +346,7 @@ public sealed class TimeTrackingViewModelTests
entries.Add(new TimeEntry { Category = "Korrektur", Date = today, DurationMinutes = 20 });
entries.Add(new TimeEntry { Category = "Verwaltung", Date = today, DurationMinutes = 10 });
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
var vm = BuildVm(entries, new FakeWorkTasks());
var korrektur = vm.CategorySummaries.Single(s => s.Category == "Korrektur");
Assert.Equal("50 min", korrektur.MinutesDisplay);
@@ -356,7 +359,7 @@ public sealed class TimeTrackingViewModelTests
{
var entries = new FakeTimeEntries();
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
var vm = BuildVm(entries, new FakeWorkTasks());
vm.DeleteEntryCommand.Execute(vm.WeekEntries[0]);
@@ -367,7 +370,7 @@ public sealed class TimeTrackingViewModelTests
public async Task AddEntry_RuftOnAddEntryAufUndSpeichertErgebnis()
{
var entries = new FakeTimeEntries();
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
var vm = BuildVm(entries, new FakeWorkTasks());
vm.OnAddEntry = () => Task.FromResult<TimeEntry?>(
new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 15 });
@@ -375,6 +378,64 @@ public sealed class TimeTrackingViewModelTests
Assert.Single(vm.WeekEntries);
}
// ── Unterrichtszeit-Vorschlag (Nutzer-Feedback: Zeiterfassung soll bei der Unterrichtszeit
// nachfragen statt sie komplett automatisch zu erfassen) ────────────────────────────────────
[Fact]
public void HasTeachingTimeSuggestionToday_OhneStundenplanHeute_IstFalse()
{
var vm = BuildVm(new FakeTimeEntries(), new FakeWorkTasks()); // leere FakeTimetableSlots
Assert.False(vm.HasTeachingTimeSuggestionToday);
}
[Fact]
public async Task SuggestTeachingTime_SchlaegtBeginnMitPufferVorErsterUndNachLetzterPeriodeVor()
{
var today = DateTime.Today.DayOfWeek;
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { Weekday = today, PeriodNumber = 1 });
slots.Add(new TimetableSlot { Weekday = today, PeriodNumber = 2 });
var periodSchedule = TestSupport.BuildPeriodScheduleService();
periodSchedule.SetPeriods([
new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(7, 50), End = new TimeOnly(8, 35) },
new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 35), End = new TimeOnly(9, 20) },
]);
var entries = new FakeTimeEntries();
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks(), slots, periodSchedule);
Assert.True(vm.HasTeachingTimeSuggestionToday);
(TimeOnly Start, TimeOnly End)? suggested = null;
vm.OnSuggestTeachingTime = (start, end) =>
{
suggested = (start, end);
return Task.FromResult<TimeEntry?>(new TimeEntry
{
Category = "Unterricht", Date = DateOnly.FromDateTime(DateTime.Today),
StartTime = start, EndTime = end, DurationMinutes = (int)(end - start).TotalMinutes,
});
};
await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
Assert.Equal(new TimeOnly(7, 35), suggested!.Value.Start); // 7:50 - 15 Min Puffer
Assert.Equal(new TimeOnly(9, 30), suggested.Value.End); // 9:20 + 10 Min Puffer
Assert.Single(entries.GetByDate(DateOnly.FromDateTime(DateTime.Today)));
}
[Fact]
public async Task SuggestTeachingTime_OhneStundenplanHeute_TutNichts()
{
var vm = BuildVm(new FakeTimeEntries(), new FakeWorkTasks());
var called = false;
vm.OnSuggestTeachingTime = (_, _) => { called = true; return Task.FromResult<TimeEntry?>(null); };
await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
Assert.False(called);
}
}
public sealed class AddTimeEntryDialogViewModelTests