diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs
index 0accca5..b418758 100644
--- a/LehrerApp.Core/Models/Planning.cs
+++ b/LehrerApp.Core/Models/Planning.cs
@@ -103,7 +103,7 @@ public enum UnitStatus { Planned, Active, Completed }
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
-public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3 }
+public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3, Cancelled = 4 }
///
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
diff --git a/LehrerApp.Core/Services/LessonSchedulingService.cs b/LehrerApp.Core/Services/LessonSchedulingService.cs
index 0e90202..c2a982d 100644
--- a/LehrerApp.Core/Services/LessonSchedulingService.cs
+++ b/LehrerApp.Core/Services/LessonSchedulingService.cs
@@ -16,7 +16,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons)
{
foreach (var other in lessons.GetByUnit(lesson.UnitId))
{
- if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate)
+ if (other.Id == lesson.Id || other.Status is LessonStatus.Conducted or LessonStatus.Cancelled || other.Date <= oldDate)
continue;
other.Date = other.Date.AddDays(delta);
lessons.Save(other);
@@ -67,7 +67,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons)
HomeworkChecked = source.HomeworkChecked,
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
Reflection = source.Reflection,
- Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status,
+ Status = source.Status is LessonStatus.Conducted or LessonStatus.Cancelled ? LessonStatus.Draft : source.Status,
};
source.Homework = null;
source.HomeworkChecked = false;
diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs
index 4d5987c..06d8dc9 100644
--- a/LehrerApp.Desktop.Tests/Fakes.cs
+++ b/LehrerApp.Desktop.Tests/Fakes.cs
@@ -610,11 +610,15 @@ public class FakeMcpConfirmation : IMcpConfirmationService
public string? LastMessage { get; private set; }
public int CallCount { get; private set; }
- public Task ConfirmAsync(string title, string message, CancellationToken ct)
+ public string? LastOperationKey { get; private set; }
+
+ public Task ConfirmAsync(string title, string message, CancellationToken ct,
+ [System.Runtime.CompilerServices.CallerMemberName] string operationKey = "")
{
CallCount++;
LastTitle = title;
LastMessage = message;
+ LastOperationKey = operationKey;
return Task.FromResult(Response);
}
}
diff --git a/LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs b/LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs
index 17d8f81..d8f89d7 100644
--- a/LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs
+++ b/LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs
@@ -45,6 +45,39 @@ public sealed class LessonSchedulingServiceTests
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
}
+ [Fact]
+ public void Move_RuecktAuchAusgefalleneFolgestundenNichtNach()
+ {
+ var unitId = Guid.NewGuid();
+ var groupId = Guid.NewGuid();
+ var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1) };
+ var cancelledFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Cancelled };
+ var repo = new FakeLessons();
+ repo.Add(moved); repo.Add(cancelledFollowing);
+
+ new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), null, shiftFollowing: true);
+
+ Assert.Equal(new DateOnly(2026, 9, 4), cancelledFollowing.Date);
+ }
+
+ [Fact]
+ public void SplitAndMoveSecondPart_AusgefalleneQuellstundeSetztFortsetzungAufEntwurfZurueck()
+ {
+ var source = new Lesson
+ {
+ UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
+ Status = LessonStatus.Cancelled,
+ Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 60 }],
+ };
+ var repo = new FakeLessons();
+ repo.Add(source);
+
+ var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 30,
+ new(2026, 9, 3), 1, null);
+
+ Assert.Equal(LessonStatus.Draft, continuation.Status);
+ }
+
[Fact]
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
{
diff --git a/LehrerApp.Desktop.Tests/LessonStatusDisplayTests.cs b/LehrerApp.Desktop.Tests/LessonStatusDisplayTests.cs
new file mode 100644
index 0000000..3c08489
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/LessonStatusDisplayTests.cs
@@ -0,0 +1,38 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Desktop.ViewModels.Groups;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+public sealed class LessonStatusDisplayTests
+{
+ [Theory]
+ [InlineData(LessonStatus.Draft, "Entwurf")]
+ [InlineData(LessonStatus.Ready, "Bereit")]
+ [InlineData(LessonStatus.Conducted, "Durchgeführt")]
+ [InlineData(LessonStatus.Cancelled, "Ausgefallen")]
+ [InlineData(LessonStatus.Planned, "Geplant")]
+ public void ToName_FromName_RoundTrip(LessonStatus status, string expectedName)
+ {
+ Assert.Equal(expectedName, LessonStatusDisplay.ToName(status));
+ Assert.Equal(status, LessonStatusDisplay.FromName(expectedName));
+ }
+
+ [Fact]
+ public void UnitSummary_AusgefalleneStundenZaehlenWederAlsGehaltenNochAlsPensum()
+ {
+ var unit = new Unit { Title = "Optik" };
+ var lessons = new List
+ {
+ new() { Status = LessonStatus.Conducted },
+ new() { Status = LessonStatus.Cancelled },
+ new() { Status = LessonStatus.Ready },
+ };
+
+ var summary = new UnitSummary(unit, lessons);
+
+ Assert.Equal(2, summary.TotalCount);
+ Assert.Equal(1, summary.ConductedCount);
+ Assert.Equal(0.5, summary.ProgressFraction);
+ }
+}
diff --git a/LehrerApp.Desktop.Tests/McpToolsTests.cs b/LehrerApp.Desktop.Tests/McpToolsTests.cs
index dea423b..f8217f1 100644
--- a/LehrerApp.Desktop.Tests/McpToolsTests.cs
+++ b/LehrerApp.Desktop.Tests/McpToolsTests.cs
@@ -340,6 +340,25 @@ public sealed class McpToolsTests
Assert.Equal(result.Id, phase.Id);
}
+ [Fact]
+ public async Task AddLessonPhase_UebergibtEigenenMethodennamenAlsOperationKey()
+ {
+ // Belegt, dass IMcpConfirmationService.ConfirmAsync den [CallerMemberName]-Mechanismus
+ // tatsächlich nutzt (Grundlage für die Sitzungsfreigabe "diese Aktion nicht mehr
+ // nachfragen" in AvaloniaMcpConfirmationService) - ohne echtes UI testbar, weil der Name
+ // vom Compiler an der Aufrufstelle in AddLessonPhase eingesetzt wird, unabhängig von der
+ // konkreten IMcpConfirmationService-Implementierung.
+ var lesson = new Lesson { Topic = "Brechung" };
+ var lessons = new FakeLessons();
+ lessons.Add(lesson);
+ var confirmation = new FakeMcpConfirmation();
+ var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
+
+ await tool.AddLessonPhase(lesson.Id, "Einstieg", 10);
+
+ Assert.Equal(nameof(LessonPlanTools.AddLessonPhase), confirmation.LastOperationKey);
+ }
+
[Fact]
public async Task UpdateLessonPhase_AendertNurAngegebeneFelder()
{
diff --git a/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs b/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs
index b0b36bd..5366ed8 100644
--- a/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs
+++ b/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs
@@ -1,7 +1,9 @@
+using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
+using LehrerApp.Core.Services;
using LehrerApp.Desktop.Views.Mcp;
namespace LehrerApp.Desktop.Services.Mcp;
@@ -17,16 +19,38 @@ namespace LehrerApp.Desktop.Services.Mcp;
/// vor dem Anzeigen aus einer möglichen Minimierung geholt und aktiviert, und der Dialog selbst
/// läuft `Topmost`.
///
+/// Nutzer-Feedback (Nachtrag): bei vielen gleichartigen Vorschlägen in Folge (z.B. 17x
+/// "add_lesson_phase" für eine neu generierte Unterrichtseinheit) einzeln nachfragen zu müssen, ist
+/// unzumutbar. Der Dialog bietet deshalb zwei Sitzungsfreigaben an ("diese Aktion" / "alle
+/// Aktionen"), die als reines In-Memory-Bookkeeping auf dieser Singleton-Instanz leben — sie gelten
+/// bis zum Beenden der App (neuer Prozess = neue Instanz = wieder alles ungetraut) und werden nie
+/// persistiert. ist der Name der aufrufenden Tool-Methode (siehe
+/// ), nicht der MCP-Wire-Name.
+///
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
/// hängen bleiben — nach wird der Dialog automatisch geschlossen und die
/// Änderung als abgelehnt gewertet.
///
-public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
+public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfirmationService
{
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
- public async Task ConfirmAsync(string title, string message, CancellationToken ct)
+ private readonly object _trustLock = new();
+ private readonly HashSet _trustedOperations = [];
+ private bool _trustAll;
+
+ public async Task ConfirmAsync(string title, string message, CancellationToken ct,
+ [CallerMemberName] string operationKey = "")
{
+ bool alreadyTrusted;
+ lock (_trustLock) alreadyTrusted = _trustAll || _trustedOperations.Contains(operationKey);
+ if (alreadyTrusted)
+ {
+ logger.Info($"MCP: „{title}“ automatisch bestätigt (Sitzungsfreigabe für " +
+ $"{(_trustAll ? "alle Aktionen" : operationKey)}).");
+ return true;
+ }
+
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
return false;
@@ -34,7 +58,7 @@ public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
// sicher aufgerufen werden kann.
- return await Dispatcher.UIThread.InvokeAsync(async () =>
+ var result = await Dispatcher.UIThread.InvokeAsync(async () =>
{
if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal;
owner.Activate();
@@ -43,15 +67,25 @@ public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
{
DataContext = new McpConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" },
};
- var dialogTask = dialog.ShowDialog(owner);
+ var dialogTask = dialog.ShowDialog(owner);
var timeoutTask = Task.Delay(Timeout, ct);
var completed = await Task.WhenAny(dialogTask, timeoutTask);
if (completed != dialogTask)
{
- dialog.Close(false);
- return false;
+ dialog.Close(McpConfirmDialogResult.Rejected);
+ return McpConfirmDialogResult.Rejected;
}
return await dialogTask;
});
+
+ if (result.Approved)
+ {
+ lock (_trustLock)
+ {
+ if (result.TrustAll) _trustAll = true;
+ else if (result.TrustOperation) _trustedOperations.Add(operationKey);
+ }
+ }
+ return result.Approved;
}
}
diff --git a/LehrerApp.Desktop/Services/Mcp/IMcpConfirmationService.cs b/LehrerApp.Desktop/Services/Mcp/IMcpConfirmationService.cs
index 3a2fbb9..c0e864f 100644
--- a/LehrerApp.Desktop/Services/Mcp/IMcpConfirmationService.cs
+++ b/LehrerApp.Desktop/Services/Mcp/IMcpConfirmationService.cs
@@ -1,3 +1,5 @@
+using System.Runtime.CompilerServices;
+
namespace LehrerApp.Desktop.Services.Mcp;
///
@@ -9,7 +11,14 @@ namespace LehrerApp.Desktop.Services.Mcp;
///
public interface IMcpConfirmationService
{
- /// true, wenn der Nutzer bestätigt hat; false bei Ablehnung, Timeout oder falls kein
- /// Hauptfenster verfügbar ist (z.B. während des DB-Passwort-Prompts beim Start).
- Task ConfirmAsync(string title, string message, CancellationToken ct);
+ /// Identifiziert die Art der Operation für eine mögliche
+ /// Sitzungsfreigabe ("diese Aktion für den Rest der Sitzung nicht mehr nachfragen") — bewusst
+ /// per automatisch befüllt (der Name der aufrufenden
+ /// Tool-Methode, z.B. "AddLessonPhase"), damit kein Aufrufer diesen Parameter selbst pflegen
+ /// muss. Nicht Teil des MCP-Wire-Protokolls, rein internes Bestätigungs-Bookkeeping.
+ /// true, wenn der Nutzer bestätigt hat (direkt oder über eine bereits erteilte
+ /// Sitzungsfreigabe); false bei Ablehnung, Timeout oder falls kein Hauptfenster verfügbar ist
+ /// (z.B. während des DB-Passwort-Prompts beim Start).
+ Task ConfirmAsync(string title, string message, CancellationToken ct,
+ [CallerMemberName] string operationKey = "");
}
diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs
index 0f33600..d971a15 100644
--- a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs
+++ b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs
@@ -193,7 +193,7 @@ public class LessonPlanTools(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null,
- [Description("Neuer Status: Planned, Conducted, Draft oder Ready. Unverändert lassen: weglassen.")] LessonStatus? status = null,
+ [Description("Neuer Status: Planned, Conducted, Draft, Ready oder Cancelled (ausgefallen, z.B. Exkursion/Feiertag - Alternative zu delete_lesson, wenn die Stunde als Ereignis dokumentiert bleiben soll). Unverändert lassen: weglassen.")] LessonStatus? status = null,
[Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null,
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
CancellationToken ct = default)
@@ -314,8 +314,11 @@ public class LessonPlanTools(
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var oldDate = lesson.Date;
+ // Muss exakt dieselbe Schutzbedingung wie LessonSchedulingService.Move verwenden, sonst
+ // weicht die Vorschau-Zahl im Bestätigungstext von dem ab, was tatsächlich verschoben wird.
var affectedCount = shiftFollowingLessons
- ? lessons.GetByUnit(lesson.UnitId).Count(l => l.Id != lesson.Id && l.Status != LessonStatus.Conducted && l.Date > oldDate)
+ ? lessons.GetByUnit(lesson.UnitId).Count(l =>
+ l.Id != lesson.Id && l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled) && l.Date > oldDate)
: 0;
var message = $"„{lesson.Topic}“ von {oldDate:dd.MM.yyyy} auf {newDate:dd.MM.yyyy} verschieben?" +
@@ -340,7 +343,7 @@ public class LessonPlanTools(
return new WriteResultDto(true, lesson.Id, "Stunde verschoben.");
}
- [Description("Löscht eine Einzelstunde endgültig, inklusive ihrer Anhänge. Anders als die meisten anderen Löschvorgänge in LehrerApp landet eine gelöschte Stunde NICHT im Papierkorb — nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
+ [Description("Löscht eine Einzelstunde endgültig, inklusive ihrer Anhänge. Anders als die meisten anderen Löschvorgänge in LehrerApp landet eine gelöschte Stunde NICHT im Papierkorb — nicht rückgängig zu machen. Für eine Stunde, die nur ausgefallen ist (Exkursion, Feiertag, Vertretung ohne Ersatztermin) aber als Ereignis dokumentiert bleiben soll, ist update_lesson mit status=Cancelled meist die bessere Wahl. Muss der Nutzer erst bestätigen.")]
public async Task DeleteLesson(
[Description("ID der zu löschenden Einzelstunde.")] Guid lessonId,
CancellationToken ct = default)
diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs
index 9962ae2..e31b560 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs
@@ -145,7 +145,7 @@ public partial class GroupOverviewViewModel : ObservableObject
private void LoadNextLesson(DateOnly today)
{
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
- .Where(l => l.Status != LessonStatus.Conducted)
+ .Where(l => l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled))
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
.FirstOrDefault();
@@ -220,6 +220,7 @@ public partial class GroupOverviewViewModel : ObservableObject
private void LoadOpenHomeworkCheck(DateOnly today)
{
var previous = _lessons.GetByGroupAndRange(_groupId, today.AddDays(-HomeworkCheckLookbackDays), today.AddDays(-1))
+ .Where(l => l.Status != LessonStatus.Cancelled)
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
.FirstOrDefault();
diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs
index ad9e615..417f93b 100644
--- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs
+++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs
@@ -376,7 +376,7 @@ public partial class PlanningTabViewModel : ObservableObject
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
private void AdvanceLessonStatus()
{
- if (SelectedLesson is null || SelectedLesson.Model.Status == LessonStatus.Conducted) return;
+ if (SelectedLesson is null || SelectedLesson.Model.Status is LessonStatus.Conducted or LessonStatus.Cancelled) return;
var lesson = SelectedLesson.Model;
lesson.Status = lesson.Status == LessonStatus.Ready
? LessonStatus.Conducted
@@ -467,8 +467,11 @@ public class UnitSummary
};
CompetencyCountLabel = u.Competencies.Count == 0 ? "–" : $"{u.Competencies.Count} Kompetenz(en)";
- TotalCount = lessons.Count;
- ConductedCount = lessons.Count(l => l.Status == LessonStatus.Conducted);
+ // Ausgefallene Stunden zählen weder als gehalten noch als noch zu haltendes Pensum -
+ // sie fallen komplett aus dem Fortschritt heraus, statt den Nenner künstlich zu erhöhen.
+ var countableLessons = lessons.Where(l => l.Status != LessonStatus.Cancelled).ToList();
+ TotalCount = countableLessons.Count;
+ ConductedCount = countableLessons.Count(l => l.Status == LessonStatus.Conducted);
ProgressFraction = TotalCount == 0 ? 0 : (double)ConductedCount / TotalCount;
ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten";
}
@@ -507,6 +510,7 @@ public class LessonSummary
LessonStatus.Draft => "#78909C",
LessonStatus.Ready => "#1976D2",
LessonStatus.Conducted => "#43A047",
+ LessonStatus.Cancelled => "#C62828",
_ => "#9E9E9E",
};
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
@@ -543,13 +547,14 @@ public static class UnitStatusDisplay
public static class LessonStatusDisplay
{
- public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt"];
+ public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt", "Ausgefallen"];
public static string ToName(LessonStatus s) => s switch
{
LessonStatus.Draft => "Entwurf",
LessonStatus.Ready => "Bereit",
LessonStatus.Conducted => "Durchgeführt",
+ LessonStatus.Cancelled => "Ausgefallen",
_ => "Geplant",
};
@@ -558,6 +563,7 @@ public static class LessonStatusDisplay
"Entwurf" => LessonStatus.Draft,
"Bereit" => LessonStatus.Ready,
"Durchgeführt" => LessonStatus.Conducted,
+ "Ausgefallen" => LessonStatus.Cancelled,
_ => LessonStatus.Planned,
};
}
diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs
index 1fa95aa..17578c5 100644
--- a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs
+++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs
@@ -612,6 +612,7 @@ public partial class TimetableViewModel : ObservableObject
private bool HasUnhandledHomework(Guid groupId, DateOnly date)
{
var previousLesson = _lessons.GetByGroupAndRange(groupId, date.AddDays(-120), date.AddDays(-1))
+ .Where(l => l.Status != LessonStatus.Cancelled)
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
.FirstOrDefault();
if (previousLesson is null || string.IsNullOrWhiteSpace(previousLesson.Homework)) return false;
diff --git a/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml b/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml
index 3bac1f1..5d59a3f 100644
--- a/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml
+++ b/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml
@@ -6,7 +6,7 @@
Width="520" MinHeight="240" SizeToContent="Height"
CanResize="True" WindowStartupLocation="CenterOwner"
Topmost="True" ShowInTaskbar="True">
-
+
@@ -16,7 +16,13 @@
-
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml.cs b/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml.cs
index bb2d1a6..c68735a 100644
--- a/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml.cs
+++ b/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml.cs
@@ -7,6 +7,9 @@ public partial class McpConfirmDialog : Window
{
public McpConfirmDialog() => InitializeComponent();
- private void OnConfirm(object? sender, RoutedEventArgs e) => Close(true);
- private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
+ private void OnConfirm(object? sender, RoutedEventArgs e) =>
+ Close(new McpConfirmDialogResult(true, TrustOperationCheck.IsChecked == true, TrustAllCheck.IsChecked == true));
+
+ // Checkbox-Zustand wird beim Ablehnen bewusst ignoriert (siehe McpConfirmDialogResult).
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(McpConfirmDialogResult.Rejected);
}
diff --git a/LehrerApp.Desktop/Views/Mcp/McpConfirmDialogResult.cs b/LehrerApp.Desktop/Views/Mcp/McpConfirmDialogResult.cs
new file mode 100644
index 0000000..b6a0bf8
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Mcp/McpConfirmDialogResult.cs
@@ -0,0 +1,10 @@
+namespace LehrerApp.Desktop.Views.Mcp;
+
+/// Ergebnis von . /
+/// sind nur bei =true aussagekräftig — beim Ablehnen
+/// werden sie ignoriert (siehe McpConfirmDialog.axaml.cs), Ablehnen + "nicht mehr nachfragen" wäre
+/// widersprüchlich.
+public record McpConfirmDialogResult(bool Approved, bool TrustOperation, bool TrustAll)
+{
+ public static McpConfirmDialogResult Rejected { get; } = new(false, false, false);
+}
diff --git a/TODO.md b/TODO.md
index c1f7cd6..4b63742 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2684,6 +2684,45 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
stillschweigend zu überschreiben (die bestehende `EnsureTargetIsFree`-Prüfung in
`LessonSchedulingService` greift unverändert).
+- [x] **4.5.32** Sitzungsfreigabe für den Bestätigungsdialog + `LessonStatus.Cancelled`
+ (2026-09-12, Nutzer-Feedback: 17x "Phase hinzufügen" einzeln bestätigen müssen ist
+ unzumutbar).
+ - **Sitzungsfreigabe:** `McpConfirmDialog` bietet jetzt zwei Checkboxen ("diese Art von
+ Aktion" / "alle KI-Aktionen für den Rest der Sitzung nicht mehr nachfragen"), nur beim
+ Bestätigen wirksam (Ablehnen + Checkbox wäre widersprüchlich, wird ignoriert — siehe
+ `McpConfirmDialog.axaml.cs`). `IMcpConfirmationService.ConfirmAsync` bekommt einen neuen
+ `operationKey`-Parameter, bewusst per `[CallerMemberName]` automatisch befüllt (Name der
+ aufrufenden Tool-Methode, z.B. "AddLessonPhase") — kein bestehender Aufruf in den
+ Tool-Klassen musste dafür geändert werden. Freigaben leben als reines In-Memory-Bookkeeping
+ auf der `AvaloniaMcpConfirmationService`-Singleton-Instanz, gelten bis zum Beenden der App
+ (neuer Prozess = neue Instanz = wieder alles ungetraut), nie persistiert. Automatisch
+ bestätigte Aufrufe werden trotzdem geloggt (`AppLogger.Info`), damit stillschweigende
+ Bestätigungen nicht spurlos bleiben.
+ - **`LessonStatus.Cancelled`** ("Ausgefallen") als Alternative zu `delete_lesson`, wenn eine
+ Stunde nur ausgefallen ist (Exkursion, Feiertag, Vertretung), aber als Ereignis
+ dokumentiert bleiben soll — als `Cancelled = 4` angehängt (nicht eingefügt, siehe
+ Kommentar auf dem Enum: bestehende LiteDB-Werte 0/1 sind fix). `delete_lesson`s
+ Tool-Beschreibung weist jetzt aktiv auf diese Alternative hin.
+ `LessonStatusDisplay.Options`/`ToName`/`FromName` erweitert — der bestehende
+ Status-Auswahl-`ComboBox` in `LessonDialog.axaml` (bindet bereits an `StatusOptions`)
+ zeigt "Ausgefallen" dadurch automatisch an, ohne XAML-Änderung.
+ - Konsistente Behandlung wie bereits durchgeführte Stunden an allen Stellen, die bisher
+ nur auf `Conducted` prüften (eine ausgefallene Stunde ist ebenfalls "resolved", nicht
+ mehr offen): `LessonSchedulingService.Move`/`SplitAndMoveSecondPart` (nicht mitverschoben
+ bzw. Fortsetzung auf Entwurf zurückgesetzt), `GroupOverviewViewModel.LoadNextLesson`
+ (nicht als nächste Stunde vorgeschlagen), `AdvanceLessonStatus` (Endzustand, kein
+ Weiterschalten). Fortschrittsanzeige (`UnitSummary`) zählt ausgefallene Stunden weder
+ zum Pensum noch zu den gehaltenen Stunden (Nenner sinkt statt künstlich zu wachsen).
+ - `LoadOpenHomeworkCheck`/`HasUnhandledHomework` (Hausaufgabe-kontrollieren-Erinnerung)
+ überspringen ausgefallene Stunden bei der Suche nach der "letzten Stunde" — sonst hätte
+ eine ausgefallene Stunde mit liegengebliebenem Hausaufgabentext fälschlich weiter genagt.
+ - 9 neue Tests: 2 in
+ [LessonSchedulingServiceTests.cs](LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs),
+ neues [LessonStatusDisplayTests.cs](LehrerApp.Desktop.Tests/LessonStatusDisplayTests.cs)
+ (Round-Trip aller Status-Namen + Fortschrittsberechnung), 1 in
+ [McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (belegt den
+ `[CallerMemberName]`-Mechanismus ohne echtes UI).
+
**Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,