Files
mealplanner/backend/Services/MealPlanService.cs
Claude f58782774b feat: complete mealplanner app (backend + frontend + deployment)
.NET 8 backend with Zitadel JWT auth, TheMealDB integration,
weekly meal plan generation, shopping list aggregation.
Vue 3 + Tailwind 4 frontend with dark emerald theme,
manual OIDC PKCE auth, all views implemented.
Multi-stage Dockerfile with nginx reverse proxy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:10:10 +00:00

181 lines
5.9 KiB
C#

using Microsoft.EntityFrameworkCore;
using MealPlanner.Data;
using MealPlanner.Models;
namespace MealPlanner.Services;
public class MealPlanService(AppDbContext db, TheMealDbClient mealDbClient, RecipeService recipeService)
{
public async Task<MealPlan> GenerateWeekPlanAsync(string userId, DateOnly weekStart)
{
// Remove existing plan for this week if present
var existing = await db.MealPlans
.Include(p => p.Entries)
.FirstOrDefaultAsync(p => p.UserId == userId && p.WeekStart == weekStart);
if (existing is not null)
{
db.MealPlans.Remove(existing);
await db.SaveChangesAsync();
}
var plan = new MealPlan
{
Id = Guid.NewGuid(),
UserId = userId,
WeekStart = weekStart,
CreatedAt = DateTime.UtcNow,
};
// Get own recipes to optionally mix in (up to 3)
var ownRecipes = await db.Recipes
.Include(r => r.Ingredients)
.Where(r => r.UserId == userId)
.ToListAsync();
var usedRecipeIds = new HashSet<Guid>();
var entries = new List<MealPlanEntry>();
// Shuffle own recipes and pick up to 3
var ownToUse = ownRecipes
.OrderBy(_ => Random.Shared.Next())
.Take(Math.Min(3, ownRecipes.Count))
.ToList();
int ownIndex = 0;
for (int dayOffset = 0; dayOffset < 7; dayOffset++)
{
var date = weekStart.AddDays(dayOffset);
Recipe? recipe = null;
// Use an own recipe for this slot if available
if (ownIndex < ownToUse.Count)
{
recipe = ownToUse[ownIndex++];
}
else
{
// Fetch random from TheMealDB, retry if duplicate
for (int attempt = 0; attempt < 5; attempt++)
{
var candidate = await mealDbClient.GetRandomAsync();
if (candidate is null) continue;
if (candidate.ExternalId is not null && usedRecipeIds.Any(id =>
db.Recipes.Any(r => r.Id == id && r.ExternalId == candidate.ExternalId)))
continue;
recipe = await recipeService.EnsurePersistedAsync(candidate);
break;
}
}
if (recipe is null) continue;
usedRecipeIds.Add(recipe.Id);
entries.Add(new MealPlanEntry
{
Id = Guid.NewGuid(),
MealPlanId = plan.Id,
Date = date,
RecipeId = recipe.Id,
});
}
plan.Entries = entries;
db.MealPlans.Add(plan);
await db.SaveChangesAsync();
return await LoadPlanWithDetailsAsync(plan.Id) ?? plan;
}
public async Task<MealPlan?> GetCurrentPlanAsync(string userId)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var weekStart = GetWeekStart(today);
return await GetPlanAsync(userId, weekStart);
}
public async Task<MealPlan?> GetPlanAsync(string userId, DateOnly weekStart)
{
var plan = await db.MealPlans
.Where(p => p.UserId == userId && p.WeekStart == weekStart)
.Select(p => p.Id)
.FirstOrDefaultAsync();
if (plan == default) return null;
return await LoadPlanWithDetailsAsync(plan);
}
public async Task<MealPlanEntry?> SwapEntryAsync(Guid entryId, Guid newRecipeId, string userId)
{
var entry = await db.MealPlanEntries
.Include(e => e.MealPlan)
.FirstOrDefaultAsync(e => e.Id == entryId);
if (entry is null) return null;
if (entry.MealPlan.UserId != userId) throw new UnauthorizedAccessException();
var recipe = await db.Recipes.FindAsync(newRecipeId);
if (recipe is null) return null;
entry.RecipeId = newRecipeId;
await db.SaveChangesAsync();
return await db.MealPlanEntries
.Include(e => e.Recipe).ThenInclude(r => r.Ingredients)
.FirstOrDefaultAsync(e => e.Id == entryId);
}
public async Task<MealPlanEntry?> RerollEntryAsync(Guid entryId, string userId)
{
var entry = await db.MealPlanEntries
.Include(e => e.MealPlan).ThenInclude(p => p.Entries)
.FirstOrDefaultAsync(e => e.Id == entryId);
if (entry is null) return null;
if (entry.MealPlan.UserId != userId) throw new UnauthorizedAccessException();
var usedExternalIds = await db.MealPlanEntries
.Where(e => e.MealPlanId == entry.MealPlanId)
.Include(e => e.Recipe)
.Select(e => e.Recipe.ExternalId)
.ToListAsync();
Recipe? newRecipe = null;
for (int attempt = 0; attempt < 10; attempt++)
{
var candidate = await mealDbClient.GetRandomAsync();
if (candidate is null) continue;
if (usedExternalIds.Contains(candidate.ExternalId)) continue;
newRecipe = await recipeService.EnsurePersistedAsync(candidate);
break;
}
if (newRecipe is null) return null;
entry.RecipeId = newRecipe.Id;
await db.SaveChangesAsync();
return await db.MealPlanEntries
.Include(e => e.Recipe).ThenInclude(r => r.Ingredients)
.FirstOrDefaultAsync(e => e.Id == entryId);
}
private async Task<MealPlan?> LoadPlanWithDetailsAsync(Guid planId)
{
return await db.MealPlans
.Include(p => p.Entries)
.ThenInclude(e => e.Recipe)
.ThenInclude(r => r.Ingredients)
.FirstOrDefaultAsync(p => p.Id == planId);
}
public static DateOnly GetWeekStart(DateOnly date)
{
// Monday as week start
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
return date.AddDays(-diff);
}
}