Selaa lähdekoodia

Complete CW-EPIC-03: shopping list item actions and mobile polish

Add toggle/skip/delete with optimistic concurrency handling and jQuery
progressive enhancement (CW-STORY-03.4), then a mobile-first touch-target
and quick-add polish pass (CW-STORY-03.5).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington 1 viikko sitten
vanhempi
commit
e75916e03d
17 muutettua tiedostoa jossa 735 lisäystä ja 37 poistoa
  1. +11
    -1
      .claude/settings.json
  2. +10
    -0
      README.md
  3. +24
    -12
      docs/scrum-backlog.md
  4. +3
    -0
      src/CartWise.Application/Interfaces/IApplicationDbContext.cs
  5. +6
    -0
      src/CartWise.Application/Interfaces/IShoppingListService.cs
  6. +17
    -0
      src/CartWise.Application/Results/Result.cs
  7. +79
    -0
      src/CartWise.Application/Services/ShoppingListService.cs
  8. +25
    -0
      src/CartWise.Domain/Entities/ShoppingListItem.cs
  9. +89
    -8
      src/CartWise.Web/Controllers/ShoppingListController.cs
  10. +13
    -7
      src/CartWise.Web/Views/ShoppingList/Index.cshtml
  11. +20
    -2
      src/CartWise.Web/Views/ShoppingList/_ListItem.cshtml
  12. +37
    -7
      src/CartWise.Web/wwwroot/css/site.css
  13. +0
    -0
      src/CartWise.Web/wwwroot/js/pages/.gitkeep
  14. +32
    -0
      src/CartWise.Web/wwwroot/js/pages/shopping-list.js
  15. +100
    -0
      tests/CartWise.Application.Tests/ShoppingListServiceTests.cs
  16. +61
    -0
      tests/CartWise.Domain.Tests/ShoppingListItemTests.cs
  17. +208
    -0
      tests/CartWise.Web.Tests/ShoppingListItemActionsTests.cs

+ 11
- 1
.claude/settings.json Näytä tiedosto

@@ -20,7 +20,17 @@
"Bash(rm -f \"g:/development/C Sharp AI/CartWise/src/CartWise.Web/App_Data/cartwise.db\"*)",
"Bash(powershell -Command 'Get-Process | Where-Object { $_.Id -eq 55048 } | Select-Object Id, ProcessName, Path')",
"Bash(powershell -Command 'Get-Process | Where-Object { $_.ProcessName -like '\\\\''*CartWise*'\\\\'' } | Select-Object Id, ProcessName, Path')",
"Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify2.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify2.db\" 2>&1 | tail -15 && rm -f verify2.db*)"
"Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify2.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify2.db\" 2>&1 | tail -15 && rm -f verify2.db*)",
"Bash(python3 -c ' *)",
"Bash(python -c ' *)",
"PowerShell(Get-Process -Name \"CartWise.Web\" -ErrorAction SilentlyContinue)",
"PowerShell(Stop-Process -Force -ErrorAction SilentlyContinue)",
"Bash(chromium-cli --help)",
"Bash(curl -s -b /tmp/cw4.txt http://127.0.0.1:5187/list -o /tmp/list2.html -w \"status=%{http_code}\\\\n\")",
"Bash(sed -n '/shopping-list-quick-add/,/<\\\\/form>/p' /tmp/list2.html)",
"Bash(sed -n '/shopping-list-item/,/<\\\\/li>/p' /tmp/list2.html)",
"Bash(curl -s http://127.0.0.1:5187/css/site.css)",
"PowerShell(\"done\")"
],
"additionalDirectories": [
"G:\\development\\C Sharp AI\\CartWise"


+ 10
- 0
README.md Näytä tiedosto

@@ -160,6 +160,16 @@ Progress so far (see `docs/scrum-backlog.md` for the authoritative task-level st
- [x] `CW-STORY-02.3` Household UI — create and overview pages
- [x] `CW-STORY-02.4` Reusable `"HouseholdMember"` authorization policy, cross-household isolation verified

`CW-EPIC-03` Smart Shopping List is complete:

- [x] `CW-STORY-03.1` ShoppingList/ShoppingListItem/GroceryConcept/ProductCategory entities, EF configuration, migration
- [x] `CW-STORY-03.2` `ShoppingListService` — get/create the household's one active list
- [x] `CW-STORY-03.3` Free-text add-item flow, auto-provisioned default list, verified live end-to-end
- [x] `CW-STORY-03.4` Toggle purchased/skip/delete with optimistic concurrency handling and jQuery progressive enhancement
- [x] `CW-STORY-03.5` Mobile-first/touch-friendly polish pass (not visually verified in a browser — no browser-automation tool available in this environment; recommend a quick manual check)

Next up: `CW-EPIC-05` Stores, Purchases, and Price Intelligence (`CW-EPIC-04` is post-MVP).

Next up: `CW-EPIC-03` Smart Shopping List.

## Project Planning


+ 24
- 12
docs/scrum-backlog.md Näytä tiedosto

@@ -374,13 +374,21 @@ Both were caught by genuine integration tests (`ShoppingListAccessControlTests`,
- Concurrency conflicts are handled safely

**Tasks**
- [ ] Implement toggle purchased
- [ ] Implement skip and unskip
- [ ] Implement delete item
- [ ] Handle concurrency exceptions
- [ ] Add progressive enhancement with jQuery
- [ ] Preserve server-post fallback
- [ ] Add tests
- [x] Implement toggle purchased
- [x] Implement skip and unskip
- [x] Implement delete item
- [x] Handle concurrency exceptions
- [x] Add progressive enhancement with jQuery
- [x] Preserve server-post fallback
- [x] Add tests

**Status:** Done — `ShoppingListItem.TogglePurchased`/`ToggleSkipped` domain methods added (each regenerates `RowVersion`); `POST /list/items/{id}/toggle` and `/skip` are true toggles (one route each, per AGENTS.md §11 — no separate "unpurchase"/"unskip" routes), `/delete` hard-deletes (no "Deleted" status exists, and list items aren't history — unlike `PriceObservation`, nothing here needs to stay append-only). `IShoppingListService.TogglePurchasedAsync`/`ToggleSkippedAsync`/`DeleteItemAsync` all take `householdId` and resolve the item via a household-scoped join query (`FindItemForHouseholdAsync`) rather than trusting the route-supplied item id alone — an item belonging to a different household is indistinguishable from "not found," so no existence is leaked. On reflection this made the `"HouseholdMember"` authorization policy from `CW-STORY-02.4` unnecessary here: query-scoping is atomic (one query both finds the item and enforces ownership) and matches the pattern already used everywhere else in the codebase, so it wasn't invoked for this story — it remains available and tested for a future scenario that needs to authorize before it can even construct the right query.

**Concurrency handling**: the client-rendered `rowVersion` hidden field round-trips through the form; the service sets `_db.Entry(item).Property(i => i.RowVersion).OriginalValue = expectedRowVersion` before mutating, so EF Core's generated `UPDATE`/`DELETE` includes the *client's* last-seen value in its `WHERE` clause (the standard EF Core disconnected-entity concurrency pattern) — a stale submission throws `DbUpdateConcurrencyException`, caught and surfaced as `Result.Failure(...)`, never a 500.

**Progressive enhancement**: `wwwroot/js/pages/shopping-list.js` intercepts the toggle/skip/delete forms' submit via jQuery, POSTs via `$.post` (which sets `X-Requested-With: XMLHttpRequest` automatically), and the controller detects that header to return either the updated `_ListItem` partial (toggle/skip) or `204`/`409` (delete) instead of a redirect — `Views/ShoppingList/_ListItem.cshtml`'s three per-item forms work unmodified via plain server POST if JS is unavailable, satisfying "standard form posts must work where practical."

Two more real bugs found by tests (bringing the running total for `CW-EPIC-03` to four — see `CW-STORY-03.3`'s notes for the first two): both were test-assertion bugs on my part, not product bugs — `Assert.DoesNotContain("Purchased", html)` false-failed because the *toggle button's label* is literally "Purchased" on an unpurchased item (`isPurchased ? "Undo" : "Purchased"`), unrelated to the actual status badge. Fixed by asserting against the specific `badge bg-secondary">{status}</span>` markup instead of a bare substring — worth remembering for `CW-STORY-03.5` and beyond: any status-driven UI where the action-button label overlaps with a possible status name needs a precise assertion, not `Contains`/`DoesNotContain` on the raw status word. Verified live end-to-end via `dotnet run` + curl: toggle Needed→Purchased shown in the status badge with the correct `rowVersion`, and an AJAX request (`X-Requested-With: XMLHttpRequest`) with a deliberately stale `rowVersion` correctly returns `409`.

### `CW-STORY-03.5` Deliver a mobile-first list UI
**Release:** MVP
@@ -394,11 +402,15 @@ Both were caught by genuine integration tests (`ShoppingListAccessControlTests`,
- Tap targets are appropriate for shopping use

**Tasks**
- [ ] Build `Views/ShoppingList/Index.cshtml`
- [ ] Build `Views/ShoppingList/_ListItem.cshtml`
- [ ] Add quick-add UI
- [ ] Add touch-friendly styles
- [ ] Add `wwwroot/js/pages/shopping-list.js`
- [x] Build `Views/ShoppingList/Index.cshtml`
- [x] Build `Views/ShoppingList/_ListItem.cshtml`
- [x] Add quick-add UI
- [x] Add touch-friendly styles
- [x] Add `wwwroot/js/pages/shopping-list.js`

**Status:** Done, with a verification caveat — the Index/_ListItem views, quick-add UI, and `shopping-list.js` already existed functionally from `CW-STORY-03.3`/`03.4`; this story was the dedicated mobile-first/touch-friendly polish pass. Changes: (1) fixed `site.css`'s inverted base font size (was 14px on mobile, 16px on desktop — backwards, and inputs under 16px trigger iOS Safari's zoom-on-focus) to a flat 16px; (2) added `.shopping-list-quick-add`/`.shopping-list-item`/`.shopping-list-item-actions` CSS establishing ~44px-minimum touch targets on every button (removed `btn-sm`) and a 3rem-tall, 1.1rem-font quick-add input/button pair; (3) `NewItem.DisplayName` is now `col-12` at every breakpoint (not just mobile) so it's unambiguously the prominent primary action, with Quantity/Unit/Add sharing a secondary row below; (4) added `inputmode="decimal"` to the Quantity field so mobile browsers show a numeric keypad instead of a full keyboard; (5) narrow viewports (`max-width: 575.98px`) stack each list item's name above its actions instead of cramming them into one row.

**Verification caveat:** no browser-automation tool (Playwright/`chromium-cli`) is available in this environment, so this could not be visually screenshotted at a mobile viewport as the production-readiness guidance calls for. What *was* verified: `dotnet run` + curl confirms the rendered HTML carries the new classes/attributes exactly as written, the CSS file is served and contains the expected rules, and the full test suite (67 tests, unaffected by these purely-visual changes) still passes. The founder should do a quick real-device or DevTools-mobile-emulation pass before treating this as fully done — the CSS follows standard, well-established sizing conventions (WCAG 2.5.5's ~44px target, 16px inputs) but hasn't been eyeballed.

---



+ 3
- 0
src/CartWise.Application/Interfaces/IApplicationDbContext.cs Näytä tiedosto

@@ -1,5 +1,6 @@
using CartWise.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;

namespace CartWise.Application.Interfaces;

@@ -13,5 +14,7 @@ public interface IApplicationDbContext

DbSet<ShoppingListItem> ShoppingListItems { get; }

EntityEntry<TEntity> Entry<TEntity>(TEntity entity) where TEntity : class;

Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

+ 6
- 0
src/CartWise.Application/Interfaces/IShoppingListService.cs Näytä tiedosto

@@ -16,4 +16,10 @@ public interface IShoppingListService
string? unit,
string addedByUserId,
CancellationToken cancellationToken = default);

Task<Result<ShoppingListItem>> TogglePurchasedAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default);

Task<Result<ShoppingListItem>> ToggleSkippedAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default);

Task<Result> DeleteItemAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default);
}

+ 17
- 0
src/CartWise.Application/Results/Result.cs Näytä tiedosto

@@ -1,5 +1,22 @@
namespace CartWise.Application.Results;

public class Result
{
public bool IsSuccess { get; }

public string? Error { get; }

private Result(bool isSuccess, string? error)
{
IsSuccess = isSuccess;
Error = error;
}

public static Result Success() => new(true, null);

public static Result Failure(string error) => new(false, error);
}

public class Result<T>
{
public bool IsSuccess { get; }


+ 79
- 0
src/CartWise.Application/Services/ShoppingListService.cs Näytä tiedosto

@@ -88,4 +88,83 @@ public class ShoppingListService : IShoppingListService

return Result<ShoppingListItem>.Success(item);
}

public async Task<Result<ShoppingListItem>> TogglePurchasedAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default)
{
var item = await FindItemForHouseholdAsync(householdId, itemId, cancellationToken);
if (item is null)
{
return Result<ShoppingListItem>.Failure("Item not found.");
}

_db.Entry(item).Property(i => i.RowVersion).OriginalValue = expectedRowVersion;
item.TogglePurchased(_timeProvider.GetUtcNow().UtcDateTime);

try
{
await _db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
return Result<ShoppingListItem>.Failure("This item was changed by someone else. Please refresh and try again.");
}

return Result<ShoppingListItem>.Success(item);
}

public async Task<Result<ShoppingListItem>> ToggleSkippedAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default)
{
var item = await FindItemForHouseholdAsync(householdId, itemId, cancellationToken);
if (item is null)
{
return Result<ShoppingListItem>.Failure("Item not found.");
}

_db.Entry(item).Property(i => i.RowVersion).OriginalValue = expectedRowVersion;
item.ToggleSkipped();

try
{
await _db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
return Result<ShoppingListItem>.Failure("This item was changed by someone else. Please refresh and try again.");
}

return Result<ShoppingListItem>.Success(item);
}

public async Task<Result> DeleteItemAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default)
{
var item = await FindItemForHouseholdAsync(householdId, itemId, cancellationToken);
if (item is null)
{
return Result.Failure("Item not found.");
}

_db.Entry(item).Property(i => i.RowVersion).OriginalValue = expectedRowVersion;
_db.ShoppingListItems.Remove(item);

try
{
await _db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
return Result.Failure("This item was changed by someone else. Please refresh and try again.");
}

return Result.Success();
}

private Task<ShoppingListItem?> FindItemForHouseholdAsync(Guid householdId, Guid itemId, CancellationToken cancellationToken)
{
return (
from item in _db.ShoppingListItems
join list in _db.ShoppingLists on item.ShoppingListId equals list.ShoppingListId
where item.ShoppingListItemId == itemId && list.HouseholdId == householdId
select item
).FirstOrDefaultAsync(cancellationToken);
}
}

+ 25
- 0
src/CartWise.Domain/Entities/ShoppingListItem.cs Näytä tiedosto

@@ -69,4 +69,29 @@ public class ShoppingListItem
AddedUtc = addedUtc;
RowVersion = Guid.NewGuid();
}

public void TogglePurchased(DateTime utcNow)
{
if (Status == ShoppingListItemStatus.Purchased)
{
Status = ShoppingListItemStatus.Needed;
PurchasedUtc = null;
}
else
{
Status = ShoppingListItemStatus.Purchased;
PurchasedUtc = utcNow;
}

RowVersion = Guid.NewGuid();
}

public void ToggleSkipped()
{
Status = Status == ShoppingListItemStatus.Skipped
? ShoppingListItemStatus.Needed
: ShoppingListItemStatus.Skipped;

RowVersion = Guid.NewGuid();
}
}

+ 89
- 8
src/CartWise.Web/Controllers/ShoppingListController.cs Näytä tiedosto

@@ -1,5 +1,6 @@
using System.Globalization;
using CartWise.Application.Interfaces;
using CartWise.Application.Results;
using CartWise.Infrastructure.Identity;
using CartWise.Web.ViewModels.ShoppingList;
using Microsoft.AspNetCore.Authorization;
@@ -72,6 +73,84 @@ public class ShoppingListController : Controller
return RedirectToAction(nameof(Index));
}

[HttpPost("items/{id:guid}/toggle")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> TogglePurchased(Guid id, Guid rowVersion)
{
var household = await CurrentHouseholdOrNullAsync();
if (household is null)
{
return RedirectToAction("Create", "Household");
}

var result = await _shoppingListService.TogglePurchasedAsync(household.HouseholdId, id, rowVersion);
return RespondToItemMutation(result);
}

[HttpPost("items/{id:guid}/skip")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleSkipped(Guid id, Guid rowVersion)
{
var household = await CurrentHouseholdOrNullAsync();
if (household is null)
{
return RedirectToAction("Create", "Household");
}

var result = await _shoppingListService.ToggleSkippedAsync(household.HouseholdId, id, rowVersion);
return RespondToItemMutation(result);
}

[HttpPost("items/{id:guid}/delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteItem(Guid id, Guid rowVersion)
{
var household = await CurrentHouseholdOrNullAsync();
if (household is null)
{
return RedirectToAction("Create", "Household");
}

var result = await _shoppingListService.DeleteItemAsync(household.HouseholdId, id, rowVersion);

if (IsAjaxRequest())
{
return result.IsSuccess ? NoContent() : Problem(result.Error, statusCode: StatusCodes.Status409Conflict);
}

if (!result.IsSuccess)
{
TempData["ListError"] = result.Error;
}

return RedirectToAction(nameof(Index));
}

private async Task<Domain.Entities.Household?> CurrentHouseholdOrNullAsync()
{
var userId = _userManager.GetUserId(User)!;
return await _householdService.GetCurrentHouseholdAsync(userId);
}

private IActionResult RespondToItemMutation(Result<Domain.Entities.ShoppingListItem> result)
{
if (IsAjaxRequest())
{
return result.IsSuccess
? PartialView("_ListItem", ToItemViewModel(result.Value!))
: Problem(result.Error, statusCode: StatusCodes.Status409Conflict);
}

if (!result.IsSuccess)
{
TempData["ListError"] = result.Error;
}

return RedirectToAction(nameof(Index));
}

private bool IsAjaxRequest() => Request.Headers["X-Requested-With"] == "XMLHttpRequest";

private async Task<Domain.Entities.ShoppingList> GetOrCreateActiveListAsync(Guid householdId, string userId)
{
var list = await _shoppingListService.GetActiveListAsync(householdId);
@@ -91,17 +170,19 @@ public class ShoppingListController : Controller
Name = list.Name,
Items = list.Items
.OrderBy(i => i.SortOrder)
.Select(i => new ShoppingListItemViewModel
{
Id = i.ShoppingListItemId,
DisplayName = i.DisplayName,
QuantityDisplay = FormatQuantity(i.Quantity, i.Unit),
Status = i.Status,
ConcurrencyToken = i.RowVersion
})
.Select(ToItemViewModel)
.ToList()
};

private static ShoppingListItemViewModel ToItemViewModel(Domain.Entities.ShoppingListItem item) => new()
{
Id = item.ShoppingListItemId,
DisplayName = item.DisplayName,
QuantityDisplay = FormatQuantity(item.Quantity, item.Unit),
Status = item.Status,
ConcurrencyToken = item.RowVersion
};

private static string? FormatQuantity(decimal? quantity, string? unit)
{
if (quantity is null)


+ 13
- 7
src/CartWise.Web/Views/ShoppingList/Index.cshtml Näytä tiedosto

@@ -5,24 +5,29 @@

<h1>@Model.Name</h1>

<form asp-controller="ShoppingList" asp-action="AddItem" method="post" class="row g-2 mb-4">
@if (TempData["ListError"] is string listError)
{
<div class="alert alert-danger">@listError</div>
}

<form asp-controller="ShoppingList" asp-action="AddItem" method="post" class="shopping-list-quick-add row g-2 mb-4">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="col-12 col-sm-5">
<div class="col-12">
<label asp-for="NewItem.DisplayName" class="visually-hidden"></label>
<input asp-for="NewItem.DisplayName" class="form-control" placeholder="Add an item (e.g. Milk)" autofocus />
<input asp-for="NewItem.DisplayName" class="form-control" placeholder="Add an item (e.g. Milk)" autofocus autocomplete="off" />
<span asp-validation-for="NewItem.DisplayName" class="text-danger"></span>
</div>
<div class="col-6 col-sm-3">
<div class="col-5 col-sm-4">
<label asp-for="NewItem.Quantity" class="visually-hidden"></label>
<input asp-for="NewItem.Quantity" class="form-control" placeholder="Qty" />
<input asp-for="NewItem.Quantity" class="form-control" placeholder="Qty" inputmode="decimal" />
<span asp-validation-for="NewItem.Quantity" class="text-danger"></span>
</div>
<div class="col-6 col-sm-2">
<div class="col-4 col-sm-3">
<label asp-for="NewItem.Unit" class="visually-hidden"></label>
<input asp-for="NewItem.Unit" class="form-control" placeholder="Unit" />
<span asp-validation-for="NewItem.Unit" class="text-danger"></span>
</div>
<div class="col-12 col-sm-2">
<div class="col-3 col-sm-2">
<button type="submit" class="btn btn-primary w-100">Add</button>
</div>
</form>
@@ -43,4 +48,5 @@ else

@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script src="~/js/pages/shopping-list.js" asp-append-version="true"></script>
}

+ 20
- 2
src/CartWise.Web/Views/ShoppingList/_ListItem.cshtml Näytä tiedosto

@@ -1,6 +1,10 @@
@model CartWise.Web.ViewModels.ShoppingList.ShoppingListItemViewModel
@{
var isPurchased = Model.Status == CartWise.Domain.Enums.ShoppingListItemStatus.Purchased;
var isSkipped = Model.Status == CartWise.Domain.Enums.ShoppingListItemStatus.Skipped;
}

<li class="list-group-item d-flex justify-content-between align-items-center">
<li class="shopping-list-item list-group-item d-flex justify-content-between align-items-center" id="list-item-@Model.Id">
<span>
@Model.DisplayName
@if (!string.IsNullOrEmpty(Model.QuantityDisplay))
@@ -8,5 +12,19 @@
<span class="text-muted">(@Model.QuantityDisplay)</span>
}
</span>
<span class="badge bg-secondary">@Model.Status</span>
<span class="shopping-list-item-actions d-flex align-items-center gap-2">
<span class="badge bg-secondary">@Model.Status</span>
<form asp-controller="ShoppingList" asp-action="TogglePurchased" asp-route-id="@Model.Id" method="post" class="d-inline js-item-action-form">
<input type="hidden" name="rowVersion" value="@Model.ConcurrencyToken" />
<button type="submit" class="btn btn-outline-success">@(isPurchased ? "Undo" : "Purchased")</button>
</form>
<form asp-controller="ShoppingList" asp-action="ToggleSkipped" asp-route-id="@Model.Id" method="post" class="d-inline js-item-action-form">
<input type="hidden" name="rowVersion" value="@Model.ConcurrencyToken" />
<button type="submit" class="btn btn-outline-secondary">@(isSkipped ? "Unskip" : "Skip")</button>
</form>
<form asp-controller="ShoppingList" asp-action="DeleteItem" asp-route-id="@Model.Id" method="post" class="d-inline js-item-delete-form">
<input type="hidden" name="rowVersion" value="@Model.ConcurrencyToken" />
<button type="submit" class="btn btn-outline-danger">Delete</button>
</form>
</span>
</li>

+ 37
- 7
src/CartWise.Web/wwwroot/css/site.css Näytä tiedosto

@@ -1,11 +1,7 @@
html {
font-size: 14px;
}

@media (min-width: 768px) {
html {
font-size: 16px;
}
/* 16px base everywhere: mobile needs this at least as large as desktop for
readability, and inputs below 16px trigger iOS Safari's zoom-on-focus. */
font-size: 16px;
}

.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
@@ -28,4 +24,38 @@ body {

.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}

/* Shopping list: quick-add stays the obvious primary action, and every
tappable control meets the ~44px minimum touch target size. */
.shopping-list-quick-add input {
min-height: 3rem;
font-size: 1.1rem;
}

.shopping-list-quick-add button {
min-height: 3rem;
font-size: 1.1rem;
}

.shopping-list-item {
gap: 0.5rem;
}

.shopping-list-item-actions {
flex-wrap: wrap;
row-gap: 0.5rem;
}

.shopping-list-item-actions .btn {
min-height: 2.75rem;
min-width: 2.75rem;
padding: 0.5rem 0.9rem;
}

@media (max-width: 575.98px) {
.shopping-list-item {
flex-direction: column;
align-items: stretch !important;
}
}

+ 0
- 0
src/CartWise.Web/wwwroot/js/pages/.gitkeep Näytä tiedosto


+ 32
- 0
src/CartWise.Web/wwwroot/js/pages/shopping-list.js Näytä tiedosto

@@ -0,0 +1,32 @@
$(function () {
var $list = $('#shopping-list-items');

function showError(xhr) {
var message = (xhr.responseJSON && xhr.responseJSON.detail)
|| 'This item could not be updated. Please refresh and try again.';
alert(message);
}

$list.on('submit', '.js-item-action-form', function (event) {
event.preventDefault();
var $form = $(this);

$.post($form.attr('action'), $form.serialize())
.done(function (html) {
$form.closest('li').replaceWith(html);
})
.fail(showError);
});

$list.on('submit', '.js-item-delete-form', function (event) {
event.preventDefault();
var $form = $(this);
var $item = $form.closest('li');

$.post($form.attr('action'), $form.serialize())
.done(function () {
$item.remove();
})
.fail(showError);
});
});

+ 100
- 0
tests/CartWise.Application.Tests/ShoppingListServiceTests.cs Näytä tiedosto

@@ -121,4 +121,104 @@ public class ShoppingListServiceTests : IDisposable

Assert.False(result.IsSuccess);
}

[Fact]
public async Task TogglePurchasedAsync_MarksItemPurchased()
{
var (householdId, item) = await CreateItemAsync();

var result = await _sut.TogglePurchasedAsync(householdId, item.ShoppingListItemId, item.RowVersion);

Assert.True(result.IsSuccess);
Assert.Equal(Domain.Enums.ShoppingListItemStatus.Purchased, result.Value!.Status);
}

[Fact]
public async Task TogglePurchasedAsync_FailsForItemInAnotherHousehold()
{
var (_, item) = await CreateItemAsync();

var result = await _sut.TogglePurchasedAsync(Guid.NewGuid(), item.ShoppingListItemId, item.RowVersion);

Assert.False(result.IsSuccess);
}

[Fact]
public async Task TogglePurchasedAsync_FailsOnConcurrencyConflict()
{
var (householdId, item) = await CreateItemAsync();
var staleRowVersion = Guid.NewGuid();

var result = await _sut.TogglePurchasedAsync(householdId, item.ShoppingListItemId, staleRowVersion);

Assert.False(result.IsSuccess);
Assert.NotNull(result.Error);
}

[Fact]
public async Task ToggleSkippedAsync_MarksItemSkipped()
{
var (householdId, item) = await CreateItemAsync();

var result = await _sut.ToggleSkippedAsync(householdId, item.ShoppingListItemId, item.RowVersion);

Assert.True(result.IsSuccess);
Assert.Equal(Domain.Enums.ShoppingListItemStatus.Skipped, result.Value!.Status);
}

[Fact]
public async Task ToggleSkippedAsync_UnskipsOnSecondCall()
{
var (householdId, item) = await CreateItemAsync();
var firstResult = await _sut.ToggleSkippedAsync(householdId, item.ShoppingListItemId, item.RowVersion);

var secondResult = await _sut.ToggleSkippedAsync(householdId, item.ShoppingListItemId, firstResult.Value!.RowVersion);

Assert.True(secondResult.IsSuccess);
Assert.Equal(Domain.Enums.ShoppingListItemStatus.Needed, secondResult.Value!.Status);
}

[Fact]
public async Task DeleteItemAsync_RemovesItemFromList()
{
var (householdId, item) = await CreateItemAsync();

var result = await _sut.DeleteItemAsync(householdId, item.ShoppingListItemId, item.RowVersion);

Assert.True(result.IsSuccess);
var list = await _sut.GetActiveListAsync(householdId);
Assert.Empty(list!.Items);
}

[Fact]
public async Task DeleteItemAsync_FailsForItemInAnotherHousehold()
{
var (_, item) = await CreateItemAsync();

var result = await _sut.DeleteItemAsync(Guid.NewGuid(), item.ShoppingListItemId, item.RowVersion);

Assert.False(result.IsSuccess);
}

[Fact]
public async Task DeleteItemAsync_FailsOnConcurrencyConflict()
{
var (householdId, item) = await CreateItemAsync();
var staleRowVersion = Guid.NewGuid();

var result = await _sut.DeleteItemAsync(householdId, item.ShoppingListItemId, staleRowVersion);

Assert.False(result.IsSuccess);
var list = await _sut.GetActiveListAsync(householdId);
Assert.Single(list!.Items);
}

private async Task<(Guid HouseholdId, Domain.Entities.ShoppingListItem Item)> CreateItemAsync()
{
var householdId = Guid.NewGuid();
var list = await _sut.CreateListAsync(householdId, "Groceries", "user-1");
var item = await _sut.AddItemAsync(list.Value!.ShoppingListId, "Milk", null, null, "user-1");

return (householdId, item.Value!);
}
}

+ 61
- 0
tests/CartWise.Domain.Tests/ShoppingListItemTests.cs Näytä tiedosto

@@ -0,0 +1,61 @@
using CartWise.Domain.Entities;
using CartWise.Domain.Enums;

namespace CartWise.Domain.Tests;

public class ShoppingListItemTests
{
private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc);

[Fact]
public void TogglePurchased_MarksNeededItemAsPurchased()
{
var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now);
var item = list.AddItem("Milk", null, null, "user-1", Now);
var originalRowVersion = item.RowVersion;

item.TogglePurchased(Now);

Assert.Equal(ShoppingListItemStatus.Purchased, item.Status);
Assert.Equal(Now, item.PurchasedUtc);
Assert.NotEqual(originalRowVersion, item.RowVersion);
}

[Fact]
public void TogglePurchased_UndoesPurchaseWhenAlreadyPurchased()
{
var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now);
var item = list.AddItem("Milk", null, null, "user-1", Now);
item.TogglePurchased(Now);

item.TogglePurchased(Now.AddMinutes(5));

Assert.Equal(ShoppingListItemStatus.Needed, item.Status);
Assert.Null(item.PurchasedUtc);
}

[Fact]
public void ToggleSkipped_MarksNeededItemAsSkipped()
{
var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now);
var item = list.AddItem("Milk", null, null, "user-1", Now);
var originalRowVersion = item.RowVersion;

item.ToggleSkipped();

Assert.Equal(ShoppingListItemStatus.Skipped, item.Status);
Assert.NotEqual(originalRowVersion, item.RowVersion);
}

[Fact]
public void ToggleSkipped_UnskipsWhenAlreadySkipped()
{
var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now);
var item = list.AddItem("Milk", null, null, "user-1", Now);
item.ToggleSkipped();

item.ToggleSkipped();

Assert.Equal(ShoppingListItemStatus.Needed, item.Status);
}
}

+ 208
- 0
tests/CartWise.Web.Tests/ShoppingListItemActionsTests.cs Näytä tiedosto

@@ -0,0 +1,208 @@
using System.Net;
using System.Text.RegularExpressions;

namespace CartWise.Web.Tests;

public class ShoppingListItemActionsTests : IClassFixture<CartWiseWebApplicationFactory>
{
private readonly CartWiseWebApplicationFactory _factory;

public ShoppingListItemActionsTests(CartWiseWebApplicationFactory factory)
{
_factory = factory;
}

[Fact]
public async Task TogglePurchased_MarksItemPurchased()
{
var client = _factory.CreateClient();
await WebTestHelpers.RegisterAsync(client, "toggle1@example.com", "Toggle User");
await WebTestHelpers.CreateHouseholdAsync(client, "Toggle Household");
await AddItemAsync(client, "Milk");

var listHtml = await client.GetStringAsync("/list");
var (id, rowVersion) = ExtractFirstItemInfo(listHtml);
var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml);

var response = await client.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary<string, string>
{
["__RequestVerificationToken"] = token,
["rowVersion"] = rowVersion
}));
response.EnsureSuccessStatusCode();

var updatedHtml = await client.GetStringAsync("/list");
HtmlAssert.Contains("Purchased", updatedHtml);
}

[Fact]
public async Task TogglePurchased_TwiceReturnsItemToNeeded()
{
var client = _factory.CreateClient();
await WebTestHelpers.RegisterAsync(client, "toggle2@example.com", "Toggle User 2");
await WebTestHelpers.CreateHouseholdAsync(client, "Toggle Household 2");
await AddItemAsync(client, "Milk");

var listHtml = await client.GetStringAsync("/list");
var (id, rowVersion) = ExtractFirstItemInfo(listHtml);
var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml);

await client.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary<string, string>
{
["__RequestVerificationToken"] = token,
["rowVersion"] = rowVersion
}));

var midHtml = await client.GetStringAsync("/list");
var (_, secondRowVersion) = ExtractFirstItemInfo(midHtml);
var midToken = WebTestHelpers.ExtractAntiForgeryToken(midHtml);

await client.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary<string, string>
{
["__RequestVerificationToken"] = midToken,
["rowVersion"] = secondRowVersion
}));

var finalHtml = await client.GetStringAsync("/list");
HtmlAssert.Contains("Needed", finalHtml);
}

[Fact]
public async Task ToggleSkipped_MarksItemSkipped()
{
var client = _factory.CreateClient();
await WebTestHelpers.RegisterAsync(client, "skip1@example.com", "Skip User");
await WebTestHelpers.CreateHouseholdAsync(client, "Skip Household");
await AddItemAsync(client, "Bread");

var listHtml = await client.GetStringAsync("/list");
var (id, rowVersion) = ExtractFirstItemInfo(listHtml);
var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml);

var response = await client.PostAsync($"/list/items/{id}/skip", new FormUrlEncodedContent(new Dictionary<string, string>
{
["__RequestVerificationToken"] = token,
["rowVersion"] = rowVersion
}));
response.EnsureSuccessStatusCode();

var updatedHtml = await client.GetStringAsync("/list");
HtmlAssert.Contains("Skipped", updatedHtml);
}

[Fact]
public async Task DeleteItem_RemovesItFromTheList()
{
var client = _factory.CreateClient();
await WebTestHelpers.RegisterAsync(client, "delete1@example.com", "Delete User");
await WebTestHelpers.CreateHouseholdAsync(client, "Delete Household");
await AddItemAsync(client, "Eggs");

var listHtml = await client.GetStringAsync("/list");
var (id, rowVersion) = ExtractFirstItemInfo(listHtml);
var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml);

var response = await client.PostAsync($"/list/items/{id}/delete", new FormUrlEncodedContent(new Dictionary<string, string>
{
["__RequestVerificationToken"] = token,
["rowVersion"] = rowVersion
}));
response.EnsureSuccessStatusCode();

var updatedHtml = await client.GetStringAsync("/list");
Assert.DoesNotContain("Eggs", updatedHtml);
HtmlAssert.Contains("No items yet", updatedHtml);
}

[Fact]
public async Task ToggleItem_FailsForAnItemInAnotherHousehold()
{
var clientA = _factory.CreateClient();
var clientB = _factory.CreateClient();
await WebTestHelpers.RegisterAsync(clientA, "isoalice@example.com", "Iso Alice");
await WebTestHelpers.RegisterAsync(clientB, "isobob@example.com", "Iso Bob");
await WebTestHelpers.CreateHouseholdAsync(clientA, "Iso Household A");
await WebTestHelpers.CreateHouseholdAsync(clientB, "Iso Household B");
await AddItemAsync(clientA, "Alice Only Item");

var listHtmlA = await clientA.GetStringAsync("/list");
var (id, rowVersion) = ExtractFirstItemInfo(listHtmlA);

var listHtmlB = await clientB.GetStringAsync("/list");
var tokenB = WebTestHelpers.ExtractAntiForgeryToken(listHtmlB);

var response = await clientB.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary<string, string>
{
["__RequestVerificationToken"] = tokenB,
["rowVersion"] = rowVersion
}));
response.EnsureSuccessStatusCode();

var finalHtmlA = await clientA.GetStringAsync("/list");
Assert.True(HasStatusBadge(finalHtmlA, "Needed"), "Expected the item to still show a Needed status badge.");
Assert.False(HasStatusBadge(finalHtmlA, "Purchased"), "The other household's toggle should not have changed this item's status.");
}

[Fact]
public async Task ToggleItem_AjaxRequestWithStaleRowVersionReturnsConflict()
{
var client = _factory.CreateClient();
await WebTestHelpers.RegisterAsync(client, "conflict1@example.com", "Conflict User");
await WebTestHelpers.CreateHouseholdAsync(client, "Conflict Household");
await AddItemAsync(client, "Butter");

var listHtml = await client.GetStringAsync("/list");
var (id, _) = ExtractFirstItemInfo(listHtml);
var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml);

var request = new HttpRequestMessage(HttpMethod.Post, $"/list/items/{id}/toggle")
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["__RequestVerificationToken"] = token,
["rowVersion"] = Guid.NewGuid().ToString()
})
};
request.Headers.Add("X-Requested-With", "XMLHttpRequest");

var response = await client.SendAsync(request);

Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}

private static async Task AddItemAsync(HttpClient client, string displayName)
{
var html = await client.GetStringAsync("/list");
var token = WebTestHelpers.ExtractAntiForgeryToken(html);

var response = await client.PostAsync("/list/items", new FormUrlEncodedContent(new Dictionary<string, string>
{
["__RequestVerificationToken"] = token,
["NewItem.DisplayName"] = displayName
}));
response.EnsureSuccessStatusCode();
}

private static bool HasStatusBadge(string html, string status)
{
return Regex.IsMatch(html, $"badge bg-secondary\">{Regex.Escape(status)}</span>");
}

private static (string Id, string RowVersion) ExtractFirstItemInfo(string html)
{
var idMatch = Regex.Match(html, "id=\"list-item-([0-9a-fA-F-]{36})\"");
if (!idMatch.Success)
{
throw new InvalidOperationException("No list item found in HTML.");
}

var window = html.Substring(idMatch.Index, Math.Min(1200, html.Length - idMatch.Index));
var rowVersionMatch = Regex.Match(window, "name=\"rowVersion\" value=\"([^\"]+)\"");
if (!rowVersionMatch.Success)
{
throw new InvalidOperationException("No rowVersion field found for the list item.");
}

return (idMatch.Groups[1].Value, rowVersionMatch.Groups[1].Value);
}
}

Loading…
Peruuta
Tallenna

Powered by TurnKey Linux.