feat: schedule tab 'Automatisch' with dialog, pause, delete and run-now

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 19:19:14 +02:00
1 parent 5a4e4c2e88
commit 8978cebd17
7 files changed
+940

No files matched your search

@@ -160,4 +160,34 @@ public static class LearningFormat
timeLimitMinutes is > 0
? $"Du hast {timeLimitMinutes} Minuten. Die Uhr startet sofort und läuft weiter, auch wenn du die Seite schließt. Hinweise und Lösungen gibt es erst nach der Abgabe."
: "Hinweise und Lösungen gibt es erst nach der Abgabe.";
// ---- Automatische Übungsblätter (Phase 11c) ----
public static string ScheduleStatus(LearningScheduleStatus status) => status switch
{
LearningScheduleStatus.Created => "Blatt erzeugt",
LearningScheduleStatus.CreatedAsDraft => "Als Entwurf erzeugt",
LearningScheduleStatus.Deferred => "Aufgeschoben (Tageslimit)",
LearningScheduleStatus.Failed => "Fehlgeschlagen",
LearningScheduleStatus.Skipped => "Übersprungen",
_ => "Noch nicht gelaufen",
};
public static Color ScheduleStatusColor(LearningScheduleStatus status) => status switch
{
LearningScheduleStatus.Created => Color.Success,
LearningScheduleStatus.CreatedAsDraft or LearningScheduleStatus.Deferred or LearningScheduleStatus.Skipped => Color.Warning,
LearningScheduleStatus.Failed => Color.Error,
_ => Color.Default,
};
/// <summary>„Mo–Fr 15:00“.</summary>
public static string ScheduleTimes(LearningWeekdays days, TimeOnly time) => LearningScheduleRules.DescribeWeekdays(days) + " " + time.ToString("HH:mm", CultureInfo.InvariantCulture);
/// <summary>„Di., 13.10. 15:00“ in Europe/Berlin.</summary>
public static string LocalMoment(DateTimeOffset moment) =>
FamilyTime.ToFamilyTime(moment).ToString("ddd", German).TrimEnd('.') + "., " + FamilyTime.ToFamilyTime(moment).ToString("dd.MM. HH:mm", German);
public static string DifficultyPlan(int start, int raiseEveryRuns) =>
raiseEveryRuns > 0 ? $"{Difficulty(start)}, danach alle {(raiseEveryRuns == 1 ? "Blatt" : $"{raiseEveryRuns} Blätter")} eine Stufe schwerer" : $"{Difficulty(start)}, bleibt gleich";
}
@@ -30,6 +30,11 @@
role="tab" aria-selected="@(_tab == Tab.Sets ? "true" : "false")" Class="fp-learning-tab-sets">Blätter (@_sets.Count)</MudChip>
<MudChip T="string" Color="@(_tab == Tab.Review ? Color.Primary : _queue.Count > 0 ? Color.Warning : Color.Default)" Variant="@(_tab == Tab.Review ? Variant.Filled : Variant.Outlined)"
OnClick="() => _tab = Tab.Review" role="tab" aria-selected="@(_tab == Tab.Review ? "true" : "false")" Class="fp-learning-tab-review">Zu prüfen (@_queue.Count)</MudChip>
@if (_ai is not null)
{
<MudChip T="string" Color="@(_tab == Tab.Auto ? Color.Primary : Color.Default)" Variant="@(_tab == Tab.Auto ? Variant.Filled : Variant.Outlined)" OnClick="() => _tab = Tab.Auto"
role="tab" aria-selected="@(_tab == Tab.Auto ? "true" : "false")" Class="fp-learning-tab-auto">Automatisch</MudChip>
}
</div>
@if (_loading)
@@ -43,6 +48,10 @@
<MudButton Variant="Variant.Text" Color="Color.Inherit" OnClick="LoadAsync">Erneut versuchen</MudButton>
</MudAlert>
}
else if (_tab == Tab.Auto && _ai is not null)
{
<ScheduleTab Children="_children" CanCreate="_ai.CanGenerateLearning" IsCloud="_ai.LearningGenerationIsCloud" />
}
else if (_tab == Tab.Review)
{
@if (_queue.Count == 0)
@@ -151,6 +160,7 @@
{
Sets,
Review,
Auto,
}
private List<LearningSetDto> _sets = [];
@@ -0,0 +1,235 @@
@using System.Globalization
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Admin
@using FamilyPlanner.Shared.Learning
@inject ILearningApi Api
@* Zeitplan für automatisch erzeugte Übungsblätter anlegen oder ändern (Phase 11c). Verbindlich ist die serverseitige Prüfung; hier gelten dieselben Validatoren. *@
<MudDialog>
<DialogContent>
<EditForm Model="this" OnSubmit="SaveAsync" FormName="learning-schedule-form" id="learning-schedule-form">
<MudStack Spacing="2">
@if (IsCloud)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="fp-sched-privacy">
Datenschutz: Die KI läuft bei einem Cloud-Anbieter außerhalb des Heimnetzes. Fach, Klasse und dein Zusatzwunsch werden bei jedem automatischen Lauf dorthin gesendet –
schreibe keine Namen oder privaten Angaben hinein.
</MudAlert>
}
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="fp-dialog-error">@_error</MudAlert>
}
<div class="d-flex flex-wrap gap-2">
<MudSelect T="Guid" @bind-Value="_target" Label="Für Kind" Variant="Variant.Outlined" Class="fp-task-field fp-sched-target" Disabled="_busy"
ToStringFunc="@(id => Children.FirstOrDefault(c => c.Id == id)?.DisplayName ?? string.Empty)"
Error="@HasError("TargetUserId")" ErrorText="@ErrorFor("TargetUserId")">
@foreach (var child in Children)
{
<MudSelectItem T="Guid" Value="@child.Id">@child.DisplayName</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" @bind-Value="_subject" Label="Fach" Variant="Variant.Outlined" Class="fp-task-field fp-sched-subject" Disabled="_busy"
Error="@HasError("Subject")" ErrorText="@ErrorFor("Subject")">
@foreach (var subject in LearningSubjects.All)
{
<MudSelectItem T="string" Value="@subject">@subject</MudSelectItem>
}
</MudSelect>
</div>
<div class="d-flex flex-wrap gap-2">
<MudSelect T="int" @bind-Value="_gradeLevel" Label="Klassenstufe" Variant="Variant.Outlined" Class="fp-task-field fp-sched-grade" Disabled="_busy"
ToStringFunc="@(g => LearningFormat.GradeLevel(g))" Error="@HasError("GradeLevel")" ErrorText="@ErrorFor("GradeLevel")">
@for (var level = LearningSet.MinGradeLevel; level <= LearningSet.MaxGradeLevel; level++)
{
<MudSelectItem T="int" Value="@level">@LearningFormat.GradeLevel(level)</MudSelectItem>
}
</MudSelect>
<MudNumericField T="int" @bind-Value="_count" Label="Aufgaben pro Blatt" Min="LearningSchedule.MinCount" Max="LearningSchedule.MaxCount" Variant="Variant.Outlined"
Class="fp-task-field fp-sched-count" Disabled="_busy" Error="@HasError("ItemCount")" ErrorText="@ErrorFor("ItemCount")"
HelperText="@($"{LearningSchedule.MinCount} bis {LearningSchedule.MaxCount}")" />
</div>
<div class="d-flex flex-wrap gap-2">
<MudSelect T="int" @bind-Value="_start" Label="Start-Schwierigkeit" Variant="Variant.Outlined" Class="fp-task-field fp-sched-start" Disabled="_busy"
ToStringFunc="@(d => LearningFormat.Difficulty(d))" Error="@HasError("StartDifficulty")" ErrorText="@ErrorFor("StartDifficulty")">
@for (var d = 1; d <= 3; d++)
{
<MudSelectItem T="int" Value="@d">@LearningFormat.Difficulty(d)</MudSelectItem>
}
</MudSelect>
<MudNumericField T="int" @bind-Value="_raise" Label="Eine Stufe schwerer nach … Blättern" Min="0" Max="LearningSchedule.MaxRaiseEveryRuns" Variant="Variant.Outlined"
Class="fp-task-field fp-sched-raise" Disabled="_busy" Error="@HasError("RaiseEveryRuns")" ErrorText="@ErrorFor("RaiseEveryRuns")"
HelperText="0 = Schwierigkeit bleibt gleich. Höchstens „Schwer“." />
</div>
<fieldset class="fp-sched-days-box">
<legend class="mud-typography mud-typography-subtitle2">Wochentage</legend>
<div class="d-flex flex-wrap gap-1 fp-sched-days" role="group" aria-label="Wochentage">
@for (var i = 0; i < DayNames.Length; i++)
{
var day = i;
var flag = (LearningWeekdays)(1 << day);
var selected = (_weekdays & flag) != 0;
<MudChip T="string" Size="Size.Medium" Color="@(selected ? Color.Primary : Color.Default)" Variant="@(selected ? Variant.Filled : Variant.Outlined)"
OnClick="() => ToggleDay(flag)" Disabled="_busy" role="checkbox" aria-checked="@(selected ? "true" : "false")"
Class="@($"fp-sched-day fp-sched-day-{day}")">@DayNames[day]</MudChip>
}
</div>
<div class="d-flex flex-wrap gap-1 mt-1">
<MudButton Size="Size.Small" Variant="Variant.Text" OnClick="() => _weekdays = LearningWeekdays.All" Disabled="_busy" Class="fp-sched-preset-all">Täglich</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Text" OnClick="() => _weekdays = LearningWeekdays.Weekdays" Disabled="_busy" Class="fp-sched-preset-weekdays">Mo–Fr</MudButton>
</div>
@if (HasError("Weekdays"))
{
<MudText Typo="Typo.caption" Color="Color.Error" Class="fp-sched-days-error">@ErrorFor("Weekdays")</MudText>
}
</fieldset>
<MudTextField T="string" @bind-Value="_time" InputType="InputType.Time" Label="Uhrzeit (Berlin)" Variant="Variant.Outlined" Class="fp-sched-time" Disabled="_busy"
Error="@HasError("TimeOfDay")" ErrorText="@ErrorFor("TimeOfDay")" />
<MudTextField @bind-Value="_extra" Label="Zusatzwunsch (optional)" Variant="Variant.Outlined" Lines="2" Class="fp-sched-extra" Disabled="_busy"
MaxLength="LearningScheduleLimits.ExtraMaxLength" Counter="LearningScheduleLimits.ExtraMaxLength" Immediate="true"
HelperText="Thema oder Stil – die KI behandelt das nur als Wunsch." Error="@HasError("Extra")" ErrorText="@ErrorFor("Extra")" />
<MudSwitch T="bool" @bind-Value="_autoAssign" Label="Blatt direkt dem Kind zuweisen" Color="Color.Warning" Disabled="_busy" Class="fp-sched-assign" />
@if (_autoAssign)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="fp-sched-assign-warning">
Achtung: Das Kind sieht das Blatt sofort, ohne dass du es vorher durchgesehen hast. Die KI-Aufgaben werden zwar geprüft und Rechenaufgaben nachgerechnet,
sie können aber trotzdem Fehler enthalten. Ohne diese Option landet jedes Blatt als Entwurf bei dir zur Prüfung.
</MudAlert>
}
else
{
<MudText Typo="Typo.body2" Class="fp-sched-draft-hint">Jedes Blatt landet als <b>Entwurf</b> bei dir und geht erst nach deiner Prüfung an das Kind.</MudText>
}
<MudSwitch T="bool" @bind-Value="_active" Label="Zeitplan aktiv" Color="Color.Primary" Disabled="_busy" Class="fp-sched-active" />
</MudStack>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_busy" Class="fp-sched-cancel">Abbrechen</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="@(_busy || Children.Count == 0)" Class="fp-sched-save">Speichern</MudButton>
</DialogActions>
</MudDialog>
@code {
private static readonly string[] DayNames = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
[Parameter, EditorRequired]
public IReadOnlyList<UserDirectoryEntry> Children { get; set; } = [];
/// <summary>Der Anbieter liegt außerhalb des Heimnetzes: Datenschutzhinweis anzeigen.</summary>
[Parameter]
public bool IsCloud { get; set; }
/// <summary>Zeitplan zum Ändern; <c>null</c> = neuer Zeitplan.</summary>
[Parameter]
public LearningScheduleDto? Existing { get; set; }
private Guid _target;
private string _subject = LearningSubjects.Math;
private int _gradeLevel = 3;
private int _count = 10;
private int _start = 1;
private int _raise = 4;
private LearningWeekdays _weekdays = LearningWeekdays.Weekdays;
private string _time = "15:00";
private string? _extra;
private bool _autoAssign;
private bool _active = true;
private bool _busy;
private string? _error;
private Dictionary<string, string> _errors = [];
protected override void OnInitialized()
{
if (Existing is { } e)
{
(_target, _subject, _gradeLevel, _count, _start, _raise) = (e.TargetUserId, e.Subject, e.GradeLevel, e.ItemCount, e.StartDifficulty, e.RaiseEveryRuns);
(_weekdays, _extra, _autoAssign, _active) = (e.Weekdays, e.Extra, e.AutoAssign, e.IsActive);
_time = e.TimeOfDay.ToString("HH:mm", CultureInfo.InvariantCulture);
}
else if (Children.Count > 0)
{
_target = Children[0].Id;
}
}
private void ToggleDay(LearningWeekdays flag) => _weekdays ^= flag;
private bool HasError(string field) => _errors.ContainsKey(field);
private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);
private async Task SaveAsync()
{
if (_busy)
{
return;
}
_error = null;
_errors = [];
if (!TimeOnly.TryParseExact(_time, ["HH:mm", "H:mm", "HH:mm:ss"], CultureInfo.InvariantCulture, DateTimeStyles.None, out var time))
{
_errors["TimeOfDay"] = "Bitte eine Uhrzeit angeben.";
return;
}
var request = new SaveLearningScheduleRequest(
_target, _subject, _gradeLevel, _count, _start, _raise, string.IsNullOrWhiteSpace(_extra) ? null : _extra.Trim(),
_weekdays, time, _autoAssign, _active, Existing?.Version);
foreach (var failure in new SaveLearningScheduleRequestValidator().Validate(request).Errors)
{
_errors.TryAdd(failure.PropertyName, failure.ErrorMessage);
}
if (_errors.Count > 0)
{
return;
}
_busy = true;
try
{
var response = Existing is null ? await Api.CreateScheduleAsync(request) : await Api.UpdateScheduleAsync(Existing.Id, request);
if (response.Succeeded && response.Value is { } saved)
{
Dialog?.Close(DialogResult.Ok(saved));
}
else if (response.Problem?.Errors is { Count: > 0 } errors)
{
foreach (var (field, messages) in errors)
{
_errors[field] = string.Join(" ", messages);
}
_error = "Bitte die Eingaben prüfen.";
}
else if (response.Problem?.Status == 409 && Existing is not null)
{
_error = "Der Zeitplan wurde inzwischen geändert. Bitte schließe den Dialog und öffne ihn neu.";
}
else
{
_error = response.Problem?.Message ?? "Der Zeitplan konnte nicht gespeichert werden.";
}
}
finally
{
_busy = false;
}
}
private void Cancel() => Dialog?.Close(DialogResult.Cancel());
}
@@ -0,0 +1,245 @@
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Admin
@using FamilyPlanner.Shared.Learning
@inject ILearningApi Api
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@* Reiter „Automatisch“ (Phase 11c): Zeitpläne für automatisch erzeugte Übungsblätter. Alle KI-/Fehlertexte werden nur als Text angezeigt. *@
<section class="fp-sched" aria-label="Automatische Übungsblätter">
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.body2" Class="fp-sched-intro">
Die App erzeugt zur eingestellten Zeit selbst ein neues Blatt per KI. Standardmäßig landet es als <b>Entwurf</b> bei dir.
</MudText>
<MudSpacer />
@if (CanCreate)
{
<MudButton OnClick="() => OpenDialogAsync(null)" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Disabled="@(Children.Count == 0)" Class="fp-sched-new">
Neuer Zeitplan
</MudButton>
}
</div>
@if (!CanCreate)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2 fp-sched-no-ai">
Es ist noch keine KI für Übungsblätter eingerichtet (Verwaltung → KI). Bestehende Zeitpläne lassen sich ansehen, pausieren und löschen.
</MudAlert>
}
@if (_error is not null)
{
<MudAlert Severity="Severity.Warning" role="alert" Class="mb-2 fp-sched-error">@_error</MudAlert>
}
@if (_loading)
{
<LoadingIndicator />
}
else if (_schedules.Count == 0)
{
<MudText Class="my-6 text-center fp-sched-empty">Noch kein Zeitplan. Lege einen an, damit jede Woche automatisch neue Aufgaben bereitliegen.</MudText>
}
else
{
<ul class="fp-sched-list" aria-label="Zeitpläne">
@foreach (var schedule in _schedules)
{
var item = schedule;
var running = _running == item.Id;
<li class="fp-sched-item" data-schedule-id="@item.Id">
<div class="d-flex align-center flex-wrap gap-2">
<span class="fp-sched-title">@item.Subject · @LearningFormat.GradeLevel(item.GradeLevel) · @item.ChildName</span>
@if (!item.IsActive)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Class="fp-sched-paused">Pausiert</MudChip>
}
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@(item.AutoAssign ? Color.Warning : Color.Default)" Class="fp-sched-mode">
@(item.AutoAssign ? "Direkt zuweisen" : "Entwurf zur Prüfung")
</MudChip>
</div>
<div class="fp-learning-meta fp-sched-meta">
@LearningFormat.ScheduleTimes(item.Weekdays, item.TimeOfDay) · @(item.ItemCount == 1 ? "1 Aufgabe" : $"{item.ItemCount} Aufgaben") ·
Schwierigkeit: @LearningFormat.DifficultyPlan(item.StartDifficulty, item.RaiseEveryRuns) (aktuell @LearningFormat.Difficulty(item.CurrentDifficulty))
</div>
<div class="fp-learning-meta fp-sched-next">
@if (item.NextRunAt is { } next)
{
<span>Nächster Lauf: @(item.RetryAfter is { } retry && retry > DateTimeOffset.UtcNow ? $"ab {LearningFormat.LocalMoment(retry)} (erneuter Versuch)" : LearningFormat.LocalMoment(next))</span>
}
else
{
<span>Kein Lauf geplant (pausiert).</span>
}
</div>
<div class="fp-learning-meta fp-sched-last">
<MudChip T="string" Size="Size.Small" Color="@LearningFormat.ScheduleStatusColor(item.LastStatus)" Variant="Variant.Outlined" Class="fp-sched-status">
@LearningFormat.ScheduleStatus(item.LastStatus)
</MudChip>
@if (item.LastRunAt is { } last)
{
<span> @LearningFormat.LocalMoment(last)</span>
}
@if (!string.IsNullOrWhiteSpace(item.LastMessage))
{
<span class="fp-sched-message"> – @item.LastMessage</span>
}
@if (item.LastSetId is { } setId)
{
<MudLink Href="@($"lernen/blatt/{setId}")" Class="fp-sched-open-set">Letztes Blatt ansehen</MudLink>
}
</div>
<div class="d-flex align-center flex-wrap gap-1 fp-sched-actions">
<MudButton Variant="Variant.Outlined" Size="Size.Small" StartIcon="@Icons.Material.Filled.PlayArrow" OnClick="() => RunAsync(item)"
Disabled="@(_running is not null || !CanCreate)" Class="fp-sched-run">
@(running ? "KI erstellt das Blatt …" : "Jetzt ausführen")
</MudButton>
<MudSwitch T="bool" Value="item.IsActive" ValueChanged="(bool on) => SetActiveAsync(item, on)" Color="Color.Primary" Label="Aktiv" Disabled="@(_running is not null)" Class="fp-sched-toggle" />
<MudSpacer />
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="() => OpenDialogAsync(item)" Disabled="@(!CanCreate)" aria-label="Zeitplan ändern" Class="fp-sched-edit" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => DeleteAsync(item)" aria-label="Zeitplan löschen" Class="fp-sched-delete" />
</div>
@if (running)
{
<div class="d-flex align-center gap-2 mt-1 fp-sched-busy" role="status" aria-live="polite">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span>Das kann bis zu einer Minute dauern.</span>
</div>
}
</li>
}
</ul>
}
</section>
@code {
/// <summary>Kinder, für die Zeitpläne angelegt werden können.</summary>
[Parameter, EditorRequired]
public IReadOnlyList<UserDirectoryEntry> Children { get; set; } = [];
/// <summary>Die KI für Übungsblätter ist eingerichtet (<c>CanGenerateLearning</c>): neu anlegen und ausführen.</summary>
[Parameter]
public bool CanCreate { get; set; }
[Parameter]
public bool IsCloud { get; set; }
private List<LearningScheduleDto> _schedules = [];
private bool _loading = true;
private string? _error;
private Guid? _running;
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
var result = await Api.GetSchedulesAsync();
if (result.Succeeded && result.Value is { } list)
{
_schedules = list;
_error = null;
}
else if (_schedules.Count == 0)
{
_error = result.Problem?.Message ?? "Die Zeitpläne konnten nicht geladen werden.";
}
_loading = false;
}
private async Task OpenDialogAsync(LearningScheduleDto? existing)
{
var parameters = new DialogParameters<ScheduleDialog>
{
{ x => x.Children, Children },
{ x => x.IsCloud, IsCloud },
{ x => x.Existing, existing },
};
var dialog = await DialogService.ShowAsync<ScheduleDialog>(
existing is null ? "Neuer Zeitplan" : "Zeitplan ändern", parameters, new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseOnEscapeKey = true });
var result = await dialog.Result;
if (result is { Canceled: false })
{
await LoadAsync();
}
}
private async Task SetActiveAsync(LearningScheduleDto schedule, bool active)
{
_error = null;
var request = new SaveLearningScheduleRequest(
schedule.TargetUserId, schedule.Subject, schedule.GradeLevel, schedule.ItemCount, schedule.StartDifficulty, schedule.RaiseEveryRuns, schedule.Extra,
schedule.Weekdays, schedule.TimeOfDay, schedule.AutoAssign, active, schedule.Version);
var result = await Api.UpdateScheduleAsync(schedule.Id, request);
var message = result.Succeeded ? null : result.Problem?.Message ?? "Der Zeitplan konnte nicht geändert werden.";
await LoadAsync();
_error = message;
}
private async Task DeleteAsync(LearningScheduleDto schedule)
{
var confirmed = await DialogService.ShowMessageBoxAsync(
"Zeitplan löschen?",
$"Den Zeitplan „{schedule.Subject} für {schedule.ChildName}“ wirklich löschen? Bereits erzeugte Blätter bleiben bestehen.",
yesText: "Löschen", cancelText: "Abbrechen");
if (confirmed != true)
{
return;
}
var result = await Api.DeleteScheduleAsync(schedule.Id);
if (result.Succeeded)
{
_schedules.RemoveAll(s => s.Id == schedule.Id);
_error = null;
}
else
{
_error = result.Problem?.Message ?? "Der Zeitplan konnte nicht gelöscht werden.";
}
}
private async Task RunAsync(LearningScheduleDto schedule)
{
if (_running is not null)
{
return;
}
var confirmed = await DialogService.ShowMessageBoxAsync(
"Jetzt ein Blatt erzeugen?",
$"Die KI erstellt jetzt ein neues Blatt für {schedule.ChildName}. Das verbraucht einen KI-Aufruf deines Tageslimits. "
+ (schedule.AutoAssign ? "Das Blatt wird direkt zugewiesen." : "Das Blatt landet als Entwurf bei dir."),
yesText: "Jetzt erzeugen", cancelText: "Abbrechen");
if (confirmed != true)
{
return;
}
_running = schedule.Id;
_error = null;
StateHasChanged(); // Wartezustand sofort zeigen (die Dialog-Fortsetzung rendert nicht von selbst)
try
{
var result = await Api.RunScheduleAsync(schedule.Id);
if (result.Succeeded && result.Value is { } run)
{
Snackbar.Add(run.Message, Severity.Success);
Navigation.NavigateTo($"lernen/blatt/{run.SetId}");
return;
}
var message = result.Problem?.Message ?? "Das Blatt konnte nicht erzeugt werden.";
await LoadAsync(); // der Lauf hat den Zeitplan (Status, Meldung) ggf. verändert
_error = message;
}
finally
{
_running = null;
}
}
}
@@ -61,6 +61,19 @@ public interface ILearningApi
/// </summary>
Task<ApiResult<LearningAiGradeResultDto>> AiGradeAsync(Guid attemptId, LearningAiGradeRequest request, CancellationToken ct = default);
// ---- Automatische Übungsblätter (Phase 11c, nur mit LearningManage und AiUse) ----
Task<ApiResult<List<LearningScheduleDto>>> GetSchedulesAsync(CancellationToken ct = default);
Task<ApiResult<LearningScheduleDto>> CreateScheduleAsync(SaveLearningScheduleRequest request, CancellationToken ct = default);
/// <summary>Bei 409 (veralteter Stand) steht die Meldung in <see cref="ApiResult{T}.Problem"/>; die Oberfläche lädt dann neu.</summary>
Task<ApiResult<LearningScheduleDto>> UpdateScheduleAsync(Guid id, SaveLearningScheduleRequest request, CancellationToken ct = default);
Task<ApiResult<bool>> DeleteScheduleAsync(Guid id, CancellationToken ct = default);
/// <summary>„Jetzt ausführen“: erzeugt sofort ein Blatt (ein KI-Aufruf, kann dauern). Fehler (Tageslimit, keine KI …) stehen im Problem.</summary>
Task<ApiResult<LearningScheduleRunResultDto>> RunScheduleAsync(Guid id, CancellationToken ct = default);
// ---- Kinder ----
Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default);
@@ -168,6 +181,35 @@ public sealed class LearningApi(HttpClient http) : ILearningApi
}
}
public Task<ApiResult<List<LearningScheduleDto>>> GetSchedulesAsync(CancellationToken ct = default) =>
Send<List<LearningScheduleDto>>(() => http.GetAsync($"{Base}/schedules", ct), ct);
public Task<ApiResult<LearningScheduleDto>> CreateScheduleAsync(SaveLearningScheduleRequest request, CancellationToken ct = default) =>
Send<LearningScheduleDto>(() => http.PostAsJsonAsync($"{Base}/schedules", request, ct), ct);
public Task<ApiResult<LearningScheduleDto>> UpdateScheduleAsync(Guid id, SaveLearningScheduleRequest request, CancellationToken ct = default) =>
Send<LearningScheduleDto>(() => http.PutAsJsonAsync($"{Base}/schedules/{id}", request, ct), ct);
public Task<ApiResult<bool>> DeleteScheduleAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{Base}/schedules/{id}", ct), ct);
public async Task<ApiResult<LearningScheduleRunResultDto>> RunScheduleAsync(Guid id, CancellationToken ct = default)
{
try
{
return await Send<LearningScheduleRunResultDto>(() => http.PostAsync($"{Base}/schedules/{id}/run", null, ct), ct);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Zeitlimit des Browsers (langsame KI): der Server arbeitet eventuell weiter, ein fertiges Blatt erscheint dann in der Liste.
return ApiResult<LearningScheduleRunResultDto>.Fail(new ApiProblem
{
Status = 504,
Detail = "Die KI braucht zu lange. Ein fertiges Blatt erscheint gleich in der Liste; sonst versuche es später noch einmal.",
});
}
}
public Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default) =>
Send<List<MyLearningSetDto>>(() => http.GetAsync($"{Base}/my", ct), ct);
@@ -209,6 +209,78 @@ internal sealed class FakeLearningApi : ILearningApi
return Result(() => detail);
}
// ---- Automatische Übungsblätter (Phase 11c) ----
public List<LearningScheduleDto> Schedules { get; } = [];
public List<(Guid? Id, SaveLearningScheduleRequest Request)> ScheduleSaves { get; } = [];
public List<Guid> ScheduleRuns { get; } = [];
public List<Guid> ScheduleDeletes { get; } = [];
/// <summary>Antwort auf „Jetzt ausführen“; Standard: Blatt erzeugt.</summary>
public Func<Guid, ApiResult<LearningScheduleRunResultDto>>? ScheduleRunBehavior { get; set; }
/// <summary>Wird bei „Jetzt ausführen“ abgewartet (Wartezustand); <c>null</c> = sofort.</summary>
public TaskCompletionSource? ScheduleRunGate { get; set; }
public static LearningScheduleDto Schedule(
Guid? child = null, string childName = "Mia", bool active = true, bool autoAssign = false, LearningScheduleStatus last = LearningScheduleStatus.Never, string? message = null) =>
new(Guid.NewGuid(), child ?? Mia, childName, LearningSubjects.Math, 3, 10, 1, 4, 1, null, LearningWeekdays.Weekdays, new TimeOnly(15, 0), autoAssign, active,
active ? new DateTimeOffset(2026, 10, 13, 13, 0, 0, TimeSpan.Zero) : null, null, last == LearningScheduleStatus.Never ? null : Now, last, message, null, 0, [1]);
public Task<ApiResult<List<LearningScheduleDto>>> GetSchedulesAsync(CancellationToken ct = default) =>
Task.FromResult(Offline ? ApiResult<List<LearningScheduleDto>>.Fail(ApiProblem.Offline) : ApiResult<List<LearningScheduleDto>>.Ok([.. Schedules]));
public Task<ApiResult<LearningScheduleDto>> CreateScheduleAsync(SaveLearningScheduleRequest request, CancellationToken ct = default)
{
ScheduleSaves.Add((null, request));
var created = FromRequest(Guid.NewGuid(), request);
Schedules.Add(created);
return Task.FromResult(ApiResult<LearningScheduleDto>.Ok(created));
}
public Task<ApiResult<LearningScheduleDto>> UpdateScheduleAsync(Guid id, SaveLearningScheduleRequest request, CancellationToken ct = default)
{
ScheduleSaves.Add((id, request));
var index = Schedules.FindIndex(s => s.Id == id);
if (index < 0)
{
return Task.FromResult(ApiResult<LearningScheduleDto>.Fail(new ApiProblem { Status = 404 }));
}
Schedules[index] = FromRequest(id, request);
return Task.FromResult(ApiResult<LearningScheduleDto>.Ok(Schedules[index]));
}
public Task<ApiResult<bool>> DeleteScheduleAsync(Guid id, CancellationToken ct = default)
{
ScheduleDeletes.Add(id);
Schedules.RemoveAll(s => s.Id == id);
return Task.FromResult(ApiResult<bool>.Ok(true));
}
public async Task<ApiResult<LearningScheduleRunResultDto>> RunScheduleAsync(Guid id, CancellationToken ct = default)
{
ScheduleRuns.Add(id);
if (ScheduleRunGate is { } gate)
{
await gate.Task;
}
if (ScheduleRunBehavior is { } behavior)
{
return behavior(id);
}
var detail = AddSet("Automatisch (12.10.)", itemCount: 3);
return ApiResult<LearningScheduleRunResultDto>.Ok(new LearningScheduleRunResultDto(Schedules.Single(s => s.Id == id), detail.Set.Id, false, "Blatt als Entwurf erzeugt – bitte prüfen."));
}
private LearningScheduleDto FromRequest(Guid id, SaveLearningScheduleRequest r) =>
new(id, r.TargetUserId, Users.FirstOrDefault(u => u.Id == r.TargetUserId)?.DisplayName ?? "?", r.Subject, r.GradeLevel, r.ItemCount, r.StartDifficulty, r.RaiseEveryRuns, r.StartDifficulty,
r.Extra, r.Weekdays, r.TimeOfDay, r.AutoAssign, r.IsActive, r.IsActive ? Now.AddDays(1) : null, null, null, LearningScheduleStatus.Never, null, null, 0, [2]);
// ---- KI-Entwurf (Phase 10b) ----
public List<CreateAiDraftRequest> AiDrafts { get; } = [];
@@ -0,0 +1,306 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Ai;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
using FamilyPlanner.Web.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Reiter „Automatisch“ (Phase 11c): Liste, Dialog, Pause, Löschen, „Jetzt ausführen“, Fehlertexte nur als Text.</summary>
public sealed class ScheduleTests : LearningTestBase
{
private string CurrentUri => Services.GetRequiredService<NavigationManager>().Uri;
private IRenderedComponent<LearningHost> OpenTab(AiStatusDto? status = null)
{
AiApi.Status = status ?? new AiStatusDto(true, true, false);
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
var cut = Render<LearningHost>(p => p.AddChildContent<ParentOverview>());
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-learning-tab-auto")));
cut.Find(".fp-learning-tab-auto").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched")));
return cut;
}
private static void Confirm(IRenderedComponent<LearningHost> cut, string button)
{
cut.WaitForAssertion(() => Assert.Contains(cut.FindAll("button"), b => Text(b) == button));
cut.FindAll("button").First(b => Text(b) == button).Click();
}
// ---------- Sichtbarkeit und Liste ----------
[Fact]
public void The_tab_is_hidden_without_ai_access()
{
AiApi.Status = null;
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
var cut = Render<LearningHost>(p => p.AddChildContent<ParentOverview>());
cut.WaitForAssertion(() => Assert.True(AiApi.StatusCalls > 0));
Assert.Empty(cut.FindAll(".fp-learning-tab-auto"));
Assert.NotEmpty(cut.FindAll(".fp-learning-new"));
}
[Fact]
public void An_empty_list_explains_what_to_do()
{
var cut = OpenTab();
Assert.Contains("Noch kein Zeitplan", cut.Find(".fp-sched-empty").TextContent);
Assert.NotEmpty(cut.FindAll(".fp-sched-new"));
}
[Fact]
public void Schedules_show_times_mode_difficulty_next_run_and_the_last_result()
{
Api.Schedules.Add(FakeLearningApi.Schedule(last: LearningScheduleStatus.Created, message: "Blatt als Entwurf erzeugt – bitte prüfen.") with { LastSetId = Guid.NewGuid(), RunCount = 2 });
Api.Schedules.Add(FakeLearningApi.Schedule(FakeLearningApi.Ben, "Ben", active: false, autoAssign: true));
var cut = OpenTab();
var items = cut.FindAll(".fp-sched-item");
Assert.Equal(2, items.Count);
Assert.Contains("Mathe · 3. Klasse · Mia", Text(items[0].QuerySelector(".fp-sched-title")!));
Assert.Contains("Mo–Fr 15:00", Text(items[0].QuerySelector(".fp-sched-meta")!));
Assert.Contains("10 Aufgaben", Text(items[0].QuerySelector(".fp-sched-meta")!));
Assert.Contains("Leicht, danach alle 4 Blätter eine Stufe schwerer", Text(items[0].QuerySelector(".fp-sched-meta")!));
Assert.Equal("Entwurf zur Prüfung", Text(items[0].QuerySelector(".fp-sched-mode")!));
Assert.Equal("Nächster Lauf: Di., 13.10. 15:00", Text(items[0].QuerySelector(".fp-sched-next")!));
Assert.Contains("Blatt erzeugt", Text(items[0].QuerySelector(".fp-sched-last")!));
Assert.Contains("bitte prüfen", Text(items[0].QuerySelector(".fp-sched-message")!));
Assert.NotEmpty(items[0].QuerySelectorAll(".fp-sched-open-set"));
Assert.Contains("Pausiert", Text(items[1].QuerySelector(".fp-sched-paused")!));
Assert.Equal("Direkt zuweisen", Text(items[1].QuerySelector(".fp-sched-mode")!));
Assert.Contains("Kein Lauf geplant", Text(items[1].QuerySelector(".fp-sched-next")!));
Assert.Contains("Noch nicht gelaufen", Text(items[1].QuerySelector(".fp-sched-status")!));
}
[Fact]
public void Messages_of_the_last_run_are_shown_as_plain_text()
{
Api.Schedules.Add(FakeLearningApi.Schedule(last: LearningScheduleStatus.Failed, message: "<b>fett</b><img src=x onerror=alert(1)>"));
var cut = OpenTab();
Assert.Equal("– <b>fett</b><img src=x onerror=alert(1)>", Text(cut.Find(".fp-sched-message")));
Assert.Empty(cut.FindAll(".fp-sched-message b"));
Assert.Empty(cut.FindAll(".fp-sched-message img"));
}
[Fact]
public void Without_a_generation_provider_creating_and_running_are_unavailable_but_pausing_stays_possible()
{
Api.Schedules.Add(FakeLearningApi.Schedule());
var cut = OpenTab(new AiStatusDto(true, false, false));
Assert.Empty(cut.FindAll(".fp-sched-new"));
Assert.NotEmpty(cut.FindAll(".fp-sched-no-ai"));
Assert.True(cut.Find(".fp-sched-run").HasAttribute("disabled"));
Assert.NotEmpty(cut.FindAll(".fp-sched-toggle"));
Assert.NotEmpty(cut.FindAll(".fp-sched-delete"));
}
// ---------- Anlegen und Ändern ----------
[Fact]
public void Creating_a_schedule_sends_the_entered_values_and_lists_it()
{
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
var cut = OpenTab();
cut.Find(".fp-sched-new").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched-save")));
Assert.Empty(cut.FindAll(".fp-sched-privacy"));
Assert.NotEmpty(cut.FindAll(".fp-sched-draft-hint")); // Standard: Entwurf zur Prüfung
Assert.Empty(cut.FindAll(".fp-sched-assign-warning"));
cut.Find(".fp-sched-count input").Change("12");
cut.Find(".fp-sched-raise input").Change("2");
cut.Find(".fp-sched-time input").Change("16:30");
cut.Find(".fp-sched-save").Click();
cut.WaitForAssertion(() => Assert.Single(Api.ScheduleSaves));
var (id, request) = Api.ScheduleSaves[0];
Assert.Null(id);
Assert.Equal((FakeLearningApi.Mia, LearningSubjects.Math, 3, 12, 1, 2), (request.TargetUserId, request.Subject, request.GradeLevel, request.ItemCount, request.StartDifficulty, request.RaiseEveryRuns));
Assert.Equal((LearningWeekdays.Weekdays, new TimeOnly(16, 30), false, true), (request.Weekdays, request.TimeOfDay, request.AutoAssign, request.IsActive));
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-sched-item")));
}
[Fact]
public void Weekday_chips_and_presets_change_the_selection()
{
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
var cut = OpenTab();
cut.Find(".fp-sched-new").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched-day")));
cut.Find(".fp-sched-preset-all").Click();
cut.Find(".fp-sched-day-1").Click(); // Dienstag abwählen
cut.Find(".fp-sched-save").Click();
cut.WaitForAssertion(() => Assert.Single(Api.ScheduleSaves));
Assert.Equal(LearningWeekdays.All & ~LearningWeekdays.Tuesday, Api.ScheduleSaves[0].Request.Weekdays);
}
[Fact]
public void Direct_assignment_shows_a_warning_and_is_sent_only_when_chosen()
{
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
var cut = OpenTab();
cut.Find(".fp-sched-new").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched-assign")));
cut.Find(".fp-sched-assign input").Change(true);
cut.WaitForAssertion(() => Assert.Contains("ohne dass du es vorher durchgesehen hast", cut.Find(".fp-sched-assign-warning").TextContent));
cut.Find(".fp-sched-save").Click();
cut.WaitForAssertion(() => Assert.Single(Api.ScheduleSaves));
Assert.True(Api.ScheduleSaves[0].Request.AutoAssign);
}
[Fact]
public void Invalid_input_is_reported_without_calling_the_api()
{
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
var cut = OpenTab();
cut.Find(".fp-sched-new").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched-day")));
// alle Tage abwählen (Standard Mo–Fr)
foreach (var index in new[] { 0, 1, 2, 3, 4 })
{
cut.Find($".fp-sched-day-{index}").Click();
}
cut.Find(".fp-sched-save").Click();
cut.WaitForAssertion(() => Assert.Contains("mindestens einen Wochentag", cut.Find(".fp-sched-days-error").TextContent));
Assert.Empty(Api.ScheduleSaves);
}
[Fact]
public void The_cloud_privacy_note_appears_for_cloud_providers()
{
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
var cut = OpenTab(new AiStatusDto(true, true, false, LearningGenerationIsCloud: true));
cut.Find(".fp-sched-new").Click();
cut.WaitForAssertion(() => Assert.Contains("Cloud-Anbieter", cut.Find(".fp-sched-privacy").TextContent));
}
[Fact]
public void Editing_a_schedule_prefills_the_dialog_and_sends_the_version()
{
var existing = FakeLearningApi.Schedule(autoAssign: false) with { ItemCount = 7, Weekdays = LearningWeekdays.Monday | LearningWeekdays.Wednesday, TimeOfDay = new TimeOnly(9, 5) };
Api.Schedules.Add(existing);
var cut = OpenTab();
cut.Find(".fp-sched-edit").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched-save")));
Assert.Equal("7", cut.Find(".fp-sched-count input").GetAttribute("value"));
Assert.Equal("09:05", cut.Find(".fp-sched-time input").GetAttribute("value"));
cut.Find(".fp-sched-save").Click();
cut.WaitForAssertion(() => Assert.Single(Api.ScheduleSaves));
var (id, request) = Api.ScheduleSaves[0];
Assert.Equal(existing.Id, id);
Assert.Equal(existing.Version, request.Version);
Assert.Equal((7, LearningWeekdays.Monday | LearningWeekdays.Wednesday, new TimeOnly(9, 5)), (request.ItemCount, request.Weekdays, request.TimeOfDay));
}
// ---------- Pause, Löschen ----------
[Fact]
public void The_switch_pauses_and_resumes_a_schedule()
{
var existing = FakeLearningApi.Schedule();
Api.Schedules.Add(existing);
var cut = OpenTab();
cut.Find(".fp-sched-toggle input").Change(false);
cut.WaitForAssertion(() => Assert.Single(Api.ScheduleSaves));
Assert.Equal((existing.Id, false, existing.Version), (Api.ScheduleSaves[0].Id, Api.ScheduleSaves[0].Request.IsActive, Api.ScheduleSaves[0].Request.Version));
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-sched-paused")));
}
[Fact]
public void Deleting_asks_first_and_removes_the_schedule()
{
var existing = FakeLearningApi.Schedule();
Api.Schedules.Add(existing);
var cut = OpenTab();
cut.Find(".fp-sched-delete").Click();
Confirm(cut, "Abbrechen");
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".mud-dialog")));
Assert.Empty(Api.ScheduleDeletes);
cut.Find(".fp-sched-delete").Click();
Confirm(cut, "Löschen");
cut.WaitForAssertion(() => Assert.Equal([existing.Id], Api.ScheduleDeletes));
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-sched-item")));
}
// ---------- Jetzt ausführen ----------
[Fact]
public void Run_now_confirms_shows_the_waiting_state_and_opens_the_new_sheet()
{
var existing = FakeLearningApi.Schedule();
Api.Schedules.Add(existing);
Api.ScheduleRunGate = new TaskCompletionSource();
var cut = OpenTab();
cut.Find(".fp-sched-run").Click();
Confirm(cut, "Jetzt erzeugen");
cut.WaitForAssertion(() => Assert.Contains("KI erstellt das Blatt", cut.Find(".fp-sched-run").TextContent));
Assert.NotEmpty(cut.FindAll(".fp-sched-busy"));
Assert.True(cut.Find(".fp-sched-run").HasAttribute("disabled")); // kein zweiter Lauf während der Wartezeit
Assert.True(cut.Find(".fp-sched-edit").HasAttribute("disabled") || cut.Find(".fp-sched-toggle input").HasAttribute("disabled"));
Api.ScheduleRunGate.SetResult();
cut.WaitForAssertion(() => Assert.Contains("lernen/blatt/", CurrentUri));
Assert.Equal([existing.Id], Api.ScheduleRuns);
}
[Fact]
public void Run_now_can_be_cancelled_in_the_confirmation()
{
Api.Schedules.Add(FakeLearningApi.Schedule());
var cut = OpenTab();
cut.Find(".fp-sched-run").Click();
Confirm(cut, "Abbrechen");
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".mud-dialog")));
Assert.Empty(Api.ScheduleRuns);
}
[Fact]
public void A_failed_run_shows_the_message_as_text_and_stays_on_the_page()
{
var existing = FakeLearningApi.Schedule();
Api.Schedules.Add(existing);
Api.ScheduleRunBehavior = _ => ApiResult<LearningScheduleRunResultDto>.Fail(new ApiProblem { Status = 429, Detail = "Das Tageslimit von 5 KI-Aufrufen ist <b>erreicht</b>." });
var cut = OpenTab();
cut.Find(".fp-sched-run").Click();
Confirm(cut, "Jetzt erzeugen");
cut.WaitForAssertion(() => Assert.Contains("Tageslimit von 5 KI-Aufrufen ist <b>erreicht</b>.", cut.Find(".fp-sched-error").TextContent));
Assert.Empty(cut.FindAll(".fp-sched-error b"));
Assert.DoesNotContain("lernen/blatt/", CurrentUri);
Assert.False(cut.Find(".fp-sched-run").HasAttribute("disabled")); // wieder bedienbar
}
}