merge: Phase 11d meal wishlist

This commit is contained in:
Jannis committed 2026-10-10 19:27:14 +02:00
commit 9796f86596
22 files changed
+3498 -27

No files matched your search

+2
View File
@@ -83,6 +83,8 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und
| Einstellungen ✅ | `FamilySettings` (FamilyName, TimeZoneId, MealPlanDays (7–10), MealTypes, TaskCategories, ChorePointsEnabled, ChildTasksRequireApproval, GuestsCanEditShoppingList, GuestChatEnabled) |
| Einkauf ✅ | `ShoppingList` (Name, IsDefault, SortOrder, Soft Delete), `ShoppingItem` (Client-Id, Name, Quantity, Category, IsDone, DoneAt, DoneBy, RowVersion, Soft Delete), `ShoppingSuggestion` (Name, Category, CategoryIsManual, UseCount, LastUsedAt). Die Items-DTOs liefern `CreatedByName` („hinzugefügt von …“); für Kinder prüft `ShoppingPlausibility` (Sonderzeichen/Ziffern, Zeichenwiederholung, Tastaturmuster, kleine Quatschliste) Neu-Einträge: verdächtige liefern 409 mit `needsConfirmation`, der Client fragt nach und sendet `Confirm = true` |
| Essen ✅ | `MealPlanEntry` (Date als `DateOnly`, MealType, DishName, Note, RecipeUrl, Ingredients, WishId, RowVersion, Soft Delete; eindeutig je Tag + Mahlzeit), `Dish` (Name, RecipeUrl, Ingredients, UseCount – für Autovervollständigung), `MealWish` (DishName, Note, Wunschtag/-mahlzeit, Status offen/übernommen/abgelehnt, Soft Delete) |
| Einkauf ✅ | `ShoppingList` (Name, IsDefault, SortOrder, Soft Delete), `ShoppingItem` (Client-Id, Name, Quantity, Category, IsDone, DoneAt, DoneBy, RowVersion, Soft Delete), `ShoppingSuggestion` (Name, Category, CategoryIsManual, UseCount, LastUsedAt) |
| Essen ✅ | `MealPlanEntry` (Date als `DateOnly`, MealType, DishName, Note, RecipeUrl, Ingredients, WishId, RowVersion, Soft Delete; eindeutig je Tag + Mahlzeit), `Dish` (Name, Note, RecipeUrl, Ingredients, UseCount = Planungen, WishCount, WishedBy, LastPlannedAt, Soft Delete; eindeutiger NormalizedName – dauerhafte Wunschliste und Autovervollständigung, `/api/meals/wishlist`), `MealWish` (DishName, Note, Wunschtag/-mahlzeit, Status offen/übernommen/abgelehnt, Soft Delete) |
| Aufgaben ✅ | `TodoTask` (Title, Notes, DueDate + DueTime → DueAt, ReminderMinutesBefore → RemindAt, Priority, Status inkl. „wartet auf Bestätigung“, Category, IsPrivate, IsChore, Points, NeedsApproval, AssignedToFamily, RecurrenceRule + RecurrenceMode, SeriesId/SeriesStart/SeriesIndex, CompletedBy/At, ApprovedBy/At, RowVersion, Soft Delete), `TaskAssignment`, `TaskChecklistItem`, `TaskHistoryEntry` (Verlauf), `PointsLedgerEntry` (Punktebuch). Kategorien kommen aus `FamilySettings.TaskCategories`; „wer/wann/genehmigt“ steht an der Aufgabe und im Verlauf (ADR-0014) |
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, StartUtc, EndUtc, TimeZoneId, RecurrenceRule, ExceptionDates, Uid, PersonIds, ForWholeFamily, Sequence, RowVersion, Soft Delete; Overrides als eigene Zeile mit `SeriesMasterId` + `RecurrenceId`), `IcsFeed` (Owner, Scope, CalendarId, TaskMode, TokenHash, TokenPrefix, LastAccessAt, RevokedAt) – ADR-0015/0016 |
| KI ✅ | `AiProviderConfig` (Name, Type Ollama/Claude/OpenAiCompatible, BaseUrl, Model, ApiKeyProtected, TimeoutSeconds, IsActive, AllowImages, Purposes, DailyLimitPerUser, RowVersion, Soft Delete), `AiUsage` (Person, Anbieter, Zweck, Dauer, Tokens, Erfolg/Fehlerart – ohne Inhalt) – ADR-0020 |
+3 -1
View File
@@ -145,6 +145,9 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- [x] Dashboard: allgemeine Info-Kachel für Freitext-Notizen (`DashboardNote`, Recht `DashboardNotesEdit` für Eltern, alle sehen sie, nur Text) – Migration `Phase11aDashboardNotes`
- [x] Oberfläche: Klick auf die eigenen Initialen öffnet ein Menü (Passwort ändern, Meine Einstellungen mit Theme-Wahl, Benachrichtigungen, Abmelden)
## Phase 11d – Essen: Wunschliste
- [x] Essen: Wunschliste – Wünsche und bereits hinterlegte Gerichte landen in einer dauerhaften, durchsuchbaren Wunschliste und lassen sich jederzeit wieder in den Plan übernehmen (Migration `Phase11dMealWishlist`, ADR-0013)
## Phase 12 – Deployment
- [ ] Finale `docker-compose.yml` (inkl. optionalem Caddy), `.env.example`
- [ ] Backup-/Restore-Skripte
@@ -167,7 +170,6 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- Chat: GIF-/Meme-Suche (Anbieter und Datenschutz klären, z. B. nur über den Server abfragen, keine Weitergabe von Kinderdaten)
- Chat: Emoji-Auswahl (Button neben „Bild senden“)
- Chat: KI-Chat als zusätzlicher Kanal (z. B. „KI-Chat“ unter „Familienchat“): Chattext wird zum Prompt, die Antwort erscheint im Verlauf; Admin schaltet ihn ein/aus und legt fest, welche Anbieter/Modelle auswählbar sind (neuer `AiPurpose`, Tageslimit und Datenschutz-Opt-in aus ADR-0020; Kinder-Schutz: Rechte, Inhaltsfilter, Protokoll ohne Inhalt klären)
- Essen: Wunschliste – Wünsche und bereits hinterlegte Gerichte landen in einer dauerhaften, durchsuchbaren Wunschliste und lassen sich jederzeit wieder in den Plan übernehmen
- Lernen: tolerantere Antwort-Auswertung für Zahlen mit Einheiten und Schreibweisen (z. B. „1€ 30 Cent“ = „1,30“, „2 Euro 50“); unklare Fälle optional zur Gegenprüfung an die KI (Purpose und Opt-in aus ADR-0020), Ergebnis bleibt Vorschlag, Eltern bestätigen
- Lernen: Kind sieht die Note auch auf dem Lernzettel selbst (Kopfbereich des Blatts/Ergebnisansicht), nicht nur in der Liste
- Lernen: Übungsblätter automatisch erzeugen (Zeitplan je Kind, z. B. täglich/wöchentlich 10 neue Aufgaben, Schwierigkeit steigt schrittweise; Hintergrunddienst, nur Eltern konfigurieren, Tageslimit der KI beachten, Entwürfe bleiben prüfbar oder werden nach Eltern-Einstellung direkt zugewiesen)
+22
View File
@@ -58,3 +58,25 @@ Verbindungsverlust zeigt die Seite den Offline-Status und lädt nach dem Wiederv
* Tauschen statt Ablehnen macht Drag & Drop ohne Rückfragen nutzbar; ein Tausch erzeugt zwei Live-Ereignisse.
* Wünsche sind ein eigener, einfacher Workflow (offen → übernommen/abgelehnt) ohne Abstimmungen oder Kommentare.
* Eine Rezeptdatenbank bleibt im Backlog; der Gerichtekatalog merkt sich nur die letzten Angaben je Gericht.
## Nachtrag Phase 11d: dauerhafte Wunschliste
* **Datenmodell:** Der Gerichtekatalog `Dish` wird zur dauerhaften Wunschliste. Der eindeutige `NormalizedName` (kleingeschrieben,
getrimmt) dedupliziert; neu sind `Note`, `WishedBy` (letzter Wünscher), `WishCount` (Anzahl Wünsche), `LastPlannedAt` (nullable,
`null` = nie geplant) und Soft Delete (`ISoftDeletable`). `UseCount` zählt weiterhin die Planungen. Der eindeutige Index auf
`NormalizedName` ist bewusst **nicht gefiltert**: Ein gelöschtes Gericht wird bei erneutem Wunsch oder Planen per
`IgnoreQueryFilters()` gefunden und reaktiviert (`Dish.Restore()`), statt ein Duplikat anzulegen.
* **Migration `Phase11dMealWishlist`:** ergänzt die Spalten, füllt `LastPlannedAt` aus `LastUsedAt` (wo `UseCount > 0`), trägt
geplante Einträge nach, die im Katalog fehlen, und legt offene/übernommene Essenswünsche als Gerichte an (dedupliziert über
`lower(trim(DishName))`, `WishCount` = Anzahl der Wünsche). Alles reines T-SQL, ohne Daten lauffähig.
* **Api:** `POST /api/meals/wishes` legt das Gericht zusätzlich an bzw. erhöht `WishCount` (Fehler dabei brechen den Wunsch nicht ab).
`GET /api/meals/wishlist?q=&sort=lastPlanned|mostPlanned|name` (`MealPlanRead`; Familie, Admin und Kinder, Gäste 403;
unbekannte Sortierung = `lastPlanned`, nie Geplantes steht hinten). `POST /api/meals/wishlist/{id}/plan` (Tag + Mahlzeit,
optional `Replace`, 409 bei belegtem Platz; `MealPlanEdit` + `CanEditPlan`, Kinder 403) erzeugt den Plan-Eintrag mit Rezeptlink,
Zutaten und Notiz des Gerichts und zählt `UseCount`/`LastPlannedAt` hoch. `DELETE /api/meals/wishlist/{id}` (Soft Delete,
idempotent): Familie/Admin alles, Kinder nur selbst gewünschte Gerichte, die nie geplant wurden
(`MealPlanRules.CanDeleteWishlistEntry`, objektbezogen geprüft und getestet). Änderungen melden `MealWishesChanged`.
* **Oberfläche:** Abschnitt „Wunschliste“ auf der Essenplan-Seite mit Suchfeld, Sortierung (zuletzt geplant / am häufigsten / A–Z)
und „In den Plan“-Dialog (`MealSlotPicker`, Ersetzen nur nach Rückfrage).
* **Offen:** Ein offener Essenswunsch bleibt offen, wenn dasselbe Gericht direkt aus der Wunschliste geplant wird; die Wunschliste
hat keine Seitenblätterung (höchstens 500 Treffer, Standard 200).
@@ -41,6 +41,10 @@ public static class MealEndpoints
group.MapPost("/wishes/{id:guid}/reject", RejectWishAsync).RequirePermission(Permission.MealPlanEdit);
group.MapDelete("/wishes/{id:guid}", DeleteWishAsync).RequirePermission(Permission.MealPlanEdit);
group.MapGet("/wishlist", GetWishlistAsync).RequirePermission(Permission.MealPlanRead);
group.MapPost("/wishlist/{id:guid}/plan", PlanWishlistDishAsync).RequirePermission(Permission.MealPlanEdit).Validate<PlanWishlistDishRequest>();
group.MapDelete("/wishlist/{id:guid}", DeleteWishlistDishAsync).RequirePermission(Permission.MealPlanEdit);
return app;
}
@@ -337,6 +341,7 @@ public static class MealEndpoints
AppDbContext db,
ISettingsProvider settingsProvider,
IHubContext<LiveHub> hub,
ILoggerFactory loggerFactory,
CancellationToken ct)
{
if (!MealPlanRules.CanProposeWish(await user.AccessLevelAsync(Permission.MealPlanEdit, settingsProvider)))
@@ -360,6 +365,7 @@ public static class MealEndpoints
};
db.MealWishes.Add(wish);
await db.SaveChangesAsync(ct);
await UpsertWishedDishAsync(db, wish, user.UserId(), loggerFactory, ct);
await NotifyWishesChangedAsync(hub, ct);
var names = await UserNamesAsync(db, [wish.CreatedBy], ct);
@@ -419,24 +425,7 @@ public static class MealEndpoints
};
wish.Accept(user.UserId() ?? Guid.Empty, timeProvider.GetUtcNow(), entry.Id);
try
{
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction = await db.Database.BeginTransactionAsync(ct);
if (occupied is not null)
{
db.MealPlanEntries.Remove(occupied); // Soft Delete gibt den Platz frei
await db.SaveChangesAsync(ct);
}
db.MealPlanEntries.Add(entry);
await db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
});
}
catch (DbUpdateException)
if (!await SaveEntryInSlotAsync(db, occupied, entry, ct))
{
return EndpointHelpers.Conflict("Der Essenplan wurde inzwischen geändert. Bitte neu laden und erneut versuchen.");
}
@@ -506,8 +495,177 @@ public static class MealEndpoints
return TypedResults.NoContent();
}
// ---------- Wunschliste ----------
internal static async Task<Results<Ok<List<WishlistDishDto>>, ForbidHttpResult>> GetWishlistAsync(
string? q, string? sort, int? take, ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, CancellationToken ct)
{
if (!MealPlanRules.CanSeeWishlist(await user.AccessLevelAsync(Permission.MealPlanEdit, settingsProvider)))
{
return TypedResults.Forbid();
}
var query = db.Dishes.AsNoTracking();
if (!string.IsNullOrWhiteSpace(q))
{
var term = Dish.Normalize(q);
query = query.Where(d => d.NormalizedName.Contains(term));
}
// SQL Server sortiert NULL (nie geplant) bei absteigender Sortierung ans Ende.
var ordered = MealPlanRules.NormalizeWishlistSort(sort) switch
{
WishlistSort.MostPlanned => query.OrderByDescending(d => d.UseCount).ThenBy(d => d.NormalizedName),
WishlistSort.Name => query.OrderBy(d => d.NormalizedName),
_ => query.OrderByDescending(d => d.LastPlannedAt).ThenBy(d => d.NormalizedName),
};
var dishes = await ordered.Take(Math.Clamp(take ?? 200, 1, 500)).ToListAsync(ct);
var names = await UserNamesAsync(db, dishes.Select(d => d.WishedBy), ct);
return TypedResults.Ok(dishes.Select(d => ToDto(d, names)).ToList());
}
internal static async Task<Results<Ok<MealPlanEntryDto>, NotFound, ForbidHttpResult, ValidationProblem, ProblemHttpResult>> PlanWishlistDishAsync(
Guid id,
PlanWishlistDishRequest request,
ClaimsPrincipal user,
AppDbContext db,
ISettingsProvider settingsProvider,
IHubContext<LiveHub> hub,
TimeProvider timeProvider,
ILoggerFactory loggerFactory,
CancellationToken ct)
{
if (!MealPlanRules.CanEditPlan(await user.AccessLevelAsync(Permission.MealPlanEdit, settingsProvider)))
{
return TypedResults.Forbid(); // Kinder wünschen nur
}
var dish = await db.Dishes.AsNoTracking().SingleOrDefaultAsync(d => d.Id == id, ct);
if (dish is null)
{
return TypedResults.NotFound();
}
var settings = await settingsProvider.GetAsync(ct);
if (!MealPlanRules.IsValidMealType(request.MealType, settings.MealTypes))
{
return UnknownMealType(nameof(PlanWishlistDishRequest.MealType));
}
var occupied = await db.MealPlanEntries.SingleOrDefaultAsync(e => e.Date == request.Date && e.MealType == request.MealType, ct);
if (occupied is not null && !request.Replace)
{
return EndpointHelpers.Conflict($"Für diesen Platz ist bereits „{occupied.DishName}“ geplant.");
}
var entry = new MealPlanEntry
{
Date = request.Date,
MealType = request.MealType,
DishName = dish.Name,
Note = dish.Note,
RecipeUrl = dish.RecipeUrl,
Ingredients = [.. dish.Ingredients],
};
if (!await SaveEntryInSlotAsync(db, occupied, entry, ct))
{
return EndpointHelpers.Conflict("Der Essenplan wurde inzwischen geändert. Bitte neu laden und erneut versuchen.");
}
await TrackDishAsync(db, entry, countUse: true, timeProvider, loggerFactory, ct);
if (occupied is not null)
{
await hub.Clients.Group(LiveHub.MealsGroup).SendAsync(MealEvents.EntryRemoved, occupied.Id, ct);
}
var dto = ToDto(entry);
await hub.Clients.Group(LiveHub.MealsGroup).SendAsync(MealEvents.EntryChanged, dto, ct);
await NotifyWishesChangedAsync(hub, ct);
return TypedResults.Ok(dto);
}
internal static async Task<Results<NoContent, ForbidHttpResult>> DeleteWishlistDishAsync(
Guid id, ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, IHubContext<LiveHub> hub, CancellationToken ct)
{
var dish = await db.Dishes.SingleOrDefaultAsync(d => d.Id == id, ct);
if (dish is null)
{
return TypedResults.NoContent(); // idempotent
}
var access = await user.AccessLevelAsync(Permission.MealPlanEdit, settingsProvider);
var wasPlanned = dish.UseCount > 0 || dish.LastPlannedAt is not null;
if (!MealPlanRules.CanDeleteWishlistEntry(access, dish.WishedBy, wasPlanned, user.UserId() ?? Guid.Empty))
{
return TypedResults.Forbid();
}
db.Dishes.Remove(dish); // Soft Delete über den Interceptor
await db.SaveChangesAsync(ct);
await NotifyWishesChangedAsync(hub, ct);
return TypedResults.NoContent();
}
// ---------- Hilfen ----------
/// <summary>Legt das Gericht eines Wunschs in der Wunschliste an bzw. erhöht den Wunschzähler (gelöschte werden reaktiviert).</summary>
private static async Task UpsertWishedDishAsync(AppDbContext db, MealWish wish, Guid? userId, ILoggerFactory loggerFactory, CancellationToken ct)
{
try
{
var normalized = Dish.Normalize(wish.DishName);
var dish = await db.Dishes.IgnoreQueryFilters().SingleOrDefaultAsync(d => d.NormalizedName == normalized, ct);
if (dish is null)
{
dish = new Dish { NormalizedName = normalized, Name = wish.DishName };
db.Dishes.Add(dish);
}
else if (dish.IsDeleted)
{
dish.Restore();
}
dish.Note = wish.Note ?? dish.Note;
dish.WishedBy = userId ?? dish.WishedBy;
dish.WishCount++;
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
// Der Wunsch selbst ist gespeichert; die Wunschliste ist nicht kritisch (z. B. gleichzeitiges Anlegen).
loggerFactory.CreateLogger(typeof(MealEndpoints)).LogDebug(ex, "Wunschliste konnte nicht aktualisiert werden.");
db.ChangeTracker.Clear();
}
}
/// <summary>Speichert den neuen Eintrag; ein belegter Platz wird in derselben Transaktion freigegeben. <c>false</c> bei Konflikt.</summary>
private static async Task<bool> SaveEntryInSlotAsync(AppDbContext db, MealPlanEntry? occupied, MealPlanEntry entry, CancellationToken ct)
{
try
{
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction = await db.Database.BeginTransactionAsync(ct);
if (occupied is not null)
{
db.MealPlanEntries.Remove(occupied); // Soft Delete gibt den Platz frei
await db.SaveChangesAsync(ct);
}
db.MealPlanEntries.Add(entry);
await db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
});
return true;
}
catch (DbUpdateException)
{
return false;
}
}
/// <summary>Merkt sich Gericht, Rezeptlink und Zutaten für Autovervollständigung und Vorbelegung.</summary>
private static async Task TrackDishAsync(
AppDbContext db, MealPlanEntry entry, bool countUse, TimeProvider timeProvider, ILoggerFactory loggerFactory, CancellationToken ct)
@@ -515,13 +673,17 @@ public static class MealEndpoints
try
{
var normalized = Dish.Normalize(entry.DishName);
var dish = await db.Dishes.SingleOrDefaultAsync(d => d.NormalizedName == normalized, ct);
var dish = await db.Dishes.IgnoreQueryFilters().SingleOrDefaultAsync(d => d.NormalizedName == normalized, ct);
if (dish is null)
{
dish = new Dish { NormalizedName = normalized };
db.Dishes.Add(dish);
countUse = true;
}
else if (dish.IsDeleted)
{
dish.Restore();
}
dish.Name = entry.DishName;
dish.RecipeUrl = entry.RecipeUrl ?? dish.RecipeUrl;
@@ -534,6 +696,7 @@ public static class MealEndpoints
{
dish.UseCount++;
dish.LastUsedAt = timeProvider.GetUtcNow();
dish.LastPlannedAt = dish.LastUsedAt;
}
await db.SaveChangesAsync(ct);
@@ -571,6 +734,10 @@ public static class MealEndpoints
entry.Id, entry.Date, entry.MealType, entry.DishName, entry.Note, entry.RecipeUrl, [.. entry.Ingredients],
entry.WishId, entry.UpdatedBy, entry.UpdatedAt, entry.RowVersion);
private static WishlistDishDto ToDto(Dish dish, Dictionary<Guid, string> names) => new(
dish.Id, dish.Name, dish.Note, dish.RecipeUrl, [.. dish.Ingredients], dish.WishCount, dish.UseCount, dish.LastPlannedAt,
dish.WishedBy, dish.WishedBy is { } wishedBy ? names.GetValueOrDefault(wishedBy) : null);
private static MealWishDto ToDto(MealWish wish, Dictionary<Guid, string> names) => new(
wish.Id, wish.DishName, wish.Note, wish.PreferredDate, wish.PreferredMealType, wish.Status, wish.CreatedBy,
wish.CreatedBy is { } createdBy ? names.GetValueOrDefault(createdBy) : null, wish.CreatedAt, wish.DecidedAt, wish.PlanEntryId);
+32 -2
View File
@@ -39,22 +39,52 @@ public sealed class MealPlanEntry : AuditableEntity, ISoftDeletable, IHasRowVers
public MealSlot Slot => new(Date, MealType);
}
/// <summary>Katalog bisheriger Gerichte für Autovervollständigung und Vorbelegung (Rezeptlink, Zutaten).</summary>
public sealed class Dish : AuditableEntity
/// <summary>
/// Dauerhafte, durchsuchbare Wunschliste bisheriger, gewünschter und geplanter Gerichte (ADR-0013). Der eindeutige
/// <see cref="NormalizedName"/> dedupliziert; <see cref="UseCount"/> zählt Planungen. Dient zugleich der
/// Autovervollständigung und Vorbelegung (Rezeptlink, Zutaten).
/// </summary>
public sealed class Dish : AuditableEntity, ISoftDeletable
{
public string NormalizedName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Note { get; set; }
public string? RecipeUrl { get; set; }
public List<string> Ingredients { get; set; } = [];
/// <summary>Wie oft das Gericht in den Plan aufgenommen wurde.</summary>
public int UseCount { get; set; }
public DateTimeOffset LastUsedAt { get; set; }
/// <summary>Wer sich das Gericht zuletzt gewünscht hat (optional).</summary>
public Guid? WishedBy { get; set; }
/// <summary>Wie oft das Gericht gewünscht wurde.</summary>
public int WishCount { get; set; }
/// <summary>Zuletzt geplant am (<c>null</c> = noch nie geplant).</summary>
public DateTimeOffset? LastPlannedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
public static string Normalize(string name) => name.Trim().ToLowerInvariant();
/// <summary>Gelöschtes Gericht wieder in die Wunschliste holen (bei erneutem Wunsch oder Planen).</summary>
public void Restore()
{
IsDeleted = false;
DeletedAt = null;
DeletedBy = null;
}
}
public enum MealWishStatus
@@ -108,6 +108,33 @@ public static class MealPlanRules
AccessLevel.Restricted => wishCreatedBy == userId && status == MealWishStatus.Open,
_ => false,
};
/// <summary>Wunschliste ansehen: Familie, Admin und Kinder; Gäste nicht.</summary>
public static bool CanSeeWishlist(AccessLevel editAccess) => editAccess >= AccessLevel.Restricted;
/// <summary>Familie/Admin löschen jedes Gericht der Wunschliste, Kinder nur selbst gewünschte, die nie geplant wurden.</summary>
public static bool CanDeleteWishlistEntry(AccessLevel editAccess, Guid? wishedBy, bool wasPlanned, Guid userId) => editAccess switch
{
AccessLevel.Full => true,
AccessLevel.Restricted => wishedBy is { } wisher && wisher == userId && !wasPlanned,
_ => false,
};
/// <summary>Sortierung der Wunschliste: <c>lastPlanned</c> (Standard), <c>mostPlanned</c>, <c>name</c>.</summary>
public static string NormalizeWishlistSort(string? sort) => sort?.Trim().ToLowerInvariant() switch
{
"mostplanned" => WishlistSort.MostPlanned,
"name" => WishlistSort.Name,
_ => WishlistSort.LastPlanned,
};
}
/// <summary>Sortierschlüssel der Wunschliste (Query-Parameter <c>sort</c>).</summary>
public static class WishlistSort
{
public const string LastPlanned = "lastPlanned";
public const string MostPlanned = "mostPlanned";
public const string Name = "name";
}
/// <summary>Vorschau einer Zutat für die Übernahme auf die Einkaufsliste.</summary>
@@ -29,8 +29,10 @@ internal sealed class DishConfiguration : IEntityTypeConfiguration<Dish>
builder.ToTable("Dishes");
builder.Property(d => d.NormalizedName).HasMaxLength(MealPlanEntry.DishNameMaxLength).IsRequired();
builder.Property(d => d.Name).HasMaxLength(MealPlanEntry.DishNameMaxLength).IsRequired();
builder.Property(d => d.Note).HasMaxLength(MealWish.NoteMaxLength);
builder.Property(d => d.RecipeUrl).HasMaxLength(MealPlanEntry.RecipeUrlMaxLength);
builder.PrimitiveCollection(d => d.Ingredients).IsRequired();
// Eindeutig über alle Zeilen (auch gelöschte): ein gelöschtes Gericht wird bei erneutem Wunsch/Planen reaktiviert.
builder.HasIndex(d => d.NormalizedName).IsUnique();
builder.HasIndex(d => d.UseCount);
}
@@ -0,0 +1,175 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase11dMealWishlist : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DeletedAt",
table: "Dishes",
type: "datetimeoffset",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "DeletedBy",
table: "Dishes",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "Dishes",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "LastPlannedAt",
table: "Dishes",
type: "datetimeoffset",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Note",
table: "Dishes",
type: "nvarchar(300)",
maxLength: 300,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WishCount",
table: "Dishes",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<Guid>(
name: "WishedBy",
table: "Dishes",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Dishes_IsDeleted",
table: "Dishes",
column: "IsDeleted");
// Daten: aus dem bisherigen Gerichtekatalog wird die dauerhafte Wunschliste (ADR-0013).
// Jede Anweisung ist ein eigener Batch, damit die neuen Spalten sichtbar sind.
migrationBuilder.Sql(
"""
UPDATE [Dishes] SET [LastPlannedAt] = [LastUsedAt] WHERE [UseCount] > 0;
""");
// Geplante Einträge, die im Katalog fehlen (z. B. wenn die Katalogpflege früher fehlschlug), nachtragen.
migrationBuilder.Sql(
"""
WITH [Planned] AS (
SELECT
LOWER(LTRIM(RTRIM([DishName]))) AS [Key],
LTRIM(RTRIM([DishName])) AS [Name],
[RecipeUrl],
[Ingredients],
[CreatedAt],
ROW_NUMBER() OVER (PARTITION BY LOWER(LTRIM(RTRIM([DishName]))) ORDER BY [Date] DESC, [CreatedAt] DESC) AS [Rank],
COUNT(*) OVER (PARTITION BY LOWER(LTRIM(RTRIM([DishName])))) AS [Total]
FROM [MealPlanEntries]
WHERE [IsDeleted] = 0 AND LTRIM(RTRIM([DishName])) <> ''
)
INSERT INTO [Dishes] ([Id], [CreatedAt], [UpdatedAt], [NormalizedName], [Name], [RecipeUrl], [Ingredients],
[UseCount], [LastUsedAt], [LastPlannedAt], [WishCount], [IsDeleted])
SELECT NEWID(), [CreatedAt], [CreatedAt], [Key], [Name], [RecipeUrl], COALESCE([Ingredients], N'[]'),
[Total], [CreatedAt], [CreatedAt], 0, 0
FROM [Planned] p
WHERE p.[Rank] = 1 AND NOT EXISTS (SELECT 1 FROM [Dishes] d WHERE d.[NormalizedName] = p.[Key]);
""");
// Offene und übernommene Essenswünsche als Gerichte (dedupliziert über lower(trim(DishName))).
migrationBuilder.Sql(
"""
WITH [Wished] AS (
SELECT
LOWER(LTRIM(RTRIM([DishName]))) AS [Key],
LTRIM(RTRIM([DishName])) AS [Name],
[Note],
[CreatedBy],
[CreatedAt],
ROW_NUMBER() OVER (PARTITION BY LOWER(LTRIM(RTRIM([DishName]))) ORDER BY [CreatedAt] DESC) AS [Rank],
COUNT(*) OVER (PARTITION BY LOWER(LTRIM(RTRIM([DishName])))) AS [Total]
FROM [MealWishes]
WHERE [IsDeleted] = 0 AND [Status] IN (N'Open', N'Accepted') AND LTRIM(RTRIM([DishName])) <> ''
)
INSERT INTO [Dishes] ([Id], [CreatedAt], [CreatedBy], [UpdatedAt], [NormalizedName], [Name], [Note], [Ingredients],
[UseCount], [LastUsedAt], [WishCount], [WishedBy], [IsDeleted])
SELECT NEWID(), [CreatedAt], [CreatedBy], [CreatedAt], [Key], [Name], [Note], N'[]',
0, [CreatedAt], [Total], [CreatedBy], 0
FROM [Wished] w
WHERE w.[Rank] = 1 AND NOT EXISTS (SELECT 1 FROM [Dishes] d WHERE d.[NormalizedName] = w.[Key]);
""");
// Bereits vorhandene Gerichte: Wunschzähler und Wünscher ergänzen (nur Wünsche, deren Gericht nicht eben neu angelegt wurde).
migrationBuilder.Sql(
"""
UPDATE d
SET d.[WishCount] = d.[WishCount] + w.[Total],
d.[WishedBy] = COALESCE(d.[WishedBy], w.[CreatedBy]),
d.[Note] = COALESCE(d.[Note], w.[Note])
FROM [Dishes] d
INNER JOIN (
SELECT
LOWER(LTRIM(RTRIM([DishName]))) AS [Key],
MAX([Note]) AS [Note],
MAX([CreatedBy]) AS [CreatedBy],
COUNT(*) AS [Total]
FROM [MealWishes]
WHERE [IsDeleted] = 0 AND [Status] IN (N'Open', N'Accepted') AND LTRIM(RTRIM([DishName])) <> ''
GROUP BY LOWER(LTRIM(RTRIM([DishName])))
) w ON w.[Key] = d.[NormalizedName]
WHERE d.[WishCount] = 0;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Dishes_IsDeleted",
table: "Dishes");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Dishes");
migrationBuilder.DropColumn(
name: "DeletedBy",
table: "Dishes");
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "Dishes");
migrationBuilder.DropColumn(
name: "LastPlannedAt",
table: "Dishes");
migrationBuilder.DropColumn(
name: "Note",
table: "Dishes");
migrationBuilder.DropColumn(
name: "WishCount",
table: "Dishes");
migrationBuilder.DropColumn(
name: "WishedBy",
table: "Dishes");
}
}
}
@@ -1134,10 +1134,22 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.PrimitiveCollection<string>("Ingredients")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LastPlannedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset>("LastUsedAt")
.HasColumnType("datetimeoffset");
@@ -1151,6 +1163,10 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Note")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<string>("RecipeUrl")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
@@ -1164,8 +1180,16 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.Property<int>("UseCount")
.HasColumnType("int");
b.Property<int>("WishCount")
.HasColumnType("int");
b.Property<Guid?>("WishedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.HasIndex("NormalizedName")
.IsUnique();
@@ -79,6 +79,22 @@ public sealed record CreateMealWishRequest(string DishName, string? Note = null,
/// <summary>Wunsch in einen Platz übernehmen. Ist der Platz belegt, wird nur mit <see cref="Replace"/> ersetzt, sonst 409.</summary>
public sealed record AcceptMealWishRequest(DateOnly Date, string MealType, bool Replace = false);
/// <summary>Gericht der dauerhaften Wunschliste (ADR-0013). <see cref="UseCount"/> = wie oft geplant, <see cref="WishCount"/> = wie oft gewünscht.</summary>
public sealed record WishlistDishDto(
Guid Id,
string Name,
string? Note,
string? RecipeUrl,
IReadOnlyList<string> Ingredients,
int WishCount,
int UseCount,
DateTimeOffset? LastPlannedAt,
Guid? WishedBy,
string? WishedByName);
/// <summary>Gericht der Wunschliste in einen Platz planen. Ist der Platz belegt, wird nur mit <see cref="Replace"/> ersetzt, sonst 409.</summary>
public sealed record PlanWishlistDishRequest(DateOnly Date, string MealType, bool Replace = false);
/// <summary>Namen der SignalR-Ereignisse des Essenplans am <c>/hubs/live</c>-Hub.</summary>
public static class MealEvents
{
@@ -138,6 +154,11 @@ public sealed class AcceptMealWishRequestValidator : AbstractValidator<AcceptMea
public AcceptMealWishRequestValidator() => MealValidation.MealSlot(this, r => r.Date, r => r.MealType);
}
public sealed class PlanWishlistDishRequestValidator : AbstractValidator<PlanWishlistDishRequest>
{
public PlanWishlistDishRequestValidator() => MealValidation.MealSlot(this, r => r.Date, r => r.MealType);
}
public sealed class IngredientsToShoppingRequestValidator : AbstractValidator<IngredientsToShoppingRequest>
{
public IngredientsToShoppingRequestValidator() =>
@@ -0,0 +1,108 @@
@using FamilyPlanner.Domain.Meals
@using FamilyPlanner.Shared.Meals
<section class="fp-wishlist" aria-labelledby="wishlist-title">
<MudText Typo="Typo.h6" HtmlTag="h2" id="wishlist-title" Class="mb-1">
Wunschliste
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Class="ml-1 fp-wishlist-count">@Items.Count</MudChip>
</MudText>
<MudText Typo="Typo.body2" Class="mb-2">Alle gewünschten und bisher geplanten Gerichte – jederzeit wieder in den Plan übernehmbar.</MudText>
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudTextField T="string" Value="Search" ValueChanged="OnSearchChanged" Label="Wunschliste durchsuchen" Variant="Variant.Outlined"
Margin="Margin.Dense" Clearable="true" InputId="wishlist-search" Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" Class="fp-wishlist-search" />
<MudButtonGroup Variant="Variant.Outlined" Size="Size.Small" aria-label="Sortierung der Wunschliste">
<MudButton Class="fp-wishlist-sort-lastPlanned" Color="@SortColor(WishlistSort.LastPlanned)" OnClick="@(() => SelectSort(WishlistSort.LastPlanned))">Zuletzt geplant</MudButton>
<MudButton Class="fp-wishlist-sort-mostPlanned" Color="@SortColor(WishlistSort.MostPlanned)" OnClick="@(() => SelectSort(WishlistSort.MostPlanned))">Am häufigsten</MudButton>
<MudButton Class="fp-wishlist-sort-name" Color="@SortColor(WishlistSort.Name)" OnClick="@(() => SelectSort(WishlistSort.Name))">A–Z</MudButton>
</MudButtonGroup>
</div>
@if (Items.Count == 0)
{
<MudText Typo="Typo.body2" Class="fp-empty fp-wishlist-empty">
@(string.IsNullOrWhiteSpace(Search) ? "Die Wunschliste ist noch leer." : "Kein Gericht gefunden.")
</MudText>
}
else
{
<ul class="fp-wish-items fp-wishlist-items">
@foreach (var dish in Items)
{
<li class="fp-wish fp-wishlist-item">
<div class="fp-wish-main">
<span class="fp-wish-dish fp-wishlist-name">@dish.Name</span>
<span class="fp-wish-meta fp-wishlist-meta">@Meta(dish)</span>
@if (dish.Note is not null)
{
<span class="fp-wish-note">„@dish.Note“</span>
}
</div>
@if (CanPlan)
{
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EventAvailable"
OnClick="@(() => OnPlan.InvokeAsync(dish))" Class="fp-wishlist-plan"
aria-label="@($"„{dish.Name}“ in den Plan")">In den Plan</MudButton>
}
@if (CanDelete(dish))
{
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" OnClick="@(() => OnDelete.InvokeAsync(dish))"
Class="fp-wishlist-delete" aria-label="@($"„{dish.Name}“ aus der Wunschliste löschen")" />
}
</li>
}
</ul>
}
</section>
@code {
[Parameter, EditorRequired]
public IReadOnlyList<WishlistDishDto> Items { get; set; } = [];
[Parameter]
public string? Search { get; set; }
[Parameter]
public string Sort { get; set; } = WishlistSort.LastPlanned;
[Parameter]
public bool CanPlan { get; set; }
[Parameter]
public Func<WishlistDishDto, bool> CanDelete { get; set; } = _ => false;
[Parameter]
public EventCallback<string?> OnSearch { get; set; }
[Parameter]
public EventCallback<string> OnSort { get; set; }
[Parameter]
public EventCallback<WishlistDishDto> OnPlan { get; set; }
[Parameter]
public EventCallback<WishlistDishDto> OnDelete { get; set; }
private Task OnSearchChanged(string? value) => OnSearch.InvokeAsync(value);
private Task SelectSort(string sort) => OnSort.InvokeAsync(sort);
private Color SortColor(string sort) => Sort == sort ? Color.Primary : Color.Default;
private static string Meta(WishlistDishDto dish)
{
var parts = new List<string> { MealFormat.LastPlanned(dish.LastPlannedAt) };
if (dish.UseCount > 0)
{
parts.Add($"{MealFormat.Times(dish.UseCount)} geplant");
}
if (dish.WishCount > 0)
{
parts.Add($"{MealFormat.Times(dish.WishCount)} gewünscht" + (dish.WishedByName is { Length: > 0 } name ? $" (zuletzt {name})" : string.Empty));
}
return string.Join(" · ", parts);
}
}
@@ -30,5 +30,12 @@ public static class MealFormat
public static string IngredientsText(IEnumerable<string> ingredients) => string.Join("\n", ingredients);
/// <summary>„zuletzt geplant am 12.10.2026“ bzw. „noch nie geplant“ (Familienzeitzone).</summary>
public static string LastPlanned(DateTimeOffset? value) => value is { } at
? $"zuletzt geplant am {TimeZoneInfo.ConvertTime(at, FamilyPlanner.Domain.Common.FamilyTime.Berlin).ToString("dd.MM.yyyy", German)}"
: "noch nie geplant";
public static string Times(int count) => count == 1 ? "1-mal" : $"{count}-mal";
public static string IngredientCount(int count) => count == 1 ? "1 Zutat" : $"{count} Zutaten";
}
@@ -0,0 +1,87 @@
@using FamilyPlanner.Domain.Meals
@using FamilyPlanner.Shared.Meals
@inject IMealsApi MealsApi
<MudDialog>
<DialogContent>
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="mb-3 fp-dialog-error">@_error</MudAlert>
}
@if (_pending is { } pending && Occupant(pending) is { } occupant)
{
<MudAlert Severity="Severity.Warning" Class="mb-3 fp-accept-replace">
Am @MealFormat.Day(pending.Date) (@pending.MealType) ist bereits „@occupant.DishName“ geplant.
<div class="d-flex gap-2 mt-2">
<MudButton Variant="Variant.Filled" Color="Color.Warning" Disabled="_busy" OnClick="@(() => PlanAsync(pending, replace: true))"
Class="fp-accept-replace-confirm">Ersetzen</MudButton>
<MudButton Variant="Variant.Text" Disabled="_busy" OnClick="@(() => _pending = null)">Anderen Platz wählen</MudButton>
</div>
</MudAlert>
}
else
{
<MudText Typo="Typo.body2" Class="mb-3">Wann gibt es „@Dish.Name“?</MudText>
<MealSlotPicker Days="Days" MealTypes="MealTypes" Entries="Entries" Today="Today" OnSelect="SelectAsync" />
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_busy">Abbrechen</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
[Parameter, EditorRequired]
public WishlistDishDto Dish { get; set; } = default!;
[Parameter, EditorRequired]
public IReadOnlyList<DateOnly> Days { get; set; } = [];
[Parameter, EditorRequired]
public IReadOnlyList<string> MealTypes { get; set; } = [];
[Parameter]
public IReadOnlyList<MealPlanEntryDto> Entries { get; set; } = [];
[Parameter]
public DateOnly Today { get; set; }
private MealSlot? _pending;
private string? _error;
private bool _busy;
private MealPlanEntryDto? Occupant(MealSlot slot) => Entries.FirstOrDefault(e => e.Date == slot.Date && e.MealType == slot.MealType);
private Task SelectAsync(MealSlot slot)
{
if (Occupant(slot) is not null)
{
_pending = slot; // erst bestätigen lassen
return Task.CompletedTask;
}
return PlanAsync(slot, replace: false);
}
private async Task PlanAsync(MealSlot slot, bool replace)
{
_busy = true;
_error = null;
var result = await MealsApi.PlanWishlistDishAsync(Dish.Id, new PlanWishlistDishRequest(slot.Date, slot.MealType, replace));
_busy = false;
if (result.Problem is { } problem)
{
_pending = null;
_error = problem.Message;
return;
}
Dialog?.Close(DialogResult.Ok(result.Value));
}
private void Cancel() => Dialog?.Cancel();
}
+80 -4
View File
@@ -153,6 +153,8 @@ else
{
<MealWishList Wishes="_wishes" CanDecide="CanDecideWish" CanDelete="CanDeleteWish"
OnAccept="AcceptWishAsync" OnReject="RejectWishAsync" OnDelete="DeleteWishAsync" />
<DishWishlist Items="_wishlist" Search="@_wishlistSearch" Sort="@_wishlistSort" CanPlan="CanEdit" CanDelete="CanDeleteWishlistDish"
OnSearch="SearchWishlistAsync" OnSort="SortWishlistAsync" OnPlan="PlanWishlistDishAsync" OnDelete="DeleteWishlistDishAsync" />
}
}
@@ -170,6 +172,9 @@ else
private MealPlanDto? _plan;
private List<MealPlanEntryDto> _entries = [];
private List<MealWishDto> _wishes = [];
private List<WishlistDishDto> _wishlist = [];
private string? _wishlistSearch;
private string _wishlistSort = WishlistSort.LastPlanned;
private List<DishSuggestionDto>? _dishes;
private DateOnly? _from;
private string? _error;
@@ -193,6 +198,9 @@ else
private bool CanDeleteWish(MealWishDto wish) => MealPlanRules.CanDeleteWish(EditAccess, wish.CreatedBy, wish.Status, UserId);
private bool CanDeleteWishlistDish(WishlistDishDto dish) =>
MealPlanRules.CanDeleteWishlistEntry(EditAccess, dish.WishedBy, dish.UseCount > 0 || dish.LastPlannedAt is not null, UserId);
private IReadOnlyList<string> MealTypes =>
_plan is null ? [] : MealPlanRules.EffectiveMealTypes(_plan.ConfiguredMealTypes, _entries.Select(e => e.MealType));
@@ -204,7 +212,7 @@ else
Live.Connected += OnConnectedAsync;
Live.StateChanged += OnLiveStateChanged;
await Live.EnsureStartedAsync();
await Task.WhenAll(LoadPlanAsync(), LoadWishesAsync());
await Task.WhenAll(LoadPlanAsync(), LoadWishesAsync(), LoadWishlistAsync());
}
// ---------- Laden ----------
@@ -240,6 +248,20 @@ else
}
}
private async Task LoadWishlistAsync()
{
if (!CanProposeWish)
{
return;
}
var result = await MealsApi.GetWishlistAsync(_wishlistSearch, _wishlistSort);
if (result.Value is { } items)
{
_wishlist = items;
}
}
private async Task<IReadOnlyList<DishSuggestionDto>> DishesAsync()
{
if (_dishes is null)
@@ -400,6 +422,7 @@ else
_wishes.RemoveAll(w => w.Id == wish.Id);
_wishes.Insert(0, wish);
Snackbar.Add($"Dein Wunsch „{wish.DishName}“ wurde gesendet.", Severity.Success);
await LoadWishlistAsync();
}
}
@@ -424,7 +447,7 @@ else
_entries.RemoveAll(e => e.Date == entry.Date && e.MealType == entry.MealType);
Upsert(entry);
Snackbar.Add($"„{wish.DishName}“ ist eingeplant.", Severity.Success);
await LoadWishesAsync();
await Task.WhenAll(LoadWishesAsync(), LoadWishlistAsync());
}
}
@@ -448,6 +471,59 @@ else
Snackbar.Add($"Wunsch „{wish.DishName}“ gelöscht.", Severity.Normal);
}
// ---------- Wunschliste ----------
private async Task SearchWishlistAsync(string? query)
{
_wishlistSearch = query;
await LoadWishlistAsync();
}
private async Task SortWishlistAsync(string sort)
{
_wishlistSort = sort;
await LoadWishlistAsync();
}
private async Task PlanWishlistDishAsync(WishlistDishDto dish)
{
if (_plan is null)
{
return;
}
var parameters = new DialogParameters<PlanDishDialog>
{
{ d => d.Dish, dish },
{ d => d.Days, _plan.Days },
{ d => d.MealTypes, _plan.ConfiguredMealTypes },
{ d => d.Entries, _entries },
{ d => d.Today, _plan.Today },
};
var dialog = await DialogService.ShowAsync<PlanDishDialog>("In den Plan", parameters, DialogDefaults);
if (await dialog.Result is { Canceled: false, Data: MealPlanEntryDto entry })
{
_entries.RemoveAll(e => e.Date == entry.Date && e.MealType == entry.MealType);
Upsert(entry);
Snackbar.Add($"„{dish.Name}“ ist eingeplant.", Severity.Success);
await LoadWishlistAsync();
}
}
private async Task DeleteWishlistDishAsync(WishlistDishDto dish)
{
var result = await MealsApi.DeleteWishlistDishAsync(dish.Id);
if (result.Problem is { } problem)
{
Snackbar.Add(problem.Message, Severity.Error);
return;
}
_wishlist.RemoveAll(d => d.Id == dish.Id);
_dishes = null; // Autovervollständigung neu laden
Snackbar.Add($"„{dish.Name}“ aus der Wunschliste gelöscht.", Severity.Normal);
}
// ---------- Live-Aktualisierung ----------
private void Upsert(MealPlanEntryDto entry)
@@ -481,13 +557,13 @@ else
private Task OnRemoteWishesChangedAsync() => InvokeAsync(async () =>
{
await LoadWishesAsync();
await Task.WhenAll(LoadWishesAsync(), LoadWishlistAsync());
StateHasChanged();
});
private Task OnConnectedAsync() => InvokeAsync(async () =>
{
await Task.WhenAll(LoadPlanAsync(), LoadWishesAsync());
await Task.WhenAll(LoadPlanAsync(), LoadWishesAsync(), LoadWishlistAsync());
StateHasChanged();
});
@@ -27,6 +27,13 @@ public interface IMealsApi
Task<ApiResult<MealWishDto>> RejectWishAsync(Guid id, CancellationToken ct = default);
Task<ApiResult<bool>> DeleteWishAsync(Guid id, CancellationToken ct = default);
/// <summary>Dauerhafte Wunschliste; <paramref name="sort"/> = <c>lastPlanned</c>, <c>mostPlanned</c> oder <c>name</c>.</summary>
Task<ApiResult<List<WishlistDishDto>>> GetWishlistAsync(string? query, string sort, CancellationToken ct = default);
Task<ApiResult<MealPlanEntryDto>> PlanWishlistDishAsync(Guid id, PlanWishlistDishRequest request, CancellationToken ct = default);
Task<ApiResult<bool>> DeleteWishlistDishAsync(Guid id, CancellationToken ct = default);
}
public sealed class MealsApi(HttpClient http) : IMealsApi
@@ -66,6 +73,16 @@ public sealed class MealsApi(HttpClient http) : IMealsApi
public Task<ApiResult<bool>> DeleteWishAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{Base}/wishes/{id}", ct), ct);
public Task<ApiResult<List<WishlistDishDto>>> GetWishlistAsync(string? query, string sort, CancellationToken ct = default) =>
SendAsync<List<WishlistDishDto>>(
() => http.GetAsync($"{Base}/wishlist?sort={Uri.EscapeDataString(sort)}{(string.IsNullOrWhiteSpace(query) ? string.Empty : $"&q={Uri.EscapeDataString(query.Trim())}")}", ct), ct);
public Task<ApiResult<MealPlanEntryDto>> PlanWishlistDishAsync(Guid id, PlanWishlistDishRequest request, CancellationToken ct = default) =>
SendAsync<MealPlanEntryDto>(() => http.PostAsJsonAsync($"{Base}/wishlist/{id}/plan", request, ct), ct);
public Task<ApiResult<bool>> DeleteWishlistDishAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{Base}/wishlist/{id}", ct), ct);
/// <summary>Wie <see cref="ApiCall"/>, aber 409 liefert die Fehlermeldung des Servers (kein aktueller Stand im Body).</summary>
private static async Task<ApiResult<T>> SendAsync<T>(Func<Task<HttpResponseMessage>> send, CancellationToken ct)
{
+7 -1
View File
@@ -920,10 +920,16 @@ button.fp-meal-dish {
}
/* Essenswünsche */
.fp-meal-wishes {
.fp-meal-wishes,
.fp-wishlist {
margin-top: 16px;
}
.fp-wishlist-search {
flex: 1 1 220px;
max-width: 360px;
}
.fp-wish-items {
list-style: none;
margin: 0;
@@ -283,6 +283,125 @@ public sealed class MealTests(AppFixture fixture)
(await admin.PostAsJsonAsync($"/api/meals/{Guid.NewGuid()}/to-shopping", new IngredientsToShoppingRequest(list.Id), Ct)).StatusCode);
}
private static async Task<List<WishlistDishDto>> WishlistAsync(HttpClient client, string? query = null, string? sort = null) =>
(await client.GetFromJsonAsync<List<WishlistDishDto>>(
$"/api/meals/wishlist?q={Uri.EscapeDataString(query ?? string.Empty)}&sort={sort}", Ct))!;
[Fact]
public async Task Wishes_and_planned_dishes_land_in_a_searchable_deduplicated_wishlist()
{
using var family = (await fixture.CreateSignedInUserAsync(Roles.Family, Ct)).Client;
var (childUser, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _ = child;
var day = NewWeek();
var key = Guid.NewGuid().ToString("N")[..8];
var dish = $"Wunsch{key}";
// Wunsch legt das Gericht an; erneuter Wunsch (andere Schreibweise) dedupliziert und zählt hoch
Assert.Equal(HttpStatusCode.Created, (await child.PostAsJsonAsync("/api/meals/wishes", new CreateMealWishRequest(dish, "mit Soße"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Created, (await child.PostAsJsonAsync("/api/meals/wishes", new CreateMealWishRequest($" {dish.ToUpperInvariant()} "), Ct)).StatusCode);
var listed = Assert.Single(await WishlistAsync(child, key));
Assert.Equal((dish, "mit Soße", 2, 0, childUser.Id, childUser.DisplayName), (listed.Name, listed.Note, listed.WishCount, listed.UseCount, listed.WishedBy!.Value, listed.WishedByName));
Assert.Null(listed.LastPlannedAt);
Assert.Equal(listed.Id, Assert.Single(await WishlistAsync(family, key.ToUpperInvariant())).Id);
// Planen: Zähler und letztes Plandatum; Platz belegt → nur mit Ersetzen; ungültige Mahlzeit → 400
var planned = await family.PostAsJsonAsync($"/api/meals/wishlist/{listed.Id}/plan", new PlanWishlistDishRequest(day, Lunch), Ct);
Assert.Equal(HttpStatusCode.OK, planned.StatusCode);
var entry = (await planned.Content.ReadFromJsonAsync<MealPlanEntryDto>(Ct))!;
Assert.Equal((dish, "mit Soße", day, Lunch), (entry.DishName, entry.Note, entry.Date, entry.MealType));
var after = Assert.Single(await WishlistAsync(family, key));
Assert.Equal(1, after.UseCount);
Assert.NotNull(after.LastPlannedAt);
Assert.Equal(HttpStatusCode.Conflict,
(await family.PostAsJsonAsync($"/api/meals/wishlist/{listed.Id}/plan", new PlanWishlistDishRequest(day, Lunch), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,
(await family.PostAsJsonAsync($"/api/meals/wishlist/{listed.Id}/plan", new PlanWishlistDishRequest(day, "Mitternacht"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound,
(await family.PostAsJsonAsync($"/api/meals/wishlist/{Guid.NewGuid()}/plan", new PlanWishlistDishRequest(day, Dinner), Ct)).StatusCode);
var replaced = await family.PostAsJsonAsync($"/api/meals/wishlist/{listed.Id}/plan", new PlanWishlistDishRequest(day, Lunch, Replace: true), Ct);
Assert.Equal(HttpStatusCode.OK, replaced.StatusCode);
Assert.Equal(2, Assert.Single(await WishlistAsync(family, key)).UseCount);
// Direkt geplante Gerichte erscheinen ebenfalls; Sortierung nach Häufigkeit und Name
var other = $"Direkt{key}";
await CreateAsync(family, day, Dinner, other);
var byName = (await WishlistAsync(family, key, "name")).Select(d => d.Name).ToList();
Assert.Equal([other, dish], byName);
Assert.Equal(dish, (await WishlistAsync(family, key, "mostPlanned"))[0].Name);
Assert.Equal(2, (await WishlistAsync(family, key, "lastPlanned")).Count);
Assert.Equal(2, (await WishlistAsync(family, key, "nonsense")).Count);
}
[Fact]
public async Task Wishlist_rights_are_enforced_per_object()
{
using var family = (await fixture.CreateSignedInUserAsync(Roles.Family, Ct)).Client;
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, sibling) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
using var _ = child;
using var __ = sibling;
using var ___ = guest;
var day = NewWeek();
var key = Guid.NewGuid().ToString("N")[..8];
await child.PostAsJsonAsync("/api/meals/wishes", new CreateMealWishRequest($"Eigen{key}"), Ct);
await child.PostAsJsonAsync("/api/meals/wishes", new CreateMealWishRequest($"Geplant{key}"), Ct);
var own = Assert.Single(await WishlistAsync(child, $"eigen{key}"));
var planned = Assert.Single(await WishlistAsync(child, $"geplant{key}"));
// Gäste sehen nichts, Kinder planen nicht
Assert.Equal(HttpStatusCode.Forbidden, (await guest.GetAsync("/api/meals/wishlist", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await guest.DeleteAsync($"/api/meals/wishlist/{own.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden,
(await child.PostAsJsonAsync($"/api/meals/wishlist/{own.Id}/plan", new PlanWishlistDishRequest(day, Lunch), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden,
(await guest.PostAsJsonAsync($"/api/meals/wishlist/{own.Id}/plan", new PlanWishlistDishRequest(day, Lunch), Ct)).StatusCode);
Assert.Equal(2, (await WishlistAsync(sibling, key)).Count);
// Löschen: Geschwister nicht fremde, Kind eigene nur ohne Planung, Familie alles
Assert.Equal(HttpStatusCode.Forbidden, (await sibling.DeleteAsync($"/api/meals/wishlist/{own.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await family.PostAsJsonAsync($"/api/meals/wishlist/{planned.Id}/plan", new PlanWishlistDishRequest(day, Lunch), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await child.DeleteAsync($"/api/meals/wishlist/{planned.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await child.DeleteAsync($"/api/meals/wishlist/{own.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await child.DeleteAsync($"/api/meals/wishlist/{own.Id}", Ct)).StatusCode); // idempotent
Assert.Equal(HttpStatusCode.NoContent, (await family.DeleteAsync($"/api/meals/wishlist/{planned.Id}", Ct)).StatusCode);
Assert.Empty(await WishlistAsync(family, key));
}
[Fact]
public async Task Deleted_wishlist_dish_is_reactivated_by_a_new_wish_or_plan()
{
using var family = (await fixture.CreateSignedInUserAsync(Roles.Family, Ct)).Client;
var (_, child) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
using var _ = child;
var day = NewWeek();
var key = Guid.NewGuid().ToString("N")[..8];
var dish = $"Zurück{key}";
await child.PostAsJsonAsync("/api/meals/wishes", new CreateMealWishRequest(dish), Ct);
var first = Assert.Single(await WishlistAsync(family, key));
Assert.Equal(HttpStatusCode.NoContent, (await family.DeleteAsync($"/api/meals/wishlist/{first.Id}", Ct)).StatusCode);
Assert.Empty(await WishlistAsync(family, key));
Assert.Empty((await family.GetFromJsonAsync<List<DishSuggestionDto>>($"/api/meals/dishes?q={key}", Ct))!);
// Erneuter Wunsch: dasselbe Gericht (gleiche Id) ist wieder da – kein Duplikat, kein Index-Fehler
await child.PostAsJsonAsync("/api/meals/wishes", new CreateMealWishRequest(dish.ToUpperInvariant()), Ct);
var again = Assert.Single(await WishlistAsync(family, key));
Assert.Equal(first.Id, again.Id);
// Löschen und über den Plan reaktivieren
await family.DeleteAsync($"/api/meals/wishlist/{first.Id}", Ct);
Assert.Empty(await WishlistAsync(family, key));
await CreateAsync(family, day, Lunch, dish);
var planned = Assert.Single(await WishlistAsync(family, key));
Assert.Equal((first.Id, 1), (planned.Id, planned.UseCount));
Assert.NotNull(planned.LastPlannedAt);
}
[Fact]
public async Task Changes_are_broadcast_via_signalr()
{
@@ -169,4 +169,49 @@ public sealed class MealPlanRulesTests
Assert.True(MealPlanRules.CanDeleteWish(AccessLevel.Full, sibling, MealWishStatus.Rejected, child));
Assert.False(MealPlanRules.CanDeleteWish(AccessLevel.None, child, MealWishStatus.Open, child));
}
[Fact]
public void Wishlist_is_visible_to_family_and_children_but_not_to_guests()
{
Assert.True(MealPlanRules.CanSeeWishlist(AccessLevel.Full));
Assert.True(MealPlanRules.CanSeeWishlist(AccessLevel.Restricted));
Assert.False(MealPlanRules.CanSeeWishlist(AccessLevel.None));
}
[Fact]
public void Child_deletes_only_own_wishlist_entries_that_were_never_planned()
{
var child = Guid.NewGuid();
var sibling = Guid.NewGuid();
Assert.True(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.Restricted, child, wasPlanned: false, child));
Assert.False(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.Restricted, child, wasPlanned: true, child));
Assert.False(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.Restricted, sibling, wasPlanned: false, child));
Assert.False(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.Restricted, null, wasPlanned: false, child));
Assert.True(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.Full, sibling, wasPlanned: true, child));
Assert.True(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.Full, null, wasPlanned: false, child));
Assert.False(MealPlanRules.CanDeleteWishlistEntry(AccessLevel.None, child, wasPlanned: false, child));
}
[Theory]
[InlineData(null, WishlistSort.LastPlanned)]
[InlineData("", WishlistSort.LastPlanned)]
[InlineData("unbekannt", WishlistSort.LastPlanned)]
[InlineData("mostPlanned", WishlistSort.MostPlanned)]
[InlineData(" MOSTPLANNED ", WishlistSort.MostPlanned)]
[InlineData("name", WishlistSort.Name)]
public void Wishlist_sort_falls_back_to_last_planned(string? input, string expected) =>
Assert.Equal(expected, MealPlanRules.NormalizeWishlistSort(input));
[Fact]
public void Deleted_dish_is_restored_when_wished_or_planned_again()
{
var dish = new Dish { Name = "Pizza", IsDeleted = true, DeletedAt = DateTimeOffset.UtcNow, DeletedBy = Guid.NewGuid() };
dish.Restore();
Assert.False(dish.IsDeleted);
Assert.Null(dish.DeletedAt);
Assert.Null(dish.DeletedBy);
}
}
@@ -56,6 +56,18 @@ public sealed class MealValidatorTests
Assert.False(new AcceptMealWishRequestValidator().Validate(new AcceptMealWishRequest(Day, new string('x', 41))).IsValid);
}
[Fact]
public void Planning_a_wishlist_dish_needs_day_and_meal()
{
var validator = new PlanWishlistDishRequestValidator();
Assert.True(validator.Validate(new PlanWishlistDishRequest(Day, "Mittagessen")).IsValid);
Assert.True(validator.Validate(new PlanWishlistDishRequest(Day, "Mittagessen", Replace: true)).IsValid);
Assert.False(validator.Validate(new PlanWishlistDishRequest(default, "Mittagessen")).IsValid);
Assert.False(validator.Validate(new PlanWishlistDishRequest(Day, " ")).IsValid);
Assert.False(validator.Validate(new PlanWishlistDishRequest(Day, new string('x', MealPlanEntry.MealTypeMaxLength + 1))).IsValid);
}
[Fact]
public void Ingredient_transfer_limits_the_number_of_ingredients()
{
@@ -28,8 +28,19 @@ internal sealed class FakeMealsApi : IMealsApi
new("Pfannkuchen", null, [], 3),
];
public List<WishlistDishDto> Wishlist { get; } = [];
public List<(string? Query, string Sort)> WishlistQueries { get; } = [];
private int _version;
public WishlistDishDto SeedDish(string name, int used = 0, int wished = 0, DateTimeOffset? lastPlanned = null, Guid? wishedBy = null, string? wishedByName = null)
{
var dish = new WishlistDishDto(Guid.NewGuid(), name, null, null, [], wished, used, lastPlanned, wishedBy, wishedByName);
Wishlist.Add(dish);
return dish;
}
public MealPlanEntryDto Seed(DateOnly date, string mealType, string dish, IReadOnlyList<string>? ingredients = null, string? note = null) =>
Add(new MealPlanEntryDto(Guid.NewGuid(), date, mealType, dish, note, null, ingredients ?? [], null, null, DateTimeOffset.UtcNow, NextVersion()));
@@ -131,6 +142,36 @@ internal sealed class FakeMealsApi : IMealsApi
public Task<ApiResult<bool>> DeleteWishAsync(Guid id, CancellationToken ct = default) => Ok(Wishes.RemoveAll(w => w.Id == id) >= 0);
public Task<ApiResult<List<WishlistDishDto>>> GetWishlistAsync(string? query, string sort, CancellationToken ct = default)
{
WishlistQueries.Add((query, sort));
var items = Wishlist.Where(d => string.IsNullOrWhiteSpace(query) || d.Name.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase));
var ordered = sort switch
{
WishlistSort.MostPlanned => items.OrderByDescending(d => d.UseCount),
WishlistSort.Name => items.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase),
_ => items.OrderByDescending(d => d.LastPlannedAt),
};
return Ok(ordered.ToList());
}
public Task<ApiResult<MealPlanEntryDto>> PlanWishlistDishAsync(Guid id, PlanWishlistDishRequest request, CancellationToken ct = default)
{
var dish = Wishlist.Single(d => d.Id == id);
if (Entries.Any(e => e.Date == request.Date && e.MealType == request.MealType) && !request.Replace)
{
return Task.FromResult(ApiResult<MealPlanEntryDto>.Fail(new ApiProblem { Status = 409, Detail = "Platz belegt." }));
}
Entries.RemoveAll(e => e.Date == request.Date && e.MealType == request.MealType);
var entry = Add(new MealPlanEntryDto(Guid.NewGuid(), request.Date, request.MealType, dish.Name, dish.Note, dish.RecipeUrl, dish.Ingredients,
null, null, DateTimeOffset.UtcNow, NextVersion()));
Wishlist[Wishlist.IndexOf(dish)] = dish with { UseCount = dish.UseCount + 1, LastPlannedAt = DateTimeOffset.UtcNow };
return Ok(entry);
}
public Task<ApiResult<bool>> DeleteWishlistDishAsync(Guid id, CancellationToken ct = default) => Ok(Wishlist.RemoveAll(d => d.Id == id) >= 0);
private MealPlanEntryDto Add(MealPlanEntryDto entry)
{
Entries.Add(entry);
@@ -300,6 +300,113 @@ public sealed class MealsPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-meal-list .fp-meal-dish")));
}
[Fact]
public void Wishlist_shows_dishes_with_planning_history()
{
_api.SeedDish("Pizza", used: 3, wished: 2, lastPlanned: new DateTimeOffset(2026, 10, 5, 12, 0, 0, TimeSpan.Zero), wishedByName: "Anna");
_api.SeedDish("Gulasch");
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
Assert.Equal(2, cut.FindAll(".fp-wishlist-item").Count);
var pizza = cut.FindAll(".fp-wishlist-item").Single(i => i.QuerySelector(".fp-wishlist-name")!.TextContent == "Pizza");
Assert.Contains("zuletzt geplant am 05.10.2026", pizza.QuerySelector(".fp-wishlist-meta")!.TextContent);
Assert.Contains("3-mal geplant", pizza.QuerySelector(".fp-wishlist-meta")!.TextContent);
Assert.Contains("2-mal gewünscht (zuletzt Anna)", pizza.QuerySelector(".fp-wishlist-meta")!.TextContent);
var gulasch = cut.FindAll(".fp-wishlist-item").Single(i => i.QuerySelector(".fp-wishlist-name")!.TextContent == "Gulasch");
Assert.Contains("noch nie geplant", gulasch.QuerySelector(".fp-wishlist-meta")!.TextContent);
}
[Fact]
public void Wishlist_is_searched_and_sorted_through_the_api()
{
_api.SeedDish("Pizza", used: 1);
_api.SeedDish("Nudeln", used: 5);
_api.SeedDish("Pizzabrötchen", used: 2);
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
cut.Find("#wishlist-search").Change("pizza");
cut.WaitForAssertion(() => Assert.Equal(["Pizza", "Pizzabrötchen"], cut.FindAll(".fp-wishlist-name").Select(n => n.TextContent).Order()));
Assert.Equal(("pizza", WishlistSort.LastPlanned), _api.WishlistQueries[^1]);
cut.Find(".fp-wishlist-sort-mostPlanned").Click();
cut.WaitForAssertion(() => Assert.Equal(["Pizzabrötchen", "Pizza"], cut.FindAll(".fp-wishlist-name").Select(n => n.TextContent)));
cut.Find("#wishlist-search").Change("kuchen");
cut.WaitForAssertion(() => Assert.Contains("Kein Gericht gefunden.", cut.Find(".fp-wishlist-empty").TextContent));
}
[Fact]
public void Family_plans_a_wishlist_dish_into_a_free_slot()
{
var dish = _api.SeedDish("Pizza");
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
cut.Find(".fp-wishlist-plan").Click();
cut.WaitForElement(Slot(Today.AddDays(1), "Abendessen")).Click();
cut.WaitForAssertion(() => Assert.Equal("Pizza", cut.Find(".fp-meal-list .fp-meal-dish").TextContent));
var entry = Assert.Single(_api.Entries);
Assert.Equal((Today.AddDays(1), "Abendessen", "Pizza"), (entry.Date, entry.MealType, entry.DishName));
cut.WaitForAssertion(() => Assert.Contains("1-mal geplant", cut.Find(".fp-wishlist-meta").TextContent));
Assert.Equal(dish.Id, _api.Wishlist.Single().Id);
}
[Fact]
public void Planning_a_wishlist_dish_into_an_occupied_slot_asks_before_replacing()
{
_api.Seed(Today, "Mittagessen", "Linsen");
_api.SeedDish("Pizza");
SignInAs(Roles.Admin);
var cut = Render<MealsPageHost>();
cut.Find(".fp-wishlist-plan").Click();
cut.WaitForElement(Slot(Today, "Mittagessen")).Click();
cut.WaitForElement(".fp-accept-replace");
Assert.Contains("Linsen", cut.Find(".fp-accept-replace").TextContent);
Assert.Equal("Linsen", Assert.Single(_api.Entries).DishName);
cut.Find(".fp-accept-replace-confirm").Click();
cut.WaitForAssertion(() => Assert.Equal("Pizza", Assert.Single(_api.Entries).DishName));
}
[Fact]
public void Child_sees_the_wishlist_but_cannot_plan_and_deletes_only_own_unplanned_dishes()
{
_api.SeedDish("Eigenes", wishedBy: UserId);
_api.SeedDish("Geplantes", used: 2, wishedBy: UserId, lastPlanned: DateTimeOffset.UtcNow);
_api.SeedDish("Fremdes", wishedBy: Guid.NewGuid());
SignInAs(Roles.Child);
var cut = Render<MealsPageHost>();
Assert.Equal(3, cut.FindAll(".fp-wishlist-item").Count);
Assert.Empty(cut.FindAll(".fp-wishlist-plan"));
var deletable = cut.FindAll(".fp-wishlist-item").Where(i => i.QuerySelector(".fp-wishlist-delete") is not null).ToList();
Assert.Equal("Eigenes", Assert.Single(deletable).QuerySelector(".fp-wishlist-name")!.TextContent);
cut.Find(".fp-wishlist-delete").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain(_api.Wishlist, d => d.Name == "Eigenes"));
Assert.Equal(2, cut.FindAll(".fp-wishlist-item").Count);
}
[Fact]
public void Family_deletes_any_wishlist_dish()
{
_api.SeedDish("Pizza", used: 4, lastPlanned: DateTimeOffset.UtcNow, wishedBy: Guid.NewGuid());
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
cut.Find(".fp-wishlist-delete").Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-wishlist-item")));
Assert.Empty(_api.Wishlist);
Assert.Contains("Die Wunschliste ist noch leer.", cut.Find(".fp-wishlist-empty").TextContent);
}
[Fact]
public void Navigation_moves_by_plan_length()
{