Arbeitszeit: wiederkehrende Aufgaben + Auto-Aufgabe bei Klausurkorrektur (6.1.4/6.1.5)

Damit ist Kapitel 6 (Arbeitszeit & Aufgaben) vollständig abgeschlossen (bis auf den
Export der Auswertung, der an das noch fehlende Kapitel 11 hängt).

- WorkTask.Recurrence (None/Weekly/Monthly): beim Abschließen einer wiederkehrenden
  Aufgabe wird automatisch die nächste Instanz mit verschobenem Fälligkeitsdatum erzeugt.
- GroupDetailViewModel.SetExamStatus legt beim ersten Wechsel einer Klausur von
  "Geplant" auf "Durchgeführt" automatisch eine Korrektur-Aufgabe an.
This commit is contained in:
2026-08-16 00:39:18 +02:00
parent f9e398b225
commit 2b4fda7bb3
8 changed files with 259 additions and 9 deletions
+3
View File
@@ -85,6 +85,8 @@ public class WorkTask
public int? EstimatedMinutes { get; set; }
public WorkTaskStatus Status { get; set; } = WorkTaskStatus.Open;
public string? Notes { get; set; }
// Beim Abschließen (6.1.3) wird bei Weekly/Monthly automatisch die nächste Aufgabe erzeugt (6.1.4).
public TaskRecurrence Recurrence { get; set; } = TaskRecurrence.None;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -103,3 +105,4 @@ public class TimeEntry
}
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other }
public enum WorkTaskStatus { Open, InProgress, Done }
public enum TaskRecurrence { None, Weekly, Monthly }
@@ -0,0 +1,72 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class GroupDetailViewModelTests
{
private static (GroupDetailViewModel Vm, FakeExams Exams, FakeWorkTasks Tasks) Build(LearningGroup group, Exam exam)
{
var exams = new FakeExams([exam]);
var tasks = new FakeWorkTasks();
var groups = new FakeGroups([group]);
var students = new FakeStudents([]);
var memberships = new FakeMemberships([]);
var subjects = new FakeSubjects([]);
var grades = new FakeGrades();
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
students, memberships, groups, new FakeCompetencyDomains()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, new FakeCompetencyDomains()));
vm.LoadGroup(group.Id);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
return (vm, exams, tasks);
}
[Fact]
public void SetExamStatus_PlannedZuConducted_ErstelltKorrekturAufgabe()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Date = new DateOnly(2026, 3, 10) };
var (vm, _, tasks) = Build(group, exam);
vm.SetExamStatusCommand.Execute(ExamStatus.Conducted);
var task = Assert.Single(tasks.GetAll());
Assert.Equal("Klausur korrigieren: Klausur 1", task.Title);
Assert.Equal(TaskCategory.Correction, task.Category);
Assert.Equal(group.Id, task.GroupId);
Assert.Equal(new DateOnly(2026, 3, 24), task.DueDate);
}
[Fact]
public void SetExamStatus_ConductedErneutGesetzt_ErstelltKeineZweiteAufgabe()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Date = new DateOnly(2026, 3, 10) };
var (vm, _, tasks) = Build(group, exam);
vm.SetExamStatusCommand.Execute(ExamStatus.Conducted);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
vm.SetExamStatusCommand.Execute(ExamStatus.Conducted);
Assert.Single(tasks.GetAll());
}
[Fact]
public void SetExamStatus_DirektAufGraded_ErstelltKeineAufgabe()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Date = new DateOnly(2026, 3, 10) };
var (vm, _, tasks) = Build(group, exam);
vm.SetExamStatusCommand.Execute(ExamStatus.Graded);
Assert.Empty(tasks.GetAll());
}
}
@@ -93,6 +93,66 @@ public sealed class WorkTaskListViewModelTests
Assert.Equal(WorkTaskStatus.Open, vm.Tasks[0].Model.Status);
}
[Fact]
public void CycleStatus_WoechentlicheWiederholungAufErledigt_ErzeugtNaechsteInstanz()
{
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask
{
Title = "Wochenbericht", Status = WorkTaskStatus.InProgress,
Recurrence = TaskRecurrence.Weekly, DueDate = new DateOnly(2026, 3, 10),
Category = TaskCategory.Admin,
});
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries())
{
StatusFilter = WorkTaskListViewModel.AllFilter,
};
vm.CycleStatusCommand.Execute(vm.Tasks[0]); // -> Done
var all = tasks.GetAll();
Assert.Equal(2, all.Count);
var next = all.Single(t => t.Status == WorkTaskStatus.Open);
Assert.Equal("Wochenbericht", next.Title);
Assert.Equal(new DateOnly(2026, 3, 17), next.DueDate);
Assert.Equal(TaskRecurrence.Weekly, next.Recurrence);
}
[Fact]
public void CycleStatus_MonatlicheWiederholung_AddiertEinenMonat()
{
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask
{
Title = "Bericht", Status = WorkTaskStatus.InProgress,
Recurrence = TaskRecurrence.Monthly, DueDate = new DateOnly(2026, 3, 10),
});
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries())
{
StatusFilter = WorkTaskListViewModel.AllFilter,
};
vm.CycleStatusCommand.Execute(vm.Tasks[0]); // -> Done
var next = tasks.GetAll().Single(t => t.Status == WorkTaskStatus.Open);
Assert.Equal(new DateOnly(2026, 4, 10), next.DueDate);
}
[Fact]
public void CycleStatus_OhneWiederholung_ErzeugtKeineNeueAufgabe()
{
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { Title = "T", Status = WorkTaskStatus.InProgress });
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries())
{
StatusFilter = WorkTaskListViewModel.AllFilter,
};
vm.CycleStatusCommand.Execute(vm.Tasks[0]); // -> Done
Assert.Single(tasks.GetAll());
}
[Fact]
public void DeleteTask_EntferntAufgabeAusDerListe()
{
@@ -193,6 +253,37 @@ public sealed class AddEditWorkTaskDialogViewModelTests
Assert.Equal(source.Id, vm.Result!.Id);
Assert.Equal(WorkTaskStatus.InProgress, vm.Result.Status);
}
[Fact]
public void Save_WiederholungOhneFaelligkeitsdatum_SetztFehler()
{
var vm = new AddEditWorkTaskDialogViewModel(null, [])
{
Title = "T",
SelectedRecurrence = TaskRecurrenceDisplay.Label(TaskRecurrence.Weekly),
};
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEmpty(vm.DueDateError);
}
[Fact]
public void Save_WiederholungMitFaelligkeitsdatum_UebernimmtRecurrence()
{
var vm = new AddEditWorkTaskDialogViewModel(null, [])
{
Title = "T",
DueDateText = "10.03.2026",
SelectedRecurrence = TaskRecurrenceDisplay.Label(TaskRecurrence.Monthly),
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(TaskRecurrence.Monthly, vm.Result!.Recurrence);
}
}
public sealed class TimeTrackingViewModelTests
@@ -179,6 +179,7 @@ public partial class GroupDetailViewModel : ObservableObject
private readonly ISubjectRepository _subjects;
private readonly IExamRepository _exams;
private readonly IGradeRepository _grades;
private readonly IWorkTaskRepository _tasks;
[ObservableProperty] private LearningGroup? _group;
[ObservableProperty] private string _groupTitle = "";
@@ -224,12 +225,12 @@ public partial class GroupDetailViewModel : ObservableObject
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
IGroupMembershipRepository memberships, ISubjectRepository subjects,
IExamRepository exams, IGradeRepository grades,
IExamRepository exams, IGradeRepository grades, IWorkTaskRepository tasks,
ParticipationTabViewModel participationTab, GradeOverviewTabViewModel gradeOverviewTab,
PlanningTabViewModel planningTab)
{
_groups = groups; _students = students; _memberships = memberships; _subjects = subjects;
_exams = exams; _grades = grades;
_exams = exams; _grades = grades; _tasks = tasks;
ParticipationTab = participationTab;
GradeOverviewTab = gradeOverviewTab;
PlanningTab = planningTab;
@@ -418,12 +419,25 @@ public partial class GroupDetailViewModel : ObservableObject
if (SelectedExam is null) return;
var exam = _exams.GetById(SelectedExam.Id);
if (exam is null) return;
var previousStatus = exam.Status;
exam.Status = status;
if (status == ExamStatus.Returned)
exam.ReturnedAt ??= DateOnly.FromDateTime(DateTime.Today);
else
exam.ReturnedAt = null;
_exams.Save(exam);
// 6.1.5: beim erstmaligen Erreichen von "Durchgeführt" automatisch eine Korrektur-Aufgabe anlegen.
if (status == ExamStatus.Conducted && previousStatus == ExamStatus.Planned)
_tasks.Save(new WorkTask
{
Title = $"Klausur korrigieren: {exam.Title}",
Category = TaskCategory.Correction,
GroupId = exam.GroupId,
DueDate = exam.Date.AddDays(14),
Status = WorkTaskStatus.Open,
});
var id = exam.Id;
ReloadExams();
SelectedExam = Exams.FirstOrDefault(e => e.Id == id);
@@ -55,6 +55,22 @@ public static class TaskCategoryDisplay
Enum.GetValues<TaskCategory>().FirstOrDefault(c => Label(c) == label, TaskCategory.Other);
}
public static class TaskRecurrenceDisplay
{
public static string Label(TaskRecurrence r) => r switch
{
TaskRecurrence.None => "Keine",
TaskRecurrence.Weekly => "Wöchentlich",
TaskRecurrence.Monthly => "Monatlich",
_ => r.ToString(),
};
public static string[] Options { get; } = Enum.GetValues<TaskRecurrence>().Select(Label).ToArray();
public static TaskRecurrence FromLabel(string? label) =>
Enum.GetValues<TaskRecurrence>().FirstOrDefault(r => Label(r) == label, TaskRecurrence.None);
}
// ── Aufgabenliste (6.1.1, 6.1.3) ─────────────────────────────────────────────
public partial class WorkTaskListViewModel : ObservableObject
@@ -168,8 +184,28 @@ public partial class WorkTaskListViewModel : ObservableObject
private void CycleStatus(WorkTaskListItem? item)
{
if (item is null) return;
item.Model.Status = WorkTaskStatusDisplay.Next(item.Model.Status);
var newStatus = WorkTaskStatusDisplay.Next(item.Model.Status);
item.Model.Status = newStatus;
_tasks.Save(item.Model);
// Wiederkehrende Aufgabe (6.1.4): beim Abschließen automatisch die nächste Instanz anlegen.
if (newStatus == WorkTaskStatus.Done && item.Model.Recurrence != TaskRecurrence.None
&& item.Model.DueDate is { } dueDate)
{
_tasks.Save(new WorkTask
{
Title = item.Model.Title,
Category = item.Model.Category,
GroupId = item.Model.GroupId,
DueDate = item.Model.Recurrence == TaskRecurrence.Weekly
? dueDate.AddDays(7) : dueDate.AddMonths(1),
EstimatedMinutes = item.Model.EstimatedMinutes,
Recurrence = item.Model.Recurrence,
Notes = item.Model.Notes,
Status = WorkTaskStatus.Open,
});
}
Refresh();
}
@@ -196,6 +232,10 @@ public class WorkTaskListItem(WorkTask model, string? groupName, int actualMinut
public string StatusColorHex => WorkTaskStatusDisplay.ColorHex(Model.Status);
public string EstimatedMinutesDisplay => Model.EstimatedMinutes is { } m ? $"{m} min" : "";
// Wiederkehrende Aufgabe (6.1.4).
public bool IsRecurring => Model.Recurrence != TaskRecurrence.None;
public string RecurrenceLabel => $"🔁 {TaskRecurrenceDisplay.Label(Model.Recurrence)}";
// Ist-Zeit vs. Schätzung (6.2.4) — nur anzeigen, wenn tatsächlich etwas erfasst wurde.
public bool HasActualTime => actualMinutes > 0;
public string ActualVsEstimateDisplay => Model.EstimatedMinutes is { } estimate
@@ -214,6 +254,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
[ObservableProperty] private LearningGroup? _selectedGroup;
[ObservableProperty] private string _dueDateText = "";
[ObservableProperty] private string _estimatedMinutesText = "";
[ObservableProperty] private string _selectedRecurrence = TaskRecurrenceDisplay.Options[0];
[ObservableProperty] private string _notes = "";
[ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _dueDateError = "";
@@ -221,6 +262,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
public string DialogTitle => _source is null ? "Aufgabe anlegen" : "Aufgabe bearbeiten";
public List<string> CategoryOptions { get; } = [.. TaskCategoryDisplay.Options];
public List<string> RecurrenceOptions { get; } = [.. TaskRecurrenceDisplay.Options];
public List<LearningGroup> Groups { get; }
public WorkTask? Result { get; private set; }
@@ -235,6 +277,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
SelectedGroup = source.GroupId is { } id ? groups.FirstOrDefault(g => g.Id == id) : null;
DueDateText = source.DueDate?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "";
EstimatedMinutesText = source.EstimatedMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
SelectedRecurrence = TaskRecurrenceDisplay.Label(source.Recurrence);
Notes = source.Notes ?? "";
}
@@ -254,6 +297,10 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
else dueDate = d;
}
var recurrence = TaskRecurrenceDisplay.FromLabel(SelectedRecurrence);
if (recurrence != TaskRecurrence.None && dueDate is null)
{ DueDateError = "Fälligkeitsdatum erforderlich, damit die nächste Instanz geplant werden kann."; valid = false; }
int? estimatedMinutes = null;
if (!string.IsNullOrWhiteSpace(EstimatedMinutesText))
{
@@ -272,6 +319,7 @@ public partial class AddEditWorkTaskDialogViewModel : ObservableObject
GroupId = SelectedGroup?.Id,
DueDate = dueDate,
EstimatedMinutes = estimatedMinutes,
Recurrence = recurrence,
Status = _source?.Status ?? WorkTaskStatus.Open,
Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(),
CreatedAt = _source?.CreatedAt ?? DateTime.UtcNow,
@@ -54,6 +54,13 @@
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Wiederholung" FontSize="12" Opacity="0.7"
ToolTip.Tip="Beim Abschließen wird automatisch die nächste Instanz mit neuem Fälligkeitsdatum angelegt. Braucht ein Fälligkeitsdatum als Ausgangspunkt."/>
<ComboBox ItemsSource="{Binding RecurrenceOptions}" SelectedItem="{Binding SelectedRecurrence}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Notizen (optional)" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Notes}" AcceptsReturn="True" TextWrapping="Wrap" Height="80"/>
@@ -36,6 +36,9 @@
<Run Text=" · "/>
<Run Text="{Binding GroupName}"/>
</TextBlock>
<!-- Wiederkehrende Aufgabe (6.1.4) -->
<TextBlock Text="{Binding RecurrenceLabel}" FontSize="11" Opacity="0.6"
IsVisible="{Binding IsRecurring}"/>
<!-- Ist-Zeit vs. Schätzung (6.2.4) -->
<TextBlock Text="{Binding ActualVsEstimateDisplay}" FontSize="11" Opacity="0.6"
IsVisible="{Binding HasActualTime}"/>
+18 -6
View File
@@ -780,8 +780,8 @@ dadurch faktisch schon (Quick-Input-Dialog bzw. "Offene Entschuldigungen" im Das
Modelle `WorkTask` und `TimeEntry` existieren, Repositories ebenfalls.
Navigationspunkt "Arbeitszeit" zeigt Aufgabenverwaltung (6.1), Zeiterfassung (6.2) und Auswertung
(6.3) als drei Tabs (`WorkloadViewModel`/`WorkloadView`, gleiches Container-Tab-Muster wie
`GroupDetailViewModel`) — nur der Export der Auswertung (6.3.3) ist noch offen, da er an das noch
nicht existierende Kapitel 11 (Export-Infrastruktur) hängt.
`GroupDetailViewModel`). **Kapitel 6 ist damit vollständig abgeschlossen** bis auf den Export der
Auswertung (6.3.3), der am noch nicht existierenden Kapitel 11 (Export-Infrastruktur) hängt.
### 6.1 Aufgabenverwaltung
- [x] **6.1.1** Aufgabenliste mit Filter nach Status, Kategorie, Gruppe und Fälligkeit —
@@ -796,9 +796,17 @@ nicht existierende Kapitel 11 (Export-Infrastruktur) hängt.
- [x] **6.1.3** Status per Klick wechseln (`Open → InProgress → Done → Open`) über eine farbige
Status-Kachel je Zeile; "Offene Aufgaben" (alles außer `Done`) ist der Default-Filter, damit
Erledigtes automatisch ausgeblendet ist, ohne separate Sichtbarkeits-Logik.
- [ ] **6.1.4** Wiederkehrende Aufgaben (wöchentlich/monatlich).
- [ ] **6.1.5** Automatische Aufgabe "Klausur korrigieren" beim Statuswechsel einer Klausur
auf `Conducted` (Anbindung an 1.1.3).
- [x] **6.1.4** Wiederkehrende Aufgaben (wöchentlich/monatlich) — `WorkTask.Recurrence`
(`TaskRecurrence`: None/Weekly/Monthly). Kein eigenes Serien-/Vorlagen-Modell: Beim
Abschließen (Status → `Done` in 6.1.3) wird einfach eine neue `WorkTask` mit gleichem
Titel/Kategorie/Gruppe und um 7 Tage bzw. 1 Monat verschobenem Fälligkeitsdatum erzeugt —
genau das Verhalten, das ein Lehrer von "wöchentlich wiederkehrend" erwartet, ohne
Hintergrundjob oder Kalenderlogik. Braucht deshalb ein Fälligkeitsdatum als Ausgangspunkt
(im Dialog erzwungen, sobald eine Wiederholung gewählt ist).
- [x] **6.1.5** Automatische Aufgabe "Klausur korrigieren" beim Statuswechsel einer Klausur
auf `Conducted` (Anbindung an 1.1.3) — Hook in `GroupDetailViewModel.SetExamStatus`, feuert
nur beim *erstmaligen* Wechsel von `Planned` auf `Conducted` (nicht bei jedem erneuten
Klick), Fälligkeit 14 Tage nach dem Klausurtermin, Kategorie `Correction`.
### 6.2 Zeiterfassung
- [x] **6.2.1** Timer starten/stoppen mit Zuordnung zu Aufgabe oder Kategorie —
@@ -1273,4 +1281,8 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe
Unklarheit; Desktop bekommt dort perspektivisch Graph/Kompetenz-Verknüpfung/KI-Planung, ein
möglicher Companion-Client bleibt bewusst minimal).
**→ nächster sinnvoller Schritt: Kapitel 6, 10 oder 11.**
7. **Kapitel 6** (Arbeitszeit), **11** (Export), **10** (Sync) — danach.
7. ~~**Kapitel 6** (Arbeitszeit & Aufgaben)~~ — vollständig erledigt (6.1 Aufgabenverwaltung
inkl. wiederkehrender Aufgaben, 6.2 Zeiterfassung, 6.3 Auswertung). Offen bleibt nur **6.3.3**
(Export der Auswertung), das am noch fehlenden Kapitel 11 hängt.
**→ nächster sinnvoller Schritt: Kapitel 10 (Sync) oder 11 (Export).**
8. **Kapitel 11** (Export), **10** (Sync) — danach.