diff --git a/docker-compose.yml b/docker-compose.yml index d528486a..8c712773 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/recommendation.md b/recommendation.md new file mode 100644 index 00000000..1ba9667a --- /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 00000000..44c39b9c --- /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. diff --git a/src/CookTime/Models/Contracts/RecipeRecommendationDto.cs b/src/CookTime/Models/Contracts/RecipeRecommendationDto.cs new file mode 100644 index 00000000..e50ba98a --- /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 7f741e72..1ad9ed37 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/Properties/launchSettings.json b/src/CookTime/Properties/launchSettings.json index cbbe10f3..3905e8cf 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" } 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 00000000..50206bfe --- /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 18452a09..570aa4f4 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/CookTime/client-app/.nvmrc b/src/CookTime/client-app/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /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 6036212a..7e027303 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 00000000..4b04ed4e --- /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 00000000..31292f6c --- /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 d7398245..2cfcfb3a 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 00000000..63229d55 --- /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 00000000..9f3a6fef --- /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 4959bb7f..9ebe46b6 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 b51234d9..bf1ca271 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 673f0f33..47262210 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 +} diff --git a/src/CookTimeTests/API/ApiIntegrationTests.cs b/src/CookTimeTests/API/ApiIntegrationTests.cs index 0578e47b..767d9585 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 00000000..a4fcdc69 --- /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(0, recommendations[0].ScoreBreakdown.IngredientSimilarity); + } + + [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(recentlyCooked.Score, neverCooked.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 + } + ] + }); + } +}