Arbeitszeit: Zeiterfassung (Kapitel 6.2)

Neuer Tab "Zeiterfassung" neben "Aufgaben" (WorkloadViewModel als Tab-Container,
gleiches Muster wie GroupDetailViewModel): Timer mit Zuordnung zu Aufgabe/Kategorie,
manuelle Nacherfassung (Von-Bis oder Dauer), Wochenübersicht nach Kategorie, sowie
"X / Y min erfasst" direkt in der Aufgabenliste als Ist-vs-Soll-Vergleich.
This commit is contained in:
2026-08-15 23:17:44 +02:00
parent 5c0b6854f5
commit d8b05702bc
14 changed files with 707 additions and 38 deletions
+12
View File
@@ -280,6 +280,18 @@ public class FakeWorkTasks : IWorkTaskRepository
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
}
public class FakeTimeEntries : ITimeEntryRepository
{
private readonly List<TimeEntry> _all = [];
public void Add(TimeEntry e) => _all.Add(e);
public List<TimeEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList();
public List<TimeEntry> GetByDateRange(DateOnly from, DateOnly to) =>
_all.Where(e => e.Date >= from && e.Date <= to).ToList();
public List<TimeEntry> GetByTask(Guid taskId) => _all.Where(e => e.TaskId == taskId).ToList();
public void Save(TimeEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeReportGrades : IReportGradeRepository
{
private readonly List<ReportGrade> _all = [];
@@ -12,7 +12,7 @@ public sealed class WorkTaskListViewModelTests
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { Title = "Offen", Status = WorkTaskStatus.Open });
tasks.Add(new WorkTask { Title = "Erledigt", Status = WorkTaskStatus.Done });
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]));
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
Assert.Single(vm.Tasks);
Assert.Equal("Offen", vm.Tasks[0].Title);
@@ -23,7 +23,7 @@ public sealed class WorkTaskListViewModelTests
{
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { Title = "Erledigt", Status = WorkTaskStatus.Done });
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]))
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries())
{
StatusFilter = WorkTaskListViewModel.AllFilter,
};
@@ -37,7 +37,7 @@ public sealed class WorkTaskListViewModelTests
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { Title = "Korrektur", Category = TaskCategory.Correction });
tasks.Add(new WorkTask { Title = "Vorbereitung", Category = TaskCategory.Preparation });
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]))
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries())
{
CategoryFilter = TaskCategoryDisplay.Label(TaskCategory.Correction),
};
@@ -53,7 +53,7 @@ public sealed class WorkTaskListViewModelTests
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { Title = "Mit Gruppe", GroupId = group.Id });
tasks.Add(new WorkTask { Title = "Ohne Gruppe", GroupId = null });
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([group]))
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([group]), new FakeTimeEntries())
{
GroupFilter = WorkTaskListViewModel.NoGroupFilter,
};
@@ -67,7 +67,7 @@ public sealed class WorkTaskListViewModelTests
{
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { Title = "T", Status = WorkTaskStatus.Open });
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]))
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries())
{
StatusFilter = WorkTaskListViewModel.AllFilter,
};
@@ -82,7 +82,7 @@ public sealed class WorkTaskListViewModelTests
{
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { Title = "T", Status = WorkTaskStatus.Done });
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]))
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries())
{
StatusFilter = WorkTaskListViewModel.AllFilter,
};
@@ -97,7 +97,7 @@ public sealed class WorkTaskListViewModelTests
{
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { Title = "T" });
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]));
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
vm.DeleteTaskCommand.Execute(vm.Tasks[0]);
@@ -108,7 +108,7 @@ public sealed class WorkTaskListViewModelTests
public async Task AddTask_RuftOnEditTaskAufUndSpeichertErgebnis()
{
var tasks = new FakeWorkTasks();
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]));
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
vm.OnEditTask = _ => Task.FromResult<WorkTask?>(new WorkTask { Title = "Neu" });
await vm.AddTaskCommand.ExecuteAsync(null);
@@ -124,7 +124,7 @@ public sealed class WorkTaskListViewModelTests
tasks.Add(new WorkTask { Title = "Ohne Frist", DueDate = null });
tasks.Add(new WorkTask { Title = "Spät fällig", DueDate = DateOnly.FromDateTime(DateTime.Today.AddDays(10)) });
tasks.Add(new WorkTask { Title = "Bald fällig", DueDate = DateOnly.FromDateTime(DateTime.Today.AddDays(1)) });
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]));
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
Assert.Equal(["Bald fällig", "Spät fällig", "Ohne Frist"], vm.Tasks.Select(t => t.Title));
}
@@ -193,3 +193,156 @@ public sealed class AddEditWorkTaskDialogViewModelTests
Assert.Equal(WorkTaskStatus.InProgress, vm.Result.Status);
}
}
public sealed class TimeTrackingViewModelTests
{
[Fact]
public void StartStopTimer_SpeichertZeiteintragMitMindestensEinerMinute()
{
var entries = new FakeTimeEntries();
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks())
{
SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction),
};
vm.StartTimerCommand.Execute(null);
Assert.True(vm.IsTimerRunning);
vm.StopTimerCommand.Execute(null);
Assert.False(vm.IsTimerRunning);
var saved = Assert.Single(entries.GetByDate(DateOnly.FromDateTime(DateTime.Today)));
Assert.True(saved.DurationMinutes >= 1);
Assert.Equal(TaskCategoryDisplay.Label(TaskCategory.Correction), saved.Category);
}
[Fact]
public void SelectedTimerTask_UebernimmtDessenKategorie()
{
var task = new WorkTask { Title = "T", Category = TaskCategory.Meeting };
var tasks = new FakeWorkTasks();
tasks.Add(task);
var vm = new TimeTrackingViewModel(new FakeTimeEntries(), tasks);
vm.SelectedTimerTask = task;
Assert.Equal(TaskCategoryDisplay.Label(TaskCategory.Meeting), vm.SelectedTimerCategory);
}
[Fact]
public void Load_ZeigtNurEintraegeDerAktuellenWoche()
{
var entries = new FakeTimeEntries();
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());
Assert.Single(vm.WeekEntries);
Assert.Equal(30, vm.WeekEntries[0].Model.DurationMinutes);
}
[Fact]
public void CategorySummaries_GruppiertUndSummiertProKategorie()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var entries = new FakeTimeEntries();
entries.Add(new TimeEntry { Category = "Korrektur", Date = today, DurationMinutes = 30 });
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 korrektur = vm.CategorySummaries.Single(s => s.Category == "Korrektur");
Assert.Equal("50 min", korrektur.MinutesDisplay);
Assert.Equal(1.0, korrektur.BarFraction);
Assert.Equal("60 min diese Woche", vm.TotalWeekMinutesDisplay);
}
[Fact]
public void DeleteEntry_EntferntEintragAusDerWoche()
{
var entries = new FakeTimeEntries();
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
vm.DeleteEntryCommand.Execute(vm.WeekEntries[0]);
Assert.Empty(vm.WeekEntries);
}
[Fact]
public async Task AddEntry_RuftOnAddEntryAufUndSpeichertErgebnis()
{
var entries = new FakeTimeEntries();
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
vm.OnAddEntry = () => Task.FromResult<TimeEntry?>(
new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 15 });
await vm.AddEntryCommand.ExecuteAsync(null);
Assert.Single(vm.WeekEntries);
}
}
public sealed class AddTimeEntryDialogViewModelTests
{
[Fact]
public void Save_MitDauer_ErzeugtEintrag()
{
var vm = new AddTimeEntryDialogViewModel([])
{
DateText = "10.01.2026",
DurationText = "45",
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(45, vm.Result!.DurationMinutes);
Assert.Equal(new DateOnly(2026, 1, 10), vm.Result.Date);
}
[Fact]
public void Save_MitVonBis_BerechnetDauer()
{
var vm = new AddTimeEntryDialogViewModel([])
{
DateText = "10.01.2026",
StartTimeText = "14:00",
EndTimeText = "14:30",
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(30, vm.Result!.DurationMinutes);
}
[Fact]
public void Save_OhneDauerUndOhneZeiten_SetztFehler()
{
var vm = new AddTimeEntryDialogViewModel([]) { DateText = "10.01.2026" };
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEmpty(vm.TimeError);
}
[Fact]
public void Save_EndeVorBeginn_SetztFehler()
{
var vm = new AddTimeEntryDialogViewModel([])
{
DateText = "10.01.2026",
StartTimeText = "14:30",
EndTimeText = "14:00",
};
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEmpty(vm.TimeError);
}
}