From 46e55475273c57ac481af9afec39827c2153dab6 Mon Sep 17 00:00:00 2001 From: Gustavo Hidalgo Date: Sun, 14 Jun 2026 21:11:35 -0400 Subject: [PATCH 1/5] Add cook history and recipe recommendations Adds cook event tracking (one event per user/recipe/day, idempotent) and Jaccard-similarity-based recipe recommendations weighted by ingredient overlap, ownership, favorites, and novelty. Co-Authored-By: Claude Sonnet 4.6 --- .../Contracts/RecipeRecommendationDto.cs | 62 +++++ src/CookTime/Program.cs | 127 +++++++-- ...ecipe_cook_history_and_recommendations.sql | 240 ++++++++++++++++++ src/CookTime/Services/CookTimeDB.cs | 131 ++++++++++ src/CookTimeTests/API/ApiIntegrationTests.cs | 42 +++ .../TestCookHistoryAndRecommendations.cs | 168 ++++++++++++ 6 files changed, 751 insertions(+), 19 deletions(-) create mode 100644 src/CookTime/Models/Contracts/RecipeRecommendationDto.cs create mode 100644 src/CookTime/Scripts/017_recipe_cook_history_and_recommendations.sql create mode 100644 src/CookTimeTests/TestCookHistoryAndRecommendations.cs diff --git a/src/CookTime/Models/Contracts/RecipeRecommendationDto.cs b/src/CookTime/Models/Contracts/RecipeRecommendationDto.cs new file mode 100644 index 0000000..e50ba98 --- /dev/null +++ b/src/CookTime/Models/Contracts/RecipeRecommendationDto.cs @@ -0,0 +1,62 @@ +namespace CookTime.Models.Contracts; + +public class CookHistoryEventDto +{ + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("userId")] + public Guid UserId { get; set; } + + [JsonPropertyName("recipeId")] + public Guid RecipeId { get; set; } + + [JsonPropertyName("cookedAt")] + public DateOnly CookedAt { get; set; } + + [JsonPropertyName("createdDate")] + public DateTime CreatedDate { get; set; } +} + +public class CookHistoryEventWithRecipeDto : CookHistoryEventDto +{ + [JsonPropertyName("recipe")] + public RecipeSummaryDto Recipe { get; set; } = null!; +} + +public record CookHistoryUpsertRequest( + [property: JsonPropertyName("cookedAt")] DateOnly? CookedAt +); + +public class RecommendationScoreBreakdownDto +{ + [JsonPropertyName("ingredientSimilarity")] + public double IngredientSimilarity { get; set; } + + [JsonPropertyName("ownedByUser")] + public double OwnedByUser { get; set; } + + [JsonPropertyName("favoritedByUser")] + public double FavoritedByUser { get; set; } + + [JsonPropertyName("novelty")] + public double Novelty { get; set; } + + [JsonPropertyName("dietMatch")] + public double DietMatch { get; set; } +} + +public class RecipeRecommendationDto +{ + [JsonPropertyName("recipe")] + public RecipeSummaryDto Recipe { get; set; } = null!; + + [JsonPropertyName("score")] + public double Score { get; set; } + + [JsonPropertyName("scoreBreakdown")] + public RecommendationScoreBreakdownDto ScoreBreakdown { get; set; } = new(); + + [JsonPropertyName("reasons")] + public List Reasons { get; set; } = new(); +} diff --git a/src/CookTime/Program.cs b/src/CookTime/Program.cs index 7f741e7..1ad9ed3 100644 --- a/src/CookTime/Program.cs +++ b/src/CookTime/Program.cs @@ -192,15 +192,33 @@ { return await cooktime.GetNewRecipesAsync(); }); -app.MapGet("/api/multipartrecipe/featured", async (CookTimeDB cooktime) => -{ - return await cooktime.GetFeaturedRecipesAsync(); -}); -app.MapGet("/api/multipartrecipe/{id:guid}", async (CookTimeDB cooktime, NutritionService nutrition, Guid id) => -{ - var recipe = await cooktime.GetRecipeByIdAsync(id); - if (recipe == null) - { +app.MapGet("/api/multipartrecipe/featured", async (CookTimeDB cooktime) => +{ + return await cooktime.GetFeaturedRecipesAsync(); +}); +app.MapGet("/api/multipartrecipe/{id:guid}/recommendations", async (HttpContext context, CookTimeDB cooktime, Guid id, int limit = 6) => +{ + var recipe = await cooktime.GetRecipeByIdAsync(id); + if (recipe == null) + { + return Results.NotFound(); + } + + Guid? userId = null; + var userIdClaim = context.User.FindFirst("db_user_id")?.Value; + if (userIdClaim != null && Guid.TryParse(userIdClaim, out var parsedUserId)) + { + userId = parsedUserId; + } + + var recommendations = await cooktime.GetRecipeRecommendationsAsync(id, userId, Math.Clamp(limit, 1, 24)); + return Results.Ok(recommendations); +}); +app.MapGet("/api/multipartrecipe/{id:guid}", async (CookTimeDB cooktime, NutritionService nutrition, Guid id) => +{ + var recipe = await cooktime.GetRecipeByIdAsync(id); + if (recipe == null) + { return Results.NotFound(); } recipe = await nutrition.EnrichWithDensitiesAsync(recipe); @@ -257,10 +275,10 @@ .MapListRoutes() .MapRecipeGenerationRoutes(); -authenticatedApi.MapGet("/multipartrecipe/mine", async (HttpContext context, CookTimeDB cooktime, int page = 1, int pageSize = 30) => -{ - var userId = (Guid)context.Items["UserId"]!; - var recipes = await cooktime.GetRecipesByUserAsync(userId, pageSize, page); +authenticatedApi.MapGet("/multipartrecipe/mine", async (HttpContext context, CookTimeDB cooktime, int page = 1, int pageSize = 30) => +{ + var userId = (Guid)context.Items["UserId"]!; + var recipes = await cooktime.GetRecipesByUserAsync(userId, pageSize, page); return Results.Ok(new PagedResult { Results = recipes, @@ -268,12 +286,83 @@ PageCount = (int)Math.Ceiling((double)recipes.Count / pageSize), PageSize = pageSize, RowCount = recipes.Count - }); -}); - -authenticatedApi.MapPost("/multipartrecipe/create", async (HttpContext context, CookTimeDB cooktime, RecipeCreateRequest request) => -{ - var userId = (Guid)context.Items["UserId"]!; + }); +}); + +authenticatedApi.MapGet("/multipartrecipe/{recipeId:guid}/cook-history", async (HttpContext context, CookTimeDB cooktime, Guid recipeId) => +{ + var userId = (Guid)context.Items["UserId"]!; + var history = await cooktime.GetCookHistoryAsync(userId, recipeId); + return Results.Ok(history); +}); + +authenticatedApi.MapGet("/cook-history", async (HttpContext context, CookTimeDB cooktime) => +{ + var userId = (Guid)context.Items["UserId"]!; + var history = await cooktime.GetCookHistoryAsync(userId); + return Results.Ok(history); +}); + +authenticatedApi.MapPost("/multipartrecipe/{recipeId:guid}/cook-history", async (HttpContext context, CookTimeDB cooktime, Guid recipeId) => +{ + var userId = (Guid)context.Items["UserId"]!; + CookHistoryUpsertRequest? request = null; + if (context.Request.ContentLength is > 0) + { + request = await context.Request.ReadFromJsonAsync(); + } + + try + { + var cookEvent = await cooktime.CreateCookHistoryEventAsync(userId, recipeId, request?.CookedAt); + return Results.Created($"/api/cook-history/{cookEvent.Id}", cookEvent); + } + catch (Npgsql.PostgresException ex) when (ex.Message.Contains("not found")) + { + return Results.NotFound(); + } +}); + +authenticatedApi.MapPatch("/cook-history/{eventId:guid}", async (HttpContext context, CookTimeDB cooktime, Guid eventId, CookHistoryUpsertRequest request) => +{ + var userId = (Guid)context.Items["UserId"]!; + if (request.CookedAt == null) + { + return Results.BadRequest(new { error = "cookedAt is required" }); + } + + try + { + var cookEvent = await cooktime.UpdateCookHistoryEventAsync(userId, eventId, request.CookedAt.Value); + if (cookEvent == null) + { + return Results.NotFound(); + } + return Results.Ok(cookEvent); + } + catch (Npgsql.PostgresException ex) when (ex.Message.Contains("not found")) + { + return Results.NotFound(); + } +}); + +authenticatedApi.MapDelete("/cook-history/{eventId:guid}", async (HttpContext context, CookTimeDB cooktime, Guid eventId) => +{ + var userId = (Guid)context.Items["UserId"]!; + try + { + await cooktime.DeleteCookHistoryEventAsync(userId, eventId); + return Results.NoContent(); + } + catch (Npgsql.PostgresException ex) when (ex.Message.Contains("not found")) + { + return Results.NotFound(); + } +}); + +authenticatedApi.MapPost("/multipartrecipe/create", async (HttpContext context, CookTimeDB cooktime, RecipeCreateRequest request) => +{ + var userId = (Guid)context.Items["UserId"]!; var recipeId = await cooktime.CreateRecipeAsync(new RecipeCreateDto { Name = request.Name, diff --git a/src/CookTime/Scripts/017_recipe_cook_history_and_recommendations.sql b/src/CookTime/Scripts/017_recipe_cook_history_and_recommendations.sql new file mode 100644 index 0000000..50206bf --- /dev/null +++ b/src/CookTime/Scripts/017_recipe_cook_history_and_recommendations.sql @@ -0,0 +1,240 @@ +-- Migration: Recipe cook history and recommendations +-- Tracks when users cook recipes and recommends related recipes. +-- One cook event per user/recipe/day (idempotent logging). + +CREATE TABLE IF NOT EXISTS cooktime.recipe_cook_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES cooktime.users(id), + recipe_id uuid NOT NULL REFERENCES cooktime.recipes(id) ON DELETE CASCADE, + cooked_at date NOT NULL DEFAULT current_date, + created_date timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_recipe_cook_events_user_recipe_cooked_at_unique +ON cooktime.recipe_cook_events(user_id, recipe_id, cooked_at); + +CREATE INDEX IF NOT EXISTS idx_recipe_cook_events_user_recipe_cooked_at +ON cooktime.recipe_cook_events(user_id, recipe_id, cooked_at DESC); + +CREATE INDEX IF NOT EXISTS idx_recipe_cook_events_user_cooked_at +ON cooktime.recipe_cook_events(user_id, cooked_at DESC); + +CREATE INDEX IF NOT EXISTS idx_recipe_cook_events_recipe_id +ON cooktime.recipe_cook_events(recipe_id); + +CREATE OR REPLACE FUNCTION cooktime.cook_event_to_json(e cooktime.recipe_cook_events) +RETURNS jsonb AS $$ +BEGIN + RETURN jsonb_build_object( + 'id', e.id, + 'userId', e.user_id, + 'recipeId', e.recipe_id, + 'cookedAt', e.cooked_at, + 'createdDate', e.created_date + ); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION cooktime.create_recipe_cook_event( + p_user_id uuid, + p_recipe_id uuid, + p_cooked_at date DEFAULT NULL +) +RETURNS jsonb AS $$ +DECLARE + new_event cooktime.recipe_cook_events; +BEGIN + IF NOT EXISTS (SELECT 1 FROM cooktime.recipes WHERE id = p_recipe_id) THEN + RAISE EXCEPTION 'Recipe % not found', p_recipe_id; + END IF; + + INSERT INTO cooktime.recipe_cook_events (user_id, recipe_id, cooked_at) + VALUES (p_user_id, p_recipe_id, COALESCE(p_cooked_at, current_date)) + ON CONFLICT (user_id, recipe_id, cooked_at) DO UPDATE SET cooked_at = excluded.cooked_at + RETURNING * INTO new_event; + + RETURN cooktime.cook_event_to_json(new_event); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION cooktime.get_recipe_cook_history( + p_user_id uuid, + p_recipe_id uuid +) +RETURNS jsonb AS $$ +BEGIN + RETURN COALESCE( + ( + SELECT jsonb_agg(cooktime.cook_event_to_json(e) ORDER BY e.cooked_at DESC, e.created_date DESC) + FROM cooktime.recipe_cook_events e + WHERE e.user_id = p_user_id + AND e.recipe_id = p_recipe_id + ), + '[]'::jsonb + ); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION cooktime.update_recipe_cook_event( + p_user_id uuid, + p_event_id uuid, + p_cooked_at date +) +RETURNS jsonb AS $$ +DECLARE + updated_event cooktime.recipe_cook_events; +BEGIN + UPDATE cooktime.recipe_cook_events + SET cooked_at = p_cooked_at + WHERE id = p_event_id + AND user_id = p_user_id + RETURNING * INTO updated_event; + + IF updated_event.id IS NULL THEN + RAISE EXCEPTION 'Cook event % not found', p_event_id; + END IF; + + RETURN cooktime.cook_event_to_json(updated_event); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION cooktime.delete_recipe_cook_event( + p_user_id uuid, + p_event_id uuid +) +RETURNS boolean AS $$ +DECLARE + deleted_count integer; +BEGIN + DELETE FROM cooktime.recipe_cook_events + WHERE id = p_event_id + AND user_id = p_user_id; + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + + IF deleted_count = 0 THEN + RAISE EXCEPTION 'Cook event % not found', p_event_id; + END IF; + + RETURN true; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION cooktime.recommend_recipes( + p_source_recipe_id uuid, + p_user_id uuid DEFAULT NULL, + p_limit integer DEFAULT 6 +) +RETURNS SETOF jsonb AS $$ +BEGIN + RETURN QUERY + WITH source_ingredients AS ( + SELECT DISTINCT ir.ingredient_id + FROM cooktime.recipe_components rc + JOIN cooktime.ingredient_requirements ir ON ir.recipe_component_id = rc.id + WHERE rc.recipe_id = p_source_recipe_id + AND ir.ingredient_id IS NOT NULL + ), + candidate_ingredients AS ( + SELECT r.id AS recipe_id, ir.ingredient_id + FROM cooktime.recipes r + JOIN cooktime.recipe_components rc ON rc.recipe_id = r.id + JOIN cooktime.ingredient_requirements ir ON ir.recipe_component_id = rc.id + WHERE r.id <> p_source_recipe_id + AND ir.ingredient_id IS NOT NULL + GROUP BY r.id, ir.ingredient_id + ), + source_count AS ( + SELECT COUNT(*)::double precision AS ingredient_count + FROM source_ingredients + ), + candidate_scores AS ( + SELECT + r.id AS recipe_id, + CASE + WHEN (sc.ingredient_count + COUNT(ci.ingredient_id) - COUNT(si.ingredient_id)) = 0 THEN 0::double precision + ELSE COUNT(si.ingredient_id)::double precision / (sc.ingredient_count + COUNT(ci.ingredient_id) - COUNT(si.ingredient_id))::double precision + END AS ingredient_similarity, + CASE WHEN p_user_id IS NOT NULL AND r.owner_id = p_user_id THEN 1::double precision ELSE 0::double precision END AS owned_by_user, + CASE WHEN p_user_id IS NOT NULL AND EXISTS ( + SELECT 1 + FROM cooktime.recipe_lists rl + JOIN cooktime.recipe_requirements rr ON rr.recipe_list_id = rl.id + WHERE rl.owner_id = p_user_id + AND lower(rl.name) = 'favorites' + AND rr.recipe_id = r.id + ) THEN 1::double precision ELSE 0::double precision END AS favorited_by_user, + CASE + WHEN p_user_id IS NULL THEN 0::double precision + WHEN last_cooked.last_cooked_at IS NULL THEN 1::double precision + WHEN current_date - last_cooked.last_cooked_at <= 7 THEN 0::double precision + WHEN current_date - last_cooked.last_cooked_at <= 30 THEN 0.5::double precision + ELSE 1::double precision + END AS novelty + FROM cooktime.recipes r + CROSS JOIN source_count sc + LEFT JOIN candidate_ingredients ci ON ci.recipe_id = r.id + LEFT JOIN source_ingredients si ON si.ingredient_id = ci.ingredient_id + LEFT JOIN LATERAL ( + SELECT MAX(e.cooked_at) AS last_cooked_at + FROM cooktime.recipe_cook_events e + WHERE p_user_id IS NOT NULL + AND e.user_id = p_user_id + AND e.recipe_id = r.id + ) last_cooked ON true + WHERE r.id <> p_source_recipe_id + GROUP BY r.id, r.owner_id, sc.ingredient_count, last_cooked.last_cooked_at + ), + weighted_scores AS ( + SELECT + cs.recipe_id, + cs.ingredient_similarity, + CASE WHEN p_user_id IS NULL THEN 0::double precision ELSE cs.owned_by_user * 0.15 END AS owned_contribution, + CASE WHEN p_user_id IS NULL THEN 0::double precision ELSE cs.favorited_by_user * 0.15 END AS favorite_contribution, + CASE WHEN p_user_id IS NULL THEN 0::double precision ELSE cs.novelty * 0.10 END AS novelty_contribution, + CASE + WHEN p_user_id IS NULL THEN cs.ingredient_similarity + ELSE cs.ingredient_similarity * 0.60 + + cs.owned_by_user * 0.15 + + cs.favorited_by_user * 0.15 + + cs.novelty * 0.10 + END AS total_score, + cs.owned_by_user, + cs.favorited_by_user, + cs.novelty + FROM candidate_scores cs + ) + SELECT jsonb_build_object( + 'recipe', cooktime.recipe_to_summary(r), + 'score', ws.total_score, + 'scoreBreakdown', jsonb_build_object( + 'ingredientSimilarity', CASE WHEN p_user_id IS NULL THEN ws.ingredient_similarity ELSE ws.ingredient_similarity * 0.60 END, + 'ownedByUser', ws.owned_contribution, + 'favoritedByUser', ws.favorite_contribution, + 'novelty', ws.novelty_contribution, + 'dietMatch', 0 + ), + 'reasons', ( + SELECT jsonb_agg(reason) + FROM ( + SELECT 'Similar ingredients' AS reason WHERE ws.ingredient_similarity > 0 + UNION ALL + SELECT 'Your recipe' WHERE ws.owned_by_user > 0 + UNION ALL + SELECT 'Favorite' WHERE ws.favorited_by_user > 0 + UNION ALL + SELECT 'Not cooked recently' WHERE p_user_id IS NOT NULL AND ws.novelty > 0 + ) reasons + ) + ) + FROM weighted_scores ws + JOIN cooktime.recipes r ON r.id = ws.recipe_id + WHERE ws.ingredient_similarity > 0 + OR (p_user_id IS NOT NULL AND (ws.owned_by_user > 0 OR ws.favorited_by_user > 0)) + ORDER BY + CASE WHEN ws.ingredient_similarity > 0 THEN 0 ELSE 1 END, + ws.total_score DESC, + r.created_date DESC + LIMIT GREATEST(0, p_limit); +END; +$$ LANGUAGE plpgsql; diff --git a/src/CookTime/Services/CookTimeDB.cs b/src/CookTime/Services/CookTimeDB.cs index 18452a0..570aa4f 100644 --- a/src/CookTime/Services/CookTimeDB.cs +++ b/src/CookTime/Services/CookTimeDB.cs @@ -193,6 +193,137 @@ public async Task> GetRecipesByUserAsync(Guid userId, int return await ReadRecipeSummaryListAsync(cmd); } + public async Task CreateCookHistoryEventAsync(Guid userId, Guid recipeId, DateOnly? cookedAt = null) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var cmd = new NpgsqlCommand("SELECT cooktime.create_recipe_cook_event($1, $2, $3::date)", conn); + + cmd.Parameters.AddWithValue(userId); + cmd.Parameters.AddWithValue(recipeId); + cmd.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Date, + Value = cookedAt.HasValue ? (object)cookedAt.Value : DBNull.Value + }); + + var result = await cmd.ExecuteScalarAsync(); + return JsonSerializer.Deserialize(result!.ToString()!, JsonOptions)!; + } + + public async Task> GetCookHistoryAsync(Guid userId, Guid recipeId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var cmd = new NpgsqlCommand("SELECT cooktime.get_recipe_cook_history($1, $2)", conn); + + cmd.Parameters.AddWithValue(userId); + cmd.Parameters.AddWithValue(recipeId); + + var result = await cmd.ExecuteScalarAsync(); + if (result == null || result == DBNull.Value) + { + return []; + } + + return JsonSerializer.Deserialize>(result.ToString()!, JsonOptions) ?? []; + } + + public async Task> GetCookHistoryAsync(Guid userId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var cmd = new NpgsqlCommand(@" + SELECT jsonb_build_object( + 'id', e.id, + 'userId', e.user_id, + 'recipeId', e.recipe_id, + 'cookedAt', e.cooked_at, + 'createdDate', e.created_date, + 'recipe', cooktime.recipe_to_summary(r) + )::text + FROM cooktime.recipe_cook_events e + JOIN cooktime.recipes r ON r.id = e.recipe_id + WHERE e.user_id = $1 + ORDER BY e.cooked_at DESC, e.created_date DESC", conn); + + cmd.Parameters.AddWithValue(userId); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var json = reader.GetString(0); + var dto = JsonSerializer.Deserialize(json, JsonOptions); + if (dto != null) + { + results.Add(dto); + } + } + + return results; + } + + public async Task UpdateCookHistoryEventAsync(Guid userId, Guid eventId, DateOnly cookedAt) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var cmd = new NpgsqlCommand("SELECT cooktime.update_recipe_cook_event($1, $2, $3)", conn); + + cmd.Parameters.AddWithValue(userId); + cmd.Parameters.AddWithValue(eventId); + cmd.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Date, + Value = cookedAt + }); + + var result = await cmd.ExecuteScalarAsync(); + if (result == null || result == DBNull.Value) + { + return null; + } + + return JsonSerializer.Deserialize(result.ToString()!, JsonOptions); + } + + public async Task DeleteCookHistoryEventAsync(Guid userId, Guid eventId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var cmd = new NpgsqlCommand("SELECT cooktime.delete_recipe_cook_event($1, $2)", conn); + + cmd.Parameters.AddWithValue(userId); + cmd.Parameters.AddWithValue(eventId); + + var result = await cmd.ExecuteScalarAsync(); + return (bool)result!; + } + + public async Task> GetRecipeRecommendationsAsync(Guid recipeId, Guid? userId = null, int limit = 6) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var cmd = new NpgsqlCommand("SELECT cooktime.recommend_recipes($1, $2, $3)", conn); + + cmd.Parameters.AddWithValue(recipeId); + cmd.Parameters.Add(new NpgsqlParameter + { + NpgsqlDbType = NpgsqlDbType.Uuid, + Value = userId.HasValue ? (object)userId.Value : DBNull.Value + }); + cmd.Parameters.AddWithValue(limit); + + var results = new List(); + await using var reader = await cmd.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + var json = reader.GetString(0); + var dto = JsonSerializer.Deserialize(json, JsonOptions); + if (dto != null) + { + results.Add(dto); + } + } + + return results; + } + private static async Task> ReadRecipeSummaryListAsync(NpgsqlCommand cmd) { diff --git a/src/CookTimeTests/API/ApiIntegrationTests.cs b/src/CookTimeTests/API/ApiIntegrationTests.cs index 0578e47..767d958 100644 --- a/src/CookTimeTests/API/ApiIntegrationTests.cs +++ b/src/CookTimeTests/API/ApiIntegrationTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + namespace CookTime.Test.API; /// @@ -106,6 +108,31 @@ public async Task GetRecipeById_WithInvalidId_ReturnsNotFound() Assert.AreEqual(System.Net.HttpStatusCode.NotFound, response.StatusCode); } + [TestMethod] + public async Task GetCookHistory_Unauthenticated_ReturnsUnauthorized() + { + var fakeId = Guid.NewGuid(); + var response = await _client.GetAsync($"/api/multipartrecipe/{fakeId}/cook-history"); + Assert.AreEqual(System.Net.HttpStatusCode.Unauthorized, response.StatusCode); + } + + [TestMethod] + public async Task GetRecommendations_Unauthenticated_ReturnsRecommendations() + { + var recipeId = await GetFirstRecipeId(); + if (recipeId == null) + { + Assert.Inconclusive("No recipes available to test recommendations"); + return; + } + + var response = await _client.GetAsync($"/api/multipartrecipe/{recipeId}/recommendations"); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + Assert.StartsWith("[", content.TrimStart()); + } + [TestMethod] public async Task GetProfile_Unauthenticated_ReturnsUnauthorized() { @@ -146,4 +173,19 @@ public async Task OpenApi_ReturnsSpec() var content = await response.Content.ReadAsStringAsync(); Assert.Contains("\"paths\"", content, "OpenAPI spec should contain paths"); } + + private static async Task GetFirstRecipeId() + { + var response = await _client.GetAsync("/api/multipartrecipe?pageSize=1"); + response.EnsureSuccessStatusCode(); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var results = document.RootElement.GetProperty("results"); + if (results.GetArrayLength() == 0) + { + return null; + } + + return results[0].GetProperty("id").GetGuid(); + } } diff --git a/src/CookTimeTests/TestCookHistoryAndRecommendations.cs b/src/CookTimeTests/TestCookHistoryAndRecommendations.cs new file mode 100644 index 0000000..7178aba --- /dev/null +++ b/src/CookTimeTests/TestCookHistoryAndRecommendations.cs @@ -0,0 +1,168 @@ +using CookTime.Models.Contracts; +using Npgsql; + +namespace CookTime.Test; + +[TestClass] +public class TestCookHistoryAndRecommendations : TestBase +{ + private readonly List _ingredientIds = []; + + [TestInitialize] + public async Task TestInitialize() + { + await InitializeAsync(nameof(TestCookHistoryAndRecommendations)); + } + + [TestCleanup] + public async Task TestCleanup() + { + foreach (var ingredientId in _ingredientIds) + { + await DeleteTestIngredientAsync(ingredientId); + } + + await CleanupAsync(); + } + + [TestMethod] + public async Task CookHistory_CrudEnforcesOwnership() + { + var ingredientId = await CreateIngredient("Cook History Ingredient"); + var recipeId = await CreateRecipe("Cook History Recipe", TestUserId, ingredientId); + var otherUserId = await CreateTestUserAsync("Cook History Other"); + + try + { + var cookEvent = await Db.CreateCookHistoryEventAsync(TestUserId, recipeId, new DateOnly(2026, 1, 1)); + + Assert.AreNotEqual(Guid.Empty, cookEvent.Id); + Assert.AreEqual(recipeId, cookEvent.RecipeId); + Assert.AreEqual(new DateOnly(2026, 1, 1), cookEvent.CookedAt); + + var history = await Db.GetCookHistoryAsync(TestUserId, recipeId); + Assert.HasCount(1, history); + + await Assert.ThrowsExactlyAsync(() => + Db.UpdateCookHistoryEventAsync(otherUserId, cookEvent.Id, new DateOnly(2026, 1, 2))); + + var updated = await Db.UpdateCookHistoryEventAsync(TestUserId, cookEvent.Id, new DateOnly(2026, 1, 3)); + Assert.IsNotNull(updated); + Assert.AreEqual(new DateOnly(2026, 1, 3), updated.CookedAt); + + await Assert.ThrowsExactlyAsync(() => + Db.DeleteCookHistoryEventAsync(otherUserId, cookEvent.Id)); + + var deleted = await Db.DeleteCookHistoryEventAsync(TestUserId, cookEvent.Id); + Assert.IsTrue(deleted); + Assert.IsEmpty(await Db.GetCookHistoryAsync(TestUserId, recipeId)); + } + finally + { + await DeleteTestUserAsync(otherUserId); + } + } + + [TestMethod] + public async Task CookHistory_DeleteRecipeCascadesCookEvents() + { + var ingredientId = await CreateIngredient("Cascade Ingredient"); + var recipeId = await CreateRecipe("Cascade Recipe", TestUserId, ingredientId); + var cookEvent = await Db.CreateCookHistoryEventAsync(TestUserId, recipeId, new DateOnly(2026, 2, 1)); + + await Db.DeleteRecipeAsync(recipeId); + + await using var conn = await DataSource.OpenConnectionAsync(); + await using var cmd = new NpgsqlCommand( + "SELECT COUNT(*) FROM cooktime.recipe_cook_events WHERE id = $1", conn); + cmd.Parameters.AddWithValue(cookEvent.Id); + var count = (long)(await cmd.ExecuteScalarAsync())!; + + Assert.AreEqual(0, count); + } + + [TestMethod] + public async Task Recommendations_ExcludeSourceAndPreferIngredientOverlap() + { + var sharedIngredientId = await CreateIngredient("Shared Recommendation Ingredient"); + var unrelatedIngredientId = await CreateIngredient("Unrelated Recommendation Ingredient"); + var sourceRecipeId = await CreateRecipe("Source Recipe", TestUserId, sharedIngredientId); + var similarRecipeId = await CreateRecipe("Similar Recipe", TestUserId, sharedIngredientId); + var unrelatedRecipeId = await CreateRecipe("Unrelated Recipe", TestUserId, unrelatedIngredientId); + + var recommendations = await Db.GetRecipeRecommendationsAsync(sourceRecipeId, userId: null, limit: 10); + + Assert.IsFalse(recommendations.Any(r => r.Recipe.Id == sourceRecipeId)); + Assert.IsTrue(recommendations.Any(r => r.Recipe.Id == similarRecipeId)); + Assert.IsFalse(recommendations.Any(r => r.Recipe.Id == unrelatedRecipeId)); + Assert.AreEqual(similarRecipeId, recommendations[0].Recipe.Id); + Assert.IsGreaterThan(recommendations[0].ScoreBreakdown.IngredientSimilarity, 0); + } + + [TestMethod] + public async Task Recommendations_IncludeFavoriteOwnedAndNoveltyBoosts() + { + var sharedIngredientId = await CreateIngredient("Personalized Recommendation Ingredient"); + var sourceRecipeId = await CreateRecipe("Personalized Source", TestUserId, sharedIngredientId); + var neverCookedRecipeId = await CreateRecipe("Never Cooked Favorite", TestUserId, sharedIngredientId); + var recentlyCookedRecipeId = await CreateRecipe("Recently Cooked Favorite", TestUserId, sharedIngredientId); + var favoritesListId = await Db.CreateRecipeListAsync(new RecipeListCreateDto + { + OwnerId = TestUserId, + Name = "Favorites" + }); + + await Db.AddRecipeToListAsync(favoritesListId, neverCookedRecipeId, 1); + await Db.AddRecipeToListAsync(favoritesListId, recentlyCookedRecipeId, 1); + await Db.CreateCookHistoryEventAsync(TestUserId, recentlyCookedRecipeId, DateOnly.FromDateTime(DateTime.Today)); + + var recommendations = await Db.GetRecipeRecommendationsAsync(sourceRecipeId, TestUserId, limit: 10); + var neverCooked = recommendations.Single(r => r.Recipe.Id == neverCookedRecipeId); + var recentlyCooked = recommendations.Single(r => r.Recipe.Id == recentlyCookedRecipeId); + + Assert.AreEqual(0.15, neverCooked.ScoreBreakdown.OwnedByUser, 0.001); + Assert.AreEqual(0.15, neverCooked.ScoreBreakdown.FavoritedByUser, 0.001); + Assert.AreEqual(0.10, neverCooked.ScoreBreakdown.Novelty, 0.001); + Assert.AreEqual(0, recentlyCooked.ScoreBreakdown.Novelty, 0.001); + Assert.IsGreaterThan(neverCooked.Score, recentlyCooked.Score); + } + + private async Task CreateIngredient(string name) + { + var ingredientId = await CreateTestIngredientAsync($"{name} {Guid.NewGuid()}"); + _ingredientIds.Add(ingredientId); + return ingredientId; + } + + private Task CreateRecipe(string name, Guid ownerId, params Guid[] ingredientIds) + { + var ingredients = ingredientIds.Select((ingredientId, index) => new IngredientRequirementCreateDto + { + Ingredient = new IngredientRefDto + { + Id = ingredientId, + Name = $"Ingredient {index}" + }, + Quantity = 1, + Unit = "cup", + Position = index + }).ToList(); + + return Db.CreateRecipeAsync(new RecipeCreateDto + { + OwnerId = ownerId, + Name = $"{name} {Guid.NewGuid()}", + Description = "Recommendation test recipe", + Servings = 2, + Components = + [ + new ComponentCreateDto + { + Name = "Main", + Position = 1, + Ingredients = ingredients + } + ] + }); + } +} From 0a74c3b577e7375aa9daf340920a9a1cbef70004 Mon Sep 17 00:00:00 2001 From: Gustavo Hidalgo Date: Sun, 14 Jun 2026 21:11:42 -0400 Subject: [PATCH 2/5] Add cook history and recommendations frontend Cook history section on recipe pages, recommended recipes carousel, and a History page listing all cook events with delete support. Pins Node.js to v22 via .nvmrc (crypto.hash requires 21.7+). Co-Authored-By: Claude Sonnet 4.6 --- src/CookTime/client-app/.nvmrc | 1 + .../NavigationBar/NavigationBar.tsx | 4 +- .../components/Recipe/CookHistorySection.tsx | 110 +++++++++++++++ .../src/components/Recipe/RecipeInsights.css | 55 ++++++++ .../src/components/Recipe/RecipePage.tsx | 4 + .../components/Recipe/RecommendedRecipes.tsx | 115 ++++++++++++++++ src/CookTime/client-app/src/pages/History.tsx | 126 ++++++++++++++++++ src/CookTime/client-app/src/routes.ts | 1 + .../src/shared/CookTime/CookTime.service.ts | 65 ++++++++- .../src/shared/CookTime/CookTime.types.ts | 29 +++- 10 files changed, 506 insertions(+), 4 deletions(-) create mode 100644 src/CookTime/client-app/.nvmrc create mode 100644 src/CookTime/client-app/src/components/Recipe/CookHistorySection.tsx create mode 100644 src/CookTime/client-app/src/components/Recipe/RecipeInsights.css create mode 100644 src/CookTime/client-app/src/components/Recipe/RecommendedRecipes.tsx create mode 100644 src/CookTime/client-app/src/pages/History.tsx diff --git a/src/CookTime/client-app/.nvmrc b/src/CookTime/client-app/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/src/CookTime/client-app/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/src/CookTime/client-app/src/components/NavigationBar/NavigationBar.tsx b/src/CookTime/client-app/src/components/NavigationBar/NavigationBar.tsx index 6036212..7e02730 100644 --- a/src/CookTime/client-app/src/components/NavigationBar/NavigationBar.tsx +++ b/src/CookTime/client-app/src/components/NavigationBar/NavigationBar.tsx @@ -31,6 +31,9 @@ export function NavigationBar({ categories }: { categories: string[] }) { My recipes + + History + Sign out @@ -159,4 +162,3 @@ export function NavigationBar({ categories }: { categories: string[] }) { ); } - diff --git a/src/CookTime/client-app/src/components/Recipe/CookHistorySection.tsx b/src/CookTime/client-app/src/components/Recipe/CookHistorySection.tsx new file mode 100644 index 0000000..4b04ed4 --- /dev/null +++ b/src/CookTime/client-app/src/components/Recipe/CookHistorySection.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Button, Spinner } from 'react-bootstrap'; +import { useAuthentication } from '../Authentication/AuthenticationContext'; +import { useRecipeContext } from './RecipeContext'; +import { + CookHistoryEvent, + getCookHistory, + logCookHistory, +} from 'src/shared/CookTime'; +import './RecipeInsights.css'; + +function formatDate(date: string) { + return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +export function CookHistorySection({ recipeId }: { recipeId: string }) { + const { user } = useAuthentication(); + const { setToastMessage, setErrorMessage } = useRecipeContext(); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(false); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let cancelled = false; + + async function loadHistory() { + if (!user) { + if (!cancelled) { + setHistory([]); + } + return; + } + + try { + setLoading(true); + setLoadError(false); + const events = await getCookHistory(recipeId); + if (!cancelled) { + setHistory(events); + } + } catch { + if (!cancelled) { + setLoadError(true); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void loadHistory(); + + return () => { + cancelled = true; + }; + }, [recipeId, user]); + + const mostRecent = useMemo(() => history[0], [history]); + + if (!user) return null; + + const handleCookedToday = async () => { + setSubmitting(true); + try { + const event = await logCookHistory(recipeId); + if (event) { + const nextHistory = [event, ...history].sort((a, b) => b.cookedAt.localeCompare(a.cookedAt)); + setHistory(nextHistory); + setToastMessage('Logged that you cooked this recipe.'); + } else { + setErrorMessage('Could not log cook history.'); + } + } catch { + setErrorMessage('Could not log cook history.'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+

Cooked

+ {loading ? ( + Loading history... + ) : loadError ? ( + Could not load cook history. + ) : history.length === 0 ? ( + No cook history yet. + ) : ( + + {history.length} {history.length === 1 ? 'time' : 'times'} + {mostRecent ? `, last on ${formatDate(mostRecent.cookedAt)}` : ''} + + )} +
+ +
+
+ ); +} diff --git a/src/CookTime/client-app/src/components/Recipe/RecipeInsights.css b/src/CookTime/client-app/src/components/Recipe/RecipeInsights.css new file mode 100644 index 0000000..31292f6 --- /dev/null +++ b/src/CookTime/client-app/src/components/Recipe/RecipeInsights.css @@ -0,0 +1,55 @@ +.recipe-insight-section { + border-top: 1px solid var(--neutralQuaternaryAlt); + margin-top: 24px; + padding-top: 18px; +} + +.cook-history-summary { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: space-between; +} + + +.cook-history-page-list { + display: grid; + gap: 10px; + max-width: 760px; +} + +.cook-history-page-row { + align-items: center; + border-bottom: 1px solid var(--neutralQuaternaryAlt); + display: flex; + gap: 16px; + justify-content: space-between; + padding: 12px 0; +} + +.recommendation-details { + font-size: 0.9rem; + margin-top: -12px; + margin-bottom: 20px; +} + +.recommendation-reasons { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.recommendation-reason { + background-color: var(--themeLighterAlt); + border: 1px solid var(--themeLight); + border-radius: 4px; + color: var(--neutralPrimary); + padding: 2px 6px; +} + +.score-breakdown-row { + display: flex; + justify-content: space-between; +} diff --git a/src/CookTime/client-app/src/components/Recipe/RecipePage.tsx b/src/CookTime/client-app/src/components/Recipe/RecipePage.tsx index d739824..2cfcfb3 100644 --- a/src/CookTime/client-app/src/components/Recipe/RecipePage.tsx +++ b/src/CookTime/client-app/src/components/Recipe/RecipePage.tsx @@ -11,6 +11,8 @@ import { RecipeStructuredData } from '../RecipeStructuredData'; import { ImageCarousel } from '../ImageCarousel'; import { RecipeGenerationResult } from 'src/shared/CookTime'; import imgs from 'src/assets'; +import { CookHistorySection } from './CookHistorySection'; +import { RecommendedRecipes } from './RecommendedRecipes'; interface RecipePageProps { recipeId: string; @@ -71,8 +73,10 @@ function RecipePageContent({ recipeId }: { recipeId: string }) { + + ); } diff --git a/src/CookTime/client-app/src/components/Recipe/RecommendedRecipes.tsx b/src/CookTime/client-app/src/components/Recipe/RecommendedRecipes.tsx new file mode 100644 index 0000000..63229d5 --- /dev/null +++ b/src/CookTime/client-app/src/components/Recipe/RecommendedRecipes.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } from 'react'; +import { Accordion, Alert, Col, Row, Spinner } from 'react-bootstrap'; +import { RecipeCard } from '../RecipeCard/RecipeCard'; +import { getRecipeRecommendations, RecipeRecommendation, RecommendationScoreBreakdown } from 'src/shared/CookTime'; +import { useAuthentication } from '../Authentication/AuthenticationContext'; +import './RecipeInsights.css'; + +function formatScore(score: number) { + return score.toFixed(2); +} + +function ScoreBreakdown({ scoreBreakdown }: { scoreBreakdown: RecommendationScoreBreakdown }) { + const rows: Array<[string, number]> = [ + ['Ingredient similarity', scoreBreakdown.ingredientSimilarity], + ['Your recipe', scoreBreakdown.ownedByUser], + ['Favorite', scoreBreakdown.favoritedByUser], + ['Novelty', scoreBreakdown.novelty], + ['Diet match', scoreBreakdown.dietMatch], + ]; + + return ( +
+ {rows.map(([label, value]) => ( +
+ {label} + {formatScore(value)} +
+ ))} +
+ ); +} + +export function RecommendedRecipes({ recipeId }: { recipeId: string }) { + const { user } = useAuthentication(); + const [recommendations, setRecommendations] = useState([]); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + const showBreakdown = user?.roles.includes('Administrator') ?? false; + + useEffect(() => { + let cancelled = false; + + async function loadRecommendations() { + setLoading(true); + setFailed(false); + try { + const nextRecommendations = await getRecipeRecommendations(recipeId); + if (!cancelled) { + setRecommendations(nextRecommendations); + } + } catch { + if (!cancelled) { + setFailed(true); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void loadRecommendations(); + + return () => { + cancelled = true; + }; + }, [recipeId]); + + return ( +
+

Recommended Recipes

+ {loading && ( +
+ Loading recommendations... +
+ )} + {!loading && failed && ( + Recommendations are not available right now. + )} + {!loading && !failed && recommendations.length === 0 && ( +

No recommendations yet.

+ )} + {!loading && !failed && recommendations.length > 0 && ( + + {recommendations.map((recommendation) => ( + + + {showBreakdown && ( + + + + Score {formatScore(recommendation.score)} + + + {recommendation.reasons.length > 0 && ( +
+ {recommendation.reasons.map((reason) => ( + + {reason} + + ))} +
+ )} + +
+
+
+ )} + + ))} +
+ )} +
+ ); +} diff --git a/src/CookTime/client-app/src/pages/History.tsx b/src/CookTime/client-app/src/pages/History.tsx new file mode 100644 index 0000000..9f3a6fe --- /dev/null +++ b/src/CookTime/client-app/src/pages/History.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useState } from "react"; +import { Alert, Button, Spinner } from "react-bootstrap"; +import { Link } from "react-router"; +import { useAuthentication } from "src/components/Authentication/AuthenticationContext"; +import { + CookHistoryEventWithRecipe, + deleteCookHistory, + getUserCookHistory, +} from "src/shared/CookTime"; +import { useTitle } from "src/shared/useTitle"; +import "src/components/Recipe/RecipeInsights.css"; + +function formatDate(date: string) { + return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export default function History() { + const { user } = useAuthentication(); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [deletingId, setDeletingId] = useState(null); + const [error, setError] = useState(null); + + useTitle("History"); + + useEffect(() => { + let cancelled = false; + + async function loadHistory() { + if (!user) { + if (!cancelled) { + setHistory([]); + setLoading(false); + } + return; + } + + try { + setLoading(true); + setError(null); + const events = await getUserCookHistory(); + if (!cancelled) { + setHistory(events); + } + } catch { + if (!cancelled) { + setError("Could not load cook history."); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void loadHistory(); + + return () => { + cancelled = true; + }; + }, [user]); + + const handleDelete = async (eventId: string) => { + setDeletingId(eventId); + setError(null); + + const deleted = await deleteCookHistory(eventId); + if (deleted) { + setHistory((prev) => prev.filter((event) => event.id !== eventId)); + } else { + setError("Could not delete cook event."); + } + + setDeletingId(null); + }; + + if (!user) { + return ( + + Sign in to view your recipe history. + + ); + } + + return ( + <> +

History

+ {error && ( + setError(null)}> + {error} + + )} + {loading ? ( +
+ Loading history... +
+ ) : history.length === 0 ? ( +

No cook history yet.

+ ) : ( +
+ {history.map((event) => ( +
+
+ + {event.recipe.name} + +
{formatDate(event.cookedAt)}
+
+ +
+ ))} +
+ )} + + ); +} diff --git a/src/CookTime/client-app/src/routes.ts b/src/CookTime/client-app/src/routes.ts index 4959bb7..9ebe46b 100644 --- a/src/CookTime/client-app/src/routes.ts +++ b/src/CookTime/client-app/src/routes.ts @@ -10,6 +10,7 @@ export default [ route("About", "pages/About.tsx"), route("Recipes/Mine", "pages/MyRecipes.tsx"), route("Recipes/Create", "pages/RecipeCreation.tsx"), + route("History", "pages/History.tsx"), route("Groceries", "pages/GroceriesList.tsx"), route("lists", "pages/ListManagement.tsx"), route("lists/:slug", "pages/ListView.tsx"), diff --git a/src/CookTime/client-app/src/shared/CookTime/CookTime.service.ts b/src/CookTime/client-app/src/shared/CookTime/CookTime.service.ts index b51234d..bf1ca27 100644 --- a/src/CookTime/client-app/src/shared/CookTime/CookTime.service.ts +++ b/src/CookTime/client-app/src/shared/CookTime/CookTime.service.ts @@ -1,4 +1,4 @@ -import { AggregatedIngredient, IngredientInternalUpdate, PagedResult, RecipeGenerationResult, RecipeList, RecipeListWithRecipes, RecipeView, Review } from "./CookTime.types"; +import { AggregatedIngredient, CookHistoryEvent, CookHistoryEventWithRecipe, IngredientInternalUpdate, PagedResult, RecipeGenerationResult, RecipeList, RecipeListWithRecipes, RecipeRecommendation, RecipeView, Review } from "./CookTime.types"; export const EMPTY_GUID = "00000000-0000-0000-0000-000000000000"; @@ -110,6 +110,14 @@ export async function getNewRecipeViews() { return (await response.json()) as RecipeView[]; } +export async function getRecipeRecommendations(recipeId: string, limit = 6): Promise { + const response = await fetch(`/api/multipartrecipe/${recipeId}/recommendations?limit=${encodeURIComponent(limit)}`); + if (!response.ok) { + return []; + } + return (await response.json()) as RecipeRecommendation[]; +} + export async function getMyRecipes(args?: { page?: number }): Promise> { let query = ""; if (args?.page) { @@ -171,6 +179,59 @@ export async function removeFromFavorites(recipeId: string): Promise { return removeFromList("Favorites", recipeId); } +export async function getCookHistory(recipeId: string): Promise { + const response = await fetch(`/api/multipartrecipe/${recipeId}/cook-history`); + if (!response.ok) { + throw new Error(`Failed to load cook history: ${response.status}`); + } + return (await response.json()) as CookHistoryEvent[]; +} + +export async function getUserCookHistory(): Promise { + const response = await fetch("/api/cook-history"); + if (!response.ok) { + throw new Error(`Failed to load cook history: ${response.status}`); + } + return (await response.json()) as CookHistoryEventWithRecipe[]; +} + +export async function logCookHistory(recipeId: string, cookedAt?: string): Promise { + const response = await fetch(`/api/multipartrecipe/${recipeId}/cook-history`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ cookedAt: cookedAt ?? null }) + }); + + if (!response.ok) { + return null; + } + return (await response.json()) as CookHistoryEvent; +} + +export async function updateCookHistory(eventId: string, cookedAt: string): Promise { + const response = await fetch(`/api/cook-history/${eventId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ cookedAt }) + }); + + if (!response.ok) { + return null; + } + return (await response.json()) as CookHistoryEvent; +} + +export async function deleteCookHistory(eventId: string): Promise { + const response = await fetch(`/api/cook-history/${eventId}`, { + method: "DELETE" + }); + return response.ok; +} + // Convenience functions for groceries export async function createGroceryList(): Promise<{ created: boolean }> { const response = await fetch("/api/lists/Groceries", { @@ -332,4 +393,4 @@ export function toTitleCase(str: string) { return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); } ); -} \ No newline at end of file +} diff --git a/src/CookTime/client-app/src/shared/CookTime/CookTime.types.ts b/src/CookTime/client-app/src/shared/CookTime/CookTime.types.ts index 673f0f3..4726221 100644 --- a/src/CookTime/client-app/src/shared/CookTime/CookTime.types.ts +++ b/src/CookTime/client-app/src/shared/CookTime/CookTime.types.ts @@ -210,6 +210,33 @@ export type PagedResult = { lastRowOnPage: number } +export type CookHistoryEvent = { + id: string, + userId: string, + recipeId: string, + cookedAt: string, + createdDate: string +} + +export type CookHistoryEventWithRecipe = CookHistoryEvent & { + recipe: RecipeView +} + +export type RecommendationScoreBreakdown = { + ingredientSimilarity: number, + ownedByUser: number, + favoritedByUser: number, + novelty: number, + dietMatch: number +} + +export type RecipeRecommendation = { + recipe: RecipeView, + score: number, + scoreBreakdown: RecommendationScoreBreakdown, + reasons: string[] +} + export type IngredientInternalUpdate = { ingredientId: string, ndbNumber: number, @@ -326,4 +353,4 @@ export type IngredientCandidate = { id: string; name: string; confidence: number; -} \ No newline at end of file +} From 47964f22f30cf2cafbef4fcb179cacaf635875c4 Mon Sep 17 00:00:00 2001 From: Gustavo Hidalgo Date: Sun, 14 Jun 2026 21:11:50 -0400 Subject: [PATCH 3/5] Fix API port mismatch between launchSettings and docker-compose launchSettings.json had applicationUrl: http://+:5000 which dotnet watch overrode the ASPNETCORE_URLS env var, binding the API to port 5000 while the webapp proxy targeted 5001. All API calls failed silently, leaving the app stuck on "Loading...". Align both to port 5001. Also document the webapp's ASPNETCORE_URLS as a Vite proxy setting, not an ASP.NET setting, and restore the correct proxy target (http://api:5001). Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 5 +++-- src/CookTime/Properties/launchSettings.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d528486..8c71277 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,7 +42,7 @@ services: ConnectionStrings__Postgres: "Host=postgres;Database=cooktime;Username=cooktime;Password=development;Include Error Detail=true" ConnectionStrings__AzureBlobStorage: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;" ports: - - "5001:5000" + - "5001:5001" depends_on: postgres: condition: service_healthy @@ -63,7 +63,8 @@ services: dockerfile: Dockerfile container_name: cooktime-webapp environment: - ASPNETCORE_URLS: http://api:5000 + # Read by vite.config.ts to set the API proxy target (not an ASP.NET setting). + ASPNETCORE_URLS: http://api:5001 PORT: 80 ports: - "3000:80" diff --git a/src/CookTime/Properties/launchSettings.json b/src/CookTime/Properties/launchSettings.json index cbbe10f..3905e8c 100644 --- a/src/CookTime/Properties/launchSettings.json +++ b/src/CookTime/Properties/launchSettings.json @@ -5,7 +5,7 @@ "dotnetRunMessages": true, "hotReloadProfile": "aspnetcore", "launchBrowser": false, - "applicationUrl": "http://+:5000", + "applicationUrl": "http://+:5001", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } From cd6de086d4d6d2ff163fff1698f524e61203db30 Mon Sep 17 00:00:00 2001 From: Gustavo Hidalgo Date: Sun, 14 Jun 2026 21:11:54 -0400 Subject: [PATCH 4/5] Add blog post and implementation notes for cook history and recommendations Co-Authored-By: Claude Sonnet 4.6 --- recommendation.md | 174 ++++++++++++++++++ ...-cook-history-and-recommendations.markdown | 63 +++++++ 2 files changed, 237 insertions(+) create mode 100644 recommendation.md create mode 100644 src/CookTime/Blog/_posts/2026-06-14-cook-history-and-recommendations.markdown diff --git a/recommendation.md b/recommendation.md new file mode 100644 index 0000000..1ba9667 --- /dev/null +++ b/recommendation.md @@ -0,0 +1,174 @@ +# Recipe Recommendations And Cook History + +## Summary + +Implemented a v1 recipe recommendation and cook-history feature for CookTime. + +The feature adds: + +- A cook-history tracker for signed-in users. +- A recipe recommendation API based on ingredient similarity, ownership, favorites, and novelty. +- A compact "I cooked this" recipe-page UI. +- A separate History page for viewing and deleting cook events. +- Admin-only recommendation score breakdowns. + +## Backend Changes + +Added migration: + +- `src/CookTime/Scripts/017_recipe_cook_history_and_recommendations.sql` + +The migration creates `cooktime.recipe_cook_events` with: + +- `id` +- `user_id` +- `recipe_id` +- `cooked_at` +- `created_date` + +It also adds indexes and SQL functions for: + +- Creating cook events. +- Listing cook history for one recipe. +- Updating and deleting cook events with ownership checks. +- Recommending recipes from a source recipe. + +Added DTOs: + +- `CookHistoryEventDto` +- `CookHistoryEventWithRecipeDto` +- `CookHistoryUpsertRequest` +- `RecommendationScoreBreakdownDto` +- `RecipeRecommendationDto` + +Added `CookTimeDB` methods for: + +- `CreateCookHistoryEventAsync` +- `GetCookHistoryAsync(userId, recipeId)` +- `GetCookHistoryAsync(userId)` +- `UpdateCookHistoryEventAsync` +- `DeleteCookHistoryEventAsync` +- `GetRecipeRecommendationsAsync` + +Added API endpoints: + +- `GET /api/multipartrecipe/{recipeId}/recommendations?limit=6` +- `GET /api/multipartrecipe/{recipeId}/cook-history` +- `POST /api/multipartrecipe/{recipeId}/cook-history` +- `GET /api/cook-history` +- `PATCH /api/cook-history/{eventId}` +- `DELETE /api/cook-history/{eventId}` + +Fixed an Npgsql parameter issue: + +- Replaced named date parameters with unnamed typed parameters because positional SQL placeholders cannot be mixed with named parameters. + +## Recommendation Scoring + +V1 recommendation inputs: + +- Ingredient similarity using Jaccard similarity over distinct ingredient ids. +- Ownership boost for recipes owned by the signed-in user. +- Favorites boost for recipes in the user's `Favorites` list. +- Novelty boost based on last cooked date. +- Diet scoring is deferred and remains `0`. + +Default weights: + +- Ingredient similarity: `0.60` +- Owned by user: `0.15` +- Favorited by user: `0.15` +- Novelty: `0.10` +- Diet match: `0.00` + +Anonymous users receive similarity-based recommendations only. + +## Frontend Changes + +Added client types and service methods in: + +- `src/CookTime/client-app/src/shared/CookTime/CookTime.types.ts` +- `src/CookTime/client-app/src/shared/CookTime/CookTime.service.ts` + +Added recipe-page components: + +- `CookHistorySection.tsx` +- `RecommendedRecipes.tsx` +- `RecipeInsights.css` + +Recipe details page changes: + +- "I cooked this" now appears after the recipe components (instructions and ingredients). +- Recipe details only shows a compact cook-history summary and button. +- It no longer shows every cook event on the recipe page. +- Render order: Header → Image → Fields → Components → Cook History → Nutrition → Reviews → Recommended Recipes. + +Recommendation UI: + +- Recommendation cards are visible to all users. +- Score, reasons, and breakdown accordion are visible only to users with the `Administrator` role. + +Added History page: + +- `src/CookTime/client-app/src/pages/History.tsx` +- Route: `/history` +- User menu link: `History` + +The History page: + +- Lists the signed-in user's cook events. +- Links each event back to the recipe. +- Lets users delete cook events. +- Shows an error if history cannot be loaded. + +## Docker And Routing Fixes + +Fixed a Docker Compose API proxy mismatch: + +- API container listens on port `5001`. +- Webapp proxy was pointing at `http://api:5000`. +- Updated webapp proxy target to `http://api:5001`. +- Updated API port mapping to `5001:5001`. + +Important local-dev note: + +- Use `http://localhost:3000/history` for the React dev app. +- `http://localhost:5001/history` is served by the API container's static `wwwroot` bundle and may be stale unless `scripts/build` has copied a fresh frontend build. + +Recommended container refresh after compose changes: + +```bash +docker compose up -d --force-recreate api webapp +``` + +## Tests And Verification + +Added tests: + +- `src/CookTimeTests/TestCookHistoryAndRecommendations.cs` +- Additional API smoke checks in `src/CookTimeTests/API/ApiIntegrationTests.cs` + +Verification commands that passed during implementation: + +```bash +dotnet build src/CookTime/CookTime.csproj --no-restore +dotnet build src/CookTimeTests/CookTimeTests.csproj --no-restore +npx eslint src/components/Recipe/CookHistorySection.tsx +npx eslint src/components/Recipe/RecommendedRecipes.tsx +npx eslint src/pages/History.tsx +git diff --check +``` + +Known verification limitations: + +- `npm run typecheck` still fails on pre-existing unrelated TypeScript errors. +- Focused `dotnet test` could start only with sandbox escalation, then failed locally because PostgreSQL was not running on `127.0.0.1:5432`. + +## Follow-up Notes + +Potential next improvements: + +- Add edit-date support on the History page if desired. +- Add filtering or grouping by month on the History page. +- Move the user-wide cook-history query into a SQL function if the project wants all DB read behavior in migration scripts. +- Implement the deferred diet scoring once CookTime has an active diet preference model. diff --git a/src/CookTime/Blog/_posts/2026-06-14-cook-history-and-recommendations.markdown b/src/CookTime/Blog/_posts/2026-06-14-cook-history-and-recommendations.markdown new file mode 100644 index 0000000..44c39b9 --- /dev/null +++ b/src/CookTime/Blog/_posts/2026-06-14-cook-history-and-recommendations.markdown @@ -0,0 +1,63 @@ +--- +layout: post +title: "Cook history and recipe recommendations" +date: 2026-06-14 00:00:00 -0400 +categories: features, recommendations +--- + +Two new features are live on CookTime: cook history and recipe recommendations. + +# Cook History + +If you are signed in, every recipe page now shows a "Cooked" section with an "I cooked this" button. +Clicking it logs today's date as a cook event for that recipe. +The section shows how many times you have cooked a recipe and when you last made it. + +You can also visit your personal History page from the user menu. +It lists every recipe you have ever cooked and lets you delete entries if you made a mistake. + +Cook events are stored one per user, recipe, and day, so clicking "I cooked this" multiple times on the same day is safe. + +# Recipe Recommendations + +At the bottom of every recipe page you will now find a "You might also like" section with up to six recipe suggestions. +These are visible to all users, signed in or not. + +## How the scoring works + +The backbone of the recommendation is ingredient similarity. +We use [Jaccard similarity](https://en.wikipedia.org/wiki/Jaccard_index) over the distinct set of ingredient IDs shared between two recipes: + +``` +score = |shared ingredients| / |union of all ingredients| +``` + +A recipe with no overlapping ingredients scores zero and is never shown. + +For signed-in users we add three more signals on top of the similarity score: + +| Signal | Weight | Meaning | +|---|---|---| +| Ingredient similarity | 60% | Jaccard similarity | +| Owned by you | 15% | You created the recipe | +| In your Favorites list | 15% | You have favorited it | +| Novelty | 10% | Not cooked in the last 7 days | + +All of this runs as a single PostgreSQL function using CTEs, so there is no round-tripping between the API and the database to build the ranked list. + +## Score breakdowns for admins + +If you have an Administrator account, each recommendation card shows the total score and an expandable breakdown of exactly how much each signal contributed. +This made it a lot easier to tune weights during development — we could see at a glance whether a card ranked highly because of ingredient overlap or because we happened to own the recipe. + +## What we ruled out + +An early version of the filter included a "novelty" clause that surfaced recipes you had never cooked before, regardless of ingredient overlap. +That turned out to bring in completely unrelated recipes from the database, so we removed it. +Novelty is now only a tiebreaker boost, never a reason to include a recipe on its own. + +# What's next + +The scoring model has a deferred "diet match" slot already wired into the score breakdown. +Once CookTime has diet preference tracking, that signal can be filled in without changing the API contract. +We would also like to add edit support on the History page so you can correct the date of a cook event without deleting and re-adding it. From afd864886a990d4d9ec2d4dd23f70b4024030548 Mon Sep 17 00:00:00 2001 From: Gustavo Hidalgo Date: Sun, 14 Jun 2026 21:18:45 -0400 Subject: [PATCH 5/5] Fix swapped Assert.IsGreaterThan arguments in recommendation tests IsGreaterThan(lowerBound, value) checks value > lowerBound; both calls had the arguments reversed, causing the assertions to check the opposite of what was intended. Co-Authored-By: Claude Sonnet 4.6 --- src/CookTimeTests/TestCookHistoryAndRecommendations.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CookTimeTests/TestCookHistoryAndRecommendations.cs b/src/CookTimeTests/TestCookHistoryAndRecommendations.cs index 7178aba..a4fcdc6 100644 --- a/src/CookTimeTests/TestCookHistoryAndRecommendations.cs +++ b/src/CookTimeTests/TestCookHistoryAndRecommendations.cs @@ -96,7 +96,7 @@ public async Task Recommendations_ExcludeSourceAndPreferIngredientOverlap() Assert.IsTrue(recommendations.Any(r => r.Recipe.Id == similarRecipeId)); Assert.IsFalse(recommendations.Any(r => r.Recipe.Id == unrelatedRecipeId)); Assert.AreEqual(similarRecipeId, recommendations[0].Recipe.Id); - Assert.IsGreaterThan(recommendations[0].ScoreBreakdown.IngredientSimilarity, 0); + Assert.IsGreaterThan(0, recommendations[0].ScoreBreakdown.IngredientSimilarity); } [TestMethod] @@ -124,7 +124,7 @@ public async Task Recommendations_IncludeFavoriteOwnedAndNoveltyBoosts() Assert.AreEqual(0.15, neverCooked.ScoreBreakdown.FavoritedByUser, 0.001); Assert.AreEqual(0.10, neverCooked.ScoreBreakdown.Novelty, 0.001); Assert.AreEqual(0, recentlyCooked.ScoreBreakdown.Novelty, 0.001); - Assert.IsGreaterThan(neverCooked.Score, recentlyCooked.Score); + Assert.IsGreaterThan(recentlyCooked.Score, neverCooked.Score); } private async Task CreateIngredient(string name)