Compare commits

...

18 Commits

Author SHA1 Message Date
Josh Rogers 6ca6c7d401 Copy package-lock.json into frontend runtime stage for npm ci
YesChef/pipeline/head This commit looks good
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:30:48 -05:00
Josh Rogers c7ba88f447 Fix frontend Dockerfile COPY destination for multi-source glob
YesChef/pipeline/head There was a failure building this commit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:52:43 -05:00
Josh Rogers 0e59fd5bad Add Jenkinsfile for build, test, and Gitea image push
YesChef/pipeline/head There was a failure building this commit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:44:53 -05:00
Josh Rogers fa465ac29c Section defaults on products with prompt-on-divergence
- Add DefaultSection (string?) to Product, FamilyProduct, and
  FamilyProductOverride. Resolution order on item add: per-store memory
  (ProductStoreSection) → family default (override or family product) →
  global product default, matched case-insensitively by name to the
  current store''s StoreSection rows.
- Drop the section dropdown from the add-item form; the backend resolves
  on POST. Section change on a list row syncs the family default:
  silently creates it when none exists, no-op when it matches, returns
  promptSaveDefault=true when it differs so the client confirms before
  updating.
- Expose DefaultSection on the catalog page form. Replace the native
  datalist with a styled TextCombobox component for autocomplete
  suggestions (seeded from typicalSections) while still allowing
  free-text entry. Pattern doc-noted as the sibling of ProductTypeahead.
- Remove the now-unused GET /api/products/{kind}/{id}/section endpoint
  and its tests; the add-form pre-fill they backed is gone.
2026-05-15 22:21:56 -05:00
Josh Rogers 6d84aad94b Pre-fill list section on product pick; tighten backend warnings
- Adds GET /api/products/{kind}/{id}/section?storeId=... exposing the
  per-store memory the list page mirrors when a product is picked, so the
  section dropdown reflects what the backend would auto-assign on POST.
- Treats backend warnings as errors via Directory.Build.props; fixes the
  surfaced warnings (obsolete PostgreSqlBuilder ctor, nullable string[]
  in IsEquivalentTo, redundant nullable flow).
- Annotates wire-exposed enums (ProductKind, UnitKind, UnitCategory,
  UnitCategoryFlags) with JsonStringEnumConverter so they round-trip as
  strings regardless of caller options. Unblocks the integration tests
  that deserialize DTOs via GetFromJsonAsync without the global converter.
2026-05-15 21:30:00 -05:00
Josh Rogers f38530cf81 Backlog: reflect shipped product catalog state
Replace the speculative product-catalog design notes with a
shipped/remaining breakdown, capture the decisions made along the way
(visible override pill, whole-override reset, unconditional family-product
delete, recipes share the catalog), and tighten the auto-assign-section
item to reflect that only the frontend pre-fill on product pick is left.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:34:09 -05:00
Josh Rogers 1ce92f3c99 Add product catalog management page
Surfaces the family-scoped product catalog: search, add a family product,
edit a global catalog entry (writes a FamilyProductOverride), and reset
or delete. Adds an "Edited" badge in both the catalog list and the
shopping-list typeahead so overridden entries are distinguishable from
their global defaults. Backend gains DELETE /family/{id} and
DELETE /global/{id}/override to support the new flows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:31:37 -05:00
Josh Rogers 7c30c7db27 Block create-list flow when no stores exist
Tapping "+ New list" with zero stores now shows a warning toast with an
"Add a store" action linking to /stores, instead of opening a form whose
required store dropdown would be empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:53:51 -05:00
Josh Rogers b7e4ebc15a Add permanent list delete; move stores out of main nav
- Add DELETE /api/lists/{id}/permanent endpoint for hard-delete alongside existing soft-delete (archive)
- Add Delete button with confirmation on list detail page next to Archive
- Handle ListDeleted SignalR event on lists overview for real-time removal
- Remove Stores from bottom nav; add Manage stores link at bottom of Lists page
- Add back-to-lists navigation on the Stores page

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-14 21:38:53 -05:00
Josh Rogers bf01063c3a Replace ad-hoc Tailwind strings with design tokens
Define .field, .field-lg, .select, .select-lg, .btn-primary,
.btn-secondary, and .btn-danger-link in app.css as @layer components.
The .select tokens use appearance-none + an inline SVG chevron so
all dropdowns look identical to text inputs (no OS-level gray gradient).

Apply tokens across every in-app form: QuantityInput, shopping list
add form and item rows, recipe new/edit pages, recipe detail, stores,
and lists create form. Drop the STYLE_GUIDE.md doc in favour of the
tokens themselves as the source of truth, and update CLAUDE.md to
document the token names and usage rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:25:57 -05:00
Josh Rogers a398f8cf44 Fix form control consistency; add frontend style guide
- QuantityInput unit select: add bg-white (browser defaults to gray
  without it) and capitalize default option to "Unit"
- Shopping list section dropdowns: rename "Uncategorized" → "No section"
  for consistent title-case phrasing across all default options
- src/frontend/STYLE_GUIDE.md: documents form control classes, button
  variants, text casing rules, icon usage, color tokens, and spacing
  rhythm so all future UI work stays consistent
- CLAUDE.md: link to the style guide so it is always consulted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:13:36 -05:00
Josh Rogers 32ce4d1a6b Align add-item form controls to consistent height
Section dropdown and Add button were py-2.5 while QuantityInput uses
py-2, causing a height mismatch. Normalized all controls in the second
row to py-2 and capped the section dropdown width at max-w-36.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:10:40 -05:00
Josh Rogers 09003b963d Replace x delete buttons with trash can icon
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:08:28 -05:00
Josh Rogers bd540e506f Backlog: structured multi-step recipe instructions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:03:59 -05:00
Josh Rogers 0d20e446e0 Backlog: document auto-assign section from product feature
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:57:37 -05:00
Josh Rogers 7b7e871827 Give shopping list item name field full-width row
The name field was crammed into a single flex row with qty/unit inputs,
section dropdown, and Add button, leaving it too narrow to use. Moved
it above the controls row so it spans full width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:56:19 -05:00
Josh Rogers b31ff77548 Fix unit/product kind enums serializing as integers
Two bugs found during exploratory testing:

1. UnitKind and ProductKind enums were serialized as 0/1 instead of
   "Global"/"Family" because no global JSON converter was registered.
   Added JsonStringEnumConverter to ConfigureHttpJsonOptions so all
   enum responses (kind, category) serialize as strings. This also
   fixes UnitCategory coming back as a number.

2. units.svelte.ts triggered a Svelte 5 state_unsafe_mutation error
   because the `all` getter called load() (which mutates $state) from
   inside a $derived expression in QuantityInput. Wrapped the load()
   call in untrack() so the side-effect runs outside the reactive
   tracking context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:37:16 -05:00
Josh Rogers 68292c2906 Harden recipe edit page and cover allowedUnitCategories projection
Edit page now surfaces load and save errors instead of failing silently,
round-trips sourceUrl through PUT, warns before discarding unsaved
changes, and offers an explicit Cancel button. Delete moved off the
detail page's primary action row into a less-prominent footer link so a
mis-tap on Edit can no longer destroy the recipe. Added integration
tests covering AllowedUnitCategories in the recipe GET projection for
all four product-link shapes (global no-override, global with override,
family product, unlinked).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:01:06 -05:00
37 changed files with 3006 additions and 291 deletions
+38 -53
View File
@@ -46,55 +46,31 @@ Speculative or longer-term ideas live in `ideas.md`.
## Product catalog
### Introduce Product entity + family-scoped overrides
A `Product` becomes the canonical thing being bought. Shopping list items and recipe ingredients reference a product instead of (or in addition to) carrying a free-form name. Goals:
1. Ship an extensive pre-populated grocery catalog so users don't enter "Bananas" from scratch.
2. Allow free-form product creation when something isn't in the catalog.
3. Allow editing of pre-populated product details (e.g. assigning a default section per store).
4. **Custom products and edits are scoped to the family that made them** — never visible to other families.
5. Long-term: track family-level edits/additions to surface promotion candidates for the global catalog.
The product-catalog foundation has shipped. A `Product` is the canonical thing being bought; `ShoppingListItem` and `RecipeIngredient` carry an optional `ProductId`/`FamilyProductId` link with the free-form `Name` retained as a fallback for one-off text entries.
#### Data model sketch
- `Product` (global catalog, read-only): `Id`, `Name`, `DefaultUnit?`, `Brand?`, `Notes?`, search keys.
- `FamilyProduct` (family-owned): `Id`, `FamilyId`, `Name`, plus the same detail fields. Family-only products live here.
- `FamilyProductOverride` (family-scoped edit of a global product): `FamilyId`, `ProductId`, overridden fields. The effective view = global + family override merged.
- Per-store-per-product section assignment: `ProductStoreSection` (`FamilyId`, `ProductId`-or-`FamilyProductId`, `StoreId`, `StoreSectionId`). This is what auto-assigns "Bananas → Produce" on next add at that store, answering the open question in the sections feature.
- `ShoppingListItem` and `RecipeIngredient` get optional `ProductId` (or `FamilyProductId`); free-form `Name` remains as a fallback for legacy rows / pure-text entries.
#### Shipped
- **Entities & schema:** `Product` (global, read-only), `FamilyProduct` (family-owned), `FamilyProductOverride` (per-family edit of a global product, composite PK on `FamilyId` + `ProductId`), and `ProductStoreSection` (per-store-per-product section memory). `AllowedUnitCategories` on each so the unit dropdown can be narrowed per product.
- **API (`/api/products`):** search with transparent override merge (returns `isOverridden` per row); `POST` to create a `FamilyProduct`; `PUT /family/{id}` and `PUT /global/{id}` (the latter upserts a `FamilyProductOverride`); `DELETE /family/{id}` and `DELETE /global/{id}/override` to remove.
- **Frontend typeahead:** wired into the shopping list add-item form and both recipe new/edit forms. Picking a product attaches the link; editing the input after a pick clears it. No-match Enter creates a pure-text item (`ProductId = null`).
- **Catalog management page (`/products`):** search the effective catalog, add a family product, edit a global product (writes an override) or family product, "Reset to catalog default" for an overridden global, and delete a family product. Linked from the lists page.
- **Override indicator:** "Edited" pill on overridden rows in both the catalog page and the typeahead dropdown.
- **`ProductStoreSection` write path:** when an item is saved/checked with `(productId, sectionId)`, the mapping is remembered for `(family, store, product)`.
- **Auto-assign section from product on add.** `GET /api/products/{kind}/{id}/section?storeId=...` exposes the remembered mapping; `onItemProductChange` on the list page calls it and pre-fills the section dropdown so the user can see (and override) what the backend would auto-assign. The recipe → list copy path already lands ingredients in remembered sections server-side.
- **Seed data:** ~50 hand-curated common-groceries items in `Data/Seed/products.json`, applied by `CatalogSeeder` on startup (idempotent on `Name`).
#### API
- `/api/products?q=` — search effective catalog (global family additions, with overrides applied). Returns merged results without exposing whether a hit came from the global catalog vs. family.
- `/api/products` POST — create a `FamilyProduct` (family scope).
- `/api/products/{id}` PUT — write a `FamilyProductOverride` if the id refers to a global product, or update the `FamilyProduct` directly if family-owned.
- All scoped by the authed user's `FamilyId`.
#### Remaining
- **"Add '<text>' as a new product" affordance in the typeahead.** Today the typeahead only suggests existing catalog rows; an unmatched name becomes a pure-text item. Adding an explicit affordance to promote that name into a `FamilyProduct` from the same dropdown is still open.
- **Seed expansion to ~23k curated items.** Current seed file has ~50 entries. Growth is a data exercise, not a code one — keep `products.json` the source of truth, keep the seeder idempotent on `Name`.
- **Catalog ingestion tooling (future).** When the curated list starts feeling limiting, build re-runnable importers for public datasets so we don't grow by hand-typing.
- Candidate sources: **USDA FoodData Central** (public domain — Branded Foods ~400k with UPCs, Foundation/SR Legacy generics) and **OpenFoodFacts** (ODbL share-alike + attribution — ~3M global products, barcodes, images; watch the share-alike obligation on derived datasets).
- Tooling concerns: normalize and dedupe by name+brand+UPC; record `source` + `source-id` on each imported `Product` for re-sync/revert; per-source attribution metadata for license compliance; idempotent scripts kept out of the runtime startup path; a quality/popularity flag so imports don't drown the curated set in the typeahead.
- **Promotion analytics (long-term).** Aggregate `FamilyProduct` creations across families to surface promotion candidates for the global catalog. Requires the multi-tenant model AND privacy guardrails: only emit aggregated, normalized name counts — never raw family data. Manual editorial step to actually promote.
#### Seed data
- **Decision (2026-05-06):** start with a hand-curated common-groceries list (~23k items). High-signal, fast to ship, no licensing entanglement, gives the typeahead something useful on day one.
- Catalog seeding runs as a one-time data migration, not on every startup.
- **Decision (2026-05-06):** recipes share the same product catalog. `RecipeIngredient` gets an optional `ProductId` — typeahead during entry, with **free-form text entry always available as a fallback** (for "salt to taste", "1 onion", or anything not in the catalog). This unblocks the "add recipe to grocery list" flow described below.
#### Future: catalog ingestion tooling
- Build tooling to ingest from public-domain / open-source catalog sources so the curated list can grow without manual data entry. In scope when the curated list starts to feel limiting.
- Candidate sources to support:
- **USDA FoodData Central** (public domain) — Branded Foods (~400k with UPCs) and Foundation/SR Legacy (generic items). Bulk CSV/JSON downloads available.
- **OpenFoodFacts** (ODbL — share-alike, attribution) — ~3M global products, barcodes, images. Watch out for the share-alike obligation on derived datasets.
- Any future open dataset that surfaces.
- Tooling concerns:
- Normalization pipeline: dedupe by name + brand + UPC, map to our `Product` schema, drop nutrition fields we don't use (or keep behind a flag).
- Provenance: record source + source-id on each imported `Product` so we can re-sync or revert.
- License compliance: keep per-source attribution metadata; surface in an /about or /credits page if any source requires it.
- Re-runnable: idempotent import scripts (no duplicates on re-run); separate from runtime startup.
- Curation workflow: imported items shouldn't drown the curated set in the typeahead — likely a quality/popularity flag controls default surfacing.
#### Promotion analytics (long-term)
- Aggregate `FamilyProduct` creations across families: count distinct families using a given normalized name, with a threshold (e.g. ≥N families with ≥M lists each) before flagging for editorial review.
- This requires the multi-tenant model above, AND privacy considerations: only emit aggregated, normalized name counts — never raw family data.
- Manual editorial step to actually promote → keeps the global catalog quality high.
#### Open questions
- Free-form entry on a list — does the user pick from a typeahead (preferred) or can they bypass the catalog entirely with a one-off text item? Recommend typeahead with "Add '<text>' as a new product" affordance.
- When a global product is overridden, is the override applied automatically (transparent) or shown as "Edited by your family" in the UI?
- Should recipes use the same product catalog, or stay free-form? Big consistency win if they share, but recipe ingredients tend to be more abstract ("flour") than shopping items ("King Arthur AP Flour 5lb").
- How does this interact with the existing `Store.Name` uniqueness — moot if we add `FamilyId` to everything.
#### Decisions on record
- **Overrides are visibly marked, not transparent.** A small "Edited" pill in any product surface tells the user this row differs from the catalog default.
- **Recipes share the catalog with shopping lists** (decided 2026-05-06). `RecipeIngredient` carries an optional `ProductId`; free-form text remains for "salt to taste" / "1 onion" cases.
- **Reset granularity is whole-override.** A single "Reset to catalog default" button removes the entire `FamilyProductOverride` row. Per-field reset was considered and rejected as over-engineering for v1.
- **Family-product delete is unconditional.** FKs from `ShoppingListItem`/`RecipeIngredient` are `SET NULL`, `ProductStoreSection` cascades — so deleting a family product is safe; linked rows keep their text and lose the link.
## Shopping list items
@@ -157,16 +133,25 @@ Replace free-form `Quantity` strings with a structured `(Quantity, UnitOfMeasure
- "A few", "to taste", "some" — these are real recipe quantities that don't fit `(decimal, unit)`. Probably modeled as a special `IsApproximate` flag with an optional `QuantityNote` rather than forcing them into the structured shape.
### Per-store sections — remaining polish
The base feature is shipped (entity, default seed on store create, list view groups by section). Remaining nice-to-haves:
- **Per-store ingredient memory:** remember "last time `Bananas` was bought at Kroger it was in Produce" and auto-assign on next add at that store. Adds an `IngredientSection` mapping table per store. Pairs naturally with the product catalog.
- **Recipes → sections:** when pulling recipe ingredients into a list, map them to the list's store's sections (only meaningful once the per-store ingredient memory or product catalog lands).
The base feature is shipped (entity, default seed on store create, list view groups by section, per-store ingredient memory via `ProductStoreSection` auto-assigning on item add and recipe-to-list copy). Remaining nice-to-haves:
- **Section drag-to-reorder** in the store edit UI — section walk order matters, but reordering today only works by editing `SortOrder` numbers manually.
## Lists
## Recipes
### Block create-list flow when no stores exist
- A shopping list requires a `Store` (see `ListSummary.store` and the `newStoreId` state in `lists/+page.svelte`), so the create-list flow shouldn't be available until at least one store exists.
- Behavior: if user tries to open the create-list UI (or hits the create-list page directly via URL) with zero stores, surface a `toast.warning()` (or modal) that says "You need to create a store first" with a CTA linking to `/stores`. Don't render the empty/broken create form.
### Structured multi-step instructions
Replace the single free-form instructions textarea with an ordered list of discrete steps, each in its own text box.
**UX:** steps are numbered automatically. Users can add a step, remove a step, and reorder steps (drag or up/down arrows). Each step is a short textarea (23 rows) rather than a single-line input, to accommodate steps with sub-detail.
**Data model:** a new `RecipeStep` entity — `(Id, RecipeId, SortOrder, Text)` — replaces the `Recipe.Instructions` string. Migration: split existing `Instructions` on double-newline (or numbered-list pattern) into individual rows; anything that doesn't parse cleanly lands as a single step.
**API:** `Recipe` GET returns `steps: [{ id, sortOrder, text }]` instead of `instructions: string`. POST/PUT accept the same shape. The old `Instructions` column can be kept nullable for backward compatibility during the migration window, then dropped.
**Backward compatibility:** the recipe detail view currently renders `instructions` as `whitespace-pre-wrap`. Switch it to a numbered `<ol>` once steps are in place; the edit page replaces the textarea with the step list.
**Open questions:**
- Keep `Instructions` as a migration fallback column, or do a single-step cutover migration?
- Should steps support rich text (bold ingredient names, timers) or stay plain text for v1?
## Stores
+7
View File
@@ -88,6 +88,13 @@ When adding a feature: create `Features/<Name>/<Name>Endpoints.cs` with a `Map<N
### Frontend — SvelteKit with runes
**UI tokens:** form controls, buttons, and select elements use named CSS component classes defined in `src/frontend/src/app.css`. Always use these instead of raw Tailwind strings:
- Inputs / textareas: `.field` (standard, `py-2 text-sm`) or `.field-lg` (prominent, `py-2.5 text-base`)
- Selects: `.select` or `.select-lg` — these add `appearance-none` + a consistent chevron so dropdowns look identical to text inputs
- Buttons: `.btn-primary`, `.btn-secondary`, `.btn-danger-link`
- Width, margin, and flex utilities are added alongside the token class (e.g. `class="field w-full"`)
- Never hardcode the border, background, padding, or focus ring on a new control — always start from a token
- `svelte.config.js` forces `runes: true` for all non-`node_modules` files. Use Svelte 5 runes (`$state`, `$derived`, `$effect`); do not use legacy reactive `$:` syntax.
- `src/lib/api.ts``api<T>(path, opts)` helper. Stores the JWT in `localStorage` under `token`, attaches `Authorization: Bearer …`, and on 401 it clears the token and `goto('/login')`.
- `src/lib/auth.svelte.ts` — runes-based auth state.
Vendored
+223
View File
@@ -0,0 +1,223 @@
// Jenkins pipeline for YesChef.
//
// Stages:
// 1. Restore + build the .NET backend solution
// 2. Run backend unit tests
// 3. Run backend integration tests against a sidecar Postgres container named `postgres`
// (Testcontainers also works because the docker socket is mounted)
// 4. Install, type-check, unit-test, and build the SvelteKit frontend
// 5. Build the backend and frontend Docker images from their existing Dockerfiles
//
// Requires the Jenkins Docker Pipeline plugin, an agent with Docker available, and a
// workspace user that can talk to the Docker daemon (the host docker socket is mounted
// into the build containers so Testcontainers can spawn its own DB instances).
pipeline {
agent any
options {
timestamps()
timeout(time: 45, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '20'))
disableConcurrentBuilds()
}
environment {
DOTNET_SDK_IMAGE = 'mcr.microsoft.com/dotnet/sdk:10.0'
NODE_IMAGE = 'node:22-slim'
POSTGRES_IMAGE = 'postgres:17'
POSTGRES_DB = 'yeschef'
POSTGRES_USER = 'yeschef'
POSTGRES_PASSWORD = 'yeschef'
// Gitea container registry target. The registry lives on the same host as
// Gitea itself; the owner is the Gitea user/org that owns the images.
GITEA_REGISTRY = 'git.therogersfamily.tech'
GITEA_OWNER = 'josh'
// Jenkins credential ID for the Gitea push. Type: giteaPersonalAccessToken
// (the gitea-personal-access-token plugin). The PAT must have at least
// `write:package` scope. The token is bound as a Secret String at push time
// and fed to `docker login --password-stdin` using GITEA_OWNER as the
// username.
GITEA_CREDENTIALS = 'gitea-ci'
BACKEND_IMAGE = "${GITEA_REGISTRY}/${GITEA_OWNER}/yeschef-api"
FRONTEND_IMAGE = "${GITEA_REGISTRY}/${GITEA_OWNER}/yeschef-web"
IMAGE_TAG = "${env.BUILD_NUMBER}"
// Keep NuGet / npm caches inside the workspace so they survive across
// docker.inside containers without needing a shared host mount.
DOTNET_CLI_TELEMETRY_OPTOUT = '1'
NUGET_PACKAGES = "${env.WORKSPACE}/.nuget"
npm_config_cache = "${env.WORKSPACE}/.npm"
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Backend: restore & build') {
agent {
docker {
image "${DOTNET_SDK_IMAGE}"
reuseNode true
}
}
steps {
sh 'dotnet --info'
sh 'dotnet restore src/backend/YesChef.slnx'
sh 'dotnet build src/backend/YesChef.slnx -c Release --no-restore'
}
}
stage('Backend: unit tests') {
agent {
docker {
image "${DOTNET_SDK_IMAGE}"
reuseNode true
}
}
steps {
// global.json lives in src/backend and switches dotnet test to MTP mode,
// which requires --project (positional project paths are rejected).
dir('src/backend') {
sh '''
dotnet test \
--project YesChef.Api.UnitTests/YesChef.Api.UnitTests.csproj \
-c Release --no-build \
--report-trx --results-directory ../../TestResults/backend-unit
'''
}
}
post {
always {
junit allowEmptyResults: true, testResults: 'TestResults/backend-unit/**/*.trx'
}
}
}
stage('Backend: integration tests') {
steps {
script {
// Sidecar Postgres reachable from the build container at host `postgres`.
// Testcontainers can still spin up additional DBs because the host docker
// socket is mounted into the SDK container below.
docker.image("${POSTGRES_IMAGE}").withRun(
"-e POSTGRES_DB=${POSTGRES_DB}" +
" -e POSTGRES_USER=${POSTGRES_USER}" +
" -e POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
) { pg ->
// Wait for the DB to accept connections.
docker.image("${POSTGRES_IMAGE}").inside("--link ${pg.id}:postgres") {
sh '''
for i in $(seq 1 30); do
if pg_isready -h postgres -U yeschef >/dev/null 2>&1; then
echo "postgres is ready"
exit 0
fi
sleep 2
done
echo "postgres did not become ready in time" >&2
exit 1
'''
}
docker.image("${DOTNET_SDK_IMAGE}").inside(
"--link ${pg.id}:postgres" +
" -v /var/run/docker.sock:/var/run/docker.sock" +
" -e ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
) {
dir('src/backend') {
sh '''
dotnet test \
--project YesChef.Api.IntegrationTests/YesChef.Api.IntegrationTests.csproj \
-c Release \
--report-trx --results-directory ../../TestResults/backend-integration
'''
}
}
}
}
}
post {
always {
junit allowEmptyResults: true, testResults: 'TestResults/backend-integration/**/*.trx'
}
}
}
stage('Frontend') {
agent {
docker {
image "${NODE_IMAGE}"
reuseNode true
}
}
stages {
stage('Install') {
steps {
dir('src/frontend') { sh 'npm ci' }
}
}
stage('Type check') {
steps {
dir('src/frontend') { sh 'npm run check' }
}
}
stage('Unit tests') {
steps {
dir('src/frontend') { sh 'npm run test:unit' }
}
}
stage('Build') {
steps {
dir('src/frontend') { sh 'npm run build' }
}
}
}
}
stage('Docker images') {
steps {
script {
def backendImg = docker.build("${BACKEND_IMAGE}:${IMAGE_TAG}", 'src/backend/YesChef.Api')
def frontendImg = docker.build("${FRONTEND_IMAGE}:${IMAGE_TAG}", 'src/frontend')
backendImg.tag('latest')
frontendImg.tag('latest')
// Gitea PAT credential (giteaPersonalAccessToken) — exposes the
// token as a Secret String, so bind with `string` and feed it to
// docker login on stdin. Username for the package registry is the
// Gitea owner.
withCredentials([string(credentialsId: "${GITEA_CREDENTIALS}", variable: 'GITEA_TOKEN')]) {
sh """
set +x
echo "\$GITEA_TOKEN" | docker login ${GITEA_REGISTRY} -u ${GITEA_OWNER} --password-stdin
"""
try {
sh "docker push ${BACKEND_IMAGE}:${IMAGE_TAG}"
sh "docker push ${BACKEND_IMAGE}:latest"
sh "docker push ${FRONTEND_IMAGE}:${IMAGE_TAG}"
sh "docker push ${FRONTEND_IMAGE}:latest"
} finally {
sh "docker logout ${GITEA_REGISTRY} || true"
}
}
}
}
}
}
post {
always {
archiveArtifacts artifacts: 'TestResults/**/*.trx', allowEmptyArchive: true, fingerprint: false
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
@@ -24,7 +24,14 @@ public sealed class RecipeBuilder
public RecipeBuilder ForFamily(int familyId) { _familyId = familyId; return this; }
public RecipeBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
public RecipeBuilder WithIngredient(string name, int sortOrder = 0, decimal? quantity = null, int? unitOfMeasureId = null, int? familyUnitOfMeasureId = null)
public RecipeBuilder WithIngredient(
string name,
int sortOrder = 0,
decimal? quantity = null,
int? unitOfMeasureId = null,
int? familyUnitOfMeasureId = null,
int? productId = null,
int? familyProductId = null)
{
_ingredients.Add(new RecipeIngredient
{
@@ -33,6 +40,8 @@ public sealed class RecipeBuilder
Quantity = quantity,
UnitOfMeasureId = unitOfMeasureId,
FamilyUnitOfMeasureId = familyUnitOfMeasureId,
ProductId = productId,
FamilyProductId = familyProductId,
});
return this;
}
@@ -343,9 +343,9 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest
await db.SaveChangesAsync();
});
var results = await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q=");
var results = (await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q="))!;
var milk = results!.Single(r => r.Name == "Milk");
var milk = results.Single(r => r.Name == "Milk");
await Assert.That(milk.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume);
var eggs = results.Single(r => r.Name == "Eggs");
@@ -362,4 +362,96 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest
var response = await AnonymousClient.GetAsync("/api/products");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}
[Test]
public async Task Create_family_product_persists_default_section()
{
var response = await Client.PostAsJsonAsync("/api/products",
new ProductEndpoints.CreateProductRequest("Sourdough", null, null, UnitCategoryFlags.None, " Bakery "));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var dto = await response.Content.ReadFromJsonAsync<ProductEndpoints.ProductDto>();
await Assert.That(dto!.DefaultSection).IsEqualTo("Bakery");
var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync());
await Assert.That(stored.DefaultSection).IsEqualTo("Bakery");
}
[Test]
public async Task Update_family_product_clears_default_section_when_blank()
{
var familyId = await GetFamilyIdAsync();
var product = await UseDbAsync(async db =>
{
var p = new FamilyProduct { FamilyId = familyId, Name = "Flour", DefaultSection = "Pantry" };
db.FamilyProducts.Add(p);
await db.SaveChangesAsync();
return p;
});
var response = await Client.PutAsJsonAsync($"/api/products/family/{product.Id}",
new ProductEndpoints.UpdateProductRequest(null, null, null, null, " "));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var refreshed = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == product.Id));
await Assert.That(refreshed.DefaultSection).IsNull();
}
[Test]
public async Task Update_global_product_writes_default_section_to_override()
{
var familyId = await GetFamilyIdAsync();
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bananas", DefaultSection = "Produce" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
var response = await Client.PutAsJsonAsync($"/api/products/global/{product.Id}",
new ProductEndpoints.UpdateProductRequest(null, null, null, null, "Frozen"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var dto = await response.Content.ReadFromJsonAsync<ProductEndpoints.ProductDto>();
await Assert.That(dto!.DefaultSection).IsEqualTo("Frozen");
// Global row untouched.
var global = await UseDbAsync(db => db.Products.SingleAsync(p => p.Id == product.Id));
await Assert.That(global.DefaultSection).IsEqualTo("Produce");
var ovr = await UseDbAsync(db => db.FamilyProductOverrides
.SingleAsync(o => o.FamilyId == familyId && o.ProductId == product.Id));
await Assert.That(ovr.DefaultSection).IsEqualTo("Frozen");
}
[Test]
public async Task Search_projects_effective_default_section()
{
var familyId = await GetFamilyIdAsync();
await UseDbAsync(async db =>
{
db.Products.Add(new Product { Name = "Apples", DefaultSection = "Produce" });
db.Products.Add(new Product { Name = "Berries", DefaultSection = "Produce" });
await db.SaveChangesAsync();
var berries = db.Products.Single(p => p.Name == "Berries");
db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride
{
FamilyId = familyId,
ProductId = berries.Id,
DefaultSection = "Frozen",
});
db.FamilyProducts.Add(new FamilyProduct
{
FamilyId = familyId,
Name = "House Bread",
DefaultSection = "Bakery",
});
await db.SaveChangesAsync();
});
var results = (await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q="))!;
await Assert.That(results.Single(r => r.Name == "Apples").DefaultSection).IsEqualTo("Produce");
await Assert.That(results.Single(r => r.Name == "Berries").DefaultSection).IsEqualTo("Frozen");
await Assert.That(results.Single(r => r.Name == "House Bread").DefaultSection).IsEqualTo("Bakery");
}
}
@@ -111,7 +111,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(body.GetProperty("title").GetString()).IsEqualTo("Soup");
await Assert.That(body.GetProperty("createdBy").GetString()).IsEqualTo(User.Name);
var ingredientNames = body.GetProperty("ingredients").EnumerateArray()
.Select(i => i.GetProperty("name").GetString()).ToArray();
.Select(i => i.GetProperty("name").GetString()!).ToArray();
await Assert.That(ingredientNames).IsEquivalentTo(new[] { "broth cube", "water" });
}
@@ -131,7 +131,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
var hits = await Client.GetFromJsonAsync<List<JsonElement>>("/api/recipes?q=Pa");
await Assert.That(hits!.Select(h => h.GetProperty("title").GetString()))
await Assert.That(hits!.Select(h => h.GetProperty("title").GetString()!))
.IsEquivalentTo(new[] { "Pancakes", "Pad Thai" });
}
@@ -336,6 +336,100 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(stored.AllowedUnitCategories & UnitCategoryFlags.Volume).IsEqualTo(UnitCategoryFlags.Volume);
}
[Test]
public async Task Get_returns_allowed_unit_categories_from_global_product_when_no_override()
{
var familyId = await UseDbAsync(db =>
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
var productId = await UseDbAsync(async db =>
{
var p = new Product { Name = "Flour-base", AllowedUnitCategories = UnitCategoryFlags.Weight | UnitCategoryFlags.Volume };
db.Products.Add(p);
await db.SaveChangesAsync();
return p.Id;
});
var recipe = await CreateRecipeAsync(b => b
.Titled("Bread")
.WithIngredient("flour", sortOrder: 1, quantity: 2m, productId: productId));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
var ingredient = body.GetProperty("ingredients").EnumerateArray().Single();
var cats = (UnitCategoryFlags)ingredient.GetProperty("allowedUnitCategories").GetInt32();
await Assert.That(cats).IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Volume);
}
[Test]
public async Task Get_returns_override_allowed_unit_categories_when_family_overrides_product()
{
var familyId = await UseDbAsync(db =>
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
var productId = await UseDbAsync(async db =>
{
var p = new Product { Name = "Flour-ovr", AllowedUnitCategories = UnitCategoryFlags.Weight };
db.Products.Add(p);
await db.SaveChangesAsync();
db.Set<FamilyProductOverride>().Add(new FamilyProductOverride
{
FamilyId = familyId,
ProductId = p.Id,
AllowedUnitCategories = UnitCategoryFlags.Volume | UnitCategoryFlags.Count,
});
await db.SaveChangesAsync();
return p.Id;
});
var recipe = await CreateRecipeAsync(b => b
.Titled("Pancakes-ovr")
.WithIngredient("flour", sortOrder: 1, quantity: 1m, productId: productId));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
var ingredient = body.GetProperty("ingredients").EnumerateArray().Single();
var cats = (UnitCategoryFlags)ingredient.GetProperty("allowedUnitCategories").GetInt32();
await Assert.That(cats).IsEqualTo(UnitCategoryFlags.Volume | UnitCategoryFlags.Count);
}
[Test]
public async Task Get_returns_allowed_unit_categories_from_family_product()
{
var familyId = await UseDbAsync(db =>
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
var familyProductId = await UseDbAsync(async db =>
{
var fp = new FamilyProduct
{
FamilyId = familyId,
Name = "House Flour-fp",
AllowedUnitCategories = UnitCategoryFlags.Weight | UnitCategoryFlags.Packaging,
};
db.FamilyProducts.Add(fp);
await db.SaveChangesAsync();
return fp.Id;
});
var recipe = await CreateRecipeAsync(b => b
.Titled("Loaf")
.WithIngredient("house flour", sortOrder: 1, quantity: 1m, familyProductId: familyProductId));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
var ingredient = body.GetProperty("ingredients").EnumerateArray().Single();
var cats = (UnitCategoryFlags)ingredient.GetProperty("allowedUnitCategories").GetInt32();
await Assert.That(cats).IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Packaging);
}
[Test]
public async Task Get_returns_zero_allowed_unit_categories_for_unlinked_ingredient()
{
var recipe = await CreateRecipeAsync(b => b
.Titled("Mystery")
.WithIngredient("salt", sortOrder: 1));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
var ingredient = body.GetProperty("ingredients").EnumerateArray().Single();
await Assert.That(ingredient.GetProperty("allowedUnitCategories").GetInt32()).IsEqualTo(0);
}
[Test]
public async Task Create_accumulates_distinct_categories_across_ingredients()
{
@@ -77,7 +77,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(body.GetProperty("name").GetString()).IsEqualTo("groceries");
var items = body.GetProperty("items").EnumerateArray()
.Select(i => i.GetProperty("name").GetString()).ToArray();
.Select(i => i.GetProperty("name").GetString()!).ToArray();
await Assert.That(items).IsEquivalentTo(new[] { "bread", "milk" });
}
@@ -207,10 +207,10 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
}
[Test]
public async Task Add_item_records_section_memory_and_auto_assigns_on_next_add()
public async Task Add_item_resolves_section_from_global_product_default()
{
var list = await CreateListAsync();
var section = await UseDbAsync(async db =>
var produce = await UseDbAsync(async db =>
{
var s = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
db.StoreSections.Add(s);
@@ -219,99 +219,312 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
});
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bananas-Memory" };
var p = new Product { Name = "Bananas-Default", DefaultSection = "Produce" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
// First add: explicit section + product → memory recorded.
var first = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Bananas", SectionId: section.Id, ProductId: product.Id));
await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.Created);
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Bananas", ProductId: product.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
// Second add: same product, no section → auto-assigned from memory.
var second = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("More Bananas", ProductId: product.Id));
await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.Created);
var items = await UseDbAsync(db => db.ShoppingListItems
.Where(i => i.ShoppingListId == list.Id).OrderBy(i => i.Id).ToListAsync());
await Assert.That(items.Count).IsEqualTo(2);
await Assert.That(items[1].SectionId).IsEqualTo(section.Id);
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(produce.Id);
}
[Test]
public async Task Patch_item_section_updates_memory_for_next_add()
public async Task Add_item_resolves_section_with_case_insensitive_name_match()
{
var list = await CreateListAsync();
var (originalSection, correctedSection) = await UseDbAsync(async db =>
var produce = await UseDbAsync(async db =>
{
var s1 = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Pantry", SortOrder = 1 };
var s2 = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Bakery", SortOrder = 2 };
db.StoreSections.AddRange(s1, s2);
var s = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
db.StoreSections.Add(s);
await db.SaveChangesAsync();
return (s1, s2);
return s;
});
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bread-Memory" };
var p = new Product { Name = "Bananas-Case", DefaultSection = " produce " };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
// Add with section A; that records (Product → A) memory.
var addResponse = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Bread", SectionId: originalSection.Id, ProductId: product.Id));
var addBody = await addResponse.Content.ReadFromJsonAsync<JsonElement>();
var firstItemId = addBody.GetProperty("id").GetInt32();
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Bananas", ProductId: product.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
// User corrects to section B → memory should update.
var patchResponse = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{firstItemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(correctedSection.Id));
await Assert.That(patchResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
// Next add (no section) should now pull section B.
var second = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("More Bread", ProductId: product.Id));
var secondBody = await second.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(secondBody.GetProperty("sectionId").GetInt32()).IsEqualTo(correctedSection.Id);
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(produce.Id);
}
[Test]
public async Task Section_memory_is_scoped_per_store()
public async Task Add_item_resolves_to_null_when_default_section_does_not_exist_in_store()
{
var listA = await CreateListAsync();
var otherStore = await Data.CreateStoreAsync(b => b.Named("Other Store"));
var listB = await Data.CreateListAsync(b => b.ForStore(otherStore).CreatedBy(User));
var (sectionA, sectionB) = await UseDbAsync(async db =>
{
var a = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce A", SortOrder = 1 };
var b = new StoreSection { FamilyId = otherStore.FamilyId, StoreId = otherStore.Id, Name = "Produce B", SortOrder = 1 };
db.StoreSections.AddRange(a, b);
await db.SaveChangesAsync();
return (a, b);
});
var list = await CreateListAsync();
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Apples-Memory" };
var p = new Product { Name = "Imports-Only", DefaultSection = "Specialty" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
// Memorize at store A only.
await Client.PostAsJsonAsync($"/api/lists/{listA.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Apples", SectionId: sectionA.Id, ProductId: product.Id));
// Add at store B with no section → no memory yet for store B.
var response = await Client.PostAsJsonAsync($"/api/lists/{listB.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Apples", ProductId: product.Id));
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Saffron", ProductId: product.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null);
}
[Test]
public async Task Add_item_prefers_family_override_default_over_global_product_default()
{
var list = await CreateListAsync();
var familyId = Store.FamilyId;
var (produce, frozen) = await UseDbAsync(async db =>
{
var p = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
var f = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Frozen", SortOrder = 2 };
db.StoreSections.AddRange(p, f);
await db.SaveChangesAsync();
return (p, f);
});
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Berries", DefaultSection = "Produce" };
db.Products.Add(p);
await db.SaveChangesAsync();
db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride
{
FamilyId = familyId,
ProductId = p.Id,
DefaultSection = "Frozen",
});
await db.SaveChangesAsync();
return p;
});
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Berries", ProductId: product.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(frozen.Id);
}
[Test]
public async Task Add_item_prefers_per_store_memory_over_family_default()
{
var list = await CreateListAsync();
var familyId = Store.FamilyId;
var (produce, pantry) = await UseDbAsync(async db =>
{
var p = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
var pn = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Pantry", SortOrder = 2 };
db.StoreSections.AddRange(p, pn);
await db.SaveChangesAsync();
return (p, pn);
});
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Garlic", DefaultSection = "Produce" };
db.Products.Add(p);
await db.SaveChangesAsync();
db.ProductStoreSections.Add(new ProductStoreSection
{
FamilyId = familyId,
StoreId = Store.Id,
ProductId = p.Id,
StoreSectionId = pantry.Id,
});
await db.SaveChangesAsync();
return p;
});
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Garlic", ProductId: product.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(pantry.Id);
}
[Test]
public async Task Patch_item_section_silently_creates_family_override_when_no_default_exists()
{
var list = await CreateListAsync(b => b.WithItem("Bananas"));
var familyId = Store.FamilyId;
var produce = await UseDbAsync(async db =>
{
var s = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
db.StoreSections.Add(s);
await db.SaveChangesAsync();
return s;
});
var (itemId, productId) = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bananas-Silent" };
db.Products.Add(p);
await db.SaveChangesAsync();
var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id);
item.ProductId = p.Id;
await db.SaveChangesAsync();
return (item.Id, p.Id);
});
var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(produce.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("promptSaveDefault").GetBoolean()).IsFalse();
var ovr = await UseDbAsync(db => db.FamilyProductOverrides
.SingleAsync(o => o.FamilyId == familyId && o.ProductId == productId));
await Assert.That(ovr.DefaultSection).IsEqualTo("Produce");
}
[Test]
public async Task Patch_item_section_returns_prompt_when_existing_family_default_differs()
{
var list = await CreateListAsync(b => b.WithItem("Bananas"));
var familyId = Store.FamilyId;
var (produce, bakery) = await UseDbAsync(async db =>
{
var p = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
var b = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Bakery", SortOrder = 2 };
db.StoreSections.AddRange(p, b);
await db.SaveChangesAsync();
return (p, b);
});
var (itemId, productId) = await UseDbAsync(async db =>
{
var p = new Product { Name = "Banana-Bread" };
db.Products.Add(p);
await db.SaveChangesAsync();
db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride
{
FamilyId = familyId,
ProductId = p.Id,
DefaultSection = "Bakery",
});
var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id);
item.ProductId = p.Id;
await db.SaveChangesAsync();
return (item.Id, p.Id);
});
// First call without saveAsDefault — should prompt.
var promptResponse = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(produce.Id));
var promptBody = await promptResponse.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(promptBody.GetProperty("promptSaveDefault").GetBoolean()).IsTrue();
await Assert.That(promptBody.GetProperty("currentDefaultSection").GetString()).IsEqualTo("Bakery");
await Assert.That(promptBody.GetProperty("newSectionName").GetString()).IsEqualTo("Produce");
// Override still says Bakery — item section changed but default untouched.
var ovrBefore = await UseDbAsync(db => db.FamilyProductOverrides
.SingleAsync(o => o.FamilyId == familyId && o.ProductId == productId));
await Assert.That(ovrBefore.DefaultSection).IsEqualTo("Bakery");
var itemBefore = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId));
await Assert.That(itemBefore.SectionId).IsEqualTo(produce.Id);
// Second call with saveAsDefault=true — override updates.
var acceptResponse = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(produce.Id, SaveAsDefault: true));
await Assert.That(acceptResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
var ovrAfter = await UseDbAsync(db => db.FamilyProductOverrides
.SingleAsync(o => o.FamilyId == familyId && o.ProductId == productId));
await Assert.That(ovrAfter.DefaultSection).IsEqualTo("Produce");
}
[Test]
public async Task Patch_item_section_no_prompt_when_default_already_matches()
{
var list = await CreateListAsync(b => b.WithItem("Bananas"));
var familyId = Store.FamilyId;
var produce = await UseDbAsync(async db =>
{
var s = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
db.StoreSections.Add(s);
await db.SaveChangesAsync();
return s;
});
var itemId = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bananas-Match" };
db.Products.Add(p);
await db.SaveChangesAsync();
db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride
{
FamilyId = familyId,
ProductId = p.Id,
DefaultSection = "Produce",
});
var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id);
item.ProductId = p.Id;
await db.SaveChangesAsync();
return item.Id;
});
var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(produce.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("promptSaveDefault").GetBoolean()).IsFalse();
}
[Test]
public async Task Patch_item_section_sets_family_product_default_for_family_products()
{
var list = await CreateListAsync(b => b.WithItem("House Bread"));
var familyId = Store.FamilyId;
var bakery = await UseDbAsync(async db =>
{
var s = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Bakery", SortOrder = 1 };
db.StoreSections.Add(s);
await db.SaveChangesAsync();
return s;
});
var (itemId, productId) = await UseDbAsync(async db =>
{
var p = new FamilyProduct { FamilyId = familyId, Name = "House Bread" };
db.FamilyProducts.Add(p);
await db.SaveChangesAsync();
var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id);
item.FamilyProductId = p.Id;
await db.SaveChangesAsync();
return (item.Id, p.Id);
});
var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(bakery.Id));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var fp = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == productId));
await Assert.That(fp.DefaultSection).IsEqualTo("Bakery");
}
[Test]
public async Task Patch_item_section_without_product_link_does_not_touch_defaults()
{
var list = await CreateListAsync(b => b.WithItem("free-form"));
var familyId = Store.FamilyId;
var produce = await UseDbAsync(async db =>
{
var s = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
db.StoreSections.Add(s);
await db.SaveChangesAsync();
return s;
});
var itemId = await UseDbAsync(db => db.ShoppingListItems
.Where(i => i.ShoppingListId == list.Id).Select(i => i.Id).SingleAsync());
var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(produce.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("promptSaveDefault").GetBoolean()).IsFalse();
await Assert.That(await UseDbAsync(db => db.FamilyProductOverrides.AnyAsync())).IsFalse();
}
[Test]
public async Task Add_item_rejects_other_familys_family_product()
{
@@ -377,7 +590,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/lists/{list.Id}");
var items = body.GetProperty("items").EnumerateArray()
.Select(i => i.GetProperty("name").GetString()).ToArray();
.Select(i => i.GetProperty("name").GetString()!).ToArray();
await Assert.That(items).IsEquivalentTo(new[] { "kept" });
}
@@ -1,4 +1,3 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Testcontainers.PostgreSql;
using TUnit.Core.Interfaces;
@@ -16,8 +15,7 @@ public sealed class PostgresFixture : IAsyncInitializer, IAsyncDisposable
{
private const string TemplateDbName = "yeschef_template";
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:17")
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("postgres:17")
.WithDatabase("postgres")
.WithUsername("postgres")
.WithPassword("postgres")
@@ -141,6 +141,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(p => p.Brand).HasMaxLength(200);
e.Property(p => p.Notes).HasMaxLength(1000);
e.Property(p => p.AllowedUnitCategories).HasConversion<int>();
e.Property(p => p.DefaultSection).HasMaxLength(100);
e.HasIndex(p => p.Name).IsUnique();
});
@@ -150,6 +151,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(p => p.Brand).HasMaxLength(200);
e.Property(p => p.Notes).HasMaxLength(1000);
e.Property(p => p.AllowedUnitCategories).HasConversion<int>();
e.Property(p => p.DefaultSection).HasMaxLength(100);
e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(p => new { p.FamilyId, p.Name }).IsUnique();
});
@@ -161,6 +163,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(o => o.Brand).HasMaxLength(200);
e.Property(o => o.Notes).HasMaxLength(1000);
e.Property(o => o.AllowedUnitCategories).HasConversion<int?>();
e.Property(o => o.DefaultSection).HasMaxLength(100);
e.HasOne(o => o.Family).WithMany().HasForeignKey(o => o.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(o => o.Product).WithMany().HasForeignKey(o => o.ProductId).OnDelete(DeleteBehavior.Cascade);
});
@@ -13,5 +13,6 @@ public class FamilyProduct
public string? Brand { get; set; }
public string? Notes { get; set; }
public UnitCategoryFlags AllowedUnitCategories { get; set; }
public string? DefaultSection { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -18,5 +18,8 @@ public class FamilyProductOverride
// Nullable so "inherit global" (null) is distinguishable from
// "explicitly None / any unit" (UnitCategoryFlags.None).
public UnitCategoryFlags? AllowedUnitCategories { get; set; }
// Null = inherit Product.DefaultSection. Non-null = family override of
// the recommended section name.
public string? DefaultSection { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -14,5 +14,10 @@ public class Product
// None = "any unit". Non-zero narrows the unit-dropdown suggestions to the
// flagged categories. Families can replace this with FamilyProductOverride.
public UnitCategoryFlags AllowedUnitCategories { get; set; }
// Recommended section name (e.g. "Produce"). Resolved at runtime against
// the active store's StoreSection rows by case-insensitive name match —
// sections are family/store-scoped so there's no FK relationship. Family
// overrides take precedence; per-store memory takes precedence over both.
public string? DefaultSection { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -1,5 +1,8 @@
using System.Text.Json.Serialization;
namespace YesChef.Api.Entities;
[JsonConverter(typeof(JsonStringEnumConverter<UnitCategory>))]
public enum UnitCategory
{
Count = 0,
@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace YesChef.Api.Entities;
/// <summary>
@@ -7,6 +9,7 @@ namespace YesChef.Api.Entities;
/// 32-bit integer so families can OR in additional categories over time.
/// </summary>
[System.Flags]
[JsonConverter(typeof(JsonStringEnumConverter<UnitCategoryFlags>))]
public enum UnitCategoryFlags
{
None = 0,
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using YesChef.Api.Auth;
using YesChef.Api.Data;
@@ -10,6 +11,7 @@ public static class ProductEndpoints
/// <summary>Discriminator on product DTOs so the client can route subsequent
/// PUT/POST calls to the right code path. The catalog UI itself doesn't
/// need to surface this — both kinds render the same way.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<ProductKind>))]
public enum ProductKind { Global, Family }
public record ProductDto(
@@ -19,10 +21,11 @@ public static class ProductEndpoints
string? Brand,
string? Notes,
bool IsOverridden,
UnitCategoryFlags AllowedUnitCategories);
UnitCategoryFlags AllowedUnitCategories,
string? DefaultSection);
public record CreateProductRequest(string Name, string? Brand, string? Notes, UnitCategoryFlags AllowedUnitCategories = UnitCategoryFlags.None);
public record UpdateProductRequest(string? Name, string? Brand, string? Notes, UnitCategoryFlags? AllowedUnitCategories = null);
public record CreateProductRequest(string Name, string? Brand, string? Notes, UnitCategoryFlags AllowedUnitCategories = UnitCategoryFlags.None, string? DefaultSection = null);
public record UpdateProductRequest(string? Name, string? Brand, string? Notes, UnitCategoryFlags? AllowedUnitCategories = null, string? DefaultSection = null);
private const int SearchResultLimit = 50;
@@ -56,10 +59,12 @@ public static class ProductEndpoints
GlobalBrand = p.Brand,
GlobalNotes = p.Notes,
GlobalAllowedUnitCategories = p.AllowedUnitCategories,
GlobalDefaultSection = p.DefaultSection,
OverrideName = o != null ? o.Name : null,
OverrideBrand = o != null ? o.Brand : null,
OverrideNotes = o != null ? o.Notes : null,
OverrideAllowedUnitCategories = o != null ? o.AllowedUnitCategories : null,
OverrideDefaultSection = o != null ? o.DefaultSection : null,
HasOverride = o != null,
})
.Take(SearchResultLimit)
@@ -86,11 +91,12 @@ public static class ProductEndpoints
r.OverrideBrand ?? r.GlobalBrand,
r.OverrideNotes ?? r.GlobalNotes,
r.HasOverride,
r.OverrideAllowedUnitCategories ?? r.GlobalAllowedUnitCategories);
r.OverrideAllowedUnitCategories ?? r.GlobalAllowedUnitCategories,
r.OverrideDefaultSection ?? r.GlobalDefaultSection);
}).Where(d => d is not null).Cast<ProductDto>();
var familyDtos = familyRows.Select(p =>
new ProductDto(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories));
new ProductDto(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories, p.DefaultSection));
var results = globalDtos.Concat(familyDtos)
.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
@@ -119,6 +125,7 @@ public static class ProductEndpoints
Brand = request.Brand,
Notes = request.Notes,
AllowedUnitCategories = request.AllowedUnitCategories,
DefaultSection = NormalizeSection(request.DefaultSection),
};
db.FamilyProducts.Add(product);
await db.SaveChangesAsync();
@@ -148,11 +155,55 @@ public static class ProductEndpoints
product.Brand = request.Brand;
product.Notes = request.Notes;
if (request.AllowedUnitCategories is { } cats) product.AllowedUnitCategories = cats;
product.DefaultSection = NormalizeSection(request.DefaultSection);
await db.SaveChangesAsync();
return Results.Ok(ToDto(product));
});
// Remove a family-owned product. ShoppingListItem / RecipeIngredient
// FKs are configured ON DELETE SET NULL, and ProductStoreSection rows
// cascade — so deletion is safe; previously-linked rows just lose the
// product link and continue to render their free-form Name.
group.MapDelete("/family/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var product = await db.FamilyProducts.FirstOrDefaultAsync(p => p.Id == id && p.FamilyId == familyId);
if (product is null) return Results.NotFound();
db.FamilyProducts.Remove(product);
await db.SaveChangesAsync();
return Results.NoContent();
});
// Reset a family's view of a global product to the catalog default by
// deleting any FamilyProductOverride row. Returns the now-unmodified
// effective DTO so the client can swap state without a refetch. No-op
// (still 200) when there's nothing to reset — keeps the UI idempotent.
group.MapDelete("/global/{id:int}/override", async (int id, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var product = await db.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
if (product is null) return Results.NotFound();
var ovr = await db.FamilyProductOverrides
.FirstOrDefaultAsync(o => o.FamilyId == familyId && o.ProductId == id);
if (ovr is not null)
{
db.FamilyProductOverrides.Remove(ovr);
await db.SaveChangesAsync();
}
return Results.Ok(new ProductDto(
product.Id,
ProductKind.Global,
product.Name,
product.Brand,
product.Notes,
IsOverridden: false,
product.AllowedUnitCategories,
product.DefaultSection));
});
// Update a global product for this family by upserting an override.
// Each non-null field becomes the override; null fields fall back to
// the global value. To "reset" a field to the global, send null —
@@ -184,6 +235,7 @@ public static class ProductEndpoints
// itself be null, i.e. "inherit global"). Pass an explicit value to
// either narrow categories or restore "any" (UnitCategoryFlags.None).
if (request.AllowedUnitCategories is { } cats) ovr.AllowedUnitCategories = cats;
ovr.DefaultSection = NormalizeSection(request.DefaultSection);
ovr.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
@@ -194,12 +246,26 @@ public static class ProductEndpoints
ovr.Brand ?? product.Brand,
ovr.Notes ?? product.Notes,
IsOverridden: true,
ovr.AllowedUnitCategories ?? product.AllowedUnitCategories));
ovr.AllowedUnitCategories ?? product.AllowedUnitCategories,
ovr.DefaultSection ?? product.DefaultSection));
});
return group;
}
private static ProductDto ToDto(FamilyProduct p) =>
new(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories);
new(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories, p.DefaultSection);
/// <summary>
/// Strip whitespace; turn empty into null so "no default" round-trips
/// consistently regardless of whether the client sends `""` or omits the
/// field. Storing whitespace would also break the case-insensitive match
/// against StoreSection names.
/// </summary>
private static string? NormalizeSection(string? value)
{
if (value is null) return null;
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}
@@ -22,7 +22,19 @@ public static class ShoppingListEndpoints
int? FamilyUnitOfMeasureId = null,
bool IsApproximate = false,
string? QuantityNote = null);
public record SetItemSectionRequest(int? SectionId);
public record SetItemSectionRequest(int? SectionId, bool SaveAsDefault = false);
/// <summary>
/// Response shape for the section PATCH. When the item is linked to a
/// product and the family-level default exists with a different name,
/// <see cref="PromptSaveDefault"/> is true and the client should ask the
/// user whether to update the default. Re-call with SaveAsDefault=true to
/// accept.
/// </summary>
public record SetItemSectionResponse(
bool PromptSaveDefault,
string? CurrentDefaultSection,
string? NewSectionName);
private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}";
@@ -185,6 +197,20 @@ public static class ShoppingListEndpoints
return Results.NoContent();
});
group.MapDelete("/{id:int}/permanent", async (int id, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
{
var familyId = http.User.GetFamilyId();
var list = await db.ShoppingLists.FirstOrDefaultAsync(l => l.Id == id && l.FamilyId == familyId);
if (list is null) return Results.NotFound();
db.ShoppingLists.Remove(list);
await db.SaveChangesAsync();
await hub.Clients.Group(OverviewGroup(familyId)).SendAsync("ListDeleted", new { list.Id });
return Results.NoContent();
});
group.MapPost("/{listId:int}/items", async (int listId, AddItemRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
{
var familyId = http.User.GetFamilyId();
@@ -202,11 +228,10 @@ public static class ShoppingListEndpoints
if (await ValidateUnitLink(db, familyId, request.UnitOfMeasureId, request.FamilyUnitOfMeasureId) is { } unitError)
return unitError;
// Auto-assign a section from memory when caller didn't pick one
// but supplied a product link — "we put bananas in Produce last
// time we shopped here, do it again."
// No explicit section → resolve through the product's defaults
// (per-store memory → family default → global default).
var resolvedSectionId = request.SectionId
?? await LookupRememberedSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId);
?? await ResolveSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId);
var item = new ShoppingListItem
{
@@ -226,12 +251,6 @@ public static class ShoppingListEndpoints
db.ShoppingListItems.Add(item);
list.UpdatedAt = DateTime.UtcNow;
// If the caller explicitly chose a section, record/update memory
// for next time. Auto-assigned sections don't need a write back —
// the existing memory row already says exactly this.
if (request.SectionId.HasValue)
await RememberSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId, request.SectionId);
await AllowedUnitCategoryLearner.LearnAsync(db, familyId, new[]
{
new AllowedUnitCategoryLearner.Pair(request.ProductId, request.FamilyProductId, item.UnitOfMeasureId, item.FamilyUnitOfMeasureId),
@@ -252,18 +271,47 @@ public static class ShoppingListEndpoints
.FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId && i.RemovedAt == null);
if (item is null) return Results.NotFound();
if (request.SectionId is int sectionId &&
!await db.StoreSections.AnyAsync(s => s.Id == sectionId && s.StoreId == item.ShoppingList.StoreId && s.FamilyId == familyId))
return Results.BadRequest(new { error = "Unknown section." });
string? newSectionName = null;
if (request.SectionId is int sectionId)
{
var section = await db.StoreSections
.Where(s => s.Id == sectionId && s.StoreId == item.ShoppingList.StoreId && s.FamilyId == familyId)
.Select(s => new { s.Name })
.FirstOrDefaultAsync();
if (section is null) return Results.BadRequest(new { error = "Unknown section." });
newSectionName = section.Name;
}
item.SectionId = request.SectionId;
// Manual section change is the user correcting the memory: persist
// the new mapping so future adds at this store learn from it.
await RememberSectionAsync(db, familyId, item.ShoppingList.StoreId, item.ProductId, item.FamilyProductId, request.SectionId);
// For product-linked items, sync the family-level default. The
// rules per spec:
// - No existing default (override or family product) → set it
// silently. Creates a FamilyProductOverride for globals.
// - Existing default matches → no-op.
// - Existing default differs:
// * SaveAsDefault=true → update.
// * Otherwise → return PromptSaveDefault=true so the client
// can ask the user. The item section change still sticks.
var prompt = false;
string? currentDefault = null;
if (newSectionName is not null && (item.ProductId.HasValue || item.FamilyProductId.HasValue))
{
currentDefault = await GetFamilyDefaultSectionNameAsync(db, familyId, item.ProductId, item.FamilyProductId);
var matches = currentDefault is not null
&& string.Equals(currentDefault.Trim(), newSectionName.Trim(), StringComparison.OrdinalIgnoreCase);
var noDefaultYet = string.IsNullOrWhiteSpace(currentDefault);
if (noDefaultYet || request.SaveAsDefault)
await SetFamilyDefaultSectionAsync(db, familyId, item.ProductId, item.FamilyProductId, newSectionName);
else if (!matches)
prompt = true;
}
await db.SaveChangesAsync();
await hub.Clients.Group($"list-{listId}").SendAsync("ItemSectionChanged", new { item.Id, item.SectionId });
return Results.Ok();
return Results.Ok(new SetItemSectionResponse(prompt, currentDefault, newSectionName));
});
group.MapPatch("/{listId:int}/items/{itemId:int}/check", async (int listId, int itemId, YesChefDb db, IHubContext<ShoppingListHub> hub, HttpContext http) =>
@@ -356,9 +404,9 @@ public static class ShoppingListEndpoints
var idx = 0;
foreach (var ing in recipe.Ingredients)
{
// Carry the ingredient's product link onto the list item, and
// use the per-store memory to assign a section if we have one.
var rememberedSectionId = await LookupRememberedSectionAsync(db, familyId, list.StoreId, ing.ProductId, ing.FamilyProductId);
// Carry the ingredient's product link onto the list item and
// resolve a section through the product's defaults if any.
var rememberedSectionId = await ResolveSectionAsync(db, familyId, list.StoreId, ing.ProductId, ing.FamilyProductId);
newItems.Add(new ShoppingListItem
{
@@ -456,50 +504,127 @@ public static class ShoppingListEndpoints
}
/// <summary>
/// Look up the section a product was last placed in at this store, for
/// this family. Returns null if no memory exists or no product link was
/// supplied.
/// Resolve the effective section for a product-linked item at this store.
/// Tiers, highest priority first:
/// 1. ProductStoreSection — per-store explicit override.
/// 2. Family default — FamilyProductOverride.DefaultSection (global product)
/// or FamilyProduct.DefaultSection (family product), matched by name
/// against this store's sections.
/// 3. Global default — Product.DefaultSection, matched by name.
/// Returns null when no tier produces a section (or no product is linked).
/// </summary>
internal static async Task<int?> LookupRememberedSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId)
internal static async Task<int?> ResolveSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId)
{
if (!productId.HasValue && !familyProductId.HasValue) return null;
return await db.ProductStoreSections
var perStore = await db.ProductStoreSections
.Where(p => p.FamilyId == familyId && p.StoreId == storeId
&& p.ProductId == productId && p.FamilyProductId == familyProductId)
.Select(p => (int?)p.StoreSectionId)
.FirstOrDefaultAsync();
if (perStore.HasValue) return perStore;
var defaultName = await GetEffectiveDefaultSectionNameAsync(db, familyId, productId, familyProductId);
if (string.IsNullOrWhiteSpace(defaultName)) return null;
return await db.StoreSections
.Where(s => s.StoreId == storeId && s.FamilyId == familyId
&& EF.Functions.ILike(s.Name, defaultName.Trim()))
.Select(s => (int?)s.Id)
.FirstOrDefaultAsync();
}
/// <summary>
/// Upsert the (Family, Store, Product) → Section memory. No-op if no
/// product link or no section is supplied. Caller must ensure section
/// belongs to the same store and family.
/// Family-level default section name only — FamilyProductOverride for a
/// global product, or FamilyProduct.DefaultSection for a family product.
/// Does NOT fall back to the global Product default. Use this when
/// deciding whether the family has its own override and whether a prompt
/// is needed before changing it.
/// </summary>
internal static async Task RememberSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId, int? sectionId)
internal static async Task<string?> GetFamilyDefaultSectionNameAsync(YesChefDb db, int familyId, int? productId, int? familyProductId)
{
if (!sectionId.HasValue) return;
if (!productId.HasValue && !familyProductId.HasValue) return;
var existing = await db.ProductStoreSections
.FirstOrDefaultAsync(p => p.FamilyId == familyId && p.StoreId == storeId
&& p.ProductId == productId && p.FamilyProductId == familyProductId);
if (existing is null)
if (productId.HasValue)
{
db.ProductStoreSections.Add(new ProductStoreSection
{
FamilyId = familyId,
StoreId = storeId,
ProductId = productId,
FamilyProductId = familyProductId,
StoreSectionId = sectionId.Value,
});
return await db.FamilyProductOverrides.AsNoTracking()
.Where(o => o.FamilyId == familyId && o.ProductId == productId)
.Select(o => o.DefaultSection)
.FirstOrDefaultAsync();
}
else if (existing.StoreSectionId != sectionId.Value)
if (familyProductId.HasValue)
{
existing.StoreSectionId = sectionId.Value;
existing.UpdatedAt = DateTime.UtcNow;
return await db.FamilyProducts.AsNoTracking()
.Where(p => p.Id == familyProductId && p.FamilyId == familyId)
.Select(p => p.DefaultSection)
.FirstOrDefaultAsync();
}
return null;
}
/// <summary>
/// Persist the family-level default section. For global products, upserts
/// a FamilyProductOverride; for family products, mutates the row directly.
/// Caller is responsible for SaveChangesAsync.
/// </summary>
internal static async Task SetFamilyDefaultSectionAsync(YesChefDb db, int familyId, int? productId, int? familyProductId, string sectionName)
{
if (productId.HasValue)
{
var ovr = await db.FamilyProductOverrides
.FirstOrDefaultAsync(o => o.FamilyId == familyId && o.ProductId == productId);
if (ovr is null)
{
db.FamilyProductOverrides.Add(new Entities.FamilyProductOverride
{
FamilyId = familyId,
ProductId = productId.Value,
DefaultSection = sectionName,
});
}
else
{
ovr.DefaultSection = sectionName;
ovr.UpdatedAt = DateTime.UtcNow;
}
return;
}
if (familyProductId.HasValue)
{
var fp = await db.FamilyProducts
.FirstOrDefaultAsync(p => p.Id == familyProductId && p.FamilyId == familyId);
if (fp is not null)
fp.DefaultSection = sectionName;
}
}
/// <summary>
/// Effective default section name for a product, walking the family
/// override (or family product) and falling back to the global product.
/// Pure read — does not consider per-store memory or section availability.
/// </summary>
internal static async Task<string?> GetEffectiveDefaultSectionNameAsync(YesChefDb db, int familyId, int? productId, int? familyProductId)
{
if (productId.HasValue)
{
var row = await (
from p in db.Products.AsNoTracking()
where p.Id == productId
join o in db.FamilyProductOverrides.Where(o => o.FamilyId == familyId)
on p.Id equals o.ProductId into overrides
from o in overrides.DefaultIfEmpty()
select new { Override = o != null ? o.DefaultSection : null, Global = p.DefaultSection }
).FirstOrDefaultAsync();
return row?.Override ?? row?.Global;
}
if (familyProductId.HasValue)
{
return await db.FamilyProducts.AsNoTracking()
.Where(p => p.Id == familyProductId && p.FamilyId == familyId)
.Select(p => p.DefaultSection)
.FirstOrDefaultAsync();
}
return null;
}
}
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using YesChef.Api.Auth;
using YesChef.Api.Data;
@@ -9,6 +10,7 @@ public static class UnitEndpoints
{
/// <summary>Discriminator so the client can route subsequent PUT/DELETE
/// calls to the right code path. Global units cannot be edited.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<UnitKind>))]
public enum UnitKind { Global, Family }
public record UnitDto(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace YesChef.Api.Migrations
{
/// <inheritdoc />
public partial class AddProductDefaultSection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DefaultSection",
table: "Products",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DefaultSection",
table: "FamilyProducts",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DefaultSection",
table: "FamilyProductOverrides",
type: "character varying(100)",
maxLength: 100,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DefaultSection",
table: "Products");
migrationBuilder.DropColumn(
name: "DefaultSection",
table: "FamilyProducts");
migrationBuilder.DropColumn(
name: "DefaultSection",
table: "FamilyProductOverrides");
}
}
}
@@ -90,6 +90,10 @@ namespace YesChef.Api.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DefaultSection")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("FamilyId")
.HasColumnType("integer");
@@ -125,6 +129,10 @@ namespace YesChef.Api.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("DefaultSection")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
@@ -289,6 +297,10 @@ namespace YesChef.Api.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DefaultSection")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
+4
View File
@@ -1,5 +1,6 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
@@ -20,6 +21,9 @@ using YesChef.Api.Features.Units;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddDbContext<YesChefDb>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
+2 -2
View File
@@ -1,6 +1,6 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json .
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
@@ -8,7 +8,7 @@ RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/build .
COPY --from=build /app/package.json .
COPY --from=build /app/package.json /app/package-lock.json ./
RUN npm ci --omit=dev
EXPOSE 3000
ENV PORT=3000
+54
View File
@@ -21,3 +21,57 @@ body {
button {
@apply cursor-pointer;
}
/* ── Design tokens ── */
/* Use .field for standard single-line text inputs and textareas inside the app shell. */
@layer components {
.field {
@apply rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none;
}
/* Larger variant for prominent standalone inputs (recipe title, item name search bar). */
.field-lg {
@apply rounded-lg border border-gray-300 bg-white px-3 py-2.5 text-base focus:border-primary focus:outline-none;
}
/*
* Use .select / .select-lg for <select> elements. Removes native OS appearance
* (which can add a grey gradient even when bg-white is set) and injects a
* consistent chevron so all dropdowns look identical to text inputs.
* Note: @apply cannot compose other component classes in Tailwind v4, so
* the base field styles are duplicated here.
*/
.select {
@apply appearance-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.4rem center;
background-repeat: no-repeat;
background-size: 1.1rem 1.1rem;
padding-right: 1.75rem;
}
.select-lg {
@apply appearance-none rounded-lg border border-gray-300 bg-white px-3 py-2.5 text-base focus:border-primary focus:outline-none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.4rem center;
background-repeat: no-repeat;
background-size: 1.1rem 1.1rem;
padding-right: 1.75rem;
}
/* Standard action button — primary green. */
.btn-primary {
@apply rounded-lg bg-primary px-4 py-2 font-semibold text-white disabled:opacity-50;
}
/* Outlined button — secondary / cancel. Same height as btn-primary. */
.btn-secondary {
@apply rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 disabled:opacity-50;
}
/* Low-prominence destructive link — use at the bottom of detail pages. */
.btn-danger-link {
@apply text-sm text-danger hover:underline;
}
}
@@ -17,6 +17,7 @@
notes: string | null;
isOverridden: boolean;
allowedUnitCategories: number;
defaultSection: string | null;
}
</script>
@@ -183,6 +184,12 @@
}}
>
<span>{suggestion.name}</span>
{#if suggestion.isOverridden}
<span
class="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800"
title="Edited by your family">Edited</span
>
{/if}
{#if suggestion.brand}
<span class="ml-2 text-xs text-gray-400">{suggestion.brand}</span>
{/if}
+3 -3
View File
@@ -86,15 +86,15 @@
oninput={onQuantityInput}
placeholder="Qty"
aria-label={ariaLabel}
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
class="field w-20 px-2"
/>
<select
value={composite}
onchange={onUnitChange}
aria-label="Unit"
class="w-24 rounded-lg border border-gray-300 px-1 py-2 text-sm focus:border-primary focus:outline-none"
class="select w-24 px-1"
>
<option value="">unit</option>
<option value="">Unit</option>
{#each visible as unit}
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
{/each}
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
// Free-text input with a styled dropdown of static suggestions. Users can
// pick from the list or type a custom value — whatever's in the input is
// what binds.
//
// Use this when the suggestion set is a small, static list of strings
// (e.g. common section names, predefined tags). For API-driven typeahead
// returning structured objects (products, users, etc.), use
// ProductTypeahead.svelte as the pattern. Look & keyboard behavior here
// intentionally mirror ProductTypeahead so the two feel like siblings.
interface Props {
value: string;
suggestions: readonly string[];
placeholder?: string;
ariaLabel?: string;
inputClass?: string;
}
let {
value = $bindable(''),
suggestions,
placeholder = '',
ariaLabel,
inputClass = '',
}: Props = $props();
let showDropdown = $state(false);
let activeIndex = $state(-1);
const listboxId = `text-combobox-${crypto.randomUUID().slice(0, 8)}`;
// Filter on substring (case-insensitive). Empty input shows everything so
// the user can scan the seeded list without typing first.
const filtered = $derived.by(() => {
const q = value.trim().toLowerCase();
if (q.length === 0) return [...suggestions];
return suggestions.filter((s) => s.toLowerCase().includes(q));
});
function selectSuggestion(s: string) {
value = s;
showDropdown = false;
activeIndex = -1;
}
function onInput() {
showDropdown = filtered.length > 0;
activeIndex = -1;
}
function onKeydown(e: KeyboardEvent) {
if (!showDropdown || filtered.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % filtered.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = activeIndex <= 0 ? filtered.length - 1 : activeIndex - 1;
} else if (e.key === 'Enter') {
if (activeIndex >= 0) {
e.preventDefault();
selectSuggestion(filtered[activeIndex]);
}
} else if (e.key === 'Escape') {
showDropdown = false;
activeIndex = -1;
}
}
function onBlur() {
// Delay so a mousedown-then-mouseup click on a suggestion is recorded
// before the dropdown closes.
setTimeout(() => {
showDropdown = false;
activeIndex = -1;
}, 120);
}
</script>
<div class="relative">
<input
type="text"
bind:value
oninput={onInput}
onkeydown={onKeydown}
onblur={onBlur}
onfocus={() => {
if (filtered.length > 0) showDropdown = true;
}}
{placeholder}
aria-label={ariaLabel}
aria-autocomplete="list"
aria-expanded={showDropdown}
aria-controls={listboxId}
role="combobox"
class={inputClass}
autocomplete="off"
/>
{#if showDropdown}
<ul
id={listboxId}
class="absolute left-0 right-0 z-10 mt-1 max-h-64 overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg"
role="listbox"
>
{#each filtered as suggestion, i (suggestion)}
<li
role="option"
aria-selected={i === activeIndex}
class="cursor-pointer px-3 py-2 text-sm {i === activeIndex
? 'bg-primary/10'
: 'hover:bg-gray-50'}"
onmousedown={(e) => {
// mousedown rather than click so we beat the blur handler.
e.preventDefault();
selectSuggestion(suggestion);
}}
>
{suggestion}
</li>
{/each}
</ul>
{/if}
</div>
+15
View File
@@ -0,0 +1,15 @@
// Suggested grocery store section names — used to seed autocomplete for the
// product default-section field. Mirrors StoreSectionDefaults.Names on the
// backend; kept here as a static constant since it changes rarely and the
// list is tiny. Custom entries are still allowed.
export const typicalSections = [
'Produce',
'Meat & Seafood',
'Dairy',
'Bakery',
'Frozen',
'Pantry',
'Condiments',
'Beverages',
'Other',
] as const;
+5 -1
View File
@@ -1,3 +1,4 @@
import { untrack } from 'svelte';
import { api } from '$lib/api';
export type UnitKind = 'Global' | 'Family';
@@ -24,7 +25,10 @@ let error = $state<string | null>(null);
export const units = {
get all() {
if (cache === null && !loading) {
void load();
// Use untrack so the load() side-effect (which mutates $state) is
// not flagged as an illegal mutation when this getter is read inside
// a $derived expression.
untrack(() => void load());
}
return cache ?? [];
},
-1
View File
@@ -17,7 +17,6 @@
const navItems = [
{ href: '/lists', label: 'Lists', icon: '📋' },
{ href: '/recipes', label: 'Recipes', icon: '📖' },
{ href: '/stores', label: 'Stores', icon: '🏪' },
{ href: '/family', label: 'Family', icon: '👪' }
];
+31 -4
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { toast } from '$lib/toast.svelte';
import { startConnection, stopConnection } from '$lib/signalr';
import type { HubConnection } from '@microsoft/signalr';
@@ -46,6 +48,10 @@
lists = lists.filter((l) => l.id !== data.id);
});
connection.on('ListDeleted', (data: { id: number }) => {
lists = lists.filter((l) => l.id !== data.id);
});
connection.on('ListSummaryUpdated', (data: ListSummary) => {
lists = lists.map((l) =>
l.id === data.id ? { ...l, itemCount: data.itemCount, checkedCount: data.checkedCount, updatedAt: data.updatedAt } : l
@@ -60,6 +66,16 @@
await stopConnection();
});
function toggleCreate() {
if (!showCreate && stores.length === 0) {
toast.warning('You need to create a store first', {
action: { label: 'Add a store', onClick: () => goto('/stores') }
});
return;
}
showCreate = !showCreate;
}
async function createList() {
if (!newName.trim() || !newStoreId) return;
await api<{ id: number }>('/api/lists', {
@@ -75,7 +91,7 @@
<div class="mb-4 flex items-center justify-between">
<h2 class="text-2xl font-bold">Shopping Lists</h2>
<button
onclick={() => (showCreate = !showCreate)}
onclick={toggleCreate}
class="rounded-full bg-primary px-4 py-2 text-sm font-semibold text-white"
>
{showCreate ? 'Cancel' : '+ New list'}
@@ -89,12 +105,12 @@
bind:value={newName}
placeholder="List name"
required
class="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none"
class="field mb-3 w-full"
/>
<select
bind:value={newStoreId}
required
class="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none"
class="select mb-3 w-full"
>
<option value={null} disabled>Select store</option>
{#each stores as store}
@@ -103,7 +119,7 @@
</select>
<button
type="submit"
class="w-full rounded-lg bg-primary py-2 font-semibold text-white"
class="btn-primary w-full"
>
Create
</button>
@@ -148,4 +164,15 @@
{/each}
</div>
{/if}
<div class="mt-6 space-y-3 border-t border-gray-100 pt-4">
<a href="/stores" class="flex items-center justify-between text-sm text-gray-500">
<span>Manage stores</span>
<span>&rsaquo;</span>
</a>
<a href="/products" class="flex items-center justify-between text-sm text-gray-500">
<span>Manage products</span>
<span>&rsaquo;</span>
</a>
</div>
</div>
+63 -42
View File
@@ -47,7 +47,6 @@
let items = $state<ListItem[]>([]);
let sections = $state<Section[]>([]);
let newItemName = $state('');
let newItemSectionId = $state<number | null>(null);
let newItemProductId = $state<number | null>(null);
let newItemFamilyProductId = $state<number | null>(null);
let newItemQuantity = $state<QuantityValue>({ quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null });
@@ -170,12 +169,14 @@
async function addItem() {
if (!newItemName.trim()) return;
const maxSort = items.length > 0 ? Math.max(...items.map((i) => i.sortOrder)) : 0;
// No explicit section is sent — the backend resolves one from the
// product's defaults (per-store memory → family default → global
// default). The user adjusts after the fact on the list row.
await api('/api/lists/' + listId + '/items', {
method: 'POST',
body: JSON.stringify({
name: newItemName,
sortOrder: maxSort + 1,
sectionId: newItemSectionId,
productId: newItemProductId,
familyProductId: newItemFamilyProductId,
quantity: newItemIsApproximate ? null : newItemQuantity.quantity,
@@ -208,15 +209,16 @@
newItemProductId = null;
newItemFamilyProductId = null;
newItemAllowedUnitCategories = 0;
} else if (product.kind === 'Global') {
return;
}
if (product.kind === 'Global') {
newItemProductId = product.id;
newItemFamilyProductId = null;
newItemAllowedUnitCategories = product.allowedUnitCategories;
} else {
newItemProductId = null;
newItemFamilyProductId = product.id;
newItemAllowedUnitCategories = product.allowedUnitCategories;
}
newItemAllowedUnitCategories = product.allowedUnitCategories;
}
async function toggleItem(itemId: number) {
@@ -227,10 +229,29 @@
// Optimistic — the SignalR ItemSectionChanged echo will reconcile.
items = items.map((i) => (i.id === itemId ? { ...i, sectionId } : i));
try {
await api(`/api/lists/${listId}/items/${itemId}/section`, {
const res = await api<{
promptSaveDefault: boolean;
currentDefaultSection: string | null;
newSectionName: string | null;
}>(`/api/lists/${listId}/items/${itemId}/section`, {
method: 'PATCH',
body: JSON.stringify({ sectionId })
});
// Product had an existing family default that differs from this
// new choice. Ask whether to update the default — accepting
// re-PATCHes with saveAsDefault, declining leaves it as a one-off.
if (res.promptSaveDefault && res.newSectionName) {
const productName = items.find((i) => i.id === itemId)?.name ?? 'this product';
const message = `Save “${productName}” → ${res.newSectionName} as the default? ` +
`(currently ${res.currentDefaultSection})`;
if (confirm(message)) {
await api(`/api/lists/${listId}/items/${itemId}/section`, {
method: 'PATCH',
body: JSON.stringify({ sectionId, saveAsDefault: true })
});
}
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to update section');
}
@@ -253,6 +274,12 @@
await api(`/api/lists/${listId}`, { method: 'DELETE' });
goto('/lists');
}
async function deleteList() {
if (!confirm(`Permanently delete "${list!.name}"? This cannot be undone.`)) return;
await api(`/api/lists/${listId}/permanent`, { method: 'DELETE' });
goto('/lists');
}
</script>
{#if loading}
@@ -265,22 +292,38 @@
<h2 class="text-2xl font-bold">{list.name}</h2>
<p class="text-sm text-gray-500">{list.store.name}</p>
</div>
<button
onclick={archiveList}
class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-500"
>
Archive
</button>
<div class="flex gap-2">
<button
onclick={archiveList}
class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-500"
>
Archive
</button>
<button
onclick={deleteList}
class="btn-danger-link text-sm"
>
Delete
</button>
</div>
</div>
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 space-y-1">
<div class="flex flex-wrap gap-2">
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 space-y-2">
<ProductTypeahead
bind:value={newItemName}
placeholder="Add an item..."
ariaLabel="Item name"
inputClass="field-lg w-full"
onsubmit={addItem}
onProductChange={onItemProductChange}
/>
<div class="flex flex-wrap items-start gap-2">
{#if newItemIsApproximate}
<input
type="text"
bind:value={newItemQuantityNote}
placeholder="e.g. to taste"
class="w-36 rounded-lg border border-gray-300 px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
class="field w-36 px-2"
/>
{:else}
<QuantityInput
@@ -288,31 +331,9 @@
allowedUnitCategories={newItemAllowedUnitCategories}
/>
{/if}
<div class="min-w-0 flex-1">
<ProductTypeahead
bind:value={newItemName}
placeholder="Add an item..."
ariaLabel="Item name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none"
onsubmit={addItem}
onProductChange={onItemProductChange}
/>
</div>
{#if sections.length > 0}
<select
bind:value={newItemSectionId}
class="rounded-lg border border-gray-300 bg-white px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
aria-label="Section"
>
<option value={null}>Uncategorized</option>
{#each sections as section (section.id)}
<option value={section.id}>{section.name}</option>
{/each}
</select>
{/if}
<button
type="submit"
class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white"
class="btn-primary"
>
Add
</button>
@@ -355,10 +376,10 @@
<select
value={item.sectionId ?? ''}
onchange={(e) => setItemSection(item.id, (e.currentTarget.value === '' ? null : Number(e.currentTarget.value)))}
class="shrink-0 rounded border border-gray-200 bg-white px-1 py-0.5 text-xs text-gray-500 focus:border-primary focus:outline-none"
class="select shrink-0 rounded-md border-gray-200 px-1 py-0.5 text-xs text-gray-500"
aria-label="Section for {item.name}"
>
<option value="">Uncategorized</option>
<option value="">No section</option>
{#each sections as section (section.id)}
<option value={section.id}>{section.name}</option>
{/each}
@@ -369,7 +390,7 @@
class="shrink-0 p-1 text-gray-300 active:text-danger"
aria-label="Remove {item.name}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</li>
{/each}
@@ -417,7 +438,7 @@
class="shrink-0 p-1 text-gray-300 active:text-danger"
aria-label="Remove {item.name}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</li>
{/each}
@@ -0,0 +1,390 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { toast } from '$lib/toast.svelte';
import { UnitCategoryFlag, type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
import TextCombobox from '$lib/TextCombobox.svelte';
import { typicalSections } from '$lib/typicalSections';
let products = $state<ProductSuggestion[]>([]);
let query = $state('');
let loading = $state(true);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Increment per request; only the most recent response wins. Same pattern as the typeahead.
let requestSeq = 0;
// Form modal — used for both add (editingProduct === null) and edit.
let formOpen = $state(false);
let editingProduct = $state<ProductSuggestion | null>(null);
let formName = $state('');
let formBrand = $state('');
let formNotes = $state('');
let formDefaultSection = $state('');
// Bitfield matching the backend UnitCategoryFlags. 0 = "any unit allowed".
let formCategories = $state(0);
let saving = $state(false);
// Confirmation modals — rendered after the form closes so we only ever show one dialog at a time.
let pendingResetGlobal = $state<ProductSuggestion | null>(null);
let pendingDeleteFamily = $state<ProductSuggestion | null>(null);
const categoryOptions = [
{ flag: UnitCategoryFlag.Count, label: 'Count' },
{ flag: UnitCategoryFlag.Weight, label: 'Weight' },
{ flag: UnitCategoryFlag.Volume, label: 'Volume' },
{ flag: UnitCategoryFlag.Packaging, label: 'Packaging' }
];
onMount(() => {
void search('');
});
async function search(q: string) {
const seq = ++requestSeq;
loading = true;
try {
const results = await api<ProductSuggestion[]>(
`/api/products?q=${encodeURIComponent(q)}`
);
if (seq !== requestSeq) return;
products = results;
} catch (e) {
if (seq !== requestSeq) return;
toast.error(e instanceof Error ? e.message : 'Failed to load products');
} finally {
if (seq === requestSeq) loading = false;
}
}
function onQueryInput() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => void search(query.trim()), 200);
}
function startAdd() {
editingProduct = null;
formName = '';
formBrand = '';
formNotes = '';
formDefaultSection = '';
formCategories = 0;
formOpen = true;
}
function startEdit(p: ProductSuggestion) {
editingProduct = p;
formName = p.name;
formBrand = p.brand ?? '';
formNotes = p.notes ?? '';
formDefaultSection = p.defaultSection ?? '';
formCategories = p.allowedUnitCategories;
formOpen = true;
}
function toggleCategory(flag: number) {
formCategories ^= flag;
}
function closeForm() {
formOpen = false;
editingProduct = null;
}
async function saveForm() {
const name = formName.trim();
if (!name) return;
saving = true;
try {
const body = {
name,
brand: formBrand.trim() || null,
notes: formNotes.trim() || null,
allowedUnitCategories: formCategories,
defaultSection: formDefaultSection.trim() || null
};
if (editingProduct === null) {
await api('/api/products', { method: 'POST', body: JSON.stringify(body) });
} else if (editingProduct.kind === 'Family') {
await api(`/api/products/family/${editingProduct.id}`, {
method: 'PUT',
body: JSON.stringify(body)
});
} else {
await api(`/api/products/global/${editingProduct.id}`, {
method: 'PUT',
body: JSON.stringify(body)
});
}
closeForm();
await search(query.trim());
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to save product');
} finally {
saving = false;
}
}
function requestResetGlobal() {
if (editingProduct?.kind !== 'Global') return;
pendingResetGlobal = editingProduct;
closeForm();
}
function requestDeleteFamily() {
if (editingProduct?.kind !== 'Family') return;
pendingDeleteFamily = editingProduct;
closeForm();
}
async function confirmResetGlobal() {
if (!pendingResetGlobal) return;
const id = pendingResetGlobal.id;
pendingResetGlobal = null;
try {
await api(`/api/products/global/${id}/override`, { method: 'DELETE' });
await search(query.trim());
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to reset product');
}
}
async function confirmDeleteFamily() {
if (!pendingDeleteFamily) return;
const id = pendingDeleteFamily.id;
pendingDeleteFamily = null;
try {
await api(`/api/products/family/${id}`, { method: 'DELETE' });
await search(query.trim());
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to delete product');
}
}
</script>
<div>
<div class="mb-4">
<a href="/lists" class="text-sm text-gray-500">&larr; Back to lists</a>
<h2 class="mt-1 text-2xl font-bold">Products</h2>
<p class="text-sm text-gray-500">Search the catalog, add your own, or edit how a catalog item shows up for your family.</p>
</div>
<div class="mb-4 flex gap-2">
<input
type="text"
bind:value={query}
oninput={onQueryInput}
placeholder="Search products"
aria-label="Search products"
class="field flex-1 py-2.5"
/>
<button type="button" onclick={startAdd} class="btn-primary py-2.5">Add</button>
</div>
{#if loading}
<p class="py-8 text-center text-gray-400">Loading...</p>
{:else if products.length === 0}
<p class="py-12 text-center text-gray-400">
{query.trim() ? 'No products match' : 'No products in catalog yet'}
</p>
{:else}
<ul class="space-y-2">
{#each products as product (product.kind + product.id)}
<li class="flex items-center gap-3 rounded-lg bg-white px-4 py-3 shadow-sm">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="truncate font-medium">{product.name}</span>
{#if product.isOverridden}
<span
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800"
title="Edited by your family">Edited</span
>
{/if}
{#if product.kind === 'Family'}
<span
class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
title="Family-only product (not in the global catalog)">Yours</span
>
{/if}
</div>
{#if product.brand}
<div class="text-xs text-gray-400">{product.brand}</div>
{/if}
{#if product.defaultSection}
<div class="text-xs text-gray-400">Section: {product.defaultSection}</div>
{/if}
</div>
<button onclick={() => startEdit(product)} class="text-sm text-gray-400">Edit</button>
</li>
{/each}
</ul>
{/if}
</div>
{#if formOpen}
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="product-form-title"
>
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
<h3 id="product-form-title" class="mb-1 text-lg font-semibold">
{editingProduct === null
? 'Add product'
: editingProduct.kind === 'Family'
? 'Edit product'
: 'Edit catalog product'}
</h3>
{#if editingProduct?.kind === 'Global'}
<p class="mb-3 text-xs text-gray-500">
Changes save as a family-only override. Other families still see the catalog defaults.
</p>
{/if}
<form
onsubmit={(e) => {
e.preventDefault();
void saveForm();
}}
class="space-y-3"
>
<label class="block">
<span class="text-xs font-medium text-gray-700">Name</span>
<input type="text" bind:value={formName} required class="field mt-1 w-full" />
</label>
<label class="block">
<span class="text-xs font-medium text-gray-700"
>Brand <span class="text-gray-400">(optional)</span></span
>
<input type="text" bind:value={formBrand} class="field mt-1 w-full" />
</label>
<label class="block">
<span class="text-xs font-medium text-gray-700"
>Notes <span class="text-gray-400">(optional)</span></span
>
<textarea bind:value={formNotes} rows="2" class="field mt-1 w-full"></textarea>
</label>
<div class="block">
<span class="text-xs font-medium text-gray-700"
>Default section <span class="text-gray-400">(optional)</span></span
>
<div class="mt-1">
<TextCombobox
bind:value={formDefaultSection}
suggestions={typicalSections}
placeholder="e.g. Produce"
ariaLabel="Default section"
inputClass="field w-full"
/>
</div>
<span class="mt-0.5 block text-xs text-gray-400">
Used to slot this item into the matching section at each store. Pick a common
name or type your own.
</span>
</div>
<fieldset>
<legend class="text-xs font-medium text-gray-700">Allowed units</legend>
<p class="mt-0.5 text-xs text-gray-400">Leave all unchecked to allow any unit.</p>
<div class="mt-2 grid grid-cols-2 gap-1.5">
{#each categoryOptions as opt}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={(formCategories & opt.flag) !== 0}
onchange={() => toggleCategory(opt.flag)}
/>
{opt.label}
</label>
{/each}
</div>
</fieldset>
<div class="flex justify-end gap-2 pt-2">
<button type="button" onclick={closeForm} class="btn-secondary">Cancel</button>
<button
type="submit"
disabled={saving || !formName.trim()}
class="btn-primary"
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{#if editingProduct?.kind === 'Global' && editingProduct.isOverridden}
<div class="mt-2 border-t border-gray-100 pt-2 text-right">
<button type="button" onclick={requestResetGlobal} class="btn-danger-link">
Reset to catalog default
</button>
</div>
{/if}
{#if editingProduct?.kind === 'Family'}
<div class="mt-2 border-t border-gray-100 pt-2 text-right">
<button type="button" onclick={requestDeleteFamily} class="btn-danger-link">
Delete product
</button>
</div>
{/if}
</form>
</div>
</div>
{/if}
{#if pendingResetGlobal}
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="reset-product-title"
>
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
<h3 id="reset-product-title" class="mb-2 text-lg font-semibold">Reset to catalog default?</h3>
<p class="mb-5 text-sm text-gray-600">
<span class="font-medium">{pendingResetGlobal.name}</span> will go back to its catalog
values for your family. Your edits will be discarded.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (pendingResetGlobal = null)}
class="btn-secondary"
>
Cancel
</button>
<button type="button" onclick={confirmResetGlobal} class="btn-primary">Reset</button>
</div>
</div>
</div>
{/if}
{#if pendingDeleteFamily}
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="delete-product-title"
>
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
<h3 id="delete-product-title" class="mb-2 text-lg font-semibold">Delete product?</h3>
<p class="mb-5 text-sm text-gray-600">
Delete <span class="font-medium">{pendingDeleteFamily.name}</span>? Any list items or
ingredients linked to it will keep their text but lose the link.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (pendingDeleteFamily = null)}
class="btn-secondary"
>
Cancel
</button>
<button
type="button"
onclick={confirmDeleteFamily}
class="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white"
>
Delete
</button>
</div>
</div>
</div>
{/if}
@@ -97,22 +97,16 @@
<div class="mb-4 flex gap-2">
<button
onclick={() => (showAddToList = !showAddToList)}
class="flex-1 rounded-lg bg-primary py-2.5 font-semibold text-white"
class="btn-primary flex-1 py-2.5"
>
Add to list
</button>
<button
onclick={() => goto(`/recipes/${recipeId}/edit`)}
class="rounded-lg border border-gray-300 px-4 py-2.5 text-sm text-gray-700"
class="btn-secondary py-2.5"
>
Edit
</button>
<button
onclick={deleteRecipe}
class="rounded-lg border border-danger px-4 py-2.5 text-sm text-danger"
>
Delete
</button>
</div>
{#if showAddToList}
@@ -162,5 +156,14 @@
</div>
</div>
{/if}
<div class="mt-10 border-t border-gray-100 pt-4">
<button
onclick={deleteRecipe}
class="btn-danger-link"
>
Delete this recipe
</button>
</div>
</div>
{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { goto, beforeNavigate } from '$app/navigation';
import { api } from '$lib/api';
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte';
@@ -45,35 +45,64 @@
let description = $state('');
let instructions = $state('');
let servings = $state<number | undefined>();
let sourceUrl = $state<string | null>(null);
let ingredients = $state<IngredientForm[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let saving = $state(false);
let saveError = $state<string | null>(null);
let dirty = $state(false);
let justSaved = $state(false);
const recipeId = $derived(Number(page.params.id));
onMount(async () => {
const recipe = await api<RecipeResponse>(`/api/recipes/${recipeId}`);
title = recipe.title;
description = recipe.description ?? '';
instructions = recipe.instructions ?? '';
servings = recipe.servings ?? undefined;
ingredients = recipe.ingredients.length === 0
? [emptyIngredient()]
: recipe.ingredients.map((i) => ({
name: i.name,
initialName: i.productId !== null || i.familyProductId !== null ? i.name : null,
quantity: {
quantity: i.quantity,
unitOfMeasureId: i.unitOfMeasureId,
familyUnitOfMeasureId: i.familyUnitOfMeasureId,
},
isApproximate: i.isApproximate,
quantityNote: i.quantityNote ?? '',
productId: i.productId,
familyProductId: i.familyProductId,
allowedUnitCategories: i.allowedUnitCategories,
}));
loading = false;
try {
const recipe = await api<RecipeResponse>(`/api/recipes/${recipeId}`);
title = recipe.title;
description = recipe.description ?? '';
instructions = recipe.instructions ?? '';
servings = recipe.servings ?? undefined;
sourceUrl = recipe.sourceUrl;
ingredients = recipe.ingredients.length === 0
? [emptyIngredient()]
: recipe.ingredients.map((i) => ({
name: i.name,
initialName: i.productId !== null || i.familyProductId !== null ? i.name : null,
quantity: {
quantity: i.quantity,
unitOfMeasureId: i.unitOfMeasureId,
familyUnitOfMeasureId: i.familyUnitOfMeasureId,
},
isApproximate: i.isApproximate,
quantityNote: i.quantityNote ?? '',
productId: i.productId,
familyProductId: i.familyProductId,
allowedUnitCategories: i.allowedUnitCategories,
}));
} catch (err) {
loadError = err instanceof Error ? err.message : 'Failed to load recipe.';
} finally {
loading = false;
loaded = !loadError;
}
});
// Becomes true once the initial fetch has populated the form. The dirty
// $effect uses this to ignore the load-time writes and only flip on real
// user edits afterward.
let loaded = $state(false);
$effect(() => {
// Touch every piece of editable state so any change re-runs this.
title; description; instructions; servings; sourceUrl;
$state.snapshot(ingredients);
if (loaded) dirty = true;
});
beforeNavigate(({ cancel }) => {
if (!dirty || justSaved) return;
if (!confirm('You have unsaved changes. Leave without saving?')) cancel();
});
function emptyIngredient(): IngredientForm {
@@ -127,6 +156,7 @@
async function save() {
if (!title.trim()) return;
saving = true;
saveError = null;
try {
await api(`/api/recipes/${recipeId}`, {
method: 'PUT',
@@ -135,7 +165,7 @@
description: description || null,
instructions: instructions || null,
servings: servings || null,
sourceUrl: null,
sourceUrl,
ingredients: ingredients
.filter((i) => i.name.trim())
.map((i, idx) => ({
@@ -151,7 +181,10 @@
}))
})
});
justSaved = true;
goto(`/recipes/${recipeId}`);
} catch (err) {
saveError = err instanceof Error ? err.message : 'Failed to save recipe.';
} finally {
saving = false;
}
@@ -160,6 +193,11 @@
{#if loading}
<p class="py-8 text-center text-gray-400">Loading...</p>
{:else if loadError}
<div class="py-8 text-center">
<p class="mb-3 text-danger">{loadError}</p>
<button onclick={() => goto(`/recipes/${recipeId}`)} class="text-sm text-gray-500">&larr; Back to recipe</button>
</div>
{:else}
<div>
<button onclick={() => goto(`/recipes/${recipeId}`)} class="mb-2 text-sm text-gray-500">&larr; Back</button>
@@ -169,16 +207,16 @@
<input
type="text"
bind:value={title}
placeholder="Recipe title"
placeholder="Recipe title"
required
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-lg focus:border-primary focus:outline-none"
class="field-lg w-full"
/>
<textarea
bind:value={description}
placeholder="Short description (optional)"
placeholder="Short description (optional)"
rows={2}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
class="field-lg w-full"
></textarea>
<div>
@@ -187,9 +225,9 @@
<input
type="number"
bind:value={servings}
min={1}
min={1}
placeholder="e.g. 4"
class="mt-1 block w-24 rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none"
class="field mt-1 block w-24"
/>
</label>
</div>
@@ -203,8 +241,8 @@
<input
type="text"
bind:value={ingredient.quantityNote}
placeholder="e.g. to taste"
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
placeholder="e.g. to taste"
class="field w-44 px-2"
/>
{:else}
<QuantityInput
@@ -218,7 +256,7 @@
initialSelectedName={ingredient.initialName}
placeholder="Ingredient name"
ariaLabel="Ingredient name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
inputClass="field w-full"
onProductChange={(p) => onIngredientProductChange(idx, p)}
/>
</div>
@@ -229,7 +267,7 @@
class="px-2 text-gray-300 active:text-danger"
aria-label="Remove ingredient"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
{/if}
</div>
@@ -255,16 +293,30 @@
bind:value={instructions}
placeholder="Instructions (optional)"
rows={6}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
class="field-lg w-full"
></textarea>
<button
type="submit"
disabled={saving}
class="w-full rounded-lg bg-primary py-3 font-semibold text-white disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
{#if saveError}
<p class="text-sm text-danger" role="alert">{saveError}</p>
{/if}
<div class="flex gap-2">
<button
type="button"
onclick={() => goto(`/recipes/${recipeId}`)}
disabled={saving}
class="btn-secondary flex-1 py-3"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
class="btn-primary flex-[2] py-3"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
{/if}
@@ -112,14 +112,14 @@
bind:value={title}
placeholder="Recipe title"
required
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-lg focus:border-primary focus:outline-none"
class="field-lg w-full"
/>
<textarea
bind:value={description}
placeholder="Short description (optional)"
rows={2}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
class="field-lg w-full"
></textarea>
<div>
@@ -130,7 +130,7 @@
bind:value={servings}
min={1}
placeholder="e.g. 4"
class="mt-1 block w-24 rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none"
class="field mt-1 block w-24"
/>
</label>
</div>
@@ -145,7 +145,7 @@
type="text"
bind:value={ingredient.quantityNote}
placeholder="e.g. to taste"
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
class="field w-44 px-2"
/>
{:else}
<QuantityInput
@@ -158,7 +158,7 @@
bind:value={ingredient.name}
placeholder="Ingredient name"
ariaLabel="Ingredient name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
inputClass="field w-full"
onProductChange={(p) => onIngredientProductChange(idx, p)}
/>
</div>
@@ -169,7 +169,7 @@
class="px-2 text-gray-300 active:text-danger"
aria-label="Remove ingredient"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
{/if}
</div>
@@ -195,13 +195,13 @@
bind:value={instructions}
placeholder="Instructions (optional)"
rows={6}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
class="field-lg w-full"
></textarea>
<button
type="submit"
disabled={saving}
class="w-full rounded-lg bg-primary py-3 font-semibold text-white disabled:opacity-50"
class="btn-primary w-full py-3"
>
{saving ? 'Saving...' : 'Save Recipe'}
</button>
+6 -3
View File
@@ -152,7 +152,10 @@
</script>
<div>
<h2 class="mb-4 text-2xl font-bold">Stores</h2>
<div class="mb-4">
<a href="/lists" class="text-sm text-gray-500">&larr; Back to lists</a>
<h2 class="mt-1 text-2xl font-bold">Stores</h2>
</div>
<form onsubmit={e => { e.preventDefault(); addStore(); }} class="mb-6 flex gap-2">
<input
@@ -160,9 +163,9 @@
bind:value={newName}
placeholder="New store name"
required
class="flex-1 rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
class="field flex-1 py-2.5"
/>
<button type="submit" class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white">
<button type="submit" class="btn-primary py-2.5">
Add
</button>
</form>