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>
This commit is contained in:
180
backend/Services/MealPlanService.cs
Normal file
180
backend/Services/MealPlanService.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user