Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
174 changes: 174 additions & 0 deletions recommendation.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions src/CookTime/Models/Contracts/RecipeRecommendationDto.cs
Original file line number Diff line number Diff line change
@@ -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<string> Reasons { get; set; } = new();
}
Loading
Loading