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:
2026-04-14 19:10:10 +00:00
parent 660bcd1953
commit f58782774b
51 changed files with 4061 additions and 0 deletions

View 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);
}
}

View File

@@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore;
using MealPlanner.Data;
using MealPlanner.Models;
namespace MealPlanner.Services;
public class RecipeService(AppDbContext db, TheMealDbClient mealDbClient)
{
public async Task<List<Recipe>> GetOwnRecipesAsync(string userId)
{
return await db.Recipes
.Include(r => r.Ingredients)
.Where(r => r.UserId == userId)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync();
}
public async Task<Recipe?> GetByIdAsync(Guid id)
{
return await db.Recipes
.Include(r => r.Ingredients)
.FirstOrDefaultAsync(r => r.Id == id);
}
public async Task<Recipe?> GetByIdOrFetchAsync(Guid id)
{
var recipe = await GetByIdAsync(id);
return recipe;
}
public async Task<List<Recipe>> SearchAsync(string query, string userId)
{
// Local own recipes
var own = await db.Recipes
.Include(r => r.Ingredients)
.Where(r => r.UserId == userId && r.Title.ToLower().Contains(query.ToLower()))
.ToListAsync();
// External results
var external = await mealDbClient.SearchAsync(query);
// Deduplicate external by ExternalId
var existingExternalIds = await db.Recipes
.Where(r => r.Source == RecipeSource.TheMealDb && r.ExternalId != null)
.Select(r => r.ExternalId!)
.ToListAsync();
var newExternal = external
.Where(r => r.ExternalId != null && !existingExternalIds.Contains(r.ExternalId))
.ToList();
return [.. own, .. newExternal];
}
public async Task<Recipe> CreateAsync(string userId, Recipe recipe)
{
recipe.Id = Guid.NewGuid();
recipe.UserId = userId;
recipe.Source = RecipeSource.Own;
recipe.CreatedAt = DateTime.UtcNow;
foreach (var ing in recipe.Ingredients)
{
ing.Id = Guid.NewGuid();
ing.RecipeId = recipe.Id;
}
db.Recipes.Add(recipe);
await db.SaveChangesAsync();
return recipe;
}
public async Task<Recipe?> UpdateAsync(Guid id, string userId, Recipe updated)
{
var recipe = await db.Recipes
.Include(r => r.Ingredients)
.FirstOrDefaultAsync(r => r.Id == id);
if (recipe is null) return null;
if (recipe.UserId != userId) throw new UnauthorizedAccessException();
recipe.Title = updated.Title;
recipe.Instructions = updated.Instructions;
recipe.ImageUrl = updated.ImageUrl;
// Replace ingredients
db.RecipeIngredients.RemoveRange(recipe.Ingredients);
recipe.Ingredients = updated.Ingredients.Select(ing => new RecipeIngredient
{
Id = Guid.NewGuid(),
RecipeId = recipe.Id,
Name = ing.Name,
Amount = ing.Amount,
Unit = ing.Unit,
Category = ing.Category,
}).ToList();
await db.SaveChangesAsync();
return recipe;
}
public async Task<bool> DeleteAsync(Guid id, string userId)
{
var recipe = await db.Recipes.FirstOrDefaultAsync(r => r.Id == id);
if (recipe is null) return false;
if (recipe.UserId != userId) throw new UnauthorizedAccessException();
db.Recipes.Remove(recipe);
await db.SaveChangesAsync();
return true;
}
// Save an external recipe to DB so it can be referenced in meal plans
public async Task<Recipe> EnsurePersistedAsync(Recipe recipe)
{
if (recipe.ExternalId is not null)
{
var existing = await db.Recipes
.Include(r => r.Ingredients)
.FirstOrDefaultAsync(r => r.ExternalId == recipe.ExternalId);
if (existing is not null) return existing;
}
db.Recipes.Add(recipe);
await db.SaveChangesAsync();
return recipe;
}
}

View File

@@ -0,0 +1,91 @@
using Microsoft.EntityFrameworkCore;
using MealPlanner.Data;
using MealPlanner.Models;
namespace MealPlanner.Services;
public class ShoppingListService(AppDbContext db)
{
public async Task<List<ShoppingItem>> GetShoppingListAsync(Guid mealPlanId, string userId)
{
var plan = await db.MealPlans
.Include(p => p.Entries)
.ThenInclude(e => e.Recipe)
.ThenInclude(r => r.Ingredients)
.FirstOrDefaultAsync(p => p.Id == mealPlanId);
if (plan is null) return [];
if (plan.UserId != userId) throw new UnauthorizedAccessException();
var settings = await db.UserSettings.FindAsync(userId);
int householdSize = settings?.HouseholdSize ?? 2;
var checkedItems = await db.CheckedShoppingItems
.Where(c => c.MealPlanId == mealPlanId && c.UserId == userId)
.ToDictionaryAsync(c => c.ItemName.ToLowerInvariant(), c => c.IsChecked);
// Aggregate ingredients
var aggregated = new Dictionary<string, ShoppingItem>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in plan.Entries)
{
foreach (var ing in entry.Recipe.Ingredients)
{
var key = ing.Name.Trim().ToLowerInvariant();
if (!aggregated.TryGetValue(key, out var item))
{
item = new ShoppingItem
{
Name = ing.Name.Trim(),
Unit = ing.Unit,
Category = ing.Category,
TotalAmount = null,
IsChecked = checkedItems.TryGetValue(key, out var chk) && chk,
};
aggregated[key] = item;
}
if (ing.Amount.HasValue)
{
item.TotalAmount = (item.TotalAmount ?? 0) + ing.Amount.Value * householdSize;
}
}
}
return [.. aggregated.Values.OrderBy(i => i.Category).ThenBy(i => i.Name)];
}
public async Task<CheckedShoppingItem> ToggleCheckAsync(Guid mealPlanId, string itemName, string userId)
{
var plan = await db.MealPlans.FindAsync(mealPlanId);
if (plan is null) throw new KeyNotFoundException("Meal plan not found");
if (plan.UserId != userId) throw new UnauthorizedAccessException();
var key = itemName.ToLowerInvariant();
var existing = await db.CheckedShoppingItems
.FirstOrDefaultAsync(c => c.MealPlanId == mealPlanId && c.UserId == userId
&& c.ItemName.ToLower() == key);
if (existing is null)
{
existing = new CheckedShoppingItem
{
Id = Guid.NewGuid(),
MealPlanId = mealPlanId,
ItemName = itemName,
UserId = userId,
IsChecked = true,
};
db.CheckedShoppingItems.Add(existing);
}
else
{
existing.IsChecked = !existing.IsChecked;
}
await db.SaveChangesAsync();
return existing;
}
}

View File

@@ -0,0 +1,134 @@
using System.Text.Json;
using MealPlanner.Models;
namespace MealPlanner.Services;
public class TheMealDbClient(HttpClient httpClient)
{
private const string BaseUrl = "https://www.themealdb.com/api/json/v1/1/";
public async Task<Recipe?> GetRandomAsync()
{
var response = await httpClient.GetAsync($"{BaseUrl}random.php");
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
var meals = ParseMeals(json);
return meals.FirstOrDefault();
}
public async Task<List<Recipe>> SearchAsync(string query)
{
var response = await httpClient.GetAsync($"{BaseUrl}search.php?s={Uri.EscapeDataString(query)}");
if (!response.IsSuccessStatusCode) return [];
var json = await response.Content.ReadAsStringAsync();
return ParseMeals(json);
}
public async Task<Recipe?> GetByIdAsync(string id)
{
var response = await httpClient.GetAsync($"{BaseUrl}lookup.php?i={id}");
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
return ParseMeals(json).FirstOrDefault();
}
private static List<Recipe> ParseMeals(string json)
{
var result = new List<Recipe>();
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("meals", out var mealsEl) || mealsEl.ValueKind == JsonValueKind.Null)
return result;
foreach (var meal in mealsEl.EnumerateArray())
{
var recipe = MapMeal(meal);
if (recipe is not null)
result.Add(recipe);
}
return result;
}
private static Recipe? MapMeal(JsonElement meal)
{
var id = meal.GetStringOrNull("idMeal");
var title = meal.GetStringOrNull("strMeal");
if (id is null || title is null) return null;
var recipe = new Recipe
{
Id = Guid.NewGuid(),
ExternalId = id,
Title = title,
Instructions = meal.GetStringOrNull("strInstructions") ?? "",
ImageUrl = meal.GetStringOrNull("strMealThumb"),
Source = RecipeSource.TheMealDb,
CreatedAt = DateTime.UtcNow,
};
// Parse strIngredient1-20 / strMeasure1-20
for (int i = 1; i <= 20; i++)
{
var name = meal.GetStringOrNull($"strIngredient{i}");
var measure = meal.GetStringOrNull($"strMeasure{i}");
if (string.IsNullOrWhiteSpace(name)) continue;
var ingredient = new RecipeIngredient
{
Id = Guid.NewGuid(),
RecipeId = recipe.Id,
Name = name.Trim(),
};
if (!string.IsNullOrWhiteSpace(measure))
{
ParseMeasure(measure.Trim(), out var amount, out var unit);
ingredient.Amount = amount;
ingredient.Unit = unit;
}
recipe.Ingredients.Add(ingredient);
}
return recipe;
}
private static void ParseMeasure(string measure, out decimal? amount, out string? unit)
{
amount = null;
unit = null;
if (string.IsNullOrWhiteSpace(measure)) return;
// Try to extract leading number
var parts = measure.Trim().Split(' ', 2);
if (parts.Length > 0 && decimal.TryParse(parts[0], System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var parsed))
{
amount = parsed;
unit = parts.Length > 1 ? parts[1].Trim() : null;
}
else
{
unit = measure;
}
}
}
file static class JsonElementExtensions
{
public static string? GetStringOrNull(this JsonElement el, string propertyName)
{
if (el.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
{
var val = prop.GetString();
return string.IsNullOrWhiteSpace(val) ? null : val;
}
return null;
}
}