diff --git a/README.md b/README.md index 48b19cb..e3a4434 100644 --- a/README.md +++ b/README.md @@ -176,9 +176,16 @@ Progress so far (see `docs/scrum-backlog.md` for the authoritative task-level st - [x] `CW-STORY-04.4` Real `OpenFoodFactsProductDataProvider` (typed `HttpClient`, no API key required) - [x] `CW-STORY-04.5` Camera-based scan page (native `BarcodeDetector` API, manual-entry fallback) with add-to-list — verified live against the real Open Food Facts API -Next up: `CW-EPIC-05` Stores, Purchases, and Price Intelligence. +`CW-EPIC-05` Stores, Purchases, and Price Intelligence is complete: -Next up: `CW-EPIC-03` Smart Shopping List. +- [x] `CW-STORY-05.1` Retailer/StoreLocation entities, EF configuration, migration (store tracking optional per `DEC-011`) +- [x] `CW-STORY-05.2` Purchase/PurchaseItem entities, EF configuration, migration +- [x] `CW-STORY-05.3` Append-only PriceObservation entity, EF configuration, migration (cascade-delete protected) +- [x] `CW-STORY-05.4` Purchase recording flow — start, add items, complete +- [x] `CW-STORY-05.5` `PriceService` derives price observations from completed purchases (latest/average/median/lowest/unit price) +- [x] `CW-STORY-05.6` `PriceController` + price views (index, per-product history, manual price logging), real price insight on product details + +Next up: `CW-EPIC-06` Shopping Mode. ## Project Planning diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md index 242ed92..a240ccd 100644 --- a/docs/scrum-backlog.md +++ b/docs/scrum-backlog.md @@ -658,12 +658,14 @@ Verified live end-to-end with a **real** barcode against the **real** Open Food - Product details can show historical price insight **Tasks** -- [ ] Create `PriceController` -- [ ] Build `Views/Price/Index.cshtml` -- [ ] Build `Views/Price/Product.cshtml` -- [ ] Add price history to product details view -- [ ] Add filtering and sorting UI -- [ ] Add freshness and source display +- [x] Create `PriceController` +- [x] Build `Views/Price/Index.cshtml` +- [x] Build `Views/Price/Product.cshtml` +- [x] Add price history to product details view +- [x] Add filtering and sorting UI +- [x] Add freshness and source display + +**Status:** Done — `PriceController` exposes `GET /prices` (household's priced products, sortable by name/price-asc/price-desc/recent via a query-string `sort`), `GET /prices/product/{productId}` (full observation history plus latest/average/median/lowest/unit-price insight for that household), and `POST /prices/product/{productId}/observe` (manual "I saw this price" entry — `PriceObservationSourceType.UserEntered`, `confidenceScore: 1m`, no purchase required). Added `IPriceService.GetHouseholdPricedProductsAsync` + `PricedProductDto` to back the index page, grouping observations by product in-memory (consistent with the existing median/average approach) and falling back to "Unknown product" defensively if a product record is ever missing. `Views/Product/Details.cshtml`'s former placeholder text is replaced with real last-observed price + observation count, sourced via `IProductService`'s existing `GetDetailsAsync` combined with a new `IPriceService.GetHouseholdPriceInsightAsync` call in `ProductController.Details`, plus a link to the full price history page — closing the gap deliberately left open in `05.4`'s status note. All routes are household-scoped the same way `PurchaseController` is (resolve current household via `IHouseholdService`, never trust a client-supplied id). Added a "Prices" nav link. 5 new `PriceService` tests (empty/grouped/cross-household for `GetHouseholdPricedProductsAsync`) and 5 new `PriceController` web integration tests (anonymous redirect, empty state, 404 for unknown product, full observe→history flow, cross-household isolation) — 151 tests passing (up from 143). Verified live via `dotnet run` + curl: register → create household → `/prices` empty state → 404 for an unknown product id → existing `/products/search` route unaffected by `ProductController`'s new constructor dependencies. --- diff --git a/src/CartWise.Application/DTOs/PricedProductDto.cs b/src/CartWise.Application/DTOs/PricedProductDto.cs new file mode 100644 index 0000000..c0460ab --- /dev/null +++ b/src/CartWise.Application/DTOs/PricedProductDto.cs @@ -0,0 +1,18 @@ +namespace CartWise.Application.DTOs; + +public class PricedProductDto +{ + public Guid ProductId { get; init; } + + public string ProductName { get; init; } = string.Empty; + + public decimal LatestPrice { get; init; } + + public DateTime LatestObservedUtc { get; init; } + + public decimal AveragePrice { get; init; } + + public decimal LowestPrice { get; init; } + + public int ObservationCount { get; init; } +} diff --git a/src/CartWise.Application/Interfaces/IPriceService.cs b/src/CartWise.Application/Interfaces/IPriceService.cs index 8d419a8..dc702f6 100644 --- a/src/CartWise.Application/Interfaces/IPriceService.cs +++ b/src/CartWise.Application/Interfaces/IPriceService.cs @@ -20,4 +20,6 @@ public interface IPriceService Task GetHouseholdPriceInsightAsync(Guid householdId, Guid productId, CancellationToken cancellationToken = default); Task> GetHouseholdPriceHistoryAsync(Guid householdId, Guid productId, CancellationToken cancellationToken = default); + + Task> GetHouseholdPricedProductsAsync(Guid householdId, CancellationToken cancellationToken = default); } diff --git a/src/CartWise.Application/Services/PriceService.cs b/src/CartWise.Application/Services/PriceService.cs index c8ebf26..14f95f6 100644 --- a/src/CartWise.Application/Services/PriceService.cs +++ b/src/CartWise.Application/Services/PriceService.cs @@ -89,6 +89,44 @@ public class PriceService : IPriceService .ToListAsync(cancellationToken); } + public async Task> GetHouseholdPricedProductsAsync(Guid householdId, CancellationToken cancellationToken = default) + { + var observations = await _db.PriceObservations + .AsNoTracking() + .Where(o => o.HouseholdId == householdId) + .ToListAsync(cancellationToken); + + if (observations.Count == 0) + { + return []; + } + + var productIds = observations.Select(o => o.ProductId).Distinct().ToList(); + var productNames = await _db.Products + .AsNoTracking() + .Where(p => productIds.Contains(p.ProductId)) + .ToDictionaryAsync(p => p.ProductId, p => p.Name, cancellationToken); + + return observations + .GroupBy(o => o.ProductId) + .Select(group => + { + var latest = group.OrderByDescending(o => o.ObservedUtc).First(); + return new PricedProductDto + { + ProductId = group.Key, + ProductName = productNames.GetValueOrDefault(group.Key, "Unknown product"), + LatestPrice = latest.Price, + LatestObservedUtc = latest.ObservedUtc, + AveragePrice = Math.Round(group.Average(o => o.Price), 2), + LowestPrice = group.Min(o => o.Price), + ObservationCount = group.Count() + }; + }) + .OrderBy(p => p.ProductName) + .ToList(); + } + private static decimal CalculateMedian(List sortedPrices) { var count = sortedPrices.Count; diff --git a/src/CartWise.Web/Controllers/PriceController.cs b/src/CartWise.Web/Controllers/PriceController.cs new file mode 100644 index 0000000..dfa43a5 --- /dev/null +++ b/src/CartWise.Web/Controllers/PriceController.cs @@ -0,0 +1,144 @@ +using CartWise.Application.Interfaces; +using CartWise.Domain.Enums; +using CartWise.Infrastructure.Identity; +using CartWise.Web.ViewModels.Price; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace CartWise.Web.Controllers; + +[Authorize] +[Route("prices")] +public class PriceController : Controller +{ + private readonly IPriceService _priceService; + private readonly IProductService _productService; + private readonly IHouseholdService _householdService; + private readonly UserManager _userManager; + + public PriceController( + IPriceService priceService, + IProductService productService, + IHouseholdService householdService, + UserManager userManager) + { + _priceService = priceService; + _productService = productService; + _householdService = householdService; + _userManager = userManager; + } + + [HttpGet("")] + public async Task Index(string? sort) + { + var household = await CurrentHouseholdOrNullAsync(); + if (household is null) + { + return RedirectToAction("Create", "Household"); + } + + var products = await _priceService.GetHouseholdPricedProductsAsync(household.HouseholdId); + + IEnumerable ordered = sort switch + { + "price-asc" => products.OrderBy(p => p.LatestPrice), + "price-desc" => products.OrderByDescending(p => p.LatestPrice), + "recent" => products.OrderByDescending(p => p.LatestObservedUtc), + _ => products.OrderBy(p => p.ProductName) + }; + + var viewModel = new PriceIndexViewModel + { + Sort = sort ?? "name", + Products = ordered.Select(p => new PricedProductRowViewModel + { + ProductId = p.ProductId, + ProductName = p.ProductName, + LatestPrice = p.LatestPrice, + LatestObservedUtc = p.LatestObservedUtc, + AveragePrice = p.AveragePrice, + LowestPrice = p.LowestPrice, + ObservationCount = p.ObservationCount + }).ToList() + }; + + return View(viewModel); + } + + [HttpGet("product/{productId:guid}")] + public async Task Product(Guid productId) + { + var household = await CurrentHouseholdOrNullAsync(); + if (household is null) + { + return RedirectToAction("Create", "Household"); + } + + var details = await _productService.GetDetailsAsync(productId); + if (details is null) + { + return NotFound(); + } + + var insight = await _priceService.GetHouseholdPriceInsightAsync(household.HouseholdId, productId); + var history = await _priceService.GetHouseholdPriceHistoryAsync(household.HouseholdId, productId); + + var viewModel = new PriceProductViewModel + { + ProductId = productId, + ProductName = details.Name, + LatestPrice = insight?.LatestPrice, + LatestObservedUtc = insight?.LatestObservedUtc, + AveragePrice = insight?.AveragePrice, + MedianPrice = insight?.MedianPrice, + LowestPrice = insight?.LowestPrice, + UnitPrice = insight?.UnitPrice, + ObservationCount = insight?.ObservationCount ?? 0, + History = history.Select(h => new PriceObservationRowViewModel + { + Price = h.Price, + UnitPrice = h.UnitPrice, + ObservedUtc = h.ObservedUtc, + SourceType = h.SourceType + }).ToList() + }; + + return View(viewModel); + } + + [HttpPost("product/{productId:guid}/observe")] + [ValidateAntiForgeryToken] + public async Task Observe(Guid productId, decimal price, DateTime? observedOn) + { + var household = await CurrentHouseholdOrNullAsync(); + if (household is null) + { + return RedirectToAction("Create", "Household"); + } + + if (price <= 0) + { + ModelState.AddModelError(string.Empty, "Price must be greater than zero."); + return RedirectToAction(nameof(Product), new { productId }); + } + + var observedUtc = observedOn ?? DateTime.UtcNow; + + await _priceService.RecordObservationAsync( + productId, + price, + observedUtc, + PriceObservationSourceType.UserEntered, + confidenceScore: 1m, + householdId: household.HouseholdId); + + return RedirectToAction(nameof(Product), new { productId }); + } + + private async Task CurrentHouseholdOrNullAsync() + { + var userId = _userManager.GetUserId(User)!; + return await _householdService.GetCurrentHouseholdAsync(userId); + } +} diff --git a/src/CartWise.Web/Controllers/ProductController.cs b/src/CartWise.Web/Controllers/ProductController.cs index 8c4b121..cd1de31 100644 --- a/src/CartWise.Web/Controllers/ProductController.cs +++ b/src/CartWise.Web/Controllers/ProductController.cs @@ -1,6 +1,8 @@ using CartWise.Application.Interfaces; +using CartWise.Infrastructure.Identity; using CartWise.Web.ViewModels.Product; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace CartWise.Web.Controllers; @@ -9,10 +11,20 @@ namespace CartWise.Web.Controllers; public class ProductController : Controller { private readonly IProductService _productService; + private readonly IPriceService _priceService; + private readonly IHouseholdService _householdService; + private readonly UserManager _userManager; - public ProductController(IProductService productService) + public ProductController( + IProductService productService, + IPriceService priceService, + IHouseholdService householdService, + UserManager userManager) { _productService = productService; + _priceService = priceService; + _householdService = householdService; + _userManager = userManager; } [HttpGet("products/search")] @@ -58,6 +70,19 @@ public class ProductController : Controller BarcodeIdentifiers = details.BarcodeIdentifiers.ToList() }; + var userId = _userManager.GetUserId(User)!; + var household = await _householdService.GetCurrentHouseholdAsync(userId); + if (household is not null) + { + var insight = await _priceService.GetHouseholdPriceInsightAsync(household.HouseholdId, id); + if (insight is not null) + { + viewModel.LatestPrice = insight.LatestPrice; + viewModel.LatestObservedUtc = insight.LatestObservedUtc; + viewModel.PriceObservationCount = insight.ObservationCount; + } + } + return View(viewModel); } diff --git a/src/CartWise.Web/ViewModels/Price/PriceViewModels.cs b/src/CartWise.Web/ViewModels/Price/PriceViewModels.cs new file mode 100644 index 0000000..4d40b73 --- /dev/null +++ b/src/CartWise.Web/ViewModels/Price/PriceViewModels.cs @@ -0,0 +1,59 @@ +namespace CartWise.Web.ViewModels.Price; + +public class PriceIndexViewModel +{ + public string Sort { get; set; } = "name"; + + public List Products { get; set; } = []; +} + +public class PricedProductRowViewModel +{ + public Guid ProductId { get; set; } + + public string ProductName { get; set; } = string.Empty; + + public decimal LatestPrice { get; set; } + + public DateTime LatestObservedUtc { get; set; } + + public decimal AveragePrice { get; set; } + + public decimal LowestPrice { get; set; } + + public int ObservationCount { get; set; } +} + +public class PriceProductViewModel +{ + public Guid ProductId { get; set; } + + public string ProductName { get; set; } = string.Empty; + + public decimal? LatestPrice { get; set; } + + public DateTime? LatestObservedUtc { get; set; } + + public decimal? AveragePrice { get; set; } + + public decimal? MedianPrice { get; set; } + + public decimal? LowestPrice { get; set; } + + public decimal? UnitPrice { get; set; } + + public int ObservationCount { get; set; } + + public List History { get; set; } = []; +} + +public class PriceObservationRowViewModel +{ + public decimal Price { get; set; } + + public decimal? UnitPrice { get; set; } + + public DateTime ObservedUtc { get; set; } + + public string SourceType { get; set; } = string.Empty; +} diff --git a/src/CartWise.Web/ViewModels/Product/ProductDetailsViewModel.cs b/src/CartWise.Web/ViewModels/Product/ProductDetailsViewModel.cs index 83c2ec5..f23ec24 100644 --- a/src/CartWise.Web/ViewModels/Product/ProductDetailsViewModel.cs +++ b/src/CartWise.Web/ViewModels/Product/ProductDetailsViewModel.cs @@ -15,4 +15,10 @@ public class ProductDetailsViewModel public string? ImageUrl { get; set; } public List BarcodeIdentifiers { get; set; } = []; + + public decimal? LatestPrice { get; set; } + + public DateTime? LatestObservedUtc { get; set; } + + public int PriceObservationCount { get; set; } } diff --git a/src/CartWise.Web/Views/Price/Index.cshtml b/src/CartWise.Web/Views/Price/Index.cshtml new file mode 100644 index 0000000..567fc0d --- /dev/null +++ b/src/CartWise.Web/Views/Price/Index.cshtml @@ -0,0 +1,45 @@ +@model CartWise.Web.ViewModels.Price.PriceIndexViewModel +@{ + ViewData["Title"] = "Prices"; +} + +

Prices

+ +@if (Model.Products.Count == 0) +{ +

No prices recorded yet. Prices appear here once you record a purchase or log an observation.

+} +else +{ +
+
+ + +
+
+ + +} diff --git a/src/CartWise.Web/Views/Price/Product.cshtml b/src/CartWise.Web/Views/Price/Product.cshtml new file mode 100644 index 0000000..788afa7 --- /dev/null +++ b/src/CartWise.Web/Views/Price/Product.cshtml @@ -0,0 +1,78 @@ +@model CartWise.Web.ViewModels.Price.PriceProductViewModel +@{ + ViewData["Title"] = Model.ProductName; +} + +

@Model.ProductName

+

View product details

+ +@if (Model.ObservationCount == 0) +{ +

No price history yet for this household.

+} +else +{ +
+
Last observed
+
@Model.LatestPrice!.Value.ToString("C") on @Model.LatestObservedUtc!.Value.ToLocalTime().ToString("MMMM d, yyyy")
+ +
Average
+
@Model.AveragePrice!.Value.ToString("C")
+ +
Median
+
@Model.MedianPrice!.Value.ToString("C")
+ +
Lowest
+
@Model.LowestPrice!.Value.ToString("C")
+ + @if (Model.UnitPrice is not null) + { +
Unit price
+
@Model.UnitPrice.Value.ToString("C")
+ } + +
Observations
+
@Model.ObservationCount
+
+} + +

Log a price you saw

+
+
+ + +
+
+ + +
+
+ +
+
+ +@if (Model.History.Count > 0) +{ +

History

+ + + + + + + + + + + @foreach (var observation in Model.History) + { + + + + + + + } + +
DatePriceUnit priceSource
@observation.ObservedUtc.ToLocalTime().ToString("MMM d, yyyy")@observation.Price.ToString("C")@(observation.UnitPrice?.ToString("C") ?? "—")@observation.SourceType
+} diff --git a/src/CartWise.Web/Views/Product/Details.cshtml b/src/CartWise.Web/Views/Product/Details.cshtml index c4eb10e..df7dc3f 100644 --- a/src/CartWise.Web/Views/Product/Details.cshtml +++ b/src/CartWise.Web/Views/Product/Details.cshtml @@ -34,4 +34,17 @@ -

Price history and recording a purchase will appear here once purchase tracking is available.

+

Price

+@if (Model.PriceObservationCount == 0) +{ +

No price history yet for this household.

+} +else +{ +

+ Last observed @Model.LatestPrice!.Value.ToString("C") + on @Model.LatestObservedUtc!.Value.ToLocalTime().ToString("MMMM d, yyyy") + (@Model.PriceObservationCount observation@(Model.PriceObservationCount == 1 ? "" : "s")) +

+} +

View full price history

diff --git a/src/CartWise.Web/Views/Shared/_Layout.cshtml b/src/CartWise.Web/Views/Shared/_Layout.cshtml index ddbf6b8..63f22e9 100644 --- a/src/CartWise.Web/Views/Shared/_Layout.cshtml +++ b/src/CartWise.Web/Views/Shared/_Layout.cshtml @@ -37,6 +37,9 @@ + }